@tsa-group/claude-usage 0.4.8 → 0.4.9

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -350,6 +350,12 @@ v0.4.7 起支援自我更新,但**由伺服器決定**,不是去問 npm:
350
350
  ## 已知限制
351
351
 
352
352
  - **非官方 endpoint**:額度 % 來自 Claude Code 內部的 `/api/oauth/usage`,Anthropic 可能變更。
353
+ - **macOS 桌面版憑證也是逆向出來的**:Claude.app 把 OAuth token 存在
354
+ `~/Library/Application Support/Claude/config.json` 的 `oauth:tokenCacheV2`,用 Chromium 的
355
+ macOS 方案(Keychain「Claude Safe Storage」主金鑰 + PBKDF2-SHA1 + AES-128-CBC)加密。
356
+ 首次背景存取會彈 Keychain 授權對話 —— 在終端機跑一次
357
+ `security find-generic-password -w -s "Claude Safe Storage"` 並按**一律允許**即可,
358
+ 之後 daemon 讀得到。`doctor` 會偵測這個狀態並提示。與 `/api/oauth/usage` 同一風險類別。
353
359
  - **Windows 桌面版憑證是逆向出來的**:桌面版把 OAuth token 存在
354
360
  `%LOCALAPPDATA%\Packages\Claude_*\LocalCache\Roaming\Claude\config.json` 的
355
361
  `oauth:tokenCacheV2`,以 Chromium 的 `v10` 方案(DPAPI + AES-256-GCM)加密。
