Skip to content

feat: accounts sync between the mobile app and the extension - #2625

Open
sonytooo wants to merge 10 commits into
v2from
feature/accounts-sync
Open

feat: accounts sync between the mobile app and the extension#2625
sonytooo wants to merge 10 commits into
v2from
feature/accounts-sync

Conversation

@sonytooo

Copy link
Copy Markdown
Member

Business logic for syncing selected accounts and their keys between the Ambire mobile app and the browser extension over animated QR codes. Platform-agnostic — the same controller methods serve both products.

Added

  • libs/accountsSyncAccountsSyncPayload v1 ({ v, secret, accounts, keys, seeds }) plus serializeAccountsSyncPayload / parseAccountsSyncPayload. The payload is gzipped (pako) before it is hex encoded for the UR frames, because JSON full of hex strings compresses 3x or better, and inflation is capped at 2MB so a hostile QR code can't balloon the background's memory.
  • KeystoreController.exportForSync(keyAddrs, includeSeeds) — returns the password secret entry, the selected StoredKeys and (optionally) the recovery phrases they were derived from, all read as stored. Nothing is decrypted, so it works while locked; refuses when the device has no password secret.
  • KeystoreController.importFromSync(payload, password) — derives the other device's main key from its password, decrypts the seeds and private keys and re-encrypts them under the local main key through the existing #addSeed / #addKeys / #addKeysExternallyStored. Wrong password reuses the existing silent Incorrect password path.
  • AccountsController.getAccountsForSync(addrs) — the selected Account records, preferences and all.
  • MainController.exportAccountsForSync / importAccountsFromSync — one withStatus-wrapped method per direction. Export ships the payload through the one-time-data channel (never persisted state); import parses and unlocks the key material before #updateAccounts, so a wrong password adds neither keys nor accounts. Both accept an optional trailing requestId and reply through it, so the UI can await the result.

Changed / fixed along the way

  • decryptMainKeyWithSecret extracted into libs/keystore (the exact inverse of the existing encryptMainKeyWithSecret) and reused by #unlockWithSecretGCM, so the unwrap + wrong-password mapping exists once.
  • #addSeed takes an optional id and returns the stored id, so a synced seed keeps the id the other device gave it and the synced keys' meta.fromSeedId keeps resolving.
  • Pre-existing bug: the isReadyToStoreKeys setter fired #addKeys and #addKeysExternallyStored in parallel void calls, and both rewrite the whole keystoreKeys storage entry — whichever finished last dropped the other's keys. No existing flow queued both at once; syncing does. They now flush sequentially through #addQueuedKeysAndSeeds (seeds included), which clears the queues, reports failures via emitError instead of an unhandled rejection, and always emitUpdates.
  • New onboarding queue #seedsToAddOnKeystoreReady, next to the two existing key queues, so an import that happens before the device password is set still lands.

Security notes

  • Plaintext key material never enters a QR code — only ciphertexts and the password secret entry travel.
  • Only the password secret is exported; biometrics and email-vault secrets are device-bound and excluded.
  • Legacy AES-CTR payloads are rejected (the keystore migrates them on unlock, and the app must be unlocked to export).
  • The exporter's main key is briefly reconstructed in the importer's memory, used only to decrypt, and never persisted. Export is read-only — nothing is written on the exporting device.
  • The blob is offline brute-forceable against a single password; scrypt N=131072 is the cost barrier, and both designs warn that the QR code carries sensitive information.

Tests

accountsSync.test.ts, 7 new cases in keystore.test.ts and a new mainAccountsSync.test.ts with two full device-to-device round trips: subset export, signer works on the importing device, preferences travel, onboarding order (import → set password → keys land), wrong password imports nothing, foreign QR data imports nothing, empty selection exports nothing, double sync doesn't duplicate.

sonytooo and others added 8 commits August 12, 2026 13:19
Adds the ground work for syncing accounts between the Ambire mobile app and
the browser extension over animated QR codes.

- libs/accountsSync: the transported payload (existing types only), its
  serialization and a strict parser for the scanned bytes
- keystore: exportForSync collects the selected keys, the seeds they were
  derived from and the password wrapped main key, all still encrypted;
  importFromSync unwraps the other device's main key with its password and
  re-encrypts everything with the local one
- libs/keystore: decryptMainKeyWithSecret, the inverse of
  encryptMainKeyWithSecret, now also used by the GCM unlock path
- addSeed can preserve the id a seed had on the other device, so synced keys
  keep pointing to the seed they were derived from

Fixes a bug where the isReadyToStoreKeys setter added the queued internal and
external keys in parallel; both persist the same storage entry, so one of the
two sets was lost. They are now added sequentially, together with the seeds
queued by a sync that happened before the device password was set.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
- accounts: getAccountsForSync returns the records of the selected accounts
- main: exportAccountsForSync collects them together with the keys controlling
  them and sends the payload to the UI over the one time data channel, so it
  never lands in persisted state
