Compare commits

...

10 Commits

Author SHA1 Message Date
Ruslan Nigmatullin
eed4fccd03 Add device key app-server check script 2026-04-24 12:16:35 -07:00
Ruslan Nigmatullin
21ab239bc5 Use data protection keychain for macOS device keys 2026-04-24 12:16:18 -07:00
Ruslan Nigmatullin
e06c0a480d codex: fix CI failure on PR #18431 2026-04-23 21:58:51 -07:00
Ruslan Nigmatullin
8d431c0c50 app-server: require keychain entitlements before macOS fallback 2026-04-23 21:58:51 -07:00
Ruslan Nigmatullin
972149926d Handle missing entitlement for macOS device keys 2026-04-23 21:58:51 -07:00
Ruslan Nigmatullin
6fd3bf8bbe Move macOS device key provider to Objective-C 2026-04-23 21:58:51 -07:00
Ruslan Nigmatullin
9b592ee45d codex: fix CI failure on PR #18431
## Summary

Add the required parameter-name comment for the None authentication-context argument in the macOS device-key lookup path. CI macOS argument-comment lint enforces these comments for positional literal-like arguments.

## Validation

- just fmt
- cargo test -p codex-device-key
- just fix -p codex-device-key
- bazel build --config=argument-comment-lint //codex-rs/device-key:device-key-unit-tests-bin
- git diff --check

Note: a full local just argument-comment-lint run reached the fixed device-key target, then failed later in an unrelated webrtc-sys build script with a stale sandbox path outside this PR changes.
2026-04-23 21:58:51 -07:00
Ruslan Nigmatullin
4785b945e8 codex: fix CI failure on PR #18431
## Summary

Load LocalAuthentication.framework at runtime before constructing LAContext instead of linking the framework into every macOS target. The previous direct framework link made unrelated macOS Bazel targets link against an SDK-backed framework path, which caused the macOS CI jobs to fail during linking.

The signing flow still uses LAContext for authenticated private-key operations; this only changes how the framework becomes available to the Objective-C runtime.

## Validation

- just fmt
- cargo test -p codex-device-key
- just fix -p codex-device-key
- git diff --check
- bazel build //codex-rs/device-key:device-key
2026-04-23 21:58:50 -07:00
Ruslan Nigmatullin
08b0d83b66 app-server: require local auth for macOS device keys
Require macOS-created device keys to prove user presence before private-key use by adding the UserPresence access-control flag at key creation time. This keeps the provider aligned with the device-ownership goal: a valid signature should require both the device-local private key and a successful local biometric or password authentication.

Thread a process-local LAContext into signing lookups and reuse it across sign operations. Security.framework still owns the authentication policy and validity window, while the reusable context lets macOS reuse a recent successful authentication when policy permits instead of prompting on every connection.

Public-key lookup and binding reads continue to avoid the authentication context because they do not use the private key.

- just fmt
- cargo test -p codex-device-key
- just fix -p codex-device-key
- git diff --check
2026-04-23 21:58:50 -07:00
Ruslan Nigmatullin
72a6cab442 app-server: add macOS device key provider
The device-key crate needs a platform provider that can keep private keys non-extractable on macOS while preserving the crate-level protection policy and structured signing boundary.

macOS has two useful local protection classes for this API: hardware-backed Secure Enclave keys when available, and OS-protected non-extractable keys as an explicit fallback. Reporting which class was selected keeps that distinction visible to callers.

The Security.framework access-control flags used here provide device-local non-exportability and continuity while the user session is unlocked. They do not require UserPresence or Biometry for each signature, so the key itself is not treated as a complete same-controller compromise boundary; the API still relies on local-transport restriction, app-server authorization, and structured payload validation.

- Added the macOS DeviceKeyProvider implementation backed by Security.framework.
- Created and loaded P-256 private keys by stable device-key ID.
- Preferred Secure Enclave keys by default and allowed OS-protected non-extractable fallback only when requested by policy.
- Returned SPKI DER public keys and DER-encoded ECDSA signatures through the existing crate API.
- Documented the macOS provider threat-model boundary around this-device-only keys without UserPresence or Biometry flags.
- Added macOS-only Security.framework and CoreFoundation dependencies.

- just fmt
- cargo test -p codex-device-key
- just fix -p codex-device-key
- git diff --check
- just bazel-lock-update
- just bazel-lock-check
2026-04-23 21:58:50 -07:00
11 changed files with 1092 additions and 37 deletions

View File

@@ -61,6 +61,7 @@ osx.frameworks(names = [
"IOSurface",
"IOKit",
"Kernel",
"LocalAuthentication",
"Metal",
"MetalKit",
"OpenGL",

1
codex-rs/Cargo.lock generated
View File

@@ -2538,6 +2538,7 @@ version = "0.0.0"
dependencies = [
"async-trait",
"base64 0.22.1",
"cc",
"p256",
"pretty_assertions",
"rand 0.9.3",

View File

@@ -237,6 +237,7 @@ clap = "4"
clap_complete = "4"
color-eyre = "0.6.3"
constant_time_eq = "0.3.1"
core-foundation = "0.10.1"
crossbeam-channel = "0.5.15"
crypto_box = { version = "0.9.1", features = ["seal"] }
crossterm = "0.28.1"

View File

@@ -1,6 +1,27 @@
load("@rules_cc//cc:objc_library.bzl", "objc_library")
load("//:defs.bzl", "codex_rust_crate")
codex_rust_crate(
name = "device-key",
crate_name = "codex_device_key",
# Bazel wires the Objective-C provider through :macos-provider below, so skip Cargo's build.rs.
build_script_enabled = False,
deps_extra = select({
"@platforms//os:macos": [":macos-provider"],
"//conditions:default": [],
}),
)
objc_library(
name = "macos-provider",
srcs = ["src/platform/macos_provider.m"],
hdrs = ["src/platform/macos_provider.h"],
copts = ["-fobjc-arc"],
sdk_frameworks = [
"Foundation",
"LocalAuthentication",
"Security",
],
tags = ["manual"],
visibility = ["//visibility:private"],
)

View File

@@ -3,6 +3,7 @@ name = "codex-device-key"
version.workspace = true
edition.workspace = true
license.workspace = true
build = "build.rs"
[lints]
workspace = true
@@ -20,3 +21,6 @@ url = { workspace = true }
[dev-dependencies]
pretty_assertions = { workspace = true }
[build-dependencies]
cc = "1"

View File

@@ -0,0 +1,17 @@
fn main() {
println!("cargo:rerun-if-changed=src/platform/macos_provider.h");
println!("cargo:rerun-if-changed=src/platform/macos_provider.m");
if std::env::var("CARGO_CFG_TARGET_OS").as_deref() != Ok("macos") {
return;
}
cc::Build::new()
.file("src/platform/macos_provider.m")
.flag("-fobjc-arc")
.compile("codex_device_key_macos_provider");
println!("cargo:rustc-link-lib=framework=Foundation");
println!("cargo:rustc-link-lib=framework=Security");
println!("cargo:rustc-link-lib=framework=LocalAuthentication");
}

View File

@@ -1,49 +1,63 @@
use crate::DeviceKeyError;
use crate::DeviceKeyInfo;
use crate::DeviceKeyProtectionClass;
use crate::DeviceKeyProvider;
use crate::ProviderCreateRequest;
use crate::ProviderSignature;
use std::sync::Arc;
#[cfg(target_os = "macos")]
mod macos;
#[cfg(target_os = "macos")]
pub(crate) fn default_provider() -> Arc<dyn DeviceKeyProvider> {
Arc::new(UnsupportedDeviceKeyProvider)
Arc::new(macos::MacOsDeviceKeyProvider)
}
#[derive(Debug)]
pub(crate) struct UnsupportedDeviceKeyProvider;
#[cfg(not(target_os = "macos"))]
pub(crate) fn default_provider() -> Arc<dyn DeviceKeyProvider> {
Arc::new(unsupported::UnsupportedDeviceKeyProvider)
}
impl DeviceKeyProvider for UnsupportedDeviceKeyProvider {
fn create(&self, request: ProviderCreateRequest) -> Result<DeviceKeyInfo, DeviceKeyError> {
let _ = request.key_id_for(DeviceKeyProtectionClass::HardwareTpm);
let _ = request
.protection_policy
.allows(DeviceKeyProtectionClass::HardwareTpm);
Err(DeviceKeyError::HardwareBackedKeysUnavailable)
}
#[cfg(not(target_os = "macos"))]
mod unsupported {
use crate::DeviceKeyError;
use crate::DeviceKeyInfo;
use crate::DeviceKeyProtectionClass;
use crate::DeviceKeyProvider;
use crate::ProviderCreateRequest;
use crate::ProviderSignature;
fn delete(
&self,
_key_id: &str,
_protection_class: DeviceKeyProtectionClass,
) -> Result<(), DeviceKeyError> {
Ok(())
}
#[derive(Debug)]
pub(crate) struct UnsupportedDeviceKeyProvider;
fn get_public(
&self,
_key_id: &str,
_protection_class: DeviceKeyProtectionClass,
) -> Result<DeviceKeyInfo, DeviceKeyError> {
Err(DeviceKeyError::KeyNotFound)
}
impl DeviceKeyProvider for UnsupportedDeviceKeyProvider {
fn create(&self, request: ProviderCreateRequest) -> Result<DeviceKeyInfo, DeviceKeyError> {
let _ = request.key_id_for(DeviceKeyProtectionClass::HardwareTpm);
let _ = request
.protection_policy
.allows(DeviceKeyProtectionClass::HardwareTpm);
Err(DeviceKeyError::HardwareBackedKeysUnavailable)
}
fn sign(
&self,
_key_id: &str,
_protection_class: DeviceKeyProtectionClass,
_payload: &[u8],
) -> Result<ProviderSignature, DeviceKeyError> {
Err(DeviceKeyError::KeyNotFound)
fn delete(
&self,
_key_id: &str,
_protection_class: DeviceKeyProtectionClass,
) -> Result<(), DeviceKeyError> {
Ok(())
}
fn get_public(
&self,
_key_id: &str,
_protection_class: DeviceKeyProtectionClass,
) -> Result<DeviceKeyInfo, DeviceKeyError> {
Err(DeviceKeyError::KeyNotFound)
}
fn sign(
&self,
_key_id: &str,
_protection_class: DeviceKeyProtectionClass,
_payload: &[u8],
) -> Result<ProviderSignature, DeviceKeyError> {
Err(DeviceKeyError::KeyNotFound)
}
}
}

View File

@@ -0,0 +1,261 @@
use crate::DeviceKeyAlgorithm;
use crate::DeviceKeyError;
use crate::DeviceKeyInfo;
use crate::DeviceKeyProtectionClass;
use crate::DeviceKeyProvider;
use crate::ProviderCreateRequest;
use crate::ProviderSignature;
use crate::sec1_public_key_to_spki_der;
use std::ffi::CStr;
use std::ffi::CString;
use std::ffi::c_char;
use std::ffi::c_int;
use std::ptr;
use std::slice;
const MAC_STATUS_OK: c_int = 0;
const MAC_STATUS_NOT_FOUND: c_int = 1;
const MAC_STATUS_HARDWARE_UNAVAILABLE: c_int = 2;
const MAC_KEY_CLASS_SECURE_ENCLAVE: c_int = 0;
const MAC_KEY_CLASS_OS_PROTECTED_NONEXTRACTABLE: c_int = 1;
#[repr(C)]
struct MacBytesResult {
status: c_int,
data: *mut u8,
len: usize,
error_message: *mut c_char,
}
unsafe extern "C" {
fn codex_device_key_macos_create_or_load_public_key(
key_tag: *const c_char,
key_class: c_int,
) -> MacBytesResult;
fn codex_device_key_macos_load_public_key(
key_tag: *const c_char,
key_class: c_int,
) -> MacBytesResult;
fn codex_device_key_macos_delete(key_tag: *const c_char, key_class: c_int) -> MacBytesResult;
fn codex_device_key_macos_sign(
key_tag: *const c_char,
key_class: c_int,
payload: *const u8,
payload_len: usize,
) -> MacBytesResult;
fn codex_device_key_macos_free_bytes_result(result: *mut MacBytesResult);
}
impl MacBytesResult {
fn into_bytes(mut self) -> Result<Vec<u8>, DeviceKeyError> {
let result = match self.status {
MAC_STATUS_OK => {
if self.data.is_null() && self.len != 0 {
Err(DeviceKeyError::Platform(
"macOS device-key provider returned null data".to_string(),
))
} else {
let bytes = if self.len == 0 {
Vec::new()
} else {
unsafe { slice::from_raw_parts(self.data.cast_const(), self.len).to_vec() }
};
Ok(bytes)
}
}
MAC_STATUS_NOT_FOUND => Err(DeviceKeyError::KeyNotFound),
MAC_STATUS_HARDWARE_UNAVAILABLE => Err(DeviceKeyError::HardwareBackedKeysUnavailable),
_ => Err(DeviceKeyError::Platform(self.error_message())),
};
unsafe {
codex_device_key_macos_free_bytes_result(ptr::addr_of_mut!(self));
}
result
}
fn error_message(&self) -> String {
if self.error_message.is_null() {
return "unknown macOS device-key provider error".to_string();
}
unsafe { CStr::from_ptr(self.error_message) }
.to_string_lossy()
.into_owned()
}
}
#[derive(Debug)]
pub(crate) struct MacOsDeviceKeyProvider;
impl DeviceKeyProvider for MacOsDeviceKeyProvider {
fn create(&self, request: ProviderCreateRequest) -> Result<DeviceKeyInfo, DeviceKeyError> {
let secure_enclave_key_id =
request.key_id_for(DeviceKeyProtectionClass::HardwareSecureEnclave);
match create_or_load_key_info(&secure_enclave_key_id, MacKeyClass::SecureEnclave) {
Ok(info) => Ok(info),
Err(secure_enclave_error) => {
if !matches!(
secure_enclave_error,
DeviceKeyError::HardwareBackedKeysUnavailable
) {
return Err(secure_enclave_error);
}
if !request
.protection_policy
.allows(DeviceKeyProtectionClass::OsProtectedNonextractable)
{
return Err(DeviceKeyError::DegradedProtectionNotAllowed {
available: DeviceKeyProtectionClass::OsProtectedNonextractable,
});
}
let fallback_key_id =
request.key_id_for(DeviceKeyProtectionClass::OsProtectedNonextractable);
create_or_load_key_info(&fallback_key_id, MacKeyClass::OsProtectedNonextractable)
.map_err(|fallback_error| {
DeviceKeyError::Platform(format!(
"Secure Enclave key creation failed ({secure_enclave_error}); OS-protected fallback failed ({fallback_error})"
))
})
}
}
}
fn delete(
&self,
key_id: &str,
protection_class: DeviceKeyProtectionClass,
) -> Result<(), DeviceKeyError> {
let class = MacKeyClass::from_protection_class(protection_class)
.ok_or(DeviceKeyError::KeyNotFound)?;
delete_key(key_id, class)
}
fn get_public(
&self,
key_id: &str,
protection_class: DeviceKeyProtectionClass,
) -> Result<DeviceKeyInfo, DeviceKeyError> {
let class = MacKeyClass::from_protection_class(protection_class)
.ok_or(DeviceKeyError::KeyNotFound)?;
let public_key = load_public_key(key_id, class)?;
key_info(key_id, class, public_key.as_slice())
}
fn sign(
&self,
key_id: &str,
protection_class: DeviceKeyProtectionClass,
payload: &[u8],
) -> Result<ProviderSignature, DeviceKeyError> {
let class = MacKeyClass::from_protection_class(protection_class)
.ok_or(DeviceKeyError::KeyNotFound)?;
let signature_der = sign(key_id, class, payload)?;
Ok(ProviderSignature {
signature_der,
algorithm: DeviceKeyAlgorithm::EcdsaP256Sha256,
})
}
}
#[derive(Debug, Clone, Copy)]
enum MacKeyClass {
SecureEnclave,
OsProtectedNonextractable,
}
impl MacKeyClass {
fn native(self) -> c_int {
match self {
Self::SecureEnclave => MAC_KEY_CLASS_SECURE_ENCLAVE,
Self::OsProtectedNonextractable => MAC_KEY_CLASS_OS_PROTECTED_NONEXTRACTABLE,
}
}
fn protection_class(self) -> DeviceKeyProtectionClass {
match self {
Self::SecureEnclave => DeviceKeyProtectionClass::HardwareSecureEnclave,
Self::OsProtectedNonextractable => DeviceKeyProtectionClass::OsProtectedNonextractable,
}
}
fn tag_prefix(self) -> &'static str {
match self {
Self::SecureEnclave => "secure-enclave",
Self::OsProtectedNonextractable => "os-protected-nonextractable",
}
}
fn from_protection_class(protection_class: DeviceKeyProtectionClass) -> Option<Self> {
match protection_class {
DeviceKeyProtectionClass::HardwareSecureEnclave => Some(Self::SecureEnclave),
DeviceKeyProtectionClass::OsProtectedNonextractable => {
Some(Self::OsProtectedNonextractable)
}
DeviceKeyProtectionClass::HardwareTpm => None,
}
}
}
fn create_or_load_key_info(
key_id: &str,
class: MacKeyClass,
) -> Result<DeviceKeyInfo, DeviceKeyError> {
let public_key = create_or_load_public_key(key_id, class)?;
key_info(key_id, class, public_key.as_slice())
}
fn create_or_load_public_key(key_id: &str, class: MacKeyClass) -> Result<Vec<u8>, DeviceKeyError> {
let tag = key_tag_cstring(key_id, class)?;
unsafe { codex_device_key_macos_create_or_load_public_key(tag.as_ptr(), class.native()) }
.into_bytes()
}
fn load_public_key(key_id: &str, class: MacKeyClass) -> Result<Vec<u8>, DeviceKeyError> {
let tag = key_tag_cstring(key_id, class)?;
unsafe { codex_device_key_macos_load_public_key(tag.as_ptr(), class.native()) }.into_bytes()
}
fn delete_key(key_id: &str, class: MacKeyClass) -> Result<(), DeviceKeyError> {
let tag = key_tag_cstring(key_id, class)?;
unsafe { codex_device_key_macos_delete(tag.as_ptr(), class.native()) }
.into_bytes()
.map(|_| ())
}
fn sign(key_id: &str, class: MacKeyClass, payload: &[u8]) -> Result<Vec<u8>, DeviceKeyError> {
let tag = key_tag_cstring(key_id, class)?;
unsafe {
codex_device_key_macos_sign(
tag.as_ptr(),
class.native(),
payload.as_ptr(),
payload.len(),
)
}
.into_bytes()
}
fn key_info(
key_id: &str,
class: MacKeyClass,
sec1_public_key: &[u8],
) -> Result<DeviceKeyInfo, DeviceKeyError> {
Ok(DeviceKeyInfo {
key_id: key_id.to_string(),
public_key_spki_der: sec1_public_key_to_spki_der(sec1_public_key)?,
algorithm: DeviceKeyAlgorithm::EcdsaP256Sha256,
protection_class: class.protection_class(),
})
}
fn key_tag_cstring(key_id: &str, class: MacKeyClass) -> Result<CString, DeviceKeyError> {
CString::new(key_tag(key_id, class)).map_err(|err| DeviceKeyError::Platform(err.to_string()))
}
fn key_tag(key_id: &str, class: MacKeyClass) -> String {
format!(
"com.openai.codex.device-key.{}.{}",
class.tag_prefix(),
key_id
)
}

View File

@@ -0,0 +1,50 @@
#ifndef CODEX_DEVICE_KEY_MACOS_PROVIDER_H
#define CODEX_DEVICE_KEY_MACOS_PROVIDER_H
#include <stddef.h>
#include <stdint.h>
#ifdef __cplusplus
extern "C" {
#endif
typedef enum CodexDeviceKeyMacStatus {
CodexDeviceKeyMacStatusOk = 0,
CodexDeviceKeyMacStatusNotFound = 1,
CodexDeviceKeyMacStatusHardwareUnavailable = 2,
CodexDeviceKeyMacStatusPlatformError = 3,
} CodexDeviceKeyMacStatus;
typedef enum CodexDeviceKeyMacKeyClass {
CodexDeviceKeyMacKeyClassSecureEnclave = 0,
CodexDeviceKeyMacKeyClassOsProtectedNonextractable = 1,
} CodexDeviceKeyMacKeyClass;
typedef struct CodexDeviceKeyMacBytesResult {
int32_t status;
uint8_t *data;
size_t len;
char *error_message;
} CodexDeviceKeyMacBytesResult;
CodexDeviceKeyMacBytesResult codex_device_key_macos_create_or_load_public_key(
const char *key_tag,
int32_t key_class);
CodexDeviceKeyMacBytesResult codex_device_key_macos_load_public_key(
const char *key_tag,
int32_t key_class);
CodexDeviceKeyMacBytesResult codex_device_key_macos_delete(
const char *key_tag,
int32_t key_class);
CodexDeviceKeyMacBytesResult codex_device_key_macos_sign(
const char *key_tag,
int32_t key_class,
const uint8_t *payload,
size_t payload_len);
void codex_device_key_macos_free_bytes_result(CodexDeviceKeyMacBytesResult *result);
#ifdef __cplusplus
}
#endif
#endif

View File

@@ -0,0 +1,385 @@
#import "macos_provider.h"
#import <Foundation/Foundation.h>
#import <LocalAuthentication/LocalAuthentication.h>
#import <Security/Security.h>
#include <stdlib.h>
#include <string.h>
static NSTimeInterval const CodexDeviceKeyTouchIdReuseDurationSeconds = 300.0;
static OSStatus const CodexDeviceKeyErrSecMissingEntitlement = -34018;
static CodexDeviceKeyMacBytesResult CodexDeviceKeyMacResultMake(
CodexDeviceKeyMacStatus status,
NSData *data,
NSString *errorMessage) {
CodexDeviceKeyMacBytesResult result = {
.status = status,
.data = NULL,
.len = 0,
.error_message = NULL,
};
if (data.length > 0) {
result.data = malloc(data.length);
if (result.data == NULL) {
result.status = CodexDeviceKeyMacStatusPlatformError;
errorMessage = @"failed to allocate result bytes";
} else {
memcpy(result.data, data.bytes, data.length);
result.len = data.length;
}
}
if (errorMessage.length > 0) {
const char *utf8 = errorMessage.UTF8String;
if (utf8 != NULL) {
size_t len = strlen(utf8);
result.error_message = malloc(len + 1);
if (result.error_message != NULL) {
memcpy(result.error_message, utf8, len + 1);
}
}
}
return result;
}
static CodexDeviceKeyMacBytesResult CodexDeviceKeyMacError(
CodexDeviceKeyMacStatus status,
NSString *message) {
return CodexDeviceKeyMacResultMake(status, nil, message);
}
static NSString *CodexDeviceKeyMacCopySecurityError(OSStatus status) {
NSString *message = CFBridgingRelease(SecCopyErrorMessageString(status, NULL));
if (message.length > 0) {
return message;
}
return [NSString stringWithFormat:@"Security.framework error code %d", status];
}
static NSString *CodexDeviceKeyMacCopyCFError(CFErrorRef error) {
if (error == NULL) {
return @"Security.framework returned an unknown error";
}
NSError *nsError = CFBridgingRelease(error);
if (nsError.localizedDescription.length > 0) {
return nsError.localizedDescription;
}
return [nsError description];
}
static BOOL CodexDeviceKeyMacClassIsValid(int32_t keyClass) {
return keyClass == CodexDeviceKeyMacKeyClassSecureEnclave ||
keyClass == CodexDeviceKeyMacKeyClassOsProtectedNonextractable;
}
static BOOL CodexDeviceKeyMacSecureEnclaveUnavailableStatus(OSStatus status) {
return status == errSecUnimplemented ||
status == errSecParam;
}
static NSData *CodexDeviceKeyMacTagData(NSString *keyTag) {
return [keyTag dataUsingEncoding:NSUTF8StringEncoding];
}
static NSMutableDictionary *CodexDeviceKeyMacPrivateKeyQuery(
NSString *keyTag,
int32_t keyClass,
LAContext *authenticationContext) {
NSMutableDictionary *query = [@{
(__bridge id)kSecClass: (__bridge id)kSecClassKey,
(__bridge id)kSecAttrKeyClass: (__bridge id)kSecAttrKeyClassPrivate,
(__bridge id)kSecAttrApplicationTag: CodexDeviceKeyMacTagData(keyTag),
(__bridge id)kSecReturnRef: @YES,
(__bridge id)kSecUseDataProtectionKeychain: @YES,
} mutableCopy];
if (keyClass == CodexDeviceKeyMacKeyClassSecureEnclave) {
query[(__bridge id)kSecAttrTokenID] = (__bridge id)kSecAttrTokenIDSecureEnclave;
} else {
query[(__bridge id)kSecAttrIsExtractable] = @NO;
}
if (authenticationContext != nil) {
query[(__bridge id)kSecUseAuthenticationContext] = authenticationContext;
}
return query;
}
static SecKeyRef CodexDeviceKeyMacCopyPrivateKey(
NSString *keyTag,
int32_t keyClass,
LAContext *authenticationContext,
CodexDeviceKeyMacStatus *status,
NSString **errorMessage) {
CFTypeRef item = NULL;
OSStatus secStatus = SecItemCopyMatching(
(__bridge CFDictionaryRef)CodexDeviceKeyMacPrivateKeyQuery(
keyTag, keyClass, authenticationContext),
&item);
if (secStatus == errSecItemNotFound) {
*status = CodexDeviceKeyMacStatusNotFound;
return NULL;
}
if (secStatus != errSecSuccess) {
*status = CodexDeviceKeyMacStatusPlatformError;
*errorMessage = CodexDeviceKeyMacCopySecurityError(secStatus);
return NULL;
}
if (item == NULL) {
*status = CodexDeviceKeyMacStatusPlatformError;
*errorMessage = @"Security.framework returned an empty key reference";
return NULL;
}
*status = CodexDeviceKeyMacStatusOk;
return (SecKeyRef)item;
}
static SecKeyRef CodexDeviceKeyMacCreatePrivateKey(
NSString *keyTag,
int32_t keyClass,
CodexDeviceKeyMacStatus *status,
NSString **errorMessage) {
CFErrorRef accessControlError = NULL;
SecAccessControlRef accessControl = SecAccessControlCreateWithFlags(
kCFAllocatorDefault,
kSecAttrAccessibleWhenUnlockedThisDeviceOnly,
kSecAccessControlPrivateKeyUsage | kSecAccessControlUserPresence,
&accessControlError);
if (accessControl == NULL) {
*status = CodexDeviceKeyMacStatusPlatformError;
*errorMessage = CodexDeviceKeyMacCopyCFError(accessControlError);
return NULL;
}
NSMutableDictionary *privateAttributes = [@{
(__bridge id)kSecAttrIsPermanent: @YES,
(__bridge id)kSecAttrAccessControl: (__bridge id)accessControl,
(__bridge id)kSecAttrApplicationTag: CodexDeviceKeyMacTagData(keyTag),
(__bridge id)kSecAttrLabel: keyTag,
} mutableCopy];
if (keyClass == CodexDeviceKeyMacKeyClassOsProtectedNonextractable) {
privateAttributes[(__bridge id)kSecAttrIsExtractable] = @NO;
}
NSMutableDictionary *attributes = [@{
(__bridge id)kSecAttrKeyType: (__bridge id)kSecAttrKeyTypeECSECPrimeRandom,
(__bridge id)kSecAttrKeySizeInBits: @256,
(__bridge id)kSecAttrLabel: keyTag,
(__bridge id)kSecUseDataProtectionKeychain: @YES,
(__bridge id)kSecPrivateKeyAttrs: privateAttributes,
} mutableCopy];
if (keyClass == CodexDeviceKeyMacKeyClassSecureEnclave) {
attributes[(__bridge id)kSecAttrTokenID] = (__bridge id)kSecAttrTokenIDSecureEnclave;
}
CFErrorRef createError = NULL;
SecKeyRef key = SecKeyCreateRandomKey((__bridge CFDictionaryRef)attributes, &createError);
CFRelease(accessControl);
if (key != NULL) {
*status = CodexDeviceKeyMacStatusOk;
return key;
}
NSError *nsError = createError == NULL ? nil : CFBridgingRelease(createError);
OSStatus code = nsError == nil ? 0 : (OSStatus)nsError.code;
// Missing Keychain entitlements affect both Secure Enclave and OS-protected permanent keys, so
// do not classify this as degraded hardware availability and retry with the fallback class.
if (code == CodexDeviceKeyErrSecMissingEntitlement) {
*status = CodexDeviceKeyMacStatusPlatformError;
*errorMessage = @"macOS Keychain entitlements are required to create persistent device keys";
return NULL;
}
if (keyClass == CodexDeviceKeyMacKeyClassSecureEnclave &&
CodexDeviceKeyMacSecureEnclaveUnavailableStatus(code)) {
*status = CodexDeviceKeyMacStatusHardwareUnavailable;
return NULL;
}
*status = CodexDeviceKeyMacStatusPlatformError;
*errorMessage = nsError.localizedDescription.length > 0
? nsError.localizedDescription
: @"Security.framework failed to create a private key";
return NULL;
}
static CodexDeviceKeyMacBytesResult CodexDeviceKeyMacCopyPublicKeyResult(SecKeyRef privateKey) {
SecKeyRef publicKey = SecKeyCopyPublicKey(privateKey);
if (publicKey == NULL) {
return CodexDeviceKeyMacError(
CodexDeviceKeyMacStatusPlatformError,
@"Security.framework did not return a public key");
}
CFErrorRef error = NULL;
CFDataRef publicKeyData = SecKeyCopyExternalRepresentation(publicKey, &error);
CFRelease(publicKey);
if (publicKeyData == NULL) {
return CodexDeviceKeyMacError(
CodexDeviceKeyMacStatusPlatformError,
CodexDeviceKeyMacCopyCFError(error));
}
NSData *data = CFBridgingRelease(publicKeyData);
return CodexDeviceKeyMacResultMake(CodexDeviceKeyMacStatusOk, data, nil);
}
static LAContext *CodexDeviceKeyMacReusableAuthenticationContext(void) {
static LAContext *context = nil;
static dispatch_once_t onceToken;
dispatch_once(&onceToken, ^{
context = [[LAContext alloc] init];
context.touchIDAuthenticationAllowableReuseDuration =
CodexDeviceKeyTouchIdReuseDurationSeconds;
});
return context;
}
CodexDeviceKeyMacBytesResult codex_device_key_macos_create_or_load_public_key(
const char *keyTag,
int32_t keyClass) {
@autoreleasepool {
if (keyTag == NULL || !CodexDeviceKeyMacClassIsValid(keyClass)) {
return CodexDeviceKeyMacError(
CodexDeviceKeyMacStatusPlatformError,
@"invalid macOS device-key provider argument");
}
NSString *tag = [NSString stringWithUTF8String:keyTag];
CodexDeviceKeyMacStatus status = CodexDeviceKeyMacStatusOk;
NSString *errorMessage = nil;
SecKeyRef key = CodexDeviceKeyMacCreatePrivateKey(tag, keyClass, &status, &errorMessage);
if (key == NULL) {
if (status == CodexDeviceKeyMacStatusHardwareUnavailable) {
return CodexDeviceKeyMacError(status, nil);
}
CodexDeviceKeyMacStatus loadStatus = CodexDeviceKeyMacStatusOk;
NSString *loadErrorMessage = nil;
key = CodexDeviceKeyMacCopyPrivateKey(
tag, keyClass, nil, &loadStatus, &loadErrorMessage);
if (key == NULL) {
if (loadStatus == CodexDeviceKeyMacStatusNotFound) {
return CodexDeviceKeyMacError(status, errorMessage);
}
return CodexDeviceKeyMacError(
CodexDeviceKeyMacStatusPlatformError,
[NSString stringWithFormat:
@"key creation failed (%@); reload failed (%@)",
errorMessage ?: @"unknown error",
loadErrorMessage ?: @"unknown error"]);
}
}
CodexDeviceKeyMacBytesResult result = CodexDeviceKeyMacCopyPublicKeyResult(key);
CFRelease(key);
return result;
}
}
CodexDeviceKeyMacBytesResult codex_device_key_macos_load_public_key(
const char *keyTag,
int32_t keyClass) {
@autoreleasepool {
if (keyTag == NULL || !CodexDeviceKeyMacClassIsValid(keyClass)) {
return CodexDeviceKeyMacError(
CodexDeviceKeyMacStatusPlatformError,
@"invalid macOS device-key provider argument");
}
NSString *tag = [NSString stringWithUTF8String:keyTag];
CodexDeviceKeyMacStatus status = CodexDeviceKeyMacStatusOk;
NSString *errorMessage = nil;
SecKeyRef key = CodexDeviceKeyMacCopyPrivateKey(tag, keyClass, nil, &status, &errorMessage);
if (key == NULL) {
return CodexDeviceKeyMacError(status, errorMessage);
}
CodexDeviceKeyMacBytesResult result = CodexDeviceKeyMacCopyPublicKeyResult(key);
CFRelease(key);
return result;
}
}
CodexDeviceKeyMacBytesResult codex_device_key_macos_delete(
const char *keyTag,
int32_t keyClass) {
@autoreleasepool {
if (keyTag == NULL || !CodexDeviceKeyMacClassIsValid(keyClass)) {
return CodexDeviceKeyMacError(
CodexDeviceKeyMacStatusPlatformError,
@"invalid macOS device-key provider argument");
}
NSString *tag = [NSString stringWithUTF8String:keyTag];
NSMutableDictionary *query = CodexDeviceKeyMacPrivateKeyQuery(tag, keyClass, nil);
[query removeObjectForKey:(__bridge id)kSecReturnRef];
OSStatus status = SecItemDelete((__bridge CFDictionaryRef)query);
if (status == errSecSuccess || status == errSecItemNotFound) {
return CodexDeviceKeyMacResultMake(CodexDeviceKeyMacStatusOk, nil, nil);
}
return CodexDeviceKeyMacError(
CodexDeviceKeyMacStatusPlatformError,
CodexDeviceKeyMacCopySecurityError(status));
}
}
CodexDeviceKeyMacBytesResult codex_device_key_macos_sign(
const char *keyTag,
int32_t keyClass,
const uint8_t *payload,
size_t payloadLen) {
@autoreleasepool {
if (keyTag == NULL || payload == NULL || !CodexDeviceKeyMacClassIsValid(keyClass)) {
return CodexDeviceKeyMacError(
CodexDeviceKeyMacStatusPlatformError,
@"invalid macOS device-key provider argument");
}
NSString *tag = [NSString stringWithUTF8String:keyTag];
CodexDeviceKeyMacStatus status = CodexDeviceKeyMacStatusOk;
NSString *errorMessage = nil;
SecKeyRef key = CodexDeviceKeyMacCopyPrivateKey(
tag,
keyClass,
CodexDeviceKeyMacReusableAuthenticationContext(),
&status,
&errorMessage);
if (key == NULL) {
return CodexDeviceKeyMacError(status, errorMessage);
}
NSData *payloadData = [NSData dataWithBytes:payload length:payloadLen];
CFErrorRef error = NULL;
CFDataRef signature = SecKeyCreateSignature(
key,
kSecKeyAlgorithmECDSASignatureMessageX962SHA256,
(__bridge CFDataRef)payloadData,
&error);
CFRelease(key);
if (signature == NULL) {
return CodexDeviceKeyMacError(
CodexDeviceKeyMacStatusPlatformError,
CodexDeviceKeyMacCopyCFError(error));
}
NSData *signatureData = CFBridgingRelease(signature);
return CodexDeviceKeyMacResultMake(CodexDeviceKeyMacStatusOk, signatureData, nil);
}
}
void codex_device_key_macos_free_bytes_result(CodexDeviceKeyMacBytesResult *result) {
if (result == NULL) {
return;
}
free(result->data);
free(result->error_message);
result->data = NULL;
result->len = 0;
result->error_message = NULL;
}

View File

@@ -0,0 +1,300 @@
#!/usr/bin/env python3
"""Exercise app-server device/key/* RPCs against a local Codex binary.
This is intentionally dependency-free so it can be run against a freshly built
and code-signed local CLI:
python3 codex-rs/scripts/check-device-key-app-server.py \
--binary codex-rs/target/debug/codex
The script uses a temporary CODEX_HOME and spawns `codex app-server --listen
stdio://`. It creates a new persistent device key each successful run; the
app-server API currently does not expose a delete RPC.
"""
from __future__ import annotations
import argparse
import json
import os
import subprocess
import sys
import tempfile
import time
from pathlib import Path
from typing import Any
DEFAULT_TOKEN_SHA256_EMPTY = "47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU"
class DeviceKeyRpcError(RuntimeError):
def __init__(self, method: str, error: dict[str, Any]) -> None:
self.method = method
self.error = error
super().__init__(f"{method} failed: {error}")
class AppServerClient:
def __init__(
self,
*,
binary: Path,
codex_home: Path,
verbose: bool,
) -> None:
self._verbose = verbose
env = os.environ.copy()
env["CODEX_HOME"] = str(codex_home)
env["CODEX_APP_SERVER_MANAGED_CONFIG_PATH"] = str(
codex_home / "managed_config.toml"
)
env.setdefault("RUST_LOG", "info")
env.pop("CODEX_INTERNAL_ORIGINATOR_OVERRIDE", None)
self._process = subprocess.Popen(
[str(binary), "app-server", "--listen", "stdio://"],
cwd=codex_home,
env=env,
stdin=subprocess.PIPE,
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
text=True,
bufsize=1,
)
if self._process.stdin is None or self._process.stdout is None:
raise RuntimeError("failed to open app-server stdio pipes")
self._stdin = self._process.stdin
self._stdout = self._process.stdout
def close(self) -> None:
if self._process.poll() is None:
self._process.terminate()
try:
self._process.wait(timeout=5)
except subprocess.TimeoutExpired:
self._process.kill()
self._process.wait(timeout=5)
stderr = self._process.stderr.read() if self._process.stderr else ""
if self._verbose and stderr:
print("app-server stderr:", file=sys.stderr)
print(stderr, file=sys.stderr, end="" if stderr.endswith("\n") else "\n")
def send(self, message: dict[str, Any]) -> None:
line = json.dumps(message, separators=(",", ":"))
if self._verbose:
print(f">>> {line}", file=sys.stderr)
self._stdin.write(line + "\n")
self._stdin.flush()
def read_until_id(self, request_id: int, timeout_seconds: float) -> dict[str, Any]:
deadline = time.monotonic() + timeout_seconds
while time.monotonic() < deadline:
line = self._stdout.readline()
if not line:
raise RuntimeError("app-server stdout closed")
if self._verbose:
print(f"<<< {line.strip()}", file=sys.stderr)
message = json.loads(line)
if message.get("id") == request_id:
return message
raise TimeoutError(f"timed out waiting for response id {request_id}")
def request(
self,
request_id: int,
method: str,
params: dict[str, Any] | None,
*,
timeout_seconds: float,
) -> dict[str, Any]:
message: dict[str, Any] = {"id": request_id, "method": method}
if params is not None:
message["params"] = params
self.send(message)
response = self.read_until_id(request_id, timeout_seconds)
if "error" in response:
raise DeviceKeyRpcError(method, response["error"])
result = response.get("result")
if not isinstance(result, dict):
raise RuntimeError(f"{method} returned non-object result: {result!r}")
return result
def parse_args() -> argparse.Namespace:
parser = argparse.ArgumentParser(
description="Run app-server device/key/create, device/key/sign, and device/key/public.",
)
parser.add_argument(
"--binary",
type=Path,
default=Path("codex-rs/target/debug/codex"),
help="Path to the codex CLI binary to test.",
)
parser.add_argument(
"--codex-home",
type=Path,
help="CODEX_HOME to use. Defaults to a temporary directory.",
)
parser.add_argument(
"--keep-codex-home",
action="store_true",
help="Do not delete the temporary CODEX_HOME after the run.",
)
parser.add_argument(
"--protection-policy",
choices=("hardware_only", "allow_os_protected_nonextractable"),
default="hardware_only",
help="Protection policy sent to device/key/create.",
)
parser.add_argument(
"--account-user-id",
default="acct_local_secure_enclave_check",
help="accountUserId used for device/key/create and sign payload.",
)
parser.add_argument(
"--client-id",
default="cli_local_secure_enclave_check",
help="clientId used for device/key/create and sign payload.",
)
parser.add_argument(
"--timeout-seconds",
type=float,
default=120.0,
help="Timeout for each app-server request.",
)
parser.add_argument("--verbose", action="store_true", help="Print JSON-RPC traffic.")
return parser.parse_args()
def main() -> int:
args = parse_args()
binary = args.binary.expanduser().resolve()
if not binary.is_file():
print(f"binary not found: {binary}", file=sys.stderr)
return 2
temp_home: tempfile.TemporaryDirectory[str] | None = None
if args.codex_home:
codex_home = args.codex_home.expanduser().resolve()
codex_home.mkdir(parents=True, exist_ok=True)
else:
temp_home = tempfile.TemporaryDirectory(prefix="codex-device-key-check-")
codex_home = Path(temp_home.name)
client = AppServerClient(binary=binary, codex_home=codex_home, verbose=args.verbose)
try:
init_result = client.request(
1,
"initialize",
{
"clientInfo": {
"name": "device-key-local-signing-check",
"version": "0.1.0",
},
"capabilities": {"experimentalApi": True},
},
timeout_seconds=args.timeout_seconds,
)
client.send({"method": "initialized"})
created = client.request(
2,
"device/key/create",
{
"accountUserId": args.account_user_id,
"clientId": args.client_id,
"protectionPolicy": args.protection_policy,
},
timeout_seconds=args.timeout_seconds,
)
sign_payload = {
"type": "remoteControlClientConnection",
"nonce": "nonce-local-secure-enclave-check",
"audience": "remote_control_client_websocket",
"sessionId": "wssess_local_secure_enclave_check",
"targetOrigin": "https://chatgpt.com",
"targetPath": "/api/codex/remote/control/client",
"accountUserId": args.account_user_id,
"clientId": args.client_id,
"tokenSha256Base64url": DEFAULT_TOKEN_SHA256_EMPTY,
"tokenExpiresAt": 4_102_444_800,
"scopes": ["remote_control_controller_websocket"],
}
signed = client.request(
3,
"device/key/sign",
{"keyId": created["keyId"], "payload": sign_payload},
timeout_seconds=args.timeout_seconds,
)
public = client.request(
4,
"device/key/public",
{"keyId": created["keyId"]},
timeout_seconds=args.timeout_seconds,
)
summary = {
"binary": str(binary),
"codexHome": str(codex_home),
"initialize": {
"platformOs": init_result.get("platformOs"),
"userAgent": init_result.get("userAgent"),
},
"create": {
"keyId": created.get("keyId"),
"algorithm": created.get("algorithm"),
"protectionClass": created.get("protectionClass"),
"publicKeySpkiDerBase64Length": len(
created.get("publicKeySpkiDerBase64", "")
),
},
"sign": {
"algorithm": signed.get("algorithm"),
"signatureDerBase64Length": len(signed.get("signatureDerBase64", "")),
"signedPayloadBase64Length": len(signed.get("signedPayloadBase64", "")),
},
"public": {
"keyIdMatchesCreate": public.get("keyId") == created.get("keyId"),
"algorithm": public.get("algorithm"),
"protectionClass": public.get("protectionClass"),
"publicKeyMatchesCreate": public.get("publicKeySpkiDerBase64")
== created.get("publicKeySpkiDerBase64"),
},
}
print(json.dumps(summary, indent=2, sort_keys=True))
if created.get("protectionClass") != "hardware_secure_enclave":
print(
"device/key/create did not return hardware_secure_enclave",
file=sys.stderr,
)
return 3
return 0
except DeviceKeyRpcError as exc:
print(
json.dumps(
{
"binary": str(binary),
"codexHome": str(codex_home),
"failedMethod": exc.method,
"error": exc.error,
},
indent=2,
sort_keys=True,
)
)
return 1
finally:
client.close()
if temp_home is not None and not args.keep_codex_home:
temp_home.cleanup()
elif temp_home is not None:
print(f"kept CODEX_HOME at {codex_home}", file=sys.stderr)
if __name__ == "__main__":
raise SystemExit(main())