Ethereum Ledger signing (#1542)

* [TESTING] Ecdsa signing

* remove hardcoding

* Remove consoles

* Fix import of exdsa accounts from ledger

* Add signing

* Add correct keypair type to hardware meta

* lint

* Update ui libs to 3.14.1

* Most recent changes

* Detect chain type and hash message accordingly

* testing logs

* Only use pubkey

* Make Ethereum signing work

* lint

* Remove rogue console.logs

* Change to isHex()

---------

Co-authored-by: Valentin Fernandez <tinchofernandez8@gmail.com>
This commit is contained in:
Tarik Gul 2025-09-17 11:51:33 -04:00 committed by GitHub
parent 592b63f60f
commit a154f9bd54
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
10 changed files with 105 additions and 47 deletions

View File

@ -64,8 +64,8 @@ export default class Extension {
return true;
}
private accountsCreateHardware ({ accountIndex, address, addressOffset, genesisHash, hardwareType, name }: RequestAccountCreateHardware): boolean {
keyring.addHardware(address, hardwareType, { accountIndex, addressOffset, genesisHash, name });
private accountsCreateHardware ({ accountIndex, address, addressOffset, genesisHash, hardwareType, name, type }: RequestAccountCreateHardware): boolean {
keyring.addHardware(address, hardwareType, { accountIndex, addressOffset, genesisHash, name, type });
return true;
}

View File

@ -204,6 +204,7 @@ export interface RequestAccountCreateHardware {
genesisHash: HexString;
hardwareType: string;
name: string;
type: KeypairType;
}
export interface RequestAccountChangePassword {

View File

@ -9,6 +9,7 @@ export interface Chain {
genesisHash?: string;
hasMetadata: boolean;
icon: string;
isEthereum?: boolean;
isUnknown?: boolean;
name: string;
registry: Registry;

View File

@ -9,7 +9,7 @@ import React, { useCallback, useContext, useEffect, useRef, useState } from 'rea
import { settings } from '@polkadot/ui-settings';
import { ActionContext, Address, Button, ButtonArea, Dropdown, VerticalSpace, Warning } from '../components/index.js';
import { ActionContext, Address, Button, ButtonArea, Dropdown, Switch, VerticalSpace, Warning } from '../components/index.js';
import { useLedger, useTranslation } from '../hooks/index.js';
import { createAccountHardware } from '../messaging.js';
import { Header, Name } from '../partials/index.js';
@ -39,9 +39,10 @@ function ImportLedger ({ className }: Props): React.ReactElement {
const [error, setError] = useState<string | null>(null);
const [isBusy, setIsBusy] = useState(false);
const [genesis, setGenesis] = useState<HexString | null>(null);
const [isEthereum, setIsEthereum] = useState(false);
const onAction = useContext(ActionContext);
const [name, setName] = useState<string | null>(null);
const { address, error: ledgerError, isLoading: ledgerLoading, isLocked: ledgerLocked, refresh, warning: ledgerWarning } = useLedger(genesis, accountIndex, addressOffset);
const { address, error: ledgerError, isLoading: ledgerLoading, isLocked: ledgerLocked, refresh, type, warning: ledgerWarning } = useLedger(genesis, accountIndex, addressOffset, isEthereum);
useEffect(() => {
if (address) {
@ -72,10 +73,10 @@ function ImportLedger ({ className }: Props): React.ReactElement {
const _onSave = useCallback(
() => {
if (address && genesis && name) {
if (address && genesis && name && type) {
setIsBusy(true);
createAccountHardware(address, 'ledger', accountIndex, addressOffset, name, genesis)
createAccountHardware(address, 'ledger', accountIndex, addressOffset, name, genesis, type)
.then(() => onAction('/'))
.catch((error: Error) => {
console.error(error);
@ -85,7 +86,7 @@ function ImportLedger ({ className }: Props): React.ReactElement {
});
}
},
[accountIndex, address, addressOffset, genesis, name, onAction]
[accountIndex, address, addressOffset, genesis, name, onAction, type]
);
// select element is returning a string
@ -105,7 +106,16 @@ function ImportLedger ({ className }: Props): React.ReactElement {
isExternal
isHardware
name={name}
type={type ?? undefined}
/>
<div className='ethereum-toggle'>
<Switch
checked={isEthereum}
checkedLabel={t('Ethereum Account')}
onChange={setIsEthereum}
uncheckedLabel={t('ED25519 Account')}
/>
</div>
<Dropdown
className='network'
label={t('Network')}
@ -137,6 +147,7 @@ function ImportLedger ({ className }: Props): React.ReactElement {
options={addOps.current}
value={addressOffset}
/>
</>
)}
{!!ledgerWarning && (
@ -183,4 +194,8 @@ export default styled(ImportLedger)<Props>`
.refreshIcon {
margin-right: 0.3rem;
}
.ethereum-toggle {
margin: 1rem 0;
}
`;

View File

@ -27,6 +27,7 @@ interface Props {
className?: string;
error: string | null;
genesisHash?: string;
isEthereum?: boolean;
onSignature?: ({ signature }: { signature: HexString }, signedTransaction?: HexString) => void;
payloadJson?: SignerPayloadJSON;
payloadExt?: ExtrinsicPayload
@ -55,11 +56,11 @@ function getMetadataProof (chain: Chain, payload: SignerPayloadJSON) {
};
}
function LedgerSign ({ accountIndex, addressOffset, className, error, genesisHash, onSignature, payloadExt, payloadJson, setError }: Props): React.ReactElement<Props> {
function LedgerSign ({ accountIndex, addressOffset, className, error, genesisHash, isEthereum = false, onSignature, payloadExt, payloadJson, setError }: Props): React.ReactElement<Props> {
const [isBusy, setIsBusy] = useState(false);
const { t } = useTranslation();
const chain = useMetadata(genesisHash);
const { error: ledgerError, isLoading: ledgerLoading, isLocked: ledgerLocked, ledger, refresh, warning: ledgerWarning } = useLedger(genesisHash, accountIndex, addressOffset);
const { error: ledgerError, isLoading: ledgerLoading, isLocked: ledgerLocked, ledger, refresh, type: _ledgerType, warning: ledgerWarning } = useLedger(genesisHash, accountIndex, addressOffset, isEthereum);
useEffect(() => {
if (ledgerError) {
@ -94,30 +95,53 @@ function LedgerSign ({ accountIndex, addressOffset, className, error, genesisHas
}
const { raw, txMetadata } = getMetadataProof(chain, payloadJson);
const metaBuff = Buffer.from(txMetadata);
(ledger as LedgerGeneric).signWithMetadata(raw.toU8a(true), accountIndex, addressOffset, { metadata: metaBuff })
.then((signature) => {
const extrinsic = chain.registry.createType(
'Extrinsic',
{ method: raw.method },
{ version: 4 }
);
if (isEthereum) {
(ledger as LedgerGeneric).signWithMetadataEcdsa(raw.toU8a(true), accountIndex, addressOffset, { metadata: metaBuff })
.then(({ signature }) => {
const extrinsic = chain.registry.createType(
'Extrinsic',
{ method: raw.method },
{ version: 4 }
);
(ledger as LedgerGeneric).getAddress(chain.ss58Format, false, accountIndex, addressOffset)
.then(({ address }) => {
extrinsic.addSignature(address, signature.signature, raw.toHex());
onSignature(signature, extrinsic.toHex());
})
.catch((e: Error) => {
setError(e.message);
setIsBusy(false);
});
}).catch((e: Error) => {
setError(e.message);
setIsBusy(false);
});
(ledger as LedgerGeneric).getAddressEcdsa(false, accountIndex, addressOffset)
.then(({ address }) => {
extrinsic.addSignature(`0x${address}`, signature, raw.toHex());
onSignature({ signature }, extrinsic.toHex());
})
.catch((e: Error) => {
setError(e.message);
setIsBusy(false);
});
}).catch((e: Error) => {
setError(e.message);
setIsBusy(false);
});
} else {
(ledger as LedgerGeneric).signWithMetadata(raw.toU8a(true), accountIndex, addressOffset, { metadata: metaBuff })
.then((signature) => {
const extrinsic = chain.registry.createType(
'Extrinsic',
{ method: raw.method },
{ version: 4 }
);
(ledger as LedgerGeneric).getAddress(chain.ss58Format, false, accountIndex, addressOffset)
.then(({ address }) => {
extrinsic.addSignature(address, signature.signature, raw.toHex());
onSignature(signature, extrinsic.toHex());
})
.catch((e: Error) => {
setError(e.message);
setIsBusy(false);
});
}).catch((e: Error) => {
setError(e.message);
setIsBusy(false);
});
}
} else if (currApp === 'chainSpecific') {
(ledger as Ledger).sign(payloadExt.toU8a(true), accountIndex, addressOffset)
.then((signature) => {
@ -128,7 +152,7 @@ function LedgerSign ({ accountIndex, addressOffset, className, error, genesisHas
});
}
},
[accountIndex, addressOffset, chain, ledger, onSignature, payloadJson, payloadExt, setError]
[accountIndex, addressOffset, chain, ledger, onSignature, payloadJson, payloadExt, setError, isEthereum]
);
return (

View File

@ -43,7 +43,7 @@ function isRawPayload (payload: SignerPayloadJSON | SignerPayloadRaw): payload i
return !!(payload as SignerPayloadRaw).data;
}
export default function Request ({ account: { accountIndex, addressOffset, genesisHash, isExternal, isHardware }, buttonText, isFirst, request, signId, url }: Props): React.ReactElement<Props> | null {
export default function Request ({ account: { accountIndex, addressOffset, genesisHash, isExternal, isHardware, type }, buttonText, isFirst, request, signId, url }: Props): React.ReactElement<Props> | null {
const onAction = useContext(ActionContext);
const [{ hexBytes, payload }, setData] = useState<Data>({ hexBytes: null, payload: null });
const [error, setError] = useState<string | null>(null);
@ -125,6 +125,7 @@ export default function Request ({ account: { accountIndex, addressOffset, genes
addressOffset={addressOffset || 0}
error={error}
genesisHash={json.genesisHash}
isEthereum={type === 'ethereum'}
onSignature={_onSignature}
payloadExt={payload}
payloadJson={json}

View File

@ -14,6 +14,7 @@ import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
import React, { useCallback, useContext, useEffect, useRef, useState } from 'react';
import CopyToClipboard from 'react-copy-to-clipboard';
import { hexToU8a, isHex } from '@polkadot/util';
import { decodeAddress, encodeAddress } from '@polkadot/util-crypto';
import details from '../assets/details.svg';
@ -71,8 +72,7 @@ function findAccountByAddress (accounts: AccountJson[], _address: string): Accou
// recodes an supplied address using the prefix/genesisHash, include the actual saved account & chain
function recodeAddress (address: string, accounts: AccountWithChildren[], chain: Chain | null, settings: SettingsStruct): Recoded {
// decode and create a shortcut for the encoded address
const publicKey = decodeAddress(address);
const publicKey = isHex(address) ? hexToU8a(address) : decodeAddress(address);
// find our account using the actual publicKey, and then find the associated chain
const account = findSubstrateAccount(accounts, publicKey);
const prefix = chain ? chain.ss58Format : (settings.prefix === -1 ? 42 : settings.prefix);

View File

@ -7,6 +7,7 @@
import type { Network } from '@polkadot/networks/types';
import type { HexString } from '@polkadot/util/types';
import type { KeypairType } from '@polkadot/util-crypto/types';
import { useCallback, useEffect, useMemo, useState } from 'react';
@ -31,6 +32,7 @@ interface State extends StateBase {
isLocked: boolean;
ledger: LedgerGeneric | Ledger | null;
refresh: () => void;
type: KeypairType | null;
warning: string | null;
}
@ -82,13 +84,14 @@ function retrieveLedger (genesis: string): LedgerGeneric | Ledger {
return ledger;
}
export default function useLedger (genesis?: string | null, accountIndex = 0, addressOffset = 0): State {
export default function useLedger (genesis?: string | null, accountIndex = 0, addressOffset = 0, isEthereum = false): State {
const [isLoading, setIsLoading] = useState(false);
const [isLocked, setIsLocked] = useState(false);
const [refreshLock, setRefreshLock] = useState(false);
const [warning, setWarning] = useState<string | null>(null);
const [error, setError] = useState<string | null>(null);
const [address, setAddress] = useState<string | null>(null);
const [type, setType] = useState<KeypairType | null>(null);
const { t } = useTranslation();
const handleGetAddressError = (e: Error, genesis: string) => {
@ -154,17 +157,28 @@ export default function useLedger (genesis?: string | null, accountIndex = 0, ad
// Just in case, but this shouldn't be triggered
assert(chosenNetwork, t('This network is not available, please report an issue to update the known chains'));
const currApp = settings.get().ledgerApp;
if (currApp === 'generic' || currApp === 'migration') {
(ledger as LedgerGeneric).getAddress(chosenNetwork.ss58Format, false, accountIndex, addressOffset)
.then((res) => {
setIsLoading(false);
setAddress(res.address);
}).catch((e: Error) => {
handleGetAddressError(e, genesis);
});
if (isEthereum) {
(ledger as LedgerGeneric).getAddressEcdsa(false, accountIndex, addressOffset)
.then((res) => {
setIsLoading(false);
setAddress(`0x${res.address}`);
setType('ethereum');
}).catch((e: Error) => {
handleGetAddressError(e, genesis);
});
} else {
(ledger as LedgerGeneric).getAddress(chosenNetwork.ss58Format, false, accountIndex, addressOffset)
.then((res) => {
setIsLoading(false);
setAddress(res.address);
setType('ed25519');
}).catch((e: Error) => {
handleGetAddressError(e, genesis);
});
}
} else if (currApp === 'chainSpecific') {
(ledger as Ledger).getAddress(false, accountIndex, addressOffset)
.then((res) => {
@ -177,7 +191,7 @@ export default function useLedger (genesis?: string | null, accountIndex = 0, ad
// If the dependency array is exhaustive, with t, the translation function, it
// triggers a useless re-render when ledger device is connected.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [accountIndex, addressOffset, genesis, ledger]);
}, [accountIndex, addressOffset, genesis, ledger, isEthereum]);
const refresh = useCallback(() => {
setRefreshLock(true);
@ -185,5 +199,5 @@ export default function useLedger (genesis?: string | null, accountIndex = 0, ad
setWarning(null);
}, []);
return ({ ...getState(), address, error, isLoading, isLocked, ledger, refresh, warning });
return ({ ...getState(), address, error, isLoading, isLocked, ledger, refresh, type, warning });
}

View File

@ -143,8 +143,8 @@ export async function createAccountExternal (name: string, address: string, gene
return sendMessage('pri(accounts.create.external)', { address, genesisHash, name });
}
export async function createAccountHardware (address: string, hardwareType: string, accountIndex: number, addressOffset: number, name: string, genesisHash: HexString): Promise<boolean> {
return sendMessage('pri(accounts.create.hardware)', { accountIndex, address, addressOffset, genesisHash, hardwareType, name });
export async function createAccountHardware (address: string, hardwareType: string, accountIndex: number, addressOffset: number, name: string, genesisHash: HexString, type: KeypairType): Promise<boolean> {
return sendMessage('pri(accounts.create.hardware)', { accountIndex, address, addressOffset, genesisHash, hardwareType, name, type });
}
export async function createAccountSuri (name: string, password: string, suri: string, type?: KeypairType, genesisHash?: HexString | null): Promise<boolean> {

View File

@ -182,6 +182,8 @@
"Ledger App": "",
"Don't ask again": "",
"Previous": "",
"ED25519 Account": "",
"Ethereum Account": "",
"It looks like this request is coming from an suspicious origin. Please verify the source carefully.": "",
"Password is not strong enough": "",
"Password must be at least {{length}} characters long": ""