From 4a052de4c5fbf17b93ac0478667091654d90e52a Mon Sep 17 00:00:00 2001 From: rob thijssen Date: Wed, 16 Sep 2026 13:31:30 +0300 Subject: [PATCH] feat: create and import accounts under either ML-DSA parameter set Both variants of the runtime's DilithiumSignatureScheme are in use on the chain: ML-DSA-65 is the default, and ML-DSA-87 holds existing accounts, crystal_alice among them. The backend already signed with either one. The UI, following upstream, picked the type from the network, so an ML-DSA-87 account could not be created or imported from a phrase. Create and Import now show a "Key type" selector. The same phrase gives a different account under each type, so Import keeps the selector out of "advanced" and warns next to it. Changing the network no longer resets the user's choice. Create re-derives the address preview when the type changes. The backend specs prove it end to end: - the zero seed validated as ML-DSA-87 gives crystal_alice's account id; - the same seed as ML-DSA-65 gives a different account; - an account created as ML-DSA-87 is stored as dilithium87 and signs with variant 0 at ML-DSA-87 size. The React UI specs are not run. Upstream skips them ("FIXME hanging") and leaves .spec.tsx out of `yarn test`. They also can't run under node's test runner: enzyme needs cheerio's removed lib/utils subpath, and jest.spyOn can't redefine ESM namespace exports. The dropdowns themselves are covered only by typecheck, lint and build. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_012uDUodEcRbBwNRi3UCmw8f --- .../src/background/handlers/Extension.spec.ts | 55 ++++++++++++++++++- .../src/Popup/CreateAccount/index.tsx | 29 ++++++++-- .../src/Popup/ImportSeed/SeedAndPath.tsx | 18 +++++- .../src/Popup/ImportSeed/index.tsx | 14 +++-- packages/extension-ui/src/util/keyTypes.ts | 23 ++++++++ .../public/locales/en/translation.json | 4 +- 6 files changed, 128 insertions(+), 15 deletions(-) create mode 100644 packages/extension-ui/src/util/keyTypes.ts diff --git a/packages/extension-base/src/background/handlers/Extension.spec.ts b/packages/extension-base/src/background/handlers/Extension.spec.ts index 3704f5b7..d598271a 100644 --- a/packages/extension-base/src/background/handlers/Extension.spec.ts +++ b/packages/extension-base/src/background/handlers/Extension.spec.ts @@ -15,8 +15,8 @@ import type { KeypairType } from '@polkadot/util-crypto/types'; import { Scheme, sizes } from '@quantus/crypto'; import keyring from '@polkadot/ui-keyring'; -import { hexToU8a, stringToHex } from '@polkadot/util'; -import { cryptoWaitReady } from '@polkadot/util-crypto'; +import { hexToU8a, stringToHex, u8aToHex } from '@polkadot/util'; +import { cryptoWaitReady, decodeAddress } from '@polkadot/util-crypto'; import { AccountsStore } from '../../stores/index.js'; import { HEISENBERG_GENESIS, heisenbergMetadataDef } from '../../test/metadata.js'; @@ -97,6 +97,57 @@ describe('Extension', () => { expect(result.exportedJson.encoded).toBeDefined(); }); + describe('both ML-DSA parameter sets', () => { + // The chain's DilithiumSignatureScheme has exactly two variants, so both are + // in use and both must be importable. crystal_alice is the vector: seed + // 0x00…00 as ML-DSA-87, funded at dev genesis, and its address is the one the + // chain itself holds a balance for. The same seed as ML-DSA-65 is a different, + // unfunded account — which is what importing it as the default type used to + // produce, with nothing on screen to say so. + const ZERO_SEED = `0x${'00'.repeat(32)}`; + const CRYSTAL_ALICE = 'qzk1Nxai3dZD9Cn5kwGcgL6mKxsfxwqdis7kDQJ52aJS2vSn7'; + + it('validates a seed as ML-DSA-87 to the account the chain funded', async () => { + const { address } = await extension.handle('id', 'pri(seed.validate)', { + suri: ZERO_SEED, + type: 'dilithium87' + }, {} as chrome.runtime.Port); + + expect(u8aToHex(decodeAddress(address))).toEqual(u8aToHex(decodeAddress(CRYSTAL_ALICE))); + }); + + it('gives a different account for the same seed as ML-DSA-65', async () => { + const { address } = await extension.handle('id', 'pri(seed.validate)', { + suri: ZERO_SEED, + type: 'dilithium65' + }, {} as chrome.runtime.Port); + + expect(u8aToHex(decodeAddress(address))).not.toEqual(u8aToHex(decodeAddress(CRYSTAL_ALICE))); + }); + + it('stores an imported ML-DSA-87 account as ML-DSA-87, and it signs', async () => { + await extension.handle('id', 'pri(accounts.create.suri)', { + name: 'crystal_alice', + password, + suri: ZERO_SEED, + type: 'dilithium87' + }, {} as chrome.runtime.Port); + + const pair = keyring.getPair(CRYSTAL_ALICE); + + expect(pair.type).toEqual('dilithium87'); + + pair.decodePkcs8(password); + + // Variant 0 is Dilithium87 in the runtime's DilithiumSignatureScheme, and + // the body is sig ‖ pk at the 87 sizes. + const signed = pair.sign(new Uint8Array([1, 2, 3]), { context: new Uint8Array(), withType: true }); + + expect(signed[0]).toEqual(0); + expect(signed.length).toEqual(sizes(Scheme.MlDsa87).signatureWithPublicKey + 1); + }); + }); + describe('account derivation', () => { let address: string; diff --git a/packages/extension-ui/src/Popup/CreateAccount/index.tsx b/packages/extension-ui/src/Popup/CreateAccount/index.tsx index c9b8bb1f..63f5ef2a 100644 --- a/packages/extension-ui/src/Popup/CreateAccount/index.tsx +++ b/packages/extension-ui/src/Popup/CreateAccount/index.tsx @@ -12,6 +12,7 @@ import { createAccountSuri, createSeed, validateSeed } from '../../messaging.js' import { HeaderWithSteps } from '../../partials/index.js'; import { styled } from '../../styled.js'; import { DEFAULT_TYPE } from '../../util/defaultType.js'; +import { KEY_TYPE_OPTIONS } from '../../util/keyTypes.js'; import Mnemonic from './Mnemonic.js'; interface Props { @@ -41,18 +42,27 @@ function CreateAccount ({ className }: Props): React.ReactElement { // eslint-disable-next-line react-hooks/exhaustive-deps }, []); + // Only the Ethereum case is decided by the network; otherwise the type is the + // user's choice between the two ML-DSA parameter sets and a network change + // must not reset it. + useEffect((): void => { + if (chain?.definition.chainType === 'ethereum') { + setType('ethereum'); + } else { + setType((current) => current === 'ethereum' ? DEFAULT_TYPE : current); + } + }, [chain]); + + // Re-derive on every type change. The phrase is the same either way; the + // account it produces is not, and the address preview has to show the one + // that will actually be created. useEffect((): void => { if (seed) { - const type = chain && chain.definition.chainType === 'ethereum' - ? 'ethereum' - : DEFAULT_TYPE; - - setType(type); validateSeed(seed, type) .then(({ address }) => setAddress(address)) .catch(console.error); } - }, [seed, chain]); + }, [seed, type]); const _onCreate = useCallback( (name: string, password: string): void => { @@ -117,6 +127,13 @@ function CreateAccount ({ className }: Props): React.ReactElement { options={options} value={genesisHash} /> + void; onAccountChange: (account: AccountInfo | null) => void; type: KeypairType; + onTypeChange: (type: KeypairType) => void; } -function SeedAndPath ({ className, onAccountChange, onNextStep, type }: Props): React.ReactElement { +function SeedAndPath ({ className, onAccountChange, onNextStep, onTypeChange, type }: Props): React.ReactElement { const { t } = useTranslation(); const genesisOptions = useGenesisHashOptions(); const [address, setAddress] = useState(''); @@ -93,6 +95,20 @@ function SeedAndPath ({ className, onAccountChange, onNextStep, type }: Props): options={genesisOptions} value={genesis} /> + {/* Not under "advanced": choosing the wrong type imports a different + account from the same phrase, with nothing else on screen to say + so. The address preview above updates as this changes, and that + preview is what the warning points the reader at. */} + + + {t('The same recovery phrase gives a different account under each key type. Check the address above is the one you expect before continuing.')} +
{ - setType( - chain && chain.definition.chainType === 'ethereum' - ? 'ethereum' - : DEFAULT_TYPE - ); + if (chain?.definition.chainType === 'ethereum') { + setType('ethereum'); + } else { + setType((current) => current === 'ethereum' ? DEFAULT_TYPE : current); + } }, [chain]); const _onCreate = useCallback((name: string, password: string): void => { @@ -84,6 +87,7 @@ function ImportSeed (): React.ReactElement { ) diff --git a/packages/extension-ui/src/util/keyTypes.ts b/packages/extension-ui/src/util/keyTypes.ts new file mode 100644 index 00000000..74da5333 --- /dev/null +++ b/packages/extension-ui/src/util/keyTypes.ts @@ -0,0 +1,23 @@ +// Copyright 2019-2026 @polkadot/extension-ui authors & contributors +// SPDX-License-Identifier: Apache-2.0 + +import type { KeypairType } from '@polkadot/util-crypto/types'; + +/** + * The key types a Quantus account can have. + * + * Exactly the variants of the runtime's `DilithiumSignatureScheme` — both are in + * use on the chain, so both have to be creatable and importable. Upstream never + * offered a choice here: it derived the type from the network, because each + * chain it supported had one valid type. Quantus has two on the same chain, so + * the network cannot decide and the user has to. + * + * The distinction is not cosmetic. The same recovery phrase or seed produces a + * **different account** under each type — crystal_alice's seed is a funded + * account as ML-DSA-87 and an empty one as ML-DSA-65 — so importing under the + * wrong type silently yields the wrong address. + */ +export const KEY_TYPE_OPTIONS: { text: string, value: KeypairType }[] = [ + { text: 'ML-DSA-65 (recommended)', value: 'dilithium65' }, + { text: 'ML-DSA-87', value: 'dilithium87' } +]; diff --git a/packages/extension/public/locales/en/translation.json b/packages/extension/public/locales/en/translation.json index 0f40334d..a9fdf980 100644 --- a/packages/extension/public/locales/en/translation.json +++ b/packages/extension/public/locales/en/translation.json @@ -149,5 +149,7 @@ "... we are not in the information collection business (even anonymized).": "", "Understood, let me continue": "", "Password must be at least {{length}} characters long": "", - "Open in a tab": "" + "Open in a tab": "", + "Key type": "", + "The same recovery phrase gives a different account under each key type. Check the address above is the one you expect before continuing.": "" }