src/devin-connect-credentials.js
Rama master · réplica de lectura del repositorio original.
/**
* DEVIN_CONNECT credential store — encrypted email/password at rest.
*
* WHY THIS EXISTS: the DEVIN_CONNECT session token (account.apiKey,
* `devin-session-token$<JWT>`) is an opaque server-side session_id with NO
* expiry claim and NO refresh path (Auth1 login returns hasRefresh:false). When
* the server retires that session_id, the account goes permanently 'error' with
* no recovery — a single point of failure for the whole DEVIN_CONNECT surface.
*
* The ONLY way to mint a fresh session token is a full email/password Auth1
* login (windsurfLogin). So to auto-recover, we must hold the password. This
* module keeps those credentials encrypted at rest with AES-256-GCM under a key
* derived from the operator-supplied DEVIN_CONNECT_CRED_KEY.
*
* SECURITY POSTURE:
* - OFF by default: no DEVIN_CONNECT_CRED_KEY → store is disabled, nothing is
* written or read. Auto-relogin simply never triggers.
* - Key never touches disk; only the AES-GCM ciphertext + per-record salt/iv
* /authTag are persisted (accounts.creds.json, gitignored).
* - Plaintext passwords are NEVER logged. Callers reference records by email.
* - Tampering or a wrong key fails closed (GCM auth tag mismatch → throw).
*
* File shape (accounts.creds.json):
* { "v": 1, "records": { "<email-lower>": { salt, iv, tag, ct } } } (all hex)
*/
import { readFileSync, writeFileSync, renameSync, mkdirSync, rmdirSync, openSync, closeSync, unlinkSync, realpathSync, readdirSync, statSync, existsSync, linkSync } from 'fs';
import { join, dirname, basename, resolve } from 'path';
import { createCipheriv, createDecipheriv, scryptSync, randomBytes, randomUUID } from 'crypto';
import { hostname } from 'os';
import { config, log } from './config.js';
import { bumpConnect, __registerCredHealth } from './devin-connect-metrics.js';
// Bump the repair counter without letting a metrics hiccup break a cred read.
function bumpCredRepaired() {
try { bumpConnect('cred_store_repaired'); } catch { /* metrics are best-effort */ }
}
function credFilePath(env = process.env) {
return env.DEVIN_CONNECT_CRED_FILE
|| join(config.sharedDataDir || config.dataDir, 'accounts.creds.json');
}
const SCRYPT_KEYLEN = 32; // AES-256
const SCRYPT_PARAMS = { N: 16384, r: 8, p: 1 };
const ALGO = 'aes-256-gcm';
const FILE_VERSION = 1;
// AES-GCM's tag is fixed at 128 bits by this format's writer (`getAuthTag()`), and the
// read path must require exactly that: node otherwise accepts any shorter tag present in
// the file, which turns a truncation into an accepted credential rather than a tamper.
const GCM_TAG_BYTES = 16;
// Decrypt-failure signal. A GCM auth-tag mismatch means the master key is wrong
// (rotated/typo'd) or the record is tampered — and because every record is
// keyed off the SAME master key, a wrong key fails IDENTICALLY for the whole
// fleet, silently disabling all auto-relogin. We surface that as a counter +
// last-error so ops/observability can alarm on it instead of it hiding in a
// per-account warn. Distinct from "credential absent", which is normal.
let _decryptFailures = 0;
let _lastDecryptError = null;
export function getCredHealth() {
return { decryptFailures: _decryptFailures, lastDecryptError: _lastDecryptError };
}
export function resetCredHealth() { _decryptFailures = 0; _lastDecryptError = null; }
/** Resolve the master key material from env. Empty → store disabled. */
export function getCredKey(env = process.env) {
return String(env.DEVIN_CONNECT_CRED_KEY || '').trim();
}
/** True when credential storage is enabled (a master key is configured). */
export function isCredStoreEnabled(env = process.env) {
const key = getCredKey(env);
if (key.length === 0) return false;
// S6-F5: any non-empty key used to enable the store — `DEVIN_CONNECT_CRED_KEY=x`
// silently wrapped records under a trivially brute-forceable KEK. Below the
// floor the store reports disabled; a loud error names exactly why.
if (key.length < MIN_CRED_KEY_CHARS) {
if (!_shortKeyWarned) {
_shortKeyWarned = true;
log.error(`DEVIN_CONNECT_CRED_KEY is ${key.length} chars (< ${MIN_CRED_KEY_CHARS}) — too weak as a key-encryption key; credential store DISABLED. Set a longer key or unset it.`);
}
return false;
}
return true;
}
export const MIN_CRED_KEY_CHARS = 16;
let _shortKeyWarned = false;
function normalizeEmail(email) {
return String(email || '').trim().toLowerCase();
}
/** Derive a per-record AES key from the master key + record salt. */
function deriveKey(masterKey, salt) {
return scryptSync(masterKey, salt, SCRYPT_KEYLEN, SCRYPT_PARAMS);
}
// A stored record is { salt, iv, tag, ct } with all values lowercase hex. We
// validate shape before trusting a salvaged record so regex recovery can't
// inject garbage that later throws deep in the cipher.
function isValidRecord(rec) {
if (!rec || typeof rec !== 'object') return false;
for (const f of ['salt', 'iv', 'tag', 'ct']) {
if (typeof rec[f] !== 'string' || !/^[0-9a-fA-F]+$/.test(rec[f]) || rec[f].length === 0) return false;
}
return true;
}
// Tier 2: best-effort repair of a structurally-broken JSON wrapper before
// giving up. Handles the common real-world corruptions: a UTF-8 BOM, a trailing
// comma, and a half-written file truncated mid-object (crash/disk-full between
// write and rename, or a botched manual edit) — recover the largest prefix that
// closes cleanly at the last balanced `}`.
function tryRepairJson(text) {
let s = String(text).replace(/^/, '').trim();
// Truncate to the last closing brace so a tail-truncated file still parses
// its complete records.
const lastBrace = s.lastIndexOf('}');
if (lastBrace !== -1) s = s.slice(0, lastBrace + 1);
// Drop a dangling comma before the closing brace(s).
s = s.replace(/,\s*(}|])/g, '$1');
try {
const parsed = JSON.parse(s);
if (parsed && typeof parsed === 'object' && typeof parsed.records === 'object') return parsed;
} catch { /* fall through to tier 3 */ }
return null;
}
// Tier 3: the JSON wrapper is unsalvageable, but every record is an independent
// encrypted blob. Scan the raw text for `"<email>": { salt, iv, tag, ct }`
// fragments and rebuild the records map one entry at a time, keeping only those
// that pass shape validation. One mangled record is dropped; the rest survive.
function salvageRecordsByRegex(text) {
const records = Object.create(null);
// Match an email-ish key followed by an object literal containing the four
// hex fields in any order. Non-greedy object body, capped to avoid runaway.
const entryRe = /"([^"\n]+?)"\s*:\s*\{([^{}]{0,4000}?)\}/g;
const fieldRe = (name) => new RegExp(`"${name}"\\s*:\\s*"([0-9a-fA-F]+)"`);
let m;
while ((m = entryRe.exec(text)) !== null) {
const key = m[1];
const body = m[2];
const rec = {};
let ok = true;
for (const f of ['salt', 'iv', 'tag', 'ct']) {
const fm = body.match(fieldRe(f));
if (!fm) { ok = false; break; }
rec[f] = fm[1];
}
if (ok && isValidRecord(rec) && key !== 'records' && key !== 'v') records[key] = rec;
}
return records;
}
// Synchronous callers already run to completion on one event loop. This guard
// also rejects reentrant calls; returning success for queued work would break
// the existing boolean/throw API and lose persistence errors at its callers.
const _saveInFlight = new Set();
// file → the claim INSTANCE this process holds. The instance, not the path, is
// the lock, so a release can only give back the exact claim that was taken.
const _heldClaims = new Map();
const NO_IGNORED = new Set();
function credentialStoreError(code) {
const error = new Error(code);
error.code = code;
return error;
}
function credentialStoreBusy() {
return credentialStoreError('ERR_CRED_STORE_BUSY');
}
function canonicalCredFile(env) {
const file = resolve(credFilePath(env));
return join(realpathSync(dirname(file)), basename(file));
}
// ─── The store lock: a permanent sentinel plus unique claim INSTANCES ───────
//
// <store>.lock permanent sentinel at the fixed pre-v2 path:
// • a regular file — a fresh store
// • a directory holding format.json — a migrated one
// <store>.lock.claims/ retained coordination directory
// claim-<host>-<pid>-<token>/ one immutable claim per writer
//
// WHY THE SENTINEL IS A FILE FOR A FRESH STORE. <store>.lock used to be a
// directory that had to be created and then stamped, so a failure between those
// two steps (crash, ENOSPC) left a lock nothing could read and every later save
// was fenced forever (2026-09-17 review, P2). The sentinel is now published by
// hard-linking a fully prepared file: the path is either absent or complete, and
// there is no window in which a created-but-uninitialized lock exists. A pre-v2
// writer cannot take the path either — its mkdir fails on an existing path, and
// its release rmdir cannot remove a file — so after the first new-format writer
// no pre-v2 writer can hold this lock again (its own reclaim then finds no
// owner.json and refuses).
//
// WHY A PRE-V2 DIRECTORY IS KEPT, NEVER RENAMED AND NEVER EMPTIED. An old-version
// reclaimer may still be running: it reads the stamp, judges the owner dead,
// pauses, then deletes by PATH. If this code renamed or removed that directory —
// or left it empty — the paused reclaimer would delete whatever replaced it,
// which is exactly the P1 this change removes. So the directory stays at its path
// forever: the migrator first holds a uniquely named guard INSIDE it (an old
// reclaimer's rmdir then fails with ENOTEMPTY even after it unlinks the stamp)
// and only ever installs a permanent format.json inside it. A directory without
// that marker is refused however old it looks: a paused pre-v2 writer between its
// mkdir and its stamp is indistinguishable from a leftover, and elapsed time is
// not proof of death.
//
// CLAIMS ARE INSTANCES. The identity IS the directory name (host, pid, random
// token), so a reclaim can only delete the exact instance whose writer it proved
// dead by ESRCH on this host; claim and release both name an instance rather than
// a shared path. The claims directory is read AFTER this writer's claim exists:
// two writers can both be past their read only if each missed the other's claim,
// which requires the second claim to be created after the first read — but then
// the second read is later still and sees the first claim. A live claim is never
// removed, so the loser reads BUSY.
//
// LIMITS, stated rather than implied: only ESRCH on this host proves a pid is
// gone, so a recycled pid — and any foreign host — stays conservatively BUSY until
// an operator removes that instance; worker threads share a pid, so a claim is per
// process, not per thread; a pre-v2 directory left empty by a crash between mkdir
// and stamp is an explicitly irrecoverable legacy boundary (refused, operator
// action required); and this trusts local-filesystem readdir/mkdir/link
// visibility, the same assumption the old mkdir-based lock already made.
const LOCK_FORMAT = 2;
const LOCK_MARKER = JSON.stringify({ v: LOCK_FORMAT });
const FORMAT_FILE = 'format.json';
const LEGACY_OWNER_FILE = 'owner.json';
const CLAIM_PREFIX = 'claim-';
const CLAIM_ATTEMPTS = 3;
// Hex, not the raw name: it survives any hostname character and compares exactly.
function hostTag(host = hostname()) {
return Buffer.from(String(host), 'utf8').toString('hex');
}
function sentinelPath(file) {
return `${file}.lock`;
}
function claimsPath(file) {
return `${file}.lock.claims`;
}
function claimName(token = randomUUID().replace(/-/g, '')) {
return `${CLAIM_PREFIX}${hostTag()}-${process.pid}-${token}`;
}
// Anything this module did not create parses to null, and callers must treat
// null as an occupant they cannot prove dead — never as something reclaimable.
function parseClaim(name) {
if (typeof name !== 'string' || !name.startsWith(CLAIM_PREFIX)) return null;
const parts = name.slice(CLAIM_PREFIX.length).split('-');
if (parts.length !== 3) return null;
const [host, pid, token] = parts;
if (!/^[0-9a-f]{2,512}$/.test(host)) return null;
if (!/^[1-9][0-9]{0,9}$/.test(pid)) return null;
if (!/^[0-9a-f]{16,64}$/.test(token)) return null;
return { host, pid: Number(pid), token, name };
}
// 'dead' = this host provably cannot be running that writer any more. 'live' and
// 'unknown' both fence the store; 'unknown' is another host, or an errno that
// proves nothing (EPERM), or a pid this process is not allowed to check.
function claimState(claim) {
if (claim.host !== hostTag()) return 'unknown';
try {
process.kill(claim.pid, 0);
return 'live';
} catch (error) {
return error.code === 'ESRCH' ? 'dead' : 'unknown';
}
}
// Remove one claim INSTANCE: the name is the identity, so this can only delete the
// exact instance the caller proved dead. An instance is a leaf — a directory, or
// the staging file of a claim that was published but never completed.
function removeInstance(dir, name) {
const target = join(dir, name);
let info;
try { info = statSync(target); } catch { return true; } // already gone
if (info.isFile()) {
try { unlinkSync(target); return true; } catch { return false; }
}
if (!info.isDirectory()) return false;
let entries;
try { entries = readdirSync(target, { withFileTypes: true }); } catch { return false; }
for (const entry of entries) {
if (!entry.isFile()) return false;
try { unlinkSync(join(target, entry.name)); } catch { return false; }
}
try { rmdirSync(target); return true; } catch { return false; }
}
// Read a coordination directory and clean up only what is provably dead. Returns
// the first entry that blocks this writer, or null. `ignored` names members the
// caller already understands (the sentinel's own files).
function sweepInstances(dir, ownName, ignored) {
let blocker = null;
for (const entry of readdirSync(dir)) {
if (entry === ownName || ignored.has(entry)) continue;
const instance = parseClaim(entry);
if (!instance || claimState(instance) !== 'dead') { if (!blocker) blocker = entry; continue; }
if (removeInstance(dir, entry)) log.warn(`credential store lock reclaimed from a dead writer (pid ${instance.pid})`);
else log.warn(`credential store: dead lock instance ${entry} could not be removed; it fences nothing`);
}
return blocker;
}
function releaseInstance(dir, name) {
try { rmdirSync(join(dir, name)); return true; }
catch (error) {
if (error.code === 'ENOENT') return true; // already gone, nothing to give back
// A completed rename remains committed; a surviving claim instance fences
// later writes exactly as long as this process can still publish.
log.error(`credential store lock release failed (${error.code || 'UNKNOWN'}); writes remain fenced`);
return false;
}
}
function markerState(dir) {
let raw;
try { raw = readFileSync(join(dir, FORMAT_FILE), 'utf8'); }
catch (error) { return error.code === 'ENOENT' ? 'absent' : 'broken'; }
try { return JSON.parse(raw)?.v === LOCK_FORMAT ? 'ok' : 'broken'; }
catch { return 'broken'; }
}
// Pre-v2 stamp: the only legacy ownership this code understands. Anything
// unreadable, foreign or malformed parses to null and is refused instead of
// stolen. Never an age rule: a paused pre-v2 writer can still resume and publish,
// so only the OS may call it dead.
function parseLegacyOwner(dir) {
let owner;
try { owner = JSON.parse(readFileSync(join(dir, LEGACY_OWNER_FILE), 'utf8')); }
catch { return null; }
if (!owner || typeof owner.host !== 'string' || !Number.isSafeInteger(owner.pid) || owner.pid <= 0) return null;
return { host: owner.host, pid: owner.pid };
}
// 'dead' | 'live' | 'unknown' for an already-parsed stamp. A null/absent stamp is
// 'unknown' — absence is never evidence of death.
function legacyOwnerState(owner) {
if (!owner) return 'unknown';
if (owner.host !== hostname()) return 'unknown';
try {
process.kill(owner.pid, 0);
return 'live';
} catch (error) {
return error.code === 'ESRCH' ? 'dead' : 'unknown';
}
}
// Publish the fresh-store sentinel ATOMICALLY: the marker is fully prepared inside
// the claims directory and then hard-linked to the sentinel path, so that path is
// either absent or complete. EEXIST means somebody else published first (or a
// pre-v2 directory appeared) and the caller re-reads; every other failure leaves
// the sentinel path untouched and is reported as itself.
function publishSentinel(lock, claims) {
const staged = join(claims, claimName());
try {
writeFileSync(staged, LOCK_MARKER, { mode: 0o600, flag: 'wx' });
try { linkSync(staged, lock); }
catch (error) { if (error.code !== 'EEXIST') throw error; }
} finally {
try { unlinkSync(staged); } catch { /* best effort */ }
}
}
// Install the permanent marker INSIDE a pre-v2 directory. It is staged under a
// claim-shaped name first, so a writer that dies mid-install is reclaimed as a
// provably dead instance instead of becoming an unreadable artifact; the rename
// then makes it visible. From this moment rmdir on the directory always fails.
function installLegacyMarker(dir) {
const staged = join(dir, claimName());
try {
writeFileSync(staged, LOCK_MARKER, { mode: 0o600, flag: 'wx' });
renameSync(staged, join(dir, FORMAT_FILE));
} catch (error) {
try { unlinkSync(staged); } catch { /* best effort */ }
throw error;
}
}
// A pre-v2 directory is migrated IN PLACE: this code never renames, removes or
// vacates it. The permanent marker is installed BEFORE the dead stamp is deleted,
// so a fault in this code alone (crash, ENOSPC, EIO) leaves either the untouched
// stamp or [format.json, owner.json] — both retryable, and neither empty.
//
// MIXED-VERSION BOUNDARY. A pre-v2 reclaimer running concurrently can unlink the
// stamp it judged dead at any moment, including after this migrator revalidated
// it. If the marker install then fails, releasing the guard leaves an ordinary
// empty pre-v2 directory: this code never adopts one, so later writes read BUSY
// until an operator removes it. That is the narrow residual of running beside an
// old writer; it never widens what this code accepts, and with no old reclaimer
// involved the dead stamp survives such a fault and the retry completes.
function migrateLegacyDir(lock) {
const before = readdirSync(lock);
for (const entry of before) {
if (entry === LEGACY_OWNER_FILE || entry === FORMAT_FILE || parseClaim(entry)) continue;
throw credentialStoreBusy();
}
if (before.length === 0) {
// A pre-v2 writer between its mkdir and its stamp looks exactly like this, and
// how long it paused cannot be observed. Refuse — an operator decides.
throw credentialStoreBusy();
}
const marker = markerState(lock);
if (marker === 'broken') throw credentialStoreBusy();
if (marker === 'ok') {
// Already migrated: the marker is the permanent barrier, so only a leftover
// dead stamp and the litter of a crashed migrator may need clearing.
if (existsSync(join(lock, LEGACY_OWNER_FILE))) {
const owner = parseLegacyOwner(lock);
if (legacyOwnerState(owner) !== 'dead') throw credentialStoreBusy();
try { unlinkSync(join(lock, LEGACY_OWNER_FILE)); }
catch (error) { if (error.code !== 'ENOENT') throw error; }
}
if (sweepInstances(lock, null, new Set([FORMAT_FILE]))) throw credentialStoreBusy();
return;
}
const hadStamp = before.includes(LEGACY_OWNER_FILE);
const stampBefore = hadStamp ? parseLegacyOwner(lock) : null;
if (hadStamp && legacyOwnerState(stampBefore) !== 'dead') throw credentialStoreBusy();
// Hold a unique, nonempty barrier INSIDE this exact directory before touching the
// shared stamp: an old reclaimer's rmdir now fails (ENOTEMPTY) even after it
// unlinks the stamp, so the directory instance cannot be vacated under us.
const guard = claimName();
mkdirSync(join(lock, guard), { mode: 0o700 });
try {
if (sweepInstances(lock, guard, new Set([LEGACY_OWNER_FILE]))) throw credentialStoreBusy();
// Revalidate AFTER the barrier, and only against the identity judged dead above.
// A stamp that is gone proves nothing — an old reclaimer may have removed it, or
// an old writer may have replaced the whole directory with one it is about to
// stamp — so absence is always BUSY, never death. A different owner is refused
// too: this writer never verified that one.
const stampNow = parseLegacyOwner(lock);
if (!stampNow) throw credentialStoreBusy();
if (!stampBefore || stampNow.host !== stampBefore.host || stampNow.pid !== stampBefore.pid) throw credentialStoreBusy();
if (legacyOwnerState(stampNow) !== 'dead') throw credentialStoreBusy();
// Permanent barrier first, shared stamp second.
installLegacyMarker(lock);
try { unlinkSync(join(lock, LEGACY_OWNER_FILE)); }
catch (error) { if (error.code !== 'ENOENT') throw error; }
log.warn('credential store lock upgraded in place: a dead pre-v2 stamp was removed');
} finally {
// The guard is always handed back. In the paths this code drives, the
// directory stays non-empty without it (the marker, or the untouched stamp);
// if a concurrent pre-v2 reclaimer removed the stamp and the marker install
// then failed, this leaves an empty pre-v2 directory, which the next attempt
// refuses (BUSY, operator recovery) instead of adopting it.
releaseInstance(lock, guard);
}
}
function ensureClaimsDir(claims) {
try { mkdirSync(claims, { mode: 0o700 }); }
catch (error) { if (error.code !== 'EEXIST') throw error; }
if (!statSync(claims).isDirectory()) throw credentialStoreBusy();
}
// Occupy the store's fixed sentinel path with something a pre-v2 writer can
// neither create nor delete, or refuse. Everything unknown is a refusal.
function openSentinel(lock, claims) {
for (let attempt = 0; attempt < 3; attempt++) {
let info = null;
try { info = statSync(lock); } catch (error) { if (error.code !== 'ENOENT') throw error; }
if (info === null) { publishSentinel(lock, claims); continue; }
if (info.isDirectory()) { migrateLegacyDir(lock); return; }
if (!info.isFile()) throw credentialStoreBusy();
let raw;
try { raw = readFileSync(lock, 'utf8'); } catch { throw credentialStoreBusy(); }
try { if (JSON.parse(raw)?.v === LOCK_FORMAT) return; } catch { /* not a marker this code wrote */ }
throw credentialStoreBusy();
}
throw credentialStoreBusy(); // the path never settled: fail closed
}
// Take the store lock: publish this writer's claim instance, then read the claims
// directory to find out whether anybody else is live. Contention reads as BUSY —
// the caller must not write, and no live claim is ever touched.
function acquireClaim(file) {
const claims = claimsPath(file);
let name = null;
for (let tries = 0; tries < CLAIM_ATTEMPTS && !name; tries++) {
const candidate = claimName();
try { mkdirSync(join(claims, candidate), { mode: 0o700 }); name = candidate; }
catch (error) {
// Only a token collision is expected to be EEXIST; anything else (ENOSPC
// included) created nothing, so it is reported as itself, not as BUSY.
if (error.code !== 'EEXIST') throw error;
}
}
if (!name) throw credentialStoreBusy();
const claim = { dir: claims, name, path: join(claims, name) };
let blocker;
try {
blocker = sweepInstances(claims, name, NO_IGNORED); // the read AFTER the claim is the exclusion
} catch (error) {
// A failed read must not leak this writer's own instance: it would fence every
// later call from this process until it exits.
releaseInstance(claims, name);
throw error;
}
if (blocker) {
releaseInstance(claims, name);
log.warn(`credential store lock held elsewhere (${blocker}); refusing to write`);
throw credentialStoreBusy();
}
return claim;
}
// Last check before publishing. This writer must still hold its own claim INSTANCE
// and no live/unknown occupant may share the claims directory. That read is the
// serialisation point: of two writers that both reach it, the later one sees the
// earlier one's claim (a live writer never gives its claim up) and refuses, so
// they cannot both publish. Provably dead instances are ignored — they cannot
// publish anything. Scope: this refuses under coherent local-filesystem semantics
// and relies on every protocol participant never deleting a live claim instance;
// it is not a defence against an external agent that removes one anyway, and no
// check can make an arbitrary external removal of a live claim safe.
function assertExclusive(file) {
const claim = _heldClaims.get(file);
if (!claim) throw credentialStoreError('ERR_CRED_STORE_UNLOCKED');
if (!existsSync(claim.path)) throw credentialStoreBusy();
if (sweepInstances(claim.dir, claim.name, NO_IGNORED)) throw credentialStoreBusy();
}
function withStoreLock(env, operation) {
const file = canonicalCredFile(env);
if (_saveInFlight.has(file)) throw credentialStoreBusy();
_saveInFlight.add(file);
let claim = null;
try {
const claims = claimsPath(file);
ensureClaimsDir(claims);
openSentinel(sentinelPath(file), claims);
claim = acquireClaim(file);
_heldClaims.set(file, claim);
return operation({ ...env, DEVIN_CONNECT_CRED_FILE: file });
} finally {
if (claim) {
_heldClaims.delete(file);
releaseInstance(claim.dir, claim.name);
}
_saveInFlight.delete(file);
}
}
function copyRecordMap(records) {
return Object.assign(Object.create(null), records || {});
}
function isStoreShape(value) {
return value && typeof value === 'object' && !Array.isArray(value)
&& value.records && typeof value.records === 'object' && !Array.isArray(value.records);
}
function readSnapshot(env) {
let raw;
try {
raw = readFileSync(credFilePath(env));
} catch (error) {
if (error.code !== 'ENOENT') throw error;
return { store: { v: FILE_VERSION, records: copyRecordMap() }, raw: null, corrupt: false };
}
const text = raw.toString('utf8');
try {
const parsed = JSON.parse(text);
if (isStoreShape(parsed)) {
return { store: { v: parsed.v || FILE_VERSION, records: copyRecordMap(parsed.records) }, raw, corrupt: false };
}
} catch { /* Inspect both recovery tiers without modifying the source. */ }
const repaired = tryRepairJson(text);
const salvaged = salvageRecordsByRegex(text);
// Tier 2 can recover only a prefix. Always include intact tier-3 records
// outside that prefix instead of publishing the smaller successful tier.
const records = copyRecordMap(salvaged);
if (isStoreShape(repaired)) Object.assign(records, repaired.records);
return { store: { v: repaired?.v || FILE_VERSION, records }, raw, corrupt: true };
}
function assertRecordSetPreserved(before, after, removed = []) {
const allowedRemovals = new Set(removed);
for (const key of Object.keys(before)) {
if (!allowedRemovals.has(key) && !Object.hasOwn(after, key)) {
throw credentialStoreError('ERR_CRED_STORE_RECORD_LOSS');
}
}
}
function preserveCorruptSource(snapshot, env) {
if (!snapshot.corrupt || snapshot.raw === null) return;
const backup = `${credFilePath(env)}.corrupt.${process.pid}.${randomUUID()}.bak`;
// Preserve the original bytes, not a UTF-8 roundtrip or a salvaged subset.
// Failure must abort publication. Backups are never automatically merged
// later: without tombstones that would resurrect deliberate deletions.
writeFileSync(backup, snapshot.raw, { mode: 0o600, flag: 'wx' });
}
function writeStore(store, env = process.env) {
const file = credFilePath(env);
if (!_saveInFlight.has(file)) throw credentialStoreError('ERR_CRED_STORE_UNLOCKED');
// Ownership is an instance, not a path: if this writer's claim is gone, or a
// live competitor appeared since the lock was taken, the snapshot read below
// (or already read) may be superseded, and publishing it would drop the
// winner's records. Never publish without the exclusive instance.
assertExclusive(file);
// Match auth.js:617-622 and fs-atomic.js:111-121. Exclusive creation also
// turns an improbable random-name collision into an error, not truncation.
const tmp = `${file}.${process.pid}.${randomUUID().slice(0, 8)}.tmp`;
let fd;
let owned = false;
let published = false;
try {
fd = openSync(tmp, 'wx', 0o600);
owned = true;
writeFileSync(fd, JSON.stringify(store, null, 2));
closeSync(fd);
fd = undefined;
// The temp file is written; re-check ownership right before the rename that
// publishes it. A claim lost during the write must refuse here, not publish.
assertExclusive(file);
renameSync(tmp, file);
published = true;
} finally {
if (fd !== undefined) {
try { closeSync(fd); }
catch (error) { log.error(`credential store tmp close failed (${error.code || 'UNKNOWN'})`); }
}
if (owned && !published) {
try { unlinkSync(tmp); }
catch (error) {
if (error.code !== 'ENOENT') log.error(`credential store tmp cleanup failed (${error.code || 'UNKNOWN'})`);
}
}
}
}
function commitSnapshot(snapshot, next, env, removed = []) {
assertRecordSetPreserved(snapshot.store.records, next.records, removed);
preserveCorruptSource(snapshot, env);
writeStore(next, env);
if (snapshot.corrupt) {
bumpCredRepaired();
log.error(`credential store repaired; original bytes retained; published ${Object.keys(next.records).length} record(s)`);
}
}
function readStore(env = process.env) {
const first = readSnapshot(env);
if (!first.corrupt) return first.store;
if (Object.keys(first.store.records).length === 0) {
bumpCredRepaired();
log.error('credential store unrepairable; no intact records; source retained without overwrite');
return first.store;
}
try {
return withStoreLock(env, lockedEnv => {
// Another writer may have published or deleted records since the first
// read. Never heal from that stale snapshot, even if it has more keys.
const current = readSnapshot(lockedEnv);
if (current.corrupt && Object.keys(current.store.records).length > 0) {
commitSnapshot(current, current.store, lockedEnv);
}
return current.store;
});
} catch (error) {
bumpCredRepaired();
log.warn(`credential store repair deferred (${error.code || 'UNKNOWN'}); source not overwritten by this reader`);
return first.store;
}
}
/**
* Encrypt and persist a password for an email. No-op (returns false) when the
* store is disabled. Plaintext is never logged.
*/
export function storeCredential(email, password, env = process.env) {
const masterKey = getCredKey(env);
if (!masterKey) return false;
const key = normalizeEmail(email);
if (!key || !password) throw new Error('storeCredential: email and password required');
const salt = randomBytes(16);
const iv = randomBytes(12); // 96-bit nonce, GCM standard
const aesKey = deriveKey(masterKey, salt);
const cipher = createCipheriv(ALGO, aesKey, iv);
const ct = Buffer.concat([cipher.update(String(password), 'utf8'), cipher.final()]);
const tag = cipher.getAuthTag();
withStoreLock(env, lockedEnv => {
const snapshot = readSnapshot(lockedEnv);
const store = { v: snapshot.store.v, records: copyRecordMap(snapshot.store.records) };
store.records[key] = {
salt: salt.toString('hex'),
iv: iv.toString('hex'),
tag: tag.toString('hex'),
ct: ct.toString('hex'),
};
commitSnapshot(snapshot, store, lockedEnv);
});
log.info(`credential stored for ${key.replace(/(.{2}).*(@.*)/, '$1***$2')}`);
return true;
}
/** True if an encrypted credential exists for this email (no decryption). */
export function hasCredential(email, env = process.env) {
if (!isCredStoreEnabled(env)) return false;
return Boolean(readStore(env).records[normalizeEmail(email)]);
}
/**
* Decrypt and return the stored password for an email, or null when absent /
* store disabled. Throws on a wrong key or tampered record (GCM auth failure) —
* callers should treat that as "credential unusable", not "absent".
*/
export function getCredential(email, env = process.env) {
const masterKey = getCredKey(env);
if (!masterKey) return null;
const rec = readStore(env).records[normalizeEmail(email)];
if (!rec) return null;
try {
const aesKey = deriveKey(masterKey, Buffer.from(rec.salt, 'hex'));
// GCM's authentication strength is the tag length, and the writer always stores
// the full 128-bit tag (`getAuthTag()`). Without an explicit `authTagLength`,
// node accepts a SHORTER tag from the file and authenticates against it — so a
// store whose tag was truncated to 4 bytes still returned the password, with only
// a DEP0182 warning, and a tamper that shortens the tag was not detected. The
// length is part of this file format, so it is checked here rather than inferred.
const tagHex = rec.tag;
if (typeof tagHex !== 'string' || tagHex.length !== GCM_TAG_BYTES * 2) {
throw new Error(`credential record tag is not the ${GCM_TAG_BYTES}-byte GCM tag this format writes`);
}
const tag = Buffer.from(tagHex, 'hex');
if (tag.length !== GCM_TAG_BYTES) {
throw new Error(`credential record tag is not the ${GCM_TAG_BYTES}-byte GCM tag this format writes`);
}
const decipher = createDecipheriv(ALGO, aesKey, Buffer.from(rec.iv, 'hex'), { authTagLength: GCM_TAG_BYTES });
decipher.setAuthTag(tag);
const pt = Buffer.concat([decipher.update(Buffer.from(rec.ct, 'hex')), decipher.final()]);
// A successful decrypt proves the key is right — clear any stale alarm.
if (_decryptFailures > 0) { _decryptFailures = 0; _lastDecryptError = null; }
return pt.toString('utf8');
} catch (e) {
// Wrong/rotated master key or tampered record — fleet-wide self-heal is now
// broken. Track it loudly (counter + a single error-level line) so it can be
// alarmed on, instead of vanishing into a per-account debug warn. Re-throw so
// the caller still treats this as "credential unusable", not "absent".
_decryptFailures += 1;
_lastDecryptError = e.message;
log.error(`DEVIN_CONNECT credential decrypt FAILED (wrong/rotated DEVIN_CONNECT_CRED_KEY or tampered store?) — auto-relogin is DISABLED until fixed. failures=${_decryptFailures}`);
throw e;
}
}
/** Remove a stored credential. Returns true if a record was deleted. */
export function deleteCredential(email, env = process.env) {
if (!isCredStoreEnabled(env)) return false;
const key = normalizeEmail(email);
return withStoreLock(env, lockedEnv => {
const snapshot = readSnapshot(lockedEnv);
if (!Object.hasOwn(snapshot.store.records, key)) return false;
const store = { v: snapshot.store.v, records: copyRecordMap(snapshot.store.records) };
delete store.records[key];
commitSnapshot(snapshot, store, lockedEnv, [key]);
return true;
});
}
/** List emails with stored credentials (for ops/diagnostics; no secrets). */
export function listCredentialEmails(env = process.env) {
if (!isCredStoreEnabled(env)) return [];
return Object.keys(readStore(env).records);
}
export const __testing = { credFilePath, deriveKey, normalizeEmail, tryRepairJson, salvageRecordsByRegex, isValidRecord, readStore, assertRecordSetPreserved, sentinelPath, claimsPath, claimName, parseClaim, claimState, removeInstance, parseLegacyOwner, legacyOwnerState, markerState, migrateLegacyDir, openSentinel, acquireClaim, releaseInstance, sweepInstances };
// Surface decrypt health through the central connect-metrics endpoint without a
// static import cycle (metrics → credentials → config → ...). Registered at
// import time; the metrics module calls back into getCredHealth on demand.
__registerCredHealth(getCredHealth);