Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/tighten-credentials-permissions.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"nansen-cli": patch
---

Tighten POSIX permissions when rewriting the fallback wallet credentials file and refuse non-regular credential paths.
36 changes: 36 additions & 0 deletions src/__tests__/keychain.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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', () => {
Expand Down
29 changes: 27 additions & 2 deletions src/keychain.js
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
Expand Down