- main: importAccountsFromSync parses the scanned data, hands the keys to the
  keystore and only then adds the accounts, so a wrong password of the other
  device leaves nothing behind

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The UI awaits the export payload and the import result through the existing
one time data channel (requestId), instead of watching a transient status,
which mobile can collapse before the UI renders it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
- accounts with no keys, accounts controlled by a hardware wallet, re-syncing
  the same accounts and syncing keys whose seed the device already has
- exportForSync refuses to export keys or seeds that are still on the old
  encryption, so it fails on the device that can explain it instead of on the
  other one
- lock() drops the queued keys and seeds, as they hold decrypted material while
  waiting for the device password during onboarding

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@socket-security

socket-security Bot commented Aug 17, 2026

Copy link
Copy Markdown

Review the following changes in direct dependencies. Learn more about Socket for GitHub.

Diff Package Supply Chain
Security
Vulnerability Quality Maintenance License
Added@​types/​pako@​2.0.41001007181100
Addedpako@​2.2.010010010086100

View full report

@sonytooo sonytooo self-assigned this Aug 17, 2026
@sonytooo sonytooo added the enhancement New feature or request label Aug 17, 2026
@sonytooo
sonytooo marked this pull request as draft August 17, 2026 10:40
@sonytooo
sonytooo marked this pull request as ready for review August 17, 2026 11:53
@sonytooo

Copy link
Copy Markdown
Member Author

/review

@github-actions

Copy link
Copy Markdown

PR Reviewer Guide 🔍

Here are some key observations to aid the review process:

⏱️ Estimated effort to review: 4 🔵🔵🔵🔵⚪
🔒 No security concerns identified
✅ No TODO sections
⚡ Recommended focus areas for review

Partial state on import failure

In importFromSync, seeds are persisted via #storeSyncedSeed#addSeed (which calls this.#storage.set) before keys are added in the try block at the end. If #addKeys or #addKeysExternallyStored throws (e.g., storage quota exceeded or disk error), the error propagates and #updateAccounts in MainController.importAccountsFromSync is never reached. The result is orphaned seeds in the keystore with no accounts referencing them and no rollback. This violates the invariant against partial updates that leave state inconsistent.

for (const seed of seeds) {
  const entropy = await decryptWithKey(exportedMainKey, seed.seed)
  const seedPassphrase = seed.seedPassphrase
    ? new TextDecoder().decode(await decryptWithKey(exportedMainKey, seed.seedPassphrase))
    : null

  syncedSeedIds[seed.id] = await this.#storeSyncedSeed({
    id: seed.id,
    seed: reconstructSeedFromEntropy(entropy, seedPassphrase),
    seedPassphrase,
    hdPathTemplate: seed.hdPathTemplate,
    notBackedUp: seed.notBackedUp
  })
}

const internalKeys: ReadyToAddKeys['internal'] = []
const externalKeys: ReadyToAddKeys['external'] = []

for (const key of keys) {
  if (key.type !== 'internal') {
    externalKeys.push(key)
    continue
  }

  const { fromSeedId, ...restMeta } = key.meta
  // Drop the reference if the seed didn't come along, so it doesn't dangle
  const syncedFromSeedId = fromSeedId ? syncedSeedIds[fromSeedId] : undefined

  internalKeys.push({
    addr: key.addr,
    type: 'internal',
    label: key.label,
    dedicatedToOneSA: key.dedicatedToOneSA,
    privateKey: hexlify(await decryptWithKey(exportedMainKey, key.privKey)),
    meta: syncedFromSeedId ? { ...restMeta, fromSeedId: syncedFromSeedId } : restMeta
  })
}

try {
  // Both queue themselves until the keystore is ready to store keys, which is what
  // makes syncing before the device password is set (onboarding) work
  await this.#addKeys(internalKeys)
  await this.#addKeysExternallyStored(externalKeys)
} finally {
  this.emitUpdate()
}
Queue data loss on failure

#addQueuedKeysAndSeeds clears #seedsToAddOnKeystoreReady, #internalKeysToAddOnKeystoreReady, and #externalKeysToAddOnKeystoreReady before processing them. If #addSeed or #addKeys fails partway through, the remaining queued items are already cleared from memory and are permanently lost, while any items already persisted remain. The user would need to re-run the entire sync. Consider processing and clearing incrementally (only removing an item after it succeeds) so a failure doesn't discard the unprocessed queue.

async #addQueuedKeysAndSeeds() {
  const seedsToAdd = this.#seedsToAddOnKeystoreReady
  const internalKeysToAdd = this.#internalKeysToAddOnKeystoreReady
  const externalKeysToAdd = this.#externalKeysToAddOnKeystoreReady
  this.#seedsToAddOnKeystoreReady = []
  this.#internalKeysToAddOnKeystoreReady = []
  this.#externalKeysToAddOnKeystoreReady = []

  try {
    // Seeds first, so the keys can keep pointing to the seed they were derived from
    for (const seed of seedsToAdd) {
      await this.#addSeed(seed)
    }
    await this.#addKeys(internalKeysToAdd)
    await this.#addKeysExternallyStored(externalKeysToAdd)
  } catch (error: any) {
    this.emitError({
      level: 'major',
      message:
        'Something went wrong when saving your keys. Please try again or contact support if the problem persists.',
      error: error instanceof Error ? error : new Error('keystore: failed to add queued keys')
    })
  } finally {
    this.emitUpdate()
  }
}
Incomplete key validation

