cc-viewer 1.8.18 → 1.8.20
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 +3 -1
- package/dist/assets/App-CesjoFSv.js +2 -0
- package/dist/assets/{MdxEditorPanel-B2WzCeBQ.js → MdxEditorPanel-Dsnv2wIw.js} +1 -1
- package/dist/assets/{Mobile-B9dwWsea.js → Mobile-bvGZ392d.js} +1 -1
- package/dist/assets/{ProxyStatsModal-sA62al0G.js → ProxyStatsModal-HFk51cI8.js} +1 -1
- package/dist/assets/index-DanpP6qP.js +2 -0
- package/dist/assets/{seqResourceLoaders-B0PEz0_d.css → seqResourceLoaders-BumAjQDW.css} +1 -1
- package/dist/assets/{seqResourceLoaders-9Fe9dtRc.js → seqResourceLoaders-ByHQMNIi.js} +2 -2
- package/dist/index.html +1 -1
- package/package.json +1 -1
- package/server/interceptor.js +73 -11
- package/server/lib/ask/ask-store.js +7 -3
- package/server/lib/async-file-lock.js +29 -5
- package/server/lib/auth.js +71 -54
- package/server/lib/config-backup.js +8 -5
- package/server/lib/credential-access.js +233 -0
- package/server/lib/credential-migrate.js +147 -0
- package/server/lib/credential-store.js +120 -0
- package/server/lib/credential-vault.js +112 -0
- package/server/lib/file-access-policy.js +16 -0
- package/server/lib/im/im-config.js +81 -31
- package/server/lib/im-deny.js +22 -8
- package/server/lib/json-store.js +264 -0
- package/server/lib/prefs-store.js +8 -33
- package/server/lib/session-pin-store.js +7 -14
- package/server/pty-manager.js +10 -4
- package/server/routes/auth.js +21 -1
- package/server/routes/im.js +15 -2
- package/server/routes/preferences.js +58 -30
- package/server/server.js +8 -0
- package/server/workspace-registry.js +27 -35
- package/dist/assets/App-C8h4oK-7.js +0 -2
- package/dist/assets/index-Cp7ADaui.js +0 -2
|
@@ -0,0 +1,233 @@
|
|
|
1
|
+
// Credential access layer — the single place config modules touch the vault.
|
|
2
|
+
//
|
|
3
|
+
// credential-vault.js / credential-store.js are pure path-injected primitives; this module is
|
|
4
|
+
// the seam that binds them to cc-viewer's data root and encodes the cross-cutting rules that
|
|
5
|
+
// every credential class shares, so auth.js / im-config.js / interceptor.js / preferences.js
|
|
6
|
+
// don't each re-derive (and mis-derive) them:
|
|
7
|
+
//
|
|
8
|
+
// 1. ONE credential root, captured at module load. PROFILE_PATH is frozen at load
|
|
9
|
+
// (interceptor.js) and the vault is frozen alongside it; LOG_DIR is a live binding (setLogDir)
|
|
10
|
+
// that a logDir move (ccv --log-dir at runtime, POST /api/preferences {logDir}) can change.
|
|
11
|
+
// A frozen vault does NOT follow that move — which is the SAFE half of the trade: after a
|
|
12
|
+
// logDir switch the reader still finds the vault at the ORIGINAL root and the gate keeps
|
|
13
|
+
// working. (A live/following vault would resolve at the NEW, empty root while preferences.json
|
|
14
|
+
// is carried over with auth.enabled=true → password resolves '' → LAN gate opens. That
|
|
15
|
+
// fail-open is why the root is deliberately NOT live-bound; see the 739f49a6 re-review.)
|
|
16
|
+
// 2. master.key is created ONLY when the vault is empty/absent. A read path that finds
|
|
17
|
+
// ciphertext but no key must hard-fail (unreadable), never mint a fresh key that orphans
|
|
18
|
+
// every existing secret.
|
|
19
|
+
// 3. "Unreadable" is a first-class outcome, distinct from "absent". The LAN password gate
|
|
20
|
+
// treats an empty password as allow-all (auth.js), so a decrypt failure must surface as
|
|
21
|
+
// unreadable=true — never as ''.
|
|
22
|
+
//
|
|
23
|
+
// Boundary: L1-lib (imports json-store + credential-store/vault + findcc for the load-time root).
|
|
24
|
+
import { existsSync, readFileSync } from 'node:fs';
|
|
25
|
+
import { reportSwallowed } from '@ccv/core/error-report';
|
|
26
|
+
import { LOG_DIR } from '../../findcc.js';
|
|
27
|
+
import {
|
|
28
|
+
setSecret, getSecret, hasSecret, deleteSecret, credentialsFileFor, masterKeyPathFor, vaultUnreadable,
|
|
29
|
+
} from './credential-store.js';
|
|
30
|
+
import { _resetKeyCache } from './credential-vault.js';
|
|
31
|
+
|
|
32
|
+
// Captured at module load — deliberately NOT a live binding. See header note (1).
|
|
33
|
+
const CREDENTIALS_FILE = credentialsFileFor(LOG_DIR);
|
|
34
|
+
const MASTER_KEY_PATH = masterKeyPathFor(LOG_DIR);
|
|
35
|
+
|
|
36
|
+
export function getCredentialsFile() { return CREDENTIALS_FILE; }
|
|
37
|
+
export function getMasterKeyPath() { return MASTER_KEY_PATH; }
|
|
38
|
+
|
|
39
|
+
/** True when the vault holds at least one entry (so a missing master.key is a hard error). */
|
|
40
|
+
function _vaultHasEntries() {
|
|
41
|
+
try {
|
|
42
|
+
if (!existsSync(CREDENTIALS_FILE)) return false;
|
|
43
|
+
const data = JSON.parse(readFileSync(CREDENTIALS_FILE, 'utf-8'));
|
|
44
|
+
return !!(data && typeof data === 'object' && data.creds && Object.keys(data.creds).length > 0);
|
|
45
|
+
} catch { return false; }
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/** True when credentials.json exists but cannot be parsed — the fail-CLOSED signal (P0-A). */
|
|
49
|
+
function _vaultUnreadable() {
|
|
50
|
+
return vaultUnreadable(CREDENTIALS_FILE);
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* Guard the key-creation rule. Returns { ok:true } when a key may be used/created, or
|
|
55
|
+
* { ok:false, reason } when ciphertext exists (or the vault is unreadable) but the key is gone
|
|
56
|
+
* (hard-fail — do not mint). An unreadable vault is treated as "has entries": minting a fresh
|
|
57
|
+
* key over it would let the next write overwrite ciphertext we can no longer read.
|
|
58
|
+
*/
|
|
59
|
+
function _keyUsable() {
|
|
60
|
+
if (existsSync(MASTER_KEY_PATH)) return { ok: true };
|
|
61
|
+
if (_vaultUnreadable()) {
|
|
62
|
+
return { ok: false, reason: 'master.key is missing and credentials.json is unreadable; refusing to mint (would orphan existing ciphertext)' };
|
|
63
|
+
}
|
|
64
|
+
if (_vaultHasEntries()) {
|
|
65
|
+
return { ok: false, reason: 'master.key is missing but credentials.json holds entries; refusing to mint a new key (existing secrets would be orphaned)' };
|
|
66
|
+
}
|
|
67
|
+
return { ok: true }; // empty/absent vault → first-ever key may be created on demand
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* Read a secret back to plaintext.
|
|
72
|
+
* Returns { value: string, unreadable: boolean }.
|
|
73
|
+
* - vault entry decrypts → { value, unreadable:false }
|
|
74
|
+
* - no vault entry → { value: fallbackPlain ?? '', unreadable:false } (legacy/pre-migration)
|
|
75
|
+
* - vault entry exists but key/decrypt fails → { value: fallbackPlain ?? '', unreadable:true }
|
|
76
|
+
* - the vault FILE itself is unreadable/corrupt → { value: fallbackPlain ?? '', unreadable:true }
|
|
77
|
+
* `fallbackPlain` is the caller's legacy on-disk value (plaintext or already-base64-DECODED),
|
|
78
|
+
* used only when there is no readable vault entry (the one-version read-fallback).
|
|
79
|
+
*/
|
|
80
|
+
export function readSecretOr(kind, ref, fallbackPlain = '') {
|
|
81
|
+
try {
|
|
82
|
+
// Fail-closed on an unreadable vault FILE (P0-A): hasSecret below would report "no entry"
|
|
83
|
+
// (readJsonSafe swallows the parse error → {}), and a gate that treats empty as allow-all
|
|
84
|
+
// would then open. Surface unreadable so callers deny / drop instead.
|
|
85
|
+
if (_vaultUnreadable()) {
|
|
86
|
+
return { value: fallbackPlain || '', unreadable: true };
|
|
87
|
+
}
|
|
88
|
+
if (!hasSecret(CREDENTIALS_FILE, kind, ref)) {
|
|
89
|
+
return { value: fallbackPlain || '', unreadable: false };
|
|
90
|
+
}
|
|
91
|
+
const usable = _keyUsable();
|
|
92
|
+
if (!usable.ok) {
|
|
93
|
+
reportSwallowed('credential-access.key-unusable', new Error(usable.reason));
|
|
94
|
+
return { value: fallbackPlain || '', unreadable: true };
|
|
95
|
+
}
|
|
96
|
+
const value = getSecret(CREDENTIALS_FILE, MASTER_KEY_PATH, kind, ref);
|
|
97
|
+
return { value, unreadable: false };
|
|
98
|
+
} catch (err) {
|
|
99
|
+
// Tampered ciphertext / wrong key / IO error → unreadable, never '' silently.
|
|
100
|
+
reportSwallowed('credential-access.read', err);
|
|
101
|
+
return { value: fallbackPlain || '', unreadable: true };
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/**
|
|
106
|
+
* Write a secret to the vault. `plain` empty removes the entry. Returns true on success; on
|
|
107
|
+
* failure reports and returns false (callers must NOT then strip the legacy field).
|
|
108
|
+
*/
|
|
109
|
+
export function writeSecret(kind, ref, plain) {
|
|
110
|
+
try {
|
|
111
|
+
const usable = _keyUsable();
|
|
112
|
+
if (!usable.ok) {
|
|
113
|
+
reportSwallowed('credential-access.key-unusable', new Error(usable.reason));
|
|
114
|
+
return false;
|
|
115
|
+
}
|
|
116
|
+
setSecret(CREDENTIALS_FILE, MASTER_KEY_PATH, kind, ref, plain);
|
|
117
|
+
return true;
|
|
118
|
+
} catch (err) {
|
|
119
|
+
reportSwallowed('credential-access.write', err);
|
|
120
|
+
return false;
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/** Remove a secret. Returns true on success. */
|
|
125
|
+
export function removeSecret(kind, ref) {
|
|
126
|
+
try {
|
|
127
|
+
deleteSecret(CREDENTIALS_FILE, kind, ref);
|
|
128
|
+
return true;
|
|
129
|
+
} catch (err) {
|
|
130
|
+
reportSwallowed('credential-access.remove', err);
|
|
131
|
+
return false;
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/**
|
|
136
|
+
* Idempotent one-field migration with read-back verification (the data-safety invariant):
|
|
137
|
+
* a) if the legacy plaintext is empty → nothing to do, return { migrated:false, cleared:false }.
|
|
138
|
+
* b) write the plaintext to the vault, then READ IT BACK and compare. Only when the read-back
|
|
139
|
+
* matches do we say it is safe to clear the legacy field.
|
|
140
|
+
* c) if the vault already holds a DIFFERENT value, the on-disk plaintext is the user's later
|
|
141
|
+
* intent (hand-edit / restored backup / old binary) → it wins and overwrites the vault.
|
|
142
|
+
* `clearLegacy()` is invoked by the CALLER only when this returns { migrated:true, cleared:ok }
|
|
143
|
+
* — it performs the source-file strip inside the caller's own file lock. We never clear here.
|
|
144
|
+
* Returns { migrated:boolean, verified:boolean }.
|
|
145
|
+
*/
|
|
146
|
+
export function migrateFieldWithVerify(kind, ref, legacyPlain) {
|
|
147
|
+
if (!legacyPlain) return { migrated: false, verified: false };
|
|
148
|
+
try {
|
|
149
|
+
// If the vault already holds this exact value, nothing to write.
|
|
150
|
+
if (hasSecret(CREDENTIALS_FILE, kind, ref)) {
|
|
151
|
+
const existing = getSecret(CREDENTIALS_FILE, MASTER_KEY_PATH, kind, ref);
|
|
152
|
+
if (existing === legacyPlain) return { migrated: true, verified: true };
|
|
153
|
+
// Differing value: fall through and let the on-disk plaintext overwrite (user's later intent).
|
|
154
|
+
}
|
|
155
|
+
if (!writeSecret(kind, ref, legacyPlain)) return { migrated: false, verified: false };
|
|
156
|
+
const readBack = getSecret(CREDENTIALS_FILE, MASTER_KEY_PATH, kind, ref);
|
|
157
|
+
if (readBack !== legacyPlain) {
|
|
158
|
+
reportSwallowed('credential-access.migrate-verify', new Error(`read-back mismatch for ${kind}:${ref}`));
|
|
159
|
+
return { migrated: false, verified: false };
|
|
160
|
+
}
|
|
161
|
+
return { migrated: true, verified: true };
|
|
162
|
+
} catch (err) {
|
|
163
|
+
reportSwallowed('credential-access.migrate', err);
|
|
164
|
+
return { migrated: false, verified: false };
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
/** Test hook: drop cached key state so a fresh root/key is picked up per test. */
|
|
169
|
+
export function _resetCredentialAccess() {
|
|
170
|
+
_resetKeyCache(); // clear all cached keys across roots (test hook / root change)
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
/**
|
|
174
|
+
* Persist the apiKeys of an incoming profile list into the vault, and return the list with
|
|
175
|
+
* apiKey STRIPPED for writing to profile.json (the file must never carry the key again).
|
|
176
|
+
*
|
|
177
|
+
* Per profile (kind=profile-apiKey, ref=profile.id):
|
|
178
|
+
* - masked value (isMaskedFn true) → the client echoed the mask back unchanged: DO NOT touch
|
|
179
|
+
* the vault entry (preserves the existing key) and do not write the sentinel anywhere.
|
|
180
|
+
* - non-empty plaintext → writeSecret into the vault.
|
|
181
|
+
* - empty/absent → the profile genuinely has no key: remove the vault entry.
|
|
182
|
+
* Profiles removed from the list (present in `existingProfiles` but absent in `incomingProfiles`)
|
|
183
|
+
* have their vault entry deleted — but only for a FULL-REPLACEMENT write (pass the on-disk list),
|
|
184
|
+
* never for a merge (cc-switch import passes no existingProfiles → no deletion).
|
|
185
|
+
*
|
|
186
|
+
* Returns the stripped list (apiKey:'') to persist in profile.json.
|
|
187
|
+
*/
|
|
188
|
+
export function persistProfilesApiKeys(incomingProfiles, existingProfiles, { isMaskedFn } = {}) {
|
|
189
|
+
const isMasked = typeof isMaskedFn === 'function' ? isMaskedFn : () => false;
|
|
190
|
+
const existingById = new Map((Array.isArray(existingProfiles) ? existingProfiles : [])
|
|
191
|
+
.filter(p => p && typeof p.id === 'string').map(p => [p.id, p]));
|
|
192
|
+
const incomingIds = new Set();
|
|
193
|
+
const stripped = (Array.isArray(incomingProfiles) ? incomingProfiles : []).map((p) => {
|
|
194
|
+
if (!p || typeof p.id !== 'string') return p;
|
|
195
|
+
incomingIds.add(p.id);
|
|
196
|
+
const key = typeof p.apiKey === 'string' ? p.apiKey : '';
|
|
197
|
+
if (key && isMasked(key)) {
|
|
198
|
+
// masked echo → the client didn't change the key, so preserve the existing one. Normally the
|
|
199
|
+
// vault already holds it (leave it untouched). But a profile.json that still carries a
|
|
200
|
+
// PLAINTEXT key (pre-migration) has no vault entry yet — migrate that plaintext in now,
|
|
201
|
+
// otherwise stripping the field below would lose the only copy. If that rescue write FAILS
|
|
202
|
+
// (vault unusable), keep the on-disk plaintext rather than stripping it into oblivion.
|
|
203
|
+
if (!hasSecret(getCredentialsFile(), 'profile-apiKey', p.id)) {
|
|
204
|
+
const legacyPlain = existingById.get(p.id)?.apiKey;
|
|
205
|
+
if (legacyPlain && !isMasked(legacyPlain)) {
|
|
206
|
+
const ok = writeSecret('profile-apiKey', p.id, legacyPlain);
|
|
207
|
+
if (!ok) return { ...p, apiKey: legacyPlain }; // vault write failed → keep the on-disk key
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
return { ...p, apiKey: '' };
|
|
211
|
+
}
|
|
212
|
+
if (key) {
|
|
213
|
+
// New plaintext → vault. On failure keep the plaintext on disk (do NOT strip): mirroring the
|
|
214
|
+
// auth/IM "preserve legacy on failed vault write" rule, so the key is never lost.
|
|
215
|
+
const ok = writeSecret('profile-apiKey', p.id, key);
|
|
216
|
+
return ok ? { ...p, apiKey: '' } : { ...p, apiKey: key };
|
|
217
|
+
}
|
|
218
|
+
// Empty key: preserve any existing vault entry. An empty apiKey here usually means the key
|
|
219
|
+
// simply wasn't served (an unreadable vault hydrates ''), so deleting on empty would destroy
|
|
220
|
+
// the real ciphertext on a no-op save. A profile's key is removed only by removing the whole
|
|
221
|
+
// profile (the full-replacement diff below), never by an empty-string inference.
|
|
222
|
+
return { ...p, apiKey: '' };
|
|
223
|
+
});
|
|
224
|
+
// Full-replacement: delete vault entries for profiles no longer in the list.
|
|
225
|
+
if (Array.isArray(existingProfiles)) {
|
|
226
|
+
for (const old of existingProfiles) {
|
|
227
|
+
if (old && typeof old.id === 'string' && !incomingIds.has(old.id)) {
|
|
228
|
+
removeSecret('profile-apiKey', old.id);
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
return stripped;
|
|
233
|
+
}
|
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
// One-time credential migration — moves legacy plaintext/base64 secrets into the vault.
|
|
2
|
+
//
|
|
3
|
+
// Runs ONCE at server startup (fire-and-forget setImmediate, after config-backup so a pre-strip
|
|
4
|
+
// copy exists). For each legacy secret field it: writes the value to the vault, READS IT BACK and
|
|
5
|
+
// compares (the data-safety invariant), and only then strips the legacy field from the source
|
|
6
|
+
// file — all inside the source file's lock. Idempotent by data, not by a marker: a field that
|
|
7
|
+
// already matches the vault is a no-op; a field whose on-disk value DIFFERS from the vault wins
|
|
8
|
+
// (it is the user's later intent — hand edit / restored backup / old binary) and overwrites.
|
|
9
|
+
//
|
|
10
|
+
// The migration marker written at the end is INFORMATIONAL only (for logging + the backup-clean
|
|
11
|
+
// decision); it never gates re-migration, so a later plaintext reintroduction is still picked up.
|
|
12
|
+
//
|
|
13
|
+
// Boundary: L1-lib. Imports json-store + credential-access + findcc (load-time root).
|
|
14
|
+
import { existsSync } from 'node:fs';
|
|
15
|
+
import { join } from 'node:path';
|
|
16
|
+
import { reportSwallowed } from '@ccv/core/error-report';
|
|
17
|
+
import { LOG_DIR } from '../../findcc.js';
|
|
18
|
+
import { mutateJsonSync } from './json-store.js';
|
|
19
|
+
import { migrateFieldWithVerify, getCredentialsFile } from './credential-access.js';
|
|
20
|
+
import { refFor as authRefFor } from './auth.js';
|
|
21
|
+
import { secretRef as imSecretRef } from './im/im-config.js';
|
|
22
|
+
|
|
23
|
+
// Load-time roots, aligned with credential-access / PROFILE_PATH (not live-bound).
|
|
24
|
+
const PREFS_FILE = join(LOG_DIR, 'preferences.json');
|
|
25
|
+
const PROFILE_FILE = join(LOG_DIR, 'profile.json');
|
|
26
|
+
|
|
27
|
+
// Ref builders are imported from the OWNING runtime modules (auth.js refFor / im-config secretRef)
|
|
28
|
+
// so the migrated entries are keyed EXACTLY as the runtime readers expect — a hand-copied format
|
|
29
|
+
// here would silently orphan every migrated secret on a one-char divergence.
|
|
30
|
+
|
|
31
|
+
function _b64Decode(stored) {
|
|
32
|
+
if (!stored || typeof stored !== 'string') return '';
|
|
33
|
+
try { return Buffer.from(stored, 'base64').toString('utf-8'); } catch { return ''; }
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
// IM platform → secret field keys (mirrors im-config DESCRIPTORS; only `secret`-type fields).
|
|
37
|
+
// cred fields (appKey/appId/botId) stay base64 — they are low-sensitivity and out of scope.
|
|
38
|
+
const IM_SECRET_FIELDS = {
|
|
39
|
+
dingtalk: ['appSecret'],
|
|
40
|
+
feishu: ['appSecret'],
|
|
41
|
+
wecom: ['secret'],
|
|
42
|
+
discord: ['botToken'],
|
|
43
|
+
};
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* Migrate auth.password / authByProject.*.password (base64) into the vault, then strip them from
|
|
47
|
+
* preferences.json (leaving { enabled }).
|
|
48
|
+
* Returns { migrated, skipped, failed } counts.
|
|
49
|
+
*/
|
|
50
|
+
function migrateAuthPasswords() {
|
|
51
|
+
const result = { migrated: 0, skipped: 0, failed: 0 };
|
|
52
|
+
if (!existsSync(PREFS_FILE)) return result;
|
|
53
|
+
mutateJsonSync(PREFS_FILE, (prefs) => {
|
|
54
|
+
if (!prefs || typeof prefs !== 'object') return;
|
|
55
|
+
// global
|
|
56
|
+
if (prefs.auth && typeof prefs.auth === 'object' && prefs.auth.password) {
|
|
57
|
+
const plain = _b64Decode(prefs.auth.password);
|
|
58
|
+
const r = migrateFieldWithVerify('lan-password', authRefFor(null), plain);
|
|
59
|
+
if (r.migrated && r.verified) { delete prefs.auth.password; result.migrated++; }
|
|
60
|
+
else result.failed++;
|
|
61
|
+
}
|
|
62
|
+
// per-project
|
|
63
|
+
if (prefs.authByProject && typeof prefs.authByProject === 'object') {
|
|
64
|
+
for (const [dir, entry] of Object.entries(prefs.authByProject)) {
|
|
65
|
+
if (!entry || typeof entry !== 'object' || !entry.password) { result.skipped++; continue; }
|
|
66
|
+
const plain = _b64Decode(entry.password);
|
|
67
|
+
const r = migrateFieldWithVerify('lan-password', authRefFor(dir), plain);
|
|
68
|
+
if (r.migrated && r.verified) { delete entry.password; result.migrated++; }
|
|
69
|
+
else result.failed++;
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
}, { mode: 0o600 });
|
|
73
|
+
return result;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* Migrate IM platform secret fields (base64) into the vault, then strip them (leaving '').
|
|
78
|
+
*/
|
|
79
|
+
function migrateImSecrets() {
|
|
80
|
+
const result = { migrated: 0, skipped: 0, failed: 0 };
|
|
81
|
+
if (!existsSync(PREFS_FILE)) return result;
|
|
82
|
+
mutateJsonSync(PREFS_FILE, (prefs) => {
|
|
83
|
+
if (!prefs || typeof prefs !== 'object') return;
|
|
84
|
+
for (const [platform, fields] of Object.entries(IM_SECRET_FIELDS)) {
|
|
85
|
+
const cfg = prefs[platform];
|
|
86
|
+
if (!cfg || typeof cfg !== 'object') { result.skipped += fields.length; continue; }
|
|
87
|
+
for (const fieldKey of fields) {
|
|
88
|
+
const stored = cfg[fieldKey];
|
|
89
|
+
if (!stored) { result.skipped++; continue; }
|
|
90
|
+
const plain = _b64Decode(stored);
|
|
91
|
+
const r = migrateFieldWithVerify('im-secret', imSecretRef(platform, fieldKey), plain);
|
|
92
|
+
if (r.migrated && r.verified) { cfg[fieldKey] = ''; result.migrated++; }
|
|
93
|
+
else result.failed++;
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
}, { mode: 0o600 });
|
|
97
|
+
return result;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* Migrate profile.json plaintext apiKeys into the vault, then strip them (leaving '').
|
|
102
|
+
* Reads the file directly (the startup strip is the ONLY writer that removes the field).
|
|
103
|
+
*/
|
|
104
|
+
function migrateProfileApiKeys() {
|
|
105
|
+
const result = { migrated: 0, skipped: 0, failed: 0 };
|
|
106
|
+
if (!existsSync(PROFILE_FILE)) return result;
|
|
107
|
+
mutateJsonSync(PROFILE_FILE, (data) => {
|
|
108
|
+
if (!data || typeof data !== 'object' || !Array.isArray(data.profiles)) return;
|
|
109
|
+
for (const p of data.profiles) {
|
|
110
|
+
if (!p || typeof p.id !== 'string') { result.skipped++; continue; }
|
|
111
|
+
const plain = typeof p.apiKey === 'string' ? p.apiKey : '';
|
|
112
|
+
if (!plain) { result.skipped++; continue; }
|
|
113
|
+
const r = migrateFieldWithVerify('profile-apiKey', p.id, plain);
|
|
114
|
+
if (r.migrated && r.verified) { p.apiKey = ''; result.migrated++; }
|
|
115
|
+
else result.failed++;
|
|
116
|
+
}
|
|
117
|
+
}, { mode: 0o600, strictCorrupt: true }); // never overwrite a corrupt profile.json with a {} fallback
|
|
118
|
+
return result;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/**
|
|
122
|
+
* Run the full one-time migration. Best-effort: per-class failures are reported and counted,
|
|
123
|
+
* never thrown (a failed field keeps its legacy value, so nothing is lost and the next boot
|
|
124
|
+
* retries). Returns a summary; also writes an informational marker into credentials.json.
|
|
125
|
+
*/
|
|
126
|
+
export function migrateCredentialsToVault() {
|
|
127
|
+
const summary = { auth: null, im: null, profiles: null, ok: true };
|
|
128
|
+
try { summary.auth = migrateAuthPasswords(); } catch (e) { summary.ok = false; reportSwallowed('credential-migrate.auth', e); }
|
|
129
|
+
try { summary.im = migrateImSecrets(); } catch (e) { summary.ok = false; reportSwallowed('credential-migrate.im', e); }
|
|
130
|
+
try { summary.profiles = migrateProfileApiKeys(); } catch (e) { summary.ok = false; reportSwallowed('credential-migrate.profiles', e); }
|
|
131
|
+
|
|
132
|
+
const total = (s) => (s ? s.migrated + s.failed : 0);
|
|
133
|
+
const touched = total(summary.auth) + total(summary.im) + total(summary.profiles);
|
|
134
|
+
if (touched > 0) {
|
|
135
|
+
// Informational marker only — never used to gate re-migration.
|
|
136
|
+
try {
|
|
137
|
+
mutateJsonSync(getCredentialsFile(), (data) => {
|
|
138
|
+
if (data && typeof data === 'object') data._credsMigratedAt = new Date().toISOString();
|
|
139
|
+
}, { mode: 0o600, strictCorrupt: true });
|
|
140
|
+
} catch (e) { reportSwallowed('credential-migrate.marker', e); }
|
|
141
|
+
// Loud, single-line startup signal so an operator can see the vault came online.
|
|
142
|
+
console.error(`[cc-viewer] credential vault migration: auth=${fmt(summary.auth)} im=${fmt(summary.im)} profiles=${fmt(summary.profiles)}`);
|
|
143
|
+
}
|
|
144
|
+
return summary;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
function fmt(s) { return s ? `${s.migrated} migrated/${s.failed} failed` : 'n/a'; }
|
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
// Credential store — one encrypted home for cc-viewer's secrets, separate from plain config.
|
|
2
|
+
//
|
|
3
|
+
// Splitting secrets OUT of preferences.json / profile.json into their own credentials.json is
|
|
4
|
+
// what makes them safe to handle differently: the plain config can sync freely, while secrets
|
|
5
|
+
// live only as vault ciphertext (see credential-vault.js) in a 0600 file that is excluded from
|
|
6
|
+
// config-backup. It also means a future "sync plain config" path never drags a key along by
|
|
7
|
+
// accident — the secret is in a different file entirely.
|
|
8
|
+
//
|
|
9
|
+
// On-disk shape (credentials.json, 0600):
|
|
10
|
+
// { version: 1, creds: { "<kind>:<ref>": "<base64(iv|tag|ct)>" } }
|
|
11
|
+
// kind ∈ profile-apiKey | lan-password | im-secret. ref scopes the secret (profile id /
|
|
12
|
+
// project dir / "<platform>.<field>"). Values are ciphertext from credential-vault.
|
|
13
|
+
//
|
|
14
|
+
// Reads/writes go through the unified json-store kernel (sync locked atomic write), sharing the
|
|
15
|
+
// same per-file lock discipline as every other config file. Path-injected; the caller supplies
|
|
16
|
+
// both the credentials file and the master key path (both derive from LOG_DIR at the call site).
|
|
17
|
+
import { join } from 'node:path';
|
|
18
|
+
import { existsSync, readFileSync } from 'node:fs';
|
|
19
|
+
import { readJsonSafe, mutateJsonSync } from './json-store.js';
|
|
20
|
+
import { encryptSecret, decryptSecret, looksEncrypted } from './credential-vault.js';
|
|
21
|
+
|
|
22
|
+
const VERSION = 1;
|
|
23
|
+
|
|
24
|
+
/** Compose the storage key for a secret. */
|
|
25
|
+
export function credKey(kind, ref) { return `${kind}:${ref}`; }
|
|
26
|
+
|
|
27
|
+
// Distinguish "credentials file is ABSENT" from "present but UNREADABLE/corrupt". Read paths
|
|
28
|
+
// (hasSecret/getSecret) treat an unreadable file as "no entries", which is fail-OPEN for a gate
|
|
29
|
+
// that treats empty as allow-all. vaultUnreadable() lets the access layer fail CLOSED instead.
|
|
30
|
+
function _readCreds(file) {
|
|
31
|
+
const data = readJsonSafe(file, {});
|
|
32
|
+
return (data && typeof data === 'object' && data.creds && typeof data.creds === 'object')
|
|
33
|
+
? data.creds
|
|
34
|
+
: {};
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* True when `file` exists but cannot be parsed as a credentials file (corrupt/torn/EACCES).
|
|
39
|
+
* Absent → false. This is the fail-closed signal: a vault that SHOULD have entries but can't be
|
|
40
|
+
* read must never be treated as "empty" (→ empty password / empty apiKey).
|
|
41
|
+
*/
|
|
42
|
+
export function vaultUnreadable(file) {
|
|
43
|
+
if (!existsSync(file)) return false;
|
|
44
|
+
try {
|
|
45
|
+
const data = JSON.parse(readFileSync(file, 'utf-8'));
|
|
46
|
+
// A parsed file is "readable" iff it is a plain object (it may simply have no creds yet).
|
|
47
|
+
return !(data && typeof data === 'object' && !Array.isArray(data));
|
|
48
|
+
} catch {
|
|
49
|
+
return true;
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* Store a secret. `plain` empty/'' removes the entry (secrets are deleted, never stored empty).
|
|
55
|
+
* Returns true on success.
|
|
56
|
+
*/
|
|
57
|
+
export function setSecret(file, keyPath, kind, ref, plain) {
|
|
58
|
+
const key = credKey(kind, ref);
|
|
59
|
+
// strictCorrupt: a corrupt credentials.json must never be overwritten by the fallback — losing
|
|
60
|
+
// the whole vault (every secret) is unacceptable, unlike a self-healing preferences.json.
|
|
61
|
+
mutateJsonSync(file, (data) => {
|
|
62
|
+
if (!data || typeof data !== 'object') data = {};
|
|
63
|
+
if (!data.creds || typeof data.creds !== 'object') data.creds = {};
|
|
64
|
+
if (!plain) {
|
|
65
|
+
delete data.creds[key];
|
|
66
|
+
} else {
|
|
67
|
+
data.creds[key] = encryptSecret(keyPath, plain);
|
|
68
|
+
}
|
|
69
|
+
data.version = VERSION;
|
|
70
|
+
}, { mode: 0o600, fallback: {}, strictCorrupt: true });
|
|
71
|
+
return true;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* Read a secret back to plaintext. Returns '' when absent. Throws only on tampered/wrong-key
|
|
76
|
+
* ciphertext (auth-tag failure) — the caller decides whether that is fatal.
|
|
77
|
+
*/
|
|
78
|
+
export function getSecret(file, keyPath, kind, ref) {
|
|
79
|
+
const stored = _readCreds(file)[credKey(kind, ref)];
|
|
80
|
+
if (!stored) return '';
|
|
81
|
+
return decryptSecret(keyPath, stored);
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/** Does a ciphertext entry exist for this secret? */
|
|
85
|
+
export function hasSecret(file, kind, ref) {
|
|
86
|
+
return Boolean(_readCreds(file)[credKey(kind, ref)]);
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/** Remove a secret. No-op if absent. */
|
|
90
|
+
export function deleteSecret(file, kind, ref) {
|
|
91
|
+
const key = credKey(kind, ref);
|
|
92
|
+
mutateJsonSync(file, (data) => {
|
|
93
|
+
if (data && typeof data === 'object' && data.creds && typeof data.creds === 'object') {
|
|
94
|
+
delete data.creds[key];
|
|
95
|
+
}
|
|
96
|
+
}, { mode: 0o600, fallback: {}, strictCorrupt: true });
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* Idempotent one-time migration helper: given a legacy value (plaintext or base64), ensure a
|
|
101
|
+
* ciphertext entry exists. If the entry already holds ciphertext, leave it (idempotent). If the
|
|
102
|
+
* legacy value is empty, do nothing. Returns true if it wrote a NEW ciphertext entry.
|
|
103
|
+
* `decodeLegacy` turns the legacy on-disk value into plaintext (identity for plaintext fields,
|
|
104
|
+
* base64-decode for the base64 fields).
|
|
105
|
+
*/
|
|
106
|
+
export function migrateLegacySecret(file, keyPath, kind, ref, legacyOnDisk, decodeLegacy) {
|
|
107
|
+
if (!legacyOnDisk) return false;
|
|
108
|
+
if (hasSecret(file, kind, ref)) return false; // already migrated
|
|
109
|
+
const plain = decodeLegacy(legacyOnDisk);
|
|
110
|
+
if (!plain) return false;
|
|
111
|
+
setSecret(file, keyPath, kind, ref, plain);
|
|
112
|
+
return true;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
// Re-export so migration callers can detect already-encrypted legacy values without a second import.
|
|
116
|
+
export { looksEncrypted };
|
|
117
|
+
|
|
118
|
+
/** Canonical paths for the credentials file and master key, derived from a data root. */
|
|
119
|
+
export function credentialsFileFor(logDir) { return join(logDir, 'credentials.json'); }
|
|
120
|
+
export function masterKeyPathFor(logDir) { return join(logDir, 'master.key'); }
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
// Credential vault — real at-rest encryption for cc-viewer's secrets.
|
|
2
|
+
//
|
|
3
|
+
// The legacy encodings were obfuscation, not security: profile.json held apiKeys in plaintext,
|
|
4
|
+
// and the LAN password / IM app secrets were base64 inside preferences.json (trivially reversible;
|
|
5
|
+
// the code comments said so themselves). This module upgrades them to AES-256-GCM with a
|
|
6
|
+
// per-record random IV and a machine-local master key.
|
|
7
|
+
//
|
|
8
|
+
// Key model: a single 256-bit `master.key` (0600) generated on first use and stored next to the
|
|
9
|
+
// data root. This is the standard trade-off for a headless background server (no interactive
|
|
10
|
+
// passphrase prompt, no OS-keychain native dep). At-rest on a single machine it is inherently
|
|
11
|
+
// bounded — anyone who reads BOTH master.key and the ciphertext can decrypt — but it raises the
|
|
12
|
+
// bar from "plaintext / base64 in a world-readable-shape file" to "needs the key file too", and it
|
|
13
|
+
// is the prerequisite for any cloud handling (only ciphertext, never plaintext, may leave the
|
|
14
|
+
// machine; the master key never does).
|
|
15
|
+
//
|
|
16
|
+
// Boundary: L1-lib. Path-injected (no LOG_DIR import) so it stays a pure primitive.
|
|
17
|
+
import { existsSync, readFileSync, writeFileSync, mkdirSync, chmodSync } from 'node:fs';
|
|
18
|
+
import { dirname } from 'node:path';
|
|
19
|
+
import { createCipheriv, createDecipheriv, randomBytes } from 'node:crypto';
|
|
20
|
+
|
|
21
|
+
const KEY_MODE = 0o600;
|
|
22
|
+
const IV_BYTES = 12; // GCM standard
|
|
23
|
+
const TAG_BYTES = 16; // GCM auth tag
|
|
24
|
+
const KEY_BYTES = 32; // AES-256
|
|
25
|
+
|
|
26
|
+
// Process-local cache so we don't re-read/re-stat the key file on every encrypt. Keyed by the
|
|
27
|
+
// master.key path so tests that redirect LOG_DIR get a fresh key per path.
|
|
28
|
+
const _keyCache = new Map();
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Load (or lazily create) the master key at `keyPath`. Created with randomBytes(32) + 0600 on
|
|
32
|
+
* first use. Throws if the key file exists but is malformed (wrong length) — better to fail loud
|
|
33
|
+
* than silently re-derive and orphan existing ciphertext.
|
|
34
|
+
*/
|
|
35
|
+
export function loadMasterKey(keyPath) {
|
|
36
|
+
// Cache is only valid while the on-disk key still exists. If the key file was removed out
|
|
37
|
+
// from under us (a wipe of the data root, an OS cleanup), a stale cached key would let a
|
|
38
|
+
// writer re-encrypt with a key that no longer exists on disk — leaving ciphertext that a
|
|
39
|
+
// later cold process cannot read, and splitting state from _keyUsable()'s disk check.
|
|
40
|
+
if (_keyCache.has(keyPath) && existsSync(keyPath)) return _keyCache.get(keyPath);
|
|
41
|
+
_keyCache.delete(keyPath);
|
|
42
|
+
let key;
|
|
43
|
+
if (existsSync(keyPath)) {
|
|
44
|
+
const raw = readFileSync(keyPath);
|
|
45
|
+
// Stored raw (32 bytes). Tolerate a trailing newline for hand-inspectability.
|
|
46
|
+
const trimmed = raw.subarray(0, KEY_BYTES);
|
|
47
|
+
if (trimmed.length !== KEY_BYTES) {
|
|
48
|
+
throw new Error(`credential-vault: master key at ${keyPath} has ${trimmed.length} bytes, expected ${KEY_BYTES}`);
|
|
49
|
+
}
|
|
50
|
+
key = Buffer.from(trimmed);
|
|
51
|
+
} else {
|
|
52
|
+
key = randomBytes(KEY_BYTES);
|
|
53
|
+
mkdirSync(dirname(keyPath), { recursive: true, mode: 0o700 });
|
|
54
|
+
writeFileSync(keyPath, key, { mode: KEY_MODE });
|
|
55
|
+
try { chmodSync(keyPath, KEY_MODE); } catch { /* best-effort; non-POSIX */ }
|
|
56
|
+
}
|
|
57
|
+
_keyCache.set(keyPath, key);
|
|
58
|
+
return key;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* Encrypt a UTF-8 secret → base64(iv | tag | ciphertext). Returns '' for empty input so callers
|
|
63
|
+
* can distinguish "no secret" from "a secret that decrypts to empty".
|
|
64
|
+
*/
|
|
65
|
+
export function encryptSecret(keyPath, plain) {
|
|
66
|
+
if (!plain) return '';
|
|
67
|
+
const key = loadMasterKey(keyPath);
|
|
68
|
+
const iv = randomBytes(IV_BYTES);
|
|
69
|
+
const cipher = createCipheriv('aes-256-gcm', key, iv);
|
|
70
|
+
const ct = Buffer.concat([cipher.update(String(plain), 'utf-8'), cipher.final()]);
|
|
71
|
+
const tag = cipher.getAuthTag();
|
|
72
|
+
return Buffer.concat([iv, tag, ct]).toString('base64');
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* Decrypt a value produced by encryptSecret. Returns '' for empty input. Throws on malformed
|
|
77
|
+
* input or auth-tag mismatch (tampered / wrong key) — callers decide whether to fall back to a
|
|
78
|
+
* legacy encoding or surface the error.
|
|
79
|
+
*/
|
|
80
|
+
export function decryptSecret(keyPath, stored) {
|
|
81
|
+
if (!stored) return '';
|
|
82
|
+
const key = loadMasterKey(keyPath);
|
|
83
|
+
const buf = Buffer.from(String(stored), 'base64');
|
|
84
|
+
if (buf.length < IV_BYTES + TAG_BYTES) {
|
|
85
|
+
throw new Error('credential-vault: ciphertext too short');
|
|
86
|
+
}
|
|
87
|
+
const iv = buf.subarray(0, IV_BYTES);
|
|
88
|
+
const tag = buf.subarray(IV_BYTES, IV_BYTES + TAG_BYTES);
|
|
89
|
+
const ct = buf.subarray(IV_BYTES + TAG_BYTES);
|
|
90
|
+
const decipher = createDecipheriv('aes-256-gcm', key, iv);
|
|
91
|
+
decipher.setAuthTag(tag);
|
|
92
|
+
return Buffer.concat([decipher.update(ct), decipher.final()]).toString('utf-8');
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* Heuristic: does `stored` look like vault ciphertext (vs a legacy plaintext/base64 value)?
|
|
97
|
+
* Used by the one-time migration to detect already-migrated values. Not a security boundary —
|
|
98
|
+
* just a shape check (base64 that decodes to ≥ IV+TAG bytes).
|
|
99
|
+
*/
|
|
100
|
+
export function looksEncrypted(stored) {
|
|
101
|
+
if (!stored || typeof stored !== 'string') return false;
|
|
102
|
+
if (!/^[A-Za-z0-9+/=]+$/.test(stored)) return false;
|
|
103
|
+
let buf;
|
|
104
|
+
try { buf = Buffer.from(stored, 'base64'); } catch { return false; }
|
|
105
|
+
return buf.length >= IV_BYTES + TAG_BYTES + 1;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/** Test hook: drop the cached key for `keyPath` (or all) so a fresh one is (re)generated. */
|
|
109
|
+
export function _resetKeyCache(keyPath) {
|
|
110
|
+
if (keyPath === undefined) _keyCache.clear();
|
|
111
|
+
else _keyCache.delete(keyPath);
|
|
112
|
+
}
|
|
@@ -15,6 +15,7 @@ import { resolve, basename, sep, join } from 'node:path';
|
|
|
15
15
|
import { homedir, platform, tmpdir } from 'node:os';
|
|
16
16
|
import { getClaudeConfigDir, onLogDirChange } from '../../findcc.js';
|
|
17
17
|
import { loadWorkspaces } from '../workspace-registry.js';
|
|
18
|
+
import { getBackupRoot } from './config-backup.js';
|
|
18
19
|
|
|
19
20
|
const osPlatform = platform();
|
|
20
21
|
const isWin = osPlatform === 'win32';
|
|
@@ -91,6 +92,10 @@ const SENSITIVE_CLAUDE_FILES = new Set([
|
|
|
91
92
|
'.credentials.json',
|
|
92
93
|
'settings.json',
|
|
93
94
|
'settings.local.json',
|
|
95
|
+
// cc-viewer 凭证 vault(credentials.json 密文)与主密钥(master.key):即使 ~/.claude 在 allowlist,
|
|
96
|
+
// 也绝不跨网读(master.key 同时被下方 .key 文件名规则覆盖,这里双保险)。
|
|
97
|
+
'credentials.json',
|
|
98
|
+
'master.key',
|
|
94
99
|
]);
|
|
95
100
|
|
|
96
101
|
// ─── allowlist roots 缓存 ──────────────────────────────────────────────────────
|
|
@@ -205,6 +210,17 @@ export function isReadAllowed(absPath) {
|
|
|
205
210
|
}
|
|
206
211
|
}
|
|
207
212
|
|
|
213
|
+
// 2b) Deny the cc-viewer config-backup dir tree — every rolling backup holds credentials.json
|
|
214
|
+
// ciphertext + master.key, which together are the full decryption kit. The dir defaults to
|
|
215
|
+
// ~/.claude/cc-viewer-config-backups/ (a sibling of LOG_DIR), but it must be denied whenever it
|
|
216
|
+
// lands inside an allowlist root, regardless of whether LOG_DIR was moved.
|
|
217
|
+
try {
|
|
218
|
+
const backupReal = realpathSync(getBackupRoot());
|
|
219
|
+
if (isInsideRoot(real, backupReal)) {
|
|
220
|
+
return { ok: false, reason: 'sensitive-config-backup' };
|
|
221
|
+
}
|
|
222
|
+
} catch { /* backup root missing / not realpath-able → no extra deny */ }
|
|
223
|
+
|
|
208
224
|
// 3) 项目内文件豁免 sensitive 文件名(允许 fixtures/test-cert.pem 等合法 fixture)
|
|
209
225
|
const projectRoot = getProjectRoot();
|
|
210
226
|
const isInProj = isInsideRoot(real, projectRoot);
|