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
This commit is contained in:
riley-oai
2026-09-07 18:20:52 +00:00
committed by copyberry
parent 4f1a2bb5ff
commit b7ad941b1f
18 changed files with 625 additions and 16 deletions

12
codex-rs/Cargo.lock generated
View File

@@ -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"

View File

@@ -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" }

View File

@@ -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 }

View File

@@ -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;

View File

@@ -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<native::UserVerificationRequest, rpc::JSONRPCErrorError> {
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(&params.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;

View File

@@ -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"}))
);
}
}

View File

@@ -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"})),
}
);

View File

@@ -0,0 +1,6 @@
load("//:defs.bzl", "codex_rust_crate")
codex_rust_crate(
name = "user-verification",
crate_name = "codex_user_verification",
)

View File

@@ -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

View File

@@ -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<u8>,
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<Self, UserVerificationError> {
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<String>,
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct UserVerificationStatus {
pub credential: Option<UserVerificationKeyInfo>,
pub unavailable_reason: Option<UserVerificationUnavailableReason>,
pub unavailable_message: Option<String>,
}
#[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;

View File

@@ -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());
}

View File

@@ -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,
},
}

View File

@@ -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<AtomicBool>,
activity_check: Option<Arc<dyn Fn() -> 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;

View File

@@ -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());
}

View File

@@ -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;

View File

@@ -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"));
}

View File

@@ -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<UserVerificationStatus, UserVerificationError>;
/// Creates a protected key only if none exists. Success does not mean server enrollment.
fn ensure_key(
&self,
guard: &UserVerificationRequestGuard,
) -> Result<UserVerificationKeyCreation, UserVerificationError>;
/// Removes the local key idempotently. Backend revocation belongs to the caller.
fn delete(
&self,
guard: &UserVerificationRequestGuard,
) -> Result<UserVerificationKeyDeletion, UserVerificationError>;
/// Authenticates and signs 14096 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<UserVerificationProof, UserVerificationError>;
}
/// 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<dyn UserVerificationProvider> {
let _ = namespace.label;
Arc::new(unsupported::UnsupportedProvider)
}

View File

@@ -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<UserVerificationStatus, UserVerificationError> {
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<UserVerificationKeyCreation, UserVerificationError> {
guard.check()?;
Err(unavailable())
}
fn delete(
&self,
guard: &UserVerificationRequestGuard,
) -> Result<UserVerificationKeyDeletion, UserVerificationError> {
guard.check()?;
Err(unavailable())
}
fn verify(
&self,
_request: &UserVerificationRequest,
guard: &UserVerificationRequestGuard,
) -> Result<UserVerificationProof, UserVerificationError> {
guard.check()?;
Err(unavailable())
}
}