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 8bc770f7df..b4ac7f5b02 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/protocol/v2/user_verification.rs b/codex-rs/app-server-protocol/src/protocol/v2/user_verification.rs index 99321969d4..37a0fd820d 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 @@ -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, + /// Unpadded base64url of the SubjectPublicKeyInfo DER encoding. + pub public_key: Option, } #[derive(Serialize, Deserialize, Debug, Default, Clone, PartialEq, Eq, JsonSchema, TS)] 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 e8d6cd60c6..af2090cd29 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,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::(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::( + serde_json::to_value(&response).unwrap() + ) + .unwrap(), + response + ); +} + #[test] fn local_readiness_retains_credential_during_biometric_unavailability() { let status = UserVerificationStatusResponse { diff --git a/codex-rs/app-server/README.md b/codex-rs/app-server/README.md index f73d136bd1..509f1f994a 100644 --- a/codex-rs/app-server/README.md +++ b/codex-rs/app-server/README.md @@ -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. diff --git a/codex-rs/app-server/src/user_verification.rs b/codex-rs/app-server/src/user_verification.rs index 95c36e96b5..f7522228a0 100644 --- a/codex-rs/app-server/src/user_verification.rs +++ b/codex-rs/app-server/src/user_verification.rs @@ -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() } 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 591d77ae50..0bb5200d3c 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_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?; 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 d382e9972b..d2096bc1f1 100644 --- a/codex-rs/app-server/src/user_verification_test_support.rs +++ b/codex-rs/app-server/src/user_verification_test_support.rs @@ -57,9 +57,18 @@ impl native::UserVerificationProvider for BlockingProvider { } fn ensure_key( &self, - _guard: &native::UserVerificationRequestGuard, + guard: &native::UserVerificationRequestGuard, ) -> Result { - 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, diff --git a/codex-rs/app-server/src/user_verification_tests.rs b/codex-rs/app-server/src/user_verification_tests.rs index 65970c750d..1f05402d0c 100644 --- a/codex-rs/app-server/src/user_verification_tests.rs +++ b/codex-rs/app-server/src/user_verification_tests.rs @@ -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(