Return public key metadata from user verification enrollment (#44877)

## Why

The trusted UI host needs the local credential's public metadata to complete backend registration. `userVerification/enroll` previously returned only `credentialId`.

## What changed

- Return `algorithm` and `publicKey` for newly created or reused credentials. The algorithm is `ecdsaP256Sha256X962`; the public key is unpadded base64url SPKI-DER.
- Keep both fields optional in the protocol for compatibility with older app-servers, while current servers populate both.
- Document caller-owned backend registration and revocation, including checking metadata, signing an enrollment challenge with `userVerification/verify`, matching credential IDs, and preserving the authenticated account throughout registration.

## Testing

Add protocol coverage for absent or null metadata and populated-response round trips. Add an RPC assertion for enrollment metadata and extend the local enrollment test to check metadata when creating and reusing a key.

GitOrigin-RevId: f0726e8c430e27559e1a01ba2ea635993cbeba09
This commit is contained in:
riley-oai
2026-09-11 17:23:29 +00:00
committed by copyberry
parent 2c9e1a5775
commit 7b491281c8
8 changed files with 89 additions and 9 deletions

View File

@@ -109,11 +109,19 @@ pub struct UserVerificationStatusResponse {
#[ts(export_to = "v2/")]
pub struct UserVerificationEnrollParams {}
/// Public metadata for a created or reused local credential. The caller completes
/// backend registration; this response does not establish server enrollment.
/// Older app-servers omit the metadata fields; callers must check both before registration.
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)]
#[serde(rename_all = "camelCase")]
#[ts(export_to = "v2/")]
pub struct UserVerificationEnrollResponse {
pub credential_id: String,
// TODO: Make both metadata fields required after the experimental/alpha rollout
// guarantees them on the oldest supported app-server version.
pub algorithm: Option<String>,
/// Unpadded base64url of the SubjectPublicKeyInfo DER encoding.
pub public_key: Option<String>,
}
#[derive(Serialize, Deserialize, Debug, Default, Clone, PartialEq, Eq, JsonSchema, TS)]

View File

