ui-keyring: restoreAccount cannot rebuild a Quantus public key from an address #1

Closed
opened 2026-09-10 10:13:36 +00:00 by grenade · 2 comments
Owner

Mirror of quantus/common#3 — the same mistake, in a second place. Context in quantus/extension#1.

The line

packages/ui-keyring/src/Keyring.ts:

restoreAccount (json, password) {
  const cryptoType = Array.isArray(json.encoding.content) ? json.encoding.content[1] : 'ed25519';
  const encType = Array.isArray(json.encoding.type) ? json.encoding.type : [json.encoding.type];
  const pair = createPair(
    { toSS58: this.encodeAddress, type: cryptoType },
    { publicKey: this.decodeAddress(json.address, true) },   // ← here
    json.meta,
    
  );
  pair.decodePkcs8(password);
  
}

decodeAddress turns the SS58 string back into 32 bytes and hands them over as the public key. On Substrate that is correct — the address is the public key. On Quantus the address is Poseidon2(publicKey) and the real public key is 1952 or 2592 bytes, so this constructs a pair whose public key is a hash of the public key.

Note the ordering: decodePkcs8(password) runs on the next line and does have the real public key available inside the encrypted blob. So the fix is not to find the key from somewhere new — it is to stop pretending to know it before the blob is open, and to let the pair carry the account id as data until it is.

Do not solve this twice

quantus/common#3 has to answer the identical question for keyring.createFromJson, and it is the one that decides what createPair accepts. Settle the shape there, then apply it here — two independent answers to "how does a locked ML-DSA pair know its address" is how the two paths drift and one of them starts returning a wrong-but-plausible address.

While in this file

loadAddress guards with json.address.length !== 66 to spot Ethereum-style addresses among hex. A Quantus account id is 32 bytes and hits the normal path, so this is fine as it stands — but it is exactly the kind of length-sniffing that will need revisiting if raw public keys ever end up in this store. Worth a comment rather than a change.

Acceptance

  • a JSON file exported by the extension restores to an account with the same address
  • the address reported before unlock matches the address after unlock
  • upstream-format ed25519/sr25519 files restore unchanged
Mirror of quantus/common#3 — the same mistake, in a second place. Context in quantus/extension#1. ## The line `packages/ui-keyring/src/Keyring.ts`: ```js restoreAccount (json, password) { const cryptoType = Array.isArray(json.encoding.content) ? json.encoding.content[1] : 'ed25519'; const encType = Array.isArray(json.encoding.type) ? json.encoding.type : [json.encoding.type]; const pair = createPair( { toSS58: this.encodeAddress, type: cryptoType }, { publicKey: this.decodeAddress(json.address, true) }, // ← here json.meta, … ); pair.decodePkcs8(password); … } ``` `decodeAddress` turns the SS58 string back into 32 bytes and hands them over as the public key. On Substrate that is correct — the address *is* the public key. On Quantus the address is `Poseidon2(publicKey)` and the real public key is 1952 or 2592 bytes, so this constructs a pair whose public key is a hash of the public key. Note the ordering: `decodePkcs8(password)` runs on the *next* line and does have the real public key available inside the encrypted blob. So the fix is not to find the key from somewhere new — it is to stop pretending to know it before the blob is open, and to let the pair carry the account id as data until it is. ## Do not solve this twice quantus/common#3 has to answer the identical question for `keyring.createFromJson`, and it is the one that decides what `createPair` accepts. **Settle the shape there, then apply it here** — two independent answers to "how does a locked ML-DSA pair know its address" is how the two paths drift and one of them starts returning a wrong-but-plausible address. ## While in this file `loadAddress` guards with `json.address.length !== 66` to spot Ethereum-style addresses among hex. A Quantus account id is 32 bytes and hits the normal path, so this is fine as it stands — but it is exactly the kind of length-sniffing that will need revisiting if raw public keys ever end up in this store. Worth a comment rather than a change. ## Acceptance - [ ] a JSON file exported by the extension restores to an account with the same address - [ ] the address reported before unlock matches the address after unlock - [ ] upstream-format ed25519/sr25519 files restore unchanged
Author
Owner

Done, on quantus-restore-account (ec1399f3). 5 specs; 46 tests pass repo-wide, build clean.

The fix

Exactly as this issue framed it — solved in quantus/common#3 first, then mirrored. restoreAccount now passes an accountId rather than a public key it cannot have:

const info = isDilithium(cryptoType)
  ? { accountId: raw, publicKey: new Uint8Array(), secretKey: new Uint8Array() }
  : { publicKey: raw };

createPair uses it for the address while locked and clears it once decodePkcs8 supplies the real key — checking the two agree on the way. That check earns its place here for the same reason as in the keyring: the address sits outside the encrypted blob, so a tampered one decodes cleanly and yields an account displaying an address its key cannot sign for. Pinned by a test.

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

Acceptance

  • a JSON file exported by the extension restores to an account with the same address
  • the address reported before unlock matches the address after unlock
  • upstream-format sr25519 files restore unchanged
  • the restored account can sign, and the signature verifies against its address

The cross-repo linking has run out of road

