mirror of
https://github.com/openai/codex.git
synced 2026-05-04 13:21:54 +03:00
## Why Device-key providers should only own platform key material. The account/client binding used to authorize a signing payload is app-server state, and keeping that state in provider-specific metadata makes the same check harder to audit and harder to share across platform implementations. Persisting the binding in the shared state database gives the device-key crate a platform-neutral source of truth before it asks a provider to sign. It also lets app-server move potentially blocking key operations off the main message processor path, which matters once providers may wait for OS authentication prompts. ## What changed - Add a `device_key_bindings` state migration plus `StateRuntime` helpers keyed by `key_id`. - Add an async `DeviceKeyBindingStore` abstraction to `codex-device-key` and use it from `DeviceKeyStore::create` and `DeviceKeyStore::sign`. - Keep provider calls behind async store methods and run the synchronous provider work through `spawn_blocking`. - Wire app-server device-key RPC handling to the SQLite-backed binding store and spawn response/error delivery tasks for device-key requests. - Run the turn-start tracing test on the existing larger current-thread test harness after the larger async surface made the default test stack too small locally. ## Validation - `cargo test -p codex-device-key` - `cargo test -p codex-state device_key` - `cargo test -p codex-state` - `cargo test -p codex-app-server device_key` - `cargo test -p codex-app-server message_processor::tracing_tests::turn_start_jsonrpc_span_parents_core_turn_spans` - `cargo test -p codex-app-server` - `just fix -p codex-device-key` - `just fix -p codex-state` - `just fix -p codex-app-server` - `just bazel-lock-update` - `just bazel-lock-check` - `git diff --check`
1496 lines
54 KiB
Rust
1496 lines
54 KiB
Rust
use async_trait::async_trait;
|
|
use base64::Engine;
|
|
use base64::engine::general_purpose::URL_SAFE_NO_PAD;
|
|
use p256::pkcs8::EncodePublicKey;
|
|
use rand::random;
|
|
use serde::Deserialize;
|
|
use serde::Serialize;
|
|
use std::fmt;
|
|
use std::fmt::Debug;
|
|
use std::sync::Arc;
|
|
use std::time::SystemTime;
|
|
use std::time::UNIX_EPOCH;
|
|
use thiserror::Error;
|
|
use url::Host;
|
|
use url::Url;
|
|
|
|
mod platform;
|
|
|
|
const SIGNING_DOMAIN: &str = "codex-device-key-sign-payload/v1";
|
|
const DEVICE_KEY_ID_RANDOM_BYTES: usize = 32;
|
|
const DEVICE_KEY_ID_ENCODED_BYTES: usize = 43;
|
|
const DEVICE_KEY_ID_HARDWARE_SECURE_ENCLAVE_PREFIX: &str = "dk_hse_";
|
|
const DEVICE_KEY_ID_HARDWARE_TPM_PREFIX: &str = "dk_tpm_";
|
|
const DEVICE_KEY_ID_OS_PROTECTED_NONEXTRACTABLE_PREFIX: &str = "dk_osn_";
|
|
const DEVICE_KEY_ID_PREFIX_LEN: usize = DEVICE_KEY_ID_HARDWARE_SECURE_ENCLAVE_PREFIX.len();
|
|
const DEVICE_KEY_ID_LEN: usize = DEVICE_KEY_ID_PREFIX_LEN + DEVICE_KEY_ID_ENCODED_BYTES;
|
|
const INVALID_DEVICE_KEY_ID_MESSAGE: &str =
|
|
"keyId must be dk_hse_, dk_tpm_, or dk_osn_ followed by unpadded base64url-encoded 32 bytes";
|
|
const REMOTE_CONTROL_CONTROLLER_WEBSOCKET_SCOPE: &str = "remote_control_controller_websocket";
|
|
const MAX_REMOTE_CONTROL_DEVICE_KEY_PROOF_TTL_SECONDS: i64 = 15 * 60;
|
|
const REMOTE_CONTROL_CLIENT_CONNECTION_PATHS: &[&str] = &[
|
|
"/api/codex/remote/control/client",
|
|
"/wham/remote/control/client",
|
|
];
|
|
const REMOTE_CONTROL_CLIENT_ENROLLMENT_PATHS: &[&str] = &[
|
|
"/api/codex/remote/control/client/enroll",
|
|
"/wham/remote/control/client/enroll",
|
|
];
|
|
|
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
|
#[serde(rename_all = "snake_case")]
|
|
pub enum DeviceKeyAlgorithm {
|
|
EcdsaP256Sha256,
|
|
}
|
|
|
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
|
#[serde(rename_all = "snake_case")]
|
|
pub enum DeviceKeyProtectionClass {
|
|
HardwareSecureEnclave,
|
|
HardwareTpm,
|
|
OsProtectedNonextractable,
|
|
}
|
|
|
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
|
pub enum DeviceKeyProtectionPolicy {
|
|
HardwareOnly,
|
|
AllowOsProtectedNonextractable,
|
|
}
|
|
|
|
impl DeviceKeyProtectionPolicy {
|
|
fn allows(self, protection_class: DeviceKeyProtectionClass) -> bool {
|
|
match self {
|
|
Self::HardwareOnly => !protection_class.is_degraded(),
|
|
Self::AllowOsProtectedNonextractable => matches!(
|
|
protection_class,
|
|
DeviceKeyProtectionClass::HardwareSecureEnclave
|
|
| DeviceKeyProtectionClass::HardwareTpm
|
|
| DeviceKeyProtectionClass::OsProtectedNonextractable
|
|
),
|
|
}
|
|
}
|
|
}
|
|
|
|
impl DeviceKeyProtectionClass {
|
|
pub fn is_degraded(self) -> bool {
|
|
match self {
|
|
Self::HardwareSecureEnclave | Self::HardwareTpm => false,
|
|
Self::OsProtectedNonextractable => true,
|
|
}
|
|
}
|
|
|
|
fn key_id_prefix(self) -> &'static str {
|
|
match self {
|
|
Self::HardwareSecureEnclave => DEVICE_KEY_ID_HARDWARE_SECURE_ENCLAVE_PREFIX,
|
|
Self::HardwareTpm => DEVICE_KEY_ID_HARDWARE_TPM_PREFIX,
|
|
Self::OsProtectedNonextractable => DEVICE_KEY_ID_OS_PROTECTED_NONEXTRACTABLE_PREFIX,
|
|
}
|
|
}
|
|
}
|
|
|
|
impl fmt::Display for DeviceKeyProtectionClass {
|
|
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
|
match self {
|
|
Self::HardwareSecureEnclave => f.write_str("hardware_secure_enclave"),
|
|
Self::HardwareTpm => f.write_str("hardware_tpm"),
|
|
Self::OsProtectedNonextractable => f.write_str("os_protected_nonextractable"),
|
|
}
|
|
}
|
|
}
|
|
|
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
|
pub struct DeviceKeyCreateRequest {
|
|
pub protection_policy: DeviceKeyProtectionPolicy,
|
|
pub binding: DeviceKeyBinding,
|
|
}
|
|
|
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
|
pub struct DeviceKeyGetPublicRequest {
|
|
pub key_id: String,
|
|
}
|
|
|
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
|
pub struct DeviceKeySignRequest {
|
|
pub key_id: String,
|
|
pub payload: DeviceKeySignPayload,
|
|
}
|
|
|
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
|
pub struct DeviceKeyBinding {
|
|
pub account_user_id: String,
|
|
pub client_id: String,
|
|
}
|
|
|
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
|
pub struct DeviceKeyInfo {
|
|
pub key_id: String,
|
|
pub public_key_spki_der: Vec<u8>,
|
|
pub algorithm: DeviceKeyAlgorithm,
|
|
pub protection_class: DeviceKeyProtectionClass,
|
|
}
|
|
|
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
|
pub struct DeviceKeySignature {
|
|
pub signature_der: Vec<u8>,
|
|
/// Exact payload bytes covered by `signature_der`.
|
|
pub signed_payload: Vec<u8>,
|
|
pub algorithm: DeviceKeyAlgorithm,
|
|
}
|
|
|
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
|
struct ProviderSignature {
|
|
signature_der: Vec<u8>,
|
|
algorithm: DeviceKeyAlgorithm,
|
|
}
|
|
|
|
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
|
#[serde(tag = "type", rename_all = "camelCase")]
|
|
pub enum DeviceKeySignPayload {
|
|
RemoteControlClientConnection(RemoteControlClientConnectionSignPayload),
|
|
RemoteControlClientEnrollment(RemoteControlClientEnrollmentSignPayload),
|
|
}
|
|
|
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
|
#[serde(rename_all = "snake_case")]
|
|
pub enum RemoteControlClientConnectionAudience {
|
|
RemoteControlClientWebsocket,
|
|
}
|
|
|
|
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
|
#[serde(rename_all = "camelCase")]
|
|
pub struct RemoteControlClientConnectionSignPayload {
|
|
pub nonce: String,
|
|
pub audience: RemoteControlClientConnectionAudience,
|
|
pub session_id: String,
|
|
pub target_origin: String,
|
|
pub target_path: String,
|
|
pub account_user_id: String,
|
|
pub client_id: String,
|
|
pub token_sha256_base64url: String,
|
|
pub token_expires_at: i64,
|
|
pub scopes: Vec<String>,
|
|
}
|
|
|
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
|
#[serde(rename_all = "snake_case")]
|
|
pub enum RemoteControlClientEnrollmentAudience {
|
|
RemoteControlClientEnrollment,
|
|
}
|
|
|
|
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
|
#[serde(rename_all = "camelCase")]
|
|
pub struct RemoteControlClientEnrollmentSignPayload {
|
|
pub nonce: String,
|
|
pub audience: RemoteControlClientEnrollmentAudience,
|
|
pub challenge_id: String,
|
|
pub target_origin: String,
|
|
pub target_path: String,
|
|
pub account_user_id: String,
|
|
pub client_id: String,
|
|
pub device_identity_sha256_base64url: String,
|
|
pub challenge_expires_at: i64,
|
|
}
|
|
|
|
#[derive(Debug, Error)]
|
|
pub enum DeviceKeyError {
|
|
#[error(
|
|
"hardware-backed device keys are not available; set protectionPolicy to allow_os_protected_nonextractable to allow key protection class {available}"
|
|
)]
|
|
DegradedProtectionNotAllowed { available: DeviceKeyProtectionClass },
|
|
#[error("hardware-backed device keys are not available on this platform")]
|
|
HardwareBackedKeysUnavailable,
|
|
#[error("device key not found")]
|
|
KeyNotFound,
|
|
#[error("invalid device key payload: {0}")]
|
|
InvalidPayload(&'static str),
|
|
#[error("device key platform error: {0}")]
|
|
Platform(String),
|
|
#[error("device key cryptography error: {0}")]
|
|
Crypto(String),
|
|
}
|
|
|
|
#[derive(Debug, Clone)]
|
|
pub struct DeviceKeyStore {
|
|
provider: Arc<dyn DeviceKeyProvider>,
|
|
bindings: Arc<dyn DeviceKeyBindingStore>,
|
|
}
|
|
|
|
impl DeviceKeyStore {
|
|
pub fn new(bindings: Arc<dyn DeviceKeyBindingStore>) -> Self {
|
|
Self {
|
|
provider: platform::default_provider(),
|
|
bindings,
|
|
}
|
|
}
|
|
|
|
pub async fn create(
|
|
&self,
|
|
request: DeviceKeyCreateRequest,
|
|
) -> Result<DeviceKeyInfo, DeviceKeyError> {
|
|
let key_id_random = random_key_id_random();
|
|
validate_binding(&request.binding.account_user_id, &request.binding.client_id)?;
|
|
let provider = Arc::clone(&self.provider);
|
|
let info = spawn_provider_call(move || {
|
|
provider.create(ProviderCreateRequest {
|
|
key_id_random,
|
|
protection_policy: request.protection_policy,
|
|
})
|
|
})
|
|
.await?;
|
|
match self
|
|
.bindings
|
|
.put_binding(&info.key_id, &request.binding)
|
|
.await
|
|
{
|
|
Ok(()) => Ok(info),
|
|
Err(store_error) => {
|
|
let provider = Arc::clone(&self.provider);
|
|
let key_id = info.key_id;
|
|
let protection_class = info.protection_class;
|
|
if let Err(delete_error) =
|
|
spawn_provider_call(move || provider.delete(&key_id, protection_class)).await
|
|
{
|
|
return Err(DeviceKeyError::Platform(format!(
|
|
"failed to store device key binding ({store_error}); failed to delete newly created key ({delete_error})"
|
|
)));
|
|
}
|
|
Err(store_error)
|
|
}
|
|
}
|
|
}
|
|
|
|
pub async fn get_public(
|
|
&self,
|
|
request: DeviceKeyGetPublicRequest,
|
|
) -> Result<DeviceKeyInfo, DeviceKeyError> {
|
|
let protection_class = validate_key_id(&request.key_id)?;
|
|
let provider = Arc::clone(&self.provider);
|
|
spawn_provider_call(move || provider.get_public(&request.key_id, protection_class)).await
|
|
}
|
|
|
|
pub async fn sign(
|
|
&self,
|
|
request: DeviceKeySignRequest,
|
|
) -> Result<DeviceKeySignature, DeviceKeyError> {
|
|
let protection_class = validate_key_id(&request.key_id)?;
|
|
validate_payload(&request.payload)?;
|
|
let binding = self
|
|
.bindings
|
|
.get_binding(&request.key_id)
|
|
.await?
|
|
.ok_or(DeviceKeyError::KeyNotFound)?;
|
|
validate_payload_binding(&request.payload, &binding)?;
|
|
let signed_payload = device_key_signing_payload_bytes(&request.payload)?;
|
|
let provider = Arc::clone(&self.provider);
|
|
let key_id = request.key_id;
|
|
let provider_payload = signed_payload.clone();
|
|
let signature = spawn_provider_call(move || {
|
|
provider.sign(&key_id, protection_class, &provider_payload)
|
|
})
|
|
.await?;
|
|
Ok(DeviceKeySignature {
|
|
signature_der: signature.signature_der,
|
|
signed_payload,
|
|
algorithm: signature.algorithm,
|
|
})
|
|
}
|
|
|
|
#[cfg(test)]
|
|
fn new_for_test(provider: Arc<dyn DeviceKeyProvider>) -> Self {
|
|
Self {
|
|
provider,
|
|
bindings: Arc::new(InMemoryDeviceKeyBindingStore::default()),
|
|
}
|
|
}
|
|
}
|
|
|
|
async fn spawn_provider_call<T, F>(call: F) -> Result<T, DeviceKeyError>
|
|
where
|
|
T: Send + 'static,
|
|
F: FnOnce() -> Result<T, DeviceKeyError> + Send + 'static,
|
|
{
|
|
tokio::task::spawn_blocking(call)
|
|
.await
|
|
.map_err(|err| DeviceKeyError::Platform(format!("device key task failed: {err}")))?
|
|
}
|
|
|
|
/// Persists the account/client binding for a generated device key.
|
|
///
|
|
/// Device-key providers only own platform key material. Implementations store the binding in a
|
|
/// platform-neutral location so signing can reject payloads for the wrong account or client before
|
|
/// asking a provider to use the private key.
|
|
#[async_trait]
|
|
pub trait DeviceKeyBindingStore: Debug + Send + Sync {
|
|
async fn get_binding(&self, key_id: &str) -> Result<Option<DeviceKeyBinding>, DeviceKeyError>;
|
|
async fn put_binding(
|
|
&self,
|
|
key_id: &str,
|
|
binding: &DeviceKeyBinding,
|
|
) -> Result<(), DeviceKeyError>;
|
|
}
|
|
|
|
#[cfg(test)]
|
|
#[derive(Debug, Default)]
|
|
struct InMemoryDeviceKeyBindingStore {
|
|
bindings: std::sync::Mutex<std::collections::HashMap<String, DeviceKeyBinding>>,
|
|
}
|
|
|
|
#[cfg(test)]
|
|
#[async_trait]
|
|
impl DeviceKeyBindingStore for InMemoryDeviceKeyBindingStore {
|
|
async fn get_binding(&self, key_id: &str) -> Result<Option<DeviceKeyBinding>, DeviceKeyError> {
|
|
Ok(self
|
|
.bindings
|
|
.lock()
|
|
.map_err(|err| DeviceKeyError::Platform(err.to_string()))?
|
|
.get(key_id)
|
|
.cloned())
|
|
}
|
|
|
|
async fn put_binding(
|
|
&self,
|
|
key_id: &str,
|
|
binding: &DeviceKeyBinding,
|
|
) -> Result<(), DeviceKeyError> {
|
|
self.bindings
|
|
.lock()
|
|
.map_err(|err| DeviceKeyError::Platform(err.to_string()))?
|
|
.insert(key_id.to_string(), binding.clone());
|
|
Ok(())
|
|
}
|
|
}
|
|
|
|
#[derive(Debug)]
|
|
struct ProviderCreateRequest {
|
|
key_id_random: String,
|
|
protection_policy: DeviceKeyProtectionPolicy,
|
|
}
|
|
|
|
impl ProviderCreateRequest {
|
|
fn key_id_for(&self, protection_class: DeviceKeyProtectionClass) -> String {
|
|
key_id_for_protection_class(protection_class, &self.key_id_random)
|
|
}
|
|
}
|
|
|
|
/// Owns platform-specific non-exportable key operations for device signing.
|
|
///
|
|
/// Implementations must never expose a generic arbitrary-byte signing API outside this crate. The
|
|
/// crate validates and serializes accepted structured payloads before calling `sign`.
|
|
trait DeviceKeyProvider: Debug + Send + Sync {
|
|
fn create(&self, request: ProviderCreateRequest) -> Result<DeviceKeyInfo, DeviceKeyError>;
|
|
/// Deletes provider-owned key material after a create operation cannot be completed.
|
|
///
|
|
/// Implementations should treat missing keys as success where the platform allows it, since
|
|
/// cleanup can race with external deletion and should not mask the original persistence error
|
|
/// unless deletion itself fails unexpectedly.
|
|
fn delete(
|
|
&self,
|
|
key_id: &str,
|
|
protection_class: DeviceKeyProtectionClass,
|
|
) -> Result<(), DeviceKeyError>;
|
|
fn get_public(
|
|
&self,
|
|
key_id: &str,
|
|
protection_class: DeviceKeyProtectionClass,
|
|
) -> Result<DeviceKeyInfo, DeviceKeyError>;
|
|
fn sign(
|
|
&self,
|
|
key_id: &str,
|
|
protection_class: DeviceKeyProtectionClass,
|
|
payload: &[u8],
|
|
) -> Result<ProviderSignature, DeviceKeyError>;
|
|
}
|
|
|
|
fn random_key_id_random() -> String {
|
|
URL_SAFE_NO_PAD.encode(random::<[u8; DEVICE_KEY_ID_RANDOM_BYTES]>())
|
|
}
|
|
|
|
fn key_id_for_protection_class(
|
|
protection_class: DeviceKeyProtectionClass,
|
|
encoded_random: &str,
|
|
) -> String {
|
|
format!("{}{encoded_random}", protection_class.key_id_prefix())
|
|
}
|
|
|
|
/// Validates the account/client binding stored with a key or embedded in an accepted payload.
|
|
///
|
|
/// Providers treat the binding as metadata, so this crate keeps empty values from entering the
|
|
/// store and later matching every other empty value by accident.
|
|
fn validate_binding(account_user_id: &str, client_id: &str) -> Result<(), DeviceKeyError> {
|
|
if account_user_id.is_empty() {
|
|
return Err(DeviceKeyError::InvalidPayload(
|
|
"accountUserId must not be empty",
|
|
));
|
|
}
|
|
if client_id.is_empty() {
|
|
return Err(DeviceKeyError::InvalidPayload("clientId must not be empty"));
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
/// Keeps all externally supplied key IDs inside the random `dk_*_` namespaces created by this crate.
|
|
///
|
|
/// Platform providers use the key ID in OS-specific labels, tags, and metadata paths. Requiring the
|
|
/// exact generated shape avoids path or tag surprises and makes the namespace auditable.
|
|
fn validate_key_id(key_id: &str) -> Result<DeviceKeyProtectionClass, DeviceKeyError> {
|
|
let (protection_class, encoded_key) = parse_key_id(key_id).ok_or(
|
|
DeviceKeyError::InvalidPayload(INVALID_DEVICE_KEY_ID_MESSAGE),
|
|
)?;
|
|
if key_id.len() != DEVICE_KEY_ID_LEN {
|
|
return Err(DeviceKeyError::InvalidPayload(
|
|
INVALID_DEVICE_KEY_ID_MESSAGE,
|
|
));
|
|
}
|
|
if !URL_SAFE_NO_PAD
|
|
.decode(encoded_key)
|
|
.is_ok_and(|decoded| decoded.len() == DEVICE_KEY_ID_RANDOM_BYTES)
|
|
{
|
|
return Err(DeviceKeyError::InvalidPayload(
|
|
INVALID_DEVICE_KEY_ID_MESSAGE,
|
|
));
|
|
}
|
|
Ok(protection_class)
|
|
}
|
|
|
|
fn parse_key_id(key_id: &str) -> Option<(DeviceKeyProtectionClass, &str)> {
|
|
for protection_class in [
|
|
DeviceKeyProtectionClass::HardwareSecureEnclave,
|
|
DeviceKeyProtectionClass::HardwareTpm,
|
|
DeviceKeyProtectionClass::OsProtectedNonextractable,
|
|
] {
|
|
if let Some(encoded_key) = key_id.strip_prefix(protection_class.key_id_prefix()) {
|
|
return Some((protection_class, encoded_key));
|
|
}
|
|
}
|
|
None
|
|
}
|
|
|
|
/// Confirms the signed payload is for the same account/client binding as the selected device key.
|
|
///
|
|
/// The provider can prove continuity of the key material, but app-server authorization depends on
|
|
/// binding that key to the same account and client identity used by the remote-control flow.
|
|
fn validate_payload_binding(
|
|
payload: &DeviceKeySignPayload,
|
|
binding: &DeviceKeyBinding,
|
|
) -> Result<(), DeviceKeyError> {
|
|
let (account_user_id, client_id) = match payload {
|
|
DeviceKeySignPayload::RemoteControlClientConnection(payload) => {
|
|
(&payload.account_user_id, &payload.client_id)
|
|
}
|
|
DeviceKeySignPayload::RemoteControlClientEnrollment(payload) => {
|
|
(&payload.account_user_id, &payload.client_id)
|
|
}
|
|
};
|
|
if account_user_id != &binding.account_user_id || client_id != &binding.client_id {
|
|
return Err(DeviceKeyError::InvalidPayload(
|
|
"payload accountUserId/clientId does not match device key binding",
|
|
));
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
/// Dispatches validation by accepted payload shape before any provider sees bytes to sign.
|
|
///
|
|
/// The enum is intentionally narrow so adding another signing use case requires defining and
|
|
/// validating a new structured payload variant here.
|
|
fn validate_payload(payload: &DeviceKeySignPayload) -> Result<(), DeviceKeyError> {
|
|
match payload {
|
|
DeviceKeySignPayload::RemoteControlClientConnection(payload) => {
|
|
validate_remote_control_client_connection_payload(payload)
|
|
}
|
|
DeviceKeySignPayload::RemoteControlClientEnrollment(payload) => {
|
|
validate_remote_control_client_enrollment_payload(payload)
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Validates payloads used to prove device-key ownership while opening `/client`.
|
|
///
|
|
/// This shape is scoped to a single controller websocket connection and is only allowed to target
|
|
/// the non-enrollment remote-control client endpoints.
|
|
fn validate_remote_control_client_connection_payload(
|
|
payload: &RemoteControlClientConnectionSignPayload,
|
|
) -> Result<(), DeviceKeyError> {
|
|
validate_nonce(&payload.nonce)?;
|
|
validate_remote_control_target(
|
|
&payload.target_origin,
|
|
&payload.target_path,
|
|
REMOTE_CONTROL_CLIENT_CONNECTION_PATHS,
|
|
)?;
|
|
if payload.session_id.is_empty() {
|
|
return Err(DeviceKeyError::InvalidPayload(
|
|
"sessionId must not be empty",
|
|
));
|
|
}
|
|
validate_binding(&payload.account_user_id, &payload.client_id)?;
|
|
if !is_base64url_sha256(&payload.token_sha256_base64url) {
|
|
return Err(DeviceKeyError::InvalidPayload(
|
|
"tokenSha256Base64url must be a SHA-256 digest encoded as unpadded base64url",
|
|
));
|
|
}
|
|
if payload.scopes != [REMOTE_CONTROL_CONTROLLER_WEBSOCKET_SCOPE] {
|
|
return Err(DeviceKeyError::InvalidPayload(
|
|
"scopes must contain exactly remote_control_controller_websocket",
|
|
));
|
|
}
|
|
validate_remote_control_expiry(payload.token_expires_at, "remote-control token")?;
|
|
Ok(())
|
|
}
|
|
|
|
/// Validates payloads used during device-key enrollment.
|
|
///
|
|
/// Enrollment has a distinct payload shape and challenge identifier, so it also carries a distinct
|
|
/// endpoint allowlist from connection proofs.
|
|
fn validate_remote_control_client_enrollment_payload(
|
|
payload: &RemoteControlClientEnrollmentSignPayload,
|
|
) -> Result<(), DeviceKeyError> {
|
|
validate_nonce(&payload.nonce)?;
|
|
if payload.challenge_id.is_empty() {
|
|
return Err(DeviceKeyError::InvalidPayload(
|
|
"challengeId must not be empty",
|
|
));
|
|
}
|
|
validate_remote_control_target(
|
|
&payload.target_origin,
|
|
&payload.target_path,
|
|
REMOTE_CONTROL_CLIENT_ENROLLMENT_PATHS,
|
|
)?;
|
|
validate_binding(&payload.account_user_id, &payload.client_id)?;
|
|
if !is_base64url_sha256(&payload.device_identity_sha256_base64url) {
|
|
return Err(DeviceKeyError::InvalidPayload(
|
|
"deviceIdentitySha256Base64url must be a SHA-256 digest encoded as unpadded base64url",
|
|
));
|
|
}
|
|
validate_remote_control_expiry(payload.challenge_expires_at, "enrollment challenge")?;
|
|
Ok(())
|
|
}
|
|
|
|
/// Requires a fresh server-issued challenge with enough entropy to prevent replay guessing.
|
|
fn validate_nonce(nonce: &str) -> Result<(), DeviceKeyError> {
|
|
if !URL_SAFE_NO_PAD
|
|
.decode(nonce)
|
|
.is_ok_and(|decoded| decoded.len() >= 32)
|
|
{
|
|
return Err(DeviceKeyError::InvalidPayload(
|
|
"nonce must be at least 32 random bytes encoded as unpadded base64url",
|
|
));
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
/// Validates the remote backend origin and the endpoint set for the specific signed payload shape.
|
|
///
|
|
/// Keeping the path allowlist as an argument makes it hard to accidentally let enrollment payloads
|
|
/// sign connection endpoints, or connection payloads sign enrollment endpoints.
|
|
fn validate_remote_control_target(
|
|
target_origin: &str,
|
|
target_path: &str,
|
|
allowed_target_paths: &[&str],
|
|
) -> Result<(), DeviceKeyError> {
|
|
if !is_allowed_remote_control_origin(target_origin) {
|
|
return Err(DeviceKeyError::InvalidPayload(
|
|
"targetOrigin must be an allowed remote-control backend origin",
|
|
));
|
|
}
|
|
if !allowed_target_paths.contains(&target_path) {
|
|
return Err(DeviceKeyError::InvalidPayload(
|
|
"targetPath must match the signed payload type's remote-control endpoint",
|
|
));
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
/// Mirrors the remote-control transport allowlist for origins that may receive signed proofs.
|
|
fn is_allowed_remote_control_origin(target_origin: &str) -> bool {
|
|
let Ok(url) = Url::parse(target_origin) else {
|
|
return false;
|
|
};
|
|
if url.path() != "/" || url.query().is_some() || url.fragment().is_some() {
|
|
return false;
|
|
}
|
|
let host = url.host();
|
|
match url.scheme() {
|
|
"https" if is_localhost(&host) || is_allowed_chatgpt_host(&host) => true,
|
|
"http" if is_localhost(&host) => true,
|
|
_ => false,
|
|
}
|
|
}
|
|
|
|
/// Accepts first-party chatgpt.com hosts and staging equivalents, including subdomains.
|
|
fn is_allowed_chatgpt_host(host: &Option<Host<&str>>) -> bool {
|
|
let Some(Host::Domain(host)) = *host else {
|
|
return false;
|
|
};
|
|
host == "chatgpt.com"
|
|
|| host == "chatgpt-staging.com"
|
|
|| host.ends_with(".chatgpt.com")
|
|
|| host.ends_with(".chatgpt-staging.com")
|
|
}
|
|
|
|
/// Allows local development endpoints without opening access to arbitrary private-network hosts.
|
|
fn is_localhost(host: &Option<Host<&str>>) -> bool {
|
|
match host {
|
|
Some(Host::Domain("localhost")) => true,
|
|
Some(Host::Ipv4(ip)) => ip.is_loopback(),
|
|
Some(Host::Ipv6(ip)) => ip.is_loopback(),
|
|
_ => false,
|
|
}
|
|
}
|
|
|
|
/// Bounds remote-control proofs to the connection or enrollment attempt that requested them.
|
|
fn validate_remote_control_expiry(
|
|
expires_at: i64,
|
|
label: &'static str,
|
|
) -> Result<(), DeviceKeyError> {
|
|
let now = current_unix_seconds()?;
|
|
if expires_at <= now {
|
|
return Err(DeviceKeyError::InvalidPayload(match label {
|
|
"enrollment challenge" => "enrollment challenge is expired",
|
|
_ => "remote-control token is expired",
|
|
}));
|
|
}
|
|
if expires_at > now + MAX_REMOTE_CONTROL_DEVICE_KEY_PROOF_TTL_SECONDS {
|
|
return Err(DeviceKeyError::InvalidPayload(match label {
|
|
"enrollment challenge" => "enrollment challenge expires too far in the future",
|
|
_ => "remote-control token expires too far in the future",
|
|
}));
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
/// Checks the exact digest encoding used in remote-control challenge and token bindings.
|
|
fn is_base64url_sha256(value: &str) -> bool {
|
|
URL_SAFE_NO_PAD
|
|
.decode(value)
|
|
.is_ok_and(|digest| digest.len() == 32)
|
|
}
|
|
|
|
fn current_unix_seconds() -> Result<i64, DeviceKeyError> {
|
|
let duration = SystemTime::now()
|
|
.duration_since(UNIX_EPOCH)
|
|
.map_err(|_| DeviceKeyError::InvalidPayload("system clock is before Unix epoch"))?;
|
|
i64::try_from(duration.as_secs())
|
|
.map_err(|_| DeviceKeyError::InvalidPayload("current time does not fit in i64"))
|
|
}
|
|
|
|
/// Returns the exact bytes that device-key providers sign and verifiers must check.
|
|
///
|
|
/// The representation is UTF-8 JSON with an explicit domain separator, sorted object keys, no
|
|
/// insignificant whitespace, and the accepted structured payload. Test vectors in this crate
|
|
/// intentionally lock the field names and ordering so non-Rust verifiers can reproduce the same
|
|
/// bytes.
|
|
pub fn device_key_signing_payload_bytes(
|
|
payload: &DeviceKeySignPayload,
|
|
) -> Result<Vec<u8>, DeviceKeyError> {
|
|
let mut canonical = serde_json::to_value(SignedPayload {
|
|
domain: SIGNING_DOMAIN,
|
|
payload,
|
|
})
|
|
.map_err(|err| DeviceKeyError::Crypto(err.to_string()))?;
|
|
canonical.sort_all_objects();
|
|
serde_json::to_vec(&canonical).map_err(|err| DeviceKeyError::Crypto(err.to_string()))
|
|
}
|
|
|
|
#[derive(Serialize)]
|
|
struct SignedPayload<'a> {
|
|
domain: &'static str,
|
|
payload: &'a DeviceKeySignPayload,
|
|
}
|
|
|
|
#[allow(dead_code)]
|
|
fn sec1_public_key_to_spki_der(sec1_public_key: &[u8]) -> Result<Vec<u8>, DeviceKeyError> {
|
|
let public_key = p256::PublicKey::from_sec1_bytes(sec1_public_key)
|
|
.map_err(|err| DeviceKeyError::Crypto(err.to_string()))?;
|
|
public_key
|
|
.to_public_key_der()
|
|
.map(|der| der.as_bytes().to_vec())
|
|
.map_err(|err| DeviceKeyError::Crypto(err.to_string()))
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
use p256::ecdsa::Signature;
|
|
use p256::ecdsa::SigningKey;
|
|
use p256::ecdsa::VerifyingKey;
|
|
use p256::ecdsa::signature::Signer;
|
|
use p256::ecdsa::signature::Verifier;
|
|
use p256::elliptic_curve::rand_core::OsRng;
|
|
use p256::pkcs8::DecodePublicKey;
|
|
use pretty_assertions::assert_eq;
|
|
use std::collections::HashMap;
|
|
use std::sync::Mutex;
|
|
|
|
const TEST_TOKEN_SHA256_BASE64URL: &str = "47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU";
|
|
const TEST_NONCE_BASE64URL: &str = "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA";
|
|
|
|
#[derive(Debug)]
|
|
struct MemoryProvider {
|
|
class: DeviceKeyProtectionClass,
|
|
keys: Mutex<HashMap<String, SigningKey>>,
|
|
}
|
|
|
|
impl MemoryProvider {
|
|
fn new(class: DeviceKeyProtectionClass) -> Self {
|
|
Self {
|
|
class,
|
|
keys: Mutex::new(HashMap::new()),
|
|
}
|
|
}
|
|
|
|
fn key_count(&self) -> usize {
|
|
self.keys.lock().expect("memory provider lock").len()
|
|
}
|
|
}
|
|
|
|
impl DeviceKeyProvider for MemoryProvider {
|
|
fn create(&self, request: ProviderCreateRequest) -> Result<DeviceKeyInfo, DeviceKeyError> {
|
|
if !request.protection_policy.allows(self.class) {
|
|
return Err(DeviceKeyError::DegradedProtectionNotAllowed {
|
|
available: self.class,
|
|
});
|
|
}
|
|
let key_id = request.key_id_for(self.class);
|
|
let mut keys = self
|
|
.keys
|
|
.lock()
|
|
.map_err(|err| DeviceKeyError::Platform(err.to_string()))?;
|
|
let signing_key = keys
|
|
.entry(key_id.clone())
|
|
.or_insert_with(|| SigningKey::random(&mut OsRng));
|
|
memory_key_info(&key_id, signing_key, self.class)
|
|
}
|
|
|
|
fn delete(
|
|
&self,
|
|
key_id: &str,
|
|
protection_class: DeviceKeyProtectionClass,
|
|
) -> Result<(), DeviceKeyError> {
|
|
if protection_class != self.class {
|
|
return Ok(());
|
|
}
|
|
self.keys
|
|
.lock()
|
|
.map_err(|err| DeviceKeyError::Platform(err.to_string()))?
|
|
.remove(key_id);
|
|
Ok(())
|
|
}
|
|
|
|
fn get_public(
|
|
&self,
|
|
key_id: &str,
|
|
protection_class: DeviceKeyProtectionClass,
|
|
) -> Result<DeviceKeyInfo, DeviceKeyError> {
|
|
if protection_class != self.class {
|
|
return Err(DeviceKeyError::KeyNotFound);
|
|
}
|
|
let keys = self
|
|
.keys
|
|
.lock()
|
|
.map_err(|err| DeviceKeyError::Platform(err.to_string()))?;
|
|
let signing_key = keys.get(key_id).ok_or(DeviceKeyError::KeyNotFound)?;
|
|
memory_key_info(key_id, signing_key, self.class)
|
|
}
|
|
|
|
fn sign(
|
|
&self,
|
|
key_id: &str,
|
|
protection_class: DeviceKeyProtectionClass,
|
|
payload: &[u8],
|
|
) -> Result<ProviderSignature, DeviceKeyError> {
|
|
if protection_class != self.class {
|
|
return Err(DeviceKeyError::KeyNotFound);
|
|
}
|
|
let keys = self
|
|
.keys
|
|
.lock()
|
|
.map_err(|err| DeviceKeyError::Platform(err.to_string()))?;
|
|
let signing_key = keys.get(key_id).ok_or(DeviceKeyError::KeyNotFound)?;
|
|
let signature: Signature = signing_key.sign(payload);
|
|
Ok(ProviderSignature {
|
|
signature_der: signature.to_der().as_bytes().to_vec(),
|
|
algorithm: DeviceKeyAlgorithm::EcdsaP256Sha256,
|
|
})
|
|
}
|
|
}
|
|
|
|
#[derive(Debug)]
|
|
struct FailingBindingStore;
|
|
|
|
#[async_trait]
|
|
impl DeviceKeyBindingStore for FailingBindingStore {
|
|
async fn get_binding(
|
|
&self,
|
|
_key_id: &str,
|
|
) -> Result<Option<DeviceKeyBinding>, DeviceKeyError> {
|
|
Ok(None)
|
|
}
|
|
|
|
async fn put_binding(
|
|
&self,
|
|
_key_id: &str,
|
|
_binding: &DeviceKeyBinding,
|
|
) -> Result<(), DeviceKeyError> {
|
|
Err(DeviceKeyError::Platform("binding write failed".to_string()))
|
|
}
|
|
}
|
|
|
|
fn memory_key_info(
|
|
key_id: &str,
|
|
signing_key: &SigningKey,
|
|
class: DeviceKeyProtectionClass,
|
|
) -> Result<DeviceKeyInfo, DeviceKeyError> {
|
|
let public_key_spki_der = signing_key
|
|
.verifying_key()
|
|
.to_public_key_der()
|
|
.map_err(|err| DeviceKeyError::Crypto(err.to_string()))?
|
|
.as_bytes()
|
|
.to_vec();
|
|
Ok(DeviceKeyInfo {
|
|
key_id: key_id.to_string(),
|
|
public_key_spki_der,
|
|
algorithm: DeviceKeyAlgorithm::EcdsaP256Sha256,
|
|
protection_class: class,
|
|
})
|
|
}
|
|
|
|
fn store(class: DeviceKeyProtectionClass) -> DeviceKeyStore {
|
|
DeviceKeyStore::new_for_test(Arc::new(MemoryProvider::new(class)))
|
|
}
|
|
|
|
fn block_on<T>(future: impl std::future::Future<Output = T>) -> T {
|
|
tokio::runtime::Builder::new_current_thread()
|
|
.build()
|
|
.expect("build test runtime")
|
|
.block_on(future)
|
|
}
|
|
|
|
fn create_request(protection_policy: DeviceKeyProtectionPolicy) -> DeviceKeyCreateRequest {
|
|
DeviceKeyCreateRequest {
|
|
protection_policy,
|
|
binding: DeviceKeyBinding {
|
|
account_user_id: "account-user-1".to_string(),
|
|
client_id: "cli_123".to_string(),
|
|
},
|
|
}
|
|
}
|
|
|
|
fn remote_control_client_connection_payload() -> DeviceKeySignPayload {
|
|
DeviceKeySignPayload::RemoteControlClientConnection(
|
|
RemoteControlClientConnectionSignPayload {
|
|
nonce: TEST_NONCE_BASE64URL.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: TEST_TOKEN_SHA256_BASE64URL.to_string(),
|
|
token_expires_at: current_unix_seconds().expect("time should be valid") + 60,
|
|
scopes: vec![REMOTE_CONTROL_CONTROLLER_WEBSOCKET_SCOPE.to_string()],
|
|
},
|
|
)
|
|
}
|
|
|
|
fn remote_control_client_enrollment_payload() -> DeviceKeySignPayload {
|
|
DeviceKeySignPayload::RemoteControlClientEnrollment(
|
|
RemoteControlClientEnrollmentSignPayload {
|
|
nonce: TEST_NONCE_BASE64URL.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: TEST_TOKEN_SHA256_BASE64URL.to_string(),
|
|
challenge_expires_at: current_unix_seconds().expect("time should be valid") + 60,
|
|
},
|
|
)
|
|
}
|
|
|
|
fn assert_valid_generated_key_id(key_id: &str, expected_class: DeviceKeyProtectionClass) {
|
|
assert_eq!(key_id.len(), DEVICE_KEY_ID_LEN);
|
|
assert_eq!(
|
|
validate_key_id(key_id).expect("generated key id should be valid"),
|
|
expected_class
|
|
);
|
|
let encoded_key = key_id
|
|
.strip_prefix(expected_class.key_id_prefix())
|
|
.expect("generated key id should use protection-class prefix");
|
|
assert_eq!(encoded_key.len(), DEVICE_KEY_ID_ENCODED_BYTES);
|
|
assert_eq!(
|
|
URL_SAFE_NO_PAD
|
|
.decode(encoded_key)
|
|
.expect("generated key id should be base64url")
|
|
.len(),
|
|
DEVICE_KEY_ID_RANDOM_BYTES
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn create_requires_explicit_degraded_protection() {
|
|
let err = block_on(
|
|
store(DeviceKeyProtectionClass::OsProtectedNonextractable)
|
|
.create(create_request(DeviceKeyProtectionPolicy::HardwareOnly)),
|
|
)
|
|
.expect_err("OS-protected fallback should require opt-in");
|
|
|
|
assert!(
|
|
matches!(
|
|
err,
|
|
DeviceKeyError::DegradedProtectionNotAllowed {
|
|
available: DeviceKeyProtectionClass::OsProtectedNonextractable,
|
|
}
|
|
),
|
|
"unexpected error: {err:?}"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn create_allows_os_protected_nonextractable_policy() {
|
|
let info = block_on(
|
|
store(DeviceKeyProtectionClass::OsProtectedNonextractable).create(create_request(
|
|
DeviceKeyProtectionPolicy::AllowOsProtectedNonextractable,
|
|
)),
|
|
)
|
|
.expect("OS-protected fallback should be allowed by policy");
|
|
|
|
assert_eq!(
|
|
info.protection_class,
|
|
DeviceKeyProtectionClass::OsProtectedNonextractable
|
|
);
|
|
assert_valid_generated_key_id(
|
|
&info.key_id,
|
|
DeviceKeyProtectionClass::OsProtectedNonextractable,
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn create_generates_distinct_key_ids() {
|
|
let store = store(DeviceKeyProtectionClass::HardwareTpm);
|
|
let first = block_on(store.create(create_request(DeviceKeyProtectionPolicy::HardwareOnly)))
|
|
.expect("create should succeed");
|
|
let second =
|
|
block_on(store.create(create_request(DeviceKeyProtectionPolicy::HardwareOnly)))
|
|
.expect("create should succeed");
|
|
|
|
assert_ne!(second.key_id, first.key_id);
|
|
assert_valid_generated_key_id(&first.key_id, DeviceKeyProtectionClass::HardwareTpm);
|
|
assert_valid_generated_key_id(&second.key_id, DeviceKeyProtectionClass::HardwareTpm);
|
|
}
|
|
|
|
#[test]
|
|
fn create_deletes_provider_key_when_binding_write_fails() {
|
|
let provider = Arc::new(MemoryProvider::new(DeviceKeyProtectionClass::HardwareTpm));
|
|
let store = DeviceKeyStore {
|
|
provider: provider.clone(),
|
|
bindings: Arc::new(FailingBindingStore),
|
|
};
|
|
|
|
let err = block_on(store.create(create_request(DeviceKeyProtectionPolicy::HardwareOnly)))
|
|
.expect_err("binding failure should fail create");
|
|
|
|
assert!(
|
|
matches!(
|
|
&err,
|
|
DeviceKeyError::Platform(message) if message == "binding write failed"
|
|
),
|
|
"unexpected error: {err:?}"
|
|
);
|
|
assert_eq!(provider.key_count(), 0);
|
|
}
|
|
|
|
#[test]
|
|
fn key_id_validation_rejects_untrusted_namespaces() {
|
|
let valid_suffix = URL_SAFE_NO_PAD.encode([0_u8; DEVICE_KEY_ID_RANDOM_BYTES]);
|
|
|
|
for key_id in [
|
|
String::new(),
|
|
"dk_".to_string(),
|
|
"dk_hse_".to_string(),
|
|
format!("bad_{valid_suffix}"),
|
|
format!("dk_bad_{valid_suffix}"),
|
|
format!(
|
|
"{}{}",
|
|
DeviceKeyProtectionClass::HardwareSecureEnclave.key_id_prefix(),
|
|
&valid_suffix[..DEVICE_KEY_ID_ENCODED_BYTES - 1]
|
|
),
|
|
format!(
|
|
"{}{valid_suffix}A",
|
|
DeviceKeyProtectionClass::HardwareTpm.key_id_prefix()
|
|
),
|
|
format!(
|
|
"{}{}=",
|
|
DeviceKeyProtectionClass::OsProtectedNonextractable.key_id_prefix(),
|
|
&valid_suffix[..DEVICE_KEY_ID_ENCODED_BYTES - 1]
|
|
),
|
|
format!(
|
|
"{}{}+",
|
|
DeviceKeyProtectionClass::HardwareSecureEnclave.key_id_prefix(),
|
|
&valid_suffix[..DEVICE_KEY_ID_ENCODED_BYTES - 1]
|
|
),
|
|
] {
|
|
let err = validate_key_id(&key_id).expect_err("malformed key id should fail");
|
|
assert!(
|
|
matches!(
|
|
err,
|
|
DeviceKeyError::InvalidPayload(INVALID_DEVICE_KEY_ID_MESSAGE)
|
|
),
|
|
"unexpected error for {key_id:?}: {err:?}"
|
|
);
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn public_operations_reject_malformed_key_id_before_provider_use() {
|
|
let store = store(DeviceKeyProtectionClass::HardwareTpm);
|
|
let malformed_key_id = "not-a-device-key".to_string();
|
|
|
|
let err = block_on(store.get_public(DeviceKeyGetPublicRequest {
|
|
key_id: malformed_key_id.clone(),
|
|
}))
|
|
.expect_err("malformed get_public key id should fail");
|
|
assert!(
|
|
matches!(
|
|
err,
|
|
DeviceKeyError::InvalidPayload(INVALID_DEVICE_KEY_ID_MESSAGE)
|
|
),
|
|
"unexpected get_public error: {err:?}"
|
|
);
|
|
|
|
let err = block_on(store.sign(DeviceKeySignRequest {
|
|
key_id: malformed_key_id,
|
|
payload: remote_control_client_connection_payload(),
|
|
}))
|
|
.expect_err("malformed sign key id should fail");
|
|
assert!(
|
|
matches!(
|
|
err,
|
|
DeviceKeyError::InvalidPayload(INVALID_DEVICE_KEY_ID_MESSAGE)
|
|
),
|
|
"unexpected sign error: {err:?}"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn sign_rejects_empty_account_user_id() {
|
|
let store = store(DeviceKeyProtectionClass::HardwareTpm);
|
|
let info = block_on(store.create(create_request(DeviceKeyProtectionPolicy::HardwareOnly)))
|
|
.expect("create should succeed");
|
|
let mut payload = remote_control_client_connection_payload();
|
|
match &mut payload {
|
|
DeviceKeySignPayload::RemoteControlClientConnection(connection_payload) => {
|
|
connection_payload.account_user_id.clear();
|
|
}
|
|
DeviceKeySignPayload::RemoteControlClientEnrollment(_) => unreachable!(),
|
|
}
|
|
|
|
let err = block_on(store.sign(DeviceKeySignRequest {
|
|
key_id: info.key_id,
|
|
payload,
|
|
}))
|
|
.expect_err("empty account user id should fail");
|
|
|
|
assert!(
|
|
matches!(
|
|
err,
|
|
DeviceKeyError::InvalidPayload("accountUserId must not be empty")
|
|
),
|
|
"unexpected error: {err:?}"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn sign_uses_structured_payload() {
|
|
let store = store(DeviceKeyProtectionClass::HardwareTpm);
|
|
let info = block_on(store.create(create_request(DeviceKeyProtectionPolicy::HardwareOnly)))
|
|
.expect("create should succeed");
|
|
let payload = remote_control_client_connection_payload();
|
|
let signed_payload =
|
|
device_key_signing_payload_bytes(&payload).expect("payload should serialize");
|
|
let signature = block_on(store.sign(DeviceKeySignRequest {
|
|
key_id: info.key_id,
|
|
payload,
|
|
}))
|
|
.expect("sign should succeed");
|
|
assert_eq!(signature.signed_payload, signed_payload);
|
|
|
|
let verifying_key = VerifyingKey::from_public_key_der(&info.public_key_spki_der)
|
|
.expect("public key should decode");
|
|
let signature =
|
|
Signature::from_der(&signature.signature_der).expect("signature should decode");
|
|
verifying_key
|
|
.verify(&signed_payload, &signature)
|
|
.expect("signature should verify against structured payload");
|
|
}
|
|
|
|
#[test]
|
|
fn signing_payload_bytes_are_stable() {
|
|
let payload = DeviceKeySignPayload::RemoteControlClientConnection(
|
|
RemoteControlClientConnectionSignPayload {
|
|
nonce: TEST_NONCE_BASE64URL.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: TEST_TOKEN_SHA256_BASE64URL.to_string(),
|
|
token_expires_at: 1_700_000_000,
|
|
scopes: vec![REMOTE_CONTROL_CONTROLLER_WEBSOCKET_SCOPE.to_string()],
|
|
},
|
|
);
|
|
|
|
let bytes = device_key_signing_payload_bytes(&payload).expect("payload should serialize");
|
|
|
|
assert_eq!(
|
|
String::from_utf8(bytes).expect("payload should be utf-8"),
|
|
concat!(
|
|
"{\"domain\":\"codex-device-key-sign-payload/v1\",",
|
|
"\"payload\":{\"accountUserId\":\"account-user-1\",",
|
|
"\"audience\":\"remote_control_client_websocket\",",
|
|
"\"clientId\":\"cli_123\",",
|
|
"\"nonce\":\"AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA\",",
|
|
"\"scopes\":[\"remote_control_controller_websocket\"],",
|
|
"\"sessionId\":\"wssess_123\",",
|
|
"\"targetOrigin\":\"https://chatgpt.com\",",
|
|
"\"targetPath\":\"/api/codex/remote/control/client\",",
|
|
"\"tokenExpiresAt\":1700000000,",
|
|
"\"tokenSha256Base64url\":\"47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU\",",
|
|
"\"type\":\"remoteControlClientConnection\"}}"
|
|
)
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn enrollment_signing_payload_bytes_are_stable() {
|
|
let payload = DeviceKeySignPayload::RemoteControlClientEnrollment(
|
|
RemoteControlClientEnrollmentSignPayload {
|
|
nonce: TEST_NONCE_BASE64URL.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: TEST_TOKEN_SHA256_BASE64URL.to_string(),
|
|
challenge_expires_at: 1_700_000_060,
|
|
},
|
|
);
|
|
|
|
let bytes = device_key_signing_payload_bytes(&payload).expect("payload should serialize");
|
|
|
|
assert_eq!(
|
|
String::from_utf8(bytes).expect("payload should be utf-8"),
|
|
concat!(
|
|
"{\"domain\":\"codex-device-key-sign-payload/v1\",",
|
|
"\"payload\":{\"accountUserId\":\"account-user-1\",",
|
|
"\"audience\":\"remote_control_client_enrollment\",",
|
|
"\"challengeExpiresAt\":1700000060,",
|
|
"\"challengeId\":\"rch_123\",",
|
|
"\"clientId\":\"cli_123\",",
|
|
"\"deviceIdentitySha256Base64url\":\"47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU\",",
|
|
"\"nonce\":\"AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA\",",
|
|
"\"targetOrigin\":\"https://chatgpt.com\",",
|
|
"\"targetPath\":\"/wham/remote/control/client/enroll\",",
|
|
"\"type\":\"remoteControlClientEnrollment\"}}"
|
|
)
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn sign_rejects_malformed_token_hash() {
|
|
let store = store(DeviceKeyProtectionClass::HardwareTpm);
|
|
let info = block_on(store.create(create_request(DeviceKeyProtectionPolicy::HardwareOnly)))
|
|
.expect("create should succeed");
|
|
let mut payload = remote_control_client_connection_payload();
|
|
match &mut payload {
|
|
DeviceKeySignPayload::RemoteControlClientConnection(connection_payload) => {
|
|
connection_payload.token_sha256_base64url = "not-a-sha256".to_string();
|
|
}
|
|
DeviceKeySignPayload::RemoteControlClientEnrollment(_) => unreachable!(),
|
|
}
|
|
|
|
let err = block_on(store.sign(DeviceKeySignRequest {
|
|
key_id: info.key_id,
|
|
payload,
|
|
}))
|
|
.expect_err("malformed token hash should fail");
|
|
|
|
assert!(
|
|
matches!(
|
|
err,
|
|
DeviceKeyError::InvalidPayload(
|
|
"tokenSha256Base64url must be a SHA-256 digest encoded as unpadded base64url"
|
|
)
|
|
),
|
|
"unexpected error: {err:?}"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn sign_rejects_unexpected_scopes() {
|
|
let store = store(DeviceKeyProtectionClass::HardwareTpm);
|
|
let info = block_on(store.create(create_request(DeviceKeyProtectionPolicy::HardwareOnly)))
|
|
.expect("create should succeed");
|
|
let mut payload = remote_control_client_connection_payload();
|
|
match &mut payload {
|
|
DeviceKeySignPayload::RemoteControlClientConnection(connection_payload) => {
|
|
connection_payload.scopes = vec!["other_scope".to_string()];
|
|
}
|
|
DeviceKeySignPayload::RemoteControlClientEnrollment(_) => unreachable!(),
|
|
}
|
|
|
|
let err = block_on(store.sign(DeviceKeySignRequest {
|
|
key_id: info.key_id,
|
|
payload,
|
|
}))
|
|
.expect_err("unexpected scope should fail");
|
|
|
|
assert!(
|
|
matches!(
|
|
err,
|
|
DeviceKeyError::InvalidPayload(
|
|
"scopes must contain exactly remote_control_controller_websocket"
|
|
)
|
|
),
|
|
"unexpected error: {err:?}"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn sign_rejects_malformed_enrollment_identity_hash() {
|
|
let store = store(DeviceKeyProtectionClass::HardwareTpm);
|
|
let info = block_on(store.create(create_request(DeviceKeyProtectionPolicy::HardwareOnly)))
|
|
.expect("create should succeed");
|
|
let mut payload = remote_control_client_enrollment_payload();
|
|
match &mut payload {
|
|
DeviceKeySignPayload::RemoteControlClientEnrollment(enrollment_payload) => {
|
|
enrollment_payload.device_identity_sha256_base64url = "not-a-sha256".to_string();
|
|
}
|
|
DeviceKeySignPayload::RemoteControlClientConnection(_) => unreachable!(),
|
|
}
|
|
|
|
let err = block_on(store.sign(DeviceKeySignRequest {
|
|
key_id: info.key_id,
|
|
payload,
|
|
}))
|
|
.expect_err("malformed device identity hash should fail");
|
|
|
|
assert!(
|
|
matches!(
|
|
err,
|
|
DeviceKeyError::InvalidPayload(
|
|
"deviceIdentitySha256Base64url must be a SHA-256 digest encoded as unpadded base64url"
|
|
)
|
|
),
|
|
"unexpected error: {err:?}"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn sign_rejects_empty_target_binding() {
|
|
let store = store(DeviceKeyProtectionClass::HardwareTpm);
|
|
let info = block_on(store.create(create_request(DeviceKeyProtectionPolicy::HardwareOnly)))
|
|
.expect("create should succeed");
|
|
let mut payload = remote_control_client_connection_payload();
|
|
match &mut payload {
|
|
DeviceKeySignPayload::RemoteControlClientConnection(connection_payload) => {
|
|
connection_payload.target_origin.clear();
|
|
}
|
|
DeviceKeySignPayload::RemoteControlClientEnrollment(_) => unreachable!(),
|
|
}
|
|
|
|
let err = block_on(store.sign(DeviceKeySignRequest {
|
|
key_id: info.key_id,
|
|
payload,
|
|
}))
|
|
.expect_err("empty target origin should fail");
|
|
|
|
assert!(
|
|
matches!(
|
|
err,
|
|
DeviceKeyError::InvalidPayload(
|
|
"targetOrigin must be an allowed remote-control backend origin"
|
|
)
|
|
),
|
|
"unexpected error: {err:?}"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn sign_rejects_remote_control_paths_for_other_payload_shapes() {
|
|
let store = store(DeviceKeyProtectionClass::HardwareTpm);
|
|
let info = block_on(store.create(create_request(DeviceKeyProtectionPolicy::HardwareOnly)))
|
|
.expect("create should succeed");
|
|
let mut connection_payload = remote_control_client_connection_payload();
|
|
match &mut connection_payload {
|
|
DeviceKeySignPayload::RemoteControlClientConnection(payload) => {
|
|
payload.target_path = "/api/codex/remote/control/client/enroll".to_string();
|
|
}
|
|
DeviceKeySignPayload::RemoteControlClientEnrollment(_) => unreachable!(),
|
|
}
|
|
|
|
let err = block_on(store.sign(DeviceKeySignRequest {
|
|
key_id: info.key_id.clone(),
|
|
payload: connection_payload,
|
|
}))
|
|
.expect_err("connection payload should reject enrollment path");
|
|
assert!(
|
|
matches!(
|
|
err,
|
|
DeviceKeyError::InvalidPayload(
|
|
"targetPath must match the signed payload type's remote-control endpoint"
|
|
)
|
|
),
|
|
"unexpected connection path error: {err:?}"
|
|
);
|
|
|
|
let mut enrollment_payload = remote_control_client_enrollment_payload();
|
|
match &mut enrollment_payload {
|
|
DeviceKeySignPayload::RemoteControlClientEnrollment(payload) => {
|
|
payload.target_path = "/wham/remote/control/client".to_string();
|
|
}
|
|
DeviceKeySignPayload::RemoteControlClientConnection(_) => unreachable!(),
|
|
}
|
|
|
|
let err = block_on(store.sign(DeviceKeySignRequest {
|
|
key_id: info.key_id,
|
|
payload: enrollment_payload,
|
|
}))
|
|
.expect_err("enrollment payload should reject connection path");
|
|
assert!(
|
|
matches!(
|
|
err,
|
|
DeviceKeyError::InvalidPayload(
|
|
"targetPath must match the signed payload type's remote-control endpoint"
|
|
)
|
|
),
|
|
"unexpected enrollment path error: {err:?}"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn remote_control_origin_matches_remote_transport_allowlist() {
|
|
for origin in [
|
|
"https://chatgpt.com",
|
|
"https://chatgpt-staging.com",
|
|
"https://ab.chatgpt.com",
|
|
"https://ab.chatgpt-staging.com",
|
|
"http://localhost:8080",
|
|
"https://localhost:8443",
|
|
"http://127.0.0.1:8080",
|
|
"http://[::1]:8080",
|
|
] {
|
|
assert!(
|
|
is_allowed_remote_control_origin(origin),
|
|
"expected allowed origin: {origin}"
|
|
);
|
|
}
|
|
|
|
for origin in [
|
|
"http://chatgpt.com",
|
|
"https://chat.openai.com",
|
|
"https://api.openai.com",
|
|
"https://chatgpt.com.evil.com",
|
|
"https://evilchatgpt.com",
|
|
"https://foo.localhost",
|
|
"https://localhost.evil.com",
|
|
"https://192.168.1.2",
|
|
"https://chatgpt.com/backend-api",
|
|
"https://chatgpt.com?query=1",
|
|
] {
|
|
assert!(
|
|
!is_allowed_remote_control_origin(origin),
|
|
"expected rejected origin: {origin}"
|
|
);
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn sign_rejects_empty_session_binding() {
|
|
let store = store(DeviceKeyProtectionClass::HardwareTpm);
|
|
let info = block_on(store.create(create_request(DeviceKeyProtectionPolicy::HardwareOnly)))
|
|
.expect("create should succeed");
|
|
let mut payload = remote_control_client_connection_payload();
|
|
match &mut payload {
|
|
DeviceKeySignPayload::RemoteControlClientConnection(connection_payload) => {
|
|
connection_payload.session_id.clear();
|
|
}
|
|
DeviceKeySignPayload::RemoteControlClientEnrollment(_) => unreachable!(),
|
|
}
|
|
|
|
let err = block_on(store.sign(DeviceKeySignRequest {
|
|
key_id: info.key_id,
|
|
payload,
|
|
}))
|
|
.expect_err("empty session id should fail");
|
|
|
|
assert!(
|
|
matches!(
|
|
err,
|
|
DeviceKeyError::InvalidPayload("sessionId must not be empty")
|
|
),
|
|
"unexpected error: {err:?}"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn sign_rejects_empty_client_id() {
|
|
let store = store(DeviceKeyProtectionClass::HardwareTpm);
|
|
let info = block_on(store.create(create_request(DeviceKeyProtectionPolicy::HardwareOnly)))
|
|
.expect("create should succeed");
|
|
let mut payload = remote_control_client_connection_payload();
|
|
match &mut payload {
|
|
DeviceKeySignPayload::RemoteControlClientConnection(connection_payload) => {
|
|
connection_payload.client_id.clear();
|
|
}
|
|
DeviceKeySignPayload::RemoteControlClientEnrollment(_) => unreachable!(),
|
|
}
|
|
|
|
let err = block_on(store.sign(DeviceKeySignRequest {
|
|
key_id: info.key_id,
|
|
payload,
|
|
}))
|
|
.expect_err("empty client id should fail");
|
|
|
|
assert!(
|
|
matches!(
|
|
err,
|
|
DeviceKeyError::InvalidPayload("clientId must not be empty")
|
|
),
|
|
"unexpected error: {err:?}"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn sign_rejects_mismatched_binding() {
|
|
let store = store(DeviceKeyProtectionClass::HardwareTpm);
|
|
let info = block_on(store.create(create_request(DeviceKeyProtectionPolicy::HardwareOnly)))
|
|
.expect("create should succeed");
|
|
let mut payload = remote_control_client_connection_payload();
|
|
match &mut payload {
|
|
DeviceKeySignPayload::RemoteControlClientConnection(connection_payload) => {
|
|
connection_payload.account_user_id = "other-account-user".to_string();
|
|
}
|
|
DeviceKeySignPayload::RemoteControlClientEnrollment(_) => unreachable!(),
|
|
}
|
|
|
|
let err = block_on(store.sign(DeviceKeySignRequest {
|
|
key_id: info.key_id,
|
|
payload,
|
|
}))
|
|
.expect_err("mismatched binding should fail");
|
|
|
|
assert!(
|
|
matches!(
|
|
err,
|
|
DeviceKeyError::InvalidPayload(
|
|
"payload accountUserId/clientId does not match device key binding"
|
|
)
|
|
),
|
|
"unexpected error: {err:?}"
|
|
);
|
|
}
|
|
}
|