fix(ui-keyring): restoreAccount cannot rebuild an ML-DSA public key from an address

The mirror of the same mistake in keyring.createFromJson, fixed the same way.

restoreAccount passed `decodeAddress(json.address)` as the public key. For the
curve schemes that is correct — the address *is* the public key. An ML-DSA
account id is a one-way Poseidon2 hash, so this constructed a pair whose "public
key" was a hash of the public key. It did not throw; it produced a pair that was
simply wrong.

The public key is in `json.encoded`, still encrypted, and the pair is returned
*locked* with callers reading `pair.address` off it long before a password
appears. So it now passes an accountId — carried as data, used for the address
while locked, and checked against the real key once decodePkcs8 supplies one.
That check matters here as much as in the keyring: the address field sits outside
the encrypted blob, so a tampered one decodes cleanly and yields an account
displaying an address its key cannot sign for.

The 'ed25519' fallback for version-0 JSON is left alone. Those files predate the
crypto type being recorded and are by definition not Quantus, so the guess is
safe — but it is commented, because quantus/common#6 removes ed25519 from
KeypairType and this becomes a refusal.

Build plumbing, all of it dev-time only and all replaced by publishing these
packages to a registry (quantus/extension#2):

Resolutions point @polkadot/{keyring,util-crypto,networks,util} at the sibling
`common` checkout's build output, and @quantus/crypto at `wasm`.

tsconfig.base.json gains `*.d.ts` path mappings for them. Without these, node
resolves the portal symlink to its realpath and `@polkadot/util-crypto` then
resolves inside *common's* tree, where it is the workspace source directory with
no exports map — so EncryptedJson silently fails to resolve and KeyringPair$Json
loses `encoded` and `encoding`. The `*.d.ts` substitution has to come first
because NodeNext will not infer an extension for a bare path, and looks for a
`types` directory instead of `types.d.ts`.

tsconfig.json overrides those with the runtime `.js` view, because
@polkadot/dev-ts reads that file and would otherwise import a declaration file as
a module ("Debug Failure. Output generation failed"). tsc reads
tsconfig.build.json, which extends the base directly, so each gets the view it
needs.

The test script sets --preserve-symlinks, which yarn itself warns is required for
portals: without it the realpath problem above recurs at runtime.

Incidentally, `yarn lint` errors drop from 25 to 8 — resolving those types
removed a pile of `any`. The remaining 8 are upstream's own, in loadContract and
allowGenesis, and are left alone.

Refs quantus/ui#1

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012uDUodEcRbBwNRi3UCmw8f
This commit is contained in:
rob thijssen
2026-09-15 10:40:52 +03:00
parent 1d667d4dc0
commit ec1399f3f3
7 changed files with 188 additions and 28 deletions

View File

@@ -29,8 +29,8 @@
"clean": "polkadot-dev-clean-build",
"lint": "polkadot-dev-run-lint",
"postinstall": "polkadot-dev-yarn-only",
"test": "polkadot-dev-run-test --env browser",
"test:one": "polkadot-dev-run-test --env browser"
"test": "NODE_OPTIONS='--preserve-symlinks --preserve-symlinks-main' polkadot-dev-run-test --env browser",
"test:one": "NODE_OPTIONS='--preserve-symlinks --preserve-symlinks-main' polkadot-dev-run-test --env browser"
},
"devDependencies": {
"@polkadot/dev": "^0.83.3",
@@ -42,6 +42,11 @@
"react-native": "^0.73.1"
},
"resolutions": {
"@polkadot/keyring": "portal:../common/packages/keyring/build",
"@polkadot/networks": "portal:../common/packages/networks/build",
"@polkadot/util": "portal:../common/packages/util/build",
"@polkadot/util-crypto": "portal:../common/packages/util-crypto/build",
"@quantus/crypto": "link:../wasm/packages/quantus-crypto/build",
"typescript": "5.5.4"
}
}

View File

