Skip to content

Keyring凭据存储

从 CLI 认证工厂、系统 keyring、加密 Secrets backend 到 MCP OAuth 的锁与刷新事务,跟读凭据存储的真实源码。

基于rust-v0.150.0
CodexRustSecurityKeyringAuthentication

Keyring凭据存储 ​

“使用 keyring”在 Codex 里不是一个单一实现。CLI 认证先选择顶层存储模式,再由 keyring 模式选择 Direct 或 Secrets backend;MCP OAuth 又有自己的模式、聚合文件锁和生命周期固定规则。要理解安全边界,必须沿着 load、save、delete 三个接口追踪数据到底落在哪里,以及失败时是否允许换一个来源。

本文承接Secrets检测与脱敏和AgentIdentity密钥模型,只讨论 rust-v0.150.0 源码中的本地凭据存储,不把操作系统 keychain、Credential Manager 或 Secret Service 的内部实现假定成 Codex 行为。阅读重点是:模式选择、account key、加密文件、迁移清理、Auto 回退和刷新时的一致性。

1. 两层选择模型 ​

CLI 的 AuthCredentialsStoreMode 决定认证对象是否写文件、写 keyring、自动回退或只留内存。AuthKeyringBackendKind 只在选中 Keyring 或 Auto 时生效:Direct 把序列化对象放进系统 keyring,Secrets 把对象放进加密文件、只把 passphrase 放进 keyring。

源码位置:codex-rs/config/src/types.rs :: AuthCredentialsStoreMode、OAuthCredentialsStoreMode、AuthKeyringBackendKind

rust
pub enum AuthCredentialsStoreMode {
    #[default]
    File,
    Keyring,
    Auto,
    Ephemeral,
}

pub enum OAuthCredentialsStoreMode {
    #[default]
    Auto,
    File,
    Keyring,
}

pub enum AuthKeyringBackendKind {
    Direct,
    Secrets,
}

这里有一个容易误读的差异:CLI 的 Auto 是认证配置本身的模式;MCP OAuth 的 Auto 是 OAuth 凭据解析策略。它们都可能访问文件和 keyring,但不共享同一套生命周期语义。

源码位置:codex-rs/login/src/auth/storage.rs :: create_auth_storage、create_keyring_auth_storage

rust
match mode {
    AuthCredentialsStoreMode::File => Arc::new(FileAuthStorage::new(codex_home)),
    AuthCredentialsStoreMode::Keyring => {
        create_keyring_auth_storage(codex_home, keyring_store, keyring_backend_kind)
    }
    AuthCredentialsStoreMode::Auto => Arc::new(AutoAuthStorage::new(
        codex_home,
        keyring_store,
        keyring_backend_kind,
    )),
    AuthCredentialsStoreMode::Ephemeral => Arc::new(EphemeralAuthStorage::new(codex_home)),
}

Config::auth_config 和 bootstrap 路径都会把 feature 解析成同一个 AuthKeyringBackendKind。因此 backend 不是运行时探测结果,而是配置和 managed feature 合并后的确定选择。

源码位置:codex-rs/core/src/config/auth_keyring.rs :: auth_keyring_backend_kind_from_secret_auth_storage

rust
fn auth_keyring_backend_kind_from_secret_auth_storage(
    secret_auth_storage_enabled: bool,
) -> AuthKeyringBackendKind {
    if secret_auth_storage_enabled {
        AuthKeyringBackendKind::Secrets
    } else {
        AuthKeyringBackendKind::Direct
    }
}

2. 接口与文件后端 ​

KeyringStore 只暴露字符串值和三种操作。它把 keyring crate 的 NoEntry 转换为 None 或 false,其余错误继续向上传播。这个抽象使 login、Secrets 和 MCP OAuth 可以注入同一个 mock,而不用把测试绑定到某个桌面 keychain。

源码位置:codex-rs/keyring-store/src/lib.rs :: KeyringStore、DefaultKeyringStore::load

rust
pub trait KeyringStore: Debug + Send + Sync {
    fn load(&self, service: &str, account: &str) -> Result<Option<String>, CredentialStoreError>;
    fn save(&self, service: &str, account: &str, value: &str) -> Result<(), CredentialStoreError>;
    fn delete(&self, service: &str, account: &str) -> Result<bool, CredentialStoreError>;
}

match entry.get_password() {
    Ok(password) => Ok(Some(password)),
    Err(keyring::Error::NoEntry) => Ok(None),
    Err(error) => Err(CredentialStoreError::new(error)),
}

CLI File backend 的对象是完整 AuthDotJson,路径为 CODEX_HOME/auth.json。保存时先创建目录、pretty-print JSON,再在 Unix 设置 0600;读取时只有文件不存在被解释为“没有登录凭据”,JSON 或 IO 错误仍是错误。

源码位置:codex-rs/login/src/auth/storage.rs :: FileAuthStorage::save

rust
let json_data = serde_json::to_string_pretty(auth_dot_json)?;
let mut options = OpenOptions::new();
options.truncate(true).write(true).create(true);
#[cfg(unix)]
{
    options.mode(0o600);
}
let mut file = options.open(auth_file)?;
file.write_all(json_data.as_bytes())?;
file.flush()?;

3. Direct后端稳定键 ​

Direct backend 使用固定 service Codex Auth,但 account 不是完整 home 路径。源码先尝试 canonicalize CODEX_HOME,对规范化后的字符串做 SHA-256,取前 16 个十六进制字符并加上 cli| 前缀。这样既避免把本地路径直接暴露给 keyring,也让同一个 home 在不同调用点得到相同 account。

源码位置:codex-rs/login/src/auth/storage.rs :: compute_store_key

rust
let canonical = codex_home
    .canonicalize()
    .unwrap_or_else(|_| codex_home.to_path_buf());
let path_str = canonical.to_string_lossy();
let mut hasher = Sha256::new();
hasher.update(path_str.as_bytes());
let digest = hasher.finalize();
let hex = format!("{digest:x}");
let truncated = hex.get(..16).unwrap_or(&hex);
Ok(format!("cli|{truncated}"))

Direct load 先从 keyring 取字符串,再反序列化为 AuthDotJson;因此“keyring 中有值”不等于“凭据可用”,损坏的 JSON 会在 backend 边界报错。save 成功后才尝试删除旧的 auth.json,删除失败只记录 warning,不回滚已写入的 keyring 值。

源码位置:codex-rs/login/src/auth/storage.rs :: DirectKeyringAuthStorage::load、save

rust
match self.keyring_store.load(KEYRING_SERVICE, key) {
    Ok(Some(serialized)) => serde_json::from_str(&serialized).map(Some).map_err(|err| {
        std::io::Error::other(format!(
            "failed to deserialize CLI auth from keyring: {err}"
        ))
    }),
    Ok(None) => Ok(None),
    Err(error) => Err(std::io::Error::other(format!(
        "failed to load CLI auth from keyring: {}",
        error.message()
    ))),
}

4. Secrets后端材料 ​

Secrets backend 仍实现相同的 AuthStorageBackend,但认证 JSON 进入 secrets/codex_auth.age。系统 keyring 只保存由 load_or_create_passphrase 取得的 passphrase;SecretsManager 使用 LocalSecretsNamespace::CodexAuth 区分该文件与普通 managed secrets、MCP OAuth 文件。

源码位置:codex-rs/login/src/auth/storage.rs :: SecretsKeyringAuthStorage::new、load

rust
let secrets_manager = SecretsManager::new_with_keyring_store_and_namespace(
    codex_home.clone(),
    SecretsBackendKind::Local,
    keyring_store,
    LocalSecretsNamespace::CodexAuth,
);

match self
    .secrets_manager
    .get(&SecretScope::Global, &CODEX_AUTH_SECRET_NAME)?
{
    Some(serialized) => serde_json::from_str(&serialized).map(Some).map_err(|err| {
        std::io::Error::other(format!(
            "failed to deserialize CLI auth from encrypted auth storage: {err}"
        ))
    }),
    None => Ok(None),
}

本地 backend 读取密文后才加载 passphrase,解密 JSON,并拒绝高于当前支持版本的 schema。写入先生成密文临时文件、sync_all,再 rename;Windows 替换已有文件时会先删除旧目标,因此替换瞬间存在目标文件缺失窗口。

源码位置:codex-rs/secrets/src/local.rs :: LocalSecretsBackend::load_file、write_file_atomically

rust
let ciphertext = fs::read(&path)?;
let passphrase = self.load_or_create_passphrase()?;
let plaintext = decrypt_with_passphrase(&ciphertext, &passphrase)?;
let mut parsed: SecretsFile = serde_json::from_slice(&plaintext)?;
anyhow::ensure!(
    parsed.version <= SECRETS_VERSION,
    "secrets file version {} is newer than supported version {}",
    parsed.version,
    SECRETS_VERSION
);

