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
29 changes: 27 additions & 2 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 2 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@
"events": "^3.3.0",
"hash-wasm": "^4.12.0",
"js-yaml": "^4.1.0",
"pako": "^2.1.0",

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

"scrypt-js": "^3.0.1",
"tldts": "7.0.17",
"uuid": "9.0.0",
Expand Down Expand Up @@ -62,6 +63,7 @@
"@types/mocha": "10.0.1",
"@types/node": "24.10.11",
"@types/node-fetch": "2.6.11",
"@types/pako": "2.0.4",
"@types/ungap__structured-clone": "1.2.0",
"@types/validator": "13.7.17",
"@typescript-eslint/eslint-plugin": "8.59.2",
Expand Down
4 changes: 4 additions & 0 deletions src/controllers/accounts/accounts.ts
Original file line number Diff line number Diff line change
Expand Up @@ -406,6 +406,10 @@ export class AccountsController extends EventEmitter implements IAccountsControl
return this.accountStates[addr]?.[chainId.toString()]
}

getAccountsForSync(addrs: Account['addr'][]): Account[] {
return this.accounts.filter((account) => addrs.includes(account.addr))
}

Comment on lines +409 to +412

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

imo we don't need a dedicated method for this as it's a simple filter that is also not sync specific

resetAccountsNewlyAddedState() {
this.accounts = this.accounts.map((a) => ({ ...a, newlyAdded: false }))
this.emitUpdate()
Expand Down
254 changes: 254 additions & 0 deletions src/controllers/keystore/keystore.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ import {
import { ExternalKey, IKeystoreController, InternalKey } from '../../interfaces/keystore'
import { getPrivateKeyFromSeed, KeyIterator } from '../../libs/keyIterator/keyIterator'
import { stripHexPrefix } from '../../utils/stripHexPrefix'
import wait from '../../utils/wait'
import { StorageController } from '../storage/storage'
import { UiController } from '../ui/ui'
import { KeystoreController } from './keystore'
Expand Down Expand Up @@ -542,3 +543,256 @@ describe('import/export with pub key test', () => {
)
})
})

describe('accounts sync between two devices', () => {
const EXTERNAL_ADDR = '0xd8dA6BF26964aF9D7eEd9e03E53415D37aA96045'
const exportingPass = 'exportingDevicePass'
const importingPass = 'importingDevicePass'

let exportingKeystore: IKeystoreController
let importingKeystore: IKeystoreController
let exportedSeedId: string

const uiCtrl = new UiController({ uiManager })

const createKeystore = () =>
new KeystoreController(
'default',
new StorageController(produceMemoryStore()),
keystoreSigners,
uiCtrl
)

// The in-memory store resolves writes immediately, which hides ordering a real
// (async) device store would expose
const withSlowWrites = (store: ReturnType<typeof produceMemoryStore>) => ({
...store,
set: async (key: any, value: any) => {
await wait(10)

return store.set(key, value)
}
})

const buildPayload = (keyAddrs: string[]) =>
exportingKeystore
.exportForSync(keyAddrs)
.then((exported) => ({ v: 1 as const, accounts: [], ...exported }))

beforeEach(async () => {
exportingKeystore = createKeystore()
await exportingKeystore.addSecret('password', exportingPass, '', false)
await exportingKeystore.unlockWithSecret('password', exportingPass)

await exportingKeystore.addTempSeed({
seed: process.env.SEED,
hdPathTemplate: BIP44_STANDARD_DERIVATION_TEMPLATE
})
await exportingKeystore.persistTempSeed()
exportedSeedId = exportingKeystore.seeds[0]!.id

await exportingKeystore.addKeys([
{
addr: keyPublicAddress,
label: 'Key 1',
type: 'internal',
privateKey: privKey,
dedicatedToOneSA: false,
meta: { createdAt: new Date().getTime(), fromSeedId: exportedSeedId }
}
])
await exportingKeystore.addKeysExternallyStored([
{
addr: EXTERNAL_ADDR,
label: 'Ledger Key 1',
type: 'ledger',
dedicatedToOneSA: false,
meta: {
deviceId: '1',
deviceModel: 'nanoX',
hdPathTemplate: BIP44_STANDARD_DERIVATION_TEMPLATE,
index: 1,
createdAt: new Date().getTime()
}
}
])

importingKeystore = createKeystore()
})

test('exports the selected keys, their seed and the password wrapped main key', async () => {
const exported = await exportingKeystore.exportForSync([keyPublicAddress])

expect(exported.secret.id).toBe('password')
expect(exported.secret.aesEncrypted.cipherType).toBe('AES-GCM')
// The private key must leave the device encrypted, exactly as it is stored
expect(exported.keys).toHaveLength(1)
expect(exported.keys[0]!.addr).toBe(keyPublicAddress)
expect(exported.keys[0]!.privKey).toMatchObject({ cipherType: 'AES-GCM' })
expect(JSON.stringify(exported)).not.toContain(privKey)
// Only the seed the exported key was derived from
expect(exported.seeds.map((s) => s.id)).toEqual([exportedSeedId])
})

test('does not export keys that were not selected', async () => {
const exported = await exportingKeystore.exportForSync([EXTERNAL_ADDR])

expect(exported.keys.map((k) => k.addr)).toEqual([EXTERNAL_ADDR])
expect(exported.seeds).toHaveLength(0)
})

test('leaves the seed behind when the user opted out of exporting it', async () => {
const exported = await exportingKeystore.exportForSync([keyPublicAddress], false)

expect(exported.keys.map((k) => k.addr)).toEqual([keyPublicAddress])
expect(exported.seeds).toHaveLength(0)

// The key still signs on the other device, it is just no longer tied to a seed
await importingKeystore.addSecret('password', importingPass, '', true)
await importingKeystore.importFromSync(
{ v: 1 as const, accounts: [], ...exported },
exportingPass
)

expect(importingKeystore.seeds).toHaveLength(0)
expect(importingKeystore.keys[0]!.meta.fromSeedId).toBeUndefined()
})

describe('Negative cases', () => {
suppressConsoleBeforeEach()

test('refuses to export from a device without a password', async () => {
const biometricsOnlyKeystore = createKeystore()
await biometricsOnlyKeystore.addSecret('biometrics', 'biometricsSecret', '', true)

await expect(biometricsOnlyKeystore.exportForSync([keyPublicAddress])).rejects.toThrow(
'Set a password for this device before syncing your accounts.'
)
})

test('does not import anything when the password of the other device is wrong', async () => {
await importingKeystore.addSecret('password', importingPass, '', true)
const payload = await buildPayload([keyPublicAddress])

await expect(importingKeystore.importFromSync(payload, 'wrongPass')).rejects.toThrow(
'Incorrect password. Please try again.'
)
expect(importingKeystore.keys).toHaveLength(0)
expect(importingKeystore.seeds).toHaveLength(0)
})
})

test('imports keys and seeds into a device that already has a password', async () => {
await importingKeystore.addSecret('password', importingPass, '', true)
const payload = await buildPayload([keyPublicAddress, EXTERNAL_ADDR])

await importingKeystore.importFromSync(payload, exportingPass)

expect(importingKeystore.keys).toHaveLength(2)
expect(importingKeystore.keys).toContainEqual(
expect.objectContaining({ addr: EXTERNAL_ADDR, type: 'ledger', isExternallyStored: true })
)
// The key is re-encrypted with the importing device's main key, so it can sign
const signer = await importingKeystore.getSigner(keyPublicAddress, 'internal')
expect(signer.key.addr).toBe(keyPublicAddress)

// The seed comes along and the key keeps pointing to it
expect(importingKeystore.seeds.map((s) => s.id)).toEqual([exportedSeedId])
expect(importingKeystore.keys.find((k) => k.type === 'internal')?.meta.fromSeedId).toBe(
exportedSeedId
)
expect((await importingKeystore.getSavedSeed(exportedSeedId)).seed).toBe(process.env.SEED)
})

test('imports before the device password is set (onboarding) and stores everything once it is', async () => {
const payload = await buildPayload([keyPublicAddress, EXTERNAL_ADDR])

await importingKeystore.importFromSync(payload, exportingPass)

// Nothing can be stored yet, as there is no main key to encrypt with
expect(importingKeystore.keys).toHaveLength(0)
expect(importingKeystore.seeds).toHaveLength(0)

await importingKeystore.addSecret('password', importingPass, '', true)

expect(importingKeystore.keys).toHaveLength(2)
expect(importingKeystore.seeds.map((s) => s.id)).toEqual([exportedSeedId])
expect(importingKeystore.keys.find((k) => k.type === 'internal')?.meta.fromSeedId).toBe(
exportedSeedId
)
const signer = await importingKeystore.getSigner(keyPublicAddress, 'internal')
expect(signer.key.addr).toBe(keyPublicAddress)
})

test('tells the UI about the synced keys right after the import', async () => {
const payload = await buildPayload([keyPublicAddress, EXTERNAL_ADDR])
await importingKeystore.addSecret('password', importingPass, '', true)

// Without an update the UI keeps the keystore state it had before the sync, which
// makes every imported account look view-only
const keyCountsSeenByTheUi: number[] = []
importingKeystore.onUpdate(() => keyCountsSeenByTheUi.push(importingKeystore.keys.length))

await importingKeystore.importFromSync(payload, exportingPass)

expect(importingKeystore.keys).toHaveLength(2)
expect(keyCountsSeenByTheUi.at(-1)).toBe(2)
})

test('tells the UI about the synced keys once the onboarding password stores them', async () => {
const payload = await buildPayload([keyPublicAddress, EXTERNAL_ADDR])
// Storing the queued keys is detached from `addSecret`, so on a real device (where
// writes take a moment) it finishes after `addSecret` has already emitted. Without an
// update of its own the UI keeps rendering the synced accounts as if they had no keys.
const slowKeystore = new KeystoreController(
'default',
new StorageController(withSlowWrites(produceMemoryStore())),
keystoreSigners,
uiCtrl
)
await slowKeystore.importFromSync(payload, exportingPass)

const keyCountsSeenByTheUi: number[] = []
slowKeystore.onUpdate(() => keyCountsSeenByTheUi.push(slowKeystore.keys.length))

await slowKeystore.addSecret('password', importingPass, '', true)
// The detached storing is still in flight here, so give it room to finish and emit
for (let i = 0; i < 100 && keyCountsSeenByTheUi.at(-1) !== 2; i++) {
await wait(20)
}

expect(slowKeystore.keys).toHaveLength(2)
expect(keyCountsSeenByTheUi.at(-1)).toBe(2)
})

test('links the synced keys to a seed the device already has', async () => {
await importingKeystore.addSecret('password', importingPass, '', true)
// The very same recovery phrase was already imported on this device, under an id of
// its own, so the synced keys have to be linked to that one
await importingKeystore.addTempSeed({
seed: process.env.SEED,
hdPathTemplate: BIP44_STANDARD_DERIVATION_TEMPLATE
})
await importingKeystore.persistTempSeed()
const alreadyStoredSeedId = importingKeystore.seeds[0]!.id

await importingKeystore.importFromSync(await buildPayload([keyPublicAddress]), exportingPass)

expect(importingKeystore.seeds).toHaveLength(1)
expect(importingKeystore.seeds[0]!.id).toBe(alreadyStoredSeedId)
expect(importingKeystore.keys.find((k) => k.type === 'internal')?.meta.fromSeedId).toBe(
alreadyStoredSeedId
)
})

test('syncing the same accounts twice does not duplicate keys or seeds', async () => {
await importingKeystore.addSecret('password', importingPass, '', true)
const payload = await buildPayload([keyPublicAddress, EXTERNAL_ADDR])

await importingKeystore.importFromSync(payload, exportingPass)
await importingKeystore.importFromSync(payload, exportingPass)

expect(importingKeystore.keys).toHaveLength(2)
expect(importingKeystore.seeds).toHaveLength(1)
})
})
Loading