app-server: persist device key bindings in sqlite (#19206)

## 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`
This commit is contained in:
Ruslan Nigmatullin
2026-04-23 21:55:56 -07:00
committed by GitHub
parent e8d8080818
commit 19badb0be2
11 changed files with 622 additions and 258 deletions

View File

@@ -0,0 +1,66 @@
use super::*;
/// Persisted account/client binding for a generated device key.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct DeviceKeyBindingRecord {
pub key_id: String,
pub account_user_id: String,
pub client_id: String,
}
impl StateRuntime {
pub async fn get_device_key_binding(
&self,
key_id: &str,
) -> anyhow::Result<Option<DeviceKeyBindingRecord>> {
let row = sqlx::query(
r#"
SELECT key_id, account_user_id, client_id
FROM device_key_bindings
WHERE key_id = ?
"#,
)
.bind(key_id)
.fetch_optional(self.pool.as_ref())
.await?;
row.map(|row| {
Ok(DeviceKeyBindingRecord {
key_id: row.try_get("key_id")?,
account_user_id: row.try_get("account_user_id")?,
client_id: row.try_get("client_id")?,
})
})
.transpose()
}
pub async fn upsert_device_key_binding(
&self,
binding: &DeviceKeyBindingRecord,
) -> anyhow::Result<()> {
let now = Utc::now().timestamp();
sqlx::query(
r#"
INSERT INTO device_key_bindings (
key_id,
account_user_id,
client_id,
created_at,
updated_at
) VALUES (?, ?, ?, ?, ?)
ON CONFLICT(key_id) DO UPDATE SET
account_user_id = excluded.account_user_id,
client_id = excluded.client_id,
updated_at = excluded.updated_at
"#,
)
.bind(&binding.key_id)
.bind(&binding.account_user_id)
.bind(&binding.client_id)
.bind(now)
.bind(now)
.execute(self.pool.as_ref())
.await?;
Ok(())
}
}

View File

@@ -0,0 +1,89 @@
use super::DeviceKeyBindingRecord;
use super::StateRuntime;
use super::test_support::unique_temp_dir;
use pretty_assertions::assert_eq;
#[tokio::test]
async fn device_key_binding_round_trips_by_key_id() {
let codex_home = unique_temp_dir();
let runtime = StateRuntime::init(codex_home.clone(), "test-provider".to_string())
.await
.expect("initialize runtime");
let first = DeviceKeyBindingRecord {
key_id: "dk_tpm_AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA".to_string(),
account_user_id: "account-user-a".to_string(),
client_id: "cli_a".to_string(),
};
let second = DeviceKeyBindingRecord {
key_id: "dk_tpm_BBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB".to_string(),
account_user_id: "account-user-b".to_string(),
client_id: "cli_b".to_string(),
};
runtime
.upsert_device_key_binding(&first)
.await
.expect("insert first binding");
runtime
.upsert_device_key_binding(&second)
.await
.expect("insert second binding");
assert_eq!(
runtime
.get_device_key_binding(&first.key_id)
.await
.expect("load first binding"),
Some(first)
);
assert_eq!(
runtime
.get_device_key_binding("dk_tpm_missing")
.await
.expect("load missing binding"),
None
);
let _ = tokio::fs::remove_dir_all(codex_home).await;
}
#[tokio::test]
async fn device_key_binding_upsert_updates_existing_binding() {
let codex_home = unique_temp_dir();
let runtime = StateRuntime::init(codex_home.clone(), "test-provider".to_string())
.await
.expect("initialize runtime");
let key_id = "dk_tpm_AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA".to_string();
runtime
.upsert_device_key_binding(&DeviceKeyBindingRecord {
key_id: key_id.clone(),
account_user_id: "account-user-a".to_string(),
client_id: "cli_a".to_string(),
})
.await
.expect("insert binding");
runtime
.upsert_device_key_binding(&DeviceKeyBindingRecord {
key_id: key_id.clone(),
account_user_id: "account-user-b".to_string(),
client_id: "cli_b".to_string(),
})
.await
.expect("update binding");
assert_eq!(
runtime
.get_device_key_binding(&key_id)
.await
.expect("load updated binding"),
Some(DeviceKeyBindingRecord {
key_id,
account_user_id: "account-user-b".to_string(),
client_id: "cli_b".to_string(),
})
);
let _ = tokio::fs::remove_dir_all(codex_home).await;
}