Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: ledger signer support #605

Merged
merged 1 commit into from
Jun 19, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
641 changes: 578 additions & 63 deletions Cargo.lock

Large diffs are not rendered by default.

3 changes: 3 additions & 0 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -43,10 +43,13 @@ starknet-macros = { version = "0.2.0", path = "./starknet-macros" }

[dev-dependencies]
serde_json = "1.0.74"
starknet-signers = { version = "0.9.0", path = "./starknet-signers", features = ["ledger"] }
tokio = { version = "1.15.0", features = ["full"] }
url = "2.2.2"

[features]
default = []
ledger = ["starknet-signers/ledger"]
no_unknown_fields = [
"starknet-core/no_unknown_fields",
"starknet-providers/no_unknown_fields",
Expand Down
9 changes: 7 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,7 @@ starknet = { git = "https://github.com/xJonathanLEI/starknet-rs" }
- [x] Smart contract deployment
- [x] Signer for using [IAccount](https://github.com/OpenZeppelin/cairo-contracts/blob/main/src/openzeppelin/account/IAccount.cairo) account contracts
- [ ] Strongly-typed smart contract binding code generation from ABI
- [x] Ledger hardware wallet support

## Crates

Expand Down Expand Up @@ -95,9 +96,13 @@ Examples can be found in the [examples folder](./examples):

8. [Deploy an Argent X account to a pre-funded address](./examples/deploy_argent_account.rs)

9. [Parsing a JSON-RPC request on the server side](./examples/parse_jsonrpc_request.rs)
9. [Inspect public key with Ledger](./examples/ledger_public_key.rs)

10. [Inspecting a erased provider-specific error type](./examples/downcast_provider_error.rs)
10. [Deploy an OpenZeppelin account with Ledger](./examples/deploy_account_with_ledger.rs)

11. [Parsing a JSON-RPC request on the server side](./examples/parse_jsonrpc_request.rs)

12. [Inspecting a erased provider-specific error type](./examples/downcast_provider_error.rs)

## License

Expand Down
59 changes: 59 additions & 0 deletions examples/deploy_account_with_ledger.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
use starknet::{
accounts::AccountFactory,
core::chain_id,
macros::felt,
providers::{
jsonrpc::{HttpTransport, JsonRpcClient},
Url,
},
signers::LedgerSigner,
};
use starknet_accounts::OpenZeppelinAccountFactory;

#[tokio::main]
async fn main() {
// OpenZeppelin account contract v0.13.0 compiled with cairo v2.6.3
let class_hash = felt!("0x00e2eb8f5672af4e6a4e8a8f1b44989685e668489b0a25437733756c5a34a1d6");

// Anything you like here as salt
let salt = felt!("12345678");

let provider = JsonRpcClient::new(HttpTransport::new(
Url::parse("https://starknet-sepolia.public.blastapi.io/rpc/v0_7").unwrap(),
));

let signer = LedgerSigner::new(
"m/2645'/1195502025'/1470455285'/0'/0'/0"
.try_into()
.expect("unable to parse path"),
)
.await
.expect("failed to initialize Starknet Ledger app");

let factory = OpenZeppelinAccountFactory::new(class_hash, chain_id::SEPOLIA, signer, provider)
.await
.unwrap();

let deployment = factory.deploy_v1(salt);

let est_fee = deployment.estimate_fee().await.unwrap();

// In an actual application you might want to add a buffer to the amount
println!(
"Fund at least {} wei to {:#064x}",
est_fee.overall_fee,
deployment.address()
);
println!("Press ENTER after account is funded to continue deployment...");
std::io::stdin().read_line(&mut String::new()).unwrap();

let result = deployment.send().await;
match result {
Ok(tx) => {
println!("Transaction hash: {:#064x}", tx.transaction_hash);
}
Err(err) => {
eprintln!("Error: {err}");
}
}
}
2 changes: 1 addition & 1 deletion examples/deploy_argent_account.rs
Original file line number Diff line number Diff line change
Expand Up @@ -46,7 +46,7 @@ async fn main() {
let result = deployment.send().await;
match result {
Ok(tx) => {
dbg!(tx);
println!("Transaction hash: {:#064x}", tx.transaction_hash);
}
Err(err) => {
eprintln!("Error: {err}");
Expand Down
18 changes: 18 additions & 0 deletions examples/ledger_public_key.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
use starknet::signers::{LedgerSigner, Signer};

#[tokio::main]
async fn main() {
let path = "m/2645'/1195502025'/1470455285'/0'/0'/0";

let ledger = LedgerSigner::new(path.try_into().expect("unable to parse path"))
.await
.expect("failed to initialize Starknet Ledger app");

let public_key = ledger
.get_public_key()
.await
.expect("failed to get public key");

println!("Path: {}", path);
println!("Public key: {:#064x}", public_key.scalar());
}
9 changes: 9 additions & 0 deletions starknet-signers/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,10 @@ auto_impl = "1.0.1"
thiserror = "1.0.40"
crypto-bigint = { version = "0.5.1", default-features = false }
rand = { version = "0.8.5", features = ["std_rng"] }
coins-bip32 = { version = "0.11.1", optional = true }

# Using a fork until https://github.com/summa-tx/coins/issues/137 is fixed
coins-ledger = { git = "https://github.com/xJonathanLEI/coins", rev = "0e3be5db0b18b683433de6b666556b99c726e785", default-features = false, optional = true }

[target.'cfg(not(target_arch = "wasm32"))'.dependencies]
eth-keystore = { version = "0.5.0", default-features = false }
Expand All @@ -29,3 +33,8 @@ getrandom = { version = "0.2.9", features = ["js"] }

[target.'cfg(target_arch = "wasm32")'.dev-dependencies]
wasm-bindgen-test = "0.3.34"

[features]
default = []

ledger = ["coins-bip32", "coins-ledger"]
244 changes: 244 additions & 0 deletions starknet-signers/src/ledger.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,244 @@
use async_trait::async_trait;
use coins_ledger::{
common::{APDUData, APDUResponseCodes},
transports::LedgerAsync,
APDUAnswer, APDUCommand, Ledger,
};
use crypto_bigint::{ArrayEncoding, U256};
use starknet_core::{crypto::Signature, types::Felt};

use crate::{Signer, VerifyingKey};

pub use coins_bip32::path::DerivationPath;

/// The Ledger application identifier for app-starknet.
const CLA_STARKNET: u8 = 0x5a;

/// BIP-32 encoding of `2645'`
const EIP_2645_PURPOSE: u32 = 0x80000a55;

const EIP_2645_PATH_LENGTH: usize = 6;

const PUBLIC_KEY_SIZE: usize = 65;
const SIGNATURE_SIZE: usize = 65;

#[derive(Debug)]
pub struct LedgerSigner {
transport: Ledger,
derivation_path: DerivationPath,
}

#[derive(Debug, thiserror::Error)]
pub enum LedgerError {
#[error("derivation path is empty, not prefixed with m/2645', or is not 6-level long")]
InvalidDerivationPath,
#[error(transparent)]
TransportError(coins_ledger::LedgerError),
#[error("unknown response code from Ledger: {0}")]
UnknownResponseCode(u16),
#[error("failed Ledger request: {0}")]
UnsuccessfulRequest(APDUResponseCodes),
#[error("unexpected response length - expected: {expected}; actual: {actual}")]
UnexpectedResponseLength { expected: usize, actual: usize },
}

/// The `GetPubKey` Ledger command.
struct GetPubKeyCommand {
display: bool,
path: DerivationPath,
}

/// Part 1 of the `SignHash` command for setting path.
struct SignHashCommand1 {
path: DerivationPath,
}

/// Part 2 of the `SignHash` command for setting hash.
struct SignHashCommand2 {
hash: [u8; 32],
}

impl LedgerSigner {
/// Initializes the Starknet Ledger app. Attempts to find and connect to a Ledger device. The
/// device must be unlocked and have the Starknet app open.
///
/// The `derivation_path` passed in _must_ follow EIP-2645, i.e. having `2645'` as its "purpose"
/// level as per BIP-44, as the Ledger app does not allow other paths to be used.
///
/// The path _must_ also be 6-level in length. An example path for Starknet would be:
///
/// `m/2645'/1195502025'/1470455285'/0'/0'/0`
///
/// where:
///
/// - `2645'` is the EIP-2645 prefix
/// - `1195502025'`, decimal for `0x4741e9c9`, is the 31 lowest bits for `sha256(starknet)`
/// - `1470455285'`, decimal for `0x57a55df5`, is the 31 lowest bits for `sha256(starkli)`
///
/// Currently, the Ledger app only enforces the length and the first level of the path.
pub async fn new(derivation_path: DerivationPath) -> Result<Self, LedgerError> {
let transport = Ledger::init().await?;

if !matches!(derivation_path.iter().next(), Some(&EIP_2645_PURPOSE))
|| derivation_path.len() != EIP_2645_PATH_LENGTH
{
return Err(LedgerError::InvalidDerivationPath);
}

Ok(Self {
transport,
derivation_path,
})
}
}

#[async_trait]
impl Signer for LedgerSigner {
type GetPublicKeyError = LedgerError;
type SignError = LedgerError;

async fn get_public_key(&self) -> Result<VerifyingKey, Self::GetPublicKeyError> {
let response = self
.transport
.exchange(
&GetPubKeyCommand {
display: false,
path: self.derivation_path.clone(),
}
.into(),
)
.await?;

let data = get_apdu_data(&response)?;
if data.len() != PUBLIC_KEY_SIZE {
return Err(LedgerError::UnexpectedResponseLength {
expected: PUBLIC_KEY_SIZE,
actual: data.len(),
});
}

// Unwrapping here is safe as length is fixed
let pubkey_x = Felt::from_bytes_be(&data[1..33].try_into().unwrap());

Ok(VerifyingKey::from_scalar(pubkey_x))
}

async fn sign_hash(&self, hash: &Felt) -> Result<Signature, Self::SignError> {
get_apdu_data(
&self
.transport
.exchange(
&SignHashCommand1 {
path: self.derivation_path.clone(),
}
.into(),
)
.await?,
)?;

let response = self
.transport
.exchange(
&SignHashCommand2 {
hash: hash.to_bytes_be(),
}
.into(),
)
.await?;

let data = get_apdu_data(&response)?;

if data.len() != SIGNATURE_SIZE + 1 || data[0] != SIGNATURE_SIZE as u8 {
return Err(LedgerError::UnexpectedResponseLength {
expected: SIGNATURE_SIZE,
actual: data.len(),
});
}

// Unwrapping here is safe as length is fixed
let r = Felt::from_bytes_be(&data[1..33].try_into().unwrap());
let s = Felt::from_bytes_be(&data[33..65].try_into().unwrap());

let signature = Signature { r, s };

Ok(signature)
}
}

impl From<coins_ledger::LedgerError> for LedgerError {
fn from(value: coins_ledger::LedgerError) -> Self {
Self::TransportError(value)
}
}

impl From<GetPubKeyCommand> for APDUCommand {
fn from(value: GetPubKeyCommand) -> Self {
let path = value
.path
.iter()
.flat_map(|level| level.to_be_bytes())
.collect::<Vec<_>>();

Self {
cla: CLA_STARKNET,
ins: 0x01,
p1: if value.display { 0x01 } else { 0x00 },
p2: 0x00,
data: APDUData::new(&path),
response_len: None,
}
}
}

impl From<SignHashCommand1> for APDUCommand {
fn from(value: SignHashCommand1) -> Self {
let path = value
.path
.iter()
.flat_map(|level| level.to_be_bytes())
.collect::<Vec<_>>();

Self {
cla: CLA_STARKNET,
ins: 0x02,
p1: 0x00,
p2: 0x00,
data: APDUData::new(&path),
response_len: None,
}
}
}

impl From<SignHashCommand2> for APDUCommand {
fn from(value: SignHashCommand2) -> Self {
// For some reasons, the Ledger app expects the input to be left shifted by 4 bits...
let shifted_bytes: [u8; 32] = (U256::from_be_slice(&value.hash) << 4)
.to_be_byte_array()
.into();

Self {
cla: CLA_STARKNET,
ins: 0x02,
p1: 0x01,
p2: 0x00,
data: APDUData::new(&shifted_bytes),
response_len: None,
}
}
}

fn get_apdu_data(answer: &APDUAnswer) -> Result<&[u8], LedgerError> {
let ret_code = answer.retcode();

match TryInto::<APDUResponseCodes>::try_into(ret_code) {
Ok(status) => {
if status.is_success() {
// Unwrapping here as we've already checked success
Ok(answer.data().unwrap())
} else {
Err(LedgerError::UnsuccessfulRequest(status))
}
}
Err(_) => Err(LedgerError::UnknownResponseCode(ret_code)),
}
}
Loading
Loading