import assert from 'node:assert/strict'; import test from 'node:test'; import { Common } from '../../backend/utils/common.js'; test('maskPasswords masks TOTP and SSO cookie secrets along with passwords', () => { const config = { secret2FA: 'JBSWY3DPEHPK3PXP', multiPassHashed: 'password-hash', SSO: { rtlSSO: 1, rtlCookiePath: '/cookie-path', cookieValue: 'live-sso-cookie' }, nodes: [{ index: 1, authentication: { lnApiPassword: 'eclair-pass', macaroonPath: '/macaroon/path' } }] }; const masked = Common.maskPasswords(config); assert.equal(masked.secret2FA, '*'.repeat(20)); assert.equal(masked.SSO.cookieValue, '*'.repeat(20)); assert.equal(masked.multiPassHashed, '*'.repeat(20)); assert.equal(masked.nodes[0].authentication.lnApiPassword, '*'.repeat(20)); // Paths are configuration, not secrets — they must stay visible for the settings UI. assert.equal(masked.nodes[0].authentication.macaroonPath, '/macaroon/path'); assert.equal(masked.SSO.rtlCookiePath, '/cookie-path'); }); test('removeSecureData strips the SSO cookie along with the other secrets', () => { const config = { rtlConfFilePath: '/conf', rtlPass: 'password-hash', multiPassHashed: 'password-hash', secret2FA: 'JBSWY3DPEHPK3PXP', SSO: { rtlSSO: 1, rtlCookiePath: '/cookie-path', logoutRedirectLink: '', cookieValue: 'live-sso-cookie' }, nodes: [{ index: 1, authentication: { macaroonPath: '/macaroon/path', runeValue: 'rune', options: {} } }] }; const cleaned = Common.removeSecureData(config); assert.equal(cleaned.rtlConfFilePath, undefined); assert.equal(cleaned.rtlPass, undefined); assert.equal(cleaned.multiPassHashed, undefined); assert.equal(cleaned.secret2FA, undefined); assert.equal(cleaned.SSO.cookieValue, undefined); // Non-secret SSO settings survive — the settings UI renders them. assert.equal(cleaned.SSO.rtlCookiePath, '/cookie-path'); assert.equal(cleaned.nodes[0].authentication.macaroonPath, undefined); }); test('removeSecureData does not mutate its input', () => { // cookieValue is runtime-only: if a caller ever passes the live appConfig, an in-place // delete would wipe SSO state with no way to restore it. The function must clone. const config = { rtlPass: 'password-hash', SSO: { cookieValue: 'live-sso-cookie' }, nodes: [] }; Common.removeSecureData(config); assert.equal(config.rtlPass, 'password-hash'); assert.equal(config.SSO.cookieValue, 'live-sso-cookie'); }); test('maskPasswords masks rtlPass and runeValue', () => { const config = { rtlPass: 'login-hash', nodes: [{ index: 1, authentication: { runeValue: 'cln-rune' } }] }; const masked = Common.maskPasswords(config); assert.equal(masked.rtlPass, '*'.repeat(20)); assert.equal(masked.nodes[0].authentication.runeValue, '*'.repeat(20)); }); test('maskPasswords tolerates null values and numeric keys without skipping secrets', () => { // Integer-like keys order first; the recursion must not clobber its own key list, and // typeof null === 'object' must not send it into Object.keys(null). const config = { '1': { nested: 'value' }, lnApiPassword: 'eclair-pass', nothing: null }; const masked = Common.maskPasswords(config); assert.equal(masked.lnApiPassword, '*'.repeat(20)); assert.equal(masked.nothing, null); assert.deepEqual(masked['1'], { nested: 'value' }); }); test('handleError does not echo the absolute file path to the caller', () => { // The path belongs in the server log, not in the API response. const err = Common.handleError({ code: 'ENOENT', path: '/secret/dir/RTL-Config.json' }, 'Test', 'Reading Config Error', { lnImplementation: 'LND', settings: {} }); assert.equal(err.error.includes('/secret/dir'), false); assert.equal(err.message.includes('/secret/dir'), false); }); test('handleError keeps the absolute path out of controller-wrapped errors too', () => { // RTLConf handlers pass { statusCode, message, error: errRes } wrappers; the response // must resolve to the caller's generic message, never the wrapped fs error's path. const err = Common.handleError( { statusCode: 500, message: 'Reading File Error', error: { code: 'ENOENT', path: '/secret/dir/x.bak' } }, 'Test', 'Reading File Error', { lnImplementation: 'LND', settings: {} } ); assert.equal(err.error.includes('/secret/dir'), false); assert.equal(err.message.includes('/secret/dir'), false); }); test('maskPasswords masks every value under a headers key', () => { // Header values are always credential carriers here (macaroon, rune, basic auth), and // key-substring matching cannot catch them without also hiding *Path fields. const config = { authentication: { macaroonPath: '/visible/path', options: { headers: { 'Grpc-Metadata-macaroon': 'deadbeef', rune: 'cln-rune', authorization: 'Basic xyz' } } } }; const masked = Common.maskPasswords(config); assert.equal(masked.authentication.options.headers['Grpc-Metadata-macaroon'], '*'.repeat(20)); assert.equal(masked.authentication.options.headers.rune, '*'.repeat(20)); assert.equal(masked.authentication.options.headers.authorization, '*'.repeat(20)); assert.equal(masked.authentication.macaroonPath, '/visible/path'); }); const seedAppConfig = () => { Common.appConfig = { defaultNodeIndex: 0, selectedNodeIndex: 0, rtlConfFilePath: '/conf', dbDirectoryPath: '/db', rtlPass: 'server-hash', allowPasswordUpdate: true, enable2FA: true, secret2FA: 'server-seed', disableAuth: false, SSO: { rtlSSO: 0, rtlCookiePath: '/server-cookie', logoutRedirectLink: 'https://server-logout', cookieValue: 'server-cookie' }, nodes: [] }; Common.selectedNode = null; Common.nodes = []; }; test('addSecureData pins disableAuth and the SSO object to server-held values', () => { // The settings API must not be able to flip the authentication mode or move SSO fields; // client-supplied values for these are deployment-level switches, not settings. seedAppConfig(); const config = Common.addSecureData({ disableAuth: true, SSO: { rtlSSO: 1, rtlCookiePath: '/client-path', logoutRedirectLink: 'https://client', cookieValue: 'client-cookie' }, secret2FA: 'client-seed', nodes: [] }); assert.equal(config.disableAuth, false); assert.deepEqual(config.SSO, { rtlSSO: 0, rtlCookiePath: '/server-cookie', logoutRedirectLink: 'https://server-logout', cookieValue: 'server-cookie' }); // An explicit non-empty seed is the settings UI's enable flow and is honored. assert.equal(config.secret2FA, 'client-seed'); assert.equal(config.enable2FA, true); }); test('addSecureData restores an omitted TOTP seed and derives enable2FA from the seed', () => { seedAppConfig(); const config = Common.addSecureData({ nodes: [] }); assert.equal(config.secret2FA, 'server-seed'); assert.equal(config.enable2FA, true); }); test('addSecureData treats an empty seed with 2FA claimed on as an omission', () => { // The pre-login config response shape carries secret2FA: ''; echoing it must not wipe // the seed while enable2FA stays on. seedAppConfig(); const config = Common.addSecureData({ secret2FA: '', enable2FA: true, nodes: [] }); assert.equal(config.secret2FA, 'server-seed'); assert.equal(config.enable2FA, true); }); test('addSecureData honors an explicit seed wipe only when 2FA is disabled', () => { // The settings UI's disable flow sends secret2FA: '' together with enable2FA: false. seedAppConfig(); const config = Common.addSecureData({ secret2FA: '', enable2FA: false, nodes: [] }); assert.equal(config.secret2FA, ''); assert.equal(config.enable2FA, false); }); test('addSecureData does not pin an undefined multiPassHashed over the persisted one', () => { // First-boot state of a default install: the file already holds multiPassHashed (the // boot converted it), but the in-memory appConfig still holds plaintext multiPass and // no hash. Pinning undefined here would erase the only password from the file on save // and brick the next boot. seedAppConfig(); Common.appConfig.multiPassHashed = undefined; Common.appConfig.multiPass = 'password'; const config = Common.addSecureData({ nodes: [] }); assert.equal(Object.prototype.hasOwnProperty.call(config, 'multiPassHashed'), false); assert.equal(config.multiPass, 'password'); }); test('addSecureData pins multiPassHashed when the server holds one', () => { seedAppConfig(); Common.appConfig.multiPassHashed = 'server-hash-value'; const config = Common.addSecureData({ multiPassHashed: 'client-value', nodes: [] }); assert.equal(config.multiPassHashed, 'server-hash-value'); }); test('addSecureData pins allowPasswordUpdate and dbDirectoryPath to server-held values', () => { // allowPasswordUpdate is false precisely when the password is environment-managed, and // dbDirectoryPath redirects the runtime database — neither is writable from the UI. seedAppConfig(); Common.appConfig.allowPasswordUpdate = false; Common.appConfig.dbDirectoryPath = '/server-db'; const config = Common.addSecureData({ allowPasswordUpdate: true, dbDirectoryPath: '/client-db', nodes: [] }); assert.equal(config.allowPasswordUpdate, false); assert.equal(config.dbDirectoryPath, '/server-db'); }); test('maskPasswords masks bitcoind rpcauth', () => { const config = { rpcauth: 'user:salt$hmac', rpcuser: 'user', rpcpassword: 'pass' }; const masked = Common.maskPasswords(config); assert.equal(masked.rpcauth, '*'.repeat(20)); assert.equal(masked.rpcuser, '*'.repeat(20)); assert.equal(masked.rpcpassword, '*'.repeat(20)); }); test('maskPasswords does not mutate its input', () => { // Masking a live object must not blank the credentials LN requests authenticate with. const config = { authentication: { options: { headers: { 'Grpc-Metadata-macaroon': 'deadbeef' } } } }; Common.maskPasswords(config); assert.equal(config.authentication.options.headers['Grpc-Metadata-macaroon'], 'deadbeef'); });