源码位置:codex-rs/secrets/src/local.rs :: LocalSecretsBackend::load_or_create_passphrase、generate_passphrase

rust
let loaded = self
    .keyring_store
    .load(keyring_service(), &account)
    .map_err(|err| anyhow::anyhow!(err.message()))?;
match loaded {
    Some(existing) => Ok(SecretString::from(existing)),
    None => {
        let generated = generate_passphrase()?;
        self.keyring_store
            .save(keyring_service(), &account, generated.expose_secret())?;
        Ok(generated)
    }
}

这条链路没有“keyring 失败就写明文”的分支。缺少 passphrase 可以生成新值,但已有密文而 keyring 不可用时,解密失败会直接返回;这避免把同一份认证材料悄悄复制到明文文件。

5. 删除与迁移边界 ​

Secrets backend 的 delete 比 save 更宽容:它删除加密 namespace、fallback auth.json,还调用 direct_storage.delete() 清理旧版 Direct entry。迁移完成后,即使当前配置已经切到 Secrets,历史 Direct 值也不会残留在 keyring。

源码位置:codex-rs/login/src/auth/storage.rs :: SecretsKeyringAuthStorage::delete

rust
let keyring_removed = self
    .secrets_manager
    .delete(&SecretScope::Global, &CODEX_AUTH_SECRET_NAME)?;
let file_removed = delete_file_if_exists(&self.codex_home)?;
let direct_removed = self.direct_storage.delete()?;
Ok(keyring_removed || file_removed || direct_removed)

CLI AutoAuthStorage 只在顶层模式为 Auto 时做隐式回退:keyring load/save 出错会 warning 后访问 File;Keyring 模式则把错误交给调用者。成功写入 keyring 后,fallback 文件的删除是 best effort,删除失败不会把成功写入变成失败。

源码位置:codex-rs/login/src/auth/storage.rs :: AutoAuthStorage::load、save

rust
match self.keyring_storage.load() {
    Ok(Some(auth)) => Ok(Some(auth)),
    Ok(None) => self.file_storage.load(),
    Err(err) => {
        warn!("failed to load CLI auth from keyring, falling back to file storage: {err}");
        self.file_storage.load()
    }
}

6. MCP OAuth策略 ​

MCP OAuth 的 keyring service 是 Codex MCP Credentials,Direct entry 按服务器名和 URL 计算 key;Secrets backend 使用 LocalSecretsNamespace::McpOAuth,并把可读的 store key 再哈希为满足 SecretName 字符集的名称。File fallback 是 CODEX_HOME/.credentials.json,其中带有 executor_owned 标记,防止 executor 凭据被普通 host lookup 误读。

源码位置:codex-rs/rmcp-client/src/oauth.rs :: compute_store_key、compute_secret_name

rust
let server_name = server_name.strip_prefix("local:").unwrap_or(server_name);
let mut payload = JsonMap::new();
payload.insert("type".to_string(), Value::String(MCP_SERVER_TYPE.to_string()));
payload.insert("url".to_string(), Value::String(server_url.to_string()));
payload.insert("headers".to_string(), Value::Object(JsonMap::new()));
let truncated = sha_256_prefix(&Value::Object(payload))?;
let separator = if executor_owned { ':' } else { '|' };
Ok(format!("{server_name}{separator}{truncated}"))

源码位置:codex-rs/rmcp-client/src/oauth/resolved_store.rs :: resolve_oauth_tokens_from_store_policy

rust
OAuthCredentialsStoreMode::Auto => match load_oauth_tokens_from_keyring(
        keyring_store,
        keyring_backend_kind,
        server_name,
        url,
    ) {
        Ok(Some(tokens)) => Ok(Some(ResolvedOAuthTokens {
            tokens,
            store: ResolvedOAuthCredentialStore::Keyring(keyring_backend_kind),
        })),
        Ok(None) => Ok(load_oauth_tokens_from_file(server_name, url)?.map(|tokens| {
            ResolvedOAuthTokens {
                tokens,
                store: ResolvedOAuthCredentialStore::File,
            }
        })),
        Err(OAuthKeyringLoadError::StoreLock(error)) => Err(error.into()),
        Err(error) => {
            warn!("failed to read OAuth tokens from keyring: {error}");
            Ok(load_oauth_tokens_from_file(server_name, url)?.map(|tokens| {
                ResolvedOAuthTokens {
                    tokens,
                    store: ResolvedOAuthCredentialStore::File,
                }
            }))
        }
    },