@@ -2,6 +2,35 @@ use super::*;
use pretty_assertions::assert_eq;
use serde_json::json;
#[test]
fn enrollment_response_accepts_older_servers_without_public_metadata() {
for response in [
json!({"credentialId": "credential"}),
json!({"credentialId": "credential", "algorithm": null, "publicKey": null}),
] {
assert_eq!(
serde_json::from_value::<UserVerificationEnrollResponse>(response).unwrap(),
UserVerificationEnrollResponse {
credential_id: "credential".into(),
algorithm: None,
public_key: None,
}
);
}
let response = UserVerificationEnrollResponse {
credential_id: "credential".into(),
algorithm: Some("ecdsaP256Sha256X962".into()),
public_key: Some("public-key".into()),
};
assert_eq!(
serde_json::from_value::<UserVerificationEnrollResponse>(
serde_json::to_value(&response).unwrap()
)
.unwrap(),
response
);
}
#[test]
fn local_readiness_retains_credential_during_biometric_unavailability() {
let status = UserVerificationStatusResponse {

View File

@@ -65,7 +65,7 @@ ChatGPT account identity.
| Method | Params | Result |
| --- | --- | --- |
| `userVerification/status` | `{}` | `{credentialId, unavailableReason, unavailableMessage}` |
| `userVerification/enroll` | `{}` | `{credentialId}` |
| `userVerification/enroll` | `{}` | `{credentialId, algorithm?, publicKey?}` |
| `userVerification/delete` | `{}` | `{}` |
| `userVerification/verify` | `{challenge, title, description}` | `{proof: {credentialId, signature}}` |
| `userVerification/cancel` | `{requestId}` | `{}` |
@@ -74,9 +74,17 @@ Status reads local readiness without prompting or contacting a backend. A null
`unavailableReason` means local checks passed, not that registration is valid.
Unsupported platforms and missing account identity are reported in the status
response's `unavailableReason` field.
The initial enrollment creates or reuses the local key only. Backend
registration and revocation are integration TODOs; local success is not server
enrollment. Deletion currently removes that local key synchronously.
Enrollment creates or reuses the local key and returns its public metadata. The
`publicKey` is unpadded base64url SPKI-DER; `algorithm` is `ecdsaP256Sha256X962`.
During the experimental rollout, `algorithm` and `publicKey` are optional for
compatibility with older app-servers. Current servers populate both fields;
callers must check that both are present and non-null before backend registration.
The trusted UI host owns backend registration: obtain an enrollment challenge,
sign it with `userVerification/verify`, check that the proof's `credentialId`
matches this response, and submit the public metadata and proof to the backend.
Local success is not server enrollment. The caller must preserve the authenticated
account across this flow and reconcile uncertain registration before retrying.
Deletion removes the local key; the caller owns backend revocation.
Enrollment and deletion coordinate credential lifecycle; callers do not issue
separate generate or rotate commands. Identity comes from the authenticated
account; this API exposes no caller-selected scope.

View File

@@ -181,10 +181,12 @@ async fn run(
}
NativeOperation::Enroll => {
let key = provider.ensure_key(&guard).map_err(native_error)?;
// TODO: start enrollment, sign proof of possession, then finish registration.
// This implementation establishes the local key only.
// The trusted caller owns backend registration and can use verify
// to sign the enrollment challenge with this local credential.
rpc::UserVerificationEnrollResponse {
credential_id: key.credential.credential_id,
algorithm: Some(key.credential.algorithm),
public_key: Some(key.credential.public_key),
}
.into()
}

View File

@@ -11,6 +11,26 @@ use serde_json::json;
use std::sync::atomic::Ordering;
use tokio::time::timeout;
#[tokio::test]
async fn user_verification_rpc_enroll_returns_public_registration_metadata() -> Result<()> {
let mut h = Harness::new(ConnectionOrigin::Stdio, || true).await?;
h.initialize("test-local-ui", /*opt_in*/ true).await;
h.send(/*id*/ 1, "userVerification/enroll", json!({})).await;
let OutgoingMessage::Response(response) = h.response().await else {
panic!("local enrollment must return public metadata")
};
assert_eq!(
serde_json::to_value(response.result)?,
json!({
"credentialId": "credential",
"algorithm": "ecdsaP256Sha256X962",
"publicKey": "public-key"
})
);
h.shutdown().await;
Ok(())
}
#[tokio::test]
async fn user_verification_rpc_auth_revision_discards_native_proof() -> Result<()> {
let mut h = Harness::new(ConnectionOrigin::Stdio, || true).await?;

View File

@@ -57,9 +57,18 @@ impl native::UserVerificationProvider for BlockingProvider {
}
fn ensure_key(
&self,
_guard: &native::UserVerificationRequestGuard,
guard: &native::UserVerificationRequestGuard,
) -> Result<native::UserVerificationKeyCreation, native::UserVerificationError> {
unreachable!("this test must not create keys")
guard.check()?;
self.calls.fetch_add(/*val*/ 1, Ordering::SeqCst);
Ok(native::UserVerificationKeyCreation {
created: false,
credential: native::UserVerificationKeyInfo {
credential_id: "credential".into(),
algorithm: "ecdsaP256Sha256X962".into(),
public_key: "public-key".into(),
},
})
}
fn delete(
&self,

View File

@@ -87,7 +87,11 @@ async fn local_enrollment_reuses_key_and_status_and_delete_have_no_signing_effec
.unwrap();
assert_eq!(
serde_json::to_value(response.payload).unwrap(),
json!({"credentialId": "credential"})
json!({
"credentialId": "credential",
"algorithm": "ecdsaP256Sha256X962",
"publicKey": "public-key"
})
);
}
let response = run(