diff --git a/CHANGELOG.md b/CHANGELOG.md index a82b4580..9e1d0fc3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,9 @@ ## Unreleased +- changed: Allow for duress mode setup while in duress mode but only for non-duress accounts. +- fixed: Fixed regression causing pin disabled for other accounts when disabling PIN login within a duress account + ## 2.30.0 (2025-05-26) - added: A `transactionsRemoved` event on `EdgeCurrencyWallet`. diff --git a/src/core/account/account-api.ts b/src/core/account/account-api.ts index f1045b65..3e5f3f15 100644 --- a/src/core/account/account-api.ts +++ b/src/core/account/account-api.ts @@ -263,7 +263,8 @@ export function makeAccountApi(ai: ApiInput, accountId: string): EdgeAccount { : activeAppId + '.duress' // Ensure the duress account exists: if (forDuressAccount) { - if (ai.props.state.clientInfo.duressEnabled) { + // Fake duress mode setup if this is a duress account: + if (this.isDuressAccount) { fakeDuressModeSetup = opts.enableLogin ?? opts.pin != null ai.props.dispatch({ type: 'UPDATE_NEXT' }) return '' diff --git a/src/core/context/client-file.ts b/src/core/context/client-file.ts index 869c713b..76f68e2f 100644 --- a/src/core/context/client-file.ts +++ b/src/core/context/client-file.ts @@ -8,11 +8,7 @@ export const CLIENT_FILE_NAME = 'client.json' export interface ClientInfo { clientId: Uint8Array /** - * LoginId of the account which is under duress and is being impersonated - * by the duress account. It should be the loginId which the duress account - * is nested under.. - * This is only set if duress mode is activated via pin-login with the - * duress account's pin. + * This is a boolean flag that puts the device into duress mode. */ duressEnabled: boolean } diff --git a/src/core/login/login-reducer.ts b/src/core/login/login-reducer.ts index 1c516298..49d534b6 100644 --- a/src/core/login/login-reducer.ts +++ b/src/core/login/login-reducer.ts @@ -6,9 +6,9 @@ import { base58 } from '../../util/encoding' import { RootAction } from '../actions' import { RootState } from '../root-reducer' import { searchTree } from './login' -import { LoginStash } from './login-stash' +import { findDuressStash, LoginStash } from './login-stash' import { WalletInfoFullMap } from './login-types' -import { findPin2Stash, findPin2StashDuress } from './pin2' +import { findPin2Stash } from './pin2' export interface LoginState { readonly apiKey: string @@ -54,14 +54,20 @@ export const login = buildReducer({ // This allows us to lie about PIN being enabled or disabled while in // duress mode! const pin2Stash = clientInfo.duressEnabled - ? findPin2StashDuress(stashTree, appId) - : findPin2Stash(stashTree, appId) + ? // Use the duress stash's PIN settings, but fallback for accounts + // that don't have duress mode setup + findDuressStash(stashTree, appId) ?? findPin2Stash(stashTree, appId) + : // If we're not in duress mode, then do a normal search + findPin2Stash(stashTree, appId) + // If we have found a pin2Stash, or the duress stash we found has + // a pin2Key defined: + const pinLoginEnabled = pin2Stash?.pin2Key != null return { keyLoginEnabled, lastLogin, loginId: base58.stringify(loginId), - pinLoginEnabled: pin2Stash != null, + pinLoginEnabled, recovery2Key: recovery2Key != null ? base58.stringify(recovery2Key) : undefined, username, diff --git a/src/core/login/login-stash.ts b/src/core/login/login-stash.ts index c32f837d..9e297f56 100644 --- a/src/core/login/login-stash.ts +++ b/src/core/login/login-stash.ts @@ -26,6 +26,7 @@ import { EdgeLog, EdgePendingVoucher } from '../../types/types' import { verifyData } from '../../util/crypto/verify' import { base58 } from '../../util/encoding' import { ApiInput } from '../root-pixie' +import { searchTree } from './login' /** * The login data we store on disk. @@ -195,3 +196,17 @@ export const asLoginStash: Cleaner = asObject({ }) export const wasLoginStash = uncleaner(asLoginStash) + +/** + * Returns the duress stash nested within a given LoginStash tree. + * This will return the duress stash even if the stashTree is the duress stash + * itself. + */ +export function findDuressStash( + stashTree: LoginStash, + appId: string +): LoginStash | undefined { + const duressAppId = appId.endsWith('.duress') ? appId : appId + '.duress' + if (stashTree.appId === duressAppId) return stashTree + return searchTree(stashTree, stash => stash.appId === duressAppId) +} diff --git a/src/core/login/pin2.ts b/src/core/login/pin2.ts index a6527e6d..2fe0c150 100644 --- a/src/core/login/pin2.ts +++ b/src/core/login/pin2.ts @@ -15,7 +15,7 @@ import { ApiInput } from '../root-pixie' import { applyKits, searchTree, serverLogin } from './login' import { loginFetch } from './login-fetch' import { getStashById } from './login-selectors' -import { LoginStash } from './login-stash' +import { findDuressStash, LoginStash } from './login-stash' import { LoginKit, LoginTree, SessionKey } from './login-types' import { getLoginOtp } from './otp' @@ -43,21 +43,6 @@ export function findPin2Stash( if (stash?.pin2Key != null) return stash } -/** - * Returns a copy of the PIN login key if one exists on the local device and - * the app id matches the duress appId. - */ -export function findPin2StashDuress( - stashTree: LoginStash, - appId: string -): LoginStash | undefined { - const duressAppId = appId.endsWith('.duress') ? appId : appId + '.duress' - if (stashTree.pin2Key != null && duressAppId === stashTree.appId) - return stashTree - const stash = searchTree(stashTree, stash => stash.appId === duressAppId) - if (stash?.pin2Key != null) return stash -} - /** * Logs a user in using their PIN. * @return A `Promise` for the new root login. @@ -190,7 +175,7 @@ export async function checkPin2( const { stashTree } = getStashById(ai, loginId) const stash = forDuressAccount === true - ? findPin2StashDuress(stashTree, appId) + ? findDuressStash(stashTree, appId) : findPin2Stash(stashTree, appId) if (stash == null || stash.pin2Key == null) { throw new PinDisabledError('No PIN set locally for this account') diff --git a/test/core/login/login.test.ts b/test/core/login/login.test.ts index 2d1da66a..ca032f8b 100644 --- a/test/core/login/login.test.ts +++ b/test/core/login/login.test.ts @@ -312,6 +312,7 @@ describe('pin', function () { }) it('disable pin in duress mode disables pin-login for local user and disables pin-login for main account', async function () { + this.timeout(30000) const world = await makeFakeEdgeWorld([fakeUser], quiet) const context = await world.makeEdgeContext(contextOptions) const account = await context.loginWithPIN(fakeUser.username, fakeUser.pin) @@ -342,6 +343,186 @@ describe('pin', function () { ) }) + describe('disable pin in duress mode disables pin-login for _only_ its local user', function () { + it('when other account has no duress account', async function () { + const world = await makeFakeEdgeWorld([fakeUser], quiet) + const context = await world.makeEdgeContext(contextOptions) + + // Create other account: + const otherAccount = await context.createAccount({ + username: 'other-account', + pin: '1111' + }) + await otherAccount.logout() + + // Setup duress mode for main account: + const account = await context.loginWithPIN( + fakeUser.username, + fakeUser.pin + ) + await account.changePin({ pin: '0000', forDuressAccount: true }) + await account.logout() + + // Disable PIN login while in duress mode: + const duressAccount = await context.loginWithPIN( + fakeUser.username, + '0000' + ) + await duressAccount.changePin({ enableLogin: false }) + await duressAccount.logout() + + // Check the PIN login is disabled, but only for that account : + expect( + context.localUsers.map(user => ({ + username: user.username, + pinLoginEnabled: user.pinLoginEnabled + })) + ).deep.include.members([ + { + username: 'js test 0', + pinLoginEnabled: false + }, + { + username: 'other-account', + pinLoginEnabled: true + } + ]) + }) + + it('when other account has a duress account', async function () { + const world = await makeFakeEdgeWorld([fakeUser], quiet) + const context = await world.makeEdgeContext(contextOptions) + + // Create other account with duress mode setup: + const otherAccount = await context.createAccount({ + username: 'other-account', + pin: '1111' + }) + await otherAccount.changePin({ pin: '0000', forDuressAccount: true }) + await otherAccount.logout() + + // Setup duress mode for main account: + const account = await context.loginWithPIN( + fakeUser.username, + fakeUser.pin + ) + await account.changePin({ pin: '0000', forDuressAccount: true }) + await account.logout() + + // Disable PIN login while in duress mode: + const duressAccount = await context.loginWithPIN( + fakeUser.username, + '0000' + ) + await duressAccount.changePin({ enableLogin: false }) + await duressAccount.logout() + + // Check the PIN login is disabled, but only for that account : + expect( + context.localUsers.map(user => ({ + username: user.username, + pinLoginEnabled: user.pinLoginEnabled + })) + ).deep.include.members([ + { + username: 'js test 0', + pinLoginEnabled: false + }, + { + username: 'other-account', + pinLoginEnabled: true + } + ]) + }) + + it('when other account has a duress account with disabled pin-login', async function () { + this.timeout(30000) + const world = await makeFakeEdgeWorld([fakeUser], quiet) + const context = await world.makeEdgeContext(contextOptions) + + // Create other account with duress mode setup: + const otherAccount = await context.createAccount({ + username: 'other-account', + pin: '1111' + }) + await otherAccount.changePin({ pin: '0000', forDuressAccount: true }) + await otherAccount.logout() + + // Setup duress mode for main account: + const account = await context.loginWithPIN( + fakeUser.username, + fakeUser.pin + ) + await account.changePin({ pin: '0000', forDuressAccount: true }) + await account.logout() + + // Disable PIN for the other account while in duress mode: + const otherDuressAccount = await context.loginWithPIN( + 'other-account', + '0000' + ) + await otherDuressAccount.changePin({ enableLogin: false }) + await otherDuressAccount.logout() + + // Disable PIN login while in duress mode for main account: + const duressAccount = await context.loginWithPIN( + fakeUser.username, + '0000' + ) + await duressAccount.changePin({ enableLogin: false }) + await duressAccount.logout() + + // Check the PIN login is disabled, but only for that account : + expect( + context.localUsers.map(user => ({ + username: user.username, + pinLoginEnabled: user.pinLoginEnabled + })) + ).deep.include.members([ + { + username: 'js test 0', + pinLoginEnabled: false + }, + { + username: 'other-account', + pinLoginEnabled: false + } + ]) + }) + }) + + it('pin-login remains enabled for accounts without duress mode setup after duress mode login', async function () { + const world = await makeFakeEdgeWorld([fakeUser], quiet) + const context = await world.makeEdgeContext(contextOptions) + + const otherAccount = await context.createAccount({ + username: 'other-account', + pin: '1111' + }) + await otherAccount.logout() + + const account = await context.loginWithPIN(fakeUser.username, fakeUser.pin) + await account.changePin({ pin: '0000', forDuressAccount: true }) + await account.logout() + + // Duress login + const duressAccount = await context.loginWithPIN(fakeUser.username, '0000') + await duressAccount.logout() + + // Check the PIN login is disabled: + expect( + context.localUsers.map(user => ({ + username: user.username, + pinLoginEnabled: user.pinLoginEnabled + })) + ).deep.include.members([ + { + username: 'other-account', + pinLoginEnabled: true + } + ]) + }) + it('exiting duress mode with a disable pin-login in duress account re-enables pin-login for local user but not for the duress account', async function () { const world = await makeFakeEdgeWorld([fakeUser], quiet) const context = await world.makeEdgeContext(contextOptions) @@ -353,11 +534,12 @@ describe('pin', function () { await otherAccount.changePin({ pin: '0000', forDuressAccount: true }) await otherAccount.logout() + // Setup duress on main account: const account = await context.loginWithPIN(fakeUser.username, fakeUser.pin) await account.changePin({ pin: '0000', forDuressAccount: true }) await account.logout() - // Disable PIN login for duress account: + // Disable PIN login for duress account on the main account: const duressAccount = await context.loginWithPIN(fakeUser.username, '0000') await duressAccount.changePin({ enableLogin: false }) await duressAccount.logout() @@ -513,6 +695,7 @@ describe('pin', function () { }) expect(context.localUsers[0].pinLoginEnabled).equals(true) }) + it('check', async function () { const world = await makeFakeEdgeWorld([fakeUser], quiet) const context = await world.makeEdgeContext(contextOptions) @@ -664,6 +847,71 @@ describe('duress', function () { expect(duressAccount.isDuressAccount).equals(true) }) + describe('setup duress mode should be faked', function () { + it('while in duress account', async function () { + const world = await makeFakeEdgeWorld([fakeUser], quiet) + const context = await world.makeEdgeContext(contextOptions) + + // Setup duress mode: + const account = await context.loginWithPIN( + fakeUser.username, + fakeUser.pin + ) + await account.changePin({ pin: '0000', forDuressAccount: true }) + + // Login to duress account: + const duressAccount = await context.loginWithPIN( + fakeUser.username, + '0000' + ) + + // Setup duress account: + await duressAccount.changePin({ pin: '3333', forDuressAccount: true }) + + await duressAccount.logout() + + await expectRejection( + context.loginWithPIN(fakeUser.username, '3333'), + 'PasswordError: Invalid password' + ) + }) + }) + + describe('setup duress mode should work', function () { + it('while in duress mode but logged into a main account', async function () { + const world = await makeFakeEdgeWorld([fakeUser], quiet) + const context = await world.makeEdgeContext(contextOptions) + + // Setup other account: + await ( + await context.createAccount({ username: 'other-account', pin: '1111' }) + ).logout() + + // Setup duress mode: + const account = await context.loginWithPIN( + fakeUser.username, + fakeUser.pin + ) + await account.changePin({ pin: '0000', forDuressAccount: true }) + + // Login to duress account to enable duress mode: + const duressAccount = await context.loginWithPIN( + fakeUser.username, + '0000' + ) + await duressAccount.logout() + + // Setup duress account on other account: + const otherAccount = await context.loginWithPIN('other-account', '1111') + await otherAccount.changePin({ pin: '3333', forDuressAccount: true }) + await otherAccount.logout() + + const topicAccount = await context.loginWithPIN('other-account', '3333') + expect(topicAccount.isDuressAccount).to.equal(true) + expect(topicAccount.id).to.not.equal(otherAccount.id) + }) + }) + it('list new user even after login with duress mode', async function () { const world = await makeFakeEdgeWorld([fakeUser], quiet) const context = await world.makeEdgeContext(contextOptions)