这里的回退条件比“任何错误都换文件”严格:keyring backend 不可用时可以回退,但聚合 store lock 失败必须直接返回。锁失败意味着另一个进程可能正在更新 Secrets;此时读取 File 可能得到较新的值,却仍被后续流程优先使用旧的 Secrets 值。

7. 聚合锁与固定来源 ​

MCP 的 File 和 Secrets 都是一份包含多个服务器凭据的聚合文档,所以读写要覆盖完整的 read-modify-write。OAuthStoreLock 在 CODEX_HOME/mcp-oauth-locks 下分别维护 file-store.lock 和 secrets-store.lock;Direct keyring 每个凭据独立存放,不需要该聚合锁。

源码位置:codex-rs/rmcp-client/src/oauth/store_lock.rs :: OAuthStoreLock::acquire_for_write、try_acquire_for_read

rust
pub(super) fn acquire_for_write(store: OAuthStore) -> Result<Self, OAuthStoreLockFailure> {
    Self::acquire_with_timeout(
        store,
        STORE_LOCK_ACQUIRE_TIMEOUT,
        OAuthStoreLockMode::Exclusive,
    )
}

pub(super) fn try_acquire_for_read(store: OAuthStore) -> Result<Self, OAuthStoreLockFailure> {
    Self::acquire_with_timeout(store, Duration::ZERO, OAuthStoreLockMode::Shared)
}

ResolvedOAuthCredentialStore 会记录本次 client 生命周期实际选中的 File 或 Keyring(Direct/Secrets)。之后的 reload、save、delete 都通过这个具体值,不重新解释 Auto。这样刷新时不会因为 keyring 暂时不可用而突然切到可能过期的 .credentials.json。

源码位置:codex-rs/rmcp-client/src/oauth/resolved_store.rs :: ResolvedOAuthCredentialStore::save

rust
pub(crate) fn save<K: KeyringStore + Clone + 'static>(
    self,
    keyring_store: &K,
    server_name: &str,
    tokens: &StoredOAuthTokens,
) -> Result<()> {
    match self {
        Self::File => save_oauth_tokens_to_file(tokens),
        Self::Keyring(keyring_backend_kind) => save_oauth_tokens_with_keyring(
            keyring_store,
            keyring_backend_kind,
            server_name,
            tokens,
        ),
    }
}

8. 刷新事务 ​

MCP OAuth 刷新不是简单的“拿 refresh token 调接口”。refresh_if_needed 先把实际事务放入独立 Tokio task,使调用方取消不会中途终止可能已经旋转 token 的操作;事务内部再取得服务器级 refresh lock,重读生命周期固定的 store。

源码位置:codex-rs/rmcp-client/src/oauth/refresh_transaction.rs :: OAuthPersistor::refresh_if_needed

rust
let persistor = self.clone();
let keyring_store = keyring_store.clone();
let transaction_task = tokio::spawn(async move {
    let result = persistor
        .refresh_transaction(&keyring_store, refresh_request_timeout)
        .await;
    if let Err(error) = &result {
        warn!(
            server_name = %persistor.inner.server_name,
            refresh_reason = "expiry",
            error = %error,
            "MCP OAuth refresh transaction failed"
        );
    }
    result
});
transaction_task.await.with_context(|| {
    format!("OAuth refresh task failed for server {}", self.inner.server_name)
})?

事务拿到锁后先重读 authoritative credentials。如果别的进程已经刷新成功且新 token 尚未过期,当前进程直接采用 winner;如果凭据被删除,清空内存中的 manager 并返回 AuthorizationRequired。只有确认仍需刷新时,才解析 metadata、校验 refresh token 的 issuer,再调用 provider。

源码位置:codex-rs/rmcp-client/src/oauth/refresh_transaction.rs :: OAuthPersistor::refresh_transaction

rust
let _lock = RefreshCredentialLock::acquire_for_server(
    &self.inner.server_name,
    &self.inner.url,
)
.await?;
let latest = self.inner.credential_store.load(
    keyring_store,
    &self.inner.server_name,
    &self.inner.url,
)?;
let Some(latest) = latest else {
    let manager = self.inner.authorization_manager.clone();
    manager
        .lock()
        .await
        .set_credential_store(InMemoryCredentialStore::new());
    *self.inner.last_credentials.lock().await = None;
    return Err(AuthError::AuthorizationRequired).with_context(|| {
        format!(
            "OAuth tokens for server {} were removed before refresh; authorization required",
            self.inner.server_name
        )
    });
};

