diff --git a/lib/authentication/secure_storage/json_credential_manager.js b/lib/authentication/secure_storage/json_credential_manager.js index b187da949..5eee23090 100644 --- a/lib/authentication/secure_storage/json_credential_manager.js +++ b/lib/authentication/secure_storage/json_credential_manager.js @@ -1,53 +1,195 @@ const path = require('path'); const Logger = require('../../logger'); const fs = require('node:fs/promises'); -const os = require('os'); const Util = require('../../util'); -const { validateOnlyUserReadWritePermissionAndOwner } = require('../../file_util'); +const os = require('os'); +const crypto = require('crypto'); +const { getSecureHandle } = require('../../file_util'); + +function JsonCredentialManager(credentialCacheDir, timeoutMs = 60000) { + const topLevelKey = 'tokens'; + + this.hashKey = function (key) { + return crypto.createHash('sha256').update(key).digest('hex'); + }; + + this.getTokenDirCandidates = function () { + const candidates = []; + if (Util.exists(credentialCacheDir)) { + candidates.push({ folder: credentialCacheDir, subfolders: [] }); + } + const sfTemp = process.env.SF_TEMPORARY_CREDENTIAL_CACHE_DIR; + if (Util.exists(sfTemp)) { + candidates.push({ folder: sfTemp, subfolders: [] }); + } + const xdgCache = process.env.XDG_CACHE_HOME; + if (Util.exists(xdgCache) && process.platform === 'linux') { + candidates.push({ folder: xdgCache, subfolders: ['snowflake'] }); + } + const home = process.env.HOME; + switch (process.platform) { + case 'win32': + candidates.push({ folder: os.homedir(), subfolders: ['AppData', 'Local', 'Snowflake', 'Caches'] }); + break; + case 'linux': + if (Util.exists(home)) { + candidates.push({ folder: home, subfolders: ['.cache', 'snowflake'] }); + } + break; + case 'darwin': + if (Util.exists(home)) { + candidates.push({ folder: home, subfolders: ['Library', 'Caches', 'Snowflake'] }); + } + } + return candidates; + }; + + this.tryTokenDir = async function (dir, subDirs) { + const cacheDir = path.join(dir, ...subDirs); + try { + const stat = await fs.stat(dir); + if (!stat.isDirectory()) { + Logger.getInstance().info(`Path ${dir} is not a directory`); + return false; + } + const cacheStat = await fs.lstat(cacheDir).catch(() => {}); + if (!Util.exists(cacheStat)) { + const options = { recursive: true }; + if (process.platform !== 'win32') { + options.mode = 0o700; + } + await fs.mkdir(cacheDir, options); + return true; + } else { + if (cacheStat.isSymbolicLink()) { + Logger.getInstance().warn(`Path ${cacheDir} is a symbolic link. Symbolic links are not allowed as cache paths.`); + return false; + } + if (process.platform === 'win32') { + return true; + } + if ((cacheStat.mode & 0o777) === 0o700) { + return true; + } + await fs.chmod(cacheDir, 0o700); + return true; + } + } catch (err) { + Logger.getInstance().warn(`The path location ${cacheDir} is invalid. Please check this location is accessible or existing`); + return false; + } + }; -function JsonCredentialManager(credentialCacheDir) { - this.getTokenDir = async function () { - let tokenDir = credentialCacheDir; - if (!Util.exists(tokenDir)) { - tokenDir = os.homedir(); - } else { - Logger.getInstance().info(`The credential cache directory is configured by the user. The token will be saved at ${tokenDir}`); + const candidates = this.getTokenDirCandidates(); + for (const candidate of candidates) { + const { folder: dir, subfolders: subDirs } = candidate; + if (await this.tryTokenDir(dir, subDirs)) { + return path.join(dir, ...subDirs); + } else { + Logger.getInstance().info(`${path.join(dir, ...subDirs)} is not a valid cache directory`); + } } + return null; + }; + + this.getTokenFile = async function () { + const tokenDir = await this.getTokenDir(); if (!Util.exists(tokenDir)) { - throw new Error(`Temporary credential cache directory is invalid, and the driver is unable to use the default location(home). + throw new Error(`Temporary credential cache directory is invalid, and the driver is unable to use the default location. Please set 'credentialCacheDir' connection configuration option to enable the default credential manager.`); } - const tokenCacheFile = path.join(tokenDir, 'temporary_credential.json'); - await validateOnlyUserReadWritePermissionAndOwner(tokenCacheFile); - return tokenCacheFile; + const tokenCacheFile = path.join(tokenDir, 'credential_cache_v1.json'); + return [await getSecureHandle(tokenCacheFile, 'r+', fs), tokenCacheFile]; }; - this.readJsonCredentialFile = async function () { + this.readJsonCredentialFile = async function (fileHandle) { try { - const cred = await fs.readFile(await this.getTokenDir(), 'utf8'); + const cred = await fileHandle.readFile('utf8'); return JSON.parse(cred); } catch (err) { Logger.getInstance().warn('Failed to read token data from the file. Err: %s', err.message); return null; } }; + + this.removeStale = async function (file) { + const stat = await fs.stat(file).catch(() => { + return undefined; + }); + if (!Util.exists(stat)) { + return; + } + if (new Date().getTime() - stat.birthtimeMs > timeoutMs) { + try { + await fs.rmdir(file); + } catch (err) { + Logger.getInstance().warn('Failed to remove stale file. Error: %s', err.message); + } + } + + }; + + + this.withFileLocked = async function (fun) { + const [fileHandle, file] = await this.getTokenFile(); + const lckFile = file + '.lck'; + await this.removeStale(lckFile); + let attempts = 1; + let locked = false; + const options = {}; + if (process.platform !== 'win32') { + options.mode = 0o600; + } + while (attempts <= 10) { + Logger.getInstance().debug('Attempting to get a lock on file %s, attempt: %d', file, attempts); + attempts++; + await fs.mkdir(lckFile, options).then(() => { + locked = true; + }, () => {}); + if (locked) { + break; + } + await new Promise(resolve => setTimeout(resolve, 100)); + } + if (!locked) { + if (Util.exists(fileHandle)) { + await fileHandle.close(); + } + throw new Error('Could not acquire lock on cache file'); + } + const res = await fun(fileHandle, file); + if (Util.exists(fileHandle)) { + await fileHandle.close(); + } + await fs.rmdir(lckFile); + return res; + }; this.write = async function (key, token) { if (!validateTokenCacheOption(key)) { return null; } - - const jsonCredential = await this.readJsonCredentialFile() || {}; - jsonCredential[key] = token; - - try { - await fs.writeFile(await this.getTokenDir(), JSON.stringify(jsonCredential), { mode: 0o600 }); - } catch (err) { - throw new Error(`Failed to write token data. Please check the permission or the file format of the token. ${err.message}`); - } + const keyHash = this.hashKey(key); + + await this.withFileLocked(async (fileHandle, filename) => { + const jsonCredential = await this.readJsonCredentialFile(fileHandle) || {}; + if (!Util.exists(jsonCredential[topLevelKey])) { + jsonCredential[topLevelKey] = {}; + } + jsonCredential[topLevelKey][keyHash] = token; + + try { + const flag = Util.exists(fileHandle) ? 'r+' : 'w'; + const writeFileHandle = await getSecureHandle(filename, flag, fs); + await writeFileHandle.writeFile(JSON.stringify(jsonCredential), { mode: 0o600 }); + await writeFileHandle.close(); + } catch (err) { + throw new Error(`Failed to write token data in ${filename}. Please check the permission or the file format of the token. ${err.message}`); + } + }); }; this.read = async function (key) { @@ -55,28 +197,40 @@ function JsonCredentialManager(credentialCacheDir) { return null; } - const jsonCredential = await this.readJsonCredentialFile(); - if (!!jsonCredential && jsonCredential[key]){ - return jsonCredential[key]; - } else { - return null; - } + const keyHash = this.hashKey(key); + + return await this.withFileLocked(async (fileHandle) => { + const jsonCredential = await this.readJsonCredentialFile(fileHandle); + if (!!jsonCredential && jsonCredential[topLevelKey] && jsonCredential[topLevelKey][keyHash]) { + return jsonCredential[topLevelKey][keyHash]; + } else { + return null; + } + }); }; this.remove = async function (key) { if (!validateTokenCacheOption(key)) { return null; } - const jsonCredential = await this.readJsonCredentialFile(); - - if (jsonCredential && jsonCredential[key]) { - try { - jsonCredential[key] = null; - await fs.writeFile(await this.getTokenDir(), JSON.stringify(jsonCredential), { mode: 0o600 }); - } catch (err) { - throw new Error(`Failed to write token data from the file in ${await this.getTokenDir()}. Please check the permission or the file format of the token. ${err.message}`); - } - } + + const keyHash = this.hashKey(key); + + await this.withFileLocked(async (fileHandle, filename) => { + const jsonCredential = await this.readJsonCredentialFile(fileHandle); + + if (jsonCredential && jsonCredential[topLevelKey] && jsonCredential[topLevelKey][keyHash]) { + try { + jsonCredential[topLevelKey][keyHash] = null; + const flag = Util.exists(fileHandle) ? 'r+' : 'w'; + const writeFileHandle = await getSecureHandle(filename, flag, fs); + await writeFileHandle.writeFile(JSON.stringify(jsonCredential), { mode: 0o600 }); + await writeFileHandle.close(); + } catch (err) { + throw new Error(`Failed to write token data from the file in ${filename}. Please check the permission or the file format of the token. ${err.message}`); + } + } + }); }; function validateTokenCacheOption(key) { diff --git a/lib/file_util.js b/lib/file_util.js index 92db911c5..3526ed603 100644 --- a/lib/file_util.js +++ b/lib/file_util.js @@ -164,7 +164,9 @@ exports.validateOnlyUserReadWritePermissionAndOwner = async function (filePath, if (octalPermissions === '600') { Logger.getInstance().debug(`Validated that the user has only read and write permission for file: ${filePath}, Permission: ${permission}`); } else { - throw new Error(`Invalid file permissions (${octalPermissions} for file ${filePath}). Make sure you have read and write permissions and other users do not have access to it. Please remove the file and re-run the driver.`); + await fsp.chmod(filePath, 0o600).catch(() => { + throw new Error(`Invalid file permissions (${octalPermissions} for file ${filePath}). Make sure you have read and write permissions and other users do not have access to it. Please remove the file and re-run the driver.`); + }); } const userInfo = os.userInfo(); @@ -183,6 +185,52 @@ exports.validateOnlyUserReadWritePermissionAndOwner = async function (filePath, } }; +/** + * Checks if the provided file is writable only by the user and os tha file owner is the same as os user. FsPromises can be provided. + * @param filePath + * @param expectedMode + * @param fsPromises + * @returns {Promise} + */ +exports.getSecureHandle = async function (filePath, flags, fsPromises) { + const fsp = fsPromises ? fsPromises : require('fs/promises'); + try { + //const options = process.platform !== 'win32' ? 0o600 : undefined; + const fileHandle = await fsp.open(filePath, flags, 0o600); + if (os.platform() === 'win32') { + return fileHandle; + } + const stats = await fileHandle.stat(); + const mode = stats.mode; + const permission = mode & 0o777; + + //This should be 600 permission, which means the file permission has not been changed by others. + const octalPermissions = permission.toString(8); + if (octalPermissions === '600') { + Logger.getInstance().debug(`Validated that the user has only read and write permission for file: ${filePath}, Permission: ${permission}`); + } else { + await fileHandle.chmod(0o600).catch(() => { + throw new Error(`Invalid file permissions (${octalPermissions} for file ${filePath}). Make sure you have read and write permissions and other users do not have access to it. Please remove the file and re-run the driver.`); + }); + } + + const userInfo = os.userInfo(); + if (stats.uid === userInfo.uid) { + Logger.getInstance().debug('Validated file owner'); + } else { + throw new Error(`Invalid file owner for file ${filePath}). Make sure the system user are the owner of the file otherwise please remove the file and re-run the driver.`); + } + return fileHandle; + } catch (err) { + //When file doesn't exist - return + if (err.code === 'ENOENT') { + return null; + } else { + throw err; + } + } +}; + /** * Checks if the provided file or directory permissions are correct. * @param filePath diff --git a/lib/util.js b/lib/util.js index 3659f8156..13f686862 100644 --- a/lib/util.js +++ b/lib/util.js @@ -618,7 +618,7 @@ exports.buildCredentialCacheKey = function (host, username, credType) { Logger.getInstance().debug('Cannot build the credential cache key because one of host, username, and credType is null'); return null; } - return `{${host.toUpperCase()}}:{${username.toUpperCase()}}:{SF_NODE_JS_DRIVER}:{${credType.toUpperCase()}}`; + return `{${host.toUpperCase()}}:{${username.toUpperCase()}}:{${credType.toUpperCase()}}`; }; /** @@ -645,14 +645,6 @@ exports.checkParametersDefined = function (...parameters) { return parameters.every((element) => element !== undefined && element !== null); }; -exports.buildCredentialCacheKey = function (host, username, credType) { - if (!host || !username || !credType) { - Logger.getInstance().debug('Cannot build the credential cache key because one of host, username, and credType is null'); - return null; - } - return `{${host.toUpperCase()}}:{${username.toUpperCase()}}:{SF_NODE_JS_DRIVER}:{${credType.toUpperCase()}}`; -}; - /** * * @param {Object} customCredentialManager diff --git a/test/integration/testCache.js b/test/integration/testCache.js index af2b5cc34..5da1c9400 100644 --- a/test/integration/testCache.js +++ b/test/integration/testCache.js @@ -21,16 +21,6 @@ describe('Validate cache permissions test', async function () { await fs.unlink(validPermissionsFilePath); }); - it('should return error on insecure permissions', async function () { - await assert.rejects( - validateOnlyUserReadWritePermissionAndOwner(invalidPermissionsFilePath), - (err) => { - assert.match(err.message, /Invalid file permissions/); - return true; - }, - ); - }); - it('should return error when system user is not a file owner', async function () { const anotherFileOwnerPath = path.join(wrongOwner); const fsMock = createFsMock() diff --git a/test/unit/authentication/json_credential_manager_test.js b/test/unit/authentication/json_credential_manager_test.js index 25ca1b977..f6e401f8e 100644 --- a/test/unit/authentication/json_credential_manager_test.js +++ b/test/unit/authentication/json_credential_manager_test.js @@ -1,23 +1,39 @@ const assert = require('assert'); const JsonCredentialManager = require('../../../lib/authentication/secure_storage/json_credential_manager'); const Util = require('../../../lib/util'); -const { randomUUID } = require('crypto'); +const { randomUUID, createHash } = require('crypto'); const path = require('path'); +const os = require('os'); +const fs = require('node:fs/promises'); const host = 'mock_host'; const user = 'mock_user'; const credType = 'mock_cred'; +const credType2 = 'mock_cred2'; const key = Util.buildCredentialCacheKey(host, user, credType); +const key2 = Util.buildCredentialCacheKey(host, user, credType2); const randomPassword = randomUUID(); -const os = require('os'); +const randomPassword2 = randomUUID(); -describe('Json credential manager test', function () { +const pathFromHome = function () { + switch (process.platform) { + case 'win32': + return ['AppData', 'Local', 'Snowflake', 'Caches']; + case 'linux': + return ['.cache', 'snowflake']; + case 'darwin': + return ['Library', 'Caches', 'Snowflake']; + } + return []; +}; + +describe('Json credential manager basic test', function () { const credentialManager = new JsonCredentialManager(); it('test - initiate credential manager', async function () { if (await credentialManager.read(key) !== null) { await credentialManager.remove(key); } const savedPassword = await credentialManager.read(key); - assert.strictEqual(await credentialManager.getTokenDir(), path.join(os.homedir(), 'temporary_credential.json')); + assert.strictEqual((await credentialManager.getTokenFile())[1], path.join(os.homedir(), ...pathFromHome(), 'credential_cache_v1.json')); assert.strictEqual(savedPassword, null); }); it('test - write the mock credential with the credential manager', async function () { @@ -30,8 +46,111 @@ describe('Json credential manager test', function () { const result = await credentialManager.read(key); assert.ok(result === null); }); - it('test - token saving location when the user sets credentialCacheDir value', async function () { - const credManager = new JsonCredentialManager(os.tmpdir()); - assert.strictEqual(await credManager.getTokenDir(), path.join(os.tmpdir(), 'temporary_credential.json')); + after(async () => { + await fs.rm(path.join(os.homedir(), ...pathFromHome(), 'credential_cache_v1.json')); + }); +}); + +describe('Json credential manager provided path test', function () { + const cacheFromEnvPath = path.join(os.homedir(), 'snowflakeTests', 'cacheFromEnv'); + const XDGPath = path.join(os.homedir(), 'snowflakeTests', 'cacheFromXDG'); + const cacheFromXDGPath = path.join(XDGPath, 'snowflake'); + const cacheFromUserPath = path.join(os.homedir(), 'snowflakeTests', 'user'); + const credentialManager = new JsonCredentialManager(cacheFromUserPath); + const sftccd = process.env['SF_TEMPORARY_CREDENTIAL_CACHE_DIR']; + const xdgch = process.env['XDG_CACHE_HOME']; + process.env['SF_TEMPORARY_CREDENTIAL_CACHE_DIR'] = cacheFromEnvPath; + process.env['XDG_CACHE_HOME'] = XDGPath; + it('test - user cache', async function () { + await fs.mkdir(cacheFromUserPath, { recursive: true, mode: 0o700 }); + assert.strictEqual((await credentialManager.getTokenFile())[1], path.join(cacheFromUserPath, 'credential_cache_v1.json')); + await fs.rm(cacheFromUserPath, { recursive: true }); + }); + it('test - env variable cache', async function () { + await fs.mkdir(cacheFromEnvPath, { recursive: true, mode: 0o700 }); + assert.strictEqual((await credentialManager.getTokenFile())[1], path.join(cacheFromEnvPath, 'credential_cache_v1.json')); + await fs.rm(cacheFromEnvPath, { recursive: true }); + }); + it('test - user cache over env variable cache', async function () { + await fs.mkdir(cacheFromUserPath, { recursive: true, mode: 0o700 }); + await fs.mkdir(cacheFromEnvPath, { recursive: true, mode: 0o700 }); + assert.strictEqual((await credentialManager.getTokenFile())[1], path.join(cacheFromUserPath, 'credential_cache_v1.json')); + await fs.rm(cacheFromUserPath, { recursive: true }); + await fs.rm(cacheFromEnvPath, { recursive: true }); + }); + it('test - defaults to home', async function () { + assert.strictEqual((await credentialManager.getTokenFile())[1], path.join(os.homedir(), ...pathFromHome(), 'credential_cache_v1.json')); + }); + if (process.platform === 'linux') { + it('test - xdg variable cache', async function () { + await fs.mkdir(cacheFromXDGPath, { recursive: true, mode: 0o700 }); + assert.strictEqual((await credentialManager.getTokenFile())[1], path.join(cacheFromXDGPath, 'credential_cache_v1.json')); + await fs.rm(cacheFromXDGPath, { recursive: true }); + }); + it('test - env variable cache over xdg cache', async function () { + await fs.mkdir(cacheFromEnvPath, { recursive: true, mode: 0o700 }); + await fs.mkdir(cacheFromXDGPath, { recursive: true, mode: 0o700 }); + assert.strictEqual((await credentialManager.getTokenFile())[1], path.join(cacheFromEnvPath, 'credential_cache_v1.json')); + await fs.rm(cacheFromEnvPath, { recursive: true }); + await fs.rm(cacheFromXDGPath, { recursive: true }); + }); + } + after(() => { + if (Util.exists(sftccd)) { + process.env['SF_TEMPORARY_CREDENTIAL_CACHE_DIR'] = sftccd; + } else { + delete process.env['SF_TEMPORARY_CREDENTIAL_CACHE_DIR']; + } + if (Util.exists(xdgch)) { + process.env['XDG_CACHE_HOME'] = xdgch; + } else { + delete process.env['XDG_CACHE_HOME']; + } + }); }); + +describe('Json credential locked file failure', function () { + const cacheDirPath = path.join(os.homedir(), ...pathFromHome()); + const lockPath = path.join(cacheDirPath, 'credential_cache_v1.json.lck'); + it('test - fail on locked file', async function () { + await fs.mkdir(lockPath, { recursive: true, mode: 0o700 }); + const credentialManager = new JsonCredentialManager(); + await assert.rejects(async () => { + await credentialManager.write(key, randomPassword); + }, { message: 'Could not acquire lock on cache file' }); + await fs.rm(lockPath, { recursive: true }); + }); +}); + +describe('Json credential remove stale lock', function () { + const cacheDirPath = path.join(os.homedir(), ...pathFromHome()); + const cacheFilePath = path.join(cacheDirPath, 'credential_cache_v1.json'); + const lockPath = path.join(cacheDirPath, 'credential_cache_v1.json.lck'); + it('test - stale lock', async function () { + await fs.mkdir(lockPath, { recursive: true, mode: 0o700 }); + //Set timeout to negative because birthtime is a few ms of on Windows for some reason + const credentialManager = new JsonCredentialManager(null, -100); + await credentialManager.write(key, randomPassword); + await fs.rm(cacheFilePath); + }); +}); + +describe('Json credential format', function () { + const cacheDirPath = path.join(os.homedir(), ...pathFromHome()); + const cacheFilePath = path.join(cacheDirPath, 'credential_cache_v1.json'); + it('test - json format', async function () { + const credentialManager = new JsonCredentialManager(); + await credentialManager.write(key, randomPassword); + await credentialManager.write(key2, randomPassword2); + const hashedKey1 = createHash('sha256').update(key).digest('hex'); + const hashedKey2 = createHash('sha256').update(key2).digest('hex'); + const credentials = JSON.parse(await fs.readFile(cacheFilePath, 'utf8')); + assert.strictEqual(Util.exists(credentials), true); + assert.strictEqual(Util.exists(credentials['tokens']), true); + assert.strictEqual(credentials['tokens'][hashedKey1], randomPassword); + assert.strictEqual(credentials['tokens'][hashedKey2], randomPassword2); + await fs.rm(cacheFilePath); + }); +}); + diff --git a/test/unit/util_test.js b/test/unit/util_test.js index 47a6fcb8f..d6f4987fc 100644 --- a/test/unit/util_test.js +++ b/test/unit/util_test.js @@ -943,16 +943,12 @@ describe('Util', function () { user: mockUser, host: mockHost, cred: mockCred, - result: '{mockHost}:{mockUser}:{SF_NODE_JS_DRIVER}:{mockCred}}' + result: '{MOCKHOST}:{MOCKUSER}:{MOCKCRED}' }, ]; - testCases.forEach((name, user, host, cred, result) => { + testCases.forEach(({ name, user, host, cred, result }) => { it(`${name}`, function () { - if (!result) { - assert.strictEqual(Util.buildCredentialCacheKey(host, user, cred), null); - } else { - assert.strictEqual(Util.buildCredentialCacheKey(host, user, cred), result); - } + assert.strictEqual(Util.buildCredentialCacheKey(host, user, cred), result); }); }); });