Skip to content

Add SEP-53 message signing support for hardware wallets - #2952

Open
SmolinPavel wants to merge 10 commits into
stellar:masterfrom
SmolinPavel:feature/sep53-hardware-wallet-signing
Open

Add SEP-53 message signing support for hardware wallets#2952
SmolinPavel wants to merge 10 commits into
stellar:masterfrom
SmolinPavel:feature/sep53-hardware-wallet-signing

Conversation

@SmolinPavel

@SmolinPavel SmolinPavel commented Aug 11, 2026

Copy link
Copy Markdown

What

Enables SEP-53 message signing for hardware wallets. Previously SignMessage
returned 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_MESSAGE instruction (APDU
0x0C, documented in app-stellar as "Sign the SEP-0053 message"). The pinned
@ledgerhq/hw-app-str@7.7.4 already exposes signMessage, so no dependency
change is needed.

How

The message path mirrors the existing Soroban auth-entry path — a new
hardwareSignMessage map in hardwareConnect.ts, an isSignMessage branch in
the signWithHardwareWallet thunk, and an isSignMessage prop on
HardwareSign.

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-facing
response shape is unchanged. This is not blind signing — the message renders
as text on the device.

Two supporting fixes the path needs

  • base64 transport. The signature crosses runtime.sendMessage as base64 and
    is normalized back to a Buffer by a new toSignatureBuffer helper. A raw
    Buffer is JSON-serialized in transit, so signedBlob.toString("base64") in
    submitBlob would have produced "[object Object]".
  • signerAddress. handleSignedHwPayload now 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 — connectToLedgerTransport closes and reopens the connection on
    every call, so the key read by getWalletPublicKey is not otherwise provably
    the 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 device
rather than refusing. Deliberately not matched on the device's generic
INS_NOT_SUPPORTED (0x6d00) status word — parseWalletError is shared by every
Ledger 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: HardwareSign
runs 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.

  • 27 new unit tests covering the signing helper, the version and size gates, the
    error mapping, the thunk branch, the base64 normalization, the signerAddress
    passthrough, and a new HardwareSign suite for the account guard and the
    first-attempt error state. The thunk and component tests sign with real
    keypairs, so signature verification is exercised rather than stubbed.
  • Full Jest suite: 1540 passing. The one failing suite, testNodeCompat.js, is
    pre-existing on master and needs @stellar/freighter-api built first.
  • tsc --noEmit -p extension/tsconfig.json clean; ESLint clean on changed files.

Notes for reviewers

  • INTEGRATING_HARDWARE_WALLET.MD documented only hardwareSign; it now also
    covers hardwareSignAuth (already required) and hardwareSignMessage.
  • Two now-unused locale keys were removed alongside the deleted warning screen.
  • getAppConfiguration() also reports the device's maxDataSize, so an
    oversized 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:

  1. signerAddress is still undefined for hardware transactions and auth
    entries.
    docs/api-integration.md:145 tells integrators to "always verify
    result.signerAddress === expectedAddress", which means a dApp following that
    guidance rejects every hardware-wallet transaction. Closing it properly means
    verifying those signatures too (against tx.hash() and hash(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.
  2. signWithHardwareWallet dispatches no metrics. The signing events in
    popup/metrics/access.ts are registered on signTransaction / signBlob /
    signEntry, so hardware signing has never appeared in them. This wants one
    handler covering all three and an event shape you'd want to decide.

SmolinPavel and others added 2 commits August 10, 2026 22:30
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>
Copilot AI balanced review requested due to automatic review settings August 11, 2026 14:57

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Comment thread extension/src/popup/components/hardwareConnect/HardwareSign/index.tsx Outdated
SmolinPavel and others added 2 commits August 11, 2026 17:16
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>

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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. getWalletPublicKey reads one Ledger connection, but hardwareSignMessage subsequently calls connectToLedgerTransport(), 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 while signerAddress is 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 against expectedPublicKey before forwarding it.
      const expectedPublicKey = requestedPublicKey || activePublicKey;
      if (
        isSignMessage &&
        expectedPublicKey &&
        publicKey !== expectedPublicKey
      ) {
        throw new Error(MISMATCHED_HARDWARE_ACCOUNT_ERROR);

SmolinPavel and others added 2 commits August 11, 2026 18:38
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>

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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

  • signerAddress is 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 resolving undefined, so consumer checks such as those documented in docs/api-integration.md:106-108 and :158-160 still fail. Verify each hardware signature against publicKey in signWithHardwareWallet (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>
@SmolinPavel

Copy link
Copy Markdown
Author

@JakeUrban could you please review this PR?

Comment thread extension/src/popup/ducks/transactionSubmission.ts Outdated
SmolinPavel and others added 2 commits August 12, 2026 22:16
…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>
@SmolinPavel
SmolinPavel requested a review from JakeUrban August 12, 2026 20:31
if (
isSignMessage &&
expectedPublicKey &&
publicKey !== expectedPublicKey

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 ?

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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?

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants