pi-git-auth 1.0.2 → 1.1.1
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 +32 -4
- package/auth.ts +11 -3
- package/index.ts +20 -0
- package/keyring.ts +94 -27
- package/package.json +1 -1
- package/redact.ts +32 -0
- package/store.ts +54 -15
package/README.md
CHANGED
|
@@ -2,8 +2,8 @@
|
|
|
2
2
|
|
|
3
3
|
A [pi](https://github.com/badlogic/pi-mono) (coding-agent) extension that
|
|
4
4
|
gives the agent **git forges authentication** for **GitHub and GitLab**: login tokens are stored in the
|
|
5
|
-
OS keyring,
|
|
6
|
-
|
|
5
|
+
OS keyring, switch account, and every `git`command
|
|
6
|
+
the agent runs is transparently authenticated with that account's
|
|
7
7
|
token for its host. It also manages accounts and repositories through each
|
|
8
8
|
service's REST API.
|
|
9
9
|
|
|
@@ -36,6 +36,10 @@ TypeScript directly.
|
|
|
36
36
|
- A `tool_call` hook watches every `bash` invocation. When an active
|
|
37
37
|
account exists and the command runs `git`, the command is rewritten to
|
|
38
38
|
authenticate that account's host (see [git auth](#git-auth)).
|
|
39
|
+
- A `tool_result` hook masks token-shaped strings (GitHub/GitLab PATs,
|
|
40
|
+
URL-embedded credentials) in **every** tool output before it enters
|
|
41
|
+
the transcript — an accidental `env` dump or config echo can never
|
|
42
|
+
print the live token into the conversation.
|
|
39
43
|
|
|
40
44
|
## Layout
|
|
41
45
|
|
|
@@ -50,6 +54,7 @@ github.ts GitHub REST client
|
|
|
50
54
|
gitlab.ts GitLab REST client
|
|
51
55
|
details.ts read-only repo details overlay (tree + commits + metadata)
|
|
52
56
|
git-gate.ts command rewriting that injects the token for a host
|
|
57
|
+
redact.ts secret redaction of tool outputs (PAT / URL-credential patterns)
|
|
53
58
|
```
|
|
54
59
|
|
|
55
60
|
## Usage
|
|
@@ -149,6 +154,28 @@ provider.
|
|
|
149
154
|
- `credentials.json` keeps only a `wallet:v1:<accountKey>` marker per
|
|
150
155
|
account; the token itself is in the keyring.
|
|
151
156
|
|
|
157
|
+
#### KDE Plasma (KWallet / ksecretd)
|
|
158
|
+
|
|
159
|
+
`ksecretd` fires a KWallet unlock dialog for **every** D-Bus operation on
|
|
160
|
+
a locked collection, so the client is built to keep wallet access to the
|
|
161
|
+
absolute minimum:
|
|
162
|
+
|
|
163
|
+
- **Lookup never prompts.** A read on a locked collection returns
|
|
164
|
+
`locked` without touching the collection — no stacked prompts, no kded
|
|
165
|
+
"Repeated attempts to access a wallet have occurred" warning.
|
|
166
|
+
- **Store is a single roundtrip** (delete matching items + create the new
|
|
167
|
+
one, at most one unlock attempt) and only runs when a token actually
|
|
168
|
+
changed. `/auth switch`, `status`, and plain git use perform **zero**
|
|
169
|
+
keyring writes.
|
|
170
|
+
- **When the wallet is locked (or the keyring is otherwise unreachable)
|
|
171
|
+
while writing**, the token is transparently kept in the encrypted file
|
|
172
|
+
instead of leaving a dead `wallet:v1:` marker — the token stays
|
|
173
|
+
available. Once the wallet unlocks, the next token change moves it back
|
|
174
|
+
into the keyring.
|
|
175
|
+
- If the wallet is locked when pi starts, the in-memory token is empty for
|
|
176
|
+
that session (git auth is disabled meanwhile) and `/auth status` says so
|
|
177
|
+
explicitly instead of showing a bare `(none)`.
|
|
178
|
+
|
|
152
179
|
### Encrypted file (fallback)
|
|
153
180
|
When python3 / D-Bus / a keyring are unavailable (headless server, no
|
|
154
181
|
session bus), the extension transparently falls back to an on-disk
|
|
@@ -173,8 +200,9 @@ reachable, else file), `wallet`, or `file`.
|
|
|
173
200
|
|
|
174
201
|
- The git gate adds one regex pass per `git` command (same as any string
|
|
175
202
|
rewrite) and no network calls; login adds one TUI prompt.
|
|
176
|
-
- The keyring round-trip is one D-Bus exchange per account
|
|
177
|
-
|
|
203
|
+
- The keyring round-trip is one D-Bus exchange per account at load, and a
|
|
204
|
+
single upsert roundtrip **only for accounts whose token changed** at
|
|
205
|
+
write time (a `/auth switch` writes no keyring at all).
|
|
178
206
|
- GitHub's details view uses a single bounded set of REST calls
|
|
179
207
|
(repo meta + 5 commits + 1 recursive tree). GitLab's tree is top-level
|
|
180
208
|
only (its API does not recurse), bounded to 100 entries.
|
package/auth.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { spawn } from "node:child_process";
|
|
2
|
-
import { loadStore, saveStore, maskToken, activeAccount, accountKey, purgeAccountStorage, storeBackend, type StoreData } from "./store";
|
|
2
|
+
import { loadStore, saveStore, maskToken, activeAccount, accountKey, purgeAccountStorage, storeBackend, keyringUnavailableAtLoadFlag, type StoreData } from "./store";
|
|
3
3
|
import { SERVICES, type Platform, type Service } from "./forge";
|
|
4
4
|
|
|
5
5
|
/** The subset of ctx.ui the auth flows need. */
|
|
@@ -132,13 +132,21 @@ export function statusDetail(data: StoreData): string {
|
|
|
132
132
|
}
|
|
133
133
|
const activeKey = data.activeLogin;
|
|
134
134
|
if (activeKey) lines.push("");
|
|
135
|
-
|
|
135
|
+
const backend = storeBackend() === "keyring" ? "OS keyring (Secret Service)" : "encrypted file";
|
|
136
|
+
const kwLocked = storeBackend() === "keyring" && keyringUnavailableAtLoadFlag();
|
|
137
|
+
lines.push(`Store: ${backend}${kwLocked ? " (locked/unreachable on load)" : ""}`);
|
|
136
138
|
if (activeKey) lines.push("");
|
|
137
139
|
const active = activeKey ? data.accounts[activeKey] : undefined;
|
|
138
140
|
if (active && activeKey) {
|
|
139
141
|
lines.push("");
|
|
140
142
|
lines.push(`Active: @${active.user ?? activeKey.slice(activeKey.indexOf(":") + 1)} (${active.platform})`);
|
|
141
|
-
|
|
143
|
+
if (!active.accessToken && kwLocked) {
|
|
144
|
+
lines.push(
|
|
145
|
+
"Token: (none) — keyring locked: the token is stored in the keyring and is available again once the wallet unlocks (git auth is disabled meanwhile)"
|
|
146
|
+
);
|
|
147
|
+
} else {
|
|
148
|
+
lines.push(`Token: ${maskToken(active.accessToken)}`);
|
|
149
|
+
}
|
|
142
150
|
if (active.scopes) lines.push(`Scopes: ${active.scopes}`);
|
|
143
151
|
lines.push(`Saved: ${active.savedAt}`);
|
|
144
152
|
}
|
package/index.ts
CHANGED
|
@@ -5,6 +5,7 @@ import { loadStore, activeAccount } from "./store";
|
|
|
5
5
|
import { SERVICES } from "./forge";
|
|
6
6
|
import { findAccounts, setActiveAccount, statusDetail } from "./auth";
|
|
7
7
|
import { instrumentGit } from "./git-gate";
|
|
8
|
+
import { redactSecrets } from "./redact";
|
|
8
9
|
import { handleAuthCommand } from "./commands";
|
|
9
10
|
|
|
10
11
|
export default function (pi: ExtensionAPI) {
|
|
@@ -20,6 +21,25 @@ export default function (pi: ExtensionAPI) {
|
|
|
20
21
|
event.input.command = instrumentGit(event.input.command, host, acc.accessToken);
|
|
21
22
|
});
|
|
22
23
|
|
|
24
|
+
// ------------------------------------------------------------------
|
|
25
|
+
// Secret redaction: never let token-shaped strings reach the
|
|
26
|
+
// transcript. The gate keeps the token out of argv and off disk,
|
|
27
|
+
// but any bash output (env dump, config echo) would otherwise land
|
|
28
|
+
// in the session file and the conversation.
|
|
29
|
+
// ------------------------------------------------------------------
|
|
30
|
+
pi.on("tool_result", (event) => {
|
|
31
|
+
let changed = false;
|
|
32
|
+
const content = event.content.map((block) => {
|
|
33
|
+
if (block.type !== "text") return block;
|
|
34
|
+
const text = redactSecrets(block.text);
|
|
35
|
+
if (text === block.text) return block;
|
|
36
|
+
changed = true;
|
|
37
|
+
return { ...block, text };
|
|
38
|
+
});
|
|
39
|
+
if (!changed) return;
|
|
40
|
+
return { content };
|
|
41
|
+
});
|
|
42
|
+
|
|
23
43
|
// ------------------------------------------------------------------
|
|
24
44
|
// /auth command
|
|
25
45
|
// ------------------------------------------------------------------
|
package/keyring.ts
CHANGED
|
@@ -8,11 +8,23 @@
|
|
|
8
8
|
* It talks JSON over stdin/stdout, which means the secret NEVER appears in
|
|
9
9
|
* a process argument list — only in the parent process's memory.
|
|
10
10
|
*
|
|
11
|
+
* KWallet/ksecretd (KDE Plasma) notes:
|
|
12
|
+
* - ksecretd triggers a KWallet unlock dialog for every D-Bus operation
|
|
13
|
+
* on a LOCKED collection. To keep this from stacking prompts (and
|
|
14
|
+
* tripping kded's "Repeated attempts to access a wallet have
|
|
15
|
+
* occurred" warning):
|
|
16
|
+
* * "lookup" on a locked collection returns {"locked": true} and
|
|
17
|
+
* never touches the collection;
|
|
18
|
+
* * "upsert" does delete+create in ONE D-Bus roundtrip with at most
|
|
19
|
+
* ONE explicit unlock attempt (only when a non-empty secret is
|
|
20
|
+
* actually being written);
|
|
21
|
+
* * delete-only ("clear" / empty secret) never unlocks.
|
|
22
|
+
*
|
|
11
23
|
* Two API generations are auto-detected at runtime by introspection:
|
|
12
24
|
* - modern 0.0.1 (gnome-keyring, kwallet --secretservice):
|
|
13
25
|
* Service.Store / SearchItems / item.GetSecret
|
|
14
26
|
* - legacy 0.0.0 (KDE ksecretd, default with KWallet 6):
|
|
15
|
-
* Collection.CreateItem /
|
|
27
|
+
* Collection.CreateItem / SearchItems / Service.GetSecrets
|
|
16
28
|
*
|
|
17
29
|
* Fallback: when python3/dbus/keyring are unavailable (headless, no D-Bus),
|
|
18
30
|
* store.ts silently keeps the on-disk AES-encrypted file format.
|
|
@@ -26,10 +38,14 @@ export const STATE_DIR = join(homedir(), ".pi", "agent", "pi-git-auth");
|
|
|
26
38
|
const PY_PATH = join(STATE_DIR, "wallet-tool.py");
|
|
27
39
|
const TIMEOUT_MS = 8000;
|
|
28
40
|
|
|
41
|
+
/** Outcome of a keyring write: ok, keyring locked, or unreachable. */
|
|
42
|
+
export type WalletResult = "ok" | "locked" | "unreachable";
|
|
43
|
+
|
|
29
44
|
interface WalletRes {
|
|
30
45
|
ok: boolean;
|
|
31
46
|
secret?: string;
|
|
32
47
|
api?: string;
|
|
48
|
+
locked?: boolean;
|
|
33
49
|
error?: string;
|
|
34
50
|
}
|
|
35
51
|
|
|
@@ -38,12 +54,22 @@ const PY = `#!/usr/bin/env python3
|
|
|
38
54
|
|
|
39
55
|
Protocol: one JSON request on stdin, one JSON response line on stdout.
|
|
40
56
|
{"cmd": "available"}
|
|
41
|
-
{"cmd": "
|
|
57
|
+
{"cmd": "upsert", "attrs": {...}, "secret": "..."} # empty secret = delete only
|
|
42
58
|
{"cmd": "lookup", "attrs": {...}}
|
|
43
59
|
{"cmd": "clear", "attrs": {...}}
|
|
44
60
|
|
|
45
61
|
Auto-detects the Secret Service API generation (modern 0.0.1 vs legacy
|
|
46
62
|
0.0.0/ksecretd) by introspecting the service.
|
|
63
|
+
|
|
64
|
+
KWallet/ksecretd (KDE) note: every D-Bus operation on a LOCKED collection
|
|
65
|
+
triggers a KWallet unlock dialog. So:
|
|
66
|
+
- "lookup" on a locked collection returns {"locked": true} and never
|
|
67
|
+
touches the collection;
|
|
68
|
+
- "upsert" does delete+create in ONE roundtrip with at most ONE explicit
|
|
69
|
+
unlock attempt, and only when a non-empty secret is written;
|
|
70
|
+
- delete-only never unlocks.
|
|
71
|
+
This keeps the client from stacking unlock prompts, which is what makes
|
|
72
|
+
kded warn "Repeated attempts to access a wallet have occurred".
|
|
47
73
|
"""
|
|
48
74
|
import sys
|
|
49
75
|
import json
|
|
@@ -114,12 +140,12 @@ def main():
|
|
|
114
140
|
if str(coll) == "/":
|
|
115
141
|
out({"ok": False, "error": "no default collection in keyring"})
|
|
116
142
|
return
|
|
117
|
-
try:
|
|
118
|
-
dbusi.Unlock([coll])
|
|
119
|
-
except Exception:
|
|
120
|
-
pass
|
|
121
143
|
|
|
122
144
|
def find_items():
|
|
145
|
+
"""Return (item paths in unlocked collections, locked flag).
|
|
146
|
+
legacy ksecretd reports items in LOCKED collections separately;
|
|
147
|
+
touching them would fire KWallet unlock dialogs, so callers
|
|
148
|
+
get the flag instead."""
|
|
123
149
|
if MODERN:
|
|
124
150
|
res = dbusi.SearchItems(
|
|
125
151
|
dbus.UInt32(0),
|
|
@@ -128,11 +154,11 @@ def main():
|
|
|
128
154
|
),
|
|
129
155
|
dbus.ObjectPath("/"),
|
|
130
156
|
)
|
|
131
|
-
return [str(k) for k in res]
|
|
132
|
-
(u,
|
|
157
|
+
return [str(k) for k in res], False
|
|
158
|
+
(u, locked) = dbusi.SearchItems(dbus.Dictionary(
|
|
133
159
|
{k: v for k, v in attrs.items()}, "ss"
|
|
134
160
|
))
|
|
135
|
-
return [str(k) for k in list(u)
|
|
161
|
+
return [str(k) for k in list(u)], bool(locked)
|
|
136
162
|
|
|
137
163
|
def get_content(path):
|
|
138
164
|
"""Return the secret bytes for an item path, or None."""
|
|
@@ -171,7 +197,32 @@ def main():
|
|
|
171
197
|
except Exception:
|
|
172
198
|
pass
|
|
173
199
|
|
|
174
|
-
if cmd
|
|
200
|
+
if cmd in ("upsert", "store", "clear"):
|
|
201
|
+
paths, is_locked = find_items()
|
|
202
|
+
writing = cmd != "clear" and bool(secret)
|
|
203
|
+
if is_locked:
|
|
204
|
+
if not writing:
|
|
205
|
+
# delete-only on a locked keyring: skip it rather than
|
|
206
|
+
# prompt (best-effort purge; the file is already clean).
|
|
207
|
+
out({"ok": False, "locked": True,
|
|
208
|
+
"error": "keyring is locked"})
|
|
209
|
+
return
|
|
210
|
+
# writing while locked: exactly ONE unlock attempt (one
|
|
211
|
+
# prompt), then re-check.
|
|
212
|
+
try:
|
|
213
|
+
dbusi.Unlock([coll])
|
|
214
|
+
except Exception:
|
|
215
|
+
pass
|
|
216
|
+
paths, is_locked = find_items()
|
|
217
|
+
if is_locked:
|
|
218
|
+
out({"ok": False, "locked": True,
|
|
219
|
+
"error": "keyring is locked"})
|
|
220
|
+
return
|
|
221
|
+
for path in paths:
|
|
222
|
+
item_delete(path)
|
|
223
|
+
if cmd == "clear" or not secret:
|
|
224
|
+
out({"ok": True})
|
|
225
|
+
return
|
|
175
226
|
if MODERN:
|
|
176
227
|
item = "/org/freedesktop/secrets/0/item/" + re.sub(
|
|
177
228
|
r"[^A-Za-z0-9_]", "_", "%s_%s" % (
|
|
@@ -213,7 +264,7 @@ def main():
|
|
|
213
264
|
)
|
|
214
265
|
else:
|
|
215
266
|
coll_obj = dbus.Interface(
|
|
216
|
-
bus.get_object(owner, str(
|
|
267
|
+
bus.get_object(owner, str(coll)),
|
|
217
268
|
"org.freedesktop.Secret.Collection",
|
|
218
269
|
)
|
|
219
270
|
secret_arg = dbus.Struct((
|
|
@@ -236,7 +287,8 @@ def main():
|
|
|
236
287
|
return
|
|
237
288
|
|
|
238
289
|
if cmd == "lookup":
|
|
239
|
-
|
|
290
|
+
paths, is_locked = find_items()
|
|
291
|
+
for path in paths:
|
|
240
292
|
content = get_content(path)
|
|
241
293
|
if content:
|
|
242
294
|
out({
|
|
@@ -244,15 +296,15 @@ def main():
|
|
|
244
296
|
"secret": content.decode("utf-8", "replace"),
|
|
245
297
|
})
|
|
246
298
|
return
|
|
299
|
+
if is_locked:
|
|
300
|
+
# Item exists but its collection is locked (KWallet):
|
|
301
|
+
# report it, don't touch the collection (no unlock prompt).
|
|
302
|
+
out({"ok": False, "locked": True,
|
|
303
|
+
"error": "keyring is locked"})
|
|
304
|
+
return
|
|
247
305
|
out({"ok": False, "error": "item not found"})
|
|
248
306
|
return
|
|
249
307
|
|
|
250
|
-
if cmd == "clear":
|
|
251
|
-
for path in find_items():
|
|
252
|
-
item_delete(path)
|
|
253
|
-
out({"ok": True})
|
|
254
|
-
return
|
|
255
|
-
|
|
256
308
|
out({"ok": False, "error": "unknown command"})
|
|
257
309
|
except Exception as e:
|
|
258
310
|
msg = str(e)
|
|
@@ -319,20 +371,35 @@ export function walletAvailable(): boolean {
|
|
|
319
371
|
return availCache;
|
|
320
372
|
}
|
|
321
373
|
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
374
|
+
let lastLookupLocked = false;
|
|
375
|
+
|
|
376
|
+
/**
|
|
377
|
+
* Upsert a secret for the given attrs in ONE D-Bus roundtrip: existing
|
|
378
|
+
* matching items are deleted, then (when secret is non-empty) the new item
|
|
379
|
+
* is created. An empty secret deletes only (best-effort, never prompts).
|
|
380
|
+
* "locked" = the keyring exists but is locked (KWallet): the token must be
|
|
381
|
+
* kept in the file fallback; "unreachable" = no keyring/D-Bus at all.
|
|
382
|
+
*/
|
|
383
|
+
export function walletUpsert(attrs: Record<string, string>, secret: string): WalletResult {
|
|
384
|
+
const r = call({ cmd: "upsert", attrs, secret });
|
|
385
|
+
if (!r) return "unreachable";
|
|
386
|
+
if (r.ok) return "ok";
|
|
387
|
+
if (r.locked) return "locked";
|
|
388
|
+
return "unreachable";
|
|
326
389
|
}
|
|
327
390
|
|
|
328
|
-
/**
|
|
391
|
+
/**
|
|
392
|
+
* Read a secret; null when not found, the keyring is locked, or the
|
|
393
|
+
* keyring is unreachable. See walletWasLocked() to distinguish a locked
|
|
394
|
+
* keyring from a missing item.
|
|
395
|
+
*/
|
|
329
396
|
export function walletLookup(attrs: Record<string, string>): string | null {
|
|
330
397
|
const r = call({ cmd: "lookup", attrs });
|
|
398
|
+
lastLookupLocked = !!(r && r.locked);
|
|
331
399
|
return r && r.ok ? (r.secret ?? null) : null;
|
|
332
400
|
}
|
|
333
401
|
|
|
334
|
-
/**
|
|
335
|
-
export function
|
|
336
|
-
|
|
337
|
-
return !!(r && r.ok);
|
|
402
|
+
/** True when the last lookup failed because the keyring is locked. */
|
|
403
|
+
export function walletWasLocked(): boolean {
|
|
404
|
+
return lastLookupLocked;
|
|
338
405
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "pi-git-auth",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.1.1",
|
|
4
4
|
"description": "pi coding-agent extension: git auth for GitHub and GitLab: keyring-stored login tokens, account switching, transparent git auth, repo list/create with details overlay",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"author": "Carlo Onofrio",
|
package/redact.ts
ADDED
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Secret redaction for tool results.
|
|
3
|
+
*
|
|
4
|
+
* The git gate keeps the active token out of argv and off disk, but a
|
|
5
|
+
* bash command (an `env` dump, a `git config` echo, a `printenv`) can
|
|
6
|
+
* still print it into the tool output, which then lands in the session
|
|
7
|
+
* transcript. This module masks token-shaped strings in tool results
|
|
8
|
+
* before they are stored, so an accidental echo can never put the live
|
|
9
|
+
* token into the conversation — and there is nothing to rotate because
|
|
10
|
+
* of it.
|
|
11
|
+
*
|
|
12
|
+
* Patterns (masked to a short fingerprint, e.g. `ghp_…7SAQ`):
|
|
13
|
+
* - GitHub classic PATs: gh[pousr]_ + 20+ base62
|
|
14
|
+
* - GitHub fine-grained PATs: github_pat_ + 30+
|
|
15
|
+
* - GitLab PATs: glpat- + 16+
|
|
16
|
+
* - URL-embedded credentials: user:secret@ — the secret part only
|
|
17
|
+
*
|
|
18
|
+
* Masking is idempotent: a masked string never matches the patterns
|
|
19
|
+
* again, so repeated passes are a no-op.
|
|
20
|
+
*/
|
|
21
|
+
|
|
22
|
+
function maskTail(match: string, head: number): string {
|
|
23
|
+
return match.length <= head + 4 ? "***" : `${match.slice(0, head)}…${match.slice(-4)}`;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export function redactSecrets(text: string): string {
|
|
27
|
+
return text
|
|
28
|
+
.replace(/\bgh[pousr]_[A-Za-z0-9]{20,}/g, (m) => maskTail(m, 4))
|
|
29
|
+
.replace(/\bgithub_pat_[A-Za-z0-9_]{30,}/g, (m) => maskTail(m, 10))
|
|
30
|
+
.replace(/\bglpat-[A-Za-z0-9_-]{16,}/g, (m) => maskTail(m, 6))
|
|
31
|
+
.replace(/(\/\/[^/\s:@]+:)([^@\s]{4,})(@)/g, "$1***$3");
|
|
32
|
+
}
|
package/store.ts
CHANGED
|
@@ -3,7 +3,7 @@ import { homedir } from "node:os";
|
|
|
3
3
|
import { dirname, join } from "node:path";
|
|
4
4
|
import { createCipheriv, createDecipheriv, randomBytes } from "node:crypto";
|
|
5
5
|
import type { Platform } from "./forge";
|
|
6
|
-
import { STATE_DIR, walletAvailable,
|
|
6
|
+
import { STATE_DIR, walletAvailable, walletUpsert, walletLookup, walletAttrs } from "./keyring";
|
|
7
7
|
|
|
8
8
|
/**
|
|
9
9
|
* Credential persistence for pi-git-auth (multi-account, multi-service).
|
|
@@ -67,6 +67,12 @@ export interface StoreData {
|
|
|
67
67
|
|
|
68
68
|
let cache: StoreData | null = null;
|
|
69
69
|
let keyCache: Buffer | null = null;
|
|
70
|
+
/** Stored strings (markers/envelopes) from the last file read/write. */
|
|
71
|
+
let diskData: StoreData | null = null;
|
|
72
|
+
/** Plaintext tokens as last persisted, per account key ("" = unreadable). */
|
|
73
|
+
let storedPlaintext: Record<string, string> = {};
|
|
74
|
+
/** A `wallet:v1:` token could not be read on load (keyring locked/absent). */
|
|
75
|
+
let keyringUnavailableAtLoad = false;
|
|
70
76
|
|
|
71
77
|
const ENC_PREFIX = "enc:v1:";
|
|
72
78
|
const WALLET_PREFIX = "wallet:v1:";
|
|
@@ -163,22 +169,35 @@ function decryptToken(enc: string): string {
|
|
|
163
169
|
// Store API
|
|
164
170
|
// ---------------------------------------------------------------------------
|
|
165
171
|
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
172
|
+
/**
|
|
173
|
+
* Persist the in-memory (plaintext) state. Only accounts whose token
|
|
174
|
+
* changed since the last persist are (re)written to the backend; the
|
|
175
|
+
* others keep their on-disk marker/envelope untouched. This matters on
|
|
176
|
+
* KDE/ksecretd: a keyring write is the only operation that may trigger
|
|
177
|
+
* the KWallet unlock dialog, so e.g. `/auth switch` performs zero
|
|
178
|
+
* keyring writes (no prompts, no "repeated wallet access" warnings).
|
|
179
|
+
* When the keyring is locked/unreachable the token is kept in the
|
|
180
|
+
* encrypted file instead of leaving a dead `wallet:v1:` marker behind.
|
|
181
|
+
*/
|
|
182
|
+
function persist(data: StoreData, forceStore = false): void {
|
|
169
183
|
const mode = storeMode();
|
|
170
184
|
const accounts: Record<string, AccountRecord> = {};
|
|
185
|
+
const nowPlaintext: Record<string, string> = {};
|
|
171
186
|
for (const [k, a] of Object.entries(data.accounts)) {
|
|
187
|
+
const changed = forceStore || storedPlaintext[k] !== a.accessToken;
|
|
188
|
+
const diskStored = diskData?.accounts[k]?.accessToken;
|
|
172
189
|
let stored: string;
|
|
173
|
-
if (
|
|
174
|
-
//
|
|
175
|
-
const
|
|
176
|
-
|
|
177
|
-
|
|
190
|
+
if (changed && a.accessToken) {
|
|
191
|
+
// Single keyring roundtrip (delete matching items + create).
|
|
192
|
+
const r = mode === "wallet" ? walletUpsert(recAttrs(a, k), a.accessToken) : null;
|
|
193
|
+
stored = r === "ok" ? WALLET_PREFIX + k : encryptToken(a.accessToken);
|
|
194
|
+
} else if (diskStored) {
|
|
195
|
+
stored = diskStored; // unchanged: keep the existing marker/envelope
|
|
178
196
|
} else {
|
|
179
|
-
stored = encryptToken(a.accessToken);
|
|
197
|
+
stored = a.accessToken ? encryptToken(a.accessToken) : "";
|
|
180
198
|
}
|
|
181
199
|
accounts[k] = { ...a, accessToken: stored };
|
|
200
|
+
nowPlaintext[k] = a.accessToken;
|
|
182
201
|
}
|
|
183
202
|
const out: StoreData = {
|
|
184
203
|
accounts,
|
|
@@ -189,6 +208,9 @@ function persist(data: StoreData): void {
|
|
|
189
208
|
writeFileSync(tmp, JSON.stringify(out, null, 2) + "\n", { mode: 0o600 });
|
|
190
209
|
renameSync(tmp, CREDENTIALS_FILE);
|
|
191
210
|
chmodSync(CREDENTIALS_FILE, 0o600);
|
|
211
|
+
diskData = out;
|
|
212
|
+
storedPlaintext = nowPlaintext;
|
|
213
|
+
cache = data;
|
|
192
214
|
}
|
|
193
215
|
|
|
194
216
|
export function loadStore(): StoreData {
|
|
@@ -201,6 +223,11 @@ export function loadStore(): StoreData {
|
|
|
201
223
|
} catch {
|
|
202
224
|
raw = {};
|
|
203
225
|
}
|
|
226
|
+
// Deep snapshot BEFORE absorb() mutates records in place — diskData must
|
|
227
|
+
// keep the on-disk stored strings (markers/envelopes), never the
|
|
228
|
+
// plaintext tokens that absorb decrypts into the same objects.
|
|
229
|
+
diskData = raw.accounts && typeof raw.accounts === "object" ? JSON.parse(JSON.stringify(raw)) : null;
|
|
230
|
+
keyringUnavailableAtLoad = false;
|
|
204
231
|
const data: StoreData = { accounts: {} };
|
|
205
232
|
let migrated = false;
|
|
206
233
|
|
|
@@ -209,7 +236,10 @@ export function loadStore(): StoreData {
|
|
|
209
236
|
if (rec.accessToken.startsWith(WALLET_PREFIX)) {
|
|
210
237
|
const got = walletLookup(recAttrs(rec, key));
|
|
211
238
|
if (got === null) {
|
|
212
|
-
|
|
239
|
+
// keyring locked/cleared: keep the marker on disk, run this
|
|
240
|
+
// process without the token, and flag it for the status output.
|
|
241
|
+
rec.accessToken = "";
|
|
242
|
+
keyringUnavailableAtLoad = true;
|
|
213
243
|
} else {
|
|
214
244
|
rec.accessToken = got;
|
|
215
245
|
}
|
|
@@ -229,6 +259,7 @@ export function loadStore(): StoreData {
|
|
|
229
259
|
migrated = true;
|
|
230
260
|
}
|
|
231
261
|
data.accounts[key] = rec as AccountRecord;
|
|
262
|
+
storedPlaintext[key] = (rec as AccountRecord).accessToken;
|
|
232
263
|
};
|
|
233
264
|
|
|
234
265
|
if (raw.accounts && typeof raw.accounts === "object") {
|
|
@@ -253,7 +284,7 @@ export function loadStore(): StoreData {
|
|
|
253
284
|
// Keep a rollback copy of the pre-migration file (still 0600, no
|
|
254
285
|
// new secrets — it only contains ciphertexts/markers).
|
|
255
286
|
if (hadFile && existsSync(CREDENTIALS_FILE)) copyFileSync(CREDENTIALS_FILE, CREDENTIALS_FILE + ".bak");
|
|
256
|
-
persist(cache);
|
|
287
|
+
persist(cache, true); // force re-store under the current backend
|
|
257
288
|
} catch {
|
|
258
289
|
/* best-effort migration */
|
|
259
290
|
}
|
|
@@ -262,14 +293,20 @@ export function loadStore(): StoreData {
|
|
|
262
293
|
}
|
|
263
294
|
|
|
264
295
|
export function saveStore(data: StoreData): void {
|
|
265
|
-
cache = data;
|
|
266
296
|
persist(data);
|
|
267
297
|
}
|
|
268
298
|
|
|
299
|
+
/** True when a `wallet:v1:` token could not be read on load (keyring
|
|
300
|
+
* locked or unreachable) — the in-memory token for that account is "". */
|
|
301
|
+
export function keyringUnavailableAtLoadFlag(): boolean {
|
|
302
|
+
return keyringUnavailableAtLoad;
|
|
303
|
+
}
|
|
304
|
+
|
|
269
305
|
/** Remove one account's keyring item (idempotent, best-effort). */
|
|
270
306
|
export function purgeAccountStorage(key: string, rec?: AccountRecord | null): void {
|
|
271
307
|
try {
|
|
272
|
-
|
|
308
|
+
// Empty secret = delete-only: best-effort, never triggers a prompt.
|
|
309
|
+
if (rec) walletUpsert(recAttrs(rec, key), "");
|
|
273
310
|
} catch {
|
|
274
311
|
/* best-effort */
|
|
275
312
|
}
|
|
@@ -280,13 +317,15 @@ export function clearStore(): void {
|
|
|
280
317
|
try {
|
|
281
318
|
if (cache) {
|
|
282
319
|
for (const [k, rec] of Object.entries(cache.accounts)) {
|
|
283
|
-
|
|
320
|
+
walletUpsert(recAttrs(rec, k), "");
|
|
284
321
|
}
|
|
285
322
|
}
|
|
286
323
|
} catch {
|
|
287
324
|
/* best-effort */
|
|
288
325
|
}
|
|
289
326
|
cache = { accounts: {} };
|
|
327
|
+
diskData = null;
|
|
328
|
+
storedPlaintext = {};
|
|
290
329
|
try {
|
|
291
330
|
rmSync(CREDENTIALS_FILE);
|
|
292
331
|
} catch {
|