@@ -0,0 +1,148 @@
1
+ /**
2
+ * macOS 桌面版(Claude.app)的 Claude OAuth 憑證讀取。
3
+ *
4
+ * 為什麼需要這條路徑:跟 Windows 桌面版是**同一個問題**(見 creds-win.ts 檔頭)——
5
+ * 只用桌面版、沒在終端機開過 CLI 的人,`~/.claude/.credentials.json` 裡不會有可用的
6
+ * token,而 Keychain 裡那份是早期 CLI 登入的殘留,沒人 refresh 就會過期。實機
7
+ * (2026-08-31, Ashlyn)就是這樣:Claude Code 照常運作(它自己 refresh),但我們讀到
8
+ * 的 Keychain 憑證從 02:00 起過期,採樣連續 token_expired,而她其實一直在用。
9
+ *
10
+ * 桌面版把 token 存在 `~/Library/Application Support/Claude/config.json` 的
11
+ * `oauth:tokenCacheV2`,用 Chromium 的 macOS 加密方案:
12
+ * 主金鑰 = PBKDF2-HMAC-SHA1(Keychain「Claude Safe Storage」密碼, "saltysalt", 1003 輪, 16B)
13
+ * 密文 = base64( "v10"(3B) + AES-128-CBC(IV = 16 個 0x20) ) + PKCS7
14
+ *
15
+ * ★ 與 Windows 版的唯一差異就是加密那一步(DPAPI+AES-256-GCM ↔ Keychain+AES-128-CBC)。
16
+ * 解出來的 JSON 形狀完全相同(實機驗證:2 條目、user:inference 那筆是目標),所以
17
+ * **挑選邏輯直接重用 creds-win.ts 的 pickEntry**,不另寫一份。
18
+ *
19
+ * ★ 為什麼主金鑰用 `security` CLI 而不是 native Keychain API:與 mac 上讀 Keychain
20
+ * token、Windows 上 spawn PowerShell 做 DPAPI 完全對稱 —— 零執行期依賴、免編譯。
21
+ *
22
+ * ★ 為什麼 AES-GCM 用 Node crypto 而 KDF 用 Node crypto、只有取主金鑰密碼 spawn
23
+ * 外部程式:跟 Windows 版同一個分工原則 —— 只有「拿受作業系統保護的秘密」那一步
24
+ * 非得走系統工具,其餘純運算留在 Node,測得到、也不受外部工具版本影響。
25
+ *
26
+ * ⚠️ 未公開的內部格式,Anthropic 改版就可能失效 —— 與 /api/oauth/usage 同一風險類別。
27
+ * 失敗一律回可辨識的 source 字串,不拋例外把 daemon 打掛。
28
+ *
29
+ * SECURITY:解出的 master key 與 token 只留在記憶體,**永不列印、永不寫檔、永不上傳**。
30
+ */
31
+ import { spawnSync } from "node:child_process";
32
+ import { createDecipheriv, pbkdf2Sync } from "node:crypto";
33
+ import { existsSync, readFileSync } from "node:fs";
34
+ import { homedir } from "node:os";
35
+ import { join } from "node:path";
36
+ import { pickEntry } from "./creds-win.js";
37
+ /** Chromium 加密區塊前綴。看到別的就是格式變了,不硬解。 */
38
+ const V10 = "v10";
39
+ /** Chromium 在 macOS 的固定 KDF 參數(公開常數,非機密)。 */
40
+ const KDF_SALT = "saltysalt";
41
+ const KDF_ITER = 1003;
42
+ const KDF_KEYLEN = 16; // AES-128
43
+ /** 桌面版憑證檔位置。Claude.app 走 Electron 的 userData 慣例。 */
44
+ function configPath() {
45
+ return join(homedir(), "Library", "Application Support", "Claude", "config.json");
46
+ }
47
+ /**
48
+ * 從 Keychain 取「Claude Safe Storage」密碼,PBKDF2 成 16-byte AES 金鑰。
49
+ *
50
+ * `security find-generic-password -w -s <svc>` 只回密碼本體。首次存取可能跳授權
51
+ * 對話(App 首次要求時使用者已同意過,daemon 走同一個 ACL),非互動下拿不到就回
52
+ * 可辨識原因,不卡住。
53
+ */
54
+ function masterKey() {
55
+ const r = spawnSync("security", ["find-generic-password", "-w", "-s", "Claude Safe Storage"],
56
+ // ★ timeout 短是刻意的:`Claude Safe Storage` 的 Keychain ACL 只授權給 Claude.app,
57
+ // 從別的行程存取會**彈授權對話框並阻塞等點擊**。daemon 是非互動的,等不到人點,
58
+ // 所以與其掛在那裡耗一個 tick,不如快速 timeout 回一個可辨識的原因。
59
+ // 使用者只要在 Keychain 存取.app 對這個項目按一次「一律允許」,之後就不再彈。
60
+ { encoding: "utf8", timeout: 8_000 });
61
+ // ETIMEDOUT 幾乎必然是「在等授權對話」,與「security 不存在」是兩種不同處置:
62
+ // 前者要使用者去 Keychain 授權一次,後者是環境壞了。分開回報。
63
+ if (r.error) {
64
+ const timedOut = r.error.code === "ETIMEDOUT";
65
+ return [null, timedOut ? "keychain-needs-authorization" : "keychain-spawn-failed"];
66
+ }
67
+ // 非零通常是「找不到項目」(44) 或「使用者拒絕授權」(128)。分開讓資料看得出哪一種。
68
+ if (r.status !== 0) {
69
+ const code = r.status === 44 ? "no-item" : r.status === 128 ? "denied" : `rc${r.status}`;
70
+ return [null, `keychain-${code}`];
71
+ }
72
+ const pw = (r.stdout ?? "").trim();
73
+ if (!pw)
74
+ return [null, "keychain-empty"];
75
+ try {
76
+ const key = pbkdf2Sync(pw, KDF_SALT, KDF_ITER, KDF_KEYLEN, "sha1");
77
+ return [key, "ok"];
78
+ }
79
+ catch {
80
+ return [null, "kdf-failed"];
81
+ }
82
+ }
83
+ /**
84
+ * 拆 v10 區塊並用 AES-128-CBC 解密。
85
+ * 結構:`"v10"`(3B) │ ciphertext(16 的倍數,含 PKCS7)。IV 固定為 16 個 0x20。
86
+ *
87
+ * 與 Windows 的 decryptV10 分開實作而非共用:那邊是 GCM(有 nonce/tag 切割),
88
+ * 這邊是 CBC(固定 IV、無 tag),共用只會讓兩邊都變成一堆 if。挑選邏輯才是該共用的
89
+ * 部分,那個已經共用(pickEntry)。
90
+ */
91
+ export function decryptDesktopMac(b64, key) {
92
+ let blob;
93
+ try {
94
+ blob = Buffer.from(b64, "base64");
95
+ }
96
+ catch {
97
+ return [null, "cache-bad-base64"];
98
+ }
99
+ if (blob.subarray(0, 3).toString("latin1") !== V10) {
100
+ return [null, `cache-not-v10:${blob.subarray(0, 3).toString("latin1")}`];
101
+ }
102
+ const ct = blob.subarray(3);
103
+ // CBC 密文長度必為 16 的倍數且至少一個區塊。不符 = 資料壞了,別餵給 decipher。
104
+ if (ct.length === 0 || ct.length % 16 !== 0)
105
+ return [null, `cache-bad-ct-len:${ct.length}`];
106
+ try {
107
+ const iv = Buffer.alloc(16, 0x20); // 16 個空白
108
+ const d = createDecipheriv("aes-128-cbc", key, iv);
109
+ // autoPadding 預設 true,Node 會驗並剝 PKCS7;padding 不對會在 final() 拋。
110
+ return [Buffer.concat([d.update(ct), d.final()]).toString("utf8"), "ok"];
111
+ }
112
+ catch {
113
+ // PKCS7 驗證失敗 = 金鑰不對或資料被改過。不要當成「格式變了」。
114
+ return [null, "cache-decrypt-failed"];
115
+ }
116
+ }
117
+ /**
118
+ * macOS 桌面版憑證。回 `[creds, source]`;失敗時 creds 為 null、source 是可辨識原因
119
+ * (會記進 health / cred_source,之後從資料就看得出卡在哪一步)。
120
+ */
121
+ export function fromMacDesktop() {
122
+ if (process.platform !== "darwin")
123
+ return [null, "not-macos"];
124
+ const cfgPath = configPath();
125
+ if (!existsSync(cfgPath))
126
+ return [null, "desktop-no-config"];
127
+ let cfg;
128
+ try {
129
+ cfg = JSON.parse(readFileSync(cfgPath, "utf8"));
130
+ }
131
+ catch {
132
+ return [null, "desktop-config-unreadable"];
133
+ }
134
+ // V2 是新版;舊版鍵名無 V2 後綴。先新後舊,與 Windows 版一致。
135
+ const cached = cfg["oauth:tokenCacheV2"] ?? cfg["oauth:tokenCache"];
136
+ if (typeof cached !== "string" || !cached)
137
+ return [null, "desktop-no-token-cache"];
138
+ const [key, keyWhy] = masterKey();
139
+ if (!key)
140
+ return [null, `desktop-${keyWhy}`];
141
+ const [plain, decWhy] = decryptDesktopMac(cached, key);
142
+ if (!plain)
143
+ return [null, `desktop-${decWhy}`];
144
+ const [creds, pickWhy] = pickEntry(plain); // ★ 與 Windows 共用挑選邏輯
145
+ if (!creds)
146
+ return [null, `desktop-${pickWhy}`];
147
+ return [creds, "desktop-mac"];
148
+ }
package/dist/creds.js CHANGED
@@ -14,6 +14,7 @@ import { userInfo } from "node:os";
14
14
  import { readFileSync } from "node:fs";
