Add SEP-53 message signing support for hardware wallets - #2952
Add SEP-53 message signing support for hardware wallets#2952SmolinPavel wants to merge 10 commits into
Conversation
Freighter blocked arbitrary data signing whenever the active account was
a hardware wallet. That guard predates the Ledger Stellar app v6.0.0,
which added a dedicated SEP-53 SIGN_MESSAGE instruction (APDU 0x0C), and
the pinned @ledgerhq/hw-app-str already exposes signMessage.
Wire the message-signing path through the existing hardware overlay,
mirroring how Soroban auth entries are signed. The device applies the
SEP-53 prefix and hashing itself and displays the message for review, so
it receives the raw UTF-8 bytes and the resulting signature matches what
encodeSep53Message produces for a local key.
Two supporting fixes the path needs:
- The signature crosses runtime.sendMessage as base64 and is normalized
back to a Buffer in the background. A raw Buffer is JSON-serialized in
transit, so toString("base64") would have yielded "[object Object]".
- handleSignedHwPayload now forwards the device-derived signer address.
SEP-53 consumers need it to verify the signature, and the transaction
and auth-entry hardware paths were dropping it too.
An app older than v6.0.0 now reports that it needs updating rather than
surfacing an opaque transport error.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Two fixes from review of the SEP-53 hardware signing change. parseWalletError is shared by every Ledger flow, so matching the device's generic INS_NOT_SUPPORTED (0x6d00) status word there was wrong: that code also fires when signing a Soroban auth entry on an older app, or when a Stellar APDU is sent while another app is open. Those users would have been told to update to 6.0.0 "to sign messages" while signing a transaction. The getAppConfiguration() preflight already identifies the real case precisely, so only its sentinel is matched now. HardwareSign runs its first signing attempt automatically from a mount effect, but the error block rendered only after the "Detect device" button had been clicked. A device that failed immediately left the overlay reading "Connect device to computer" with no explanation until the user clicked through and failed a second time. Rendering on the error itself removes the flag and covers the automatic attempt, so the transaction and auth-entry overlays report their failures on first try as well. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…are-wallet-signing
There was a problem hiding this comment.
Pull request overview
Adds SEP-53 message signing support for Ledger hardware wallets.
Changes:
- Adds Ledger message signing with version validation and error handling.
- Transports signatures as base64 and forwards signer addresses.
- Updates hardware-signing UI, tests, localization, and documentation.
Reviewed changes
Copilot reviewed 16 out of 16 changed files in this pull request and generated 3 comments.
Show a summary per file
| File | Description |
|---|---|
extension/src/popup/views/SignMessage/index.tsx |
Enables the hardware signing overlay. |
extension/src/popup/locales/pt/translation.json |
Updates Portuguese signing messages. |
extension/src/popup/locales/en/translation.json |
Updates English signing messages. |
extension/src/popup/helpers/hardwareConnect.ts |
Implements Ledger SEP-53 signing. |
extension/src/popup/helpers/__tests__/hardwareConnect.test.ts |
Tests Ledger signing and version handling. |
extension/src/popup/ducks/transactionSubmission.ts |
Routes message payloads to hardware signing. |
extension/src/popup/ducks/__tests__/signWithHardwareWallet.test.ts |
Tests the new thunk branch. |
extension/src/popup/components/hardwareConnect/HardwareSign/index.tsx |
Integrates message signing and immediate errors. |
extension/src/helpers/stellar.ts |
Adds signature transport normalization. |
extension/src/helpers/__tests__/stellar.test.ts |
Tests signature normalization. |
extension/src/background/messageListener/handlers/handleSignedHwPayload.ts |
Forwards signer addresses. |
extension/src/background/messageListener/freighterApiMessageListener.ts |
Normalizes hardware signatures. |
extension/src/background/messageListener/__tests__/handleSignedHwPayload.test.ts |
Tests signer-address forwarding. |
extension/INTEGRATING_HARDWARE_WALLET.MD |
Documents additional signing integrations. |
@shared/api/types/message-request.ts |
Extends hardware payload messages. |
@shared/api/internal.ts |
Sends signer addresses to the background. |
💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.
Addresses three review comments on the SEP-53 hardware signing change. HardwareSign derived a public key from whatever device happened to be attached and passed it straight through. For a transaction that is self-correcting: the signature must satisfy the transaction's source account, so a device holding a different seed produces a signature the network rejects. A standalone SEP-53 message has no such binding, so the wrong device would return a perfectly valid signature from an account the user never approved — and, now that the signer address is reported, that account would be presented to the dApp as authoritative. Compare the derived key against the active account and refuse instead of signing. The overlay also told the user to "Review transaction on device" while a message was on screen. The instruction now follows the payload, which fixes the Soroban authorization case at the same time. Adds a HardwareSign test suite covering the account-mismatch refusal, the message-specific copy, and the first-attempt error state — the last of which had no coverage and regressed silently before. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Follow-up to the account-binding guard, from a second review pass. signFlowAccountSelector only switches accounts when the account a dApp names is actually held by the wallet; when it is not, the previously active account silently stays active, and accountNotFound is set only if the *active* key is missing rather than the requested one. Comparing the device against the active account therefore succeeded in exactly the case that matters: the dApp asks for an account the user does not have, the popup falls back to an unrelated Ledger account, and a valid signature is returned for an identity nobody requested. Compare against the requested account when one was named. Two smaller fixes from the same pass: - getAppConfiguration already reports the device's maxDataSize, so a message that exceeds it is now rejected with an actionable error instead of the library's opaque 0xb004 remap. Measured in UTF-8 bytes, since the limit is a byte limit. - The mount effect gated auto-signing on a truthy payload. An XDR or a base64 auth entry is never empty, but a SEP-53 message may be, which left the overlay idle on a fully connected device until the user clicked "Detect device" by hand. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 17 out of 17 changed files in this pull request and generated no new comments.
Suppressed comments (1)
extension/src/popup/components/hardwareConnect/HardwareSign/index.tsx:115
- This account check is not bound to the actual signing operation.
getWalletPublicKeyreads one Ledger connection, buthardwareSignMessagesubsequently callsconnectToLedgerTransport(), which closes that connection and opens another. With multiple authorized devices—or a device swap between calls—the signature can come from a different key whilesignerAddressis still reported as the key checked here, producing an unverifiable/misattributed response. Derive the key and sign through the same transport/API instance, or verify the returned SEP-53 signature againstexpectedPublicKeybefore forwarding it.
const expectedPublicKey = requestedPublicKey || activePublicKey;
if (
isSignMessage &&
expectedPublicKey &&
publicKey !== expectedPublicKey
) {
throw new Error(MISMATCHED_HARDWARE_ACCOUNT_ERROR);
connectToLedgerTransport closes and reopens the device connection on every call, and getWalletPublicKey and hardwareSignMessage each call it. The account check therefore read a key over one connection while the signature was produced over another, so a second authorized device — or a swap between the two calls — could sign with a different key while the first key was still reported as signerAddress. The guard could be defeated by exactly the situation it was added to prevent. Verify the returned signature against the key being reported, rather than trusting that the same device answered twice. Combined with the existing check that this key is the requested account, the signature the dApp receives is now provably from the account the user approved. This also pins the device's SEP-53 digest to encodeSep53Message. That agreement was previously an untested assumption — every device call is mocked — and a mismatch would have produced signatures no verifier could check. Now it fails loudly instead. The affected tests sign with real keypairs, so verification is exercised rather than stubbed: the happy path passes only because a genuine signature verifies, and the refusals only because it actually runs. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Populating signerAddress on every hardware path went too far. The key is read over a connection that the signing call then closes and reopens, so on the transaction and auth-entry paths it is the key we asked first, not provably the key that signed — the same gap the message path now closes by verifying the signature. Those two paths were reporting no signer address before this branch, and a field that can be wrong is worse than one that is absent. Restrict it to the message path, where the signature is verified against it. A transaction or auth entry signed by the wrong key still fails on its own at submission, so nothing is lost. Not addressed here: signWithHardwareWallet dispatches no metrics, so hardware signing is absent from the signing events in metrics/access.ts. That is pre-existing and applies equally to transactions and auth entries, so covering only messages would make the telemetry inconsistent. It wants one handler for all three, and an event shape Stellar owns. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 17 out of 17 changed files in this pull request and generated no new comments.
Suppressed comments (2)
extension/src/popup/helpers/tests/hardwareConnect.test.ts:205
- Use “an” before “8-byte.”
// "☕" is 3 UTF-8 bytes; 4 of them exceed a 8-byte device limit even though
extension/src/popup/components/hardwareConnect/HardwareSign/index.tsx:153
signerAddressis still explicitly omitted for hardware transaction and auth-entry responses, despite the stated supporting fix and the SDK contracts requiring a signer address. Those flows will continue resolvingundefined, so consumer checks such as those documented indocs/api-integration.md:106-108and:158-160still fail. Verify each hardware signature againstpublicKeyinsignWithHardwareWallet(as the message branch does), then forward the verified key for all three flows.
signerAddress: isSignMessage ? publicKey : undefined,
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
|
@JakeUrban could you please review this PR? |
…are-wallet-signing
…smatch A failed `verify()` in the message branch reported MISMATCHED_HARDWARE_ACCOUNT_ERROR, which reads as "The connected device does not match the selected account. Connect the device this account was added from and try again." That check covers two causes. A device swapped between the two connections is one; a device whose SEP-53 digest disagrees with `encodeSep53Message`, or one returning a malformed signature, is the other — and there the attached device is already the right one, so swapping hardware is a dead end and the real cause stays hidden. Add UNVERIFIED_SIGN_MESSAGE_ERROR for the post-sign verification and leave MISMATCHED_HARDWARE_ACCOUNT_ERROR to the pre-sign check in HardwareSign, which has actually compared two keys and can honestly name the device. The new copy asks the user to retry with other devices disconnected and to update the Stellar app if it persists, covering both causes without asserting either. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
| if ( | ||
| isSignMessage && | ||
| expectedPublicKey && | ||
| publicKey !== expectedPublicKey |
There was a problem hiding this comment.
The new guard compares the device key against requestedPublicKey with raw string equality, but accountToSign is passed through unnormalized — a dApp can legitimately supply a muxed (M...) address whose base account is exactly the account that's active and attached.
In that case signFlowAccountSelector finds no match (it also compares exact G-strings), the guard compares a G-address against the M-string, and signing dead-ends with MISMATCHED_HARDWARE_ACCOUNT_ERROR — telling the user to connect a different device while the correct one is already attached. Retrying via "Detect device" fails identically forever. The same request works for signTransaction, which normalizes muxed and federation forms via decodeAccountToSign before comparing.
isSameAccount in extension/src/helpers/stellar.ts already does the muxed→base normalization synchronously:
if (isSignMessage && expectedPublicKey && !isSameAccount(publicKey, expectedPublicKey)) {
throw new Error(MISMATCHED_HARDWARE_ACCOUNT_ERROR);
}Generated by Claude Code
There was a problem hiding this comment.
We can update this so we're consistent with signTransaction, but a dapp passing a muxed or federated address is invalid isn't it @aristidesstaffieri ?
There was a problem hiding this comment.
Traced it — the dead-end is real. signFlowAccountSelector compares exact strings (popup/helpers/account.ts:379), and currentAccount is pre-seeded from the active account so accountNotFound never fires. The user reaches the signing screen, hits the device error with the correct Ledger attached, and Detect device re-runs the same comparison forever.
One correction: signTransaction doesn't work either. decodeAccountToSign() runs at SignTransaction/index.tsx:232, but useGetSignTxData(..., accountToSign, ...) already ran at :134, and it mutates a render-local let nothing reads afterward. It gets the raw M-address too — SignMessage is just the only flow with a device guard, so it's the only one that fails loudly.
@JakeUrban muxed isn't supported end to end today: even for software accounts signMessage returns the base G, which fails the signerAddress === expectedAddress check we tell integrators to write. isSameAccount wouldn't change that — the guard would pass but the dApp still rejects. It also uses the sync getBaseAccount, so federation dead-ends identically.
So it's where it fails, not whether. Either isSameAccount (downgrades to a clean dApp-side rejection) or reject M/federation up front with copy naming the real problem. Happy to do either — preference?
What
Enables SEP-53 message signing for hardware wallets. Previously
SignMessagereturned an "Unsupported signing method" screen whenever the active account was
a hardware wallet.
That guard was added in 5.23.0 (Aug 2024). It predates Ledger Stellar app
v6.0.0, which added a dedicated SEP-53
SIGN_MESSAGEinstruction (APDU0x0C, documented in app-stellar as "Sign the SEP-0053 message"). The pinned@ledgerhq/hw-app-str@7.7.4already exposessignMessage, so no dependencychange is needed.
How
The message path mirrors the existing Soroban auth-entry path — a new
hardwareSignMessagemap inhardwareConnect.ts, anisSignMessagebranch inthe
signWithHardwareWalletthunk, and anisSignMessageprop onHardwareSign.The device applies the SEP-53 prefix and hashing itself and displays the message
for review, so it receives the raw UTF-8 bytes. The resulting signature is
identical to what
encodeSep53Message+ a local key produce, so the dApp-facingresponse shape is unchanged. This is not blind signing — the message renders
as text on the device.
Two supporting fixes the path needs
runtime.sendMessageas base64 andis normalized back to a Buffer by a new
toSignatureBufferhelper. A rawBufferis JSON-serialized in transit, sosignedBlob.toString("base64")insubmitBlobwould have produced"[object Object]".signerAddress.handleSignedHwPayloadnow forwards the signing address,which SEP-53 consumers need in order to verify the signature they get back.
The message branch verifies the returned signature against that key before
reporting it —
connectToLedgerTransportcloses and reopens the connection onevery call, so the key read by
getWalletPublicKeyis not otherwise provablythe key that signed.
Error handling
A Stellar app older than v6.0.0 now reports that it needs updating, rather than
surfacing an opaque transport error. The check is a
getAppConfiguration()preflight in
hardwareSignMessage; an unparseable version defers to the devicerather than refusing. Deliberately not matched on the device's generic
INS_NOT_SUPPORTED (0x6d00)status word —parseWalletErroris shared by everyLedger flow, and that code also fires for e.g. a Soroban auth entry on an older
app, where both the reason and the version number would be wrong.
Second commit also fixes an adjacent problem this path exposed:
HardwareSignruns its first signing attempt automatically from a mount effect, but the error
block only rendered after the "Detect device" button had been clicked. A device
that failed immediately left the overlay reading "Connect device to computer"
with no explanation until the user clicked through and failed again. Rendering on
the error itself drops the flag and covers the automatic attempt, so the existing
transaction and auth-entry overlays report failures on first try too.
Testing
Verified end to end on a Ledger Flex running Stellar app 6.0.3: the message
text renders on the device, approval returns a signature, and the dApp verifies
it against the signer address.
error mapping, the thunk branch, the base64 normalization, the
signerAddresspassthrough, and a new
HardwareSignsuite for the account guard and thefirst-attempt error state. The thunk and component tests sign with real
keypairs, so signature verification is exercised rather than stubbed.
testNodeCompat.js, ispre-existing on
masterand needs@stellar/freighter-apibuilt first.tsc --noEmit -p extension/tsconfig.jsonclean; ESLint clean on changed files.Notes for reviewers
INTEGRATING_HARDWARE_WALLET.MDdocumented onlyhardwareSign; it now alsocovers
hardwareSignAuth(already required) andhardwareSignMessage.getAppConfiguration()also reports the device'smaxDataSize, so anoversized message is rejected with an actionable error rather than the
library's
RequestDataTooLarge(0xB004) remap. Measured in UTF-8 bytes.Two pre-existing gaps this work surfaced, deliberately left alone
Both affect the hardware transaction and auth-entry paths equally, so fixing
either only for messages would make the behaviour inconsistent. Flagging rather
than guessing, since both touch areas you own:
signerAddressis stillundefinedfor hardware transactions and authentries.
docs/api-integration.md:145tells integrators to "always verifyresult.signerAddress === expectedAddress", which means a dApp following thatguidance rejects every hardware-wallet transaction. Closing it properly means
verifying those signatures too (against
tx.hash()andhash(authPreimage)respectively) before reporting a key — cheap, but it changes the wallet's
busiest signing path, and I could only test message signing on a device.
signWithHardwareWalletdispatches no metrics. The signing events inpopup/metrics/access.tsare registered onsignTransaction/signBlob/signEntry, so hardware signing has never appeared in them. This wants onehandler covering all three and an event shape you'd want to decide.