Compare commits

...

7 Commits

Author SHA1 Message Date
Greg Harper
4232b4c070 Simplify macOS keyring store helpers 2026-03-17 13:22:15 -07:00
Greg Harper
2e4757d37d Use neutral keychain test account names 2026-03-17 13:22:03 -07:00
Greg Harper
e95658e7bf Simplify macOS shared entitlement signing 2026-03-17 13:21:56 -07:00
Greg Harper
a4c075cbe2 Preserve legacy macOS keychain secrets during migration 2026-03-17 13:21:40 -07:00
Greg Harper
fe12ea7350 Rename shared macOS keychain access group 2026-03-17 13:21:27 -07:00
Greg Harper
de57f51afb Make macOS signing action entitlement-aware
# Conflicts:
#	.github/actions/macos-code-sign/action.yml
2026-03-17 13:06:32 -07:00
Greg Harper
e46830c2a9 Use shared access group for macOS keychain secrets 2026-03-17 12:59:39 -07:00
9 changed files with 551 additions and 52 deletions

View File

@@ -0,0 +1,4 @@
# macos-code-sign
This action signs the default Rust macOS release binaries and applies the shared keychain
entitlement plist to those binaries during codesign.

View File

@@ -117,8 +117,6 @@ runs:
- name: Sign macOS binaries
if: ${{ inputs.sign-binaries == 'true' }}
shell: bash
env:
TARGET: ${{ inputs.target }}
run: |
set -euo pipefail
@@ -132,16 +130,35 @@ runs:
keychain_args+=(--keychain "${APPLE_CODESIGN_KEYCHAIN}")
fi
entitlements_file="${GITHUB_WORKSPACE}/.github/macos/codex-shared-keychain.entitlements"
if [[ ! -f "$entitlements_file" ]]; then
echo "Entitlements file $entitlements_file not found"
exit 1
fi
for binary in codex codex-responses-api-proxy; do
path="codex-rs/target/${TARGET}/release/${binary}"
codesign --force --options runtime --timestamp --sign "$APPLE_CODESIGN_IDENTITY" "${keychain_args[@]}" "$path"
path="codex-rs/target/${{ inputs.target }}/release/${binary}"
if [[ ! -e "$path" ]]; then
echo "Sign path $path not found"
exit 1
fi
codesign_args=(
--force
--options runtime
--timestamp
--sign "$APPLE_CODESIGN_IDENTITY"
--entitlements "$entitlements_file"
)
codesign_args+=("${keychain_args[@]}")
codesign "${codesign_args[@]}" "$path"
done
- name: Notarize macOS binaries
if: ${{ inputs.sign-binaries == 'true' }}
shell: bash
env:
TARGET: ${{ inputs.target }}
APPLE_NOTARIZATION_KEY_P8: ${{ inputs.apple-notarization-key-p8 }}
APPLE_NOTARIZATION_KEY_ID: ${{ inputs.apple-notarization-key-id }}
APPLE_NOTARIZATION_ISSUER_ID: ${{ inputs.apple-notarization-issuer-id }}
@@ -166,7 +183,7 @@ runs:
notarize_binary() {
local binary="$1"
local source_path="codex-rs/target/${TARGET}/release/${binary}"
local source_path="codex-rs/target/${{ inputs.target }}/release/${binary}"
local archive_path="${RUNNER_TEMP}/${binary}.zip"
if [[ ! -f "$source_path" ]]; then
@@ -187,7 +204,6 @@ runs:
if: ${{ inputs.sign-dmg == 'true' }}
shell: bash
env:
TARGET: ${{ inputs.target }}
APPLE_NOTARIZATION_KEY_P8: ${{ inputs.apple-notarization-key-p8 }}
APPLE_NOTARIZATION_KEY_ID: ${{ inputs.apple-notarization-key-id }}
APPLE_NOTARIZATION_ISSUER_ID: ${{ inputs.apple-notarization-issuer-id }}
@@ -210,8 +226,7 @@ runs:
source "$GITHUB_ACTION_PATH/notary_helpers.sh"
dmg_name="codex-${TARGET}.dmg"
dmg_path="codex-rs/target/${TARGET}/release/${dmg_name}"
dmg_path="codex-rs/target/${{ inputs.target }}/release/codex-${{ inputs.target }}.dmg"
if [[ ! -f "$dmg_path" ]]; then
echo "dmg $dmg_path not found"
@@ -224,7 +239,7 @@ runs:
fi
codesign --force --timestamp --sign "$APPLE_CODESIGN_IDENTITY" "${keychain_args[@]}" "$dmg_path"
notarize_submission "$dmg_name" "$dmg_path" "$notary_key_path"
notarize_submission "codex-${{ inputs.target }}.dmg" "$dmg_path" "$notary_key_path"
xcrun stapler staple "$dmg_path"
- name: Remove signing keychain

View File

@@ -0,0 +1,10 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>keychain-access-groups</key>
<array>
<string>2DC432GLL2.com.openai.codex.shared</string>
</array>
</dict>
</plist>

4
codex-rs/Cargo.lock generated
View File

@@ -1655,6 +1655,7 @@ dependencies = [
"codex-core",
"codex-exec",
"codex-execpolicy",
"codex-keyring-store",
"codex-login",
"codex-mcp-server",
"codex-protocol",
@@ -2101,7 +2102,10 @@ dependencies = [
name = "codex-keyring-store"
version = "0.0.0"
dependencies = [
"core-foundation 0.10.1",
"keyring",
"security-framework 3.5.1",
"security-framework-sys",
"tracing",
]

View File

@@ -30,6 +30,7 @@ codex-config = { workspace = true }
codex-core = { workspace = true }
codex-exec = { workspace = true }
codex-execpolicy = { workspace = true }
codex-keyring-store = { workspace = true }
codex-login = { workspace = true }
codex-mcp-server = { workspace = true }
codex-protocol = { workspace = true }

View File

@@ -21,6 +21,7 @@ use codex_exec::Cli as ExecCli;
use codex_exec::Command as ExecCommand;
use codex_exec::ReviewArgs;
use codex_execpolicy::ExecPolicyCheckCommand;
use codex_keyring_store::migrate_existing_codex_items_to_access_group;
use codex_responses_api_proxy::Args as ResponsesApiProxyArgs;
use codex_state::StateRuntime;
use codex_state::state_db_path;
@@ -601,6 +602,14 @@ async fn cli_main(arg0_paths: Arg0DispatchPaths) -> anyhow::Result<()> {
root_config_overrides.raw_overrides.extend(toggle_overrides);
let root_remote = remote.remote;
#[cfg(target_os = "macos")]
if let Err(error) = migrate_existing_codex_items_to_access_group() {
tracing::warn!(
error = %error,
"failed to migrate existing Codex keychain items into the shared macOS access group"
);
}
match subcommand {
None => {
prepend_config_flags(

View File

@@ -15,7 +15,10 @@ tracing = { workspace = true }
keyring = { workspace = true, features = ["linux-native-async-persistent"] }
[target.'cfg(target_os = "macos")'.dependencies]
core-foundation = "0.10"
keyring = { workspace = true, features = ["apple-native"] }
security-framework = { version = "3", features = ["OSX_10_15"] }
security-framework-sys = "2.15"
[target.'cfg(target_os = "windows")'.dependencies]
keyring = { workspace = true, features = ["windows-native"] }

View File

@@ -1,10 +1,19 @@
use keyring::Entry;
use keyring::Error as KeyringError;
use std::error::Error;
use std::fmt;
use std::fmt::Debug;
use tracing::trace;
#[cfg(target_os = "macos")]
mod macos;
#[cfg(not(target_os = "macos"))]
use keyring::Entry;
pub fn migrate_existing_codex_items_to_access_group() -> Result<(), CredentialStoreError> {
Ok(())
}
#[derive(Debug)]
pub enum CredentialStoreError {
Other(KeyringError),
@@ -38,6 +47,89 @@ impl fmt::Display for CredentialStoreError {
impl Error for CredentialStoreError {}
#[cfg(target_os = "macos")]
fn load_password(service: &str, account: &str) -> Result<Option<String>, CredentialStoreError> {
macos::load_password(service, account).map_err(|error| {
trace!(
"keyring.load macos native error, service={service}, account={account}, error={error}"
);
CredentialStoreError::new(KeyringError::PlatformFailure(Box::new(error)))
})
}
#[cfg(not(target_os = "macos"))]
fn load_password(service: &str, account: &str) -> Result<Option<String>, CredentialStoreError> {
let entry = Entry::new(service, account).map_err(CredentialStoreError::new)?;
match entry.get_password() {
Ok(password) => {
trace!("keyring.load success, service={service}, account={account}");
Ok(Some(password))
}
Err(keyring::Error::NoEntry) => {
trace!("keyring.load no entry, service={service}, account={account}");
Ok(None)
}
Err(error) => {
trace!("keyring.load error, service={service}, account={account}, error={error}");
Err(CredentialStoreError::new(error))
}
}
}
#[cfg(target_os = "macos")]
fn save_password(service: &str, account: &str, value: &str) -> Result<(), CredentialStoreError> {
macos::save_password(service, account, value).map_err(|error| {
trace!(
"keyring.save macos native error, service={service}, account={account}, error={error}"
);
CredentialStoreError::new(KeyringError::PlatformFailure(Box::new(error)))
})
}
#[cfg(not(target_os = "macos"))]
fn save_password(service: &str, account: &str, value: &str) -> Result<(), CredentialStoreError> {
let entry = Entry::new(service, account).map_err(CredentialStoreError::new)?;
match entry.set_password(value) {
Ok(()) => {
trace!("keyring.save success, service={service}, account={account}");
Ok(())
}
Err(error) => {
trace!("keyring.save error, service={service}, account={account}, error={error}");
Err(CredentialStoreError::new(error))
}
}
}
#[cfg(target_os = "macos")]
fn delete_password(service: &str, account: &str) -> Result<bool, CredentialStoreError> {
macos::delete_password(service, account).map_err(|error| {
trace!(
"keyring.delete macos native error, service={service}, account={account}, error={error}"
);
CredentialStoreError::new(KeyringError::PlatformFailure(Box::new(error)))
})
}
#[cfg(not(target_os = "macos"))]
fn delete_password(service: &str, account: &str) -> Result<bool, CredentialStoreError> {
let entry = Entry::new(service, account).map_err(CredentialStoreError::new)?;
match entry.delete_credential() {
Ok(()) => {
trace!("keyring.delete success, service={service}, account={account}");
Ok(true)
}
Err(keyring::Error::NoEntry) => {
trace!("keyring.delete no entry, service={service}, account={account}");
Ok(false)
}
Err(error) => {
trace!("keyring.delete error, service={service}, account={account}, error={error}");
Err(CredentialStoreError::new(error))
}
}
}
/// Shared credential store abstraction for keyring-backed implementations.
pub trait KeyringStore: Debug + Send + Sync {
fn load(&self, service: &str, account: &str) -> Result<Option<String>, CredentialStoreError>;
@@ -51,21 +143,7 @@ pub struct DefaultKeyringStore;
impl KeyringStore for DefaultKeyringStore {
fn load(&self, service: &str, account: &str) -> Result<Option<String>, CredentialStoreError> {
trace!("keyring.load start, service={service}, account={account}");
let entry = Entry::new(service, account).map_err(CredentialStoreError::new)?;
match entry.get_password() {
Ok(password) => {
trace!("keyring.load success, service={service}, account={account}");
Ok(Some(password))
}
Err(keyring::Error::NoEntry) => {
trace!("keyring.load no entry, service={service}, account={account}");
Ok(None)
}
Err(error) => {
trace!("keyring.load error, service={service}, account={account}, error={error}");
Err(CredentialStoreError::new(error))
}
}
load_password(service, account)
}
fn save(&self, service: &str, account: &str, value: &str) -> Result<(), CredentialStoreError> {
@@ -73,36 +151,12 @@ impl KeyringStore for DefaultKeyringStore {
"keyring.save start, service={service}, account={account}, value_len={}",
value.len()
);
let entry = Entry::new(service, account).map_err(CredentialStoreError::new)?;
match entry.set_password(value) {
Ok(()) => {
trace!("keyring.save success, service={service}, account={account}");
Ok(())
}
Err(error) => {
trace!("keyring.save error, service={service}, account={account}, error={error}");
Err(CredentialStoreError::new(error))
}
}
save_password(service, account, value)
}
fn delete(&self, service: &str, account: &str) -> Result<bool, CredentialStoreError> {
trace!("keyring.delete start, service={service}, account={account}");
let entry = Entry::new(service, account).map_err(CredentialStoreError::new)?;
match entry.delete_credential() {
Ok(()) => {
trace!("keyring.delete success, service={service}, account={account}");
Ok(true)
}
Err(keyring::Error::NoEntry) => {
trace!("keyring.delete no entry, service={service}, account={account}");
Ok(false)
}
Err(error) => {
trace!("keyring.delete error, service={service}, account={account}, error={error}");
Err(CredentialStoreError::new(error))
}
}
delete_password(service, account)
}
}

View File

@@ -0,0 +1,399 @@
use security_framework::base::Error as SecurityError;
use security_framework::passwords::PasswordOptions;
use security_framework::passwords::delete_generic_password_options;
use security_framework::passwords::generic_password;
use security_framework::passwords::set_generic_password_options;
use security_framework_sys::base::errSecItemNotFound;
use std::fmt;
use tracing::warn;
#[derive(Debug, Clone)]
pub(crate) struct MacOsAccessGroupError {
details: String,
}
impl MacOsAccessGroupError {
fn new(details: impl Into<String>) -> Self {
Self {
details: details.into(),
}
}
}
impl fmt::Display for MacOsAccessGroupError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(
f,
"failed to access macOS keychain item in shared access group: {}",
self.details
)
}
}
impl std::error::Error for MacOsAccessGroupError {}
const SHARED_ACCESS_GROUP: &str = "2DC432GLL2.com.openai.codex.shared";
pub(crate) fn load_password(
service: &str,
account: &str,
) -> Result<Option<String>, MacOsAccessGroupError> {
load_password_with_store(&NativePasswordStore, service, account)
}
pub(crate) fn save_password(
service: &str,
account: &str,
value: &str,
) -> Result<(), MacOsAccessGroupError> {
save_password_with_store(&NativePasswordStore, service, account, value)
}
pub(crate) fn delete_password(service: &str, account: &str) -> Result<bool, MacOsAccessGroupError> {
delete_password_with_store(&NativePasswordStore, service, account)
}
trait PasswordStore {
fn load_shared_password_bytes(
&self,
service: &str,
account: &str,
) -> Result<Option<Vec<u8>>, MacOsAccessGroupError>;
fn load_legacy_password_bytes(
&self,
service: &str,
account: &str,
) -> Result<Option<Vec<u8>>, MacOsAccessGroupError>;
fn save_shared_password_bytes(
&self,
service: &str,
account: &str,
value: &[u8],
) -> Result<(), MacOsAccessGroupError>;
fn delete_shared_password(
&self,
service: &str,
account: &str,
) -> Result<bool, MacOsAccessGroupError>;
fn delete_legacy_password(
&self,
service: &str,
account: &str,
) -> Result<bool, MacOsAccessGroupError>;
}
#[derive(Debug)]
struct NativePasswordStore;
impl PasswordStore for NativePasswordStore {
fn load_shared_password_bytes(
&self,
service: &str,
account: &str,
) -> Result<Option<Vec<u8>>, MacOsAccessGroupError> {
load_password_bytes(
shared_password_options(service, account),
"read shared generic password",
)
}
fn load_legacy_password_bytes(
&self,
service: &str,
account: &str,
) -> Result<Option<Vec<u8>>, MacOsAccessGroupError> {
load_password_bytes(
legacy_password_options(service, account),
"read legacy generic password",
)
}
fn save_shared_password_bytes(
&self,
service: &str,
account: &str,
value: &[u8],
) -> Result<(), MacOsAccessGroupError> {
set_generic_password_options(value, shared_password_options(service, account))
.map_err(|error| wrap_security_error("write shared generic password", error))
}
fn delete_shared_password(
&self,
service: &str,
account: &str,
) -> Result<bool, MacOsAccessGroupError> {
delete_password_options(
shared_password_options(service, account),
"delete shared generic password",
)
}
fn delete_legacy_password(
&self,
service: &str,
account: &str,
) -> Result<bool, MacOsAccessGroupError> {
delete_password_options(
legacy_password_options(service, account),
"delete legacy generic password",
)
}
}
fn load_password_with_store(
store: &impl PasswordStore,
service: &str,
account: &str,
) -> Result<Option<String>, MacOsAccessGroupError> {
if let Some(password) = store.load_shared_password_bytes(service, account)? {
return decode_password(password, service, account).map(Some);
}
let Some(password) = store.load_legacy_password_bytes(service, account)? else {
return Ok(None);
};
if let Err(error) = store.save_shared_password_bytes(service, account, &password) {
warn!(
error = %error,
service,
account,
"failed to backfill legacy macOS keychain item into the shared access group"
);
}
decode_password(password, service, account).map(Some)
}
fn save_password_with_store(
store: &impl PasswordStore,
service: &str,
account: &str,
value: &str,
) -> Result<(), MacOsAccessGroupError> {
store.save_shared_password_bytes(service, account, value.as_bytes())
}
fn delete_password_with_store(
store: &impl PasswordStore,
service: &str,
account: &str,
) -> Result<bool, MacOsAccessGroupError> {
let shared_removed = store.delete_shared_password(service, account)?;
let legacy_removed = store.delete_legacy_password(service, account)?;
Ok(shared_removed || legacy_removed)
}
fn decode_password(
password: Vec<u8>,
service: &str,
account: &str,
) -> Result<String, MacOsAccessGroupError> {
String::from_utf8(password).map_err(|error| {
MacOsAccessGroupError::new(format!(
"decode password for service={service}, account={account}: {error}"
))
})
}
fn load_password_bytes(
options: PasswordOptions,
action: &str,
) -> Result<Option<Vec<u8>>, MacOsAccessGroupError> {
match generic_password(options) {
Ok(password) => Ok(Some(password)),
Err(error) if error.code() == errSecItemNotFound => Ok(None),
Err(error) => Err(wrap_security_error(action, error)),
}
}
fn delete_password_options(
options: PasswordOptions,
action: &str,
) -> Result<bool, MacOsAccessGroupError> {
match delete_generic_password_options(options) {
Ok(()) => Ok(true),
Err(error) if error.code() == errSecItemNotFound => Ok(false),
Err(error) => Err(wrap_security_error(action, error)),
}
}
fn shared_password_options(service: &str, account: &str) -> PasswordOptions {
let mut options = PasswordOptions::new_generic_password(service, account);
options.use_protected_keychain();
options.set_access_group(SHARED_ACCESS_GROUP);
options
}
fn legacy_password_options(service: &str, account: &str) -> PasswordOptions {
PasswordOptions::new_generic_password(service, account)
}
fn wrap_security_error(action: &str, error: SecurityError) -> MacOsAccessGroupError {
MacOsAccessGroupError::new(format!("{action}: {error}"))
}
#[cfg(test)]
mod tests {
use super::*;
use std::cell::Cell;
use std::cell::RefCell;
#[derive(Default)]
struct MockPasswordStore {
shared: RefCell<Option<Vec<u8>>>,
legacy: RefCell<Option<Vec<u8>>>,
save_shared_error: RefCell<Option<MacOsAccessGroupError>>,
shared_writes: Cell<usize>,
}
impl MockPasswordStore {
fn with_shared_password(self, value: &str) -> Self {
self.shared.replace(Some(value.as_bytes().to_vec()));
self
}
fn with_legacy_password(self, value: &str) -> Self {
self.legacy.replace(Some(value.as_bytes().to_vec()));
self
}
fn with_save_shared_error(self, details: &str) -> Self {
self.save_shared_error
.replace(Some(MacOsAccessGroupError::new(details)));
self
}
}
impl PasswordStore for MockPasswordStore {
fn load_shared_password_bytes(
&self,
_service: &str,
_account: &str,
) -> Result<Option<Vec<u8>>, MacOsAccessGroupError> {
Ok(self.shared.borrow().clone())
}
fn load_legacy_password_bytes(
&self,
_service: &str,
_account: &str,
) -> Result<Option<Vec<u8>>, MacOsAccessGroupError> {
Ok(self.legacy.borrow().clone())
}
fn save_shared_password_bytes(
&self,
_service: &str,
_account: &str,
value: &[u8],
) -> Result<(), MacOsAccessGroupError> {
if let Some(error) = self.save_shared_error.borrow().clone() {
return Err(error);
}
self.shared.replace(Some(value.to_vec()));
self.shared_writes.set(self.shared_writes.get() + 1);
Ok(())
}
fn delete_shared_password(
&self,
_service: &str,
_account: &str,
) -> Result<bool, MacOsAccessGroupError> {
Ok(self.shared.borrow_mut().take().is_some())
}
fn delete_legacy_password(
&self,
_service: &str,
_account: &str,
) -> Result<bool, MacOsAccessGroupError> {
Ok(self.legacy.borrow_mut().take().is_some())
}
}
#[test]
fn load_prefers_shared_password_when_present() {
let store = MockPasswordStore::default()
.with_shared_password("shared-value")
.with_legacy_password("legacy-value");
let password = load_password_with_store(&store, "Codex Auth", "test-account")
.unwrap()
.expect("expected password");
assert_eq!(password, "shared-value");
assert_eq!(
store.shared.borrow().clone(),
Some(b"shared-value".to_vec())
);
assert_eq!(
store.legacy.borrow().clone(),
Some(b"legacy-value".to_vec())
);
assert_eq!(store.shared_writes.get(), 0);
}
#[test]
fn load_backfills_shared_password_from_legacy_without_deleting_legacy() {
let store = MockPasswordStore::default().with_legacy_password("legacy-value");
let password = load_password_with_store(&store, "Codex Auth", "test-account")
.unwrap()
.expect("expected password");
assert_eq!(password, "legacy-value");
assert_eq!(
store.shared.borrow().clone(),
Some(b"legacy-value".to_vec())
);
assert_eq!(
store.legacy.borrow().clone(),
Some(b"legacy-value".to_vec())
);
assert_eq!(store.shared_writes.get(), 1);
}
#[test]
fn load_returns_legacy_password_when_shared_backfill_fails() {
let store = MockPasswordStore::default()
.with_legacy_password("legacy-value")
.with_save_shared_error("shared write failed");
let password = load_password_with_store(&store, "Codex Auth", "test-account")
.unwrap()
.expect("expected password");
assert_eq!(password, "legacy-value");
assert_eq!(store.shared.borrow().clone(), None);
assert_eq!(
store.legacy.borrow().clone(),
Some(b"legacy-value".to_vec())
);
assert_eq!(store.shared_writes.get(), 0);
}
#[test]
fn save_writes_only_to_shared_password() {
let store = MockPasswordStore::default().with_legacy_password("legacy-value");
save_password_with_store(&store, "Codex Auth", "test-account", "shared-value").unwrap();
assert_eq!(
store.shared.borrow().clone(),
Some(b"shared-value".to_vec())
);
assert_eq!(
store.legacy.borrow().clone(),
Some(b"legacy-value".to_vec())
);
assert_eq!(store.shared_writes.get(), 1);
}
}