diff --git a/.changeset/tighten-credentials-permissions.md b/.changeset/tighten-credentials-permissions.md new file mode 100644 index 00000000..0aa781bf --- /dev/null +++ b/.changeset/tighten-credentials-permissions.md @@ -0,0 +1,5 @@ +--- +"nansen-cli": patch +--- + +Tighten POSIX permissions when rewriting the fallback wallet credentials file and refuse non-regular credential paths. diff --git a/src/__tests__/keychain.test.js b/src/__tests__/keychain.test.js index 7b1cb858..cf63a689 100644 --- a/src/__tests__/keychain.test.js +++ b/src/__tests__/keychain.test.js @@ -72,6 +72,42 @@ describe('keychain', () => { expect(content).toContain('NANSEN_WALLET_PASSWORD_B64='); const b64 = content.match(/NANSEN_WALLET_PASSWORD_B64=(.+)/)[1].trim(); expect(Buffer.from(b64, 'base64').toString('utf8')).toBe('mypassword'); + if (originalPlatform !== 'win32') { + expect(fs.statSync(credPath).mode & 0o777).toBe(0o600); + } + }); + + it.skipIf(process.platform === 'win32')('should tighten permissions when rewriting an existing credentials file on POSIX', () => { + setPlatform('linux'); + execFileSync.mockImplementation(() => { throw new Error('secret-tool unavailable'); }); + + const credDir = path.join(tempDir, '.nansen', 'wallets'); + const credPath = path.join(credDir, '.credentials'); + fs.mkdirSync(credDir, { recursive: true }); + fs.writeFileSync(credPath, 'old credentials\n'); + // writeFileSync's mode is filtered by the process umask, so set and + // verify the insecure starting state explicitly. + fs.chmodSync(credPath, 0o644); + expect(fs.statSync(credPath).mode & 0o777).toBe(0o644); + + expect(storePassword('replacement-password')).toEqual({ stored: true, method: 'file' }); + expect(fs.statSync(credPath).mode & 0o777).toBe(0o600); + expect(retrievePassword()).toEqual({ password: 'replacement-password', source: 'file' }); + }); + + it.skipIf(process.platform === 'win32')('should refuse to overwrite a credentials symlink on POSIX', () => { + setPlatform('linux'); + execFileSync.mockImplementation(() => { throw new Error('secret-tool unavailable'); }); + + const credDir = path.join(tempDir, '.nansen', 'wallets'); + const credPath = path.join(credDir, '.credentials'); + const targetPath = path.join(tempDir, 'symlink-target'); + fs.mkdirSync(credDir, { recursive: true }); + fs.writeFileSync(targetPath, 'leave unchanged\n'); + fs.symlinkSync(targetPath, credPath); + + expect(storePassword('replacement-password')).toEqual({ stored: false, method: 'none' }); + expect(fs.readFileSync(targetPath, 'utf8')).toBe('leave unchanged\n'); }); it('should fall back to .credentials on unsupported platform', () => { diff --git a/src/keychain.js b/src/keychain.js index a7743edd..e37b7db3 100644 --- a/src/keychain.js +++ b/src/keychain.js @@ -151,8 +151,33 @@ function credentialsFileWrite(password) { fs.mkdirSync(dir, { mode: 0o700, recursive: true }); } const encoded = Buffer.from(password, 'utf8').toString('base64'); - fs.writeFileSync(filePath, `NANSEN_WALLET_PASSWORD_B64=${encoded}\n`, { mode: 0o600 }); - return true; + const content = `NANSEN_WALLET_PASSWORD_B64=${encoded}\n`; + + // Windows does not implement POSIX modes or O_NOFOLLOW consistently. + if (process.platform === 'win32') { + fs.writeFileSync(filePath, content, { mode: 0o600 }); + return true; + } + + let fd; + try { + const flags = fs.constants.O_WRONLY + | fs.constants.O_CREAT + | fs.constants.O_NOFOLLOW + | fs.constants.O_NONBLOCK; + fd = fs.openSync(filePath, flags, 0o600); + if (!fs.fstatSync(fd).isFile()) throw new Error('Credentials path is not a regular file'); + + // Opening an existing file does not apply the requested mode. Tighten it + // before replacing the secret, then enforce the final mode after writing. + fs.fchmodSync(fd, 0o600); + fs.ftruncateSync(fd, 0); + fs.writeFileSync(fd, content, 'utf8'); + fs.fchmodSync(fd, 0o600); + return true; + } finally { + if (fd !== undefined) fs.closeSync(fd); + } } catch { return false; }