@@ -30,8 +30,10 @@
"tslib": "^2.8.1"
},
"devDependencies": {
"@quantus/crypto": "^0.1.0",
"@types/mkdirp": "^2.0.0",
"@types/store": "^2.0.5"
"@types/store": "^2.0.5",
"fflate": "^0.8.2"
},
"peerDependencies": {
"@polkadot/keyring": "*",

View File

@@ -0,0 +1,88 @@
// Copyright 2017-2026 @polkadot/ui-keyring authors & contributors
// SPDX-License-Identifier: Apache-2.0
/// <reference types="@polkadot/dev-test/globals.d.ts" />
import type { KeyringPair$Json } from '@polkadot/keyring/types';
import { contextForSpec } from '@quantus/crypto';
import { Keyring as BaseKeyring } from '@polkadot/keyring';
import { dilithiumVerify } from '@polkadot/util-crypto';
import { Keyring } from './Keyring.js';
// crystal_alice, from the chain's dev genesis — a raw 32-byte seed straight into
// ML-DSA-87 keygen. The address is what `quantus developer create-test-wallets`
// prints, i.e. an independent implementation.
const ALICE = 'qzk1Nxai3dZD9Cn5kwGcgL6mKxsfxwqdis7kDQJ52aJS2vSn7';
const PASSWORD = 'not a good password';
const MESSAGE = new Uint8Array([0x75, 0x69]);
function backup (): KeyringPair$Json {
const base = new BaseKeyring({ ss58Format: 189, type: 'dilithium87' });
return base
.addFromSeed(new Uint8Array(32), { name: 'crystal_alice' }, 'dilithium87')
.toJson(PASSWORD);
}
// `loadAll` initialises `@polkadot/ui-settings`, which is a module singleton, so
// it may be called once per *process* — not per instance. One keyring for the
// whole file, loaded once.
const uiKeyring = new Keyring();
uiKeyring.loadAll({ ss58Format: 189, store: undefined, type: 'dilithium87' });
describe('ui-keyring restoreAccount', (): void => {
// Before the fix this constructed a pair whose "public key" was
// decodeAddress(json.address) — i.e. a Poseidon2 hash of the public key, passed
// off as the key itself. It did not throw; it produced a pair that was simply
// wrong.
it('restores an ML-DSA account to the right address', (): void => {
const pair = uiKeyring.restoreAccount(backup(), PASSWORD);
expect(pair.address).toEqual(ALICE);
expect(pair.publicKey.length).toEqual(2592);
expect(pair.addressRaw.length).toEqual(32);
});
it('restores an account that can sign for its own address', (): void => {
const pair = uiKeyring.restoreAccount(backup(), PASSWORD);
pair.decodePkcs8(PASSWORD);
const context = contextForSpec(148);
const signature = pair.sign(MESSAGE, { context });
expect(dilithiumVerify(MESSAGE, signature, pair.addressRaw, 'dilithium87', context)).toEqual(true);
});
it('fails on a wrong password without half-restoring', (): void => {
expect(() => uiKeyring.restoreAccount(backup(), 'wrong')).toThrow();
});
// The address field sits outside the encrypted blob. For the curve schemes a
// tampered one cannot decode at all; here it decodes cleanly and would yield an
// account displaying an address its key does not control.
it('rejects JSON whose address does not match its key', (): void => {
const base = new BaseKeyring({ ss58Format: 189, type: 'dilithium87' });
const other = base.addFromSeed(new Uint8Array(32).fill(3), {}, 'dilithium87');
expect(() => uiKeyring.restoreAccount({ ...backup(), address: other.address }, PASSWORD)).toThrow(/does not match the address/);
});
// restoreAccount takes the crypto type from the JSON rather than the keyring,
// so the same keyring restores both. Compared on publicKey and addressRaw
// rather than the SS58 string, which would differ only because this keyring is
// configured for prefix 189.
it('leaves sr25519 restore unchanged', (): void => {
const sr = new BaseKeyring({ type: 'sr25519' });
const pair = sr.addFromUri('//Alice');
const restored = uiKeyring.restoreAccount(pair.toJson(PASSWORD), PASSWORD);
expect(restored.publicKey).toEqual(pair.publicKey);
expect(restored.addressRaw).toEqual(pair.addressRaw);
expect(restored.type).toEqual('sr25519');
});
});

View File

@@ -11,7 +11,7 @@ import type { CreateResult, KeyringAddress, KeyringAddressType, KeyringItemType,
import { createPair } from '@polkadot/keyring';
import { chains } from '@polkadot/ui-settings';
import { bnToBn, hexToU8a, isFunction, isHex, isString, objectSpread, stringify, stringToU8a, u8aSorted, u8aToString } from '@polkadot/util';
import { base64Decode, createKeyMulti, jsonDecrypt, jsonEncrypt } from '@polkadot/util-crypto';
import { base64Decode, createKeyMulti, isDilithium, jsonDecrypt, jsonEncrypt } from '@polkadot/util-crypto';
import { env } from './observable/env.js';
import { KeyringOption } from './options/index.js';
@@ -327,11 +327,29 @@ export class Keyring extends Base implements KeyringStruct {
}
public restoreAccount (json: KeyringPair$Json, password: string): KeyringPair {
const cryptoType = Array.isArray(json.encoding.content) ? json.encoding.content[1] : 'ed25519';
// The 'ed25519' fallback is for version-0 JSON, which predates the crypto
// type being recorded. Those files are by definition not Quantus, so the
// guess is safe here — but note that quantus/common#6 removes ed25519 from
// KeypairType, at which point this has to become a refusal rather than a
// default.
const cryptoType = (Array.isArray(json.encoding.content) ? json.encoding.content[1] : 'ed25519') as KeypairType;
const encType = Array.isArray(json.encoding.type) ? json.encoding.type : [json.encoding.type];
const raw = this.decodeAddress(json.address, true);
// For every curve scheme the address and the public key are the same 32
// bytes, which is why passing one as the other works at all. An ML-DSA
// account id is a one-way Poseidon2 hash and the 1952/2592-byte public key
// is inside `json.encoded`, still encrypted — there is nothing to derive it
// from until `decodePkcs8` below runs.
//
// So hand `createPair` an account id rather than a public key it can check,
// and let it verify the two agree once the real key appears. Doing it the
// other way produced a pair whose "public key" was a hash of the public key.
const pair = createPair(
{ toSS58: this.encodeAddress, type: cryptoType as KeypairType },
{ publicKey: this.decodeAddress(json.address, true) },
{ toSS58: this.encodeAddress, type: cryptoType },
isDilithium(cryptoType)
? { accountId: raw, publicKey: new Uint8Array(), secretKey: new Uint8Array() }
: { publicKey: raw },
json.meta,
isHex(json.encoded) ? hexToU8a(json.encoded) : base64Decode(json.encoded),
encType

View File

@@ -2,7 +2,25 @@
"extends": "@polkadot/dev/config/tsconfig.json",
"compilerOptions": {
"composite": true,
// Dev-time only: point at the forked @polkadot packages in the sibling
// `common` checkout. Needed because node resolves a portal/link symlink to
// its realpath, after which `@polkadot/util-crypto` resolves inside
// *common's* tree — where it is the workspace source directory, with no
// exports map — and `EncryptedJson` silently becomes unresolvable, so
// `KeyringPair$Json` loses `encoded` and `encoding`.
//
// The `*.d.ts` substitution comes first on purpose: NodeNext will not infer
// an extension for a bare path, so `.../build/types` is looked up as a
// directory and `types.d.ts` is never tried.
//
// Replace all of these with a registry dependency; see quantus/extension#2.
"paths": {
"@polkadot/keyring": ["../../common/packages/keyring/build/index.d.ts"],
"@polkadot/keyring/*": ["../../common/packages/keyring/build/*.d.ts", "../../common/packages/keyring/build/*"],
"@polkadot/networks": ["../../common/packages/networks/build/index.d.ts"],
"@polkadot/networks/*": ["../../common/packages/networks/build/*.d.ts", "../../common/packages/networks/build/*"],
"@polkadot/util-crypto": ["../../common/packages/util-crypto/build/index.d.ts"],
"@polkadot/util-crypto/*": ["../../common/packages/util-crypto/build/*.d.ts", "../../common/packages/util-crypto/build/*"],
"@polkadot/react-identicon": ["react-identicon/src/index.ts"],
"@polkadot/react-qr": ["react-qr/src/index.ts"],
"@polkadot/reactnative-identicon": ["reactnative-identicon/src/index.ts"],

View File

@@ -2,7 +2,23 @@
"extends": "./tsconfig.base.json",
"compilerOptions": {
"baseUrl": "./packages",
"composite": false
"composite": false,
// Overrides the `*.d.ts` mappings in tsconfig.base.json for the *runtime*
// loader. `@polkadot/dev-ts` reads this file (following `extends`, with the
// child winning) and would otherwise try to import a declaration file as a
// module — "Debug Failure. Output generation failed".
//
// tsc reads tsconfig.build.json, which extends the base directly and so
// keeps the declaration mappings it needs. Both views disappear once these
// packages come from a registry; see quantus/extension#2.
"paths": {
"@polkadot/keyring": ["../../common/packages/keyring/build/index.js"],
"@polkadot/keyring/*": ["../../common/packages/keyring/build/*"],
"@polkadot/networks": ["../../common/packages/networks/build/index.js"],
"@polkadot/networks/*": ["../../common/packages/networks/build/*"],
"@polkadot/util-crypto": ["../../common/packages/util-crypto/build/index.js"],
"@polkadot/util-crypto/*": ["../../common/packages/util-crypto/build/*"]
}
},
"include": [
"packages/**/src/**/*"

View File

@@ -1596,30 +1596,29 @@ __metadata:
languageName: node
linkType: hard
"@polkadot/keyring@npm:^14.0.3":
version: 14.0.3
resolution: "@polkadot/keyring@npm:14.0.3"
"@polkadot/keyring@portal:../common/packages/keyring/build::locator=root-workspace-0b6124%40workspace%3A.":
version: 0.0.0-use.local
resolution: "@polkadot/keyring@portal:../common/packages/keyring/build::locator=root-workspace-0b6124%40workspace%3A."
dependencies:
"@polkadot/util": "npm:14.0.3"
"@polkadot/util-crypto": "npm:14.0.3"
"@quantus/crypto": "npm:^0.1.0"
tslib: "npm:^2.8.0"
peerDependencies:
"@polkadot/util": 14.0.3
"@polkadot/util-crypto": 14.0.3
checksum: 10/69f9f776363f8327d72b43794262ae709fc2824182637e499ed6e9ca94315645d78005bf1f25bdfb7305e5d79879cb932c114e6612467ddf21a760117834e8a2
languageName: node
linkType: hard
linkType: soft
"@polkadot/networks@npm:14.0.3, @polkadot/networks@npm:^14.0.3":
version: 14.0.3
resolution: "@polkadot/networks@npm:14.0.3"
"@polkadot/networks@portal:../common/packages/networks/build::locator=root-workspace-0b6124%40workspace%3A.":
version: 0.0.0-use.local
resolution: "@polkadot/networks@portal:../common/packages/networks/build::locator=root-workspace-0b6124%40workspace%3A."
dependencies:
"@polkadot/util": "npm:14.0.3"
"@substrate/ss58-registry": "npm:^1.51.0"
tslib: "npm:^2.8.0"
checksum: 10/eb006f537f103b0d417e52966d0098b528326d1ebbae84e4c7834627bb3e863b7b849856992aa58c4a0aeb0ed1e1838a9619aeba7610d0e7c75e99ffcc6c9ecd
languageName: node
linkType: hard
linkType: soft
"@polkadot/react-identicon@workspace:packages/react-identicon":
version: 0.0.0-use.local
@@ -1695,8 +1694,10 @@ __metadata:
"@polkadot/ui-settings": "npm:3.16.7"
"@polkadot/util": "npm:^14.0.3"
"@polkadot/util-crypto": "npm:^14.0.3"
"@quantus/crypto": "npm:^0.1.0"
"@types/mkdirp": "npm:^2.0.0"
"@types/store": "npm:^2.0.5"
fflate: "npm:^0.8.2"
mkdirp: "npm:^3.0.1"
rxjs: "npm:^7.8.1"
store: "npm:^2.0.12"
@@ -1740,9 +1741,9 @@ __metadata:
languageName: unknown
linkType: soft
"@polkadot/util-crypto@npm:14.0.3, @polkadot/util-crypto@npm:^14.0.3":
version: 14.0.3
resolution: "@polkadot/util-crypto@npm:14.0.3"
"@polkadot/util-crypto@portal:../common/packages/util-crypto/build::locator=root-workspace-0b6124%40workspace%3A.":
version: 0.0.0-use.local
resolution: "@polkadot/util-crypto@portal:../common/packages/util-crypto/build::locator=root-workspace-0b6124%40workspace%3A."
dependencies:
"@noble/curves": "npm:^1.3.0"
"@noble/hashes": "npm:^1.3.3"
@@ -1752,18 +1753,18 @@ __metadata:
"@polkadot/wasm-util": "npm:^7.5.3"
"@polkadot/x-bigint": "npm:14.0.3"
"@polkadot/x-randomvalues": "npm:14.0.3"
"@quantus/crypto": "npm:^0.1.0"
"@scure/base": "npm:^1.1.7"
"@scure/sr25519": "npm:^0.2.0"
tslib: "npm:^2.8.0"
peerDependencies:
"@polkadot/util": 14.0.3
checksum: 10/e8f2da806cb81d3c014415bdd633f0fc5871132ce790ca892f65899010386d64fa25f7c047574cc96402afa03b5ff77e4dff904e69b90e714a7150e18ef0f507
languageName: node
linkType: hard
linkType: soft
"@polkadot/util@npm:14.0.3, @polkadot/util@npm:^14.0.3":
version: 14.0.3
resolution: "@polkadot/util@npm:14.0.3"
"@polkadot/util@portal:../common/packages/util/build::locator=root-workspace-0b6124%40workspace%3A.":
version: 0.0.0-use.local
resolution: "@polkadot/util@portal:../common/packages/util/build::locator=root-workspace-0b6124%40workspace%3A."
dependencies:
"@polkadot/x-bigint": "npm:14.0.3"
"@polkadot/x-global": "npm:14.0.3"
@@ -1772,9 +1773,8 @@ __metadata:
"@types/bn.js": "npm:^5.1.6"
bn.js: "npm:^5.2.1"
tslib: "npm:^2.8.0"
checksum: 10/7731f26f363696a2e313fdd44d870d711924e8d24200e1c5e88769e02c220af99382460372caa1715511548753e1e3d5c1466a02308b0d4dec0700ec0ab4e88b
languageName: node
linkType: hard
linkType: soft
"@polkadot/vue-identicon@workspace:packages/vue-identicon":
version: 0.0.0-use.local
@@ -1936,6 +1936,12 @@ __metadata:
languageName: node
linkType: hard
"@quantus/crypto@link:../wasm/packages/quantus-crypto/build::locator=root-workspace-0b6124%40workspace%3A.":
version: 0.0.0-use.local
resolution: "@quantus/crypto@link:../wasm/packages/quantus-crypto/build::locator=root-workspace-0b6124%40workspace%3A."
languageName: node
linkType: soft
"@react-native-community/cli-clean@npm:12.3.0":
version: 12.3.0
resolution: "@react-native-community/cli-clean@npm:12.3.0"
@@ -6436,6 +6442,13 @@ __metadata:
languageName: node
linkType: hard
"fflate@npm:^0.8.2":
version: 0.8.3
resolution: "fflate@npm:0.8.3"
checksum: 10/6ebf528dc9c56e78e715eac615b009b25dc33e15c1920b11ebba44e6d76181c647756a81a23e19247907496b93aa99928514c53090579a65109e026ac2824aa7
languageName: node
linkType: hard
"figures@npm:^3.0.0":
version: 3.2.0
resolution: "figures@npm:3.2.0"