Report biometric type in user verification responses (#60948)

## What changed

Add `biometricType` to `userVerification/verify` responses as context alongside the signed `proof`. Successful Touch ID protected signing reports `"fingerprint"`; an unknown method reports `null`. Responses from older servers that omit the field remain supported.

Carry the native method through the verification provider and app-server protocol, and update the experimental protocol exports and API documentation.

## Testing

Add coverage for responses without `biometricType` and for returning `"fingerprint"` separately from the proof. Update existing verification coverage to check the `null` case.

GitOrigin-RevId: c82e84a325a4d9ab1e5262e5100ffc1a043bd97e
This commit is contained in:
bdavis-oai
2026-11-12 00:44:27 +00:00
committed by copyberry
parent d7840f9f22
commit 1b7c025258
13 changed files with 77 additions and 2 deletions
@@ -59,6 +59,7 @@ const EXPERIMENTAL_CLIENT_METHOD_DEPENDENCY_TYPES: &[&str] = &[
"ThreadSearchOccurrence",
"ThreadSearchTextRange",
"TurnSettingsUpdateStatus",
"UserVerificationBiometricType",
"UserVerificationProof",
"UserVerificationCancellationReason",
"UserVerificationErrorDetails",
@@ -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<UserVerificationBiometricType>,
}
#[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.
@@ -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::<UserVerificationVerifyResponse>(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 [
+5 -1
View File
@@ -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
@@ -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,
@@ -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(),
@@ -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?;
@@ -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),
})
}
}
@@ -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]]);
}
@@ -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<UserVerificationBiometricType>,
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum UserVerificationBiometricType {
Fingerprint,
}
impl std::fmt::Debug for UserVerificationProof {
+1
View File
@@ -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;
@@ -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),
})
}
}