diff --git a/.changeset/keychain-credential-storage.md b/.changeset/keychain-credential-storage.md new file mode 100644 index 000000000..26f6894c2 --- /dev/null +++ b/.changeset/keychain-credential-storage.md @@ -0,0 +1,6 @@ +--- +"@moonshot-ai/kimi-code": minor +"@moonshot-ai/kimi-code-oauth": minor +--- + +Store OAuth credentials in the OS keychain (macOS Keychain, Windows Credential Manager, Linux Secret Service) by default, falling back to the plaintext file store when the keychain is unavailable — unsupported platform, missing/locked backend, native binary not loadable, or `KIMI_DISABLE_KEYRING=1`. Existing plaintext credentials are migrated into the keychain on first read and then deleted, so users stay logged in and the on-disk secret is removed. Set `KIMI_DISABLE_KEYRING=1` to opt out and keep using the plaintext file store. diff --git a/apps/kimi-code/package.json b/apps/kimi-code/package.json index 0305be0e5..a58acd4a5 100644 --- a/apps/kimi-code/package.json +++ b/apps/kimi-code/package.json @@ -75,6 +75,7 @@ }, "optionalDependencies": { "@mariozechner/clipboard": "^0.3.2", + "@napi-rs/keyring": "1.3.0", "chalk": "^5.4.1", "cli-highlight": "^2.1.11", "commander": "^13.1.0", diff --git a/apps/kimi-code/scripts/native/check-bundle.mjs b/apps/kimi-code/scripts/native/check-bundle.mjs index 1521d6716..8e9578b72 100644 --- a/apps/kimi-code/scripts/native/check-bundle.mjs +++ b/apps/kimi-code/scripts/native/check-bundle.mjs @@ -23,7 +23,7 @@ const optionalRuntimeRequires = new Set([ 'utf-8-validate', ]); const optionalRelativeRuntimeRequires = new Set(['./crypto/build/Release/sshcrypto.node']); -const handledNativeRuntimeRequires = new Set(['koffi']); +const handledNativeRuntimeRequires = new Set(['koffi', '@napi-rs/keyring']); function isAllowedSpecifier(specifier) { if (builtins.has(specifier) || specifier.startsWith('node:')) return true; diff --git a/apps/kimi-code/scripts/native/native-deps.mjs b/apps/kimi-code/scripts/native/native-deps.mjs index f195a3cb1..bb95f982e 100644 --- a/apps/kimi-code/scripts/native/native-deps.mjs +++ b/apps/kimi-code/scripts/native/native-deps.mjs @@ -27,6 +27,15 @@ const clipboardSubpackageByTarget = Object.freeze({ 'win32-x64': '@mariozechner/clipboard-win32-x64-msvc', }); +const keyringSubpackageByTarget = Object.freeze({ + 'darwin-arm64': '@napi-rs/keyring-darwin-arm64', + 'darwin-x64': '@napi-rs/keyring-darwin-x64', + 'linux-arm64': '@napi-rs/keyring-linux-arm64-gnu', + 'linux-x64': '@napi-rs/keyring-linux-x64-gnu', + 'win32-arm64': '@napi-rs/keyring-win32-arm64-msvc', + 'win32-x64': '@napi-rs/keyring-win32-x64-msvc', +}); + const koffiTripletByTarget = Object.freeze({ 'darwin-arm64': 'darwin_arm64', 'darwin-x64': 'darwin_x64', @@ -68,6 +77,18 @@ export const nativeDeps = Object.freeze([ collect: 'native-files', parent: 'clipboard-host', }, + { + id: 'keyring-host', + name: () => '@napi-rs/keyring', + collect: 'js-only', + parent: null, + }, + { + id: 'keyring-target', + name: (target) => keyringSubpackageByTarget[target], + collect: 'native-files', + parent: 'keyring-host', + }, { id: 'pi-tui', name: () => '@earendil-works/pi-tui', diff --git a/apps/kimi-code/src/native/module-hook.ts b/apps/kimi-code/src/native/module-hook.ts index bbef5d4cb..15b8c9311 100644 --- a/apps/kimi-code/src/native/module-hook.ts +++ b/apps/kimi-code/src/native/module-hook.ts @@ -9,6 +9,7 @@ interface ModuleWithLoad { } const nodeRequire = createRequire(import.meta.url); +const NATIVE_ASSET_PACKAGES = new Set(['koffi', '@napi-rs/keyring']); let installed = false; let loadingNativePackage = false; @@ -26,10 +27,10 @@ export function installNativeModuleHook(): void { parent: unknown, isMain: boolean, ): unknown { - if (request === 'koffi' && !loadingNativePackage) { + if (NATIVE_ASSET_PACKAGES.has(request) && !loadingNativePackage) { loadingNativePackage = true; try { - const pkg = loadNativePackage('koffi'); + const pkg = loadNativePackage(request); if (pkg !== null) return pkg; } finally { loadingNativePackage = false; diff --git a/apps/kimi-code/src/native/smoke.ts b/apps/kimi-code/src/native/smoke.ts index 56d39253f..fbf09982a 100644 --- a/apps/kimi-code/src/native/smoke.ts +++ b/apps/kimi-code/src/native/smoke.ts @@ -1,6 +1,6 @@ import { getEmbeddedNativeAssetManifest, getNativePackageRoot } from './native-assets'; -const smokePackages = ['@mariozechner/clipboard', 'koffi']; +const smokePackages = ['@mariozechner/clipboard', 'koffi', '@napi-rs/keyring']; export function runNativeAssetSmokeIfRequested(): boolean { if (process.env['KIMI_CODE_NATIVE_ASSET_SMOKE'] !== '1') return false; diff --git a/apps/kimi-code/tsdown.native.config.ts b/apps/kimi-code/tsdown.native.config.ts index c1008cb61..f25f27dd6 100644 --- a/apps/kimi-code/tsdown.native.config.ts +++ b/apps/kimi-code/tsdown.native.config.ts @@ -16,7 +16,7 @@ const builtins = new Set([ ...builtinModules, ...builtinModules.map((name) => `node:${name}`), ]); -const optionalNativeDependencies = new Set(['cpu-features']); +const optionalNativeDependencies = new Set(['cpu-features', '@napi-rs/keyring']); function shouldAlwaysBundle(id: string): boolean { if (builtins.has(id) || id.startsWith('node:')) return false; diff --git a/apps/kimi-code/vitest.config.ts b/apps/kimi-code/vitest.config.ts index 350417bc2..1576ed88c 100644 --- a/apps/kimi-code/vitest.config.ts +++ b/apps/kimi-code/vitest.config.ts @@ -14,6 +14,10 @@ export default defineConfig({ name: 'cli', env: { KIMI_LOG_LEVEL: 'off', + // Keep credential tests hermetic: the OAuth toolkit now defaults to a + // keychain-backed store via resolveTokenStorage. Force the file backend + // so tests never read/write the developer's real OS keychain. + KIMI_DISABLE_KEYRING: '1', }, include: ['test/**/*.test.ts', 'test/**/*.test.tsx'], }, diff --git a/docs/en/configuration/config-files.md b/docs/en/configuration/config-files.md index 0d64f5044..2b6886412 100644 --- a/docs/en/configuration/config-files.md +++ b/docs/en/configuration/config-files.md @@ -107,6 +107,8 @@ Each entry in the `providers` table defines an API provider, keyed by a unique n | `env` | `table` | No | Fallback source for provider credentials; see below | | `custom_headers` | `table` | No | Custom HTTP headers attached to each request | +> **OAuth credential storage**: OAuth tokens are stored in the operating system keychain (macOS Keychain, Windows Credential Manager, Linux Secret Service) by default. Any pre-existing plaintext credential file is migrated into the keychain and then removed. The `oauth.storage` field records where the token lives and is injected automatically — it does not select the backend. Set [`KIMI_DISABLE_KEYRING=1`](./env-vars.md#runtime-switches) to force plaintext-file storage; this is also the automatic fallback when no keychain is available. + **`env` sub-table**: You can write provider-conventional key names (such as `KIMI_API_KEY`) inside `[providers..env]` as a fallback source for `api_key` / `base_url`. This sub-table is **read only from the config file** and does not modify the shell environment: ```toml diff --git a/docs/en/configuration/env-vars.md b/docs/en/configuration/env-vars.md index 77b7bdfb2..5d4528c75 100644 --- a/docs/en/configuration/env-vars.md +++ b/docs/en/configuration/env-vars.md @@ -134,6 +134,7 @@ Switches that control the behavior of subsystems such as telemetry, background t | `KIMI_MODEL_THINKING_KEEP` | Moonshot preserved-thinking passthrough (`thinking.keep`); applies to the `kimi` provider only, and only while Thinking is on | A value the API accepts, e.g. `all` | | `KIMI_CODE_NO_AUTO_UPDATE` | Fully disable the update preflight — no check, background install, or prompt. Legacy alias `KIMI_CLI_NO_AUTO_UPDATE` is also honored | Truthy: `1`/`true`/`yes`/`on` | | `KIMI_DISABLE_CRON` | Disable the scheduled-task tool (`CronCreate` rejects new schedules; existing tasks do not fire) | `1` to disable | +| `KIMI_DISABLE_KEYRING` | Force plaintext-file OAuth credential storage instead of the OS keychain (also the automatic fallback when no keychain is available) | `1` to force the file backend | ## Diagnostic logs diff --git a/docs/zh/configuration/config-files.md b/docs/zh/configuration/config-files.md index e0a215f56..fdddc6bae 100644 --- a/docs/zh/configuration/config-files.md +++ b/docs/zh/configuration/config-files.md @@ -107,6 +107,8 @@ timeout = 5 | `env` | `table` | 否 | 供应商凭证的备用来源,详见下文 | | `custom_headers` | `table` | 否 | 每次请求附加的自定义 HTTP 头 | +> **OAuth 凭据存储**:OAuth 令牌默认存放在操作系统密钥链(macOS Keychain、Windows 凭据管理器、Linux Secret Service)中。已有的明文凭据文件会被迁移到密钥链并随后删除。`oauth.storage` 字段记录令牌所在位置、由登录流程自动注入,并不用于选择后端。设置 [`KIMI_DISABLE_KEYRING=1`](./env-vars.md#运行时开关) 可强制使用明文文件存储;当系统没有可用密钥链时也会自动回退到该方式。 + **`env` 子表**:可以把供应商惯用的键名(如 `KIMI_API_KEY`)写在 `[providers..env]` 里,作为 `api_key` / `base_url` 的备用来源。这个子表**只在配置文件里读取**,不会修改 shell 环境: ```toml diff --git a/docs/zh/configuration/env-vars.md b/docs/zh/configuration/env-vars.md index e87eb146a..558d29a74 100644 --- a/docs/zh/configuration/env-vars.md +++ b/docs/zh/configuration/env-vars.md @@ -134,6 +134,7 @@ kimi | `KIMI_MODEL_THINKING_KEEP` | Moonshot 保留思考透传(`thinking.keep`),仅对 `kimi` 供应商生效,且仅在 Thinking 开启时注入 | API 接受的值,如 `all` | | `KIMI_CODE_NO_AUTO_UPDATE` | 完全禁用更新预检——不检查、不后台安装、不提示。同时兼容旧名 `KIMI_CLI_NO_AUTO_UPDATE` | 真值:`1`/`true`/`yes`/`on` | | `KIMI_DISABLE_CRON` | 禁用定时任务工具(`CronCreate` 拒绝新计划,已有任务不触发) | `1` 表示禁用 | +| `KIMI_DISABLE_KEYRING` | 强制 OAuth 凭据使用明文文件存储而非操作系统密钥链(系统无可用密钥链时也会自动回退到该方式) | `1` 表示强制使用文件后端 | ## 诊断日志 diff --git a/flake.nix b/flake.nix index 88a79f610..68e5e3378 100644 --- a/flake.nix +++ b/flake.nix @@ -150,7 +150,7 @@ inherit (finalAttrs) pname version src pnpmWorkspaces; inherit pnpm; fetcherVersion = 3; - hash = "sha256-u+u5Vm6UgrMW/SwiBoSz2WhKp8GOehk4p6euwlinwFI="; + hash = "sha256-XbJ8lTNywbZ+saZ33A7qAa9V3JHYgPBM1Rh2ySenvxs="; }; nativeBuildInputs = [ diff --git a/packages/node-sdk/package.json b/packages/node-sdk/package.json index 1d83cf528..5df0b220c 100644 --- a/packages/node-sdk/package.json +++ b/packages/node-sdk/package.json @@ -57,6 +57,7 @@ }, "dependencies": { "@antfu/utils": "^9.3.0", + "@napi-rs/keyring": "1.3.0", "smol-toml": "^1.6.1", "yazl": "^3.3.1", "zod": "^4.3.6" diff --git a/packages/node-sdk/vitest.config.ts b/packages/node-sdk/vitest.config.ts index 807e24f44..972fc2645 100644 --- a/packages/node-sdk/vitest.config.ts +++ b/packages/node-sdk/vitest.config.ts @@ -15,6 +15,11 @@ export default defineConfig({ name: 'kimi-sdk', env: { KIMI_LOG_LEVEL: 'off', + // Keep credential tests hermetic: the OAuth toolkit now defaults to a + // keychain-backed store via resolveTokenStorage, and the oauth source + // alias resolves the native @napi-rs/keyring binary. Force the file + // backend so tests never read/write the developer's real OS keychain. + KIMI_DISABLE_KEYRING: '1', }, include: ['test/**/*.test.ts'], }, diff --git a/packages/oauth/package.json b/packages/oauth/package.json index 9ed7b0487..2481abf8b 100644 --- a/packages/oauth/package.json +++ b/packages/oauth/package.json @@ -40,9 +40,11 @@ "scripts": { "build": "tsdown", "typecheck": "tsc -p tsconfig.json --noEmit", + "test": "vitest run", "clean": "rm -rf dist" }, "dependencies": { + "@napi-rs/keyring": "1.3.0", "proper-lockfile": "^4.1.2" }, "devDependencies": { diff --git a/packages/oauth/src/index.ts b/packages/oauth/src/index.ts index 2c515393d..597e2372b 100644 --- a/packages/oauth/src/index.ts +++ b/packages/oauth/src/index.ts @@ -20,6 +20,9 @@ export { tokenFromWire, tokenToWire } from './types'; export type { TokenStorage } from './storage'; export { FileTokenStorage } from './storage'; +export { KeyringTokenStorage, resolveTokenStorage } from './keyring-storage'; +export type { KeyringApi, KeyringEntry } from './keyring-storage'; + export type { DevicePollResult, RefreshOptions } from './oauth'; export { pollDeviceToken, refreshAccessToken, requestDeviceAuthorization } from './oauth'; diff --git a/packages/oauth/src/keyring-storage.ts b/packages/oauth/src/keyring-storage.ts new file mode 100644 index 000000000..a80cf40fc --- /dev/null +++ b/packages/oauth/src/keyring-storage.ts @@ -0,0 +1,521 @@ +/** + * Keychain-backed OAuth token storage with plaintext-file fallback. + * + * Backend: the OS keychain (macOS Keychain, Windows Credential Manager, + * Linux Secret Service) via `@napi-rs/keyring`. Tokens live under a keychain + * *service* derived per credentials directory (`keyringServiceForCredentialsDir`) + * so distinct profiles / SDK callers stay isolated exactly like the file backend + * isolates them by directory; each token is one entry whose *account* is the + * token `name` and whose *password* is the snake_case wire JSON — the exact same + * payload `FileTokenStorage` writes to disk. + * + * Selection (`resolveTokenStorage`): the keychain is used only when it is + * actually usable. Two guards are required because the failure modes differ: + * 1. The native binary may fail to load (unsupported platform / missing + * optional binary) — `require` THROWS at import time. Caught → file. + * 2. The binary may load but have no live OS backend at runtime (e.g. + * headless Linux with no Secret Service) — `require` succeeds but entry + * operations throw at CALL time. A set/get/delete capability probe under + * a SEPARATE sentinel service catches this. Failed/​mismatched → file. + * `KIMI_DISABLE_KEYRING=1` forces the file backend outright. + * + * Migration: when the keychain is selected but a token is still only on disk + * (written by an older file-only build), `load` migrates it — copy into the + * keychain, then compare-and-delete the plaintext file (only unlink a file that + * still matches the value we made keychain-authoritative) — so secrets stop + * living in the clear without ever dropping a newer token a concurrent + * file-backend writer may have just landed. Migration is LOCK-FREE, exactly like + * `FileTokenStorage`. `remove` and `list` also reconcile against the legacy file + * store so pre-migration plaintext can never linger or go missing. `save` prunes + * any legacy plaintext copy after the keychain write so a later file-backend run + * can't resurrect a superseded token. + * + * Reconcile-on-hit (flip-flop repair): `resolveTokenStorage` can pick a + * DIFFERENT backend per run for one credentialsDir (keychain locked, + * headless/SSH, `KIMI_DISABLE_KEYRING=1`, native binary missing, probe fails). + * A sequential flip-flop then splits state — the keychain may hold an OLDER + * token while a fallback run wrote a NEWER one to the plaintext file. So `load` + * reconciles against the legacy file even on a keychain HIT, adopting the file + * token ONLY when BOTH sides are valid (neither a tombstone) AND the file was + * issued strictly later (mint second `expiresAt - expiresIn`, not the expiration + * time `expiresAt`). It NEVER un-revokes a deliberate tombstone from + * stale plaintext, and only prunes a plaintext copy it made authoritative or one + * equal to the keychain value after canonical re-serialization. See + * `reconcileOnHit`. + */ + +import { createHash, randomBytes } from 'node:crypto'; +import { createRequire } from 'node:module'; +import { homedir } from 'node:os'; +import { join, resolve } from 'node:path'; + +import { assertValidTokenName, FileTokenStorage } from './storage'; +import type { TokenStorage } from './storage'; +import { classifyToken } from './token-state'; +import type { TokenInfo, TokenInfoWire } from './types'; +import { tokenFromWire, tokenToWire } from './types'; +import { isRecord } from './utils'; + +/** Keychain service that holds every kimi-code token entry. */ +export const KEYRING_SERVICE = 'kimi-code'; +/** Isolated service for the capability probe; never collides with real data. */ +export const KEYRING_PROBE_SERVICE = 'kimi-code-keyring-probe'; + +/** Minimal keychain entry surface (structurally satisfied by `Entry`). */ +export interface KeyringEntry { + getPassword(): string | null; + setPassword(password: string): void; + /** + * Returns true when a credential was deleted. The native binding NEVER throws + * and maps EVERY failure (locked/no-access/ambiguous/platform error) to the + * SAME `false` it returns for "no such entry", so `false` is ambiguous — + * "did not exist" OR "delete failed". `getPassword()` cannot disambiguate it + * (the binding likewise collapses every read error to `null`); prove absence + * with the service-scoped `findAccounts()` listing instead (see `remove`). + */ + deleteCredential(): boolean; +} + +/** Injectable keychain API so the storage is unit-testable without the OS. */ +export interface KeyringApi { + createEntry(service: string, account: string): KeyringEntry; + /** Accounts present under a service. */ + findAccounts(service: string): string[]; +} + +interface KeyringTokenStorageOptions { + readonly keyring: KeyringApi; + /** File store used both as migration source and reconciliation target. */ + readonly legacy: FileTokenStorage; + readonly service?: string; +} + +export class KeyringTokenStorage implements TokenStorage { + private readonly keyring: KeyringApi; + private readonly legacy: FileTokenStorage; + private readonly service: string; + + constructor(opts: KeyringTokenStorageOptions) { + this.keyring = opts.keyring; + this.legacy = opts.legacy; + this.service = opts.service ?? KEYRING_SERVICE; + } + + private serialize(token: TokenInfo): string { + return JSON.stringify(tokenToWire(token)); + } + + private deserialize(raw: string): TokenInfo | undefined { + let parsed: unknown; + try { + parsed = JSON.parse(raw); + } catch { + return undefined; + } + if (!isRecord(parsed)) return undefined; + return tokenFromWire(parsed as Partial); + } + + async load(name: string): Promise { + assertValidTokenName(name); + const raw = this.keyring.createEntry(this.service, name).getPassword(); + if (raw !== null) { + return this.reconcileOnHit(name, raw); + } + // Not in the keychain — migrate any plaintext token written by an older + // file-only build, then drop the cleartext copy (LOCK-FREE, exactly like + // FileTokenStorage — no proper-lockfile, because reusing the oauth-manager + // refresh lock here would deadlock the manager's in-lock re-read, and a + // separate lock wouldn't coordinate with an old file-backend process). + // + // Compare-and-delete guards against a concurrent file-backend writer (an old + // build, a KIMI_DISABLE_KEYRING process, or a fallback instance) saving a + // NEWER token between our copy-in and our remove. We never unlink a file + // whose serialized value differs from the one we just made authoritative in + // the keychain — only a token we actually migrated is deleted. + const first = await this.legacy.load(name); + if (first === undefined) return undefined; + let serialized = this.serialize(first); + let latest = first; + this.keyring.createEntry(this.service, name).setPassword(serialized); + + // Bounded converge loop: ensure the keychain holds the latest observed + // serialized value `S`, then re-read the file ONE more time right before + // deleting and only unlink when it still equals `S`. A newer value found on + // the pre-delete re-read is written to keychain and we retry; persistent + // churn after a few iterations leaves the file in place (a later load + // reconciles) rather than risk deleting a token we didn't migrate. + // + // NOTE: the residual sub-microsecond TOCTOU under concurrently-MIXED + // file+keyring backends is a documented best-effort limitation; running the + // file and keyring backends simultaneously against one credentialsDir is + // unsupported. + for (let i = 0; i < 3; i += 1) { + const current = await this.legacy.load(name); + if (current === undefined) { + // A peer already removed/migrated the file — nothing to delete, the + // keychain already holds the latest value we observed. + return latest; + } + const currentSerialized = this.serialize(current); + if (currentSerialized === serialized) { + // The file still matches what we migrated → safe to drop the cleartext. + await this.legacy.remove(name); + return latest; + } + // A newer token landed; make the keychain authoritative with it and retry + // the compare-and-delete against this newer value. + this.keyring.createEntry(this.service, name).setPassword(currentSerialized); + serialized = currentSerialized; + latest = current; + } + + // Converge budget exhausted under persistent churn: leave the file in place + // (never delete a token we may not have migrated) and return the latest + // value we wrote to the keychain; a subsequent load will reconcile. + return latest; + } + + /** + * Reconcile a keychain HIT against the legacy plaintext file. + * + * The keychain backend must be a faithful drop-in for the single-store file + * backend, but `resolveTokenStorage` can pick a DIFFERENT backend per run for + * one credentialsDir (keychain locked, headless/SSH, KIMI_DISABLE_KEYRING=1, + * native binary missing, probe fails). A flip-flop then splits state: the + * keychain may hold an OLDER token while a fallback run wrote a NEWER one to + * the plaintext file. Returning the keychain value blindly would silently + * ignore the user's real, newer token — and if the older token's refresh_token + * is now rejected, the manager would re-read it and overwrite the keychain + * with a revoked tombstone while the valid file token sits ignored → forced + * re-login despite a valid token. So we reconcile on the HIT path too. + * + * Invariant — NEVER un-revoke from plaintext: a deliberately-written revoked + * tombstone (refresh_token rejected) must outrank any stale plaintext token. + * We therefore adopt the file token ONLY when BOTH sides are VALID (neither is + * a tombstone) AND the file was ISSUED strictly later. `expiresAt` is an + * EXPIRATION time (mint second + `expiresIn`), NOT a write-order proxy, so we + * recover the mint second via `issuedAt = expiresAt - expiresIn` — the + * `expiresIn` cancels out, making the comparison robust to the server returning + * a different `expires_in` across refreshes (an older, longer-lived token can + * otherwise have a LARGER `expiresAt` than a newer, shorter-lived one). In + * every other case the keychain stays authoritative. + * + * Residual limitation: issuance time has 1-second granularity, so two tokens + * minted in the SAME wall-clock second tie and the keychain stays authoritative + * (strict `>`). That edge is practically unreachable, and we deliberately avoid + * a new wire field / monotonic generation counter to keep ZERO breaking change + * to the on-disk + keychain format. + */ + private async reconcileOnHit(name: string, raw: string): Promise { + const keyringToken = this.deserialize(raw); + const fileToken = await this.legacy.load(name); + + // FAST PATH: steady state has no plaintext file (one cheap readFile that + // ENOENTs), or the keychain bytes were corrupt JSON (must still return + // undefined per the existing contract). Either way, nothing to reconcile. + if (fileToken === undefined || keyringToken === undefined) { + return keyringToken; + } + + // Adopt the file token ONLY when both are valid (not tombstones) and the file + // was ISSUED strictly later. This is the flip-flop repair: a fallback run + // landed a newer token on disk; make it keychain-authoritative now. We compare + // mint time (`expiresAt - expiresIn`), NOT `expiresAt` (an expiration time), + // so a variable server `expires_in` can't make an older long-lived token look + // newer than a freshly-rotated short-lived one. + if ( + classifyToken(keyringToken).kind === 'valid' && + classifyToken(fileToken).kind === 'valid' && + issuedAt(fileToken) > issuedAt(keyringToken) + ) { + const fileSerialized = this.serialize(fileToken); + this.keyring.createEntry(this.service, name).setPassword(fileSerialized); + // Compare-and-delete: re-read and unlink ONLY if the on-disk bytes still + // equal what we just made authoritative — never delete a token whose bytes + // changed under a concurrent writer. + await this.removeIfBytesMatch(name, fileSerialized); + return fileToken; + } + + // Keychain stays authoritative (keyring newer/equal, or EITHER side is a + // tombstone — the no-un-revoke invariant). As cleanup, prune the plaintext + // ONLY when it is equal to the authoritative keychain value after canonical + // re-serialization (a redundant duplicate); a file whose serialized form + // differs — reordered keys, extra fields, or a genuinely different token — is + // left in place (conservative — we never delete a token we did not make + // authoritative). + // + // A file left here is intentional and persists: no future `load` cleans it + // up (this branch only prunes the redundant-duplicate case), so it lingers + // until the next explicit `remove()` / logout. Deliberate — we never delete a + // token we did not make authoritative. + // + // Inverse of the no-un-revoke invariant above: a file-side TOMBSTONE (a + // fallback run's token was 401-rejected) does NOT force-revoke a VALID + // keychain token — the keychain stays authoritative and we return its valid + // token here. Correct because that returned token carries its OWN + // refresh_token: if it too is revoked, the next refresh 401 tombstones the + // KEYCHAIN and it self-heals; if it is still valid, honoring it is right (the + // file tombstone revoked a DIFFERENT, older fallback token). We can't do + // better: a tombstone is timestamp-less (`issuedAt === 0`, all fields empty), + // so it is unorderable against the keychain token. Letting the file tombstone + // WIN would (a) invert the plaintext-is-less-trusted trust model — the mirror + // of the no-un-revoke invariant — and (b) let a stale leftover tombstone + // repeatedly force-logout a fresh keychain token. A correct ordering-based fix + // would need a wire-format change the design deliberately avoids (see the + // `expiresIn` / wire-format note above). + if (this.serialize(fileToken) === raw) { + await this.removeIfBytesMatch(name, raw); + } + return keyringToken; + } + + /** + * Compare-and-delete the plaintext copy: re-read the file and unlink it ONLY + * when its serialized bytes still equal `expected` (the value we made + * keychain-authoritative). A concurrent file-backend writer that landed a + * different token between our decision and this delete is left untouched. + */ + private async removeIfBytesMatch(name: string, expected: string): Promise { + // The re-read is the compare-and-delete guard, not redundant I/O: it catches + // a concurrent file-backend writer that landed a newer token between our + // decision and this delete, so we only unlink bytes that still match. + const current = await this.legacy.load(name); + if (current !== undefined && this.serialize(current) === expected) { + await this.legacy.remove(name); + } + } + + async save(name: string, token: TokenInfo): Promise { + // Reject invalid names BEFORE the keychain write to preserve the file + // backend's fail-before-write contract — otherwise setPassword would orphan + // a credential under an invalid account before the legacy name check threw. + assertValidTokenName(name); + this.keyring.createEntry(this.service, name).setPassword(this.serialize(token)); + // The keychain is now authoritative for `name`. Drop any plaintext copy so a + // later FILE-backend run (KIMI_DISABLE_KEYRING=1, probe/native-load failure) — + // which is keychain-unaware and cannot reconcile — can't resurrect an obsolete + // token or a stale tombstone, and no secret lingers in cleartext. Lock-free + // unlink; a missing file is a no-op. Safe because save() always writes the + // current authoritative token (refresh/login/tombstone), so any on-disk copy + // is superseded — a concurrent file-backend writer is the documented + // unsupported mixed-backend case. + // + // This unconditional prune deliberately DIFFERS from reconcileOnHit's + // read-path "leave a differing file in place" rule: there we have NOT + // established write-order authority over the file; here save() just MADE the + // keychain authoritative, so the file is by definition superseded. The only + // case the two could disagree is a TOMBSTONE-save whose `name` still has a + // valid plaintext copy — a flip-flop where the file run minted a token in the + // SAME second the keychain token was issued, so reconcileOnHit's strict `>` + // declined to adopt it. We prune anyway, ON PURPOSE: honoring the revocation + // (no resurrection of a deliberately tombstoned credential by a later + // keychain-unaware file run) outranks preserving that token. The cost is a + // forced re-login in that vanishingly rare edge — fail-CLOSED, and identical + // to what the file backend alone does once tombstoned. A recoverable re-login + // beats a resurrected revoked secret. + await this.legacy.remove(name); + } + + async remove(name: string): Promise { + assertValidTokenName(name); + // Clear both stores so a pre-migration plaintext copy can never linger + // (e.g. logout before the token was ever migrated). Missing credentials + // are a no-op, not an error. + // + // The legacy cleanup must ALWAYS run so a failing native keyring delete + // (permissions, lock state, ambiguous entries) can never leave the + // plaintext file behind — the "both stores cleared" guarantee must hold. + // A genuine keyring error is surfaced after the file is cleared, never + // swallowed. + try { + const entry = this.keyring.createEntry(this.service, name); + const deleted = entry.deleteCredential(); + if (!deleted) { + // deleteCredential() returns false for BOTH a missing entry AND a failed/denied + // delete: the v1.3.0 binding collapses every error to false, and getPassword() + // likewise collapses every error to null, so a null re-read CANNOT prove the + // entry is gone (equally "deleted" or "read denied"). Prove absence the only + // non-error-ambiguous way this binding allows — enumerate the service: + // findAccounts() THROWS when the store is unreachable (locked / no-access) and + // returns the account list (incl. empty) when reachable. So an entry that + // survives a denied delete, or a store we cannot even reach, is surfaced as a + // failure; only a reachable store that does NOT list `name` is a true no-op + // (mirrors FileTokenStorage: ENOENT no-op, EACCES throw). A thrown + // findAccounts propagates to the catch below → plaintext cleared → re-thrown. + if (this.keyring.findAccounts(this.service).includes(name)) { + throw new Error(`failed to delete keyring credential "${name}"`); + } + } + } catch (error) { + // Keyring delete failed — still clear the plaintext copy, then re-throw. + await this.legacy.remove(name); + throw error; + } + await this.legacy.remove(name); + } + + async list(): Promise { + const fromKeyring = this.keyring.findAccounts(this.service); + const fromLegacy = await this.legacy.list(); + return [...new Set([...fromKeyring, ...fromLegacy])]; + } +} + +/** + * Recover a token's mint second from persisted fields. `expiresAt` is stamped at + * issuance as `floor(mintTime) + expiresIn`, so subtracting `expiresIn` cancels + * the lifetime and yields the issuance instant — robust to a variable server + * `expires_in` across refreshes (1-second granularity; same-second mints tie). + * + * Operates on any `TokenInfo`: both fields are always numeric (`tokenFromWire` + * defaults them to 0), so even an externally-edited record compares as a + * consistent integer order — never NaN/throw. Only the caller's both-valid guard + * feeds it real minted tokens; tombstones (`expiresIn: 0`) are excluded there. + */ +function issuedAt(token: TokenInfo): number { + return token.expiresAt - token.expiresIn; +} + +/** + * Adapter over the real `@napi-rs/keyring` module shape, kept narrow so the + * production load path and the test fakes share one `KeyringApi` contract. + */ +interface NapiKeyringModule { + Entry: new (service: string, account: string) => KeyringEntry; + findCredentials: (service: string) => Array<{ account: string; password: string }>; +} + +function adaptNapiKeyring(mod: NapiKeyringModule): KeyringApi { + return { + createEntry(service, account) { + return new mod.Entry(service, account); + }, + findAccounts(service) { + return mod.findCredentials(service).map((c) => c.account); + }, + }; +} + +/** Real native load: throws (caught here) when the binary can't be required. */ +function loadNativeKeyring(): KeyringApi | undefined { + try { + const require = createRequire(import.meta.url); + const mod = require('@napi-rs/keyring') as NapiKeyringModule; + return adaptNapiKeyring(mod); + } catch { + return undefined; + } +} + +/** + * Round-trip a sentinel under an isolated service to prove the keychain has a + * live backend. Any throw, a read-back mismatch, or an inability to DELETE the + * sentinel means the keychain is not usable on this host. Delete capability is + * part of the usability contract: once selected the keychain is the + * authoritative store, so logout/revocation (`remove`) and `load`'s + * migrate-then-delete both depend on it being able to remove entries. A backend + * that can set/read but not delete would trap migrated tokens it can never + * remove and make logout throw — so we reject it here and fall back to the file + * store instead of migrating plaintext into a one-way keychain. + */ +function probeKeyring(keyring: KeyringApi): boolean { + // A UNIQUE account per attempt: two CLI processes probing concurrently must + // not share one sentinel account, or one's delete clobbers the other's + // round-trip → false mismatch → spurious file fallback on a healthy keychain + // (which then splits file/keyring state — the very thing migration avoids). + const account = `probe-${process.pid}-${randomBytes(8).toString('hex')}`; + const sentinel = `probe-${Date.now()}-${Math.random().toString(36).slice(2)}`; + const entry = keyring.createEntry(KEYRING_PROBE_SERVICE, account); + try { + entry.setPassword(sentinel); + if (entry.getPassword() !== sentinel) return false; + // The keychain is the AUTHORITATIVE store once selected, so it MUST also be + // able to DELETE — logout/revocation and load()'s migrate-then-delete all + // depend on it. The native binding never throws and maps a failed delete to + // `false`, so we do NOT trust the boolean. A backend that stores+reads but + // cannot delete would trap migrated tokens it can never remove and make + // logout throw — reject it here so we fall back to the plaintext file store + // instead of migrating into a one-way keychain. + entry.deleteCredential(); + // Confirm the delete the same non-error-ambiguous way remove() does. The binding + // collapses a denied delete to `false` AND a denied/errored read to `null`, so + // `getPassword() === null` cannot prove the sentinel is gone (equally "deleted" or + // "read denied"). findAccounts() THROWS on an unreachable store (→ caught below → + // unusable) and lists accounts when reachable; our UNIQUE per-process probe + // account being ABSENT from that listing proves the backend can truly delete (and + // also catches a "lying" deleteCredential() that returns success while the entry + // survives). Checking only our own unique account keeps this correct under + // concurrent probes. + return !keyring.findAccounts(KEYRING_PROBE_SERVICE).includes(account); + } catch { + return false; + } finally { + // Safety-net cleanup for the early-return-mismatch and throw paths, and a + // harmless idempotent no-op on the success path: never leave a sentinel + // behind, and never let cleanup mask the probe result. + try { + entry.deleteCredential(); + } catch { + /* best-effort */ + } + } +} + +/** + * Derive the keychain *service* name for a credentials directory so that the + * keyring backend isolates profiles by directory exactly like the file backend + * isolates them by `credentialsDir`. Without this, every profile / SDK caller + * collides on one fixed `'kimi-code'` service — a data-loss regression vs the + * file store. + * + * The "default" detection is deliberately keyed off the STANDARD path + * (`~/.kimi-code/credentials`), NOT `defaultKimiHome()` / `KIMI_CODE_HOME`: + * two different `KIMI_CODE_HOME` values would both look "default" and re-collide + * on `'kimi-code'`. Hashing the actual resolved dir guarantees isolation for + * every non-standard dir, while the one standard home per OS user keeps a + * stable, human-readable `'kimi-code'` service for the common case. + */ +export function keyringServiceForCredentialsDir(credentialsDir: string): string { + const resolved = resolve(credentialsDir); + const standard = resolve(join(homedir(), '.kimi-code', 'credentials')); + if (resolved === standard) return KEYRING_SERVICE; + return `kimi-code-${createHash('sha256').update(resolved).digest('hex').slice(0, 16)}`; +} + +interface ResolveTokenStorageDeps { + /** Returns a usable KeyringApi, or undefined when the native load fails. */ + loadKeyring?: () => KeyringApi | undefined; + /** Force the file backend (defaults to the KIMI_DISABLE_KEYRING env flag). */ + disabled?: boolean; +} + +/** + * Pick the token backend for `credentialsDir`: keychain when usable, otherwise + * the plaintext file store (which also seeds migration). The `deps` seam is for + * tests only; production uses the real native load + env flag. + */ +export function resolveTokenStorage( + credentialsDir: string, + deps: ResolveTokenStorageDeps = {}, +): TokenStorage { + const legacy = new FileTokenStorage(credentialsDir); + + const disabled = deps.disabled ?? process.env['KIMI_DISABLE_KEYRING'] === '1'; + if (disabled) return legacy; + + const loadKeyring = deps.loadKeyring ?? loadNativeKeyring; + const keyring = loadKeyring(); + if (keyring === undefined) return legacy; + + if (!probeKeyring(keyring)) return legacy; + + // Namespace the keychain service by credentialsDir so distinct profiles / + // SDK callers stay isolated, matching the file backend's per-directory + // isolation. The legacy file store and the derived service both come from the + // SAME credentialsDir, so a file at `/.json` migrates + // into the matching namespaced service. + const service = keyringServiceForCredentialsDir(credentialsDir); + return new KeyringTokenStorage({ keyring, legacy, service }); +} diff --git a/packages/oauth/src/storage.ts b/packages/oauth/src/storage.ts index f7d4e1abb..e5e390e8d 100644 --- a/packages/oauth/src/storage.ts +++ b/packages/oauth/src/storage.ts @@ -38,6 +38,23 @@ export interface TokenStorage { list(): Promise; } +/** + * Guard against path traversal: caller-provided names (from config.toml or + * slash commands) must not escape the credentials dir. `basename` strips any + * `..` or `/` segments; if the sanitized value differs from the input we refuse + * the request entirely rather than silently writing to a different file than the + * caller asked for. Leading-dot names (hidden files) are also rejected. + * + * Shared so non-file backends (e.g. KeyringTokenStorage) enforce the identical + * name-rejection rule, error text, and fail-before-write timing. + */ +export function assertValidTokenName(name: string): void { + const safe = basename(name); + if (safe.length === 0 || safe !== name || safe.startsWith('.')) { + throw new Error(`Invalid token name: "${name}"`); + } +} + export class FileTokenStorage implements TokenStorage { private readonly dir: string; @@ -57,16 +74,8 @@ export class FileTokenStorage implements TokenStorage { } private pathFor(name: string): string { - // Guard against path traversal: caller-provided names (from config.toml - // or slash commands) must not escape the credentials dir. `basename` - // strips any `..` or `/` segments; if the sanitized value differs from - // the input we refuse the request entirely rather than silently - // writing to a different file than the caller asked for. - const safe = basename(name); - if (safe.length === 0 || safe !== name || safe.startsWith('.')) { - throw new Error(`Invalid token name: "${name}"`); - } - return join(this.dir, `${safe}.json`); + assertValidTokenName(name); + return join(this.dir, `${name}.json`); } async load(name: string): Promise { diff --git a/packages/oauth/src/toolkit.ts b/packages/oauth/src/toolkit.ts index e5726b677..a6ce69905 100644 --- a/packages/oauth/src/toolkit.ts +++ b/packages/oauth/src/toolkit.ts @@ -24,8 +24,9 @@ import { type FetchManagedUsageError, type ParsedManagedUsage, } from './managed-usage'; +import { resolveTokenStorage } from './keyring-storage'; import { OAuthManager, type LoginOptions, type OAuthManagerOptions } from './oauth-manager'; -import { FileTokenStorage, type TokenStorage } from './storage'; +import type { TokenStorage } from './storage'; import type { OAuthFlowConfig } from './types'; export interface BearerTokenProvider { @@ -105,7 +106,7 @@ export class KimiOAuthToolkit { options.identity === undefined ? undefined : assertKimiHostIdentity(options.identity); this.homeDir = options.homeDir ?? defaultKimiHome(); const credentialsDir = options.credentialsDir ?? join(this.homeDir, 'credentials'); - this.storage = options.storage ?? new FileTokenStorage(credentialsDir); + this.storage = options.storage ?? resolveTokenStorage(credentialsDir); this.flowConfig = options.flowConfig ?? KIMI_CODE_FLOW_CONFIG; this.configAdapter = options.configAdapter; this.fetchImpl = options.fetchImpl; diff --git a/packages/oauth/test/keyring-storage.test.ts b/packages/oauth/test/keyring-storage.test.ts new file mode 100644 index 000000000..a2a882ee0 --- /dev/null +++ b/packages/oauth/test/keyring-storage.test.ts @@ -0,0 +1,1021 @@ +/** + * KeyringTokenStorage + resolveTokenStorage tests — fully hermetic. + * + * NEVER touches the real OS keychain: the keyring backend is an in-memory + * fake `KeyringApi` (a Map keyed by `service\x00account`). The file fallback + * uses a real `FileTokenStorage` over a tmp dir so migration + union with the + * plaintext store are exercised end-to-end. + */ + +import { existsSync, mkdirSync, rmSync } from 'node:fs'; +import { homedir, tmpdir } from 'node:os'; +import { join } from 'node:path'; + +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; + +import { + KEYRING_PROBE_SERVICE, + KEYRING_SERVICE, + KeyringTokenStorage, + keyringServiceForCredentialsDir, + resolveTokenStorage, +} from '../src/keyring-storage'; +import type { KeyringApi, KeyringEntry } from '../src/keyring-storage'; +import { FileTokenStorage } from '../src/storage'; +import { classifyToken, revokedTombstone } from '../src/token-state'; +import type { TokenInfo } from '../src/types'; +import { tokenToWire } from '../src/types'; + +function makeTmpDir(): string { + const dir = join( + tmpdir(), + `kimi-keyring-test-${Date.now()}-${Math.random().toString(36).slice(2)}`, + ); + mkdirSync(dir, { recursive: true }); + return dir; +} + +function sampleToken(overrides: Partial = {}): TokenInfo { + return { + accessToken: 'at-abc', + refreshToken: 'rt-xyz', + expiresAt: 1_700_000_000, + scope: 'read write', + tokenType: 'Bearer', + expiresIn: 3600, + ...overrides, + }; +} + +/** In-memory KeyringApi fake backed by a Map keyed by `service\x00account`. */ +class FakeKeyring implements KeyringApi { + public readonly store = new Map(); + + private key(service: string, account: string): string { + return `${service}\x00${account}`; + } + + createEntry(service: string, account: string): KeyringEntry { + const key = this.key(service, account); + const store = this.store; + return { + getPassword(): string | null { + return store.has(key) ? (store.get(key) as string) : null; + }, + setPassword(password: string): void { + store.set(key, password); + }, + deleteCredential(): boolean { + return store.delete(key); + }, + }; + } + + findAccounts(service: string): string[] { + const prefix = `${service}\x00`; + const accounts: string[] = []; + for (const k of this.store.keys()) { + if (k.startsWith(prefix)) accounts.push(k.slice(prefix.length)); + } + return accounts; + } +} + +/** A KeyringApi whose entry operations always throw (exercises probe fallback). */ +class ThrowingKeyring implements KeyringApi { + createEntry(): KeyringEntry { + return { + getPassword(): string | null { + throw new Error('no keychain backend available'); + }, + setPassword(): void { + throw new Error('no keychain backend available'); + }, + deleteCredential(): boolean { + throw new Error('no keychain backend available'); + }, + }; + } + + findAccounts(): string[] { + return []; + } +} + +/** + * Records every account `createEntry` is asked for, per service, so the probe's + * account-uniqueness can be asserted. Functionally identical to FakeKeyring. + */ +class RecordingKeyring extends FakeKeyring { + public readonly accountsByService = new Map(); + + override createEntry(service: string, account: string): KeyringEntry { + const seen = this.accountsByService.get(service) ?? []; + seen.push(account); + this.accountsByService.set(service, seen); + return super.createEntry(service, account); + } +} + +describe('KeyringTokenStorage', () => { + let dir: string; + let legacy: FileTokenStorage; + let keyring: FakeKeyring; + let storage: KeyringTokenStorage; + + beforeEach(() => { + dir = makeTmpDir(); + legacy = new FileTokenStorage(dir); + keyring = new FakeKeyring(); + storage = new KeyringTokenStorage({ keyring, legacy }); + }); + + afterEach(() => { + rmSync(dir, { recursive: true, force: true }); + }); + + it('round-trips a token via save/load/list/remove', async () => { + const token = sampleToken(); + expect(await storage.load('kimi-code')).toBeUndefined(); + + await storage.save('kimi-code', token); + expect(await storage.load('kimi-code')).toEqual(token); + expect(await storage.list()).toEqual(['kimi-code']); + + await storage.remove('kimi-code'); + expect(await storage.load('kimi-code')).toBeUndefined(); + expect(await storage.list()).toEqual([]); + }); + + it('stores the password as the snake_case wire JSON under KEYRING_SERVICE', async () => { + const token = sampleToken(); + await storage.save('kimi-code', token); + const raw = keyring.store.get(`${KEYRING_SERVICE}\x00kimi-code`); + expect(raw).toBeDefined(); + expect(JSON.parse(raw as string)).toEqual(tokenToWire(token)); + }); + + it('save() prunes a lingering plaintext copy after writing the keychain', async () => { + // A stale plaintext file lingers from a prior file-backend run (or a + // keychain-wins reconcile that left an older file). A later save() must make + // the keychain authoritative AND drop the cleartext, so a subsequent + // KIMI_DISABLE_KEYRING / probe-failure run (keychain-unaware FileTokenStorage) + // can no longer resurrect the obsolete token, and no secret lingers on disk. + const fileTok = sampleToken({ accessToken: 'at-stale-file', refreshToken: 'rt-stale-file' }); + const newTok = sampleToken({ accessToken: 'at-new', refreshToken: 'rt-new' }); + await legacy.save('kimi-code', fileTok); + expect(existsSync(join(dir, 'kimi-code.json'))).toBe(true); + + await storage.save('kimi-code', newTok); + + const raw = keyring.createEntry(KEYRING_SERVICE, 'kimi-code').getPassword(); + expect(raw).toBe(JSON.stringify(tokenToWire(newTok))); + expect(existsSync(join(dir, 'kimi-code.json'))).toBe(false); + }); + + it('save() is a no-op on the file when none exists (ENOENT path)', async () => { + const tok = sampleToken({ accessToken: 'at-fresh', refreshToken: 'rt-fresh' }); + expect(existsSync(join(dir, 'kimi-code.json'))).toBe(false); + + await expect(storage.save('kimi-code', tok)).resolves.toBeUndefined(); + + expect(existsSync(join(dir, 'kimi-code.json'))).toBe(false); + const raw = keyring.createEntry(KEYRING_SERVICE, 'kimi-code').getPassword(); + expect(JSON.parse(raw as string)).toEqual(tokenToWire(tok)); + }); + + it('migrates a plaintext token into the keychain, then deletes the file', async () => { + const token = sampleToken(); + await legacy.save('kimi-code', token); + expect(existsSync(join(dir, 'kimi-code.json'))).toBe(true); + + // First load migrates: returns the token, populates keyring, removes file. + const loaded = await storage.load('kimi-code'); + expect(loaded).toEqual(token); + expect(keyring.store.get(`${KEYRING_SERVICE}\x00kimi-code`)).toBeDefined(); + expect(existsSync(join(dir, 'kimi-code.json'))).toBe(false); + + // Second load reads straight from the keychain (file already gone). + expect(await storage.load('kimi-code')).toEqual(token); + }); + + it('compare-and-delete: a file under persistent churn is NEVER deleted', async () => { + // A legacy store whose value is DIFFERENT on every read — modelling a + // concurrent file-backend writer that keeps landing a fresher token on disk + // between every one of our re-reads, so the on-disk value never stabilises to + // match the one we just made keychain-authoritative. Under this persistent + // churn the bounded converge loop must exhaust its budget WITHOUT ever + // unlinking the file (we must never delete a token whose on-disk bytes differ + // from what we migrated). A later load will reconcile. + // More distinct values than the loop's re-read budget, so the file never + // stabilises within the budget. The fallback keeps `load` total even past + // the array (it is never reached in practice). + const seed = sampleToken({ accessToken: 'at-1', refreshToken: 'rt-1' }); + const tokens: TokenInfo[] = [ + seed, + sampleToken({ accessToken: 'at-2', refreshToken: 'rt-2' }), + sampleToken({ accessToken: 'at-3', refreshToken: 'rt-3' }), + sampleToken({ accessToken: 'at-4', refreshToken: 'rt-4' }), + sampleToken({ accessToken: 'at-5', refreshToken: 'rt-5' }), + ]; + const lastToken = tokens.at(-1) as TokenInfo; + class RacyLegacy extends FileTokenStorage { + public loadCalls = 0; + public removeCalls = 0; + override async load(): Promise { + const value = tokens.at(this.loadCalls) ?? lastToken; + this.loadCalls += 1; + return value; + } + override async remove(name: string): Promise { + this.removeCalls += 1; + await super.remove(name); + } + } + const racy = new RacyLegacy(dir); + // Seed a real file so a (wrongful) remove would be observable on disk. + await racy.save('kimi-code', seed); + expect(existsSync(join(dir, 'kimi-code.json'))).toBe(true); + + const racyStorage = new KeyringTokenStorage({ keyring, legacy: racy }); + const loaded = await racyStorage.load('kimi-code'); + + // The keychain ends authoritative with the newest value the loop observed + // before its budget ran out, load returns that value, the file is NEVER + // deleted (every re-read differed from the migrated value), and remove was + // never even called. + const latest = tokens.at(racy.loadCalls - 1) ?? lastToken; + expect(loaded).toEqual(latest); + expect(racy.removeCalls).toBe(0); + const raw = keyring.createEntry(KEYRING_SERVICE, 'kimi-code').getPassword(); + expect(JSON.parse(raw as string)).toEqual(tokenToWire(latest)); + expect(existsSync(join(dir, 'kimi-code.json'))).toBe(true); + }); + + it('compare-and-delete: a stable file that matches the migrated value is deleted', async () => { + // The file value never changes across re-reads, so after copying it into the + // keychain the pre-delete re-read matches → safe to unlink the cleartext. + const t1 = sampleToken({ accessToken: 'at-stable', refreshToken: 'rt-stable' }); + class StableLegacy extends FileTokenStorage { + public removeCalls = 0; + override async remove(name: string): Promise { + this.removeCalls += 1; + await super.remove(name); + } + } + const stable = new StableLegacy(dir); + await stable.save('kimi-code', t1); + expect(existsSync(join(dir, 'kimi-code.json'))).toBe(true); + + const stableStorage = new KeyringTokenStorage({ keyring, legacy: stable }); + const loaded = await stableStorage.load('kimi-code'); + + expect(loaded).toEqual(t1); + expect(stable.removeCalls).toBe(1); + expect(existsSync(join(dir, 'kimi-code.json'))).toBe(false); + const raw = keyring.createEntry(KEYRING_SERVICE, 'kimi-code').getPassword(); + expect(JSON.parse(raw as string)).toEqual(tokenToWire(t1)); + }); + + it('keychain HIT reconcile: adopts a strictly-newer plaintext token (sequential fallback flip-flop)', async () => { + // Models the flip-flop bug: keychain holds an OLDER valid token A; a later + // run that fell back to the file backend wrote a NEWER valid token B to the + // same dir+name. On the next keychain-usable run, load() must adopt B (the + // user's real, newer token), make the keychain authoritative with B, and + // drop the now-migrated plaintext copy. + const tokenA = sampleToken({ accessToken: 'at-A', refreshToken: 'rt-A', expiresAt: 1000 }); + const tokenB = sampleToken({ accessToken: 'at-B', refreshToken: 'rt-B', expiresAt: 2000 }); + + await storage.save('kimi-code', tokenA); // keychain holds older A + await legacy.save('kimi-code', tokenB); // file holds newer B + expect(existsSync(join(dir, 'kimi-code.json'))).toBe(true); + + const loaded = await storage.load('kimi-code'); + expect(loaded).toEqual(tokenB); + + // Keychain is now authoritative with B's exact wire bytes. + const raw = keyring.createEntry(KEYRING_SERVICE, 'kimi-code').getPassword(); + expect(JSON.parse(raw as string)).toEqual(tokenToWire(tokenB)); + + // The migrated plaintext copy is gone. + expect(existsSync(join(dir, 'kimi-code.json'))).toBe(false); + }); + + it('keychain HIT reconcile: adopts a file token issued later despite a SMALLER expiresAt (shorter expiresIn)', async () => { + // Regression for the variable-`expires_in` flip-flop. `expiresAt` is an + // EXPIRATION time = mintSecond + expiresIn, so it is NOT a write-order proxy. + // Keychain holds an OLDER token A minted with a LONGER lifetime; a later + // file-backend fallback run wrote a genuinely NEWER rotated token B with a + // SHORTER lifetime, so B.expiresAt < A.expiresAt even though B was issued + // later. The adoption guard must compare issuance order (expiresAt - expiresIn) + // and adopt B. The old expiresAt-only guard returned A (4000 > 5000 is false). + const tokenA = sampleToken({ + accessToken: 'at-keyring', + refreshToken: 'rt-A', + expiresAt: 5000, + expiresIn: 3600, + }); // issuedAt 1400 + const tokenB = sampleToken({ + accessToken: 'at-file', + refreshToken: 'rt-B', + expiresAt: 4000, + expiresIn: 100, + }); // issuedAt 3900 — issued later, shorter life, LOWER expiresAt + + await storage.save('kimi-code', tokenA); // keychain holds older A + await legacy.save('kimi-code', tokenB); // file holds newer B (lower expiresAt) + expect(existsSync(join(dir, 'kimi-code.json'))).toBe(true); + + const loaded = await storage.load('kimi-code'); + expect(loaded).toEqual(tokenB); + + // Keychain is now authoritative with B's exact wire bytes. + const raw = keyring.createEntry(KEYRING_SERVICE, 'kimi-code').getPassword(); + expect(JSON.parse(raw as string)).toEqual(tokenToWire(tokenB)); + + // The migrated plaintext copy is gone. + expect(existsSync(join(dir, 'kimi-code.json'))).toBe(false); + }); + + it('keychain HIT reconcile: a stale plaintext token NEVER resurrects a revoked tombstone', async () => { + // Keychain holds a deliberate revoked tombstone (refresh_token was rejected); + // a stale valid token still sits in the plaintext file. load() must NOT + // un-revoke from plaintext — the tombstone stays authoritative and B is not + // promoted. The file is left in place (its bytes differ from the + // authoritative tombstone, so the conservative cleanup does not delete it). + const prior = sampleToken(); + const tombstone = revokedTombstone(prior); + const validB = sampleToken({ accessToken: 'at-B', refreshToken: 'rt-B', expiresAt: 2000 }); + + await storage.save('kimi-code', tombstone); // keychain holds the tombstone + await legacy.save('kimi-code', validB); // stale valid plaintext + + const loaded = await storage.load('kimi-code'); + expect(loaded).toBeDefined(); + expect(classifyToken(loaded).kind).toBe('revoked'); + expect((loaded as TokenInfo).accessToken).toBe(''); + + // The tombstone is still authoritative; B was NOT promoted. + const raw = keyring.createEntry(KEYRING_SERVICE, 'kimi-code').getPassword(); + expect(JSON.parse(raw as string)).toEqual(tokenToWire(tombstone)); + + // Conservative: a file whose bytes differ from the authoritative value is + // left in place (we never delete a token we did not make authoritative). + expect(existsSync(join(dir, 'kimi-code.json'))).toBe(true); + }); + + // CHARACTERIZATION/guard test: pins the deliberate keychain-authoritative + // tradeoff for the INVERSE of the no-un-revoke direction. It encodes CURRENT + // behavior (expected to pass on current code) — not a fix verification. + it('reconcileOnHit() keeps a valid keychain token authoritative over a file-side tombstone (no force-revoke from plaintext)', async () => { + // Inverse of the no-un-revoke test: keychain holds a VALID token; a file-side + // tombstone (a fallback run's token was 401-rejected) sits in the plaintext + // file. A tombstone is timestamp-less (issuedAt === 0), so it cannot order + // against the keychain token — and the less-trusted plaintext must NOT + // force-revoke the keychain. load() must return the keychain's VALID token, + // leave the keychain entry unchanged, and leave the file tombstone in place. + const valid = sampleToken({ accessToken: 'at-valid', refreshToken: 'rt-valid', expiresAt: 2000 }); + const tombstone = revokedTombstone(valid); + + await storage.save('kimi-code', valid); // keychain holds the VALID token + await legacy.save('kimi-code', tombstone); // file holds a revoked tombstone + expect(existsSync(join(dir, 'kimi-code.json'))).toBe(true); + + const loaded = await storage.load('kimi-code'); + // The keychain's VALID token wins — NOT a revoked/undefined result. + expect(loaded).toEqual(valid); + expect(classifyToken(loaded).kind).toBe('valid'); + expect((loaded as TokenInfo).accessToken).toBe('at-valid'); + + // The keychain entry is UNCHANGED — it was not tombstoned or deleted. + const raw = keyring.createEntry(KEYRING_SERVICE, 'kimi-code').getPassword(); + expect(JSON.parse(raw as string)).toEqual(tokenToWire(valid)); + + // The file tombstone is LEFT IN PLACE (conservative "leave differing file"): + // its bytes differ from the authoritative keychain value, so it is not pruned, + // and the plaintext is never allowed to force-revoke the keychain. + expect(existsSync(join(dir, 'kimi-code.json'))).toBe(true); + const fileRaw = await legacy.load('kimi-code'); + expect(classifyToken(fileRaw).kind).toBe('revoked'); + }); + + it('keychain HIT reconcile: prunes a plaintext duplicate equal after canonical re-serialization', async () => { + // Keychain and file hold the SAME token (a just-migrated state observed by a + // concurrent peer, or a redundant copy). load() returns the keychain value + // and prunes the redundant cleartext, since it equals the authoritative + // keychain value after canonical re-serialization. + const x = sampleToken({ accessToken: 'at-X', refreshToken: 'rt-X', expiresAt: 1500 }); + await storage.save('kimi-code', x); // keychain holds X + await legacy.save('kimi-code', x); // redundant duplicate plaintext X + expect(existsSync(join(dir, 'kimi-code.json'))).toBe(true); + + const loaded = await storage.load('kimi-code'); + expect(loaded).toEqual(x); + + const raw = keyring.createEntry(KEYRING_SERVICE, 'kimi-code').getPassword(); + expect(JSON.parse(raw as string)).toEqual(tokenToWire(x)); + expect(existsSync(join(dir, 'kimi-code.json'))).toBe(false); + }); + + it('keychain HIT reconcile: equal expiresAt is NOT strictly newer, keychain wins and file is left intact', async () => { + // Pins the strict `>` (not `>=`) adoption decision: keychain holds valid X + // and the file holds a DIFFERENT valid Y with the SAME expiresAt. Since the + // file is not STRICTLY newer, the keychain stays authoritative, load returns + // X, and the differing file is left in place (conservative — not a redundant + // duplicate, not made authoritative). + const x = sampleToken({ accessToken: 'at-keyring', refreshToken: 'rt-keyring', expiresAt: 1500 }); + const y = sampleToken({ accessToken: 'at-file', refreshToken: 'rt-file', expiresAt: 1500 }); + + await storage.save('kimi-code', x); // keychain holds X + await legacy.save('kimi-code', y); // file holds a DIFFERENT token, same expiresAt + expect(existsSync(join(dir, 'kimi-code.json'))).toBe(true); + + const loaded = await storage.load('kimi-code'); + expect(loaded).toEqual(x); + + // Keychain bytes are still X (Y was NOT promoted on an equal-expiresAt tie). + const raw = keyring.createEntry(KEYRING_SERVICE, 'kimi-code').getPassword(); + expect(JSON.parse(raw as string)).toEqual(tokenToWire(x)); + + // The differing file is left intact (not a redundant duplicate). + expect(existsSync(join(dir, 'kimi-code.json'))).toBe(true); + }); + + it('keychain HIT reconcile: keyring-newer wins, the older file is left intact', async () => { + // Keychain holds the NEWER valid token B; the file holds an OLDER A. The + // keychain stays authoritative (file is not strictly newer) and load returns + // B. The older file is left in place — conservative: we only delete a file + // we made authoritative or one that is byte-identical to the authoritative + // value, and this older A is neither. + const tokenA = sampleToken({ accessToken: 'at-A', refreshToken: 'rt-A', expiresAt: 1000 }); + const tokenB = sampleToken({ accessToken: 'at-B', refreshToken: 'rt-B', expiresAt: 2000 }); + + await storage.save('kimi-code', tokenB); // keychain holds newer B + await legacy.save('kimi-code', tokenA); // file holds older A + + const loaded = await storage.load('kimi-code'); + expect(loaded).toEqual(tokenB); + + const raw = keyring.createEntry(KEYRING_SERVICE, 'kimi-code').getPassword(); + expect(JSON.parse(raw as string)).toEqual(tokenToWire(tokenB)); + + // Older file left intact (not byte-identical to B, not made authoritative). + expect(existsSync(join(dir, 'kimi-code.json'))).toBe(true); + }); + + it('keychain HIT fast path: a keychain value with no file returns the keychain token unchanged', async () => { + // Steady state: token only in the keychain, no plaintext file. load() must + // return it via the cheap fast path without touching the keychain or any + // file (just one ENOENT readFile under the hood). + const x = sampleToken({ accessToken: 'at-only', refreshToken: 'rt-only', expiresAt: 1234 }); + await storage.save('kimi-code', x); + expect(existsSync(join(dir, 'kimi-code.json'))).toBe(false); + + const loaded = await storage.load('kimi-code'); + expect(loaded).toEqual(x); + + const raw = keyring.createEntry(KEYRING_SERVICE, 'kimi-code').getPassword(); + expect(JSON.parse(raw as string)).toEqual(tokenToWire(x)); + expect(existsSync(join(dir, 'kimi-code.json'))).toBe(false); + }); + + it('remove() always clears the plaintext file even if keyring deletion throws', async () => { + const token = sampleToken(); + await legacy.save('kimi-code', token); // lingering plaintext to clear + expect(existsSync(join(dir, 'kimi-code.json'))).toBe(true); + + // Keyring whose deleteCredential throws (native ops can fail at runtime). + class DeleteThrowingKeyring extends FakeKeyring { + override createEntry(service: string, account: string): KeyringEntry { + const base = super.createEntry(service, account); + return { + getPassword: () => base.getPassword(), + setPassword(p: string): void { + base.setPassword(p); + }, + deleteCredential(): boolean { + throw new Error('keychain delete failed'); + }, + }; + } + } + const throwingStorage = new KeyringTokenStorage({ + keyring: new DeleteThrowingKeyring(), + legacy, + }); + + // The keyring error must propagate... + await expect(throwingStorage.remove('kimi-code')).rejects.toThrow('keychain delete failed'); + // ...but the legacy cleanup must still have run. + expect(existsSync(join(dir, 'kimi-code.json'))).toBe(false); + }); + + it('remove() surfaces a failed keychain delete that returns false (real @napi-rs/keyring never throws)', async () => { + // The REAL @napi-rs/keyring v1.3.0 binding maps EVERY delete failure (locked + // keychain, no-access, ambiguous, platform error) to a plain `false` and + // NEVER throws — the SAME `false` returned for "no such entry". getPassword() + // likewise swallows errors to null. This fake models a delete that FAILS: + // deleteCredential() returns false while getPassword() STILL returns the + // stored value, so the credential definitively persists. remove() must + // disambiguate via the re-read and surface the failure — otherwise a logout + // would clear the plaintext but silently leave the keychain token alive. + const token = sampleToken(); + + class FailingDeleteKeyring extends FakeKeyring { + override createEntry(service: string, account: string): KeyringEntry { + const base = super.createEntry(service, account); + return { + getPassword: () => base.getPassword(), + setPassword(p: string): void { + base.setPassword(p); + }, + // Genuine failure: returns false but the credential is NOT removed, + // so a follow-up getPassword() still sees it. + deleteCredential(): boolean { + return false; + }, + }; + } + } + + const failingKeyring = new FailingDeleteKeyring(); + const failingStorage = new KeyringTokenStorage({ keyring: failingKeyring, legacy }); + + // Seed the keychain (the credential persists through the failed delete) and a + // lingering plaintext copy that must still be cleared. + await failingStorage.save('kimi-code', token); + await legacy.save('kimi-code', token); + expect(existsSync(join(dir, 'kimi-code.json'))).toBe(true); + + // The genuine keyring failure must be surfaced... + await expect(failingStorage.remove('kimi-code')).rejects.toThrow( + /failed to delete keyring credential/, + ); + // ...but the legacy plaintext cleanup must still have run. + expect(existsSync(join(dir, 'kimi-code.json'))).toBe(false); + // The keychain credential genuinely survived (the bug being guarded against). + expect(failingKeyring.createEntry(KEYRING_SERVICE, 'kimi-code').getPassword()).not.toBeNull(); + }); + + it('remove() treats deleteCredential()=false with the name absent from the service listing as a no-op success', async () => { + // A bare `false` from deleteCredential() is overloaded: it means "delete + // failed" OR "no such entry". getPassword() cannot disambiguate (the binding + // collapses every read error to null), so absence is proven via the + // service-scoped findAccounts() listing: when the reachable store does NOT + // list `name`, the entry is genuinely gone, so logging out must RESOLVE + // without throwing (mirrors FileTokenStorage's ENOENT no-op). + class AbsentDeleteKeyring extends FakeKeyring { + override createEntry(service: string, account: string): KeyringEntry { + const base = super.createEntry(service, account); + return { + getPassword: () => base.getPassword(), + setPassword(p: string): void { + base.setPassword(p); + }, + // Mirrors the native binding's "did not exist" → false; never touches + // the backing store, so findAccounts() does NOT list the name. + deleteCredential(): boolean { + return false; + }, + }; + } + // The store is reachable and the name is absent from the listing. + override findAccounts(service: string): string[] { + return super.findAccounts(service); + } + } + + const absentStorage = new KeyringTokenStorage({ keyring: new AbsentDeleteKeyring(), legacy }); + await expect(absentStorage.remove('never-existed')).resolves.toBeUndefined(); + }); + + it('remove() surfaces a denied delete when the entry survives AND the read is denied (getPassword null)', async () => { + // FAILS on pre-fix code, PASSES on the fix. The v1.3.0 binding collapses + // every read error to null, so a denied/locked read returns null EVEN THOUGH + // the entry still exists. Here getPassword() returns null (read denied) while + // the entry STILL lives in the backing store (so findAccounts lists it) and + // deleteCredential() returns false without removing it. The OLD code keyed off + // `getPassword() !== null` → saw null → silently no-op'd → keychain credential + // SURVIVES while the plaintext is wiped and logout reports success. The NEW + // code proves absence via the service listing, sees `name` still present, and + // surfaces the failure — after still clearing the plaintext copy. + const token = sampleToken(); + + class DeniedReadSurvivingKeyring extends FakeKeyring { + override createEntry(service: string, account: string): KeyringEntry { + const base = super.createEntry(service, account); + return { + // Read is DENIED: collapses to null even though the entry exists. + getPassword: () => null, + setPassword(p: string): void { + base.setPassword(p); + }, + // Denied delete: returns false and the entry SURVIVES in the store. + deleteCredential(): boolean { + return false; + }, + }; + } + } + + const deniedKeyring = new DeniedReadSurvivingKeyring(); + const deniedStorage = new KeyringTokenStorage({ keyring: deniedKeyring, legacy }); + + // Seed the keychain (entry persists through the denied delete) and a lingering + // plaintext copy that must still be cleared. + deniedKeyring.store.set(`${KEYRING_SERVICE}\x00kimi-code`, JSON.stringify(tokenToWire(token))); + await legacy.save('kimi-code', token); + expect(existsSync(join(dir, 'kimi-code.json'))).toBe(true); + + // The surviving entry must be surfaced as a failure... + await expect(deniedStorage.remove('kimi-code')).rejects.toThrow( + /failed to delete keyring credential/, + ); + // ...but the legacy plaintext cleanup must still have run. + expect(existsSync(join(dir, 'kimi-code.json'))).toBe(false); + // The keychain credential genuinely survived (the bug being guarded against). + expect(deniedKeyring.findAccounts(KEYRING_SERVICE)).toContain('kimi-code'); + }); + + it('remove() surfaces an unreachable store (deleteCredential false, findAccounts throws)', async () => { + // FAILS on pre-fix code, PASSES on the fix. A locked / no-access store: the + // binding collapses the delete to false and a read to null, but the + // service-scoped enumeration THROWS (findCredentials throws on an unreachable + // store). The OLD code saw `getPassword() === null` → silently no-op'd + // (success), leaving the OS credential behind. The NEW code lets the + // findAccounts throw propagate to the catch → plaintext cleared → re-thrown, + // so logout fails loud (mirrors FileTokenStorage's EACCES throw). + const token = sampleToken(); + + class UnreachableStoreKeyring extends FakeKeyring { + override createEntry(service: string, account: string): KeyringEntry { + const base = super.createEntry(service, account); + return { + getPassword: () => null, + setPassword(p: string): void { + base.setPassword(p); + }, + deleteCredential: () => false, + }; + } + override findAccounts(): string[] { + throw new Error('keychain store unreachable (locked / no-access)'); + } + } + + const unreachableStorage = new KeyringTokenStorage({ + keyring: new UnreachableStoreKeyring(), + legacy, + }); + await legacy.save('kimi-code', token); // lingering plaintext to clear + expect(existsSync(join(dir, 'kimi-code.json'))).toBe(true); + + // The unreachable store must surface as a failure... + await expect(unreachableStorage.remove('kimi-code')).rejects.toThrow(/unreachable/); + // ...but the legacy plaintext cleanup must still have run. + expect(existsSync(join(dir, 'kimi-code.json'))).toBe(false); + }); + + it('remove() resolves for a genuinely-missing entry (deleteCredential false, findAccounts lacks name)', async () => { + // FAILS-guard: pins missing⇒no-op parity with FileTokenStorage's ENOENT path + // and guards against over-throwing. deleteCredential() returns false (no such + // entry), getPassword() returns null, and the reachable store's findAccounts() + // returns a list WITHOUT `name`, so remove() must RESOLVE (no throw) while + // still clearing any plaintext copy. (On the pre-fix code this also resolved, + // so this is a parity guard; the two throwing tests above are the fix proofs.) + const token = sampleToken(); + + class MissingEntryKeyring extends FakeKeyring { + override createEntry(service: string, account: string): KeyringEntry { + const base = super.createEntry(service, account); + return { + getPassword: () => null, + setPassword(p: string): void { + base.setPassword(p); + }, + deleteCredential: () => false, + }; + } + // Reachable store that lists OTHER accounts but not the one being removed. + override findAccounts(): string[] { + return ['some-other-account']; + } + } + + const missingStorage = new KeyringTokenStorage({ keyring: new MissingEntryKeyring(), legacy }); + await legacy.save('kimi-code', token); // a plaintext copy that must still clear + expect(existsSync(join(dir, 'kimi-code.json'))).toBe(true); + + await expect(missingStorage.remove('kimi-code')).resolves.toBeUndefined(); + expect(existsSync(join(dir, 'kimi-code.json'))).toBe(false); + }); + + it('list() unions keyring accounts and un-migrated legacy names, deduped', async () => { + await storage.save('alpha', sampleToken()); // lands in keyring + await legacy.save('beta', sampleToken()); // un-migrated plaintext + await legacy.save('alpha', sampleToken()); // also a stray file for alpha + + const names = await storage.list(); + expect(names.toSorted()).toEqual(['alpha', 'beta']); + }); + + it('load() returns undefined on corrupt keychain JSON (does not throw)', async () => { + keyring.store.set(`${KEYRING_SERVICE}\x00kimi-code`, '{ not json'); + expect(await storage.load('kimi-code')).toBeUndefined(); + }); + + it('remove() clears both keyring and lingering plaintext file', async () => { + await storage.save('kimi-code', sampleToken()); + await legacy.save('kimi-code', sampleToken()); // lingering plaintext + await storage.remove('kimi-code'); + expect(keyring.store.size).toBe(0); + expect(existsSync(join(dir, 'kimi-code.json'))).toBe(false); + }); + + it('remove() does not throw when nothing exists', async () => { + await expect(storage.remove('never-existed')).resolves.toBeUndefined(); + }); + + // Invalid-name rejection — strict drop-in parity with FileTokenStorage + // (storage.test.ts:146-166): same rule, same /Invalid token name/ error, and + // the guard must run BEFORE any keychain op so save() can't orphan a + // credential under an invalid account (file backend's fail-before-write). + describe('rejects invalid token names (file-backend parity)', () => { + const badNames = ['../../etc/passwd', '../etc/passwd', '.hidden', '']; + + for (const bad of badNames) { + it(`save() rejects ${JSON.stringify(bad)}`, async () => { + await expect(storage.save(bad, sampleToken())).rejects.toThrow(/Invalid token name/); + }); + + it(`load() rejects ${JSON.stringify(bad)}`, async () => { + await expect(storage.load(bad)).rejects.toThrow(/Invalid token name/); + }); + + it(`remove() rejects ${JSON.stringify(bad)}`, async () => { + await expect(storage.remove(bad)).rejects.toThrow(/Invalid token name/); + }); + } + + it('save() with an invalid name writes NOTHING to the keychain (no orphan)', async () => { + // This is the assertion that proves the orphan bug is fixed: the pre-fix + // save() called setPassword BEFORE the legacy name check threw, leaving a + // credential orphaned under the invalid account. With the guard first, the + // FakeKeyring backing store stays empty after the rejection. + await expect(storage.save('../../etc/passwd', sampleToken())).rejects.toThrow( + /Invalid token name/, + ); + expect(keyring.store.size).toBe(0); + expect(keyring.findAccounts(KEYRING_SERVICE)).toEqual([]); + expect(keyring.createEntry(KEYRING_SERVICE, '../../etc/passwd').getPassword()).toBeNull(); + }); + }); +}); + +describe('resolveTokenStorage', () => { + let dir: string; + + beforeEach(() => { + dir = makeTmpDir(); + }); + + afterEach(() => { + rmSync(dir, { recursive: true, force: true }); + delete process.env['KIMI_DISABLE_KEYRING']; + }); + + it('returns FileTokenStorage when KIMI_DISABLE_KEYRING=1', () => { + const prev = process.env['KIMI_DISABLE_KEYRING']; + process.env['KIMI_DISABLE_KEYRING'] = '1'; + try { + const storage = resolveTokenStorage(dir); + expect(storage).toBeInstanceOf(FileTokenStorage); + } finally { + if (prev === undefined) delete process.env['KIMI_DISABLE_KEYRING']; + else process.env['KIMI_DISABLE_KEYRING'] = prev; + } + }); + + it('falls back to FileTokenStorage when the native module fails to load', () => { + const storage = resolveTokenStorage(dir, { loadKeyring: () => undefined }); + expect(storage).toBeInstanceOf(FileTokenStorage); + }); + + it('falls back to FileTokenStorage when the capability probe throws', () => { + const storage = resolveTokenStorage(dir, { + loadKeyring: () => new ThrowingKeyring(), + }); + expect(storage).toBeInstanceOf(FileTokenStorage); + }); + + it('selects KeyringTokenStorage when the keyring probe succeeds', async () => { + const keyring = new FakeKeyring(); + const storage = resolveTokenStorage(dir, { loadKeyring: () => keyring }); + expect(storage).toBeInstanceOf(KeyringTokenStorage); + + // A save lands in the fake keyring, not on disk. + await storage.save('kimi-code', sampleToken()); + expect(keyring.store.get(`${keyringServiceForCredentialsDir(dir)}\x00kimi-code`)).toBeDefined(); + expect(existsSync(join(dir, 'kimi-code.json'))).toBe(false); + }); + + it('the probe sentinel never leaks into the real service list', async () => { + const keyring = new FakeKeyring(); + const storage = resolveTokenStorage(dir, { loadKeyring: () => keyring }); + // Probe ran against a separate service; nothing under KEYRING_SERVICE yet. + expect(await storage.list()).toEqual([]); + }); + + it('falls back to FileTokenStorage when the keyring can set/read but cannot DELETE', () => { + // The keychain is the AUTHORITATIVE store once selected — logout/revocation + // and load()'s migrate-then-delete depend on delete working. A backend that + // stores+reads fine but whose deleteCredential() fails (returns false AND + // leaves the entry present) would trap migrated tokens it can never remove + // and make logout throw. The probe must treat that as unusable and fall back + // to the plaintext file store, NOT migrate into a one-way keychain. + class NoDeleteKeyring extends FakeKeyring { + override createEntry(service: string, account: string): KeyringEntry { + const base = super.createEntry(service, account); + return { + getPassword: () => base.getPassword(), + setPassword(p: string): void { + base.setPassword(p); + }, + // Genuine delete failure: returns false and the entry SURVIVES, so a + // follow-up getPassword() still sees the sentinel. + deleteCredential: () => false, + }; + } + } + const storage = resolveTokenStorage(dir, { loadKeyring: () => new NoDeleteKeyring() }); + expect(storage).toBeInstanceOf(FileTokenStorage); + expect(storage).not.toBeInstanceOf(KeyringTokenStorage); + }); + + it('rejects a keyring whose delete returns true but the entry SURVIVES (lying boolean)', () => { + // The native binding maps a failed delete to `false`, but the probe does NOT + // trust the boolean — it confirms removal via the service-scoped findAccounts() + // listing (our unique probe account must be ABSENT), mirroring remove()'s own + // disambiguation. A backend that LIES (delete reports true while the entry + // persists) must still be rejected, proving the probe relies on the + // authoritative service listing, not the return value. + class LyingDeleteKeyring extends FakeKeyring { + override createEntry(service: string, account: string): KeyringEntry { + const base = super.createEntry(service, account); + return { + getPassword: () => base.getPassword(), + setPassword(p: string): void { + base.setPassword(p); + }, + // Lies: claims success while the entry remains present. + deleteCredential: () => true, + }; + } + } + const storage = resolveTokenStorage(dir, { loadKeyring: () => new LyingDeleteKeyring() }); + expect(storage).toBeInstanceOf(FileTokenStorage); + expect(storage).not.toBeInstanceOf(KeyringTokenStorage); + }); + + it('rejects a keyring whose post-delete read is DENIED (null) while the sentinel SURVIVES (read-error ambiguity)', () => { + // FAILS on the pre-fix probe, PASSES on the fix. The v1.3.0 binding collapses + // every read error to null, so a denied/locked post-delete read returns null + // EVEN THOUGH the sentinel still exists. Here the probe's set + FIRST getPassword + // round-trip succeed, deleteCredential() returns false (denied — sentinel NOT + // removed), and the post-delete getPassword() collapses to null (read denied) — + // BUT findAccounts(KEYRING_PROBE_SERVICE) STILL lists the probe account because + // the entry physically persists. The OLD probe keyed off `getPassword() === null` + // → saw null → returned true → wrongly selected KeyringTokenStorage (a one-way + // keychain that traps migrated tokens). The NEW probe proves absence via the + // service listing, sees its own account still present → returns false → falls + // back to FileTokenStorage. + class DeniedReadSurvivingProbeKeyring extends FakeKeyring { + override createEntry(service: string, account: string): KeyringEntry { + const base = super.createEntry(service, account); + let reads = 0; + return { + // First read (the round-trip check) returns the stored sentinel; every + // later read collapses to null (denied), as the binding does on error. + getPassword(): string | null { + reads += 1; + return reads === 1 ? base.getPassword() : null; + }, + setPassword(p: string): void { + base.setPassword(p); + }, + // Denied delete: returns false and the sentinel SURVIVES in the store, + // so the faithful findAccounts() still lists this probe account. + deleteCredential: () => false, + }; + } + } + const storage = resolveTokenStorage(dir, { + loadKeyring: () => new DeniedReadSurvivingProbeKeyring(), + }); + expect(storage).toBeInstanceOf(FileTokenStorage); + expect(storage).not.toBeInstanceOf(KeyringTokenStorage); + }); + + it('rejects a keyring whose post-delete findAccounts THROWS (unreachable store mid-probe)', () => { + // A locked / no-access store discovered only at the post-delete confirmation: + // set + the first read succeed, deleteCredential() returns false, but the + // service-scoped findAccounts() THROWS (findCredentials throws on an + // unreachable store). The probe's findAccounts throw is caught → probe returns + // false → FileTokenStorage, never migrating plaintext into an unusable keychain. + class UnreachableProbeKeyring extends FakeKeyring { + override createEntry(service: string, account: string): KeyringEntry { + const base = super.createEntry(service, account); + return { + getPassword: () => base.getPassword(), + setPassword(p: string): void { + base.setPassword(p); + }, + deleteCredential: () => false, + }; + } + override findAccounts(): string[] { + throw new Error('keychain store unreachable (locked / no-access)'); + } + } + const storage = resolveTokenStorage(dir, { + loadKeyring: () => new UnreachableProbeKeyring(), + }); + expect(storage).toBeInstanceOf(FileTokenStorage); + expect(storage).not.toBeInstanceOf(KeyringTokenStorage); + }); + + it('selects KeyringTokenStorage and leaves NO probe sentinel behind on the healthy path', () => { + // The healthy fake deletes properly (Map.delete mutates the backing store), + // so the probe's delete-and-re-read confirms removal → keyring is selected. + // Assert via instanceof AND that the isolated probe service has zero accounts + // afterward, proving the probe's own cleanup ran (no leaked sentinel). + const keyring = new FakeKeyring(); + const storage = resolveTokenStorage(dir, { loadKeyring: () => keyring }); + expect(storage).toBeInstanceOf(KeyringTokenStorage); + expect(keyring.findAccounts(KEYRING_PROBE_SERVICE)).toEqual([]); + }); + + it('the probe uses a unique, non-constant account per attempt', () => { + const a = new RecordingKeyring(); + const b = new RecordingKeyring(); + resolveTokenStorage(dir, { loadKeyring: () => a }); + resolveTokenStorage(dir, { loadKeyring: () => b }); + + const aAccounts = a.accountsByService.get(KEYRING_PROBE_SERVICE) ?? []; + const bAccounts = b.accountsByService.get(KEYRING_PROBE_SERVICE) ?? []; + expect(aAccounts.length).toBeGreaterThan(0); + expect(bAccounts.length).toBeGreaterThan(0); + + // Never the old fixed sentinel account. + for (const acct of [...aAccounts, ...bAccounts]) { + expect(acct).not.toBe('probe'); + } + // Distinct accounts across independent probe attempts. + expect(new Set(aAccounts).size).toBe(1); // one account used consistently within an attempt + expect(aAccounts[0]).not.toBe(bAccounts[0]); + }); + + it('a second probe sharing the backend does not clobber the first probe', () => { + // Models two concurrent CLI probes against one live keychain. While probe A + // is mid-round-trip (after set, before read), probe B runs a full + // set/get/delete cycle. With the OLD fixed `'probe'` account B's delete + // would wipe A's sentinel → A reads null → false mismatch → file fallback. + // Per-attempt unique accounts must keep A's read intact. + const shared = new FakeKeyring(); + let aAccount: string | undefined; + let injected = false; + + const interleavingKeyring: KeyringApi = { + createEntry(service, account) { + const base = shared.createEntry(service, account); + if (service !== KEYRING_PROBE_SERVICE) return base; + aAccount ??= account; + return { + getPassword: () => base.getPassword(), + setPassword(p: string): void { + base.setPassword(p); + // After A's set, interleave a second probe (process B) using the + // SAME account A used — exactly what a shared fixed sentinel does. + if (!injected) { + injected = true; + const bEntry = shared.createEntry(service, aAccount as string); + bEntry.setPassword('b-sentinel'); + bEntry.deleteCredential(); + } + }, + deleteCredential: () => base.deleteCredential(), + }; + }, + findAccounts: (service) => shared.findAccounts(service), + }; + + // Sanity: this interleave on a SHARED account breaks the probe (read null). + const storage = resolveTokenStorage(dir, { loadKeyring: () => interleavingKeyring }); + expect(storage).toBeInstanceOf(FileTokenStorage); + + // ...whereas the production probe derives a UNIQUE account per attempt, so a + // concurrent probe on its own account cannot clobber it. Two real attempts + // therefore use different accounts (asserted in the test above), which is + // what prevents the collision modeled here on a healthy keychain. + }); +}); diff --git a/packages/oauth/test/toolkit-keyring-integration.test.ts b/packages/oauth/test/toolkit-keyring-integration.test.ts new file mode 100644 index 000000000..61acf6941 --- /dev/null +++ b/packages/oauth/test/toolkit-keyring-integration.test.ts @@ -0,0 +1,370 @@ +/** + * End-to-end proof that the keychain backend is reachable through the public + * `KimiOAuthToolkit` surface — fully hermetic, NEVER touches the real OS + * keychain. + * + * Task 1 built `KeyringTokenStorage` + `resolveTokenStorage`; the adversarial + * review of that task flagged that the backend was unreachable from runtime + * (the toolkit still defaulted to `FileTokenStorage` and the symbols weren't + * exported). These tests lock in the wiring: + * + * 1. A toolkit constructed with a keyring-backed store (built from the REAL + * `resolveTokenStorage` factory + the test seam) drives the full public + * lifecycle — status / read / refresh / logout — and every credential + * read and write lands in the FAKE keychain, never in a plaintext file on + * disk. + * 2. A default-constructed toolkit (no `options.storage`) goes through + * `resolveTokenStorage`: with `KIMI_DISABLE_KEYRING=1` it transparently + * falls back to the file store and still works — proving the factory is on + * the default code path, not bypassed. + */ + +import { existsSync, mkdirSync, readdirSync, rmSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; + +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +import { + KeyringTokenStorage, + keyringServiceForCredentialsDir, + resolveTokenStorage, +} from '../src/keyring-storage'; +import type { KeyringApi, KeyringEntry } from '../src/keyring-storage'; +import { FileTokenStorage } from '../src/storage'; +import { KimiOAuthToolkit } from '../src/toolkit'; +import type { TokenInfo } from '../src/types'; + +const TEST_IDENTITY = { + userAgentProduct: 'kimi-code-cli', + version: '0.0.0-test', +} as const; + +const FLOW_CONFIG = { + name: 'kimi-code', + oauthHost: 'https://auth.kimi.com', + clientId: 'test-client-id', +} as const; + +function makeTmpDir(): string { + const dir = join( + tmpdir(), + `kimi-toolkit-keyring-${Date.now()}-${Math.random().toString(36).slice(2)}`, + ); + mkdirSync(dir, { recursive: true }); + return dir; +} + +function token(overrides: Partial = {}): TokenInfo { + return { + accessToken: 'access-1', + refreshToken: 'refresh-1', + expiresAt: 10_000, + scope: '', + tokenType: 'Bearer', + expiresIn: 3600, + ...overrides, + }; +} + +/** In-memory KeyringApi fake backed by a Map keyed by `service\x00account`. */ +class FakeKeyring implements KeyringApi { + public readonly store = new Map(); + + private key(service: string, account: string): string { + return `${service}\x00${account}`; + } + + createEntry(service: string, account: string): KeyringEntry { + const key = this.key(service, account); + const store = this.store; + return { + getPassword(): string | null { + return store.has(key) ? (store.get(key) as string) : null; + }, + setPassword(password: string): void { + store.set(key, password); + }, + deleteCredential(): boolean { + return store.delete(key); + }, + }; + } + + findAccounts(service: string): string[] { + const prefix = `${service}\x00`; + const accounts: string[] = []; + for (const k of this.store.keys()) { + if (k.startsWith(prefix)) accounts.push(k.slice(prefix.length)); + } + return accounts; + } +} + +/** Token entries currently in the fake keychain under the given service. */ +function keychainTokenNames(keyring: FakeKeyring, service: string): string[] { + return keyring.findAccounts(service); +} + +/** Plaintext `.json` token files on disk (excludes tmp write files). */ +function plaintextTokenFiles(dir: string): string[] { + try { + return readdirSync(dir).filter((e) => e.endsWith('.json')); + } catch { + return []; + } +} + +function fetchInputUrl(input: unknown): string { + if (typeof input === 'string') return input; + if (input instanceof URL) return input.href; + if (input instanceof Request) return input.url; + throw new TypeError('expected fetch input to be a string, URL, or Request'); +} + +describe('KimiOAuthToolkit with a keyring-backed store (hermetic)', () => { + let dir: string; + let keyring: FakeKeyring; + let storage: KeyringTokenStorage; + // resolveTokenStorage namespaces the keychain service per credentialsDir + // (parity with the file backend), so the service is derived from `dir`, not + // the bare KEYRING_SERVICE constant. + let service: string; + + beforeEach(() => { + dir = makeTmpDir(); + service = keyringServiceForCredentialsDir(dir); + keyring = new FakeKeyring(); + // Build the store through the REAL factory + the test seam. `disabled: + // false` is explicit so a stray KIMI_DISABLE_KEYRING in the env can't make + // this select the file backend — we are specifically proving the keyring + // path. The probe round-trips against the fake, so this returns a + // KeyringTokenStorage. + const resolved = resolveTokenStorage(dir, { + loadKeyring: () => keyring, + disabled: false, + }); + storage = resolved as KeyringTokenStorage; + }); + + afterEach(() => { + vi.restoreAllMocks(); + vi.unstubAllGlobals(); + rmSync(dir, { recursive: true, force: true }); + }); + + it('resolveTokenStorage selects KeyringTokenStorage on the public path', () => { + // The store every test in this block drives is the real factory output for + // a usable keyring — i.e. a KeyringTokenStorage, not the file fallback. + expect(storage).toBeInstanceOf(KeyringTokenStorage); + }); + + it('status() reflects a token saved into the keychain (nothing on disk)', async () => { + const toolkit = new KimiOAuthToolkit({ + homeDir: dir, + identity: TEST_IDENTITY, + storage, + now: () => 100, + flowConfig: FLOW_CONFIG, + }); + + await expect(toolkit.status()).resolves.toEqual({ + providers: [{ providerName: 'managed:kimi-code', hasToken: false }], + }); + + // Seed through the public store the toolkit was constructed with. + await storage.save('kimi-code', token()); + + await expect(toolkit.status()).resolves.toEqual({ + providers: [{ providerName: 'managed:kimi-code', hasToken: true }], + }); + // The token lives in the fake keychain, not on disk. + expect(keychainTokenNames(keyring, service)).toEqual(['kimi-code']); + expect(plaintextTokenFiles(dir)).toEqual([]); + }); + + it('getAccessToken() returns the cached token straight from the keychain', async () => { + await storage.save('kimi-code', token({ accessToken: 'cached-access' })); + const toolkit = new KimiOAuthToolkit({ + homeDir: dir, + identity: TEST_IDENTITY, + storage, + now: () => 100, + flowConfig: FLOW_CONFIG, + }); + + await expect(toolkit.tokenProvider().getAccessToken()).resolves.toBe('cached-access'); + expect(plaintextTokenFiles(dir)).toEqual([]); + }); + + it('a refresh persists the rotated token into the keychain, never to disk', async () => { + // Stored token is already expired so ensureFresh must refresh it. + await storage.save('kimi-code', token({ accessToken: 'stale-access', expiresAt: 100 })); + + const fetchImpl = vi.fn(async (input: unknown, init?: RequestInit) => { + expect(fetchInputUrl(input)).toBe(`${FLOW_CONFIG.oauthHost}/api/oauth/token`); + if (typeof init?.body !== 'string') throw new TypeError('expected form body'); + expect(new URLSearchParams(init.body).get('grant_type')).toBe('refresh_token'); + return new Response( + JSON.stringify({ + access_token: 'rotated-access', + refresh_token: 'rotated-refresh', + expires_in: 3600, + scope: '', + token_type: 'Bearer', + }), + { status: 200, headers: { 'Content-Type': 'application/json' } }, + ); + }); + vi.stubGlobal('fetch', fetchImpl); + + const toolkit = new KimiOAuthToolkit({ + homeDir: dir, + identity: TEST_IDENTITY, + storage, + now: () => 1_000, + flowConfig: FLOW_CONFIG, + }); + + const nowBeforeRefresh = Math.floor(Date.now() / 1000); + await expect(toolkit.ensureFresh()).resolves.toBe('rotated-access'); + expect(fetchImpl).toHaveBeenCalledTimes(1); + + // The rotated token is persisted into the FAKE keychain as snake_case wire + // JSON — the same payload the file store would have written, but to the + // keychain instead. + const raw = keyring.createEntry(service, 'kimi-code').getPassword(); + expect(raw).not.toBeNull(); + const persisted = JSON.parse(raw as string) as Record; + expect(persisted['access_token']).toBe('rotated-access'); + expect(persisted['refresh_token']).toBe('rotated-refresh'); + expect(persisted['expires_in']).toBe(3600); + expect(persisted['token_type']).toBe('Bearer'); + // OAuthManager stamps expiresAt from real wall-clock (Date.now), not the + // injected `now`, so assert it is a fresh ~+3600s value rather than an + // exact match against the stub. + expect(persisted['expires_at']).toBeGreaterThanOrEqual(nowBeforeRefresh + 3600); + // ...and absolutely nothing landed in plaintext on disk. + expect(plaintextTokenFiles(dir)).toEqual([]); + + // A second read returns the rotated token without another network call. + await expect(toolkit.getCachedAccessToken()).resolves.toBe('rotated-access'); + expect(fetchImpl).toHaveBeenCalledTimes(1); + }); + + it('a refresh prunes a pre-seeded stale plaintext file so a later file run cannot resurrect it', async () => { + // Pre-seed a stale plaintext token at /kimi-code.json via a + // real FileTokenStorage — exactly what a prior file-backend fallback run (or + // a keychain-wins reconcile) leaves behind. The keychain holds an expired + // token so ensureFresh refreshes and calls save(). After save() the cleartext + // copy must be gone, so a later KIMI_DISABLE_KEYRING run (keychain-unaware) + // can no longer read it back and resurrect the obsolete credential. + await new FileTokenStorage(dir).save( + 'kimi-code', + token({ accessToken: 'stale-plaintext', refreshToken: 'stale-plaintext-refresh' }), + ); + expect(plaintextTokenFiles(dir)).toEqual(['kimi-code.json']); + + // Keychain token is already expired so ensureFresh must refresh it. + await storage.save('kimi-code', token({ accessToken: 'stale-access', expiresAt: 100 })); + + const fetchImpl = vi.fn(async (input: unknown, init?: RequestInit) => { + expect(fetchInputUrl(input)).toBe(`${FLOW_CONFIG.oauthHost}/api/oauth/token`); + if (typeof init?.body !== 'string') throw new TypeError('expected form body'); + expect(new URLSearchParams(init.body).get('grant_type')).toBe('refresh_token'); + return new Response( + JSON.stringify({ + access_token: 'rotated-access', + refresh_token: 'rotated-refresh', + expires_in: 3600, + scope: '', + token_type: 'Bearer', + }), + { status: 200, headers: { 'Content-Type': 'application/json' } }, + ); + }); + vi.stubGlobal('fetch', fetchImpl); + + const toolkit = new KimiOAuthToolkit({ + homeDir: dir, + identity: TEST_IDENTITY, + storage, + now: () => 1_000, + flowConfig: FLOW_CONFIG, + }); + + await expect(toolkit.ensureFresh()).resolves.toBe('rotated-access'); + expect(fetchImpl).toHaveBeenCalledTimes(1); + + // The rotated token is authoritative in the keychain... + const raw = keyring.createEntry(service, 'kimi-code').getPassword(); + expect(raw).not.toBeNull(); + expect((JSON.parse(raw as string) as Record)['access_token']).toBe( + 'rotated-access', + ); + // ...and the pre-seeded stale plaintext copy is gone (no resurrection path). + expect(plaintextTokenFiles(dir)).toEqual([]); + }); + + it('logout() removes the token from the keychain', async () => { + await storage.save('kimi-code', token()); + const toolkit = new KimiOAuthToolkit({ + homeDir: dir, + identity: TEST_IDENTITY, + storage, + now: () => 100, + flowConfig: FLOW_CONFIG, + }); + expect((await toolkit.status()).providers[0]?.hasToken).toBe(true); + + await expect(toolkit.logout()).resolves.toMatchObject({ + providerName: 'managed:kimi-code', + ok: true, + }); + + expect((await toolkit.status()).providers[0]?.hasToken).toBe(false); + expect(keychainTokenNames(keyring, service)).toEqual([]); + expect(plaintextTokenFiles(dir)).toEqual([]); + }); +}); + +describe('KimiOAuthToolkit default storage goes through resolveTokenStorage', () => { + let dir: string; + let prevDisable: string | undefined; + + beforeEach(() => { + dir = makeTmpDir(); + prevDisable = process.env['KIMI_DISABLE_KEYRING']; + }); + + afterEach(() => { + if (prevDisable === undefined) delete process.env['KIMI_DISABLE_KEYRING']; + else process.env['KIMI_DISABLE_KEYRING'] = prevDisable; + rmSync(dir, { recursive: true, force: true }); + }); + + it('falls back to the file store when KIMI_DISABLE_KEYRING=1 (no injected storage)', async () => { + process.env['KIMI_DISABLE_KEYRING'] = '1'; + + // No `storage` option: the toolkit must build its store via + // resolveTokenStorage, which with the flag set returns a FileTokenStorage. + const toolkit = new KimiOAuthToolkit({ + homeDir: dir, + identity: TEST_IDENTITY, + now: () => 100, + flowConfig: FLOW_CONFIG, + }); + + const credentialsDir = join(dir, 'credentials'); + // Seed via an independent FileTokenStorage over the same dir the default + // factory uses; the default toolkit must read it back. + await new FileTokenStorage(credentialsDir).save('kimi-code', token({ accessToken: 'file-access' })); + + await expect(toolkit.status()).resolves.toEqual({ + providers: [{ providerName: 'managed:kimi-code', hasToken: true }], + }); + await expect(toolkit.tokenProvider().getAccessToken()).resolves.toBe('file-access'); + // Proof the file backend is what was selected: the plaintext file exists. + expect(existsSync(join(credentialsDir, 'kimi-code.json'))).toBe(true); + }); +}); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 723961f2d..62b29c3d1 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -121,6 +121,9 @@ importers: '@mariozechner/clipboard': specifier: ^0.3.2 version: 0.3.2 + '@napi-rs/keyring': + specifier: 1.3.0 + version: 1.3.0 chalk: specifier: ^5.4.1 version: 5.6.2 @@ -500,6 +503,9 @@ importers: '@antfu/utils': specifier: ^9.3.0 version: 9.3.0 + '@napi-rs/keyring': + specifier: 1.3.0 + version: 1.3.0 smol-toml: specifier: ^1.6.1 version: 1.6.1 @@ -528,6 +534,9 @@ importers: packages/oauth: dependencies: + '@napi-rs/keyring': + specifier: 1.3.0 + version: 1.3.0 proper-lockfile: specifier: ^4.1.2 version: 4.1.2 @@ -1681,6 +1690,87 @@ packages: resolution: {integrity: sha512-juG5VWh4qAivzTAeMzvY9xs9HY5rAcr2E4I7tiSSCokRFi7XIZCAu92ZkSTsIj1OPceCifL3cpfteP3pDT9/QQ==} engines: {node: '>=14.0.0'} + '@napi-rs/keyring-darwin-arm64@1.3.0': + resolution: {integrity: sha512-pl76hJvdYUBn6I24bXiOBMA9nbDapo3I5B+f3OorjDU4dUMSypXeKbOVehJe8fhgTiH24flMyTS3aAIy43xegQ==} + engines: {node: '>= 10'} + cpu: [arm64] + os: [darwin] + + '@napi-rs/keyring-darwin-x64@1.3.0': + resolution: {integrity: sha512-YcJtEV5LA3cvA4z3BurgxH5IhTsW1JfIvcAAcqcecwk06Si9F9NqkxbZVIfDwQ8oRHgaBmT3zZJnLAotCrVahw==} + engines: {node: '>= 10'} + cpu: [x64] + os: [darwin] + + '@napi-rs/keyring-freebsd-x64@1.3.0': + resolution: {integrity: sha512-vlLf31TGhfRAaxLDBhg8b89ss0HHD/lyNmL5F3UjSaz5CUXElsJmKYq9fqA/B+cZKUEUcLHHGhF0I/CqcFdaVw==} + engines: {node: '>= 10'} + cpu: [x64] + os: [freebsd] + + '@napi-rs/keyring-linux-arm-gnueabihf@1.3.0': + resolution: {integrity: sha512-KiWdMMu/Inz/bHHIAGrnF7r54FZDYXuHO6UFF/rhIrshUsxbMG1Rl9lEymNtqqsVo927G0VYcb02FzWQ3iBQRQ==} + engines: {node: '>= 10'} + cpu: [arm] + os: [linux] + + '@napi-rs/keyring-linux-arm64-gnu@1.3.0': + resolution: {integrity: sha512-eyKGpY40lm9Jvs1aD294XRH4y7+TlJM0YVAryZeXA6TX0mb4gMkxVXwSQv7MCwgah7raeUd0dKUb4BPAYIgcMg==} + engines: {node: '>= 10'} + cpu: [arm64] + os: [linux] + libc: [glibc] + + '@napi-rs/keyring-linux-arm64-musl@1.3.0': + resolution: {integrity: sha512-iIK6JWHXAJqDrEyLY3TmswwloVyt2vj+04TZnew+uSJ9gnDO8EwRbp3/iw3LpWaXiDO7VomGO6y8I0Id8uBZSw==} + engines: {node: '>= 10'} + cpu: [arm64] + os: [linux] + libc: [musl] + + '@napi-rs/keyring-linux-riscv64-gnu@1.3.0': + resolution: {integrity: sha512-/PGqrwn6EwgtK6vccASSXJRfOSP4vN1F4ASsIQ+7MdrK6hNvAJ1FZPrIuD5gGGdxezo3F++To2Wq7DbuGIeuNQ==} + engines: {node: '>= 10'} + cpu: [riscv64] + os: [linux] + libc: [glibc] + + '@napi-rs/keyring-linux-x64-gnu@1.3.0': + resolution: {integrity: sha512-2PDK1WKWTu9lBGq9VvNEkSlQD3O7YwVpmnyN2M3cy4v7NJ/8gDMd9GXv3G+FVXN13uhp4gnnPBS+ScefmEeD2A==} + engines: {node: '>= 10'} + cpu: [x64] + os: [linux] + libc: [glibc] + + '@napi-rs/keyring-linux-x64-musl@1.3.0': + resolution: {integrity: sha512-oJ2HkX8YUo46QBkn0pG+HuIKQNqr523q6vBobCn+P95s4C4K6/kLBqHY/1bg5J4ap31DzsznhnFKcfBNBsjCnw==} + engines: {node: '>= 10'} + cpu: [x64] + os: [linux] + libc: [musl] + + '@napi-rs/keyring-win32-arm64-msvc@1.3.0': + resolution: {integrity: sha512-tOd3c/uAaeoE4ycVlmAdSvygz0Zt3zdca6Y7gokBeIbaRDWpjDIUOpU3MvML59XAaqyuKGsVVu0F/DZb1lHPmw==} + engines: {node: '>= 10'} + cpu: [arm64] + os: [win32] + + '@napi-rs/keyring-win32-ia32-msvc@1.3.0': + resolution: {integrity: sha512-sPSqeAFZMGqP1R++M2JTza7GQJJ/TpCo6JU6Vcd4jnebvOaEDs9b7eipakU1PJdSvhpC2yXMCNRk9gXfrhuwHQ==} + engines: {node: '>= 10'} + cpu: [ia32] + os: [win32] + + '@napi-rs/keyring-win32-x64-msvc@1.3.0': + resolution: {integrity: sha512-4DnCWXwDc0HRKwyRlG5y0VhKZW2tNRQfKKfyj6IX/KWfDNyq9hn4n+GL1auyDcOO/v8PwnhmYo2+rOOqCkvvOg==} + engines: {node: '>= 10'} + cpu: [x64] + os: [win32] + + '@napi-rs/keyring@1.3.0': + resolution: {integrity: sha512-WrOw/bcXm0f9qHkumlT1QlArXSTWqaY9sunsDpOk+yCCorCKMxvWT/a3xko4EYHVdeZoh00yI2TydXn6eyICDA==} + engines: {node: '>= 10'} + '@napi-rs/wasm-runtime@1.1.4': resolution: {integrity: sha512-3NQNNgA1YSlJb/kMH1ildASP9HW7/7kYnRI2szWJaofaS1hWmbGI4H+d3+22aGzXXN9IJ+n+GiFVcGipJP18ow==} peerDependencies: @@ -7505,6 +7595,57 @@ snapshots: '@mozilla/readability@0.6.0': {} + '@napi-rs/keyring-darwin-arm64@1.3.0': + optional: true + + '@napi-rs/keyring-darwin-x64@1.3.0': + optional: true + + '@napi-rs/keyring-freebsd-x64@1.3.0': + optional: true + + '@napi-rs/keyring-linux-arm-gnueabihf@1.3.0': + optional: true + + '@napi-rs/keyring-linux-arm64-gnu@1.3.0': + optional: true + + '@napi-rs/keyring-linux-arm64-musl@1.3.0': + optional: true + + '@napi-rs/keyring-linux-riscv64-gnu@1.3.0': + optional: true + + '@napi-rs/keyring-linux-x64-gnu@1.3.0': + optional: true + + '@napi-rs/keyring-linux-x64-musl@1.3.0': + optional: true + + '@napi-rs/keyring-win32-arm64-msvc@1.3.0': + optional: true + + '@napi-rs/keyring-win32-ia32-msvc@1.3.0': + optional: true + + '@napi-rs/keyring-win32-x64-msvc@1.3.0': + optional: true + + '@napi-rs/keyring@1.3.0': + optionalDependencies: + '@napi-rs/keyring-darwin-arm64': 1.3.0 + '@napi-rs/keyring-darwin-x64': 1.3.0 + '@napi-rs/keyring-freebsd-x64': 1.3.0 + '@napi-rs/keyring-linux-arm-gnueabihf': 1.3.0 + '@napi-rs/keyring-linux-arm64-gnu': 1.3.0 + '@napi-rs/keyring-linux-arm64-musl': 1.3.0 + '@napi-rs/keyring-linux-riscv64-gnu': 1.3.0 + '@napi-rs/keyring-linux-x64-gnu': 1.3.0 + '@napi-rs/keyring-linux-x64-musl': 1.3.0 + '@napi-rs/keyring-win32-arm64-msvc': 1.3.0 + '@napi-rs/keyring-win32-ia32-msvc': 1.3.0 + '@napi-rs/keyring-win32-x64-msvc': 1.3.0 + '@napi-rs/wasm-runtime@1.1.4(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0)': dependencies: '@emnapi/core': 1.10.0