From 22eb1c25c84f6a3e5e45b4c2bb30311ae9b372a9 Mon Sep 17 00:00:00 2001 From: rob thijssen Date: Wed, 16 Sep 2026 16:04:13 +0300 Subject: [PATCH] feat: wallets in the background, one secret shown as every account it unlocks A wallet holds one secret: a recovery phrase, or a raw 32-byte seed for the dev accounts. The secret is encrypted with the wallet password (the keyring's scrypt + xsalsa20-poly1305 JSON encryption) under its own storage prefix, quantus:wallet. From it the wallet derives, for each account index: - an ML-DSA-65 and an ML-DSA-87 signing account, at the paths quantus-cli and the mobile wallet use. These are ordinary keyring pairs tagged with walletId and accountIndex, so signing, dapp injection and JSON export don't change. They're named " (ML-DSA-65)" and so on, so a dapp's account list can tell them apart. - 20 receive and 20 change wormhole addresses (m/44'/189189189'/n'/b'/k'), derived by @quantus/crypto 0.2.0. Only addresses leave WASM. They're derived while the password is available, so a balance can later be shown without asking for it. New messages: pri(wallets.subscribe, preview, create, addAccount, rename, forget). - The same secret can't become two wallets, because forgetting one would take the other's pairs with it. - A raw seed has one account and no wormhole addresses: wormhole derivation starts from the 64-byte BIP39 seed. - Adding an account needs the password; a wrong one is refused before any pair is made. @quantus/crypto is resolved to 0.2.0 everywhere, so the common forks and the extension share one copy of the WASM instead of bundling two. The specs use independent vectors only: - the chain node's TEST_WORMHOLE_ADDRESS, also pinned by the mobile wallet's SDK; - quantus-cli's ML-DSA-65 and ML-DSA-87 accounts for its dev phrase; - crystal_alice for the zero seed. Part of quantus/extension#14. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_012uDUodEcRbBwNRi3UCmw8f --- package.json | 3 +- packages/extension-base/package.json | 2 +- .../extension-base/src/background/Wallets.ts | 266 ++++++++++++++++++ .../src/background/handlers/Extension.spec.ts | 135 ++++++++- .../src/background/handlers/Extension.ts | 60 +++- .../extension-base/src/background/types.ts | 64 ++++- packages/extension-base/src/stores/Wallets.ts | 24 ++ packages/extension-base/src/stores/index.ts | 1 + yarn.lock | 10 +- 9 files changed, 554 insertions(+), 11 deletions(-) create mode 100644 packages/extension-base/src/background/Wallets.ts create mode 100644 packages/extension-base/src/stores/Wallets.ts diff --git a/package.json b/package.json index 6da6b377..036a0643 100644 --- a/package.json +++ b/package.json @@ -62,6 +62,7 @@ "@polkadot/util-crypto": "https://git.lair.cafe/api/packages/quantus/npm/%40polkadot%2Futil-crypto/-/14.0.3-quantus.2/util-crypto-14.0.3-quantus.2.tgz", "@polkadot/x-fetch": "^14.0.3", "safe-buffer": "^5.2.1", - "typescript": "^5.5.4" + "typescript": "^5.5.4", + "@quantus/crypto": "^0.2.0" } } diff --git a/packages/extension-base/package.json b/packages/extension-base/package.json index 56269b3f..a54cb6c7 100644 --- a/packages/extension-base/package.json +++ b/packages/extension-base/package.json @@ -35,7 +35,7 @@ "@polkadot/util": "^14.0.3", "@polkadot/util-crypto": "^14.0.3", "@quantus/codec": "^0.5.0", - "@quantus/crypto": "^0.1.1", + "@quantus/crypto": "^0.2.0", "eventemitter3": "^5.0.1", "rxjs": "^7.8.1", "tslib": "^2.8.1" diff --git a/packages/extension-base/src/background/Wallets.ts b/packages/extension-base/src/background/Wallets.ts new file mode 100644 index 00000000..782343a2 --- /dev/null +++ b/packages/extension-base/src/background/Wallets.ts @@ -0,0 +1,266 @@ +// Copyright 2019-2026 @polkadot/extension-base authors & contributors +// SPDX-License-Identifier: Apache-2.0 + +import type { RequestWalletCreate, ResponseWalletPreview, WalletAccount, WalletInfo, WalletJson } from './types.js'; + +import { wormholeAddresses, WormholeBranch } from '@quantus/crypto'; +import { BehaviorSubject } from 'rxjs'; + +import { keyring } from '@polkadot/ui-keyring'; +import { assert, isHex, objectSpread, stringToU8a, u8aToHex, u8aToString } from '@polkadot/util'; +import { jsonDecrypt, jsonEncrypt, mnemonicValidate } from '@polkadot/util-crypto'; + +import { WalletsStore } from '../stores/index.js'; + +/** + * How many wormhole addresses to derive per branch, per account index. + * + * The usual BIP44 gap limit. Deriving needs the recovery phrase, and so the + * password; showing a balance should not. So a window is derived whenever the + * password is to hand, which is at creation and when an account is added. + */ +export const WORMHOLE_WINDOW = 20; + +const SEED_WORDS = [12, 15, 18, 21, 24]; + +type Source = WalletInfo['source']; + +/** The keypair type for each signing tab, and how its pair is named for dapps. */ +const SIGNING = [ + { key: 'mldsa65', label: 'ML-DSA-65', type: 'dilithium65' }, + { key: 'mldsa87', label: 'ML-DSA-87', type: 'dilithium87' } +] as const; + +function sourceOf (secret: string): Source { + if (isHex(secret)) { + assert(isHex(secret, 256), 'A hex seed must be 32 bytes'); + + return 'seed'; + } + + const words = secret.trim().split(/\s+/); + + assert(SEED_WORDS.includes(words.length), `A recovery phrase has ${SEED_WORDS.join(', ')} words`); + assert(mnemonicValidate(secret), 'Not a valid recovery phrase'); + + return 'mnemonic'; +} + +function normalise (secret: string): string { + return isHex(secret.trim()) + ? secret.trim().toLowerCase() + : secret.trim().split(/\s+/).join(' '); +} + +/** + * The suri for one signing account. + * + * `//` is the account index, which the fork's path resolver turns into + * `m/44'/189189'/'/0'/'` — the path `quantus-cli` and the mobile + * wallet derive. A raw seed has no derivation at all: it is the ML-DSA key + * generation input itself, which is how the dev accounts are made. + */ +function suriOf (secret: string, source: Source, index: number): string { + return source === 'seed' + ? secret + : `${secret}//${index}`; +} + +/** The name a signing pair carries into the keyring, and so to dapps. */ +function pairName (name: string, index: number, label: string): string { + return index + ? `${name} #${index} (${label})` + : `${name} (${label})`; +} + +function randomId (): string { + const bytes = new Uint8Array(16); + + globalThis.crypto.getRandomValues(bytes); + + return u8aToHex(bytes).slice(2); +} + +function derive (secret: string, source: Source, index: number): Omit { + const [mldsa65, mldsa87] = SIGNING.map(({ type }) => + keyring.createFromUri(suriOf(secret, source, index), {}, type).address + ); + + return { index, mldsa65, mldsa87 }; +} + +function deriveWormhole (secret: string, source: Source, index: number, count: number): WalletAccount['wormhole'] { + if (source !== 'mnemonic') { + return null; + } + + const encode = (branch: WormholeBranch) => + wormholeAddresses(secret, '', index, branch, 0, count).map((id) => keyring.encodeAddress(id)); + + return { + change: encode(WormholeBranch.Change), + receive: encode(WormholeBranch.Receive) + }; +} + +function info ({ secret: _, ...wallet }: WalletJson): WalletInfo { + return wallet; +} + +/** + * Wallets: a secret shown as every account it unlocks. + * + * The ML-DSA pairs a wallet owns are ordinary keyring pairs, tagged with the + * wallet's id and their account index. Signing, dapp injection and JSON export + * keep working on them unchanged; this only adds the grouping, the stored + * secret that lets more accounts be added later, and the wormhole addresses, + * which have no keyring form because they have no key. + */ +export default class Wallets { + readonly subject = new BehaviorSubject([]); + + readonly #store = new WalletsStore(); + readonly #wallets = new Map(); + readonly #ready: Promise; + + constructor () { + this.#ready = this.#store.all((_, wallet) => { + this.#wallets.set(wallet.id, wallet); + }).then(() => this.#publish()); + } + + #publish (): void { + this.subject.next( + [...this.#wallets.values()] + .sort((a, b) => a.whenCreated - b.whenCreated) + .map(info) + ); + } + + async #save (wallet: WalletJson): Promise { + this.#wallets.set(wallet.id, wallet); + await this.#store.set(wallet.id, wallet); + this.#publish(); + } + + #unlock (wallet: WalletJson, password: string): string { + try { + return u8aToString(jsonDecrypt(wallet.secret, password)); + } catch { + throw new Error('Unable to unlock the wallet: the password is wrong'); + } + } + + #addPairs (wallet: WalletInfo, secret: string, index: number, password: string): void { + for (const { label, type } of SIGNING) { + keyring.addUri(suriOf(secret, wallet.source, index), password, { + accountIndex: index, + genesisHash: wallet.genesisHash, + name: pairName(wallet.name, index, label), + walletId: wallet.id + }, type); + } + } + + /** The account-0 addresses a secret would give, so a user can recognise their wallet. */ + preview (secret: string): ResponseWalletPreview { + const normalised = normalise(secret); + const source = sourceOf(normalised); + const wormhole = deriveWormhole(normalised, source, 0, 1); + + return objectSpread(derive(normalised, source, 0), { + wormhole: wormhole ? wormhole.receive[0] : null + }); + } + + async create ({ genesisHash = null, name, password, secret }: RequestWalletCreate): Promise { + await this.#ready; + + const normalised = normalise(secret); + const source = sourceOf(normalised); + const account = objectSpread(derive(normalised, source, 0), { + wormhole: deriveWormhole(normalised, source, 0, WORMHOLE_WINDOW) + }); + + // The same secret twice would be two cards fighting over one set of keyring + // pairs: forgetting either would take the other's accounts with it. + for (const wallet of this.#wallets.values()) { + assert(wallet.accounts[0]?.mldsa65 !== account.mldsa65, `This secret is already the wallet "${wallet.name}"`); + } + + const wallet: WalletJson = { + accounts: [account], + genesisHash, + id: randomId(), + name, + secret: jsonEncrypt(stringToU8a(normalised), ['scrypt', 'xsalsa20-poly1305'], password), + source, + whenCreated: Date.now() + }; + + this.#addPairs(wallet, normalised, 0, password); + await this.#save(wallet); + + return wallet.id; + } + + /** Add the next account index. Needs the password: it means deriving from the secret. */ + async addAccount (id: string, password: string): Promise { + await this.#ready; + + const wallet = this.#wallets.get(id); + + assert(wallet, 'Unable to find the wallet'); + assert(wallet.source === 'mnemonic', 'A wallet made from a raw seed has one account; only a recovery phrase can derive more'); + + const secret = this.#unlock(wallet, password); + const index = Math.max(...wallet.accounts.map((a) => a.index)) + 1; + const account = objectSpread(derive(secret, wallet.source, index), { + wormhole: deriveWormhole(secret, wallet.source, index, WORMHOLE_WINDOW) + }); + + this.#addPairs(wallet, secret, index, password); + await this.#save(objectSpread({}, wallet, { accounts: [...wallet.accounts, account] })); + } + + async rename (id: string, name: string): Promise { + await this.#ready; + + const wallet = this.#wallets.get(id); + + assert(wallet, 'Unable to find the wallet'); + + for (const { index, ...addresses } of wallet.accounts) { + for (const { key, label } of SIGNING) { + const pair = keyring.getPair(addresses[key]); + + pair && keyring.saveAccountMeta(pair, objectSpread({}, pair.meta, { name: pairName(name, index, label) })); + } + } + + await this.#save(objectSpread({}, wallet, { name })); + } + + /** Forget the wallet, its secret, and every signing pair it owns. */ + async forget (id: string): Promise { + await this.#ready; + + const wallet = this.#wallets.get(id); + + assert(wallet, 'Unable to find the wallet'); + + for (const account of wallet.accounts) { + for (const { key } of SIGNING) { + try { + keyring.forgetAccount(account[key]); + } catch { + // Already forgotten on its own. The wallet still goes. + } + } + } + + this.#wallets.delete(id); + await this.#store.remove(id); + this.#publish(); + } +} diff --git a/packages/extension-base/src/background/handlers/Extension.spec.ts b/packages/extension-base/src/background/handlers/Extension.spec.ts index d598271a..f58ead34 100644 --- a/packages/extension-base/src/background/handlers/Extension.spec.ts +++ b/packages/extension-base/src/background/handlers/Extension.spec.ts @@ -6,7 +6,7 @@ import '@polkadot/extension-mocks/chrome'; import type * as _ from '@polkadot/dev-test/globals.d.ts'; -import type { ResponseSigning } from '@polkadot/extension-base/background/types'; +import type { ResponseSigning, WalletInfo } from '@polkadot/extension-base/background/types'; import type { KeyringPair } from '@polkadot/keyring/types'; import type { SignerPayloadJSON } from '@polkadot/types/types'; import type { HexString } from '@polkadot/util/types'; @@ -15,7 +15,7 @@ import type { KeypairType } from '@polkadot/util-crypto/types'; import { Scheme, sizes } from '@quantus/crypto'; import keyring from '@polkadot/ui-keyring'; -import { hexToU8a, stringToHex, u8aToHex } from '@polkadot/util'; +import { assert, hexToU8a, stringToHex, u8aToHex } from '@polkadot/util'; import { cryptoWaitReady, decodeAddress } from '@polkadot/util-crypto'; import { AccountsStore } from '../../stores/index.js'; @@ -427,4 +427,135 @@ describe('Extension', () => { expect(res).toEqual(true); }); }); + describe('wallets', () => { + // Independent vectors only: none of these came from running this code. + // - the chain node's own test data (node/src/tests/data/quantus_key_test_data.rs), + // also pinned by the mobile wallet's SDK: TEST_MNEMONIC's wormhole receive 0 + // - quantus-cli 2.2.2, via @quantus/crypto's conformance tests: DEV's accounts + // - crystal_alice, the ML-DSA-87 dev account for the all-zero seed + const NODE_PHRASE = 'orchard answer curve patient visual flower maze noise retreat penalty cage small earth domain scan pitch bottom crunch theme club client swap slice raven'; + const NODE_WORMHOLE = '0xdfcfd6e59c75d208e84f54a887537bcf7b04265790ec79960bf49de123404d0e'; + const DEV_PHRASE = 'bottom drive obey lake curtain smoke basket hold race lonely fit walk'; + const DEV_65 = '0xf647dbdefebcfcf726ba078a83481ffc6f4f33004fdfb4cedacf5a5391bc8f00'; + const DEV_87 = '0x11c6a314e003cdee3dc51cf6569175360141578d054c38d7a70840a65cc0e990'; + const ZERO_SEED = `0x${'00'.repeat(32)}`; + const CRYSTAL_ALICE = '0x1883df2ae47d1fd428a6b8237ad7b59cf0facccaacac4541ef7758be44b3c333'; + const port = {} as chrome.runtime.Port; + const id = (address: string) => u8aToHex(decodeAddress(address)); + + let wallets: WalletInfo[] = []; + + beforeAll(async () => { + // the mock's remove() returns nothing, and forgetting awaits it + (chrome.storage.local.remove as unknown as { returns: (v: unknown) => void }).returns(Promise.resolve()); + await extension.handle('wallets', 'pri(wallets.subscribe)', null, { + onDisconnect: { addListener: () => undefined }, + postMessage: ({ subscription }: { subscription?: WalletInfo[] }) => { + subscription && (wallets = subscription); + } + } as unknown as chrome.runtime.Port); + }); + + const walletNamed = (name: string) => wallets.find((w) => w.name === name); + + it('previews all three account-0 addresses of a recovery phrase', async () => { + const dev = await extension.handle('id', 'pri(wallets.preview)', { secret: DEV_PHRASE }, port); + const node = await extension.handle('id', 'pri(wallets.preview)', { secret: NODE_PHRASE }, port); + + expect(id(dev.mldsa65)).toEqual(DEV_65); + expect(id(dev.mldsa87)).toEqual(DEV_87); + expect(node.wormhole && id(node.wormhole)).toEqual(NODE_WORMHOLE); + }); + + it('previews a raw seed with no wormhole account', async () => { + const preview = await extension.handle('id', 'pri(wallets.preview)', { secret: ZERO_SEED }, port); + + expect(id(preview.mldsa87)).toEqual(CRYSTAL_ALICE); + expect(preview.wormhole).toBe(null); + }); + + it('refuses something that is neither a phrase nor a seed', async () => { + await expect(extension.handle('id', 'pri(wallets.preview)', { secret: 'bottom drive obey' }, port)).rejects.toThrow(/recovery phrase has/); + await expect(extension.handle('id', 'pri(wallets.preview)', { secret: '0x1234' }, port)).rejects.toThrow(/32 bytes/); + }); + + it('creates a wallet whose signing accounts are keyring pairs', async () => { + const walletId = await extension.handle('id', 'pri(wallets.create)', { name: 'dev', password, secret: ` ${DEV_PHRASE.replace(/ /g, ' ')} ` }, port); + const wallet = walletNamed('dev'); + + expect(wallet?.id).toEqual(walletId); + expect(wallet && 'secret' in wallet).toBe(false); + expect(wallet?.source).toEqual('mnemonic'); + + const [account] = wallet?.accounts ?? []; + + expect(id(account.mldsa65)).toEqual(DEV_65); + expect(id(account.mldsa87)).toEqual(DEV_87); + expect(account.wormhole?.receive).toHaveLength(20); + expect(account.wormhole?.change).toHaveLength(20); + + const pair65 = keyring.getPair(account.mldsa65); + const pair87 = keyring.getPair(account.mldsa87); + + expect(pair65.type).toEqual('dilithium65'); + expect(pair87.type).toEqual('dilithium87'); + // the harness's expect has no objectContaining, so pick the fields + const tags = ({ meta: { accountIndex, name, walletId } }: KeyringPair) => ({ accountIndex, name, walletId }); + + expect(tags(pair65)).toEqual({ accountIndex: 0, name: 'dev (ML-DSA-65)', walletId }); + expect(tags(pair87)).toEqual({ accountIndex: 0, name: 'dev (ML-DSA-87)', walletId }); + }); + + it('refuses the same secret twice', async () => { + await expect(extension.handle('id', 'pri(wallets.create)', { name: 'again', password, secret: DEV_PHRASE }, port)).rejects.toThrow(/already the wallet "dev"/); + }); + + it('adds the next account index only with the right password', async () => { + const wallet = walletNamed('dev'); + + assert(wallet); + await expect(extension.handle('id', 'pri(wallets.addAccount)', { id: wallet.id, password: 'wrong' }, port)).rejects.toThrow(/password is wrong/); + await extension.handle('id', 'pri(wallets.addAccount)', { id: wallet.id, password }, port); + + const accounts = walletNamed('dev')?.accounts ?? []; + + expect(accounts.map((a) => a.index)).toEqual([0, 1]); + expect(accounts[1].mldsa65).not.toEqual(accounts[0].mldsa65); + expect(accounts[1].wormhole?.receive[0]).not.toEqual(accounts[0].wormhole?.receive[0]); + expect(keyring.getPair(accounts[1].mldsa87).meta.name).toEqual('dev #1 (ML-DSA-87)'); + }); + + it('makes a raw seed wallet with one account and no wormhole', async () => { + const walletId = await extension.handle('id', 'pri(wallets.create)', { name: 'alice', password, secret: ZERO_SEED }, port); + const [account] = walletNamed('alice')?.accounts ?? []; + + expect(id(account.mldsa87)).toEqual(CRYSTAL_ALICE); + expect(account.wormhole).toBe(null); + await expect(extension.handle('id', 'pri(wallets.addAccount)', { id: walletId, password }, port)).rejects.toThrow(/raw seed has one account/); + }); + + it('renames the wallet and the names its pairs carry to dapps', async () => { + const wallet = walletNamed('alice'); + + assert(wallet); + await extension.handle('id', 'pri(wallets.rename)', { id: wallet.id, name: 'crystal' }, port); + + expect(walletNamed('crystal')).toBeDefined(); + expect(keyring.getPair(wallet.accounts[0].mldsa65).meta.name).toEqual('crystal (ML-DSA-65)'); + }); + + it('forgets the wallet and every pair it owns', async () => { + const wallet = walletNamed('dev'); + + assert(wallet); + await extension.handle('id', 'pri(wallets.forget)', { id: wallet.id }, port); + + expect(walletNamed('dev')).toBeUndefined(); + + for (const account of wallet.accounts) { + expect(() => keyring.getPair(account.mldsa65)).toThrow(); + expect(() => keyring.getPair(account.mldsa87)).toThrow(); + } + }); + }); }); diff --git a/packages/extension-base/src/background/handlers/Extension.ts b/packages/extension-base/src/background/handlers/Extension.ts index 711763dc..f50b4134 100644 --- a/packages/extension-base/src/background/handlers/Extension.ts +++ b/packages/extension-base/src/background/handlers/Extension.ts @@ -8,7 +8,7 @@ import type { MetadataDef } from '@polkadot/extension-inject/types'; import type { KeyringPair, KeyringPair$Json, KeyringPair$Meta } from '@polkadot/keyring/types'; import type { SubjectInfo } from '@polkadot/ui-keyring/observable/types'; import type { KeypairType } from '@polkadot/util-crypto/types'; -import type { AccountJson, AllowedPath, AuthorizeRequest, MessageTypes, MetadataRequest, RequestAccountBatchExport, RequestAccountChangePassword, RequestAccountCreateExternal, RequestAccountCreateSuri, RequestAccountEdit, RequestAccountExport, RequestAccountForget, RequestAccountShow, RequestAccountTie, RequestAccountValidate, RequestActiveTabsUrlUpdate, RequestAuthorizeApprove, RequestBalancesSubscribe, RequestBalancesUnsubscribe, RequestBatchRestore, RequestDeriveCreate, RequestDeriveValidate, RequestJsonRestore, RequestMetadataApprove, RequestMetadataReject, RequestSeedCreate, RequestSeedValidate, RequestSigningApprovePassword, RequestSigningApproveSignature, RequestSigningCancel, RequestSigningIsLocked, RequestTypes, RequestUpdateAuthorizedAccounts, ResponseAccountExport, ResponseAccountsExport, ResponseAuthorizeList, ResponseDeriveValidate, ResponseJsonGetAccountInfo, ResponseSeedCreate, ResponseSeedValidate, ResponseSigningIsLocked, ResponseType, SigningRequest } from '../types.js'; +import type { AccountJson, AllowedPath, AuthorizeRequest, MessageTypes, MetadataRequest, RequestAccountBatchExport, RequestAccountChangePassword, RequestAccountCreateExternal, RequestAccountCreateSuri, RequestAccountEdit, RequestAccountExport, RequestAccountForget, RequestAccountShow, RequestAccountTie, RequestAccountValidate, RequestActiveTabsUrlUpdate, RequestAuthorizeApprove, RequestBalancesSubscribe, RequestBalancesUnsubscribe, RequestBatchRestore, RequestDeriveCreate, RequestDeriveValidate, RequestJsonRestore, RequestMetadataApprove, RequestMetadataReject, RequestSeedCreate, RequestSeedValidate, RequestSigningApprovePassword, RequestSigningApproveSignature, RequestSigningCancel, RequestSigningIsLocked, RequestTypes, RequestUpdateAuthorizedAccounts, RequestWalletAddAccount, RequestWalletCreate, RequestWalletForget, RequestWalletPreview, RequestWalletRename, ResponseAccountExport, ResponseAccountsExport, ResponseAuthorizeList, ResponseDeriveValidate, ResponseJsonGetAccountInfo, ResponseSeedCreate, ResponseSeedValidate, ResponseSigningIsLocked, ResponseType, ResponseWalletPreview, SigningRequest, WalletInfo } from '../types.js'; import type { AuthorizedAccountsDiff } from './State.js'; import type State from './State.js'; @@ -21,6 +21,7 @@ import { keyExtractSuri, mnemonicGenerate, mnemonicValidate } from '@polkadot/ut import { isExtrinsicRequest } from '../../utils/index.js'; import Balances from '../Balances.js'; +import Wallets from '../Wallets.js'; import { withErrorLog } from './helpers.js'; import { createSubscription, unsubscribe } from './subscriptions.js'; @@ -38,6 +39,7 @@ function getSuri (seed: string, type?: KeypairType): string { export default class Extension { readonly #balances = new Balances(); + readonly #wallets = new Wallets(); // Ends each balance subscription, by id; each runs once however it is reached. readonly #balanceSubs = new Map(); readonly #cachedUnlocks: CachedUnlocks; @@ -206,6 +208,44 @@ export default class Extension { * belong to one person, so holding a socket open for the life of the browser * would report far more than the feature needs. */ + private walletsSubscribe (id: string, port: chrome.runtime.Port): boolean { + const cb = createSubscription<'pri(wallets.subscribe)'>(id, port); + const subscription = this.#wallets.subject.subscribe((wallets: WalletInfo[]): void => cb(wallets)); + + port.onDisconnect.addListener((): void => { + unsubscribe(id); + subscription.unsubscribe(); + }); + + return true; + } + + private walletsPreview ({ secret }: RequestWalletPreview): ResponseWalletPreview { + return this.#wallets.preview(secret); + } + + private walletsCreate (request: RequestWalletCreate): Promise { + return this.#wallets.create(request); + } + + private async walletsAddAccount ({ id, password }: RequestWalletAddAccount): Promise { + await this.#wallets.addAccount(id, password); + + return true; + } + + private async walletsRename ({ id, name }: RequestWalletRename): Promise { + await this.#wallets.rename(id, name); + + return true; + } + + private async walletsForget ({ id }: RequestWalletForget): Promise { + await this.#wallets.forget(id); + + return true; + } + private balancesSubscribe ({ endpoint }: RequestBalancesSubscribe, id: string, port: chrome.runtime.Port): string { const cb = createSubscription<'pri(balances.subscribe)'>(id, port); @@ -667,6 +707,24 @@ export default class Extension { case 'pri(balances.subscribe)': return port && this.balancesSubscribe(request as RequestBalancesSubscribe, id, port); + case 'pri(wallets.addAccount)': + return this.walletsAddAccount(request as RequestWalletAddAccount); + + case 'pri(wallets.create)': + return this.walletsCreate(request as RequestWalletCreate); + + case 'pri(wallets.forget)': + return this.walletsForget(request as RequestWalletForget); + + case 'pri(wallets.preview)': + return this.walletsPreview(request as RequestWalletPreview); + + case 'pri(wallets.rename)': + return this.walletsRename(request as RequestWalletRename); + + case 'pri(wallets.subscribe)': + return port && this.walletsSubscribe(id, port); + case 'pri(balances.unsubscribe)': return this.balancesUnsubscribe(request as RequestBalancesUnsubscribe); diff --git a/packages/extension-base/src/background/types.ts b/packages/extension-base/src/background/types.ts index 7a57feeb..44f42e58 100644 --- a/packages/extension-base/src/background/types.ts +++ b/packages/extension-base/src/background/types.ts @@ -10,7 +10,7 @@ import type { JsonRpcResponse } from '@polkadot/rpc-provider/types'; import type { SignerPayloadJSON, SignerPayloadRaw } from '@polkadot/types/types'; import type { KeyringPairs$Json } from '@polkadot/ui-keyring/types'; import type { HexString } from '@polkadot/util/types'; -import type { KeypairType } from '@polkadot/util-crypto/types'; +import type { EncryptedJson, KeypairType } from '@polkadot/util-crypto/types'; import type { ALLOWED_PATH } from '../defaults.js'; import type { AuthResponse } from './handlers/State.js'; @@ -93,6 +93,12 @@ export interface RequestSignatures { 'pri(balances.subscribe)': [RequestBalancesSubscribe, string, AccountBalances]; 'pri(balances.unsubscribe)': [RequestBalancesUnsubscribe, boolean]; 'pri(accounts.validate)': [RequestAccountValidate, boolean]; + 'pri(wallets.addAccount)': [RequestWalletAddAccount, boolean]; + 'pri(wallets.create)': [RequestWalletCreate, string]; + 'pri(wallets.forget)': [RequestWalletForget, boolean]; + 'pri(wallets.preview)': [RequestWalletPreview, ResponseWalletPreview]; + 'pri(wallets.rename)': [RequestWalletRename, boolean]; + 'pri(wallets.subscribe)': [null, boolean, WalletInfo[]]; 'pri(accounts.changePassword)': [RequestAccountChangePassword, boolean]; 'pri(authorize.approve)': [RequestAuthorizeApprove, boolean]; 'pri(authorize.list)': [null, ResponseAuthorizeList]; @@ -199,6 +205,62 @@ export interface RequestAccountCreateSuri { type?: KeypairType; } +/** The accounts one account index of a wallet unlocks, by address. */ +export interface WalletAccount { + index: number; + mldsa65: string; + mldsa87: string; + /** + * Wormhole addresses, receive and change branches, from index 0. Null for a + * wallet made from a raw seed: wormhole derivation starts from the 64-byte + * BIP39 seed, which a raw 32-byte seed does not have. + */ + wormhole: { change: string[], receive: string[] } | null; +} + +/** A wallet as the UI sees it: everything but the secret. */ +export interface WalletInfo { + accounts: WalletAccount[]; + genesisHash: HexString | null; + id: string; + name: string; + source: 'mnemonic' | 'seed'; + whenCreated: number; +} + +/** A wallet as stored: the secret, encrypted with the wallet password. */ +export interface WalletJson extends WalletInfo { + secret: EncryptedJson; +} + +export interface RequestWalletCreate { + genesisHash?: HexString | null; + name: string; + password: string; + /** A BIP39 recovery phrase, or a 0x-prefixed 32-byte seed. */ + secret: string; +} + +export interface RequestWalletPreview { + secret: string; +} + +export type ResponseWalletPreview = Omit & { wormhole: string | null }; + +export interface RequestWalletAddAccount { + id: string; + password: string; +} + +export interface RequestWalletForget { + id: string; +} + +export interface RequestWalletRename { + id: string; + name: string; +} + export interface RequestAccountChangePassword { address: string; oldPass: string; diff --git a/packages/extension-base/src/stores/Wallets.ts b/packages/extension-base/src/stores/Wallets.ts new file mode 100644 index 00000000..3ffbdef7 --- /dev/null +++ b/packages/extension-base/src/stores/Wallets.ts @@ -0,0 +1,24 @@ +// Copyright 2019-2026 @polkadot/extension-base authors & contributors +// SPDX-License-Identifier: Apache-2.0 + +import type { WalletJson } from '../background/types.js'; + +import { EXTENSION_PREFIX } from '../defaults.js'; +import BaseStore from './Base.js'; + +/** + * Wallets: one secret each, and the public addresses it unlocks. + * + * Its own prefix rather than the keyring's `account:` space. A wallet is not a + * keyring entry, and the ML-DSA pairs it owns are stored there as they always + * were, so signing and dapp injection never need to know wallets exist. + */ +export default class WalletsStore extends BaseStore { + constructor () { + super( + EXTENSION_PREFIX && EXTENSION_PREFIX !== 'polkadot{.js}' + ? `${EXTENSION_PREFIX}quantus:wallet` + : 'quantus:wallet' + ); + } +} diff --git a/packages/extension-base/src/stores/index.ts b/packages/extension-base/src/stores/index.ts index 1db41c60..2f1d990f 100644 --- a/packages/extension-base/src/stores/index.ts +++ b/packages/extension-base/src/stores/index.ts @@ -3,3 +3,4 @@ export { default as AccountsStore } from './Accounts.js'; export { default as MetadataStore } from './Metadata.js'; +export { default as WalletsStore } from './Wallets.js'; diff --git a/yarn.lock b/yarn.lock index a294ab29..1ece101b 100644 --- a/yarn.lock +++ b/yarn.lock @@ -983,7 +983,7 @@ __metadata: "@polkadot/util": "npm:^14.0.3" "@polkadot/util-crypto": "npm:^14.0.3" "@quantus/codec": "npm:^0.5.0" - "@quantus/crypto": "npm:^0.1.1" + "@quantus/crypto": "npm:^0.2.0" eventemitter3: "npm:^5.0.1" rxjs: "npm:^7.8.1" tslib: "npm:^2.8.1" @@ -1614,13 +1614,13 @@ __metadata: languageName: node linkType: hard -"@quantus/crypto@npm:^0.1.0, @quantus/crypto@npm:^0.1.1": - version: 0.1.1 - resolution: "@quantus/crypto@npm:0.1.1::__archiveUrl=https%3A%2F%2Fgit.lair.cafe%2Fapi%2Fpackages%2Fquantus%2Fnpm%2F%2540quantus%252Fcrypto%2F-%2F0.1.1%2Fcrypto-0.1.1.tgz" +"@quantus/crypto@npm:^0.2.0": + version: 0.2.0 + resolution: "@quantus/crypto@npm:0.2.0::__archiveUrl=https%3A%2F%2Fgit.lair.cafe%2Fapi%2Fpackages%2Fquantus%2Fnpm%2F%2540quantus%252Fcrypto%2F-%2F0.2.0%2Fcrypto-0.2.0.tgz" dependencies: fflate: "npm:^0.8.2" tslib: "npm:^2.7.0" - checksum: 10/2ba96cc2310b7c9ede22ed848ba05a9c789345b4ecb4b880e100a65b8280497e79c76f48fa4721e15c8e5567c0278fd08987e7cb10492dfa4e53c0be1d741e68 + checksum: 10/dcb974070f9e02a53c239692bda662b9e7a6eda9f214aca5f5e1a4e3014a4d4a36951b5c891b560a30e659da904f555a28b437e74660cb2f96a62abc26fbed4f languageName: node linkType: hard