M1/F1: Kimi Code OAuth device-code flow

Replace the xAI OAuth stack with the Kimi device authorization grant:
- kimi_oauth.rs wire layer (device_authorization + token poll + refresh
  against kigi_env::oauth_host(); client_id per PRD; retryable statuses
  429/5xx with backoff; expired_token restarts authorization)
- X-Msh-Device-{Name,Model,Id} headers; device_id minted uuid4-hex at
  ~/.kigi/device_id (0600)
- Storage: system keyring service `kigi`, entry `oauth/kimi-code`
  (macOS/Windows native backends), atomic-file fallback under ~/.kigi;
  official client's keyring/~/.kimi never touched
- Refresh manager: 60s tick, threshold max(300, expires_in*0.5),
  401-tombstone keyed by rejected refresh token with 300s cooldown and
  rotation auto-clear, cross-process lock with sibling-adoption
  triple-check, sleep/wake forced refresh
- Deleted xAI machinery: enterprise OIDC (PKCE/JWKS/teams), devbox login,
  external auth provider, JWT tier gating + subscription paywall stack,
  X-XAI-Token-Auth marker headers, ZDR gates, /user enrichment
- kigi login / TUI /login both drive the device flow; login-host display
  now derives from kigi_env::oauth_host()
- 264 auth unit/wiremock tests; live contract probe of
  auth.kimi.com/api/oauth/device_authorization matches the wire shapes

