mirror of
https://github.com/openai/codex.git
synced 2026-11-14 14:49:31 +00:00
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:
1 parent
2c9e1a5775
commit
7b491281c8
8 files changed
+89
-9
No files matched your search
BIN
Binary file not shown.
@@ -109,11 +109,19 @@ pub struct UserVerificationStatusResponse {
|
|||||||
#[ts(export_to = "v2/")]
|
#[ts(export_to = "v2/")]
|
||||||
pub struct UserVerificationEnrollParams {}
|
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)]
|
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)]
|
||||||
#[serde(rename_all = "camelCase")]
|
#[serde(rename_all = "camelCase")]
|
||||||
#[ts(export_to = "v2/")]
|
#[ts(export_to = "v2/")]
|
||||||
pub struct UserVerificationEnrollResponse {
|
pub struct UserVerificationEnrollResponse {
|
||||||
pub credential_id: String,
|
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)]
|
#[derive(Serialize, Deserialize, Debug, Default, Clone, PartialEq, Eq, JsonSchema, TS)]
|
||||||
|
|||||||
@@ -2,6 +2,35 @@ use super::*;
|
|||||||
use pretty_assertions::assert_eq;
|
use pretty_assertions::assert_eq;
|
||||||
use serde_json::json;
|
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]
|
#[test]
|
||||||
fn local_readiness_retains_credential_during_biometric_unavailability() {
|
fn local_readiness_retains_credential_during_biometric_unavailability() {
|
||||||
let status = UserVerificationStatusResponse {
|
let status = UserVerificationStatusResponse {
|
||||||
|
|||||||
@@ -65,7 +65,7 @@ ChatGPT account identity.
|
|||||||
| Method | Params | Result |
|
| Method | Params | Result |
|
||||||
| --- | --- | --- |
|
| --- | --- | --- |
|
||||||
| `userVerification/status` | `{}` | `{credentialId, unavailableReason, unavailableMessage}` |
|
| `userVerification/status` | `{}` | `{credentialId, unavailableReason, unavailableMessage}` |
|
||||||
| `userVerification/enroll` | `{}` | `{credentialId}` |
|
| `userVerification/enroll` | `{}` | `{credentialId, algorithm?, publicKey?}` |
|
||||||
| `userVerification/delete` | `{}` | `{}` |
|
| `userVerification/delete` | `{}` | `{}` |
|
||||||
| `userVerification/verify` | `{challenge, title, description}` | `{proof: {credentialId, signature}}` |
|
| `userVerification/verify` | `{challenge, title, description}` | `{proof: {credentialId, signature}}` |
|
||||||
| `userVerification/cancel` | `{requestId}` | `{}` |
|
| `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.
|
`unavailableReason` means local checks passed, not that registration is valid.
|
||||||
Unsupported platforms and missing account identity are reported in the status
|
Unsupported platforms and missing account identity are reported in the status
|
||||||
response's `unavailableReason` field.
|
response's `unavailableReason` field.
|
||||||
The initial enrollment creates or reuses the local key only. Backend
|
Enrollment creates or reuses the local key and returns its public metadata. The
|
||||||
registration and revocation are integration TODOs; local success is not server
|
`publicKey` is unpadded base64url SPKI-DER; `algorithm` is `ecdsaP256Sha256X962`.
|
||||||
enrollment. Deletion currently removes that local key synchronously.
|
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
|
Enrollment and deletion coordinate credential lifecycle; callers do not issue
|
||||||
separate generate or rotate commands. Identity comes from the authenticated
|
separate generate or rotate commands. Identity comes from the authenticated
|
||||||
account; this API exposes no caller-selected scope.
|
account; this API exposes no caller-selected scope.
|
||||||
|
|||||||
@@ -181,10 +181,12 @@ async fn run(
|
|||||||
}
|
}
|
||||||
NativeOperation::Enroll => {
|
NativeOperation::Enroll => {
|
||||||
let key = provider.ensure_key(&guard).map_err(native_error)?;
|
let key = provider.ensure_key(&guard).map_err(native_error)?;
|
||||||
// TODO: start enrollment, sign proof of possession, then finish registration.
|
// The trusted caller owns backend registration and can use verify
|
||||||
// This implementation establishes the local key only.
|
// to sign the enrollment challenge with this local credential.
|
||||||
rpc::UserVerificationEnrollResponse {
|
rpc::UserVerificationEnrollResponse {
|
||||||
credential_id: key.credential.credential_id,
|
credential_id: key.credential.credential_id,
|
||||||
|
algorithm: Some(key.credential.algorithm),
|
||||||
|
public_key: Some(key.credential.public_key),
|
||||||
}
|
}
|
||||||
.into()
|
.into()
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -11,6 +11,26 @@ use serde_json::json;
|
|||||||
use std::sync::atomic::Ordering;
|
use std::sync::atomic::Ordering;
|
||||||
use tokio::time::timeout;
|
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]
|
#[tokio::test]
|
||||||
async fn user_verification_rpc_auth_revision_discards_native_proof() -> Result<()> {
|
async fn user_verification_rpc_auth_revision_discards_native_proof() -> Result<()> {
|
||||||
let mut h = Harness::new(ConnectionOrigin::Stdio, || true).await?;
|
let mut h = Harness::new(ConnectionOrigin::Stdio, || true).await?;
|
||||||
|
|||||||
@@ -57,9 +57,18 @@ impl native::UserVerificationProvider for BlockingProvider {
|
|||||||
}
|
}
|
||||||
fn ensure_key(
|
fn ensure_key(
|
||||||
&self,
|
&self,
|
||||||
_guard: &native::UserVerificationRequestGuard,
|
guard: &native::UserVerificationRequestGuard,
|
||||||
) -> Result<native::UserVerificationKeyCreation, native::UserVerificationError> {
|
) -> 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(
|
fn delete(
|
||||||
&self,
|
&self,
|
||||||
|
|||||||
@@ -87,7 +87,11 @@ async fn local_enrollment_reuses_key_and_status_and_delete_have_no_signing_effec
|
|||||||
.unwrap();
|
.unwrap();
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
serde_json::to_value(response.payload).unwrap(),
|
serde_json::to_value(response.payload).unwrap(),
|
||||||
json!({"credentialId": "credential"})
|
json!({
|
||||||
|
"credentialId": "credential",
|
||||||
|
"algorithm": "ecdsaP256Sha256X962",
|
||||||
|
"publicKey": "public-key"
|
||||||
|
})
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
let response = run(
|
let response = run(
|
||||||
|
|||||||
Reference in new issue
Block a user