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 "<wallet> (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) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_012uDUodEcRbBwNRi3UCmw8f
This commit is contained in:
+2
-1
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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.
|
||||
*
|
||||
* `//<n>` is the account index, which the fork's path resolver turns into
|
||||
* `m/44'/189189'/<n>'/0'/<scheme>'` — 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<WalletAccount, 'wormhole'> {
|
||||
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<WalletInfo[]>([]);
|
||||
|
||||
readonly #store = new WalletsStore();
|
||||
readonly #wallets = new Map<string, WalletJson>();
|
||||
readonly #ready: Promise<void>;
|
||||
|
||||
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<void> {
|
||||
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<string> {
|
||||
await this.#ready;
|
||||
|
||||
const normalised = normalise(secret);
|
||||
const source = sourceOf(normalised);
|
||||
const account = objectSpread<WalletAccount>(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<void> {
|
||||
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<WalletAccount>(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<void> {
|
||||
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<void> {
|
||||
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();
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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<string, VoidFunction>();
|
||||
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<string> {
|
||||
return this.#wallets.create(request);
|
||||
}
|
||||
|
||||
private async walletsAddAccount ({ id, password }: RequestWalletAddAccount): Promise<boolean> {
|
||||
await this.#wallets.addAccount(id, password);
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
private async walletsRename ({ id, name }: RequestWalletRename): Promise<boolean> {
|
||||
await this.#wallets.rename(id, name);
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
private async walletsForget ({ id }: RequestWalletForget): Promise<boolean> {
|
||||
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);
|
||||
|
||||
|
||||
@@ -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<WalletAccount, 'wormhole'> & { 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;
|
||||
|
||||
@@ -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<WalletJson> {
|
||||
constructor () {
|
||||
super(
|
||||
EXTENSION_PREFIX && EXTENSION_PREFIX !== 'polkadot{.js}'
|
||||
? `${EXTENSION_PREFIX}quantus:wallet`
|
||||
: 'quantus:wallet'
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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';
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
Reference in New Issue
Block a user