15
15
  import { claudeDir, credsPath } from "./paths.js";
16
16
  import { fromWindowsDesktop } from "./creds-win.js";
17
+ import { fromMacDesktop } from "./creds-mac-desktop.js";
17
18
  /** 可區分的結束碼 —— daemon 靠這個把「token 過期」和「網路壞掉」分開記錄。
18
19
  * 過去兩者都是 exit 1,daemon 只能寫 sample-failed,於是 2026-08-22 那次
19
20
  * token 過期靜默死了 17.6 小時沒人發現。 */
@@ -111,7 +112,9 @@ const expired = (o) => Boolean(o.expiresAt && Date.now() >= o.expiresAt);
111
112
  const SOURCES = [
112
113
  fromKeychain, // macOS 主儲存
113
114
  fromFile, // CLI 的檔案(Windows/Linux 是主儲存;mac 上只是過時副本)
114
- fromWindowsDesktop, // Windows 桌面版(MSIX,加密)
115
+ fromWindowsDesktop, // Windows 桌面版(MSIX,DPAPI+AES-GCM)
116
+ fromMacDesktop, // macOS 桌面版(Keychain+AES-CBC)—— 排最後:只有前面都過期/沒有
117
+ // 才需要它,且取 Keychain 主金鑰有成本,不該每次都跑
115
118
  ];
