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:
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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": "*",
|
||||
|
||||
88
packages/ui-keyring/src/Keyring.dilithium.spec.ts
Normal file
88
packages/ui-keyring/src/Keyring.dilithium.spec.ts
Normal 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');
|
||||
});
|
||||
});
|
||||
@@ -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
|
||||
|
||||
@@ -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"],
|
||||
|
||||
@@ -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/**/*"
|
||||
|
||||
53
yarn.lock
53
yarn.lock
@@ -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"
|
||||
|
||||
Reference in New Issue
Block a user