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) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012uDUodEcRbBwNRi3UCmw8f
This commit is contained in:
@@ -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;
|
||||
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
<Dropdown
|
||||
className={className}
|
||||
label={t('Key type')}
|
||||
onChange={setType}
|
||||
options={KEY_TYPE_OPTIONS}
|
||||
value={type}
|
||||
/>
|
||||
<AccountNamePasswordCreation
|
||||
buttonLabel={t('Add the account with the generated seed')}
|
||||
isBusy={isBusy}
|
||||
|
||||
@@ -14,15 +14,17 @@ import { objectSpread } from '@polkadot/util';
|
||||
import { ButtonArea, Dropdown, InputWithLabel, NextStepButton, TextAreaWithLabel, VerticalSpace, Warning } from '../../components/index.js';
|
||||
import { useGenesisHashOptions, useTranslation } from '../../hooks/index.js';
|
||||
import { styled } from '../../styled.js';
|
||||
import { KEY_TYPE_OPTIONS } from '../../util/keyTypes.js';
|
||||
|
||||
interface Props {
|
||||
className?: string;
|
||||
onNextStep: () => 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. */}
|
||||
<Dropdown
|
||||
className='keyTypeSelection'
|
||||
label={t('Key type')}
|
||||
onChange={onTypeChange}
|
||||
options={KEY_TYPE_OPTIONS}
|
||||
value={type}
|
||||
/>
|
||||
<Warning>
|
||||
{t('The same recovery phrase gives a different account under each key type. Check the address above is the one you expect before continuing.')}
|
||||
</Warning>
|
||||
<div
|
||||
className='advancedToggle'
|
||||
onClick={_onToggleAdvanced}
|
||||
|
||||
@@ -34,12 +34,15 @@ function ImportSeed (): React.ReactElement {
|
||||
!accounts.length && onAction();
|
||||
}, [accounts, onAction]);
|
||||
|
||||
// Only the Ethereum case is decided by the network. Anything else is the
|
||||
// user's choice between the two ML-DSA parameter sets, and resetting it on a
|
||||
// network change would silently re-derive a different account.
|
||||
useEffect((): void => {
|
||||
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 {
|
||||
<SeedAndPath
|
||||
onAccountChange={setAccount}
|
||||
onNextStep={_onNextStep}
|
||||
onTypeChange={setType}
|
||||
type={type}
|
||||
/>
|
||||
)
|
||||
|
||||
@@ -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' }
|
||||
];
|
||||
@@ -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.": ""
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user