mirror of
https://github.com/openai/codex.git
synced 2026-10-30 19:18:49 +00:00
## 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
209 lines
7.0 KiB
Rust
209 lines
7.0 KiB
Rust
//! Exercises cancellation and local-device security through the client JSON-RPC dispatcher.
|
|
|
|
use super::test_support::Harness;
|
|
use super::test_support::write_auth;
|
|
use super::*;
|
|
use crate::outgoing_message::ConnectionId;
|
|
use crate::outgoing_message::OutgoingMessage;
|
|
use anyhow::Result;
|
|
use pretty_assertions::assert_eq;
|
|
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?;
|
|
h.initialize("test-local-ui", /*opt_in*/ true).await;
|
|
h.start_verify().await?;
|
|
write_auth(h.home.path(), "second")?;
|
|
h.auth.reload().await;
|
|
h.provider.released.store(/*val*/ true, Ordering::Release);
|
|
let OutgoingMessage::Error(error) = h.response().await else {
|
|
panic!("auth change must reject proof")
|
|
};
|
|
assert_eq!(
|
|
error.error.data,
|
|
Some(json!({"type": "cancelled", "reason": "interrupted"}))
|
|
);
|
|
h.shutdown().await;
|
|
Ok(())
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn user_verification_rpc_disconnect_cancels_worker_and_releases_slot() -> Result<()> {
|
|
let mut h = Harness::new(ConnectionOrigin::Stdio, || true).await?;
|
|
h.initialize("test-local-ui", /*opt_in*/ true).await;
|
|
h.start_verify().await?;
|
|
timeout(
|
|
Duration::from_secs(/*secs*/ 5),
|
|
h.processor.connection_closed(ConnectionId(1), &h.session),
|
|
)
|
|
.await?;
|
|
assert!(matches!(h.response().await, OutgoingMessage::Error(_)));
|
|
assert_eq!(h.service.worker.available_permits(), 1);
|
|
h.shutdown().await;
|
|
Ok(())
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn user_verification_rpc_network_peers_cannot_use_server_keys() -> Result<()> {
|
|
for origin in [ConnectionOrigin::WebSocket, ConnectionOrigin::RemoteControl] {
|
|
let mut h = Harness::new(origin, || true).await?;
|
|
h.initialize("codex-tui", /*opt_in*/ true).await;
|
|
for method in [
|
|
"userVerification/enroll",
|
|
"userVerification/delete",
|
|
"userVerification/verify",
|
|
] {
|
|
let params = if method.ends_with("verify") {
|
|
json!({"challenge":"AQ", "title":"Approve", "description":""})
|
|
} else {
|
|
json!({})
|
|
};
|
|
h.send(/*id*/ 1, method, params).await;
|
|
let OutgoingMessage::Error(error) = h.response().await else {
|
|
panic!("remote native operation must fail")
|
|
};
|
|
assert_eq!(
|
|
error.error.data,
|
|
Some(json!({"type":"unavailable", "reason":"providerUnavailable"}))
|
|
);
|
|
}
|
|
assert_eq!(h.provider.calls.load(Ordering::SeqCst), 0);
|
|
h.shutdown().await;
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn user_verification_rpc_dropped_caller_cancels_native_work() -> Result<()> {
|
|
let h = Harness::new(ConnectionOrigin::InProcess, || true).await?;
|
|
let (entered, waiting) = oneshot::channel();
|
|
*h.provider.entered.lock().unwrap() = Some(entered);
|
|
let future = h.service.handle(
|
|
Operation::Verify(rpc::UserVerificationVerifyParams {
|
|
challenge: "AQ".into(),
|
|
title: "Approve".into(),
|
|
description: String::new(),
|
|
}),
|
|
Arc::clone(&h.session.rpc_gate),
|
|
CancellationToken::new(),
|
|
ConnectionOrigin::InProcess,
|
|
);
|
|
{
|
|
let future = std::pin::pin!(future);
|
|
tokio::select! { result = future => panic!("native operation should block: {}", result.is_ok()), entered = waiting => entered? }
|
|
}
|
|
let permit = timeout(Duration::from_secs(/*secs*/ 5), h.service.worker.acquire()).await??;
|
|
drop(permit);
|
|
assert_eq!(h.provider.calls.load(Ordering::SeqCst), 1);
|
|
h.shutdown().await;
|
|
Ok(())
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn user_verification_rechecks_auth_after_response_queue_wait() -> Result<()> {
|
|
let mut h = Harness::new(ConnectionOrigin::Stdio, || true).await?;
|
|
h.provider.released.store(/*val*/ true, Ordering::Release);
|
|
let response = h
|
|
.service
|
|
.handle(
|
|
Operation::Verify(rpc::UserVerificationVerifyParams {
|
|
challenge: "AQ".into(),
|
|
title: "Approve".into(),
|
|
description: String::new(),
|
|
}),
|
|
Arc::clone(&h.session.rpc_gate),
|
|
CancellationToken::new(),
|
|
ConnectionOrigin::Stdio,
|
|
)
|
|
.await
|
|
.map_err(|error| anyhow::anyhow!("{error:?}"))?;
|
|
for id in 0..16 {
|
|
h.outgoing
|
|
.send_response(
|
|
crate::outgoing_message::ConnectionRequestId {
|
|
connection_id: ConnectionId(1),
|
|
request_id: rpc::RequestId::Integer(id),
|
|
},
|
|
rpc::UserVerificationDeleteResponse {},
|
|
)
|
|
.await;
|
|
}
|
|
let outgoing = Arc::clone(&h.outgoing);
|
|
let (payload, check) = response.into_parts();
|
|
let sending = outgoing.send_response_as_checked(
|
|
crate::outgoing_message::ConnectionRequestId {
|
|
connection_id: ConnectionId(1),
|
|
request_id: rpc::RequestId::Integer(16),
|
|
},
|
|
payload,
|
|
check,
|
|
);
|
|
let mut sending = std::pin::pin!(sending);
|
|
assert!(matches!(
|
|
futures::poll!(&mut sending),
|
|
std::task::Poll::Pending
|
|
));
|
|
write_auth(h.home.path(), "second")?;
|
|
h.auth.reload().await;
|
|
h.response().await;
|
|
sending.await;
|
|
for _ in 1..16 {
|
|
h.response().await;
|
|
}
|
|
let OutgoingMessage::Error(error) = h.response().await else {
|
|
panic!("queued response must recheck auth")
|
|
};
|
|
assert_eq!(
|
|
error.error.data,
|
|
Some(json!({"type":"cancelled", "reason":"interrupted"}))
|
|
);
|
|
h.shutdown().await;
|
|
Ok(())
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn user_verification_rejects_concurrent_native_workers() -> Result<()> {
|
|
let h = Harness::new(ConnectionOrigin::Stdio, || true).await?;
|
|
let permit = h.service.worker.clone().acquire_owned().await?;
|
|
let result = h
|
|
.service
|
|
.handle(
|
|
Operation::Status,
|
|
Arc::clone(&h.session.rpc_gate),
|
|
CancellationToken::new(),
|
|
ConnectionOrigin::Stdio,
|
|
)
|
|
.await;
|
|
assert_eq!(
|
|
result.err().unwrap().message,
|
|
"Another user verification operation is still running."
|
|
);
|
|
assert_eq!(h.provider.calls.load(Ordering::SeqCst), 0);
|
|
drop(permit);
|
|
h.shutdown().await;
|
|
Ok(())
|
|
}
|