pi-git-auth 1.0.0

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/store.ts ADDED
@@ -0,0 +1,302 @@
1
+ import { readFileSync, writeFileSync, mkdirSync, rmSync, renameSync, chmodSync, copyFileSync, existsSync } from "node:fs";
2
+ import { homedir } from "node:os";
3
+ import { dirname, join } from "node:path";
4
+ import { createCipheriv, createDecipheriv, randomBytes } from "node:crypto";
5
+ import type { Platform } from "./forge";
6
+ import { STATE_DIR, walletAvailable, walletStore, walletLookup, walletClear, walletAttrs } from "./keyring";
7
+
8
+ /**
9
+ * Credential persistence for pi-git-auth (multi-account, multi-service).
10
+ *
11
+ * Tokens are NEVER stored plaintext on disk. Two backends:
12
+ *
13
+ * wallet (preferred, auto-detected): the token lives in the OS keyring
14
+ * (Secret Service: KWallet / GNOME Keyring / …). The file holds only
15
+ * a `wallet:v1:<accountKey>` marker. No key file, no disk ciphertext.
16
+ *
17
+ * file (fallback, e.g. headless without D-Bus): credentials.json (0600)
18
+ * holds each token as an `enc:v1:<base64>` AES-256-GCM envelope; the
19
+ * key lives in a separate 0600 file (key) generated on first use.
20
+ *
21
+ * Migration is transparent: legacy plaintext or `enc:v1:` tokens are moved
22
+ * into the keyring on first load (a `.bak` copy of the file is kept).
23
+ *
24
+ * Env override: PI_GIT_AUTH_STORE = auto (default) | wallet | file
25
+ *
26
+ * In memory (and everywhere loadStore() is used) tokens are plaintext.
27
+ *
28
+ * Accounts are keyed by `<platform>:<login>` so the same login can exist
29
+ * on GitHub and GitLab. One account is "active": it is used for git auth
30
+ * and by the /auth actions and LLM tool, and every action dispatches to
31
+ * the REST API of that account's platform.
32
+ *
33
+ * File migrations (all transparent on load):
34
+ * v0: { auth: AccountRecord } → github account
35
+ * v1: { accounts: { <login>: … }, activeLogin } → github keys
36
+ * v2: { accounts: { "<platform>:<login>": … }, activeLogin }
37
+ * v3: accessToken is a `wallet:v1:` marker (keyring) or `enc:v1:` (file)
38
+ */
39
+
40
+ const CREDENTIALS_FILE = join(STATE_DIR, "credentials.json");
41
+ const KEY_FILE = join(STATE_DIR, "key");
42
+
43
+ export interface AccountRecord {
44
+ /** How the token was obtained. */
45
+ type: "pat";
46
+ /** Which service the account belongs to. */
47
+ platform: Platform;
48
+ /**
49
+ * On disk: a `wallet:v1:<accountKey>` marker (keyring backend) or an
50
+ * `enc:v1:<base64>` AES-256-GCM envelope (file backend).
51
+ * In memory (loadStore result): the plaintext token.
52
+ */
53
+ accessToken: string;
54
+ /** Resolved login (original casing). */
55
+ user?: string;
56
+ /** Scopes reported by the service, best-effort. */
57
+ scopes?: string;
58
+ savedAt: string;
59
+ }
60
+
61
+ export interface StoreData {
62
+ /** Accounts keyed by accountKey(platform, login). */
63
+ accounts: Record<string, AccountRecord>;
64
+ /** Key of the active account. */
65
+ activeLogin?: string;
66
+ }
67
+
68
+ let cache: StoreData | null = null;
69
+ let keyCache: Buffer | null = null;
70
+
71
+ const ENC_PREFIX = "enc:v1:";
72
+ const WALLET_PREFIX = "wallet:v1:";
73
+
74
+ function isEncrypted(s: string): boolean {
75
+ return s.startsWith(ENC_PREFIX);
76
+ }
77
+
78
+ /** Canonical account key: `platform:login` (login trimmed, lowercased). */
79
+ export function accountKey(platform: Platform, login: string): string {
80
+ return `${platform}:${login.trim().toLowerCase()}`;
81
+ }
82
+
83
+ /** The active account record, or undefined when none is active. */
84
+ export function activeAccount(data: StoreData): AccountRecord | undefined {
85
+ return data.activeLogin ? data.accounts[data.activeLogin] : undefined;
86
+ }
87
+
88
+ // ---------------------------------------------------------------------------
89
+ // Backend selection
90
+ // ---------------------------------------------------------------------------
91
+
92
+ /** Resolved storage backend. PI_GIT_AUTH_STORE forces one; "auto" probes
93
+ * the keyring once per process. */
94
+ function storeMode(): "wallet" | "file" {
95
+ const env = (process.env.PI_GIT_AUTH_STORE ?? "auto").toLowerCase();
96
+ if (env === "file") return "file";
97
+ if (env === "wallet") return "wallet";
98
+ return walletAvailable() ? "wallet" : "file";
99
+ }
100
+
101
+ /** Human-readable backend name for status output. */
102
+ export function storeBackend(): "keyring" | "file" {
103
+ return storeMode() === "wallet" ? "keyring" : "file";
104
+ }
105
+
106
+ /** Keyring attrs for an account record. */
107
+ function recAttrs(rec: Pick<AccountRecord, "platform" | "user">, key: string): Record<string, string> {
108
+ const login = rec.user ?? key.slice(key.indexOf(":") + 1);
109
+ return walletAttrs(rec.platform, login);
110
+ }
111
+
112
+ // ---------------------------------------------------------------------------
113
+ // Key management (file backend only)
114
+ // ---------------------------------------------------------------------------
115
+
116
+ function getKey(): Buffer {
117
+ if (keyCache) return keyCache;
118
+ try {
119
+ const b = Buffer.from(readFileSync(KEY_FILE, "utf8").trim(), "base64");
120
+ if (b.length === 32) {
121
+ keyCache = b;
122
+ return keyCache;
123
+ }
124
+ } catch {
125
+ /* no key yet */
126
+ }
127
+ const k = randomBytes(32);
128
+ keyCache = k;
129
+ mkdirSync(STATE_DIR, { recursive: true, mode: 0o700 });
130
+ const tmp = KEY_FILE + ".tmp";
131
+ writeFileSync(tmp, k.toString("base64"), { mode: 0o600 });
132
+ renameSync(tmp, KEY_FILE);
133
+ chmodSync(KEY_FILE, 0o600);
134
+ return k;
135
+ }
136
+
137
+ // ---------------------------------------------------------------------------
138
+ // Crypto (AES-256-GCM, file backend)
139
+ // ---------------------------------------------------------------------------
140
+
141
+ function encryptToken(plain: string): string {
142
+ const key = getKey();
143
+ const iv = randomBytes(12);
144
+ const cipher = createCipheriv("aes-256-gcm", key, iv);
145
+ const ct = Buffer.concat([cipher.update(plain, "utf8"), cipher.final()]);
146
+ const tag = cipher.getAuthTag(); // 16 bytes
147
+ return ENC_PREFIX + Buffer.concat([iv, tag, ct]).toString("base64");
148
+ }
149
+
150
+ function decryptToken(enc: string): string {
151
+ const key = getKey();
152
+ const raw = Buffer.from(enc.slice(ENC_PREFIX.length), "base64");
153
+ const iv = raw.subarray(0, 12);
154
+ const tag = raw.subarray(12, 28);
155
+ const ct = raw.subarray(28);
156
+ const decipher = createDecipheriv("aes-256-gcm", key, iv);
157
+ decipher.setAuthTag(tag);
158
+ const out = Buffer.concat([decipher.update(ct), decipher.final()]);
159
+ return out.toString("utf8");
160
+ }
161
+
162
+ // ---------------------------------------------------------------------------
163
+ // Store API
164
+ // ---------------------------------------------------------------------------
165
+
166
+ function persist(data: StoreData): void {
167
+ // The in-memory copy holds plaintext; for each account the token is
168
+ // stored in the backend and the file keeps only a marker/envelope.
169
+ const mode = storeMode();
170
+ const accounts: Record<string, AccountRecord> = {};
171
+ for (const [k, a] of Object.entries(data.accounts)) {
172
+ let stored: string;
173
+ if (mode === "wallet") {
174
+ // Clear-then-store keeps the keyring free of duplicate items.
175
+ const attrs = recAttrs(a, k);
176
+ walletClear(attrs);
177
+ stored = walletStore(attrs, a.accessToken) ? WALLET_PREFIX + k : encryptToken(a.accessToken);
178
+ } else {
179
+ stored = encryptToken(a.accessToken);
180
+ }
181
+ accounts[k] = { ...a, accessToken: stored };
182
+ }
183
+ const out: StoreData = {
184
+ accounts,
185
+ ...(data.activeLogin ? { activeLogin: data.activeLogin } : {}),
186
+ };
187
+ mkdirSync(dirname(CREDENTIALS_FILE), { recursive: true, mode: 0o700 });
188
+ const tmp = CREDENTIALS_FILE + ".tmp";
189
+ writeFileSync(tmp, JSON.stringify(out, null, 2) + "\n", { mode: 0o600 });
190
+ renameSync(tmp, CREDENTIALS_FILE);
191
+ chmodSync(CREDENTIALS_FILE, 0o600);
192
+ }
193
+
194
+ export function loadStore(): StoreData {
195
+ if (cache) return cache;
196
+ let raw: any = {};
197
+ let hadFile = false;
198
+ try {
199
+ raw = JSON.parse(readFileSync(CREDENTIALS_FILE, "utf8"));
200
+ hadFile = true;
201
+ } catch {
202
+ raw = {};
203
+ }
204
+ const data: StoreData = { accounts: {} };
205
+ let migrated = false;
206
+
207
+ const absorb = (rec: any, key: string) => {
208
+ if (!rec?.accessToken) return;
209
+ if (rec.accessToken.startsWith(WALLET_PREFIX)) {
210
+ const got = walletLookup(recAttrs(rec, key));
211
+ if (got === null) {
212
+ rec.accessToken = ""; // keyring unreachable/cleared: don't crash, don't leak
213
+ } else {
214
+ rec.accessToken = got;
215
+ }
216
+ } else if (isEncrypted(rec.accessToken)) {
217
+ try {
218
+ rec.accessToken = decryptToken(rec.accessToken);
219
+ } catch {
220
+ // Key rotated/corrupt: don't crash, and don't expose a broken token.
221
+ rec.accessToken = "";
222
+ }
223
+ migrated = true; // legacy file format — re-stored per current backend
224
+ } else {
225
+ migrated = true; // legacy plaintext
226
+ }
227
+ if (!rec.platform) {
228
+ rec.platform = "github"; // pre-v2 records were GitHub-only
229
+ migrated = true;
230
+ }
231
+ data.accounts[key] = rec as AccountRecord;
232
+ };
233
+
234
+ if (raw.accounts && typeof raw.accounts === "object") {
235
+ for (const [k, a] of Object.entries<any>(raw.accounts)) {
236
+ // v1 keys have no platform prefix; v2 keys are `platform:login`.
237
+ absorb(a, k.includes(":") ? k : accountKey("github", k));
238
+ }
239
+ if (raw.activeLogin) {
240
+ const migratedKey = raw.activeLogin.includes(":") ? raw.activeLogin : accountKey("github", raw.activeLogin);
241
+ if (data.accounts[migratedKey]) data.activeLogin = migratedKey;
242
+ }
243
+ } else if (raw.auth?.accessToken) {
244
+ // v0: { auth: AccountRecord }
245
+ const key = accountKey("github", raw.auth.user ?? "account");
246
+ absorb(raw.auth, key);
247
+ data.activeLogin = key;
248
+ }
249
+
250
+ cache = data;
251
+ if (migrated) {
252
+ try {
253
+ // Keep a rollback copy of the pre-migration file (still 0600, no
254
+ // new secrets — it only contains ciphertexts/markers).
255
+ if (hadFile && existsSync(CREDENTIALS_FILE)) copyFileSync(CREDENTIALS_FILE, CREDENTIALS_FILE + ".bak");
256
+ persist(cache);
257
+ } catch {
258
+ /* best-effort migration */
259
+ }
260
+ }
261
+ return cache;
262
+ }
263
+
264
+ export function saveStore(data: StoreData): void {
265
+ cache = data;
266
+ persist(data);
267
+ }
268
+
269
+ /** Remove one account's keyring item (idempotent, best-effort). */
270
+ export function purgeAccountStorage(key: string, rec?: AccountRecord | null): void {
271
+ try {
272
+ if (rec) walletClear(recAttrs(rec, key));
273
+ } catch {
274
+ /* best-effort */
275
+ }
276
+ }
277
+
278
+ export function clearStore(): void {
279
+ // Purge keyring items for every known account before wiping the file.
280
+ try {
281
+ if (cache) {
282
+ for (const [k, rec] of Object.entries(cache.accounts)) {
283
+ walletClear(recAttrs(rec, k));
284
+ }
285
+ }
286
+ } catch {
287
+ /* best-effort */
288
+ }
289
+ cache = { accounts: {} };
290
+ try {
291
+ rmSync(CREDENTIALS_FILE);
292
+ } catch {
293
+ /* ignore */
294
+ }
295
+ }
296
+
297
+ /** Masked token for display: ghp_…Qw9 */
298
+ export function maskToken(token: string | undefined): string {
299
+ if (!token) return "(none)";
300
+ if (token.length <= 8) return "****";
301
+ return `${token.slice(0, 4)}…${token.slice(-4)}`;
302
+ }