diff --git a/Cargo.lock b/Cargo.lock index 06c0131c..e78f229d 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -43,6 +43,15 @@ dependencies = [ "subtle", ] +[[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" @@ -659,7 +668,7 @@ dependencies = [ "async-trait", "thiserror 2.0.18", "tracing", - "turnkey_client", + "turnkey_client 0.7.0", ] [[package]] @@ -1188,6 +1197,16 @@ dependencies = [ "winnow 0.7.10", ] +[[package]] +name = "assert-json-diff" +version = "2.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47e4f2b81832e72834d7518d8487a0396a28cc408186a2e8854c0f98011faf12" +dependencies = [ + "serde", + "serde_json", +] + [[package]] name = "async-compat" version = "0.2.4" @@ -1712,10 +1731,13 @@ dependencies = [ "tokio-test", "tracing", "tracing-log", + "turnkey_api_key_stamper 0.12.0", + "turnkey_client 0.12.0", "turnkey_enclave_encrypt", "uniffi", "uuid", "webpki", + "wiremock", "x509-cert", "zeroize", ] @@ -2479,6 +2501,24 @@ dependencies = [ "parking_lot_core", ] +[[package]] +name = "deadpool" +version = "0.12.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0be2b1d1d6ec8d846f05e137292d0b89133caf95ef33695424c09568bdd39b1b" +dependencies = [ + "deadpool-runtime", + "lazy_static", + "num_cpus", + "tokio", +] + +[[package]] +name = "deadpool-runtime" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "092966b41edc516079bdf31ec78a2e0588d1d0c08f78b91d8307215928642b2b" + [[package]] name = "der" version = "0.6.1" @@ -3131,9 +3171,9 @@ dependencies = [ [[package]] name = "h2" -version = "0.4.10" +version = "0.4.15" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a9421a676d1b147b16b82c9225157dc629087ef8ec4d5e2960f9437a90dac0a5" +checksum = "6cb093c84e8bd9b188d4c4a8cb6579fc016968d14c99882163cd3ff402a4f155" dependencies = [ "atomic-waker", "bytes", @@ -3374,13 +3414,14 @@ dependencies = [ [[package]] name = "hyper" -version = "1.6.0" +version = "1.11.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cc2b571658e38e0c01b1fdca3bbbe93c00d3d71693ff2770043f8c29bc7d6f80" +checksum = "d22053281f852e11534f5198498373cbb59295120a20771d90f7ed1897490a72" dependencies = [ + "atomic-waker", "bytes", "futures-channel", - "futures-util", + "futures-core", "h2", "http 1.4.0", "http-body 1.0.1", @@ -4698,6 +4739,29 @@ dependencies = [ "syn 2.0.114", ] +[[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.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8fcfdb36bda0c880c5931cdc7a2bcdc8ba4556847b9d912bca70bc94708711ad" +dependencies = [ + "aho-corasick", + "memchr", + "regex-syntax", +] + [[package]] name = "regex-lite" version = "0.1.6" @@ -6093,6 +6157,22 @@ dependencies = [ "thiserror 2.0.18", ] +[[package]] +name = "turnkey_api_key_stamper" +version = "0.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "16d1f38e134785766a50958cac51e5fc83ee6929e5909a953867dfce100629ae" +dependencies = [ + "base64", + "hex", + "k256", + "p256 0.13.2", + "rand_core 0.6.4", + "serde", + "serde_json", + "thiserror 2.0.18", +] + [[package]] name = "turnkey_client" version = "0.7.0" @@ -6108,7 +6188,25 @@ dependencies = [ "serde_with", "thiserror 2.0.18", "tokio", - "turnkey_api_key_stamper", + "turnkey_api_key_stamper 0.7.0", +] + +[[package]] +name = "turnkey_client" +version = "0.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4572f7db48b5279fffca8522125d23196ba0199c1cbe528debceb8eb16e7f38a" +dependencies = [ + "mime", + "prost 0.12.6", + "prost-types 0.12.6", + "reqwest 0.12.28", + "serde", + "serde_json", + "serde_with", + "thiserror 2.0.18", + "tokio", + "turnkey_api_key_stamper 0.12.0", ] [[package]] @@ -6893,6 +6991,29 @@ dependencies = [ "memchr", ] +[[package]] +name = "wiremock" +version = "0.6.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "08db1edfb05d9b3c1542e521aea074442088292f00b5f28e435c714a98f85031" +dependencies = [ + "assert-json-diff", + "base64", + "deadpool", + "futures", + "http 1.4.0", + "http-body-util", + "hyper", + "hyper-util", + "log", + "once_cell", + "regex", + "serde", + "serde_json", + "tokio", + "url", +] + [[package]] name = "wit-bindgen" version = "0.51.0" diff --git a/bedrock/Cargo.toml b/bedrock/Cargo.toml index 4e12b500..80386cc5 100644 --- a/bedrock/Cargo.toml +++ b/bedrock/Cargo.toml @@ -91,6 +91,8 @@ zeroize = "1.8" hex-literal = "1.1.0" # Provides the `hex!` macro for compile-time hex decoding http = "1.4.0" siegel-uniffi = { version = "=0.3.0", features = ["tracing"] } +turnkey_client = "0.12.0" +turnkey_api_key_stamper = "0.12.0" # TODO: once `rand` can be bumped to 0.9, add the explicit feature flag `os_rng`. this explicitly determines no `thread_rng`; # bumping this requires crypto_box to update rand: https://github.com/RustCrypto/nacl-compat/issues/176 @@ -104,5 +106,6 @@ alloy = { version = "2.0.4", default-features = false, features = [ ] } dotenvy = "0.15.7" serial_test = "3.4.0" -tokio = { version = "1.50.0", features = ["time"] } +tokio = { version = "1.50.0", features = ["time", "test-util"] } tokio-test = "0.4.5" +wiremock = "0.6.5" diff --git a/bedrock/src/backup/turnkey/api.rs b/bedrock/src/backup/turnkey/api.rs new file mode 100644 index 00000000..7489cc35 --- /dev/null +++ b/bedrock/src/backup/turnkey/api.rs @@ -0,0 +1,587 @@ +//! Turnkey API access built on the official Turnkey Rust SDK (`turnkey_client`). +//! +//! The SDK retries only the polling of `PENDING` activities to completion; it does not retry +//! transport failures, so we wrap each call in our own bounded exponential-backoff-with-jitter policy +//! covering 429/5xx/timeouts/connectivity. Query results are cached +//! in-memory for the lifetime of a single client so that multiple migrations +//! reading the same data do not issue duplicate calls. + +use std::collections::HashMap; +use std::future::Future; +use std::sync::Mutex; +use std::time::Duration; + +use base64::engine::general_purpose::URL_SAFE_NO_PAD; +use base64::Engine; +use serde::Serialize; +use sha2::{Digest, Sha256}; +use turnkey_api_key_stamper::{ + Stamp, StampHeader, StamperError, API_KEY_STAMP_HEADER_NAME, SIGNATURE_SCHEME_P256, +}; +use turnkey_client::generated::external::data::v1::User; +use turnkey_client::generated::immutable::activity::v1::{ + CreateOauthProvidersIntentV2, OauthProviderParamsV2, +}; +use turnkey_client::generated::services::coordinator::public::v1::{ + GetUsersRequest, GetWhoamiRequest, +}; +use turnkey_client::{RetryConfig, TurnkeyClient}; + +use crate::primitives::ntp::now_with_ntp; +use crate::primitives::P256Signer; +use crate::warn; + +use super::error::TurnkeyApiError; + +/// A read/query signer — a **sync factor**. Stamps `whoami` and `get_users`, +/// never privileged writes. A newtype so the split can't be crossed by accident. +#[derive(Clone, Copy)] +pub(super) struct SyncFactor<'a>(pub(super) &'a P256Signer); + +/// A write/submit signer — a **main factor**. Stamps privileged activities such +/// as `create_oauth_providers`. A newtype so it can't be passed where a sync +/// factor is expected (or vice versa). +#[derive(Clone, Copy)] +pub(super) struct MainFactor<'a>(pub(super) &'a P256Signer); + +/// Adapts a [`P256Signer`] into the Turnkey SDK's [`Stamp`] trait, so the SDK +/// client can stamp requests with a key held in native secure storage. +/// +/// The public key is validated once when the [`P256Signer`] is built, so +/// stamping only signs and reuses the cached key. +/// +/// Produces byte-for-byte the same `X-Stamp` as the SDK's own `TurnkeyP256ApiKey`. +pub struct KeypairSignerStamper { + signer: P256Signer, +} + +impl KeypairSignerStamper { + /// Wraps a verified signer for use as a Turnkey stamper. + #[must_use] + pub fn new(signer: &P256Signer) -> Self { + Self { + signer: signer.clone(), + } + } +} + +impl Stamp for KeypairSignerStamper { + fn stamp(&self, body: &[u8]) -> Result { + let digest = Sha256::digest(body); + let signature = self + .signer + .sign_digest(digest.to_vec()) + .map_err(|e| StamperError::InvalidPrivateKeyBytes(e.to_string()))?; + let stamp = ApiStamp { + public_key: self.signer.public_key_hex().to_string(), + signature: hex::encode(signature), + scheme: SIGNATURE_SCHEME_P256.to_string(), + }; + let json = serde_json::to_string(&stamp).map_err(|e| { + StamperError::InvalidPrivateKeyBytes(format!( + "stamp serialization failed: {e}" + )) + })?; + Ok(StampHeader { + name: API_KEY_STAMP_HEADER_NAME.to_string(), + value: URL_SAFE_NO_PAD.encode(json.as_bytes()), + }) + } +} + +/// Turnkey API stamp payload (mirrors the SDK's internal stamp shape). +#[derive(Serialize)] +#[serde(rename_all = "camelCase")] +struct ApiStamp { + public_key: String, + signature: String, + scheme: String, +} + +/// Bounded retry policy: exponential backoff with full jitter. +#[derive(Debug, Clone, Copy)] +struct RetryPolicy { + max_attempts: u32, + base_delay: Duration, + max_delay: Duration, +} + +impl Default for RetryPolicy { + fn default() -> Self { + Self { + max_attempts: 3, + base_delay: Duration::from_millis(250), + max_delay: Duration::from_secs(2), + } + } +} + +/// Computes the backoff delay for `attempt` (1-indexed) with full jitter, +/// capped at [`RetryPolicy::max_delay`]. +fn backoff_delay(attempt: u32, policy: &RetryPolicy) -> Duration { + let factor = 2u32.saturating_pow(attempt.saturating_sub(1)); + let exp = policy.base_delay.saturating_mul(factor); + let capped = exp.min(policy.max_delay); + let ceil_ms = u64::try_from(capped.as_millis()).unwrap_or(u64::MAX); + if ceil_ms == 0 { + return Duration::ZERO; + } + // Random jitter: uniform in [0, ceil_ms] + Duration::from_millis(rand::random::() % ceil_ms.saturating_add(1)) +} + +/// Turnkey API client using the Turnkey SDK plus Bedrock's retry and caching. +/// +/// `get_users` responses are cached for the lifetime of the client (a single +/// `check_migrations` run), keyed by sub-organization id. +pub struct TurnkeyApiClient { + retry: RetryPolicy, + users_cache: Mutex>>, + /// Overrides the SDK's default Turnkey base URL. `None` in production; set + /// only in tests to point the real client at a mock HTTP server. + base_url: Option, +} + +impl TurnkeyApiClient { + /// Creates a client with the default retry policy and an empty cache. + #[must_use] + pub fn new() -> Self { + Self { + retry: RetryPolicy::default(), + users_cache: Mutex::new(HashMap::new()), + base_url: None, + } + } + + /// Creates a client that targets `base_url` instead of Turnkey's default, + /// for driving the real client against a mock HTTP server in tests. + #[cfg(test)] + pub(super) fn with_base_url(base_url: String) -> Self { + Self { + base_url: Some(base_url), + ..Self::new() + } + } + + /// Builds an SDK client that stamps with `signer`. + /// + /// Uses the SDK's default retry config so it polls `PENDING` activities to + /// completion; transport-level retries are handled by [`Self::with_retry`]. + fn sdk_client( + &self, + signer: &P256Signer, + ) -> Result, TurnkeyApiError> { + let mut builder = TurnkeyClient::::builder() + .api_key(KeypairSignerStamper::new(signer)) + .retry_config(RetryConfig::default()); + if let Some(base_url) = &self.base_url { + builder = builder.base_url(base_url.clone()); + } + builder.build().map_err(TurnkeyApiError::from) + } + + /// Runs `op`, retrying transient failures with bounded backoff and jitter. + async fn with_retry( + &self, + operation: &str, + mut op: impl FnMut() -> Fut + Send, + ) -> Result + where + Fut: Future> + Send, + { + let mut attempt: u32 = 0; + loop { + match op().await { + Ok(value) => return Ok(value), + Err(error) => { + attempt += 1; + if attempt >= self.retry.max_attempts || !error.is_retryable() { + warn!("turnkey.request.failed op={operation} attempts={attempt} err={error}"); + return Err(error); + } + let delay = backoff_delay(attempt, &self.retry); + warn!( + "turnkey.request.retry op={operation} attempt={attempt} delay_ms={} err={error}", + delay.as_millis() + ); + tokio::time::sleep(delay).await; + } + } + } + } + + /// Locks the users cache, recovering from poisoning. + /// + /// A poisoned lock means a prior holder panicked and may have left the map + /// inconsistent. The cache is always reconstructible so discard and move on. + fn lock_cache(&self) -> std::sync::MutexGuard<'_, HashMap>> { + match self.users_cache.lock() { + Ok(guard) => guard, + Err(poisoned) => { + crate::error!( + "turnkey.cache.poisoned: discarding in-memory user cache and recovering" + ); + self.users_cache.clear_poison(); + let mut guard = poisoned.into_inner(); + guard.clear(); + guard + } + } + } + + fn cached_users(&self, suborganization_id: &str) -> Option> { + self.lock_cache().get(suborganization_id).cloned() + } + + fn cache_users(&self, suborganization_id: &str, users: &[User]) { + self.lock_cache() + .insert(suborganization_id.to_string(), users.to_vec()); + } +} + +/// # Warning +/// For any activities (i.e. requests that change the state) it is imperative that +/// the `timestamp_ms` is computed once outside any retry loops. Turnkey submissions +/// are idempotent on a fingerprint, maintaining the same `timestamp_ms` ensures a +/// request is not executed twice. +/// +/// Reference: . +/// +impl TurnkeyApiClient { + /// Resolves the sub-organization id for the user based on the public + /// key provided. Internally uses `whoami`. + /// + /// # Errors + /// Returns [`TurnkeyApiError`] on transport, stamping, or parsing failures. + pub async fn resolve_suborganization_id( + &self, + parent_organization_id: &str, + signer: SyncFactor<'_>, + ) -> Result { + let client = self.sdk_client(signer.0)?; + let request = GetWhoamiRequest { + organization_id: parent_organization_id.to_string(), + }; + self.with_retry("whoami", || async { + client + .get_whoami(request.clone()) + .await + .map(|response| response.organization_id) + .map_err(TurnkeyApiError::from) + }) + .await + } + + /// Lists the users of a sub-organization (stamped by the read/query signer). + /// + /// Results are cached for the lifetime of this client. + /// + /// # Errors + /// Returns [`TurnkeyApiError`] on transport, stamping, or parsing failures. + pub async fn get_users( + &self, + suborganization_id: &str, + signer: SyncFactor<'_>, + ) -> Result, TurnkeyApiError> { + if let Some(cached) = self.cached_users(suborganization_id) { + return Ok(cached); + } + let client = self.sdk_client(signer.0)?; + let request = GetUsersRequest { + organization_id: suborganization_id.to_string(), + }; + + let users = self + .with_retry("get_users", || async { + client + .get_users(request.clone()) + .await + .map(|response| response.users) + .map_err(TurnkeyApiError::from) + }) + .await?; + + self.cache_users(suborganization_id, &users); + Ok(users) + } + + /// Creates OAuth providers on a user (stamped by the write/submit signer). + /// + /// All `providers` are submitted in a **single** `CreateOauthProviders` + /// activity, so they are added atomically. + /// + /// # Errors + /// Returns [`TurnkeyApiError`] on transport, stamping, activity, or parsing failures. + pub async fn create_oauth_providers( + &self, + suborganization_id: &str, + user_id: &str, + providers: Vec, + signer: MainFactor<'_>, + ) -> Result<(), TurnkeyApiError> { + let client = self.sdk_client(signer.0)?; + let requested = providers.len(); + let intent = CreateOauthProvidersIntentV2 { + user_id: user_id.to_string(), + oauth_providers: providers, + }; + + let timestamp_ms = ntp_timestamp_ms()?; + let created = self + .with_retry("create_oauth_providers", || async { + client + .create_oauth_providers( + suborganization_id.to_string(), + timestamp_ms, + intent.clone(), + ) + .await + .map(|activity| activity.result.provider_ids.len()) + .map_err(TurnkeyApiError::from) + }) + .await?; + + if created != requested { + // If Turnkey's activity succeeded but the created count is mismatched, this is surfacing + // a major consistency problem with Turnkey. Requires immediate attention. + crate::error!( + "CRITICAL. turnkey.create_oauth_providers.count_mismatch requested={requested} created={created}" + ); + } + + // User has changed, remove the cache + self.lock_cache().remove(suborganization_id); + Ok(()) + } +} + +/// Current NTP time in milliseconds, for Turnkey activity timestamps. +/// +/// # Errors +/// Returns [`TurnkeyApiError`] if the time source yields a non-positive +/// timestamp (a broken clock). A zero/negative timestamp is useless to Turnkey, +/// so we fail fast rather than submit an activity that cannot succeed. +fn ntp_timestamp_ms() -> Result { + let millis = now_with_ntp().timestamp_millis(); + let millis = u128::try_from(millis).map_err(|_| { + TurnkeyApiError::Client(format!( + "time source returned a pre-epoch timestamp ({millis} ms)" + )) + })?; + if millis == 0 { + return Err(TurnkeyApiError::Client( + "time source returned a zero timestamp".to_string(), + )); + } + Ok(millis) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::backup::turnkey::test::TestSigner; + use std::sync::atomic::{AtomicU32, Ordering}; + use std::sync::Arc; + + #[test] + fn stamp_verifies_against_body() { + use base64::prelude::BASE64_URL_SAFE_NO_PAD; + use p256::ecdsa::signature::Verifier; + + let body = serde_json::json!({ "activity": "create" }).to_string(); + let verified = P256Signer::verify(Arc::new(TestSigner::new())).unwrap(); + let adapter = KeypairSignerStamper::new(&verified); + let stamp = adapter.stamp(body.as_bytes()).unwrap().value; + + let decoded = BASE64_URL_SAFE_NO_PAD.decode(&stamp).unwrap(); + let stamp_json: serde_json::Value = serde_json::from_slice(&decoded).unwrap(); + assert_eq!(stamp_json["scheme"], SIGNATURE_SCHEME_P256); + + let signature = p256::ecdsa::Signature::from_der( + &hex::decode(stamp_json["signature"].as_str().unwrap()).unwrap(), + ) + .unwrap(); + let public_key = p256::PublicKey::from_sec1_bytes( + &hex::decode(stamp_json["publicKey"].as_str().unwrap()).unwrap(), + ) + .unwrap(); + let verifying_key = p256::ecdsa::VerifyingKey::from(public_key); + assert!(verifying_key.verify(body.as_bytes(), &signature).is_ok()); + } + + #[test] + fn retryable_classes_only() { + let body = || String::new(); + assert!(TurnkeyApiError::Timeout.is_retryable()); + assert!(TurnkeyApiError::RateLimited { body: body() }.is_retryable()); + assert!(TurnkeyApiError::ServerError { + status: 503, + body: body() + } + .is_retryable()); + assert!(TurnkeyApiError::Transport { + error_message: "reset".to_string() + } + .is_retryable()); + + // Already-submitted activity: a retry would restart polling, not observe + // it. Non-retryable by design. + assert!(!TurnkeyApiError::ActivityPollingExceeded { + error_message: "still pending".to_string() + } + .is_retryable()); + assert!(!TurnkeyApiError::Unauthorized { body: body() }.is_retryable()); + assert!(!TurnkeyApiError::NotFound { body: body() }.is_retryable()); + assert!(!TurnkeyApiError::ClientError { + status: 400, + body: body() + } + .is_retryable()); + assert!(!TurnkeyApiError::Activity { + error_message: "failed".to_string() + } + .is_retryable()); + } + + #[test] + fn backoff_is_bounded_by_max_delay() { + let policy = RetryPolicy::default(); + for attempt in 1..=8 { + assert!(backoff_delay(attempt, &policy) <= policy.max_delay); + } + } + + #[test] + fn sdk_error_status_classification() { + use turnkey_client::TurnkeyClientError; + assert!(matches!( + TurnkeyApiError::from(TurnkeyClientError::UnexpectedHttpStatus( + 429, + String::new() + )), + TurnkeyApiError::RateLimited { .. } + )); + assert!(matches!( + TurnkeyApiError::from(TurnkeyClientError::UnexpectedHttpStatus( + 500, + String::new() + )), + TurnkeyApiError::ServerError { status: 500, .. } + )); + assert!(matches!( + TurnkeyApiError::from(TurnkeyClientError::UnexpectedHttpStatus( + 409, + String::new() + )), + TurnkeyApiError::ClientError { status: 409, .. } + )); + assert!(matches!( + TurnkeyApiError::from(TurnkeyClientError::MissingResult), + TurnkeyApiError::Activity { .. } + )); + // Polling giving up is transient (retryable), not a rejected activity. + assert!(matches!( + TurnkeyApiError::from(TurnkeyClientError::ExceededRetries(3)), + TurnkeyApiError::ActivityPollingExceeded { .. } + )); + // Response-header parse failures are transport-class (retryable), not the + // permanent `Client` catch-all. + let header_err = + TurnkeyApiError::from(TurnkeyClientError::MissingContentTypeHeader); + assert!(matches!(header_err, TurnkeyApiError::Transport { .. })); + assert!(header_err.is_retryable()); + } + + #[test] + fn http_error_preserves_response_body() { + use turnkey_client::TurnkeyClientError; + let error = TurnkeyApiError::from(TurnkeyClientError::UnexpectedHttpStatus( + 400, + "invalid audience".to_string(), + )); + let TurnkeyApiError::ClientError { status, body } = &error else { + panic!("expected ClientError, got {error:?}"); + }; + assert_eq!(*status, 400); + assert_eq!(body, "invalid audience"); + // The body must reach the failure log, which formats via Display. + assert!(error.to_string().contains("invalid audience")); + } + + #[test] + fn oversized_body_is_truncated_in_error() { + use turnkey_client::TurnkeyClientError; + let big = "a".repeat(4096); + let error = TurnkeyApiError::from(TurnkeyClientError::UnexpectedHttpStatus( + 500, + big.clone(), + )); + let TurnkeyApiError::ServerError { body, .. } = &error else { + panic!("expected ServerError, got {error:?}"); + }; + assert!(body.len() < big.len()); + assert!(body.contains("truncated")); + } + + /// Ensures there can be a recovery through retries after transient failures. + /// + /// Using `start_paused` so backoff sleeps are skipped. + #[tokio::test(start_paused = true)] + async fn with_retry_recovers_after_transient_failures() { + let client = TurnkeyApiClient::new(); + let calls = AtomicU32::new(0); + let result: Result = client + .with_retry("op", || { + let attempt = calls.fetch_add(1, Ordering::SeqCst); + async move { + if attempt < 2 { + Err(TurnkeyApiError::Timeout) + } else { + Ok(42) + } + } + }) + .await; + + assert_eq!(result.unwrap(), 42); + assert_eq!(calls.load(Ordering::SeqCst), 3); + } + + #[tokio::test(start_paused = true)] + async fn with_retry_gives_up_after_max_attempts() { + let client = TurnkeyApiClient::new(); + let calls = AtomicU32::new(0); + let result: Result<(), TurnkeyApiError> = client + .with_retry("op", || { + calls.fetch_add(1, Ordering::SeqCst); + async { + Err(TurnkeyApiError::ServerError { + status: 503, + body: String::new(), + }) + } + }) + .await; + + assert!(matches!(result, Err(TurnkeyApiError::ServerError { .. }))); + assert_eq!( + calls.load(Ordering::SeqCst), + RetryPolicy::default().max_attempts + ); + } + + #[tokio::test(start_paused = true)] + async fn with_retry_does_not_retry_non_retryable() { + let client = TurnkeyApiClient::new(); + let calls = AtomicU32::new(0); + let result: Result<(), TurnkeyApiError> = client + .with_retry("op", || { + calls.fetch_add(1, Ordering::SeqCst); + async { Err(TurnkeyApiError::MainUserNotFound) } + }) + .await; + + assert!(matches!(result, Err(TurnkeyApiError::MainUserNotFound))); + assert_eq!(calls.load(Ordering::SeqCst), 1); + } +} diff --git a/bedrock/src/backup/turnkey/error.rs b/bedrock/src/backup/turnkey/error.rs new file mode 100644 index 00000000..56dcce13 --- /dev/null +++ b/bedrock/src/backup/turnkey/error.rs @@ -0,0 +1,215 @@ +//! Internal error types for Turnkey module + +use crate::primitives::KeypairSignerError; +use turnkey_client::TurnkeyClientError; + +/// Rich, internal error for Turnkey API operations. Used for retry classification +/// and structured logging; never returned across the FFI boundary. +#[derive(Debug, thiserror::Error)] +pub enum TurnkeyApiError { + /// The request timed out. + #[error("Turnkey request timed out")] + Timeout, + /// Turnkey rate-limited the request (HTTP 429). + #[error("Turnkey rate limited the request: {body}")] + RateLimited { + /// The upstream response body, for diagnostics. + body: String, + }, + /// The request was not authorized (HTTP 401/403). + #[error("Turnkey request unauthorized: {body}")] + Unauthorized { + /// The upstream response body, for diagnostics. + body: String, + }, + /// The requested resource was not found (HTTP 404). + #[error("Turnkey resource not found: {body}")] + NotFound { + /// The upstream response body, for diagnostics. + body: String, + }, + /// Turnkey returned a server error (HTTP 5xx). + #[error("Turnkey server error: status {status}: {body}")] + ServerError { + /// The HTTP status code returned. + status: u16, + /// The upstream response body, for diagnostics. + body: String, + }, + /// The request was rejected with a client error (HTTP 4xx other than + /// 401/403/404/429). Permanent; must not be retried. + #[error("Turnkey client request rejected: status {status}: {body}")] + ClientError { + /// The HTTP status code returned. + status: u16, + /// The upstream response body, for diagnostics. + body: String, + }, + /// A transport-level failure (connectivity, DNS, TLS). Never contains a URL. + #[error("Turnkey transport error: {error_message}")] + Transport { + /// Description of the transport failure. + error_message: String, + }, + /// A submitted activity failed, was rejected, or required extra approval. + #[error("Turnkey activity error: {error_message}")] + Activity { + /// Description of the activity failure. + error_message: String, + }, + /// The SDK exhausted its attempts polling a submitted activity to completion + /// (it was still `PENDING`). + #[error("Turnkey activity still pending after polling: {error_message}")] + ActivityPollingExceeded { + /// The SDK's description (includes the retry count). + error_message: String, + }, + /// Producing a request stamp failed (signing or key retrieval). + #[error("failed to produce request stamp: {0}")] + Signer(String), + /// Any other Turnkey client error (decoding, serialization, etc.). + #[error("Turnkey client error: {0}")] + Client(String), + /// The expected main user (`auth_user_main`) was not found in the sub-organization. + #[error("main user not found in sub-organization")] + MainUserNotFound, + /// The account shows a data consistency error which cannot be automatically resolved. Treat as critical. + #[error("critical consistency error")] + Consistency, +} + +impl TurnkeyApiError { + /// Whether this error is worth retrying (transient classes only). + /// + /// `ActivityPollingExceeded` is deliberately **not** retryable: the activity + /// is already submitted. If execution fails at the TEE, the next migration will pick it up. + pub(super) const fn is_retryable(&self) -> bool { + match self { + Self::Timeout + | Self::RateLimited { .. } + | Self::ServerError { .. } + | Self::Transport { .. } => true, + Self::Unauthorized { .. } + | Self::NotFound { .. } + | Self::ClientError { .. } + | Self::Activity { .. } + | Self::ActivityPollingExceeded { .. } + | Self::Signer(_) + | Self::Client(_) + | Self::MainUserNotFound + | Self::Consistency => false, + } + } + + /// Collapses this rich internal error into the opaque client-facing + /// [`TurnkeyMigrationError`], preserving only the coarse retry classification. + pub(super) const fn to_migration_error(&self) -> TurnkeyMigrationError { + if self.is_retryable() { + TurnkeyMigrationError::Retryable + } else { + TurnkeyMigrationError::Failed + } + } +} + +/// Maps a signer failure to [`TurnkeyApiError::Signer`], preserving its message. +impl From for TurnkeyApiError { + fn from(error: KeypairSignerError) -> Self { + Self::Signer(error.to_string()) + } +} + +/// Max length (in bytes) of an upstream response body retained on an error, to +/// bound log volume and limit exposure of non-generic info. +const MAX_LOGGED_BODY_LEN: usize = 256; + +/// Truncates an upstream response body when logging +fn truncate_body(body: String) -> String { + if body.len() <= MAX_LOGGED_BODY_LEN { + return body; + } + let mut end = MAX_LOGGED_BODY_LEN; + while !body.is_char_boundary(end) { + end -= 1; + } + format!("{}…[truncated, {} bytes total]", &body[..end], body.len()) +} + +/// Classifies a [`TurnkeyClientError`] from the Turnkey SDK into our internal error. +impl From for TurnkeyApiError { + fn from(error: TurnkeyClientError) -> Self { + match error { + TurnkeyClientError::Http(source) => { + if source.is_timeout() { + Self::Timeout + } else { + Self::Transport { + error_message: source.without_url().to_string(), + } + } + } + TurnkeyClientError::ReqwestBuilder(source) => Self::Transport { + error_message: source.without_url().to_string(), + }, + TurnkeyClientError::UnexpectedHttpStatus(code, body) => { + // NOTE: Turnkey may occasionally append public keys or sub-organization IDs to bodies, + // which would be logged here. We rely on the short TTL of logs for this + // not to be persisted. These logs are essential to ensure proper functioning of this + // mission critical system. + let body = truncate_body(body); + match code { + 429 => Self::RateLimited { body }, + 401 | 403 => Self::Unauthorized { body }, + 404 => Self::NotFound { body }, + 400..=499 => Self::ClientError { status: code, body }, + 500..=599 => Self::ServerError { status: code, body }, + _ => Self::Client(format!("unexpected HTTP status {code}: {body}")), + } + } + TurnkeyClientError::StamperError(source) => { + Self::Signer(source.to_string()) + } + other @ TurnkeyClientError::ExceededRetries(_) => { + Self::ActivityPollingExceeded { + error_message: other.to_string(), + } + } + other @ (TurnkeyClientError::ActivityFailed(_) + | TurnkeyClientError::UnexpectedActivityStatus(_) + | TurnkeyClientError::ActivityRequiresApproval(_) + | TurnkeyClientError::MissingActivity + | TurnkeyClientError::MissingResult + | TurnkeyClientError::MissingInnerResult + | TurnkeyClientError::UnexpectedInnerActivityResult(_)) => Self::Activity { + error_message: other.to_string(), + }, + // Response-header parsing fails before the SDK inspects the status, so + // an error response missing Content-Type surfaces here (e.g. CloudFlare's default error page) + other @ (TurnkeyClientError::MissingContentTypeHeader + | TurnkeyClientError::HeaderToStrError(_) + | TurnkeyClientError::HeaderFromStrError(_)) => Self::Transport { + error_message: other.to_string(), + }, + other => Self::Client(other.to_string()), + } + } +} + +/// Opaque error returned to clients when a Turnkey migration run fails. +/// +/// All diagnostic detail is logged inside Bedrock (see [`TurnkeyApiError`]); the +/// client only learns that the run did not succeed. +#[derive(Debug, thiserror::Error, uniffi::Error)] +pub enum TurnkeyMigrationError { + /// The run failed fairly permanent; retrying will not help. Typically a + /// misconfiguration, a consistency error, or an unauthorized signer. + #[error("turnkey migration run failed")] + Failed, + /// The run failed transiently (timeout, connectivity, rate limiting, an + /// overall deadline, or a concurrent run). A later retry may succeed. + #[error("turnkey migration run failed transiently; a retry may succeed")] + Retryable, + /// A migration is already in progress. Only one migration can be running at a time. + #[error("a migration is already in progress")] + AlreadyInProgress, +} diff --git a/bedrock/src/backup/turnkey/migrations/apple_audience.rs b/bedrock/src/backup/turnkey/migrations/apple_audience.rs new file mode 100644 index 00000000..0f01ad68 --- /dev/null +++ b/bedrock/src/backup/turnkey/migrations/apple_audience.rs @@ -0,0 +1,405 @@ +//! See [`MigrationAppleAudience`] + +use std::collections::HashSet; + +use turnkey_client::generated::external::data::v1::User; +use turnkey_client::generated::immutable::activity::v1::oauth_provider_params_v2::TokenOrClaims; +use turnkey_client::generated::immutable::activity::v1::{ + OauthProviderParamsV2, OidcClaims, +}; + +use crate::primitives::config::BedrockEnvironment; + +use super::super::error::TurnkeyApiError; +use super::super::policies::{AppleAudience, APPLE_ISSUER, AUTH_USER_MAIN_USERNAME}; +use super::{MigrationContext, MigrationOutcome, TurnkeyMigration}; + +/// If the user has a "Sign in with Apple" configured, ensure `auth_user_main` has +/// an Apple OAuth provider for every required audience. +/// +/// The reason why multiple audiences must exist for Apple OIDC is because each client +/// has its own audience. World App has the iOS App, the World ID App and the Android App, +/// each with its own `aud`. Particularly for the Android App, the login is done via a webview +/// as there's no native SDK. This means that all Android clients use the same audience. +/// +/// NOTE that Apple assigns the same `sub` to all Apple OIDC tokens under the same developer +/// account. +/// +/// If the user already has at least one Apple provider, its `subject` is reused +/// and all remaining providers are created. If the user has no Apple provider at all, this is a no-op. +/// +/// # Main Factor +/// If operations need to be executed, this migration REQUIRES a Main Factor. +pub(super) struct MigrationAppleAudience; + +#[async_trait::async_trait] +impl TurnkeyMigration for MigrationAppleAudience { + fn id(&self) -> &'static str { + "apple_audience" + } + + fn description(&self) -> &'static str { + "Enable Sign in with Apple for all iOS and Android apps." + } + + async fn run( + &self, + ctx: &MigrationContext<'_>, + ) -> Result { + let users = ctx + .api + .get_users(ctx.suborganization_id, ctx.sync_factor) + .await?; + + let plan = plan(users, ctx.environment)?; + + match plan { + Plan::SkipNoAppleProvider | Plan::SkipReady => { + crate::info!("apple_audience skipped: {plan}"); + Ok(MigrationOutcome::Skipped) + } + Plan::Create { user_id, providers } => { + let Some(main_factor) = ctx.main_factor else { + return Ok(MigrationOutcome::MainFactorRequired); + }; + let details: Vec = + providers.iter().map(|p| p.provider_name.clone()).collect(); + ctx.api + .create_oauth_providers( + ctx.suborganization_id, + &user_id, + providers, + main_factor, + ) + .await?; + Ok(MigrationOutcome::Applied { details }) + } + } + } +} + +/// The action to take, computed purely from the sub-organization's users. +enum Plan { + /// Nothing to do; the user has no Apple provider. + SkipNoAppleProvider, + /// Nothing to do; all providers already set. + SkipReady, + /// Create these OAuth providers on the given user. + Create { + user_id: String, + providers: Vec, + }, +} + +impl std::fmt::Display for Plan { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Self::SkipNoAppleProvider => { + f.write_str("skip: user has no Apple provider") + } + Self::SkipReady => { + f.write_str("skip: all providers are already configured") + } + Self::Create { + user_id: _, + providers, + } => { + write!(f, "create {} OAuth provider(s)", providers.len()) + } + } + } +} + +/// Computes the plan from the sub-org's users (pure function). +/// +/// # Errors +/// Returns [`TurnkeyApiError::MainUserNotFound`] if `auth_user_main` is absent. +fn plan( + users: Vec, + environment: BedrockEnvironment, +) -> Result { + let user = users + .into_iter() + .find(|user| user.user_name == AUTH_USER_MAIN_USERNAME) + .ok_or(TurnkeyApiError::MainUserNotFound)?; + + let apple_providers: Vec<_> = user + .oauth_providers + .iter() + .filter(|provider| provider.issuer == APPLE_ISSUER) + .collect(); + + let Some(first) = apple_providers.first() else { + return Ok(Plan::SkipNoAppleProvider); + }; + + let subject = first.subject.clone(); + + if apple_providers + .iter() + .any(|provider| provider.subject != subject) + { + crate::error!( + "critical consistency error. user has Apple OIDCs with different `sub`s" + ); + return Err(TurnkeyApiError::Consistency); + } + + let audiences = environment.turnkey_apple_audiences(); + let configured: HashSet<&str> = audiences + .iter() + .map(|audience| audience.client_id) + .collect(); + + let existing: HashSet<&str> = apple_providers + .iter() + .map(|provider| provider.audience.as_str()) + .collect(); + + // TODO: remove unrecognized audiences + let unrecognized: Vec<&str> = existing.difference(&configured).copied().collect(); + if !unrecognized.is_empty() { + crate::warn!( + "auth_user_main has unrecognized Apple aduences: {}", + unrecognized.join(", ") + ); + } + + let missing: Vec<&AppleAudience> = audiences + .iter() + .filter(|audience| !existing.contains(audience.client_id)) + .collect(); + + if missing.is_empty() { + return Ok(Plan::SkipReady); + } + + let providers = missing + .iter() + .map(|audience| OauthProviderParamsV2 { + provider_name: audience.provider_name.to_string(), + token_or_claims: Some(TokenOrClaims::OidcClaims(OidcClaims { + iss: APPLE_ISSUER.to_string(), + sub: subject.clone(), + aud: audience.client_id.to_string(), + })), + }) + .collect(); + + Ok(Plan::Create { + user_id: user.user_id, + providers, + }) +} + +#[cfg(test)] +mod tests { + use super::*; + use serde_json::json; + + fn user_from_json(value: serde_json::Value) -> User { + serde_json::from_value(value).unwrap() + } + + fn staging_audiences() -> Vec<&'static str> { + BedrockEnvironment::Staging + .turnkey_apple_audiences() + .iter() + .map(|audience| audience.client_id) + .collect() + } + + fn main_user_with_apple(audiences: &[&str], subject: &str) -> User { + let providers: Vec = audiences + .iter() + .map(|aud| { + json!({ + "providerId": format!("p-{aud}"), + "providerName": "apple", + "issuer": APPLE_ISSUER, + "audience": aud, + "subject": subject, + }) + }) + .collect(); + user_from_json(json!({ + "userId": "user-main", + "userName": AUTH_USER_MAIN_USERNAME, + "oauthProviders": providers, + })) + } + + #[test] + fn skips_when_no_apple_provider() { + let users = vec![user_from_json(json!({ + "userId": "user-main", + "userName": AUTH_USER_MAIN_USERNAME, + "oauthProviders": [{ + "providerId": "p-g", + "providerName": "google", + "issuer": "https://accounts.google.com", + "audience": "aud-g", + "subject": "sub-g", + }], + }))]; + + assert!(matches!( + plan(users, BedrockEnvironment::Staging), + Ok(Plan::SkipNoAppleProvider) + )); + } + + #[test] + fn skips_when_all_audiences_present() { + let auds = staging_audiences(); + let users = vec![main_user_with_apple(&auds, "sub-1")]; + + assert!(matches!( + plan(users, BedrockEnvironment::Staging), + Ok(Plan::SkipReady) + )); + } + + /// Tests against explicitly hardcoded values + #[test] + fn production_plan_uses_production_audiences() { + let users = vec![main_user_with_apple(&["org.worldcoin.insight"], "sub-prod")]; + + let Plan::Create { providers, .. } = + plan(users, BedrockEnvironment::Production).unwrap() + else { + panic!("expected Create plan"); + }; + + let created: HashSet<&str> = providers + .iter() + .filter_map(|provider| match &provider.token_or_claims { + Some(TokenOrClaims::OidcClaims(claims)) => Some(claims.aud.as_str()), + _ => None, + }) + .collect(); + assert_eq!(created, HashSet::from(["org.world.id", "app.world.apple"])); + } + + #[test] + fn creates_only_missing_audiences() { + let auds = staging_audiences(); + // Only the first audience is present; every other one must be created. + let users = vec![main_user_with_apple(&auds[..1], "sub-apple")]; + + let Plan::Create { user_id, providers } = + plan(users, BedrockEnvironment::Staging).unwrap() + else { + panic!("expected Create plan"); + }; + + assert_eq!(user_id, "user-main"); + assert_eq!(providers.len(), auds.len() - 1); + + let created_auds: HashSet<&str> = providers + .iter() + .filter_map(|provider| match &provider.token_or_claims { + Some(TokenOrClaims::OidcClaims(claims)) => Some(claims.aud.as_str()), + _ => None, + }) + .collect(); + for aud in &auds[1..] { + assert!(created_auds.contains(aud)); + } + + for provider in &providers { + let Some(TokenOrClaims::OidcClaims(claims)) = &provider.token_or_claims + else { + panic!("expected claims-based provider"); + }; + assert_eq!(claims.sub, "sub-apple"); // CRITICAL check + assert_eq!(claims.iss, APPLE_ISSUER); + assert!(provider.provider_name.starts_with("APPLE")); + } + } + + #[test] + fn errors_when_main_user_missing() { + let users = vec![user_from_json(json!({ + "userId": "user-other", + "userName": "someone_else", + "oauthProviders": [], + }))]; + + assert!(matches!( + plan(users, BedrockEnvironment::Staging), + Err(TurnkeyApiError::MainUserNotFound) + )); + } + + #[test] + fn errors_when_account_has_multiple_apple_providers_with_different_sub() { + let auds = staging_audiences(); + let users = vec![user_from_json(json!({ + "userId": "user-main", + "userName": AUTH_USER_MAIN_USERNAME, + "oauthProviders": [ + { + "providerId": "p-0", + "providerName": "apple", + "issuer": APPLE_ISSUER, + "audience": auds[0], + "subject": "sub-a", + }, + { + "providerId": "p-1", + "providerName": "apple", + "issuer": APPLE_ISSUER, + "audience": auds[1], + "subject": "sub-b", + }, + ], + }))]; + + assert!(matches!( + plan(users, BedrockEnvironment::Staging), + Err(TurnkeyApiError::Consistency) + )); + } + + /// The `providerName` is a convenience label. This test ensures that the migrations + /// are run based on registered `aud`s, and any incorrect labels are ignored. + /// + /// Every required audience is present by `aud`, but the first provider + /// carries a configured name bound to the wrong audience (reused/wrong + /// label). The migration must still treat this as ready. + #[test] + fn completes_when_all_audiences_present_despite_reused_name() { + let auds = BedrockEnvironment::Staging.turnkey_apple_audiences(); + assert!(auds.len() >= 2, "staging needs multiple audiences"); + + let providers: Vec = auds + .iter() + .enumerate() + .map(|(i, audience)| { + let provider_name = match i { + 0 => auds[1].provider_name, // reused name, wrong audience + 1 => "legacy-apple-provider", // unique, non-configured name + _ => audience.provider_name, + }; + json!({ + "providerId": format!("p-{i}"), + "providerName": provider_name, + "issuer": APPLE_ISSUER, + "audience": audience.client_id, + "subject": "sub-apple", + }) + }) + .collect(); + let users = vec![user_from_json(json!({ + "userId": "user-main", + "userName": AUTH_USER_MAIN_USERNAME, + "oauthProviders": providers, + }))]; + + assert!(matches!( + plan(users, BedrockEnvironment::Staging), + Ok(Plan::SkipReady) + )); + } +} diff --git a/bedrock/src/backup/turnkey/migrations/mod.rs b/bedrock/src/backup/turnkey/migrations/mod.rs new file mode 100644 index 00000000..ce098d33 --- /dev/null +++ b/bedrock/src/backup/turnkey/migrations/mod.rs @@ -0,0 +1,328 @@ +//! Turnkey account migrations. The purpose is to check the state of a +//! user's Turnkey account to ensure it is correct and up-to-date. + +use crate::primitives::config::BedrockEnvironment; +use crate::{error, info}; + +use super::api::{MainFactor, SyncFactor, TurnkeyApiClient}; +use super::error::TurnkeyApiError; + +mod apple_audience; + +use apple_audience::MigrationAppleAudience; + +/// Every Turnkey migration, in the order they are applied. +/// +/// Single source of truth for which migrations exist. To add one, create a +/// module under `migrations/`, implement [`TurnkeyMigration`], and append it here. +/// +/// The list fails fast on the first error, so order first cheaper migrations, more likely +/// to be skipped, or the ones not requiring a Main Factor. +pub(super) const MIGRATIONS: &[&dyn TurnkeyMigration] = &[&MigrationAppleAudience]; + +// TODO Migrations: +// 1. Ensure `auth_user_main` is the only one in the root quorum (housekeeping) +// 2. Ensure break glass user exists and has the correct policy +// 3. Ensure all sync factors have the right deletion policy +// 4. Ensure max number of sync factor users and policies (port over from Android) + +/// Global result from running the entire set of migrations. +#[derive(Debug, Clone, PartialEq, Eq, uniffi::Enum)] +pub enum TurnkeyMigrationOutcome { + /// Every applicable migration completed (or was already satisfied). + Completed, + /// One or more migrations still need to run but require the main factor, + /// which was not provided. Re-invoke with the main factor to apply them. + MainFactorRequired { + /// Human-friendly descriptions of the migrations awaiting the main factor. + pending: Vec, + }, +} + +/// Internal outcome of a single migration run. +pub(super) enum MigrationOutcome { + /// The migration applied changes, described by `details`. + Applied { details: Vec }, + /// The migration was a no-op. This is a success case. + Skipped, + /// Changes are required but the main factor was absent, so nothing was + /// applied. Re-invoke with the main factor to apply them. + MainFactorRequired, +} + +/// Context passed to each migration. +pub(super) struct MigrationContext<'a> { + suborganization_id: &'a str, + environment: BedrockEnvironment, + sync_factor: SyncFactor<'a>, + main_factor: Option>, + api: &'a TurnkeyApiClient, +} + +/// A single reconciliation step against the Turnkey sub-organization. +#[async_trait::async_trait] +pub(super) trait TurnkeyMigration: Send + Sync { + /// Stable identifier used in logs. + fn id(&self) -> &'static str; + /// Human-friendly description of what the migration intends to do. + fn description(&self) -> &'static str; + /// Runs the migration against `ctx`. + /// + /// Implementations decide what (if any) work is needed using the sync factor + /// (reads). If changes are required but `ctx.main_factor` is absent, return + /// [`MigrationOutcome::MainFactorRequired`] instead of applying. + async fn run( + &self, + ctx: &MigrationContext<'_>, + ) -> Result; +} + +/// Runs the given migrations in order and returns the overall +/// [`TurnkeyMigrationOutcome`]. +/// +/// Production callers pass [`MIGRATIONS`]; tests pass a scripted list. +/// +/// # Errors +/// Returns [`TurnkeyApiError`] if a migration fails (transport, activity, parsing). +pub(super) async fn run_migration_list( + migrations: &[&dyn TurnkeyMigration], + suborganization_id: &str, + sync_factor: SyncFactor<'_>, + main_factor: Option>, + api: &TurnkeyApiClient, + environment: BedrockEnvironment, +) -> Result { + let mut pending_main_factor: Vec = Vec::new(); + + for migration in migrations { + let ctx = MigrationContext { + suborganization_id, + environment, + sync_factor, + main_factor, + api, + }; + + match migration.run(&ctx).await { + Ok(MigrationOutcome::Applied { details }) => { + info!( + "turnkey.migration.applied migration={} changes=[{}]", + migration.id(), + details.join(", ") + ); + } + Ok(MigrationOutcome::Skipped) => {} + Ok(MigrationOutcome::MainFactorRequired) => { + info!( + "turnkey.migration.deferred migration={} reason=main_factor_required", + migration.id() + ); + pending_main_factor.push(migration.description().to_string()); + } + Err(error) => { + error!( + "turnkey.migration.failed migration={} err={error}", + migration.id() + ); + return Err(error); + } + } + } + + if pending_main_factor.is_empty() { + Ok(TurnkeyMigrationOutcome::Completed) + } else { + Ok(TurnkeyMigrationOutcome::MainFactorRequired { + pending: pending_main_factor, + }) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::backup::turnkey::test::TestSigner; + use crate::primitives::P256Signer; + use std::sync::atomic::{AtomicBool, Ordering}; + use std::sync::Arc; + + fn signer() -> P256Signer { + P256Signer::verify(Arc::new(TestSigner::new())).unwrap() + } + + /// What a [`FakeMigration`] pretends its plan is. + enum Behavior { + /// No changes needed, regardless of the signers present. + NoWork, + /// Changes needed: applies when the main factor is present, otherwise + /// reports [`MigrationOutcome::MainFactorRequired`]. + NeedsWork, + /// Fails while running. + Fail, + } + + struct FakeMigration { + id: &'static str, + behavior: Behavior, + ran: Arc, + } + + #[async_trait::async_trait] + impl TurnkeyMigration for FakeMigration { + fn id(&self) -> &'static str { + self.id + } + fn description(&self) -> &'static str { + "fake migration" + } + async fn run( + &self, + ctx: &MigrationContext<'_>, + ) -> Result { + self.ran.store(true, Ordering::SeqCst); + match self.behavior { + Behavior::NoWork => Ok(MigrationOutcome::Skipped), + Behavior::NeedsWork if ctx.main_factor.is_some() => { + Ok(MigrationOutcome::Applied { + details: vec!["change".to_string()], + }) + } + Behavior::NeedsWork => Ok(MigrationOutcome::MainFactorRequired), + Behavior::Fail => Err(TurnkeyApiError::MainUserNotFound), + } + } + } + + async fn run_fakes( + migrations: &[&dyn TurnkeyMigration], + with_main_factor: bool, + ) -> Result { + let api = TurnkeyApiClient::new(); + let sync_factor = signer(); + let main_factor = with_main_factor.then(signer); + run_migration_list( + migrations, + "suborg-1", + SyncFactor(&sync_factor), + main_factor.as_ref().map(MainFactor), + &api, + BedrockEnvironment::Staging, + ) + .await + } + + #[tokio::test] + async fn completes_when_all_migrations_run() { + let first = Arc::new(AtomicBool::new(false)); + let second = Arc::new(AtomicBool::new(false)); + let a = FakeMigration { + id: "a", + behavior: Behavior::NeedsWork, + ran: first.clone(), + }; + let b = FakeMigration { + id: "b", + behavior: Behavior::NoWork, + ran: second.clone(), + }; + let migrations: [&dyn TurnkeyMigration; 2] = [&a, &b]; + + let outcome = run_fakes(&migrations, true).await.unwrap(); + + assert_eq!(outcome, TurnkeyMigrationOutcome::Completed); + assert!(first.load(Ordering::SeqCst)); + assert!(second.load(Ordering::SeqCst)); + } + + #[tokio::test] + async fn no_op_migration_does_not_require_main_factor() { + let ran = Arc::new(AtomicBool::new(false)); + let noop = FakeMigration { + id: "noop", + behavior: Behavior::NoWork, + ran: ran.clone(), + }; + let migrations: [&dyn TurnkeyMigration; 1] = [&noop]; + + // No main factor provided, yet a no-op migration must still complete + // rather than demand it. + let outcome = run_fakes(&migrations, false).await.unwrap(); + + assert_eq!(outcome, TurnkeyMigrationOutcome::Completed); + assert!(ran.load(Ordering::SeqCst)); + } + + #[tokio::test] + async fn defers_needed_migration_when_main_factor_absent() { + let ran = Arc::new(AtomicBool::new(false)); + let needs_main = FakeMigration { + id: "needs_main", + behavior: Behavior::NeedsWork, + ran: ran.clone(), + }; + let migrations: [&dyn TurnkeyMigration; 1] = [&needs_main]; + + let outcome = run_fakes(&migrations, false).await.unwrap(); + + assert_eq!( + outcome, + TurnkeyMigrationOutcome::MainFactorRequired { + pending: vec!["fake migration".to_string()], + } + ); + // The migration still runs so it can decide whether work is needed. + assert!(ran.load(Ordering::SeqCst)); + } + + /// Tests that even if two migrations require Main Factor but another one doesn't, + /// the one requiring a Sync Factor still runs. + #[tokio::test] + async fn accumulates_all_deferrals_and_continues() { + let after_ran = Arc::new(AtomicBool::new(false)); + let first = FakeMigration { + id: "first", + behavior: Behavior::NeedsWork, + ran: Arc::new(AtomicBool::new(false)), + }; + let second = FakeMigration { + id: "second", + behavior: Behavior::NeedsWork, + ran: Arc::new(AtomicBool::new(false)), + }; + let after = FakeMigration { + id: "after", + behavior: Behavior::NoWork, + ran: after_ran.clone(), + }; + let migrations: [&dyn TurnkeyMigration; 3] = [&first, &second, &after]; + + let outcome = run_fakes(&migrations, false).await.unwrap(); + + let TurnkeyMigrationOutcome::MainFactorRequired { pending } = outcome else { + panic!("expected MainFactorRequired, got {outcome:?}"); + }; + assert_eq!(pending.len(), 2); + assert!(after_ran.load(Ordering::SeqCst)); + } + + #[tokio::test] + async fn fails_fast_and_skips_remaining() { + let second = Arc::new(AtomicBool::new(false)); + let boom = FakeMigration { + id: "boom", + behavior: Behavior::Fail, + ran: Arc::new(AtomicBool::new(false)), + }; + let after = FakeMigration { + id: "after", + behavior: Behavior::NeedsWork, + ran: second.clone(), + }; + let migrations: [&dyn TurnkeyMigration; 2] = [&boom, &after]; + + let result = run_fakes(&migrations, true).await; + + assert!(matches!(result, Err(TurnkeyApiError::MainUserNotFound))); + assert!(!second.load(Ordering::SeqCst)); + } +} diff --git a/bedrock/src/backup/turnkey.rs b/bedrock/src/backup/turnkey/mod.rs similarity index 82% rename from bedrock/src/backup/turnkey.rs rename to bedrock/src/backup/turnkey/mod.rs index e14809b1..28018ed1 100644 --- a/bedrock/src/backup/turnkey.rs +++ b/bedrock/src/backup/turnkey/mod.rs @@ -1,4 +1,4 @@ -//! This module allows interactions with the Turnkey API for the user's backup. +//! This module allows interactions with the Turnkey system for the user's backup. use std::sync::Arc; @@ -16,7 +16,158 @@ use turnkey_enclave_encrypt::QuorumPublicKey; use crate::root_key::RootKey; +mod api; +mod error; +mod migrations; +mod policies; + +#[cfg(test)] +mod test; + +use api::{MainFactor, SyncFactor, TurnkeyApiClient}; +pub use error::TurnkeyMigrationError; +use migrations::{run_migration_list, TurnkeyMigrationOutcome, MIGRATIONS}; + +use crate::primitives::config::get_config; +use crate::primitives::P256Signer; + +/// Only one migration running at a time. +static TURNKEY_MIGRATION_LOCK: once_cell::sync::Lazy> = + once_cell::sync::Lazy::new(|| tokio::sync::Mutex::new(())); + +/// Hard ceiling on a full migration run. A degraded Turnkey (repeated timeouts +/// plus retry backoff) must not block the caller indefinitely, and iOS cannot +/// cancel a uniffi async call, so the deadline lives here. +const MIGRATION_RUN_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(180); + +/// High level manager to perform Turnkey account operations such as setup and +/// migration reconciliation. +/// +/// For use from foreign bindings. +#[derive(uniffi::Object, Clone, Debug, Default)] +pub struct TurnkeyManager; + +#[bedrock_export] +impl TurnkeyManager { + /// Creates a new `TurnkeyManager`. + #[uniffi::constructor] + #[must_use] + pub fn new() -> Self { + Self + } + + /// Reviews the Turnkey account state and applies any required migrations to + /// bring the user's sub-organization in line with the expected configuration. + /// + /// Migrations that can run with the `sync_factor` alone run immediately; + /// those that require the `main_factor` are deferred and reported via + /// [`TurnkeyMigrationOutcome::MainFactorRequired`] when it is absent, so the + /// caller can re-invoke with the main factor. + /// + /// # Arguments + /// - `suborganization_id`: the user's Turnkey sub-organization id. When + /// `None`, it is resolved via Turnkey `whoami` stamped with the sync factor. + /// - `sync_factor`: a [`P256Signer`] the caller has already constructed — and + /// thereby validated — from its sync signer; stamps read/query requests and + /// resolves the sub-organization. + /// - `main_factor`: an optional [`P256Signer`] for privileged writes with + /// [`policies::AUTH_USER_MAIN_USERNAME`], i.e. the ephemeral session key established + /// from a Main Factor. + /// + /// # Threading + /// This performs network I/O and may poll Turnkey activities to completion, + /// so it can take a while. Callers MUST invoke it off the main thread. + /// + /// # Errors + /// Returns [`TurnkeyMigrationError`] if the run fails. Diagnostic detail is + /// logged inside Bedrock and intentionally not surfaced. + pub async fn run_migrations( + &self, + suborganization_id: Option, + sync_factor: &P256Signer, + main_factor: Option>, + ) -> Result { + crate::debug!( + "run_migrations start is_suborg_provided={}", + suborganization_id.is_some() + ); + + let Ok(_guard) = TURNKEY_MIGRATION_LOCK.try_lock() else { + // A warning is logged because the native client should be aware of the + // migrations it's triggering and triggering duplicates is a native bug. + crate::warn!( + "run_migrations skipped: another migration run is already in progress" + ); + return Err(TurnkeyMigrationError::AlreadyInProgress); + }; + + // Turnkey account management is too sensitive to run against a defaulted + // environment; require explicit configuration. + let Some(config) = get_config() else { + crate::error!("run_migrations aborted: Bedrock config not initialized"); + return Err(TurnkeyMigrationError::Failed); + }; + let environment = config.environment(); + let api = TurnkeyApiClient::new(); + let sync_factor = SyncFactor(sync_factor); + + let suborganization_id = if let Some(id) = suborganization_id { + id + } else { + let parent = environment.turnkey_parent_organization_id(); + match api.resolve_suborganization_id(parent, sync_factor).await { + Ok(id) => id, + Err(error) => { + crate::error!( + "run_migrations sub-org resolution failed err={error}" + ); + return Err(error.to_migration_error()); + } + } + }; + + let run = run_migration_list( + MIGRATIONS, + &suborganization_id, + sync_factor, + main_factor.as_deref().map(MainFactor), + &api, + environment, + ); + let outcome = match tokio::time::timeout(MIGRATION_RUN_TIMEOUT, run).await { + Ok(Ok(outcome)) => outcome, + Ok(Err(error)) => { + crate::error!("run_migrations failed err={error}"); + return Err(error.to_migration_error()); + } + Err(_elapsed) => { + crate::error!( + "run_migrations timed out after {}s", + MIGRATION_RUN_TIMEOUT.as_secs() + ); + return Err(TurnkeyMigrationError::Retryable); + } + }; + + match &outcome { + TurnkeyMigrationOutcome::Completed => { + crate::debug!("run_migrations completed successfully"); + } + TurnkeyMigrationOutcome::MainFactorRequired { pending } => { + crate::debug!( + "run_migrations deferred {} migration(s) awaiting the main factor", + pending.len() + ); + } + } + Ok(outcome) + } +} + /// Allows interactions with Turnkey API. +/// +/// DEPRECATION NOTICE: Interactions with Turnkey will be migrated to be handled from +/// within Bedrock. This class should disappear in favor of [`TurnkeyManager`] #[derive(uniffi::Object, Clone, Debug, Default)] pub struct Turnkey {} diff --git a/bedrock/src/backup/turnkey/policies.rs b/bedrock/src/backup/turnkey/policies.rs new file mode 100644 index 00000000..fba576ef --- /dev/null +++ b/bedrock/src/backup/turnkey/policies.rs @@ -0,0 +1,156 @@ +//! This module contains all policies and configurations for World App's use of Turnkey. + +use crate::primitives::config::BedrockEnvironment; + +/// Represents the user's primary authentication and holds their Main Factor credentials. +/// +/// Turnkey's `userName` of the primary user who holds all the Main Factors. Sole +/// member of the root quorum. +/// +/// MUST be a precise name. Do not change this! Bad things could happen. +pub const AUTH_USER_MAIN_USERNAME: &str = "auth_user_main"; + +/// The Apple Sign In OIDC issuer. +pub const APPLE_ISSUER: &str = "https://appleid.apple.com"; + +impl BedrockEnvironment { + /// Parent Turnkey organization id (i.e. TFH) + pub(super) const fn turnkey_parent_organization_id(self) -> &'static str { + match self { + Self::Staging | Self::Sandbox => "72955dee-35e7-48a4-86ac-a8020a87bde8", + Self::Production => "2be0b97b-7732-492d-9047-ca14391d24c9", + } + } + + /// Apple audiences for which the [`AUTH_USER_MAIN_USERNAME`] should be registered if + /// using Sign in with Apple + pub(super) const fn turnkey_apple_audiences(self) -> &'static [AppleAudience] { + match self { + Self::Staging | Self::Sandbox => STAGING_APPLE_AUDIENCES, + Self::Production => PRODUCTION_APPLE_AUDIENCES, + } + } +} + +pub(super) struct AppleAudience { + /// Provider name registered in Turnkey to identify this Apple provider. + pub(super) provider_name: &'static str, + /// The `aud` claim from the OIDC JWT. + pub(super) client_id: &'static str, +} + +/// The list of `aud` claims for staging environment. +/// +/// # Warning +/// Audiences (i.e. `client_id`) MUST be unique. The `apple_audience_tables_have_no_duplicates` +/// enforces this. +const STAGING_APPLE_AUDIENCES: &[AppleAudience] = &[ + // World App iOS — the pre-existing provider; keep the name "APPLE". + AppleAudience { + provider_name: "APPLE", + client_id: "org.worldcoin.insight.staging", + }, + // World ID iOS. + AppleAudience { + provider_name: "APPLE-WID", + client_id: "org.world.staging.id", + }, + // World ID iOS (sandbox). + AppleAudience { + provider_name: "APPLE-WID-SANDBOX", + client_id: "org.world.sandbox.id", + }, + // Android (web-based Sign in with Apple). + AppleAudience { + provider_name: "APPLE-WEB", + client_id: "app.world.apple.staging", + }, +]; + +const PRODUCTION_APPLE_AUDIENCES: &[AppleAudience] = &[ + // World App iOS — the pre-existing provider; keep the name "APPLE". + AppleAudience { + provider_name: "APPLE", + client_id: "org.worldcoin.insight", + }, + // World ID iOS. + AppleAudience { + provider_name: "APPLE-WID", + client_id: "org.world.id", + }, + // Android (web-based Sign in with Apple). + AppleAudience { + provider_name: "APPLE-WEB", + client_id: "app.world.apple", + }, +]; + +#[cfg(test)] +mod tests { + use super::*; + + /// Hardcoded literal explicit matching + #[test] + fn apple_audience_tables_are_pinned() { + fn pairs(environment: BedrockEnvironment) -> Vec<(&'static str, &'static str)> { + environment + .turnkey_apple_audiences() + .iter() + .map(|audience| (audience.provider_name, audience.client_id)) + .collect() + } + + assert_eq!( + pairs(BedrockEnvironment::Staging), + vec![ + ("APPLE", "org.worldcoin.insight.staging"), + ("APPLE-WID", "org.world.staging.id"), + ("APPLE-WID-SANDBOX", "org.world.sandbox.id"), + ("APPLE-WEB", "app.world.apple.staging"), + ] + ); + + // Sandbox intentionally shares the staging table. + assert_eq!( + pairs(BedrockEnvironment::Sandbox), + pairs(BedrockEnvironment::Staging) + ); + + assert_eq!( + pairs(BedrockEnvironment::Production), + vec![ + ("APPLE", "org.worldcoin.insight"), + ("APPLE-WID", "org.world.id"), + ("APPLE-WEB", "app.world.apple"), + ] + ); + } + + #[test] + fn apple_audience_tables_have_no_duplicates() { + use std::collections::HashSet; + for environment in [ + BedrockEnvironment::Staging, + BedrockEnvironment::Sandbox, + BedrockEnvironment::Production, + ] { + let table = environment.turnkey_apple_audiences(); + let client_ids: HashSet<&str> = + table.iter().map(|audience| audience.client_id).collect(); + let provider_names: HashSet<&str> = table + .iter() + .map(|audience| audience.provider_name) + .collect(); + assert_eq!( + client_ids.len(), + table.len(), + "duplicate client_id in {environment:?} audiences" + ); + assert_eq!( + provider_names.len(), + table.len(), + "duplicate provider_name in {environment:?} audiences" + ); + } + } +} diff --git a/bedrock/src/backup/turnkey/test.rs b/bedrock/src/backup/turnkey/test.rs new file mode 100644 index 00000000..34a67e86 --- /dev/null +++ b/bedrock/src/backup/turnkey/test.rs @@ -0,0 +1,313 @@ +//! Shared test helpers for the Turnkey module. + +use p256::ecdsa::signature::hazmat::PrehashSigner; +use p256::elliptic_curve::sec1::ToEncodedPoint; + +use crate::primitives::logger::{LogLevel, Logger}; +use crate::primitives::{KeypairSigner, KeypairSignerError}; + +/// A deterministic, obviously-non-secret P-256 private key for tests (all `0x01` +/// bytes). Never put anything resembling a real key in tests. +pub const TEST_PRIVATE_KEY: [u8; 32] = [1u8; 32]; + +/// An in-process [`KeypairSigner`] backed by [`TEST_PRIVATE_KEY`], for tests. +pub struct TestSigner { + secret: p256::SecretKey, +} + +impl TestSigner { + /// Builds the canonical test signer from [`TEST_PRIVATE_KEY`]. + pub fn new() -> Self { + let secret = + p256::SecretKey::from_slice(&TEST_PRIVATE_KEY).expect("valid p256 key"); + Self { secret } + } + + /// Builds a signer from a hex-encoded P-256 private key. + /// + /// For integration tests that load a real key from the environment — do not + /// hardcode real key material in source. + pub fn from_hex(hex_key: &str) -> Self { + let bytes = hex::decode(hex_key).expect("valid hex key"); + let secret = p256::SecretKey::from_slice(&bytes).expect("valid p256 key"); + Self { secret } + } +} + +impl Default for TestSigner { + fn default() -> Self { + Self::new() + } +} + +impl KeypairSigner for TestSigner { + fn public_key(&self) -> Result, KeypairSignerError> { + Ok(self + .secret + .public_key() + .to_encoded_point(true) + .as_bytes() + .to_vec()) + } + + fn sign_digest(&self, digest: Vec) -> Result, KeypairSignerError> { + let signing_key = p256::ecdsa::SigningKey::from(self.secret.clone()); + let signature: p256::ecdsa::Signature = signing_key + .sign_prehash(&digest) + .map_err(|_| KeypairSignerError::InvalidKey)?; + Ok(signature.to_der().as_bytes().to_vec()) + } +} + +/// A [`Logger`] that prints Bedrock's log records to stdout, so `crate::info!` +/// and friends are visible when an integration test runs with `--nocapture`. +/// Install it once per test process via +/// [`crate::primitives::logger::set_logger`]. +pub struct StdoutLogger; + +impl Logger for StdoutLogger { + fn log(&self, level: LogLevel, message: String) { + println!("[bedrock][{level:?}] {message}"); + } +} + +/// Integration tests that hit the **real Turnkey API**. Ignored by default; run +/// explicitly against a real sub-organization with real credentials. +mod integration_tests { + use super::{StdoutLogger, TestSigner}; + use crate::backup::turnkey::TurnkeyManager; + use crate::primitives::config::{set_config, BedrockEnvironment, Os}; + use crate::primitives::logger::set_logger; + use crate::primitives::P256Signer; + use std::sync::Arc; + + #[tokio::test] + #[ignore = "integration: hits the real Turnkey API; requires real credentials"] + async fn run_migrations_against_real_turnkey() { + set_config(BedrockEnvironment::Staging, Os::Ios); + set_logger(Arc::new(StdoutLogger)); + + let sync_key = std::env::var("TURNKEY_SYNC_KEY").unwrap(); + let sync_factor = P256Signer::verify(Arc::new(TestSigner::from_hex(&sync_key))) + .expect("valid sync factor key"); + + let main_factor = std::env::var("TURNKEY_MAIN_KEY").ok().map(|key| { + Arc::new( + P256Signer::verify(Arc::new(TestSigner::from_hex(&key))) + .expect("valid main factor key"), + ) + }); + + let suborganization_id = std::env::var("TURNKEY_SUBORG_ID").ok(); + + let outcome = TurnkeyManager::new() + .run_migrations(suborganization_id, &sync_factor, main_factor) + .await + .expect("run_migrations should succeed against real Turnkey"); + + println!("run_migrations outcome: {outcome:?}"); + } +} + +/// Full coverage of the entire migration run process (`run_migration_list`) with +/// mocked API calls to Turnkey (follows same mocking patterns as Turnkey's SDK). +mod migration_functional_tests { + use super::TestSigner; + use crate::backup::turnkey::api::{MainFactor, SyncFactor, TurnkeyApiClient}; + use crate::backup::turnkey::migrations::{ + run_migration_list, TurnkeyMigrationOutcome, MIGRATIONS, + }; + use crate::backup::turnkey::policies::{APPLE_ISSUER, AUTH_USER_MAIN_USERNAME}; + use crate::primitives::config::BedrockEnvironment; + use crate::primitives::P256Signer; + use serde_json::json; + use std::sync::Arc; + use wiremock::matchers::{method, path}; + use wiremock::{Mock, MockServer, ResponseTemplate}; + + const LIST_USERS_PATH: &str = "/public/v1/query/list_users"; + const CREATE_OAUTH_PATH: &str = "/public/v1/submit/create_oauth_providers"; + + fn signer() -> P256Signer { + P256Signer::verify(Arc::new(TestSigner::new())).expect("valid test signer") + } + + /// `auth_user_main` carrying an Apple provider for each `audiences` entry. + fn main_user(audiences: &[&str], subject: &str) -> serde_json::Value { + let providers: Vec = audiences + .iter() + .map(|aud| { + json!({ + "providerId": format!("p-{aud}"), + "providerName": "apple", + "issuer": APPLE_ISSUER, + "audience": aud, + "subject": subject, + }) + }) + .collect(); + json!({ + "userId": "user-main", + "userName": AUTH_USER_MAIN_USERNAME, + "oauthProviders": providers, + }) + } + + async fn mount_list_users(server: &MockServer, user: serde_json::Value) { + Mock::given(method("POST")) + .and(path(LIST_USERS_PATH)) + .respond_with( + ResponseTemplate::new(200).set_body_json(json!({ "users": [user] })), + ) + .mount(server) + .await; + } + + /// A minimal COMPLETED `CreateOauthProviders` activity response. + fn completed_create(provider_ids: &[&str]) -> serde_json::Value { + json!({ + "activity": { + "id": "act-1", + "organizationId": "suborg-1", + "status": "ACTIVITY_STATUS_COMPLETED", + "type": "ACTIVITY_TYPE_CREATE_OAUTH_PROVIDERS_V2", + "fingerprint": "fp-1", + "result": { + "createOauthProvidersResultV2": { "providerIds": provider_ids } + } + } + }) + } + + #[tokio::test] + async fn apple_audiences_creates_missing_audiences_with_main_factor() { + let server = MockServer::start().await; + // Only the first staging audience present → the other three missing. + mount_list_users( + &server, + main_user(&["org.worldcoin.insight.staging"], "sub-apple"), + ) + .await; + Mock::given(method("POST")) + .and(path(CREATE_OAUTH_PATH)) + .respond_with( + ResponseTemplate::new(200) + .set_body_json(completed_create(&["n1", "n2", "n3"])), + ) + .expect(1) + .mount(&server) + .await; + + let api = TurnkeyApiClient::with_base_url(server.uri()); + let sync = signer(); + let main = signer(); + let outcome = run_migration_list( + MIGRATIONS, + "suborg-1", + SyncFactor(&sync), + Some(MainFactor(&main)), + &api, + BedrockEnvironment::Staging, + ) + .await + .unwrap(); + + assert_eq!(outcome, TurnkeyMigrationOutcome::Completed); + + // The create request carried exactly the missing staging audiences and + // reused the existing subject. + let requests = server.received_requests().await.unwrap(); + let create = requests + .iter() + .find(|request| request.url.path() == CREATE_OAUTH_PATH) + .expect("create request was sent"); + let body = String::from_utf8_lossy(&create.body); + for aud in [ + "org.world.staging.id", + "org.world.sandbox.id", + "app.world.apple.staging", + ] { + assert!(body.contains(aud), "create body missing audience {aud}"); + } + assert!(body.contains("sub-apple"), "create body missing subject"); + assert!( + !body.contains("org.worldcoin.insight.staging"), + "create body should not re-add the already-present audience" + ); + } + + #[tokio::test] + async fn apple_audiences_skips_when_all_audiences_present() { + let server = MockServer::start().await; + mount_list_users( + &server, + main_user( + &[ + "org.worldcoin.insight.staging", + "org.world.staging.id", + "org.world.sandbox.id", + "app.world.apple.staging", + ], + "sub-apple", + ), + ) + .await; + // Any create call is a bug. + Mock::given(method("POST")) + .and(path(CREATE_OAUTH_PATH)) + .respond_with(ResponseTemplate::new(500)) + .expect(0) + .mount(&server) + .await; + + let api = TurnkeyApiClient::with_base_url(server.uri()); + let sync = signer(); + let main = signer(); + let outcome = run_migration_list( + MIGRATIONS, + "suborg-1", + SyncFactor(&sync), + Some(MainFactor(&main)), + &api, + BedrockEnvironment::Staging, + ) + .await + .unwrap(); + + assert_eq!(outcome, TurnkeyMigrationOutcome::Completed); + } + + #[tokio::test] + async fn apple_audiences_defers_without_main_factor_and_never_writes() { + let server = MockServer::start().await; + mount_list_users( + &server, + main_user(&["org.worldcoin.insight.staging"], "sub-apple"), + ) + .await; + Mock::given(method("POST")) + .and(path(CREATE_OAUTH_PATH)) + .respond_with(ResponseTemplate::new(500)) + .expect(0) + .mount(&server) + .await; + + let api = TurnkeyApiClient::with_base_url(server.uri()); + let sync = signer(); + let outcome = run_migration_list( + MIGRATIONS, + "suborg-1", + SyncFactor(&sync), + None, + &api, + BedrockEnvironment::Staging, + ) + .await + .unwrap(); + + let TurnkeyMigrationOutcome::MainFactorRequired { pending } = outcome else { + panic!("expected MainFactorRequired, got {outcome:?}"); + }; + assert_eq!(pending.len(), 1); + } +} diff --git a/bedrock/src/lib.rs b/bedrock/src/lib.rs index 88b30acb..7057ad6b 100644 --- a/bedrock/src/lib.rs +++ b/bedrock/src/lib.rs @@ -37,7 +37,8 @@ pub mod nitro_enclave; // Re-export commonly used primitives at the crate root for convenience pub use primitives::{ - AuthenticatedHttpClient, HttpError, HttpMethod, UserAgent, UserAgentBuilder, + AuthenticatedHttpClient, HttpError, HttpMethod, KeypairSigner, KeypairSignerError, + UserAgent, UserAgentBuilder, }; /// Key management for World App. diff --git a/bedrock/src/primitives/mod.rs b/bedrock/src/primitives/mod.rs index 2381d7b0..ff39643e 100644 --- a/bedrock/src/primitives/mod.rs +++ b/bedrock/src/primitives/mod.rs @@ -10,6 +10,7 @@ use std::str::FromStr; // Re-export HTTP client types for external use pub use http_client::{AuthenticatedHttpClient, HttpError, HttpMethod}; +pub use signer::{KeypairSigner, KeypairSignerError, P256Signer}; pub use user_agent::{UserAgent, UserAgentBuilder}; /// The prefix for Bedrock-generated transactions. @@ -77,6 +78,9 @@ pub mod contracts; /// Network Time Provider for sensitive operations that require a clock with no skew pub mod ntp; +/// Foreign-implemented keypair signer used for Turnkey API request stamping. +pub mod signer; + /// Supported blockchain networks for Bedrock operations #[derive(Debug, Clone, Copy, PartialEq, Eq, uniffi::Enum)] #[repr(u32)] diff --git a/bedrock/src/primitives/signer.rs b/bedrock/src/primitives/signer.rs new file mode 100644 index 00000000..da4ae35c --- /dev/null +++ b/bedrock/src/primitives/signer.rs @@ -0,0 +1,210 @@ +//! Foreign-implemented keypair signer. +//! +//! Bedrock never handles the persistent private key material: the host keeps the +//! key in secure storage — a hardware-backed secure enclave (iOS Secure Enclave / +//! Android Keystore / StrongBox) is recommended — and exposes a [`KeypairSigner`] +//! that signs pre-computed digests on Bedrock's behalf. + +use std::sync::Arc; + +/// A foreign-implemented signer backed by an elliptic-curve keypair. +/// +/// The key stays in the host's secure storage (ideally a hardware-backed secure +/// enclave) and never crosses the FFI boundary: Bedrock hands the signer a digest +/// and receives a signature plus the public key. +/// +/// This is a **curve-agnostic** signing primitive. +/// +/// # Contract +/// Implementations MUST: +/// - [`Self::public_key`]: return the compressed public key as a **SEC1-encoded point** +/// (compressed `0x02`/`0x03`). +/// - [`Self::sign_digest`]: sign the provided pre-computed digest **directly** (no +/// additional hashing) with ECDSA over the key's curve, returning a +/// **DER-encoded, low-S-normalized** signature. Synchronous. +#[uniffi::export(with_foreign)] +pub trait KeypairSigner: Send + Sync { + /// Returns the public key as a SEC1-encoded point. + /// + /// # Errors + /// Returns [`KeypairSignerError`] if the key material is unavailable or invalid. + fn public_key(&self) -> Result, KeypairSignerError>; + + /// Signs a pre-computed digest with the private key. Signature MUST be normalized (low S). + /// + /// # Errors + /// Returns [`KeypairSignerError`] if signing is rejected or the key is unavailable. + fn sign_digest(&self, digest: Vec) -> Result, KeypairSignerError>; +} + +/// Errors returned by a [`KeypairSigner`] implementation. +#[crate::bedrock_error] +pub enum KeypairSignerError { + /// The signing key is unavailable or the operation was rejected (for example + /// secure storage is locked or the user cancelled a prompt). + #[error("signing key unavailable or operation rejected")] + Unavailable, + /// The key material is malformed or of an unexpected type. + #[error("invalid signing key material")] + InvalidKey, + /// The public key returned by the signer is malformed: wrong length, not a + /// valid compressed SEC1 encoding, or not a point on the P-256 curve. + #[error("invalid public key: {error_message}")] + InvalidPublicKey { + /// Human-readable reason the key was rejected. + error_message: String, + }, +} + +/// Converts unexpected UniFFI callback errors (foreign panics/exceptions) into a +/// typed error instead of unwinding across the FFI boundary. +impl From for KeypairSignerError { + fn from(error: uniffi::UnexpectedUniFFICallbackError) -> Self { + Self::Generic { + error_message: error.to_string(), + } + } +} + +/// A [`KeypairSigner`] pinned to the **P-256** curve. +/// +/// Its public key is fetched once and validated as a compressed SEC1 P-256 point, +/// so downstream stamping can reuse it without re-querying or re-validating. +#[derive(Clone, uniffi::Object)] +pub struct P256Signer { + signer: Arc, + /// Compressed SEC1 public key, hex-encoded + public_key_hex: String, +} + +#[crate::bedrock_export] +impl P256Signer { + /// Validates `signer`'s public key and wraps it for reuse. + /// + /// Foreign callers construct this **before** invoking migration APIs, so an + /// invalid key fails here with a clear [`KeypairSignerError`] rather than an + /// opaque error from a later call. The public key is fetched once and checked + /// to be a 33-byte compressed SEC1 P-256 point; the resulting handle can be + /// reused across calls (e.g. re-invoking with the main factor). + /// + /// # Errors + /// - [`KeypairSignerError`] if the signer cannot produce its public key. + /// - [`KeypairSignerError::InvalidPublicKey`] if the key is not a 33-byte + /// compressed SEC1 encoding or not a valid point on the P-256 curve. + #[uniffi::constructor] + pub fn verify(signer: Arc) -> Result { + let bytes = signer.public_key()?; + if bytes.len() != Self::COMPRESSED_PUBLIC_KEY_LEN { + return Err(KeypairSignerError::InvalidPublicKey { + error_message: format!( + "expected {}-byte compressed SEC1 key, got {} bytes", + Self::COMPRESSED_PUBLIC_KEY_LEN, + bytes.len() + ), + }); + } + // enforces a valid compressed SEC1 encoding and an on-curve point. + p256::PublicKey::from_sec1_bytes(&bytes).map_err(|error| { + KeypairSignerError::InvalidPublicKey { + error_message: format!("not a valid P-256 point: {error}"), + } + })?; + Ok(Self { + signer, + public_key_hex: hex::encode(&bytes), + }) + } +} + +impl P256Signer { + /// Length in bytes of a compressed SEC1 P-256 public key. + const COMPRESSED_PUBLIC_KEY_LEN: usize = 33; + + /// The validated compressed SEC1 public key, hex-encoded. + #[must_use] + pub fn public_key_hex(&self) -> &str { + &self.public_key_hex + } + + /// Signs a pre-computed 32-byte digest with the underlying signer. + /// + /// # Errors + /// Returns [`KeypairSignerError`] if signing is rejected or the key is + /// unavailable. + pub fn sign_digest(&self, digest: Vec) -> Result, KeypairSignerError> { + self.signer.sign_digest(digest) + } +} + +#[cfg(test)] +mod p256_signer_tests { + use super::*; + use p256::elliptic_curve::sec1::ToEncodedPoint; + + /// A signer that returns canned public-key bytes, for exercising validation + /// paths. `None` makes [`KeypairSigner::public_key`] fail. + struct MockSigner { + public_key: Option>, + } + + impl KeypairSigner for MockSigner { + fn public_key(&self) -> Result, KeypairSignerError> { + self.public_key + .clone() + .ok_or(KeypairSignerError::Unavailable) + } + fn sign_digest(&self, _digest: Vec) -> Result, KeypairSignerError> { + Ok(Vec::new()) + } + } + + fn valid_compressed_key() -> Vec { + p256::SecretKey::from_slice(&[1u8; 32]) + .expect("valid scalar") + .public_key() + .to_encoded_point(true) + .as_bytes() + .to_vec() + } + + #[test] + fn accepts_valid_compressed_key() { + let key = valid_compressed_key(); + let verified = P256Signer::verify(Arc::new(MockSigner { + public_key: Some(key.clone()), + })) + .unwrap(); + assert_eq!(verified.public_key_hex(), hex::encode(&key)); + } + + #[test] + fn rejects_wrong_length_key() { + let result = P256Signer::verify(Arc::new(MockSigner { + public_key: Some(vec![0x02; 32]), + })); + assert!(matches!( + result, + Err(KeypairSignerError::InvalidPublicKey { .. }) + )); + } + + #[test] + fn rejects_invalid_encoding() { + // Correct length, but an invalid SEC1 tag byte (not 0x02/0x03). + let mut key = valid_compressed_key(); + key[0] = 0x01; + let result = P256Signer::verify(Arc::new(MockSigner { + public_key: Some(key), + })); + assert!(matches!( + result, + Err(KeypairSignerError::InvalidPublicKey { .. }) + )); + } + + #[test] + fn propagates_public_key_error() { + let result = P256Signer::verify(Arc::new(MockSigner { public_key: None })); + assert!(matches!(result, Err(KeypairSignerError::Unavailable))); + } +}