Files
codex/codex-rs/app-server/src/attestation.rs
Jiaming Zhang 5f4d0ec343 [codex] request desktop attestation from app (#20619)
## Summary

TL;DR: teaches `codex-rs` / app-server to request a desktop-provided
attestation token and attach it as `x-oai-attestation` on the scoped
ChatGPT Codex request paths.

![DeviceCheck attestation
interface](https://raw.githubusercontent.com/openai/codex/dev/jm/devicecheck-diagram-assets/pr-assets/devicecheck-attestation-interface.png)

## Details

This PR teaches the Codex app-server runtime how to request and attach
an attestation token. It does not generate DeviceCheck tokens directly;
instead, it relies on the connected desktop app to advertise that it can
generate attestation and then asks that app for a fresh header value
when needed.

The flow is:

1. The Codex desktop app connects to app-server.
2. During `initialize`, the app can advertise that it supports
`requestAttestation`.
3. Before app-server calls selected ChatGPT Codex endpoints, it sends
the internal server request `attestation/generate` to the app.
4. app-server receives a pre-encoded header value back.
5. app-server forwards that value as `x-oai-attestation` on the scoped
outbound requests.

The code in this repo is mostly protocol and runtime plumbing: it adds
the app-server request/response shape, introduces an attestation
provider in core, wires that provider into Responses / compaction /
realtime setup paths, and covers the intended scoping with tests. The
signed macOS DeviceCheck generation remains owned by the desktop app PR.

## Related PR

- Codex desktop app implementation:
https://github.com/openai/openai/pull/878649

## Validation

<details>
<summary>Tests run</summary>

```sh
cargo test -p codex-app-server-protocol
cargo test -p codex-core attestation --lib
cargo test -p codex-app-server --lib attestation
```

Also ran:

```sh
just fix -p codex-core
just fix -p codex-app-server
just fix -p codex-app-server-protocol
just fmt
just write-app-server-schema
```

</details>

<details>
<summary>E2E DeviceCheck validation</summary>

First validated the signed desktop app boundary directly: launched a
packaged signed `Codex.app`, sent `attestation/generate`, decoded the
returned `v1.` attestation header, and validated the extracted
DeviceCheck token with `personal/jm/verify_devicecheck_token.py` using
bundle ID `com.openai.codex`. Apple returned `status_code: 200` and
`is_ok: true`.

Then ran the fuller app + app-server flow. The packaged `Codex.app`
launched a current-branch app-server via `CODEX_CLI_PATH`, and a local
MITM proxy intercepted outbound `chatgpt.com` traffic. The app-server
requested `attestation/generate` from the real Electron app process, and
the intercepted `/backend-api/codex/responses` traffic included
`x-oai-attestation` on both routes:

```text
GET  /backend-api/codex/responses  Upgrade: websocket  x-oai-attestation: present
POST /backend-api/codex/responses  Upgrade: none       x-oai-attestation: present
```

The captured header decoded to a DeviceCheck token that also validated
with Apple for `com.openai.codex` (`status_code: 200`, `is_ok: true`,
team `2DC432GLL2`).

</details>

---------

Co-authored-by: Codex <noreply@openai.com>
2026-05-08 12:36:02 -07:00

218 lines
6.6 KiB
Rust

use std::sync::Arc;
use axum::http::HeaderValue;
use codex_app_server_protocol::AttestationGenerateParams;
use codex_app_server_protocol::AttestationGenerateResponse;
use codex_app_server_protocol::ServerRequestPayload;
use codex_core::AttestationContext;
use codex_core::AttestationProvider;
use codex_core::GenerateAttestationFuture;
use serde::Serialize;
use tokio::time::Duration;
use tokio::time::timeout;
use tracing::warn;
use crate::outgoing_message::OutgoingMessageSender;
use crate::thread_state::ThreadStateManager;
const ATTESTATION_GENERATE_TIMEOUT: Duration = Duration::from_millis(100);
pub(crate) fn app_server_attestation_provider(
outgoing: Arc<OutgoingMessageSender>,
thread_state_manager: ThreadStateManager,
) -> Arc<dyn AttestationProvider> {
Arc::new(AppServerAttestationProvider {
outgoing,
thread_state_manager,
})
}
struct AppServerAttestationProvider {
outgoing: Arc<OutgoingMessageSender>,
thread_state_manager: ThreadStateManager,
}
impl std::fmt::Debug for AppServerAttestationProvider {
fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
formatter
.debug_struct("AppServerAttestationProvider")
.finish()
}
}
impl AttestationProvider for AppServerAttestationProvider {
fn header_for_request(&self, context: AttestationContext) -> GenerateAttestationFuture<'_> {
let outgoing = self.outgoing.clone();
let thread_state_manager = self.thread_state_manager.clone();
Box::pin(async move {
request_attestation_header_value_with_timeout(
outgoing,
thread_state_manager,
context.thread_id,
ATTESTATION_GENERATE_TIMEOUT,
)
.await
.and_then(|value| HeaderValue::from_bytes(value.as_bytes()).ok())
})
}
}
async fn request_attestation_header_value_with_timeout(
outgoing: Arc<OutgoingMessageSender>,
thread_state_manager: ThreadStateManager,
thread_id: codex_protocol::ThreadId,
timeout_duration: Duration,
) -> Option<String> {
let connection_id = thread_state_manager
.first_attestation_capable_connection_for_thread(thread_id)
.await?;
let connection_ids = [connection_id];
let (request_id, rx) = outgoing
.send_request_to_connections(
Some(&connection_ids),
ServerRequestPayload::AttestationGenerate(AttestationGenerateParams {}),
/*thread_id*/ None,
)
.await;
let result = match timeout(timeout_duration, rx).await {
Ok(Ok(Ok(result))) => result,
Ok(Ok(Err(err))) => {
warn!(
code = err.code,
message = %err.message,
"attestation generation request failed"
);
return app_server_attestation_header_value(
AppServerAttestationStatus::RequestFailed,
/*token*/ None,
);
}
Ok(Err(err)) => {
warn!("attestation generation request canceled: {err}");
return app_server_attestation_header_value(
AppServerAttestationStatus::RequestCanceled,
/*token*/ None,
);
}
Err(_) => {
let _canceled = outgoing.cancel_request(&request_id).await;
warn!(
timeout_seconds = timeout_duration.as_secs(),
"attestation generation request timed out"
);
return app_server_attestation_header_value(
AppServerAttestationStatus::Timeout,
/*token*/ None,
);
}
};
match serde_json::from_value::<AttestationGenerateResponse>(result) {
Ok(response) => app_server_attestation_header_value(
AppServerAttestationStatus::Ok,
Some(&response.token),
),
Err(err) => {
warn!("failed to deserialize attestation generation response: {err}");
app_server_attestation_header_value(
AppServerAttestationStatus::MalformedResponse,
/*token*/ None,
)
}
}
}
#[derive(Clone, Copy)]
enum AppServerAttestationStatus {
Ok,
Timeout,
RequestFailed,
RequestCanceled,
MalformedResponse,
}
impl AppServerAttestationStatus {
const fn code(self) -> u8 {
match self {
Self::Ok => 0,
Self::Timeout => 1,
Self::RequestFailed => 2,
Self::RequestCanceled => 3,
Self::MalformedResponse => 4,
}
}
}
#[derive(Serialize)]
struct AppServerAttestationEnvelope<'a> {
v: u8,
s: u8,
#[serde(skip_serializing_if = "Option::is_none")]
t: Option<&'a str>,
}
fn app_server_attestation_header_value(
status: AppServerAttestationStatus,
token: Option<&str>,
) -> Option<String> {
serde_json::to_string(&AppServerAttestationEnvelope {
v: 1,
s: status.code(),
t: token,
})
.map_err(|err| warn!("failed to serialize app-server attestation envelope: {err}"))
.ok()
}
#[cfg(test)]
mod tests {
use super::AppServerAttestationStatus;
use super::app_server_attestation_header_value;
use pretty_assertions::assert_eq;
#[test]
fn app_server_attestation_header_value_wraps_opaque_client_payloads() {
assert_eq!(
app_server_attestation_header_value(
AppServerAttestationStatus::Ok,
Some("v1.opaque-client-payload"),
),
Some(r#"{"v":1,"s":0,"t":"v1.opaque-client-payload"}"#.to_string())
);
}
#[test]
fn app_server_attestation_header_value_reports_app_server_failures() {
assert_eq!(
app_server_attestation_header_value(
AppServerAttestationStatus::Timeout,
/*token*/ None,
),
Some(r#"{"v":1,"s":1}"#.to_string())
);
assert_eq!(
app_server_attestation_header_value(
AppServerAttestationStatus::RequestFailed,
/*token*/ None,
),
Some(r#"{"v":1,"s":2}"#.to_string())
);
assert_eq!(
app_server_attestation_header_value(
AppServerAttestationStatus::RequestCanceled,
/*token*/ None,
),
Some(r#"{"v":1,"s":3}"#.to_string())
);
assert_eq!(
app_server_attestation_header_value(
AppServerAttestationStatus::MalformedResponse,
/*token*/ None
),
Some(r#"{"v":1,"s":4}"#.to_string())
);
}
}