mirror of
https://github.com/pchuan98/codex.git
synced 2026-07-01 00:31:56 +08:00
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:
@@ -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(¶ms).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(¶ms)
|
||||
.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(¶ms).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(¶ms)
|
||||
.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 {
|
||||
|
||||
Reference in New Issue
Block a user