From b7ad941b1fc1418484fec810f6cbade64b95034c Mon Sep 17 00:00:00 2001 From: riley-oai Date: Mon, 7 Sep 2026 18:20:52 +0000 Subject: [PATCH] Add user-verification provider abstractions and RPC adapters (#43547) ## What changed - Introduce `codex-user-verification` with a provider interface for credential status, creation, deletion, and challenge signing. Include typed errors, shared cancellation guards, and hashed account-user key namespaces. - Add P-256 public-key encoding as unpadded base64url SPKI DER, derive credential IDs from its SHA-256 digest, and redact proof fields in debug output. - Add app-server helpers to validate challenge and display-text bounds and map provider errors to typed RPC errors without exposing provider diagnostics. The platform implementation reports verification as unsupported. App-server requests still return typed unavailability, with the message updated to mention build or account availability. ## Testing Add tests for credential encoding and signature verification, invalid curve points, cancellation across guard clones, stable and distinct account namespaces, and invalid challenge or display values. Update the app-server unavailability test for the revised message. GitOrigin-RevId: fe4a4eb37c68d7fdc547704e76aa257abf3e9c81 --- codex-rs/Cargo.lock | 12 ++ codex-rs/Cargo.toml | 2 + codex-rs/app-server/Cargo.toml | 1 + codex-rs/app-server/src/user_verification.rs | 20 +-- .../src/user_verification_adapter.rs | 130 ++++++++++++++++++ .../src/user_verification_adapter_tests.rs | 33 +++++ .../tests/suite/v2/user_verification.rs | 2 +- codex-rs/user-verification/BUILD.bazel | 6 + codex-rs/user-verification/Cargo.toml | 21 +++ codex-rs/user-verification/src/credential.rs | 86 ++++++++++++ .../user-verification/src/credential_tests.rs | 43 ++++++ codex-rs/user-verification/src/error.rs | 42 ++++++ codex-rs/user-verification/src/guard.rs | 51 +++++++ codex-rs/user-verification/src/guard_tests.rs | 32 +++++ .../user-verification/src/key_namespace.rs | 25 ++++ .../src/key_namespace_tests.rs | 12 ++ codex-rs/user-verification/src/lib.rs | 70 ++++++++++ codex-rs/user-verification/src/unsupported.rs | 53 +++++++ 18 files changed, 625 insertions(+), 16 deletions(-) create mode 100644 codex-rs/app-server/src/user_verification_adapter.rs create mode 100644 codex-rs/app-server/src/user_verification_adapter_tests.rs create mode 100644 codex-rs/user-verification/BUILD.bazel create mode 100644 codex-rs/user-verification/Cargo.toml create mode 100644 codex-rs/user-verification/src/credential.rs create mode 100644 codex-rs/user-verification/src/credential_tests.rs create mode 100644 codex-rs/user-verification/src/error.rs create mode 100644 codex-rs/user-verification/src/guard.rs create mode 100644 codex-rs/user-verification/src/guard_tests.rs create mode 100644 codex-rs/user-verification/src/key_namespace.rs create mode 100644 codex-rs/user-verification/src/key_namespace_tests.rs create mode 100644 codex-rs/user-verification/src/lib.rs create mode 100644 codex-rs/user-verification/src/unsupported.rs diff --git a/codex-rs/Cargo.lock b/codex-rs/Cargo.lock index 45fca1b150..c42f32f73e 100644 --- a/codex-rs/Cargo.lock +++ b/codex-rs/Cargo.lock @@ -2225,6 +2225,7 @@ dependencies = [ "codex-state", "codex-thread-store", "codex-tools", + "codex-user-verification", "codex-utils-absolute-path", "codex-utils-cargo-bin", "codex-utils-cli", @@ -4769,6 +4770,17 @@ dependencies = [ "windows-sys 0.52.0", ] +[[package]] +name = "codex-user-verification" +version = "0.0.0" +dependencies = [ + "base64 0.22.1", + "p256", + "pretty_assertions", + "sha2 0.10.9", + "thiserror 2.0.18", +] + [[package]] name = "codex-utils-absolute-path" version = "0.0.0" diff --git a/codex-rs/Cargo.toml b/codex-rs/Cargo.toml index 3c9156f088..fa9f3410e6 100644 --- a/codex-rs/Cargo.toml +++ b/codex-rs/Cargo.toml @@ -101,6 +101,7 @@ members = [ "otel", "otel-trace-websocket", "tui", + "user-verification", "tools", "v8-poc", "websocket-client", @@ -295,6 +296,7 @@ codex-utils-sleep-inhibitor = { path = "utils/sleep-inhibitor" } codex-utils-stream-parser = { path = "utils/stream-parser" } codex-utils-string = { path = "utils/string" } codex-utils-template = { path = "utils/template" } +codex-user-verification = { path = "user-verification" } codex-v8-poc = { path = "v8-poc" } codex-workload-identity = { path = "workload-identity" } codex-windows-sandbox = { path = "windows-sandbox-rs" } diff --git a/codex-rs/app-server/Cargo.toml b/codex-rs/app-server/Cargo.toml index c64b15b998..3b5060f097 100644 --- a/codex-rs/app-server/Cargo.toml +++ b/codex-rs/app-server/Cargo.toml @@ -63,6 +63,7 @@ codex-shell-command = { workspace = true } codex-skills = { workspace = true } codex-skills-extension = { workspace = true } codex-utils-cli = { workspace = true } +codex-user-verification = { workspace = true } codex-utils-pty = { workspace = true } codex-backend-client = { workspace = true } codex-file-search = { workspace = true } diff --git a/codex-rs/app-server/src/user_verification.rs b/codex-rs/app-server/src/user_verification.rs index 1b3d1c16e6..6cc5f9b36d 100644 --- a/codex-rs/app-server/src/user_verification.rs +++ b/codex-rs/app-server/src/user_verification.rs @@ -1,17 +1,7 @@ -//! Dispatch boundary for experimental verification APIs. -//! Native operations are introduced separately from the public contract. +//! Local verification value conversion; provider dispatch follows in the next change. -use codex_app_server_protocol::JSONRPCErrorError; -use codex_app_server_protocol::UserVerificationErrorDetails; -use codex_app_server_protocol::UserVerificationUnavailableReason; +#[allow(dead_code)] +#[path = "user_verification_adapter.rs"] +mod adapter; -pub(crate) fn unavailable() -> JSONRPCErrorError { - JSONRPCErrorError { - code: -32603, - message: "User verification is not available in this build.".into(), - data: serde_json::to_value(UserVerificationErrorDetails::Unavailable { - reason: UserVerificationUnavailableReason::ProviderUnavailable, - }) - .ok(), - } -} +pub(crate) use adapter::unavailable; diff --git a/codex-rs/app-server/src/user_verification_adapter.rs b/codex-rs/app-server/src/user_verification_adapter.rs new file mode 100644 index 0000000000..b83d2e7266 --- /dev/null +++ b/codex-rs/app-server/src/user_verification_adapter.rs @@ -0,0 +1,130 @@ +//! Validates local verification requests and maps native results into the app-server API. +//! Native diagnostics stay private; clients receive bounded input errors and typed reasons. + +use base64::Engine as _; +use base64::engine::general_purpose::URL_SAFE_NO_PAD; +use codex_app_server_protocol as rpc; +use codex_user_verification as native; + +pub(super) fn validate( + params: rpc::UserVerificationVerifyParams, +) -> Result { + let invalid = || { + error( + rpc::UserVerificationErrorDetails::InvalidRequest { + reason: rpc::UserVerificationInvalidRequestReason::InvalidParams, + }, + "Invalid verification challenge or display text.", + ) + }; + if params.challenge.len() > 5462 + || params.title.is_empty() + || params.title.len() > 256 + || params.description.len() > 4096 + { + return Err(invalid()); + } + let challenge = URL_SAFE_NO_PAD + .decode(¶ms.challenge) + .map_err(|_| invalid())?; + if challenge.is_empty() || challenge.len() > 4096 { + return Err(invalid()); + } + Ok(native::UserVerificationRequest { + challenge, + title: params.title, + description: params.description, + }) +} + +pub(crate) fn unavailable() -> rpc::JSONRPCErrorError { + error( + rpc::UserVerificationErrorDetails::Unavailable { + reason: rpc::UserVerificationUnavailableReason::ProviderUnavailable, + }, + "User verification is not available in this build or account.", + ) +} + +pub(super) fn unavailable_reason( + reason: native::UserVerificationUnavailableReason, +) -> rpc::UserVerificationUnavailableReason { + match reason { + native::UserVerificationUnavailableReason::CredentialMissing => { + rpc::UserVerificationUnavailableReason::CredentialMissing + } + native::UserVerificationUnavailableReason::BiometricsUnavailable => { + rpc::UserVerificationUnavailableReason::BiometricsUnavailable + } + native::UserVerificationUnavailableReason::ProviderUnavailable => { + rpc::UserVerificationUnavailableReason::ProviderUnavailable + } + } +} + +pub(super) fn native_error(value: native::UserVerificationError) -> rpc::JSONRPCErrorError { + let (details, message) = match value { + native::UserVerificationError::Unavailable { reason, .. } => ( + rpc::UserVerificationErrorDetails::Unavailable { + reason: unavailable_reason(reason), + }, + "Local user verification is unavailable.", + ), + native::UserVerificationError::Cancelled { reason, .. } => ( + rpc::UserVerificationErrorDetails::Cancelled { + reason: match reason { + native::UserVerificationCancellationReason::UserCancelled => { + rpc::UserVerificationCancellationReason::UserCancelled + } + native::UserVerificationCancellationReason::Interrupted => { + rpc::UserVerificationCancellationReason::Interrupted + } + }, + }, + "User verification was cancelled.", + ), + native::UserVerificationError::Failed { reason, .. } => ( + rpc::UserVerificationErrorDetails::Failed { + reason: match reason { + native::UserVerificationFailureReason::AuthenticationFailed => { + rpc::UserVerificationFailureReason::AuthenticationFailed + } + native::UserVerificationFailureReason::Timeout => { + rpc::UserVerificationFailureReason::Timeout + } + native::UserVerificationFailureReason::ProviderError => { + rpc::UserVerificationFailureReason::ProviderError + } + }, + }, + "User verification failed.", + ), + }; + error(details, message) +} + +pub(super) fn error( + data: rpc::UserVerificationErrorDetails, + message: &str, +) -> rpc::JSONRPCErrorError { + rpc::JSONRPCErrorError { + code: if matches!( + data, + rpc::UserVerificationErrorDetails::InvalidRequest { .. } + ) { + -32602 + } else { + -32603 + }, + message: message.into(), + data: Some( + serde_json::to_value(data).unwrap_or_else( + |_| serde_json::json!({"type": "failed", "reason": "providerError"}), + ), + ), + } +} + +#[cfg(test)] +#[path = "user_verification_adapter_tests.rs"] +mod tests; diff --git a/codex-rs/app-server/src/user_verification_adapter_tests.rs b/codex-rs/app-server/src/user_verification_adapter_tests.rs new file mode 100644 index 0000000000..8dcf4fda38 --- /dev/null +++ b/codex-rs/app-server/src/user_verification_adapter_tests.rs @@ -0,0 +1,33 @@ +//! Validates the wire-to-native boundary before any provider work can start. + +use super::*; +use pretty_assertions::assert_eq; +use serde_json::json; + +#[test] +fn invalid_challenge_and_display_values_are_rejected_before_native_work() { + for (challenge, title, description) in [ + ("".into(), "Approve".into(), String::new()), + ("AQ==".into(), "Approve".into(), String::new()), + ( + URL_SAFE_NO_PAD.encode(vec![0; 4097]), + "Approve".into(), + String::new(), + ), + ("AQ".into(), String::new(), String::new()), + ("AQ".into(), "é".repeat(129), String::new()), + ("AQ".into(), "Approve".into(), "x".repeat(4097)), + ] { + let error = validate(rpc::UserVerificationVerifyParams { + challenge, + title, + description, + }) + .err() + .unwrap(); + assert_eq!( + error.data, + Some(json!({"type": "invalidRequest", "reason": "invalidParams"})) + ); + } +} diff --git a/codex-rs/app-server/tests/suite/v2/user_verification.rs b/codex-rs/app-server/tests/suite/v2/user_verification.rs index 74afd19a6a..1da2477f05 100644 --- a/codex-rs/app-server/tests/suite/v2/user_verification.rs +++ b/codex-rs/app-server/tests/suite/v2/user_verification.rs @@ -68,7 +68,7 @@ async fn user_verification_without_provider_returns_typed_unavailability() -> Re response.error, JSONRPCErrorError { code: -32603, - message: "User verification is not available in this build.".into(), + message: "User verification is not available in this build or account.".into(), data: Some(json!({"type": "unavailable", "reason": "providerUnavailable"})), } ); diff --git a/codex-rs/user-verification/BUILD.bazel b/codex-rs/user-verification/BUILD.bazel new file mode 100644 index 0000000000..4589f827f4 --- /dev/null +++ b/codex-rs/user-verification/BUILD.bazel @@ -0,0 +1,6 @@ +load("//:defs.bzl", "codex_rust_crate") + +codex_rust_crate( + name = "user-verification", + crate_name = "codex_user_verification", +) diff --git a/codex-rs/user-verification/Cargo.toml b/codex-rs/user-verification/Cargo.toml new file mode 100644 index 0000000000..9907adf25a --- /dev/null +++ b/codex-rs/user-verification/Cargo.toml @@ -0,0 +1,21 @@ +[package] +name = "codex-user-verification" +version.workspace = true +edition.workspace = true +license.workspace = true + +[lints] +workspace = true + +[dependencies] +base64 = { workspace = true } +p256 = { version = "0.13", default-features = false, features = ["arithmetic", "pkcs8", "std"] } +sha2 = { workspace = true } +thiserror = { workspace = true } + +[dev-dependencies] +p256 = { version = "0.13", features = ["ecdsa"] } +pretty_assertions = { workspace = true } + +[lib] +doctest = false diff --git a/codex-rs/user-verification/src/credential.rs b/codex-rs/user-verification/src/credential.rs new file mode 100644 index 0000000000..7b2ea925a3 --- /dev/null +++ b/codex-rs/user-verification/src/credential.rs @@ -0,0 +1,86 @@ +//! Public credential encoding and results; private keys never cross this boundary. + +use crate::UserVerificationError; +use crate::UserVerificationFailureReason; +use crate::UserVerificationUnavailableReason; +use base64::Engine as _; +use base64::engine::general_purpose::URL_SAFE_NO_PAD; +use p256::pkcs8::EncodePublicKey as _; +use sha2::Digest as _; +use sha2::Sha256; + +/// Validated display text and exact challenge bytes supplied by the trusted calling UI. +#[derive(Clone)] +pub struct UserVerificationRequest { + pub challenge: Vec, + pub title: String, + pub description: String, +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct UserVerificationKeyInfo { + pub credential_id: String, + pub algorithm: String, + /// Unpadded base64url of the P-256 SubjectPublicKeyInfo DER encoding. + pub public_key: String, +} + +impl UserVerificationKeyInfo { + pub fn from_sec1_public_key(bytes: &[u8]) -> Result { + let public_key = p256::PublicKey::from_sec1_bytes(bytes) + .map_err(|_| invalid_public_key())? + .to_public_key_der() + .map_err(|_| invalid_public_key())?; + let bytes = public_key.as_bytes(); + Ok(Self { + credential_id: URL_SAFE_NO_PAD.encode(Sha256::digest(bytes)), + algorithm: "ecdsaP256Sha256X962".to_string(), + public_key: URL_SAFE_NO_PAD.encode(bytes), + }) + } +} + +fn invalid_public_key() -> UserVerificationError { + UserVerificationError::Failed { + reason: UserVerificationFailureReason::ProviderError, + message: "could not encode the user-verification public key".to_string(), + } +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct UserVerificationKeyCreation { + pub created: bool, + pub credential: UserVerificationKeyInfo, +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct UserVerificationKeyDeletion { + pub deleted_credential_id: Option, +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct UserVerificationStatus { + pub credential: Option, + pub unavailable_reason: Option, + pub unavailable_message: Option, +} + +#[derive(Clone, PartialEq, Eq)] +pub struct UserVerificationProof { + pub credential_id: String, + /// Unpadded base64url of an ASN.1 DER ECDSA signature over the challenge, hashed once. + pub signature: String, +} + +impl std::fmt::Debug for UserVerificationProof { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("UserVerificationProof") + .field("credential_id", &"[REDACTED]") + .field("signature", &"[REDACTED]") + .finish() + } +} + +#[cfg(test)] +#[path = "credential_tests.rs"] +mod tests; diff --git a/codex-rs/user-verification/src/credential_tests.rs b/codex-rs/user-verification/src/credential_tests.rs new file mode 100644 index 0000000000..4aa3326366 --- /dev/null +++ b/codex-rs/user-verification/src/credential_tests.rs @@ -0,0 +1,43 @@ +//! Checks that the exported credential interoperates with DER-based service verification. + +use super::*; +use p256::ecdsa::Signature; +use p256::ecdsa::SigningKey; +use p256::ecdsa::VerifyingKey; +use p256::ecdsa::signature::Signer as _; +use p256::ecdsa::signature::Verifier as _; +use p256::pkcs8::DecodePublicKey as _; +use pretty_assertions::assert_eq; + +#[test] +fn exported_spki_verifies_the_exact_challenge_signature() { + let key = SigningKey::from_bytes((&[7_u8; 32]).into()).expect("valid signing key"); + let info = UserVerificationKeyInfo::from_sec1_public_key( + key.verifying_key() + .to_encoded_point(/*compress*/ false) + .as_bytes(), + ) + .expect("encode public key"); + let der = URL_SAFE_NO_PAD.decode(&info.public_key).expect("base64url"); + let verifier = VerifyingKey::from_public_key_der(&der).expect("valid SPKI DER"); + let challenge = b"the exact server-issued challenge"; + let signature: Signature = key.sign(challenge); + let signature = Signature::from_der(signature.to_der().as_bytes()).expect("DER signature"); + verifier + .verify(challenge, &signature) + .expect("valid signature"); + assert!(verifier.verify(b"different challenge", &signature).is_err()); + assert_eq!( + info, + UserVerificationKeyInfo { + credential_id: URL_SAFE_NO_PAD.encode(Sha256::digest(&der)), + algorithm: "ecdsaP256Sha256X962".to_string(), + public_key: URL_SAFE_NO_PAD.encode(der), + } + ); +} + +#[test] +fn invalid_curve_point_is_rejected() { + assert!(UserVerificationKeyInfo::from_sec1_public_key(&[4_u8; 65]).is_err()); +} diff --git a/codex-rs/user-verification/src/error.rs b/codex-rs/user-verification/src/error.rs new file mode 100644 index 0000000000..88f611e152 --- /dev/null +++ b/codex-rs/user-verification/src/error.rs @@ -0,0 +1,42 @@ +//! Stable error categories, with provider diagnostics kept outside public error messages. + +use thiserror::Error; + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum UserVerificationUnavailableReason { + CredentialMissing, + BiometricsUnavailable, + ProviderUnavailable, +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum UserVerificationCancellationReason { + UserCancelled, + Interrupted, +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum UserVerificationFailureReason { + AuthenticationFailed, + Timeout, + ProviderError, +} + +#[derive(Clone, Debug, Error, PartialEq, Eq)] +pub enum UserVerificationError { + #[error("user verification is unavailable: {message}")] + Unavailable { + reason: UserVerificationUnavailableReason, + message: String, + }, + #[error("user verification was cancelled: {message}")] + Cancelled { + reason: UserVerificationCancellationReason, + message: String, + }, + #[error("user verification failed: {message}")] + Failed { + reason: UserVerificationFailureReason, + message: String, + }, +} diff --git a/codex-rs/user-verification/src/guard.rs b/codex-rs/user-verification/src/guard.rs new file mode 100644 index 0000000000..e020a9dd24 --- /dev/null +++ b/codex-rs/user-verification/src/guard.rs @@ -0,0 +1,51 @@ +//! Cancellation and caller-owned identity checks for queued native operations. + +use crate::UserVerificationCancellationReason; +use crate::UserVerificationError; +use std::sync::Arc; +use std::sync::atomic::AtomicBool; +use std::sync::atomic::Ordering; + +/// Invalidates queued work and suppresses late results. Native providers observe cancellation +/// during authentication and request dismissal of their active OS prompt. +#[derive(Clone, Default)] +pub struct UserVerificationRequestGuard { + cancelled: Arc, + activity_check: Option bool + Send + Sync>>, +} + +impl UserVerificationRequestGuard { + /// The callback checks a captured identity; it must be nonblocking and must not prompt. + pub fn with_activity_check(activity_check: impl Fn() -> bool + Send + Sync + 'static) -> Self { + Self { + cancelled: Arc::default(), + activity_check: Some(Arc::new(activity_check)), + } + } + + pub fn cancel(&self) { + self.cancelled.store(/*val*/ true, Ordering::Release); + } + + pub fn is_active(&self) -> bool { + if self.activity_check.as_ref().is_some_and(|check| !check()) { + self.cancel(); + } + !self.cancelled.load(Ordering::Acquire) + } + + pub fn check(&self) -> Result<(), UserVerificationError> { + if self.is_active() { + Ok(()) + } else { + Err(UserVerificationError::Cancelled { + reason: UserVerificationCancellationReason::Interrupted, + message: "the verification operation is no longer active".to_string(), + }) + } + } +} + +#[cfg(test)] +#[path = "guard_tests.rs"] +mod tests; diff --git a/codex-rs/user-verification/src/guard_tests.rs b/codex-rs/user-verification/src/guard_tests.rs new file mode 100644 index 0000000000..920d62edae --- /dev/null +++ b/codex-rs/user-verification/src/guard_tests.rs @@ -0,0 +1,32 @@ +//! Checks cancellation shared across queued operations and captured-identity callbacks. + +use super::*; +use pretty_assertions::assert_eq; + +#[test] +fn identity_change_permanently_cancels_all_guard_clones() { + let identity_matches = Arc::new(AtomicBool::new(/*v*/ true)); + let activity = Arc::clone(&identity_matches); + let guard = + UserVerificationRequestGuard::with_activity_check(move || activity.load(Ordering::Acquire)); + let queued = guard.clone(); + assert!(queued.check().is_ok()); + identity_matches.store(/*val*/ false, Ordering::Release); + assert_eq!( + queued.check(), + Err(UserVerificationError::Cancelled { + reason: UserVerificationCancellationReason::Interrupted, + message: "the verification operation is no longer active".to_string(), + }) + ); + identity_matches.store(/*val*/ true, Ordering::Release); + assert!(!guard.is_active()); +} + +#[test] +fn cancelling_one_clone_invalidates_queued_work() { + let guard = UserVerificationRequestGuard::default(); + let queued = guard.clone(); + guard.cancel(); + assert!(!queued.is_active()); +} diff --git a/codex-rs/user-verification/src/key_namespace.rs b/codex-rs/user-verification/src/key_namespace.rs new file mode 100644 index 0000000000..6677147906 --- /dev/null +++ b/codex-rs/user-verification/src/key_namespace.rs @@ -0,0 +1,25 @@ +//! Keychain labels isolate account-user identities within the fixed plugin-service scope. + +use base64::Engine as _; +use base64::engine::general_purpose::URL_SAFE_NO_PAD; +use sha2::Digest as _; +use sha2::Sha256; + +/// An opaque account-user namespace. App-server authenticates and selects the identity. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct UserVerificationKeyNamespace { + pub(crate) label: String, +} + +impl UserVerificationKeyNamespace { + pub fn new(account_user_id: &str) -> Self { + let identity = URL_SAFE_NO_PAD.encode(Sha256::digest(account_user_id.as_bytes())); + Self { + label: format!("com.openai.codex.user-verification.plugin-service.v1.{identity}"), + } + } +} + +#[cfg(test)] +#[path = "key_namespace_tests.rs"] +mod tests; diff --git a/codex-rs/user-verification/src/key_namespace_tests.rs b/codex-rs/user-verification/src/key_namespace_tests.rs new file mode 100644 index 0000000000..d28d0c5020 --- /dev/null +++ b/codex-rs/user-verification/src/key_namespace_tests.rs @@ -0,0 +1,12 @@ +//! Account-user identities share one service scope without exposing raw identifiers. + +use super::*; +use pretty_assertions::assert_eq; + +#[test] +fn namespaces_are_stable_and_separate_accounts() { + let first = UserVerificationKeyNamespace::new("account-user-one"); + assert_eq!(first, UserVerificationKeyNamespace::new("account-user-one")); + assert_ne!(first, UserVerificationKeyNamespace::new("account-user-two")); + assert!(!first.label.contains("account-user-one")); +} diff --git a/codex-rs/user-verification/src/lib.rs b/codex-rs/user-verification/src/lib.rs new file mode 100644 index 0000000000..ae47fadd11 --- /dev/null +++ b/codex-rs/user-verification/src/lib.rs @@ -0,0 +1,70 @@ +//! Device credentials and signing, independent of RPC routing, UI, and backend registration. + +mod credential; +mod error; +mod guard; +mod key_namespace; +mod unsupported; + +pub use credential::UserVerificationKeyCreation; +pub use credential::UserVerificationKeyDeletion; +pub use credential::UserVerificationKeyInfo; +pub use credential::UserVerificationProof; +pub use credential::UserVerificationRequest; +pub use credential::UserVerificationStatus; +pub use error::UserVerificationCancellationReason; +pub use error::UserVerificationError; +pub use error::UserVerificationFailureReason; +pub use error::UserVerificationUnavailableReason; +pub use guard::UserVerificationRequestGuard; +pub use key_namespace::UserVerificationKeyNamespace; +use std::sync::Arc; + +/// Performs local credential operations for one captured account-user identity. +/// Implementations never perform network registration. Blocking implementations must run off +/// the async executor and check the guard after waiting, before effects, and before returning. +pub trait UserVerificationProvider: Send + Sync { + /// Reads local readiness without creating credentials or prompting for authentication. + fn status( + &self, + guard: &UserVerificationRequestGuard, + ) -> Result; + + /// Creates a protected key only if none exists. Success does not mean server enrollment. + fn ensure_key( + &self, + guard: &UserVerificationRequestGuard, + ) -> Result; + + /// Removes the local key idempotently. Backend revocation belongs to the caller. + fn delete( + &self, + guard: &UserVerificationRequestGuard, + ) -> Result; + + /// Authenticates and signs 1–4096 challenge bytes without interpreting an elicitation. + /// The caller owns approval UI, request correlation, and the captured identity check. + fn verify( + &self, + request: &UserVerificationRequest, + guard: &UserVerificationRequestGuard, + ) -> Result; +} + +/// Reports whether this build contains a native provider, independently of local readiness. +pub fn platform_supported() -> bool { + false +} + +/// Probes biometric hardware without reading credentials, prompting, or checking enrollment. +/// This performs local OS work; async callers should run it off their executor during setup. +pub fn device_supported() -> bool { + false +} + +pub fn platform_provider( + namespace: UserVerificationKeyNamespace, +) -> Arc { + let _ = namespace.label; + Arc::new(unsupported::UnsupportedProvider) +} diff --git a/codex-rs/user-verification/src/unsupported.rs b/codex-rs/user-verification/src/unsupported.rs new file mode 100644 index 0000000000..200fe7d212 --- /dev/null +++ b/codex-rs/user-verification/src/unsupported.rs @@ -0,0 +1,53 @@ +//! Typed unavailable behavior until a native provider is available for this platform. + +use crate::*; + +pub(crate) struct UnsupportedProvider; + +fn unavailable() -> UserVerificationError { + UserVerificationError::Unavailable { + reason: UserVerificationUnavailableReason::ProviderUnavailable, + message: "this platform does not support user verification".to_string(), + } +} + +impl UserVerificationProvider for UnsupportedProvider { + fn status( + &self, + guard: &UserVerificationRequestGuard, + ) -> Result { + guard.check()?; + Ok(UserVerificationStatus { + credential: None, + unavailable_reason: Some(UserVerificationUnavailableReason::ProviderUnavailable), + unavailable_message: Some( + "this platform does not support user verification".to_string(), + ), + }) + } + + fn ensure_key( + &self, + guard: &UserVerificationRequestGuard, + ) -> Result { + guard.check()?; + Err(unavailable()) + } + + fn delete( + &self, + guard: &UserVerificationRequestGuard, + ) -> Result { + guard.check()?; + Err(unavailable()) + } + + fn verify( + &self, + _request: &UserVerificationRequest, + guard: &UserVerificationRequestGuard, + ) -> Result { + guard.check()?; + Err(unavailable()) + } +}