diff --git a/codex-rs/app-server-protocol/schema/precomputed/app-server-exports-experimental.json.zst b/codex-rs/app-server-protocol/schema/precomputed/app-server-exports-experimental.json.zst index c3eee33958..bc5a1ffd96 100644 Binary files a/codex-rs/app-server-protocol/schema/precomputed/app-server-exports-experimental.json.zst and b/codex-rs/app-server-protocol/schema/precomputed/app-server-exports-experimental.json.zst differ diff --git a/codex-rs/app-server-protocol/src/export.rs b/codex-rs/app-server-protocol/src/export.rs index 7a98c22efb..7a8bd79656 100644 --- a/codex-rs/app-server-protocol/src/export.rs +++ b/codex-rs/app-server-protocol/src/export.rs @@ -59,6 +59,7 @@ const EXPERIMENTAL_CLIENT_METHOD_DEPENDENCY_TYPES: &[&str] = &[ "ThreadSearchOccurrence", "ThreadSearchTextRange", "TurnSettingsUpdateStatus", + "UserVerificationBiometricType", "UserVerificationProof", "UserVerificationCancellationReason", "UserVerificationErrorDetails", diff --git a/codex-rs/app-server-protocol/src/protocol/v2/user_verification.rs b/codex-rs/app-server-protocol/src/protocol/v2/user_verification.rs index 37a0fd820d..13d46f8796 100644 --- a/codex-rs/app-server-protocol/src/protocol/v2/user_verification.rs +++ b/codex-rs/app-server-protocol/src/protocol/v2/user_verification.rs @@ -152,6 +152,16 @@ pub struct UserVerificationVerifyParams { #[ts(export_to = "v2/")] pub struct UserVerificationVerifyResponse { pub proof: UserVerificationProof, + /// Native-reported authentication method for this signature. Older servers omit it. + /// This is approval context, not part of the signed proof. + pub biometric_type: Option, +} + +#[derive(Serialize, Deserialize, Debug, Clone, Copy, PartialEq, Eq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(rename_all = "camelCase", export_to = "v2/")] +pub enum UserVerificationBiometricType { + Fingerprint, } /// Cancels a native verification RPC issued on this connection, not an elicitation. diff --git a/codex-rs/app-server-protocol/src/protocol/v2/user_verification_tests.rs b/codex-rs/app-server-protocol/src/protocol/v2/user_verification_tests.rs index af2090cd29..4dd95d7be6 100644 --- a/codex-rs/app-server-protocol/src/protocol/v2/user_verification_tests.rs +++ b/codex-rs/app-server-protocol/src/protocol/v2/user_verification_tests.rs @@ -2,6 +2,23 @@ use super::*; use pretty_assertions::assert_eq; use serde_json::json; +#[test] +fn verification_response_accepts_older_servers_without_biometric_method() { + assert_eq!( + serde_json::from_value::(json!({ + "proof": {"credentialId": "credential", "signature": "signature"} + })) + .unwrap(), + UserVerificationVerifyResponse { + proof: UserVerificationProof { + credential_id: "credential".into(), + signature: "signature".into(), + }, + biometric_type: None, + } + ); +} + #[test] fn enrollment_response_accepts_older_servers_without_public_metadata() { for response in [ diff --git a/codex-rs/app-server/README.md b/codex-rs/app-server/README.md index 87d4f2c150..b595f77f68 100644 --- a/codex-rs/app-server/README.md +++ b/codex-rs/app-server/README.md @@ -196,6 +196,10 @@ prompt to close. The original RPC completes independently, with cannot roll back completed effects. It remains effective while a proof waits for outbound queue capacity, but cannot retract a response already enqueued. +`biometricType` reports the native method used for that successful signature when known. +It is `"fingerprint"` for a successful Touch ID protected signing operation and `null` +when unknown. Older servers omit it. It is context, not an additional signed proof field. + Canceling or resolving an elicitation does not itself stop a separate `userVerification/verify` RPC. Clients must cancel that RPC separately and discard late proofs after the approval is canceled or resolved. Only one native worker @@ -283,7 +287,7 @@ ChatGPT account identity. | `userVerification/status` | `{}` | `{credentialId, unavailableReason, unavailableMessage}` | | `userVerification/enroll` | `{}` | `{credentialId, algorithm?, publicKey?}` | | `userVerification/delete` | `{}` | `{}` | -| `userVerification/verify` | `{challenge, title, description}` | `{proof: {credentialId, signature}}` | +| `userVerification/verify` | `{challenge, title, description}` | `{proof: {credentialId, signature}, biometricType}` | | `userVerification/cancel` | `{requestId}` | `{}` | Status reads local readiness without prompting or contacting a backend. A null diff --git a/codex-rs/app-server/src/user_verification.rs b/codex-rs/app-server/src/user_verification.rs index f7522228a0..78c07223a3 100644 --- a/codex-rs/app-server/src/user_verification.rs +++ b/codex-rs/app-server/src/user_verification.rs @@ -199,6 +199,13 @@ async fn run( NativeOperation::Verify(request) => { let proof = provider.verify(&request, &guard).map_err(native_error)?; rpc::UserVerificationVerifyResponse { + biometric_type: proof.biometric_type.map(|biometric_type| { + match biometric_type { + native::UserVerificationBiometricType::Fingerprint => { + rpc::UserVerificationBiometricType::Fingerprint + } + } + }), proof: rpc::UserVerificationProof { credential_id: proof.credential_id, signature: proof.signature, diff --git a/codex-rs/app-server/src/user_verification_cancel_context_tests.rs b/codex-rs/app-server/src/user_verification_cancel_context_tests.rs index 0b571e2f0d..ab216c9dc4 100644 --- a/codex-rs/app-server/src/user_verification_cancel_context_tests.rs +++ b/codex-rs/app-server/src/user_verification_cancel_context_tests.rs @@ -82,6 +82,7 @@ async fn user_verification_cancel_remains_effective_until_proof_enqueue_and_clea let cancellation = context.cancellation.clone(); outgoing.register_request_context(context).await; let response = UserVerificationVerifyResponse { + biometric_type: None, proof: UserVerificationProof { credential_id: "AQ".into(), signature: "Ag".into(), diff --git a/codex-rs/app-server/src/user_verification_rpc_tests.rs b/codex-rs/app-server/src/user_verification_rpc_tests.rs index 0bb5200d3c..9f54295602 100644 --- a/codex-rs/app-server/src/user_verification_rpc_tests.rs +++ b/codex-rs/app-server/src/user_verification_rpc_tests.rs @@ -11,6 +11,26 @@ use serde_json::json; use std::sync::atomic::Ordering; use tokio::time::timeout; +#[tokio::test] +async fn user_verification_rpc_reports_native_method_separately_from_proof() -> Result<()> { + let mut h = Harness::new(ConnectionOrigin::Stdio, || true).await?; + h.initialize("test-local-ui", /*opt_in*/ true).await; + h.start_verify().await?; + h.provider.released.store(/*val*/ true, Ordering::Release); + let OutgoingMessage::Response(response) = h.response().await else { + panic!("successful native verification must return its proof") + }; + assert_eq!( + serde_json::to_value(response.result)?, + json!({ + "proof": {"credentialId": "credential", "signature": "signature"}, + "biometricType": "fingerprint" + }) + ); + h.shutdown().await; + Ok(()) +} + #[tokio::test] async fn user_verification_rpc_enroll_returns_public_registration_metadata() -> Result<()> { let mut h = Harness::new(ConnectionOrigin::Stdio, || true).await?; diff --git a/codex-rs/app-server/src/user_verification_test_support.rs b/codex-rs/app-server/src/user_verification_test_support.rs index ab2c292f18..137d27bae4 100644 --- a/codex-rs/app-server/src/user_verification_test_support.rs +++ b/codex-rs/app-server/src/user_verification_test_support.rs @@ -99,6 +99,7 @@ impl native::UserVerificationProvider for BlockingProvider { Ok(native::UserVerificationProof { credential_id: "credential".into(), signature: "signature".into(), + biometric_type: Some(native::UserVerificationBiometricType::Fingerprint), }) } } diff --git a/codex-rs/app-server/src/user_verification_tests.rs b/codex-rs/app-server/src/user_verification_tests.rs index 1f05402d0c..02c8f5049a 100644 --- a/codex-rs/app-server/src/user_verification_tests.rs +++ b/codex-rs/app-server/src/user_verification_tests.rs @@ -69,6 +69,7 @@ impl native::UserVerificationProvider for Provider { Ok(native::UserVerificationProof { credential_id: "credential".into(), signature: "signature".into(), + biometric_type: None, }) } } @@ -140,7 +141,7 @@ async fn signing_uses_decoded_bytes_without_a_pending_elicitation() { .unwrap(); assert_eq!( serde_json::to_value(response.payload).unwrap(), - json!({"proof": {"credentialId": "credential", "signature": "signature"}}) + json!({"proof": {"credentialId": "credential", "signature": "signature"}, "biometricType": null}) ); assert_eq!(*provider.signed.lock().unwrap(), vec![vec![1, 2, 3]]); } diff --git a/codex-rs/user-verification/src/credential.rs b/codex-rs/user-verification/src/credential.rs index 7b2ea925a3..1330e4c979 100644 --- a/codex-rs/user-verification/src/credential.rs +++ b/codex-rs/user-verification/src/credential.rs @@ -70,6 +70,13 @@ pub struct UserVerificationProof { pub credential_id: String, /// Unpadded base64url of an ASN.1 DER ECDSA signature over the challenge, hashed once. pub signature: String, + /// Method that authorized this signing operation, when the provider can establish it. + pub biometric_type: Option, +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum UserVerificationBiometricType { + Fingerprint, } impl std::fmt::Debug for UserVerificationProof { diff --git a/codex-rs/user-verification/src/lib.rs b/codex-rs/user-verification/src/lib.rs index 7b8b415342..16982b9a35 100644 --- a/codex-rs/user-verification/src/lib.rs +++ b/codex-rs/user-verification/src/lib.rs @@ -13,6 +13,7 @@ mod platform_macos; #[cfg(not(target_os = "macos"))] mod unsupported; +pub use credential::UserVerificationBiometricType; pub use credential::UserVerificationKeyCreation; pub use credential::UserVerificationKeyDeletion; pub use credential::UserVerificationKeyInfo; diff --git a/codex-rs/user-verification/src/platform_macos/provider.rs b/codex-rs/user-verification/src/platform_macos/provider.rs index a1df3604e1..b81c3d96df 100644 --- a/codex-rs/user-verification/src/platform_macos/provider.rs +++ b/codex-rs/user-verification/src/platform_macos/provider.rs @@ -2,6 +2,7 @@ use super::error; use super::key_protection; +use crate::UserVerificationBiometricType; use crate::UserVerificationError; use crate::UserVerificationFailureReason; use crate::UserVerificationKeyCreation; @@ -274,6 +275,10 @@ impl UserVerificationProvider for NativeProvider { Ok(UserVerificationProof { credential_id: credential.credential_id, signature: URL_SAFE_NO_PAD.encode(signature?), + // The key's biometric-only policy authorized this successful signature. + // Reading hardware capability before successful signing is not sufficient. + biometric_type: (unsafe { context.biometryType() } == LABiometryType::TouchID) + .then_some(UserVerificationBiometricType::Fingerprint), }) } }