import { afterEach, beforeEach, describe, it } from 'node:test'; import assert from 'node:assert/strict'; import { tmpdir } from 'os'; import { join } from 'path'; import { rmSync, readFileSync, writeFileSync, mkdtempSync } from 'fs'; import { getCredKey, isCredStoreEnabled, storeCredential, getCredential, hasCredential, deleteCredential, listCredentialEmails, __testing, } from '../src/devin-connect-credentials.js'; // Each test gets an isolated cred file + key via a synthetic env object. let dir; function mkEnv(key = 'test-master-key-123') { return { DEVIN_CONNECT_CRED_KEY: key, DEVIN_CONNECT_CRED_FILE: join(dir, 'creds.json') }; } beforeEach(() => { dir = mkdtempSync(join(tmpdir(), 'wsapi-cred-')); }); afterEach(() => { try { rmSync(dir, { recursive: true, force: true }); } catch {} }); describe('credential store — gating', () => { it('is disabled when no master key is set', () => { assert.equal(isCredStoreEnabled({}), false); assert.equal(getCredKey({}), ''); assert.equal(storeCredential('a@b.com', 'pw', {}), false); assert.equal(getCredential('a@b.com', {}), null); assert.equal(hasCredential('a@b.com', {}), false); assert.deepEqual(listCredentialEmails({}), []); }); it('is enabled when a master key is present', () => { assert.equal(isCredStoreEnabled(mkEnv()), true); }); }); describe('credential store — roundtrip', () => { it('encrypts then decrypts back to the original password', () => { const env = mkEnv(); assert.equal(storeCredential('User@Example.com', 's3cret-pw!', env), true); assert.equal(getCredential('user@example.com', env), 's3cret-pw!', 'email is case-insensitive'); assert.equal(hasCredential('USER@EXAMPLE.COM', env), true); assert.deepEqual(listCredentialEmails(env), ['user@example.com']); }); it('never persists the plaintext password on disk', () => { const env = mkEnv(); storeCredential('a@b.com', 'PLAINTEXT_SENTINEL', env); const raw = readFileSync(join(dir, 'creds.json'), 'utf8'); assert.ok(!raw.includes('PLAINTEXT_SENTINEL'), 'ciphertext must not contain the password'); const rec = JSON.parse(raw).records['a@b.com']; assert.ok(rec.salt && rec.iv && rec.tag && rec.ct, 'has salt/iv/tag/ct'); }); it('produces different ciphertext for the same password (random salt+iv)', () => { const env = mkEnv(); storeCredential('a@b.com', 'samepw', env); const ct1 = JSON.parse(readFileSync(join(dir, 'creds.json'), 'utf8')).records['a@b.com'].ct; storeCredential('a@b.com', 'samepw', env); const ct2 = JSON.parse(readFileSync(join(dir, 'creds.json'), 'utf8')).records['a@b.com'].ct; assert.notEqual(ct1, ct2); assert.equal(getCredential('a@b.com', env), 'samepw'); }); it('returns null for an unknown email', () => { assert.equal(getCredential('nobody@x.com', mkEnv()), null); }); it('deletes a stored credential', () => { const env = mkEnv(); storeCredential('a@b.com', 'pw', env); assert.equal(deleteCredential('a@b.com', env), true); assert.equal(hasCredential('a@b.com', env), false); assert.equal(deleteCredential('a@b.com', env), false, 'second delete is a no-op'); }); }); describe('credential store — tamper / wrong key', () => { it('throws (fails closed) when decrypting with a different master key', () => { storeCredential('a@b.com', 'pw', mkEnv('key-one')); assert.throws( () => getCredential('a@b.com', { DEVIN_CONNECT_CRED_KEY: 'key-two', DEVIN_CONNECT_CRED_FILE: join(dir, 'creds.json') }), /unable to authenticate|bad decrypt|auth/i, ); }); it('throws when the ciphertext is tampered with', () => { const env = mkEnv(); storeCredential('a@b.com', 'pw', env); const file = join(dir, 'creds.json'); const store = JSON.parse(readFileSync(file, 'utf8')); // Flip a hex nibble in the ciphertext. const ct = store.records['a@b.com'].ct; store.records['a@b.com'].ct = (ct[0] === 'a' ? 'b' : 'a') + ct.slice(1); writeFileSync(file, JSON.stringify(store)); assert.throws(() => getCredential('a@b.com', env), /auth|decrypt/i); }); it('requires email and password to store', () => { const env = mkEnv(); assert.throws(() => storeCredential('', 'pw', env), /required/); assert.throws(() => storeCredential('a@b.com', '', env), /required/); }); // GPT-05 (independent audit, 2026-09-22): a 16-byte GCM tag truncated to 4 bytes // still decrypted the password, because the read path passed no `authTagLength` and // node authenticates against whatever tag the file presents (with only a DEP0182 // warning). The tag length is part of this format, not a caller preference. it('refuses a record whose GCM tag was truncated (fails closed)', () => { const env = mkEnv(); storeCredential('a@b.com', 'pw', env); const file = join(dir, 'creds.json'); const store = JSON.parse(readFileSync(file, 'utf8')); const fullTag = store.records['a@b.com'].tag; assert.equal(fullTag.length, 32, 'the writer stores the full 16-byte tag as hex'); for (const keep of [8, 16, 24]) { // 4, 8 and 12 bytes store.records['a@b.com'].tag = fullTag.slice(0, keep); writeFileSync(file, JSON.stringify(store)); assert.throws( () => getCredential('a@b.com', env), /credential record tag is not the 16-byte GCM tag/, `a ${keep / 2}-byte tag must not be accepted as this format's 16-byte tag`, ); } }); it('refuses a record whose tag field is malformed or over-long', () => { const env = mkEnv(); storeCredential('a@b.com', 'pw', env); const file = join(dir, 'creds.json'); const store = JSON.parse(readFileSync(file, 'utf8')); const fullTag = store.records['a@b.com'].tag; for (const bad of ['', fullTag + 'ab', 'zzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzz', null]) { store.records['a@b.com'].tag = bad; writeFileSync(file, JSON.stringify(store)); assert.throws(() => getCredential('a@b.com', env), /credential record tag is not the 16-byte GCM tag/, `tag ${JSON.stringify(bad)} must be refused`); } // Control: the untouched record still decrypts, so the assertions above are about // the tag and not about the store being unreadable. store.records['a@b.com'].tag = fullTag; writeFileSync(file, JSON.stringify(store)); assert.equal(getCredential('a@b.com', env), 'pw'); }); }); describe('credential store — derived key', () => { it('derives a 32-byte AES-256 key', () => { const k = __testing.deriveKey('master', Buffer.from('0123456789abcdef')); assert.equal(k.length, 32); }); }); describe('credential store — corruption resilience (C6)', () => { // Seed two real records, then corrupt the file in different ways and assert // the resilient reader still recovers the intact credentials. A single bad // byte must NOT wipe the whole fleet's relogin credentials. function seedTwo(env) { storeCredential('alice@example.com', 'alice-pw', env); storeCredential('bob@example.com', 'bob-pw', env); } it('recovers complete records from a file truncated mid-write', () => { const env = mkEnv(); seedTwo(env); const file = join(dir, 'creds.json'); const raw = readFileSync(file, 'utf8'); // Simulate a crash mid-write: chop the file partway through the 2nd record. // The truncation leaves an unterminated string + unbalanced braces, so JSON // repair can't help — per-record salvage (tier 3) recovers alice intact. const cut = raw.indexOf('bob@example.com') + 30; writeFileSync(file, raw.slice(0, cut)); // alice's record is complete and must survive; bob's was truncated. assert.equal(getCredential('alice@example.com', env), 'alice-pw'); // Self-heal: the file is now clean JSON again on re-read. const healed = JSON.parse(readFileSync(file, 'utf8')); assert.ok(healed.records['alice@example.com']); }); it('tier 2: strips a UTF-8 BOM and a trailing comma', () => { const env = mkEnv(); storeCredential('alice@example.com', 'alice-pw', env); const file = join(dir, 'creds.json'); const raw = readFileSync(file, 'utf8'); // Prepend a BOM and inject a trailing comma before the final brace. const broken = '' + raw.replace(/\}\s*\}\s*$/, '},}'); writeFileSync(file, broken); assert.equal(getCredential('alice@example.com', env), 'alice-pw'); }); it('tier 3: regex-salvages intact records when the JSON wrapper is unrepairable', () => { const env = mkEnv(); seedTwo(env); const file = join(dir, 'creds.json'); const raw = readFileSync(file, 'utf8'); // Mangle the wrapper beyond JSON repair (garbage prefix + broken structure) // but leave both record bodies textually intact. const broken = 'GARBAGE!!!{{{ not json at all \n' + raw.replace(/^\s*\{/, ''); writeFileSync(file, broken); // Both records' { salt, iv, tag, ct } blobs are intact → both recover. assert.equal(getCredential('alice@example.com', env), 'alice-pw'); assert.equal(getCredential('bob@example.com', env), 'bob-pw'); }); it('tier 3: drops only the mangled record, keeps the rest', () => { const env = mkEnv(); seedTwo(env); const file = join(dir, 'creds.json'); const raw = readFileSync(file, 'utf8'); // Corrupt bob's ct field (odd-out hex) and break the wrapper so we hit tier 3. let broken = raw.replace(/("bob@example\.com"\s*:\s*\{[^}]*"ct"\s*:\s*")[0-9a-f]+/, '$1ZZZ_not_hex'); broken = 'noise\n' + broken.replace(/^\s*\{/, ''); writeFileSync(file, broken); assert.equal(getCredential('alice@example.com', env), 'alice-pw', 'intact record survives'); assert.equal(hasCredential('bob@example.com', env), false, 'mangled record is dropped, not poisoning the store'); }); it('tryRepairJson fixes a trailing comma and tail garbage; returns null when hopeless', () => { const good = '{ "v": 1, "records": { "a@x.com": { "salt": "aa", "iv": "bb", "tag": "cc", "ct": "dd" } }, }'; const repaired = __testing.tryRepairJson(good); assert.ok(repaired && repaired.records['a@x.com'], 'trailing comma repaired (tier 2)'); // Trailing garbage after the last brace is dropped. assert.ok(__testing.tryRepairJson('{ "v": 1, "records": {} } GARBAGE TAIL'), 'tail after last brace dropped'); // No closing brace at all → unrepairable (caller falls through to tier 3). assert.equal(__testing.tryRepairJson('{ "records": { "a": '), null); }); it('salvageRecordsByRegex validates hex shape and skips wrapper keys', () => { const text = `{ "v": 1, "records": { "good@x.com": { "salt": "aa", "iv": "bb", "tag": "cc", "ct": "dd" }, "bad@x.com": { "salt": "nothex", "iv": "bb", "tag": "cc", "ct": "dd" } } }`; const out = __testing.salvageRecordsByRegex(text); assert.ok(out['good@x.com']); assert.equal(out['bad@x.com'], undefined, 'non-hex field rejected'); assert.equal(out['records'], undefined, 'wrapper key never treated as a record'); assert.equal(out['v'], undefined); }); it('an unrecoverable empty file reads as an empty store (no throw)', () => { const env = mkEnv(); writeFileSync(join(dir, 'creds.json'), 'totally unparseable @@@@ no records here'); assert.deepEqual(listCredentialEmails(env), []); }); }); describe('S6-F5: a sub-16-char master key does not silently enable the store', () => { it('reports disabled below the entropy floor (fail-closed with a loud error)', () => { assert.equal(isCredStoreEnabled({ DEVIN_CONNECT_CRED_KEY: 'x' }), false); assert.equal(isCredStoreEnabled({ DEVIN_CONNECT_CRED_KEY: 'short-key' }), false); assert.equal(isCredStoreEnabled({ DEVIN_CONNECT_CRED_KEY: 'f'.repeat(15) }), false); assert.equal(isCredStoreEnabled({ DEVIN_CONNECT_CRED_KEY: 'f'.repeat(16) }), true, 'boundary: 16 chars is the floor'); assert.equal(isCredStoreEnabled(mkEnv()), true, 'the 19-char fixture still enables'); }); });