Gates: check/clippy --all-targets clean, fmt, deny ok, kigi-shell lib
5131 tests green.
This commit is contained in:
2026-07-17 07:37:29 -04:00
parent d6c20fc13f
commit 021b82443d
117 changed files with 4052 additions and 19900 deletions
@@ -0,0 +1,297 @@
//! Device identity headers for the Kimi Code OAuth endpoints.
//!
//! Every OAuth call (device authorization, token poll, refresh) carries three
//! headers identifying this installation (PRD F1):
//!
//! - `X-Msh-Device-Name` — the local hostname
//! - `X-Msh-Device-Model` — an honest local OS/arch string (e.g.
//! "macOS 15.5 arm64"), ported from kimi-cli's `_device_model()`
//! - `X-Msh-Device-Id` — a uuid4 hex persisted at `~/.kigi/device_id`
//! (owner-only), created on first use
//!
//! All values are ASCII-sanitized (ported from kimi-cli's
//! `_ascii_header_value`) since HTTP header values must be ASCII.
use std::path::PathBuf;
use std::sync::OnceLock;
use anyhow::Context as _;
/// Sanitize a header value to ASCII: non-ASCII bytes are dropped; an empty
/// result falls back to `"unknown"`. Port of kimi-cli `_ascii_header_value`.
pub(crate) fn ascii_header_value(value: &str) -> String {
let sanitized: String = value.chars().filter(char::is_ascii).collect();
let trimmed = sanitized.trim();
if trimmed.is_empty() {
"unknown".to_owned()
} else {
trimmed.to_owned()
}
}
/// The three device-identity headers sent on every OAuth call.
///
/// Errors when the persistent device id cannot be created (e.g. read-only
/// `~/.kigi`): the OAuth endpoints require `X-Msh-Device-Id`, so login cannot
/// proceed without it.
pub(crate) fn device_headers() -> anyhow::Result<[(&'static str, String); 3]> {
Ok([
("X-Msh-Device-Name", ascii_header_value(&device_name())),
("X-Msh-Device-Model", ascii_header_value(device_model())),
("X-Msh-Device-Id", ascii_header_value(&device_id()?)),
])
}
/// Local hostname (kimi-cli: `platform.node() or socket.gethostname()`).
fn device_name() -> String {
#[cfg(unix)]
{
let mut buf = [0u8; 256];
// SAFETY: buf is a valid writable buffer of the passed length.
let rc = unsafe { libc::gethostname(buf.as_mut_ptr().cast(), buf.len()) };
if rc == 0 {
let end = buf.iter().position(|&b| b == 0).unwrap_or(buf.len());
let name = String::from_utf8_lossy(&buf[..end]).into_owned();
if !name.trim().is_empty() {
return name;
}
}
"unknown".to_owned()
}
#[cfg(windows)]
{
std::env::var("COMPUTERNAME").unwrap_or_else(|_| "unknown".to_owned())
}
#[cfg(not(any(unix, windows)))]
{
"unknown".to_owned()
}
}
/// Honest local device-model string, computed once per process. Port of
/// kimi-cli `_device_model()`:
/// - macOS → `macOS {product_version} {arch}` (e.g. "macOS 15.5 arm64")
/// - Windows → `Windows {10|11} {arch}` (build ≥ 22000 reports 11)
/// - other → `{sysname} {kernel_release} {machine}`
pub(crate) fn device_model() -> &'static str {
static MODEL: OnceLock<String> = OnceLock::new();
MODEL.get_or_init(compute_device_model)
}
fn compute_device_model() -> String {
#[cfg(target_os = "macos")]
{
// Match Python's platform.machine() spelling on macOS.
let arch = match std::env::consts::ARCH {
"aarch64" => "arm64",
other => other,
};
match macos_product_version() {
Some(version) => format!("macOS {version} {arch}"),
None => format!("macOS {arch}"),
}
}
#[cfg(windows)]
{
let arch = std::env::consts::ARCH;
match windows_release() {
Some(release) => format!("Windows {release} {arch}"),
None => format!("Windows {arch}"),
}
}
#[cfg(not(any(target_os = "macos", windows)))]
{
let (sysname, release, machine) = uname_fields();
match (release, machine) {
(Some(r), Some(m)) => format!("{sysname} {r} {m}"),
(Some(r), None) => format!("{sysname} {r}"),
(None, Some(m)) => format!("{sysname} {m}"),
(None, None) => sysname,
}
}
}
/// macOS product version (e.g. "15.5") from the SystemVersion plist — the
/// same source Python's `platform.mac_ver()` reads.
#[cfg(target_os = "macos")]
fn macos_product_version() -> Option<String> {
let plist = std::fs::read_to_string("/System/Library/CoreServices/SystemVersion.plist").ok()?;
plist_string_value(&plist, "ProductVersion")
}
/// Extract `<key>{key}</key><string>value</string>` from a plist XML body.
#[cfg(target_os = "macos")]
fn plist_string_value(plist: &str, key: &str) -> Option<String> {
let key_tag = format!("<key>{key}</key>");
let after_key = &plist[plist.find(&key_tag)? + key_tag.len()..];
let start = after_key.find("<string>")? + "<string>".len();
let end = after_key.find("</string>")?;
(start <= end).then(|| after_key[start..end].trim().to_owned())
}
/// Windows major release ("10" or "11"), from the build number reported by
/// `cmd /c ver` (kimi-cli: `sys.getwindowsversion().build >= 22000` → 11).
#[cfg(windows)]
fn windows_release() -> Option<String> {
let output = std::process::Command::new("cmd")
.args(["/c", "ver"])
.output()
.ok()?;
let text = String::from_utf8_lossy(&output.stdout);
// "Microsoft Windows [Version 10.0.22631.3155]"
let version = text.split("Version").nth(1)?.trim();
let mut parts = version.trim_end_matches(']').split('.');
let major = parts.next()?.trim().to_owned();
let _minor = parts.next()?;
let build: u32 = parts.next()?.trim().parse().ok()?;
if major == "10" && build >= 22000 {
Some("11".to_owned())
} else {
Some(major)
}
}
/// `uname(2)` sysname / release / machine for Linux and other Unix.
#[cfg(all(unix, not(target_os = "macos")))]
fn uname_fields() -> (String, Option<String>, Option<String>) {
// SAFETY: utsname is a plain-old-data struct; uname fills it in.
let mut uts: libc::utsname = unsafe { std::mem::zeroed() };
if unsafe { libc::uname(&mut uts) } != 0 {
return (std::env::consts::OS.to_owned(), None, None);
}
fn field(raw: &[libc::c_char]) -> Option<String> {
let bytes: Vec<u8> = raw
.iter()
.take_while(|&&c| c != 0)
.map(|&c| c as u8)
.collect();
let s = String::from_utf8_lossy(&bytes).trim().to_owned();
(!s.is_empty()).then_some(s)
}
(
field(&uts.sysname).unwrap_or_else(|| std::env::consts::OS.to_owned()),
field(&uts.release),
field(&uts.machine),
)
}
#[cfg(not(unix))]
#[cfg(not(windows))]
fn uname_fields() -> (String, Option<String>, Option<String>) {
(std::env::consts::OS.to_owned(), None, None)
}
/// Path of the persistent device id: `{kigi_home}/device_id`.
fn device_id_path() -> PathBuf {
kigi_config::kigi_home().join("device_id")
}
/// Persistent uuid4-hex device id, created (owner-only, 0o600) on first use
/// and cached for the process lifetime.
pub(crate) fn device_id() -> anyhow::Result<String> {
static DEVICE_ID: OnceLock<String> = OnceLock::new();
if let Some(id) = DEVICE_ID.get() {
return Ok(id.clone());
}
let id = load_or_create_device_id(&device_id_path())?;
Ok(DEVICE_ID.get_or_init(|| id).clone())
}
/// Read `path`, or mint a uuid4 hex and persist it owner-only.
fn load_or_create_device_id(path: &std::path::Path) -> anyhow::Result<String> {
if let Ok(existing) = std::fs::read_to_string(path) {
let trimmed = existing.trim();
if !trimmed.is_empty() {
return Ok(trimmed.to_owned());
}
}
let id = uuid::Uuid::new_v4().simple().to_string();
if let Some(parent) = path.parent() {
std::fs::create_dir_all(parent)
.with_context(|| format!("creating {} for device_id", parent.display()))?;
}
std::fs::write(path, &id)
.with_context(|| format!("writing device id to {}", path.display()))?;
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
std::fs::set_permissions(path, std::fs::Permissions::from_mode(0o600))
.with_context(|| format!("chmod 600 {}", path.display()))?;
}
tracing::info!(path = %path.display(), "auth: created persistent device id");
Ok(id)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn ascii_header_value_passes_ascii_through() {
assert_eq!(ascii_header_value("macOS 15.5 arm64"), "macOS 15.5 arm64");
assert_eq!(ascii_header_value(" padded "), "padded");
}
#[test]
fn ascii_header_value_strips_non_ascii() {
assert_eq!(ascii_header_value("café-host"), "caf-host");
assert_eq!(ascii_header_value("机器"), "unknown");
assert_eq!(ascii_header_value(" "), "unknown");
}
#[test]
fn device_model_is_nonempty_ascii() {
let model = device_model();
assert!(!model.is_empty());
assert!(model.is_ascii(), "device model must be ASCII: {model:?}");
// The honest local OS name must lead the string.
#[cfg(target_os = "macos")]
assert!(model.starts_with("macOS "), "got {model:?}");
#[cfg(windows)]
assert!(model.starts_with("Windows"), "got {model:?}");
}
#[test]
fn load_or_create_device_id_roundtrips_and_is_owner_only() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("device_id");
let created = load_or_create_device_id(&path).unwrap();
assert_eq!(created.len(), 32, "uuid4 hex is 32 chars: {created:?}");
assert!(created.chars().all(|c| c.is_ascii_hexdigit()));
// Second call reads the same id back.
let reread = load_or_create_device_id(&path).unwrap();
assert_eq!(created, reread);
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
let mode = std::fs::metadata(&path).unwrap().permissions().mode();
assert_eq!(mode & 0o777, 0o600, "device_id must be owner-only");
}
}
#[test]
fn load_or_create_device_id_ignores_empty_file() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("device_id");
std::fs::write(&path, " \n").unwrap();
let created = load_or_create_device_id(&path).unwrap();
assert_eq!(created.len(), 32);
}
#[cfg(target_os = "macos")]
#[test]
fn plist_string_value_extracts_product_version() {
let plist = r#"<?xml version="1.0"?>
<dict>
<key>ProductBuildVersion</key>
<string>24F74</string>
<key>ProductVersion</key>
<string>15.5</string>
</dict>"#;
assert_eq!(
plist_string_value(plist, "ProductVersion").as_deref(),
Some("15.5")
);
assert_eq!(plist_string_value(plist, "Missing"), None);
}
}