116
119
  export function loadToken() {
117
120
  const why = [];
package/dist/doctor.js CHANGED
@@ -15,6 +15,7 @@ import { homedir, platform, release, userInfo } from "node:os";
15
15
  import { join } from "node:path";
16
16
  import { EXIT, loadToken } from "./creds.js";
17
17
  import { desktopDataDirs } from "./creds-win.js";
18
+ import { fromMacDesktop } from "./creds-mac-desktop.js";
18
19
  import { claudeDir, claudeSettingsPath, configPath, credsPath, devicePath, projectsDir, serverUrl, stateDir, } from "./paths.js";
19
20
  import { healthWarning, loadHealth } from "./health.js";
20
21
  import { readJson } from "./util.js";
@@ -113,6 +114,31 @@ export async function doctor() {
113
114
  out.push(ok(`執行原則 ${policy}(不會擋 .ps1 啟動器)`));
114
115
  }
115
116
  }
117
+ // ── macOS 桌面版(Claude.app)──
118
+ // 與 Windows 段對稱:只用桌面版、沒開過 CLI 的 mac 使用者,Keychain 那份是舊 CLI
119
+ // 殘留、會過期,真正的活 token 在桌面版加密庫裡。把狀態攤開,否則診斷會誤判成
120
+ // 「token 過期,去 /login」——但那治標不治本。
121
+ if (platform() === "darwin") {
122
+ const cfg = join(homedir(), "Library", "Application Support", "Claude", "config.json");
123
+ if (!existsSync(cfg)) {
124
+ out.push(info("桌面版 找不到 config.json(沒裝桌面版就正常)"));
125
+ }
126
+ else {
127
+ const [creds, why] = fromMacDesktop();
128
+ if (creds) {
129
+ out.push(ok(`桌面版 憑證可讀(來源 ${why}, tier=${creds.rateLimitTier})`));
130
+ }
131
+ else if (why === "desktop-keychain-needs-authorization") {
132
+ out.push(bad("桌面版 Keychain 需要授權一次"));
133
+ problems.push("macOS 桌面版憑證庫需要授權:在終端機跑一次 " +
134
+ '`security find-generic-password -w -s "Claude Safe Storage"`,' +
135
+ "跳出對話框時按**一律允許**(Always Allow)。之後背景採樣就讀得到。");
136
+ }
137
+ else {
138
+ out.push(info(`桌面版 ${why}`));
139
+ }
140
+ }
141
+ }
116
142
  // ── Windows 桌面版(MSIX)──
117
143
  // 只用桌面版、沒登入過 CLI 的人,.credentials.json 裡根本不會有 claudeAiOauth。
118
144
  // 這一段把「桌面版憑證庫在哪、有沒有東西」攤開,否則診斷又會退回「猜 + 手寫指令」。
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tsa-group/claude-usage",
3
- "version": "0.4.8",
3
+ "version": "0.4.9",
4
4
  "description": "Per-user Claude usage collector — measures Claude Code token detail and account-level rate-limit utilization locally, reports to your own ingest server.",
5
5
  "type": "module",
6
6
  "bin": {