Skip to content

Conversation

@dannywillems
Copy link
Member

Summary

  • Add mina-bip32 crate for BIP32 hierarchical deterministic key derivation as used by Ledger hardware wallets
  • Add ledger-test-vectors tool for generating JSON test vectors for Mina Ledger hardware wallet signing validation

mina-bip32 crate

  • Master key derivation using HMAC-SHA512 with "Bitcoin seed"
  • Child key derivation with secp256k1 curve order (matching Ledger's os_perso_derive_node_bip32(CX_CURVE_256K1))
  • Mina BIP44 path derivation: m/44'/12586'/account'/0/0
  • Bit masking for valid Pallas scalar field elements
  • Comprehensive documentation for external implementers

ledger-test-vectors tool

  • BIP32-derived keys using mina-bip32 crate
  • Payment and delegation transaction types
  • Coverage for mainnet/testnet, multiple accounts
  • CLI: cargo run --package ledger-test-vectors

Test plan

  • cargo test --package mina-bip32 - all 16 tests pass
  • make lint - no warnings or errors
  • cargo run --package ledger-test-vectors - generates valid JSON test vectors
  • Validate test vectors against actual Ledger hardware wallet

Notes

@dannywillems dannywillems marked this pull request as draft December 16, 2025 18:39
@querolita querolita self-assigned this Dec 17, 2025
Copy link
Member

@querolita querolita left a comment

Choose a reason for hiding this comment

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

Comments for commit dcf9885

///
/// BIP32 specifies using secp256k1 for key derivation even when the
/// target curve is different (like Pallas for Mina).
const SECP256K1_ORDER: [u8; 32] = [
Copy link
Member

Choose a reason for hiding this comment

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

checked


for i in (0..32).rev() {
let sum = a[i] as u16 + b[i] as u16 + carry;
result[i + 1] = sum as u8;
Copy link
Member

Choose a reason for hiding this comment

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

Just a note. So here, if sum is >255, what will the casting do? Will it perform the mod operation with 256? Meaning, will it discard the 9th bit?

for i in (0..32).rev() {
let sum = a[i] as u16 + b[i] as u16 + carry;
result[i + 1] = sum as u8;
carry = sum >> 8;
Copy link
Member

Choose a reason for hiding this comment

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

Here it's clear that it shifts 8 positions to get the 9th bit (potential carry bit)

break;
}
core::cmp::Ordering::Less => {
break;
Copy link
Member

Choose a reason for hiding this comment

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

efficient, good

for i in (0..32).rev() {
let diff = result[i + 1] as i16 - SECP256K1_ORDER[i] as i16 - borrow;
if diff < 0 {
result[i + 1] = (diff + 256) as u8;
Copy link
Member

Choose a reason for hiding this comment

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

safe type, also the 256 comes from the borrow bit corresponding to the next significant byte which is 2^8 in this step. Just noting for correctness check.

.expect("HMAC can take key of any size");

// Construct the HMAC data based on derivation type
if index >= HARDENED_OFFSET {
Copy link
Member

Choose a reason for hiding this comment

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

I find this confusing, the fact that both branches do the same.

mac.update(&self.private_key);
}
// Append the index in big-endian format
mac.update(&index.to_be_bytes());
Copy link
Member

Choose a reason for hiding this comment

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

everything big endian so far


// Compute child_private_key = (parent_private_key + child_key_material) mod n
// where n is the secp256k1 curve order (standard BIP32)
let final_key = add_mod_secp256k1(&self.private_key, &child_key_material);
Copy link
Member

Choose a reason for hiding this comment

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

why call it final_key in the variable but child_private_key in the comment?

/// # Algorithm
///
/// 1. Copy the 32-byte private key
/// 2. Mask the top 2 bits of the first byte to ensure the value
Copy link
Member

Choose a reason for hiding this comment

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

Are we ok with collisions?

key_bytes.reverse();

// Convert to scalar field element
ScalarField::from_le_bytes_mod_order(&key_bytes)
Copy link
Member

Choose a reason for hiding this comment

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

🆗

Copy link
Member

@querolita querolita left a comment

Choose a reason for hiding this comment

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

Comments for commit c7b7918

fee: String,
/// Account nonce
nonce: u32,
/// Valid until slot (null for no expiry)
Copy link
Member

Choose a reason for hiding this comment

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

null or None?

impl Hashable for Transaction {
type D = NetworkId;

fn to_roinput(&self) -> ROInput {
Copy link
Member

Choose a reason for hiding this comment

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

Why this order? It's not the same as the order in the struct fields.

/// on the Pallas curve.
#[derive(Clone, Debug, Serialize, Deserialize)]
pub struct PublicKeyJson {
/// X-coordinate of the public key point (hex-encoded, little-endian)
Copy link
Member

Choose a reason for hiding this comment

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

here starts little-endian world

///
/// After BIP32 derivation and bit masking, this is the scalar
/// value used for signing.
pub private_key: String,
Copy link
Member

Choose a reason for hiding this comment

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

Do you mean private_key is the sk in the formula above s = k + e * sk?

Add a new crate implementing BIP32 hierarchical deterministic key
derivation for Mina, as used by Ledger hardware wallets.

Key features:
- Master key derivation using HMAC-SHA512 with "Bitcoin seed"
- Child key derivation with secp256k1 curve order (matching Ledger)
- Mina BIP44 path: m/44'/12586'/account'/0/0
- Bit masking for valid Pallas scalar field elements
- Comprehensive documentation for external implementers

Note: Test vectors should be validated against actual Ledger devices.
The Ledger uses proprietary os_perso_derive_node_bip32(CX_CURVE_256K1).
Add a tool to generate JSON test vectors for validating Mina Ledger
hardware wallet signing implementations.

Features:
- BIP32-derived keys using mina-bip32 crate
- Payment and delegation transaction types
- Coverage for mainnet/testnet, multiple accounts
- CLI: cargo run --package ledger-test-vectors

Test vectors include:
- Seed, private key, public key coordinates, address
- Transaction details (type, to, amount, fee, nonce, memo)
- Signature (rx, s)

Note: Transaction types will be updated when
o1-labs/mina-rust#1665 is implemented.
…ion)

Add test vectors from ledger-mina repository for validation:
- Test mnemonic and expected addresses for accounts 0, 1, 2, 3, 49370, 12586
- Tests are marked as ignored because our derivation produces different
  addresses than expected - requires further investigation

Also removes deny(missing_docs) attribute and adds pbkdf2 dev-dependency.
@dannywillems dannywillems force-pushed the dw/mina-signer-c-external branch from 8ad9f4f to 4f8422f Compare December 18, 2025 16:57
@dannywillems dannywillems marked this pull request as ready for review December 18, 2025 17:00
Copy link
Member

@querolita querolita left a comment

Choose a reason for hiding this comment

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

Approving for now, but let's keep in mind the mismatch between Ledger's implementation and this one.

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.

3 participants