Getting this repo to see the forked common took four separate fixes, each for the same underlying cause: 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.

  1. resolutions pointing the four @polkadot packages at common's build output
  2. tsconfig.base.json path mappings, with the *.d.ts substitution first, because NodeNext will not infer an extension for a bare path and looks for a types directory instead of types.d.ts
  3. tsconfig.json overriding 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"
  4. --preserve-symlinks in the test script, which yarn itself warns is required for portals

All four are dev-time scaffolding for a problem that does not exist once these packages come from a registry. This is the third repo to hit it (common@quantus/crypto, now uicommon), and the extension will be the fourth with more dependencies than any of them.

quantus/extension#2 is now the blocking item, not a tidy-up. Publishing to the Gitea npm registry needs a token, which I cannot create — so it needs a hand. Until then every downstream repo pays this tax again.

One incidental result worth recording: yarn lint errors dropped from 25 to 8. The tsconfig mappings resolved types that were previously any, removing a pile of no-unsafe-assignment. The remaining 8 are upstream's own, in loadContract and allowGenesis, and are left alone per the rebasability convention — note that yarn lint therefore still exits non-zero on this repo, as it does on pristine upstream.

Done, on `quantus-restore-account` (`ec1399f3`). 5 specs; 46 tests pass repo-wide, build clean. ## The fix Exactly as this issue framed it — solved in quantus/common#3 first, then mirrored. `restoreAccount` now passes an **accountId** rather than a public key it cannot have: ```ts const info = isDilithium(cryptoType) ? { accountId: raw, publicKey: new Uint8Array(), secretKey: new Uint8Array() } : { publicKey: raw }; ``` `createPair` uses it for the address while locked and clears it once `decodePkcs8` supplies the real key — checking the two agree on the way. That check earns its place here for the same reason as in the keyring: the address sits outside the encrypted blob, so a tampered one decodes cleanly and yields an account displaying an address its key cannot sign for. Pinned by a test. The `'ed25519'` fallback for version-0 JSON is left as-is and commented. Those files predate the crypto type being recorded and are by definition not Quantus, so the guess is safe — but quantus/common#6 removes `ed25519` from `KeypairType`, at which point it has to become a refusal. ## Acceptance - [x] a JSON file exported by the extension restores to an account with the same address - [x] the address reported before unlock matches the address after unlock - [x] upstream-format sr25519 files restore unchanged - [x] the restored account can sign, and the signature verifies against its address ## The cross-repo linking has run out of road Getting this repo to see the forked `common` took four separate fixes, each for the same underlying cause: **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. 1. `resolutions` pointing the four `@polkadot` packages at `common`'s build output 2. `tsconfig.base.json` path mappings, with the `*.d.ts` substitution **first**, because NodeNext will not infer an extension for a bare path and looks for a `types` *directory* instead of `types.d.ts` 3. `tsconfig.json` overriding 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" 4. `--preserve-symlinks` in the test script, which yarn itself warns is required for portals All four are dev-time scaffolding for a problem that does not exist once these packages come from a registry. This is the third repo to hit it (`common` → `@quantus/crypto`, now `ui` → `common`), and the extension will be the fourth with more dependencies than any of them. **quantus/extension#2 is now the blocking item**, not a tidy-up. Publishing to the Gitea npm registry needs a token, which I cannot create — so it needs a hand. Until then every downstream repo pays this tax again. One incidental result worth recording: `yarn lint` errors dropped from **25 to 8**. The tsconfig mappings resolved types that were previously `any`, removing a pile of `no-unsafe-assignment`. The remaining 8 are upstream's own, in `loadContract` and `allowGenesis`, and are left alone per the rebasability convention — note that `yarn lint` therefore still exits non-zero on this repo, as it does on pristine upstream.
Author
Owner

Verified against main (2d6a9ad1), closing.

  • Restored address is right: fixed in ec1399f3. restoreAccount passes the stored account id rather than inventing a public key, the same approach as common's createFromJson. An extension-exported ML-DSA JSON restores to the same address and can sign (Keyring.dilithium.spec.ts).
  • Tampering is caught: a JSON whose address does not match its key is rejected.
  • Address stable across unlock: checked in common's dilithiumJson.spec.ts, since restoreAccount unlocks immediately.
  • Upstream files unchanged: sr25519 restore is pinned, and ed25519 still takes the unchanged branch until quantus/common#6 removes it.
Verified against `main` (`2d6a9ad1`), closing. - **Restored address is right:** fixed in `ec1399f3`. `restoreAccount` passes the stored account id rather than inventing a public key, the same approach as common's `createFromJson`. An extension-exported ML-DSA JSON restores to the same address and can sign (`Keyring.dilithium.spec.ts`). - **Tampering is caught:** a JSON whose address does not match its key is rejected. - **Address stable across unlock:** checked in common's `dilithiumJson.spec.ts`, since `restoreAccount` unlocks immediately. - **Upstream files unchanged:** sr25519 restore is pinned, and ed25519 still takes the unchanged branch until quantus/common#6 removes it.
Sign in to join this conversation.
No Label
1 Participants
Notifications
Due Date
No due date set.
Dependencies

No dependencies set.

Reference: quantus/ui#1