provider 返回的新 token 必须先写回 pinned store,再安装到 AuthorizationManager。持久化失败时恢复旧的 in-process credential 并返回错误,不把“已经能用但尚未落盘”的 token 暴露给后续请求;refresh rejection 才转换为重新授权,普通 provider 错误和超时保持普通错误。

源码位置:codex-rs/rmcp-client/src/oauth/refresh_transaction.rs :: OAuthPersistor::refresh_transaction

rust
if let Err(error) = self
    .inner
    .credential_store
    .save(keyring_store, &self.inner.server_name, &refreshed)
{
    install_tokens_in_manager(&mut guard, &latest)
        .await
        .context("failed to restore previous OAuth credentials after refresh persistence failed")?;
    return Err(error);
}

install_tokens_in_manager(&mut guard, &refreshed).await?;
*self.inner.last_credentials.lock().await = Some(refreshed);

9. 删除与回退矩阵 ​

MCP OAuth 的保存和删除还要区分 store mode。Auto 保存时,keyring backend 错误可以回退到 File,但 lock failure 不回退;Keyring 模式始终报告 keyring 错误。删除时,Auto 和 Keyring 的 keyring 删除失败会返回错误,File 模式则继续删除 File entry。

源码位置:codex-rs/rmcp-client/src/oauth.rs :: save_oauth_tokens_with_keyring_with_fallback_to_file、delete_oauth_tokens_from_keyring_and_file

rust
match save_oauth_tokens_with_keyring_and_cleanup_file(
    keyring_store,
    keyring_backend_kind,
    server_name,
    tokens,
) {
    Ok(()) => Ok(()),
    Err(error) if error.downcast_ref::<OAuthStoreLockFailure>().is_some() => Err(error),
    Err(error) => {
        let message = error.to_string();
        warn!("falling back to file storage for OAuth tokens: {message}");
        save_oauth_tokens_to_file(tokens)
            .with_context(|| format!("failed to write OAuth tokens to keyring: {message}"))
    }
}

这个矩阵解释了为什么“Auto 可回退”不能被概括成“keyring 永远优先”或“失败总写文件”:回退是策略的一部分,锁错误则是并发一致性错误。两者混在一起会把新旧 refresh token 的竞争隐藏起来。

10. 测试与适用边界 ​

源码测试覆盖的是可重复的存储契约:

  • codex-rs/keyring-store/src/lib.rs :: tests::MockKeyringStore 验证 NoEntry、保存值、删除和注入错误。
  • codex-rs/login/src/auth/storage_tests.rs 验证 Direct/Secrets roundtrip、account key、Auto 优先级、fallback 文件清理和旧 Direct entry 删除。
  • codex-rs/secrets/src/local.rs 的测试验证 namespace 文件、schema、原子写入、keyring 失败和 MCP cache 失效。
  • codex-rs/rmcp-client/src/oauth 的测试验证 store resolution、Auto 回退、聚合锁竞争、生命周期固定和刷新失败路径。

可以这样运行与源码最接近的测试:

源码位置:codex-rs/login/src/auth/storage_tests.rs、codex-rs/rmcp-client/src/oauth

text
cd codex-rs
cargo test -p codex-keyring-store --lib -- --nocapture --test-threads=1
cargo test -p codex-login --lib auth::storage -- --nocapture --test-threads=1
cargo test -p codex-core --lib auth_keyring -- --nocapture --test-threads=1
cargo test -p codex-rmcp-client --lib oauth -- --nocapture --test-threads=1

这些测试能证明模式选择、序列化/解密、错误分类、文件清理和本地并发协议;不能证明操作系统 keychain 的解锁策略、桌面会话可用性、不同用户的隔离、远端 OAuth provider 的真实响应,或硬件安全模块提供的保护。加密文件也不意味着进程内的 String 永远不会包含明文,刷新事务的 provider 超时仍可能留下未知的远端结果,只能等待下一次串行重试。

补充凭据后端选择、锁和刷新事务的关系图。

11. 阅读闭环 ​

建议按这个顺序回到源码:create_auth_storage → FileAuthStorage → DirectKeyringAuthStorage → SecretsKeyringAuthStorage → AutoAuthStorage,然后切换到 resolve_oauth_tokens_from_store_policy、OAuthStoreLock 和 OAuthPersistor::refresh_transaction。读完这条链,应该能够回答三个问题:凭据当前由谁拥有、一次失败是否允许换存储、以及刷新后的 token 在什么时候才真正对请求可见。