Skip to content

Soroban auth signing with Ledger-backed identities #2566

@fnando

Description

@fnando

Context

#2563 added stellar keys add --ledger, which persists a Secret::Ledger { hardware, public_key, hd_path } identity. Read-only operations (alias → address, keys public-key, keys address) now work without the device because the public key is cached on disk. Source-account signing also works via --source <ledger-alias> and the pre-existing --sign-with-ledger flag in sign_with.rs.

What does not work today: a Soroban contract function that calls require_auth() on an Address argument resolved from a Ledger-backed alias. The argument-parsing path collects co-signers eagerly into Vec<Signer>, and a SignerKind::Ledger in that list will panic at sign time when the device is connected.

Behavior matrix

Device Function requires auth? Result
Disconnected No ✅ Works (read-only address lookup uses cached pubkey)
Connected No ✅ Works (no auth entries → signer Vec never iterated)
Disconnected Yes ⚠️ Clean error: Missing signing key for account G…
Connected Yes 🔥 Panic at todo!("ledger device is not implemented") in Signer::get_public_key

The connected + auth-required case is the actual defect. The other rows are acceptable today.

Reproduction

// contracts/auth/src/lib.rs
#[contractimpl]
impl Contract {
    pub fn auth(_env: Env, value: Address) -> Address {
        value.require_auth();
        value
    }
}
stellar keys add my-ledger --ledger      # device connected, hd-path 0
# unplug the device for the disconnected row, plug it in for connected
stellar contract invoke --id <C…> -- auth --value my-ledger

Steps inside the CLI (connected case):

  1. parse_single_argument (cmd/soroban-cli/src/commands/contract/arg_parsing.rs:206–269) hits the Address arg and calls resolve_signer(trimmed_s, config).await (line 237).
  2. resolve_signer (arg_parsing.rs:467–472) calls Secret::signer().await, which reaches ledger::new(hd_path).await (cmd/soroban-cli/src/config/secret.rs:225) and opens the HID transport during argument parsing.
  3. The collected Vec<Signer> flows through build_host_function_parametersinvoke.rs::executesim_sign_and_send_tx (cmd/soroban-cli/src/tx.rs:33–120) → config.sign_soroban_authorizations(…) (cmd/soroban-cli/src/config/mod.rs:137–152) → signer::sign_soroban_authorizations (cmd/soroban-cli/src/signer/mod.rs:59–152).
  4. For each auth entry, the inner function loops every signer calling signer.get_public_key() to find a match.
  5. Signer::get_public_key is todo!("ledger device is not implemented") for SignerKind::Ledger (cmd/soroban-cli/src/signer/mod.rs:281). Process panics.

Constraints

  • hidapi is per-process. Every call to ledger::new() constructs a fresh HidApi::new() and TransportNativeHID::new(&hidapi) (cmd/crates/stellar-ledger/src/lib.rs:321–325 and cmd/soroban-cli/src/signer/ledger.rs:38–65). There is no pool, cache, OnceCell, or Mutex. Two concurrent transports in the same process conflict.
  • Signer construction is eager. Vec<Signer> is built during arg parsing and held until after sign_soroban_authorizations runs, so a Ledger transport stays open across the parse → simulate → RPC → sign window — much longer than necessary.
  • SignerKind::Ledger lacks a cached public key. It only holds the Ledger<TransportNativeHID> (no public_key field), so get_public_key() has nothing to return without device interaction. The cached pubkey lives on Secret::Ledger, not on SignerKind::Ledger.
  • Signer::sign_payload and Signer::get_public_key are unimplemented for SignerKind::Ledger (cmd/soroban-cli/src/signer/mod.rs:281, 288). Only Signer::sign_tx_hash has a Ledger implementation, via LedgerSigner::sign_transaction_hash at signer/mod.rs:303–306.
  • Stellar Ledger app payload support. sign_soroban_authorizations builds a 32-byte HashIdPreimageSorobanAuthorization hash. Need to verify whether the app accepts the Soroban auth preimage as a hash-signing target — likely the same primitive used by sign_transaction_hash, but worth confirming with the firmware team / app docs before relying on it.

Broader architectural gap

The --sign-with-* flag family (--sign-with-ledger, --sign-with-lab, --sign-with-key) operates only on the transaction envelope, never on auth entries. Auth entries are populated only by walking address arguments in arg_parsing.rs and auto-resolving each as a known signing identity. There is no escape hatch — no way to say "use this identity for this auth entry" — so the gap isn't strictly Ledger-specific:

  • keys add alice --secret-key … then contract invoke … -- auth --value alice — works, because Secret::SecretKey resolves cleanly in resolve_signer.
  • keys add alice --secure-store (locked keychain) then the same call — also fails to sign the auth entry, because Secret::signer().await errors and .ok()? swallows it. The user just sees the chain-side Missing signing key error.

The Ledger case is the most visible because connected-device + auth-required panics instead of erroring cleanly, but the underlying constraint (no explicit auth-entry signer flag) is broader. Worth considering a --sign-auth-with <alias> (repeatable) flag alongside the Ledger fix, separable but related.

Proposed fix

  1. Defer signer construction. Change the type collected by parse_function_arguments from Vec<Signer> to a deferred form (e.g. Vec<DeferredSigner> carrying the Secret and an alias name, or just Vec<Secret>). Materialize the actual Signer only inside sign_soroban_authorizations, one at a time, and drop it before moving on. HID opens only inside the signing window — never during arg parsing — and only one Ledger transport exists at a time, so it cannot conflict with the source-account signer.
  2. Thread the cached public key onto SignerKind::Ledger. Add a public_key: stellar_strkey::ed25519::PublicKey field so Signer::get_public_key can return synchronously without device I/O.
  3. Implement Signer::sign_payload for SignerKind::Ledger. Wire it through LedgerSigner::sign_transaction_hash (or the appropriate auth-payload primitive once verified) and convert the result into Ed25519Signature.
  4. Make signer::sign_soroban_authorizations async (or split into a sync "match auth → required pubkey" pass and an async "construct + sign" pass — two-pass is cleaner because it isolates HID I/O to the second pass).
  5. UX: each Ledger co-signer requires a device confirmation; print which alias is being prompted for (e.g. Confirm on Ledger for my-ledger…). With N auth entries needing the same Ledger identity plus a Ledger source, the user confirms N+1 times — that's expected.
  6. Optional companion: add --sign-auth-with <alias> (repeatable) so users can authorize from any identity regardless of whether it appears in argument position. Closes the broader architectural gap.

Affected files: arg_parsing.rs, invoke.rs, tx.rs, config/mod.rs, signer/mod.rs, signer/ledger.rs. Estimated scope: ~250–400 LOC.

Validation

Best done once an HID emulator is wired into soroban-test. Until then, manual testing against a real device covers the happy path; unit tests can cover the deferred-construction plumbing without touching HID.

Workaround in the meantime

Users who want to sign an auth entry as a Ledger identity should not pass that alias in argument position — the signer pipeline can't currently fulfill it. If the contract argument needs to be a literal address, look up stellar keys public-key <ledger-alias> and pass the G… string directly; an auth requirement for that address will surface as Missing signing key for account G… until the proper fix lands.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    Status

    Done

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions