app-server: define device key v2 protocol (#18428)

## Why

Clients need a stable app-server protocol surface for enrolling a local
device key, retrieving its public key, and producing a device-bound
proof.

The protocol reports `protectionClass` explicitly so clients can
distinguish hardware-backed keys from an explicitly allowed OS-protected
fallback. Signing uses a tagged `DeviceKeySignPayload` enum rather than
arbitrary bytes so each signed statement is auditable at the API
boundary.

## What changed

- Added v2 JSON-RPC methods for `device/key/create`,
`device/key/public`, and `device/key/sign`.
- Added request/response types for device-key metadata, SPKI public
keys, protection classes, and ECDSA signatures.
- Added `DeviceKeyProtectionPolicy` with hardware-only default behavior
and an explicit `allow_os_protected_nonextractable` option.
- Added the initial `remoteControlClientConnection` signing payload
variant.
- Regenerated JSON Schema and TypeScript fixtures for app-server
clients.

## Stack

This is PR 1 of 4 in the device-key app-server stack.

## Validation

- `just write-app-server-schema`
- `cargo test -p codex-app-server-protocol`
This commit is contained in:
Ruslan Nigmatullin
2026-04-21 10:08:42 -07:00
committed by GitHub
parent b06fc8bd0d
commit 48f82ca7c5
27 changed files with 1921 additions and 5 deletions
@@ -360,6 +360,18 @@ client_request_definitions! {
params: v2::AppsListParams,
response: v2::AppsListResponse,
},
DeviceKeyCreate => "device/key/create" {
params: v2::DeviceKeyCreateParams,
response: v2::DeviceKeyCreateResponse,
},
DeviceKeyPublic => "device/key/public" {
params: v2::DeviceKeyPublicParams,
response: v2::DeviceKeyPublicResponse,
},
DeviceKeySign => "device/key/sign" {
params: v2::DeviceKeySignParams,
response: v2::DeviceKeySignResponse,
},
FsReadFile => "fs/readFile" {
params: v2::FsReadFileParams,
response: v2::FsReadFileResponse,
@@ -2494,6 +2494,164 @@ pub struct FeedbackUploadResponse {
pub thread_id: String,
}
/// Device-key algorithm reported at enrollment and signing boundaries.
#[derive(Serialize, Deserialize, Debug, Clone, Copy, PartialEq, Eq, JsonSchema, TS)]
#[serde(rename_all = "snake_case")]
#[ts(rename_all = "snake_case", export_to = "v2/")]
pub enum DeviceKeyAlgorithm {
EcdsaP256Sha256,
}
/// Platform protection class for a controller-local device key.
#[derive(Serialize, Deserialize, Debug, Clone, Copy, PartialEq, Eq, JsonSchema, TS)]
#[serde(rename_all = "snake_case")]
#[ts(rename_all = "snake_case", export_to = "v2/")]
pub enum DeviceKeyProtectionClass {
HardwareSecureEnclave,
HardwareTpm,
OsProtectedNonextractable,
}
/// Protection policy for creating or loading a controller-local device key.
#[derive(Serialize, Deserialize, Debug, Clone, Copy, PartialEq, Eq, JsonSchema, TS)]
#[serde(rename_all = "snake_case")]
#[ts(rename_all = "snake_case", export_to = "v2/")]
pub enum DeviceKeyProtectionPolicy {
HardwareOnly,
AllowOsProtectedNonextractable,
}
/// Create a controller-local device key with a random key id.
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)]
#[serde(rename_all = "camelCase")]
#[ts(export_to = "v2/")]
pub struct DeviceKeyCreateParams {
/// Defaults to `hardware_only` when omitted.
#[ts(optional = nullable)]
pub protection_policy: Option<DeviceKeyProtectionPolicy>,
pub account_user_id: String,
pub client_id: String,
}
/// Device-key metadata and public key returned by create/public APIs.
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)]
#[serde(rename_all = "camelCase")]
#[ts(export_to = "v2/")]
pub struct DeviceKeyCreateResponse {
pub key_id: String,
/// SubjectPublicKeyInfo DER encoded as base64.
pub public_key_spki_der_base64: String,
pub algorithm: DeviceKeyAlgorithm,
pub protection_class: DeviceKeyProtectionClass,
}
/// Fetch a controller-local device key public key by id.
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)]
#[serde(rename_all = "camelCase")]
#[ts(export_to = "v2/")]
pub struct DeviceKeyPublicParams {
pub key_id: String,
}
/// Device-key public metadata returned by `device/key/public`.
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)]
#[serde(rename_all = "camelCase")]
#[ts(export_to = "v2/")]
pub struct DeviceKeyPublicResponse {
pub key_id: String,
/// SubjectPublicKeyInfo DER encoded as base64.
pub public_key_spki_der_base64: String,
pub algorithm: DeviceKeyAlgorithm,
pub protection_class: DeviceKeyProtectionClass,
}
/// Audience for a remote-control client connection device-key proof.
#[derive(Serialize, Deserialize, Debug, Clone, Copy, PartialEq, Eq, JsonSchema, TS)]
#[serde(rename_all = "snake_case")]
#[ts(rename_all = "snake_case", export_to = "v2/")]
pub enum RemoteControlClientConnectionAudience {
RemoteControlClientWebsocket,
}
/// Audience for a remote-control client enrollment device-key proof.
#[derive(Serialize, Deserialize, Debug, Clone, Copy, PartialEq, Eq, JsonSchema, TS)]
#[serde(rename_all = "snake_case")]
#[ts(rename_all = "snake_case", export_to = "v2/")]
pub enum RemoteControlClientEnrollmentAudience {
RemoteControlClientEnrollment,
}
/// Structured payloads accepted by `device/key/sign`.
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)]
#[serde(tag = "type", rename_all = "camelCase")]
#[ts(tag = "type", export_to = "v2/")]
pub enum DeviceKeySignPayload {
/// Payload bound to one remote-control controller websocket `/client` connection challenge.
#[serde(rename_all = "camelCase")]
#[ts(rename_all = "camelCase")]
RemoteControlClientConnection {
nonce: String,
audience: RemoteControlClientConnectionAudience,
/// Backend-issued websocket session id that this proof authorizes.
session_id: String,
/// Origin of the backend endpoint that issued the challenge and will verify this proof.
target_origin: String,
/// Websocket route path that this proof authorizes.
target_path: String,
account_user_id: String,
client_id: String,
/// Remote-control token expiration as Unix seconds.
#[ts(type = "number")]
token_expires_at: i64,
/// SHA-256 of the controller-scoped remote-control token, encoded as unpadded base64url.
token_sha256_base64url: String,
/// Must contain exactly `remote_control_controller_websocket`.
scopes: Vec<String>,
},
/// Payload bound to a remote-control client `/client/enroll` ownership challenge.
#[serde(rename_all = "camelCase")]
#[ts(rename_all = "camelCase")]
RemoteControlClientEnrollment {
nonce: String,
audience: RemoteControlClientEnrollmentAudience,
/// Backend-issued enrollment challenge id that this proof authorizes.
challenge_id: String,
/// Origin of the backend endpoint that issued the challenge and will verify this proof.
target_origin: String,
/// HTTP route path that this proof authorizes.
target_path: String,
account_user_id: String,
client_id: String,
/// SHA-256 of the requested device identity operation, encoded as unpadded base64url.
device_identity_sha256_base64url: String,
/// Enrollment challenge expiration as Unix seconds.
#[ts(type = "number")]
challenge_expires_at: i64,
},
}
/// Sign an accepted structured payload with a controller-local device key.
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)]
#[serde(rename_all = "camelCase")]
#[ts(export_to = "v2/")]
pub struct DeviceKeySignParams {
pub key_id: String,
pub payload: DeviceKeySignPayload,
}
/// ASN.1 DER signature returned by `device/key/sign`.
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)]
#[serde(rename_all = "camelCase")]
#[ts(export_to = "v2/")]
pub struct DeviceKeySignResponse {
/// ECDSA signature DER encoded as base64.
pub signature_der_base64: String,
/// Exact bytes signed by the device key, encoded as base64. Verifiers must verify this byte
/// string directly and must not reserialize `payload`.
pub signed_payload_base64: String,
pub algorithm: DeviceKeyAlgorithm,
}
/// Read a file from the host filesystem.
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)]
#[serde(rename_all = "camelCase")]
@@ -7386,6 +7544,181 @@ mod tests {
assert_eq!(decoded, params);
}
#[test]
fn device_key_create_params_round_trip_uses_protection_policy() {
let params = DeviceKeyCreateParams {
protection_policy: None,
account_user_id: "account-user-1".to_string(),
client_id: "cli_123".to_string(),
};
let value = serde_json::to_value(&params).expect("serialize device/key/create params");
assert_eq!(
value,
json!({
"accountUserId": "account-user-1",
"clientId": "cli_123",
"protectionPolicy": null,
})
);
let decoded = serde_json::from_value::<DeviceKeyCreateParams>(value)
.expect("deserialize device/key/create params");
assert_eq!(decoded, params);
let params = DeviceKeyCreateParams {
protection_policy: Some(DeviceKeyProtectionPolicy::AllowOsProtectedNonextractable),
account_user_id: "account-user-1".to_string(),
client_id: "cli_123".to_string(),
};
let value = serde_json::to_value(&params)
.expect("serialize device/key/create params with protection policy");
assert_eq!(
value,
json!({
"accountUserId": "account-user-1",
"clientId": "cli_123",
"protectionPolicy": "allow_os_protected_nonextractable",
})
);
}
#[test]
fn device_key_create_response_round_trips_protection_class() {
let response = DeviceKeyCreateResponse {
key_id: "dk_123".to_string(),
public_key_spki_der_base64: "MFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAE".to_string(),
algorithm: DeviceKeyAlgorithm::EcdsaP256Sha256,
protection_class: DeviceKeyProtectionClass::OsProtectedNonextractable,
};
let value = serde_json::to_value(&response).expect("serialize device/key/create response");
assert_eq!(
value,
json!({
"keyId": "dk_123",
"publicKeySpkiDerBase64": "MFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAE",
"algorithm": "ecdsa_p256_sha256",
"protectionClass": "os_protected_nonextractable",
})
);
let decoded = serde_json::from_value::<DeviceKeyCreateResponse>(value)
.expect("deserialize device/key/create response");
assert_eq!(decoded, response);
}
#[test]
fn device_key_sign_params_round_trip_uses_accepted_payload_enum() {
let params = DeviceKeySignParams {
key_id: "dk_123".to_string(),
payload: DeviceKeySignPayload::RemoteControlClientConnection {
nonce: "nonce-1".to_string(),
audience: RemoteControlClientConnectionAudience::RemoteControlClientWebsocket,
session_id: "wssess_123".to_string(),
target_origin: "https://chatgpt.com".to_string(),
target_path: "/api/codex/remote/control/client".to_string(),
account_user_id: "account-user-1".to_string(),
client_id: "cli_123".to_string(),
token_sha256_base64url: "47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU".to_string(),
token_expires_at: 1_700_000_000,
scopes: vec!["remote_control_controller_websocket".to_string()],
},
};
let value = serde_json::to_value(&params).expect("serialize device/key/sign params");
assert_eq!(
value,
json!({
"keyId": "dk_123",
"payload": {
"type": "remoteControlClientConnection",
"nonce": "nonce-1",
"audience": "remote_control_client_websocket",
"sessionId": "wssess_123",
"targetOrigin": "https://chatgpt.com",
"targetPath": "/api/codex/remote/control/client",
"accountUserId": "account-user-1",
"clientId": "cli_123",
"tokenSha256Base64url": "47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU",
"tokenExpiresAt": 1_700_000_000,
"scopes": ["remote_control_controller_websocket"],
},
})
);
let decoded = serde_json::from_value::<DeviceKeySignParams>(value)
.expect("deserialize device/key/sign params");
assert_eq!(decoded, params);
}
#[test]
fn device_key_sign_params_round_trip_uses_enrollment_payload() {
let params = DeviceKeySignParams {
key_id: "dk_123".to_string(),
payload: DeviceKeySignPayload::RemoteControlClientEnrollment {
nonce: "nonce-1".to_string(),
audience: RemoteControlClientEnrollmentAudience::RemoteControlClientEnrollment,
challenge_id: "rch_123".to_string(),
target_origin: "https://chatgpt.com".to_string(),
target_path: "/wham/remote/control/client/enroll".to_string(),
account_user_id: "account-user-1".to_string(),
client_id: "cli_123".to_string(),
device_identity_sha256_base64url: "47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU"
.to_string(),
challenge_expires_at: 1_700_000_000,
},
};
let value = serde_json::to_value(&params)
.expect("serialize device/key/sign params with enrollment payload");
assert_eq!(
value,
json!({
"keyId": "dk_123",
"payload": {
"type": "remoteControlClientEnrollment",
"nonce": "nonce-1",
"audience": "remote_control_client_enrollment",
"challengeId": "rch_123",
"targetOrigin": "https://chatgpt.com",
"targetPath": "/wham/remote/control/client/enroll",
"accountUserId": "account-user-1",
"clientId": "cli_123",
"deviceIdentitySha256Base64url": "47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU",
"challengeExpiresAt": 1_700_000_000,
},
})
);
let decoded = serde_json::from_value::<DeviceKeySignParams>(value)
.expect("deserialize device/key/sign params with enrollment payload");
assert_eq!(decoded, params);
}
#[test]
fn device_key_sign_response_returns_signed_payload_bytes() {
let response = DeviceKeySignResponse {
signature_der_base64: "MEUCIQD".to_string(),
signed_payload_base64: "eyJkb21haW4iOiJjb2RleA".to_string(),
algorithm: DeviceKeyAlgorithm::EcdsaP256Sha256,
};
let value = serde_json::to_value(&response).expect("serialize device/key/sign response");
assert_eq!(
value,
json!({
"signatureDerBase64": "MEUCIQD",
"signedPayloadBase64": "eyJkb21haW4iOiJjb2RleA",
"algorithm": "ecdsa_p256_sha256",
})
);
let decoded = serde_json::from_value::<DeviceKeySignResponse>(value)
.expect("deserialize device/key/sign response");
assert_eq!(decoded, response);
}
#[test]
fn fs_create_directory_params_round_trip_with_default_recursive() {
let params = FsCreateDirectoryParams {