validateKeys checks addr, type, and privKey but does not validate meta, label, or dedicatedToOneSA. A malformed or hostile QR payload with a key missing meta will pass parseAccountsSyncPayload validation, then throw a confusing TypeError at const { fromSeedId, ...restMeta } = key.meta in importFromSync — after seeds have already been persisted, amplifying the partial-state problem. Add a presence check for meta (and other required fields) in validateKeys.

const validateKeys = (keys: any) => {
  if (!Array.isArray(keys)) throw new Error('accountsSync: invalid keys')

  keys.forEach((key) => {
    if (!key || !isAddress(key.addr)) throw new Error('accountsSync: invalid key addr')
    if (typeof key.type !== 'string') throw new Error('accountsSync: invalid key type')

    if (key.type === 'internal') return requireGcmPayload(key.privKey, `key ${key.addr}`)
    // External keys are stored on the hardware device, so there is nothing to encrypt
    if (key.privKey !== null)
      throw new Error(`accountsSync: external key ${key.addr} has a privKey`)
  })
}

Comment on lines +409 to +412
getAccountsForSync(addrs: Account['addr'][]): Account[] {
return this.accounts.filter((account) => addrs.includes(account.addr))
}

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

Comment on lines +760 to +762
const isIdTaken = !!id && this.#keystoreSeeds.some((s) => s.id === id)
const newEntry: StoredKeystoreSeed = {
id: generateUuid(),
id: isIdTaken || !id ? generateUuid() : id,

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.

do we really need to check for a seed with the same id? #findStoredSeed already checks for the same seed content (perhaps we can also add a quick id check there too) and generateUuid will never generate a colliding uuid

Comment on lines +785 to +818
/**
* Adds the seeds and keys that were queued while the keystore was not ready to store
* them yet (e.g. accounts synced from another device during onboarding, where the
* device password is set afterwards).
*
* Everything runs sequentially, because internal and external keys are persisted
* under the same storage key and would otherwise overwrite each other.
*/
async #addQueuedKeysAndSeeds() {
const seedsToAdd = this.#seedsToAddOnKeystoreReady
const internalKeysToAdd = this.#internalKeysToAddOnKeystoreReady
const externalKeysToAdd = this.#externalKeysToAddOnKeystoreReady
this.#seedsToAddOnKeystoreReady = []
this.#internalKeysToAddOnKeystoreReady = []
this.#externalKeysToAddOnKeystoreReady = []

try {
// Seeds first, so the keys can keep pointing to the seed they were derived from
for (const seed of seedsToAdd) {
await this.#addSeed(seed)
}
await this.#addKeys(internalKeysToAdd)
await this.#addKeysExternallyStored(externalKeysToAdd)
} catch (error: any) {
this.emitError({
level: 'major',
message:
'Something went wrong when saving your keys. Please try again or contact support if the problem persists.',
error: error instanceof Error ? error : new Error('keystore: failed to add queued keys')
})
} finally {
this.emitUpdate()
}
}

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.

Nice one 👏 Perhaps we can also emitUpdate only once at the end? (pass emitUpdate=false to all private methods)

Comment on lines +1292 to +1299
const entropy = await decryptWithKey(exportedMainKey, seed.seed)
const seedPassphrase = seed.seedPassphrase
? new TextDecoder().decode(await decryptWithKey(exportedMainKey, seed.seedPassphrase))
: null

syncedSeedIds[seed.id] = await this.#storeSyncedSeed({
id: seed.id,
seed: reconstructSeedFromEntropy(entropy, seedPassphrase),

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.

should we reuse this logic in some way?

Comment on lines +1286 to +1288
// The exporting device's main key. Kept in this scope only, never persisted.
const exportedMainKey = await this.#decryptMainKeyWithSecret(secretKey, secret.aesEncrypted)

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.

Technically it does get persisted in memory because we import it with extractable=true. I don't know why but I 'm a bit worried to have the main key of a device transferred, even encrypted. The reason is that changing the password of the device cannot defend it from a leaked main key. Can't we derive a new key to reencrypt the payload and then use it to decrypt in the new device (instead of leaking the original main key). This shouldn't really be a concern because users will be syncing between their own devices, just sharing so we can discuss it.

Comment thread package.json
"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.

Comment on lines +803 to +805
for (const seed of seedsToAdd) {
await this.#addSeed(seed)
}

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.

addSeed writes to storage

if (secret.aesEncrypted.cipherType !== CIPHER)
throw new Error('keystore: synced main key is not encrypted with GCM')

const secretKey = await deriveSecret(this.#scryptAdapter, password, secret.scryptParams.salt)

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.

Why do we pass only scryptParams.salt? The function should read the secret's params as this will silently break if the extension and mobile app have different configurations

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

enhancement New feature or request Review effort 4/5

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants