From 6d5239f36598beedc8056df6c90468d84a492f19 Mon Sep 17 00:00:00 2001 From: Marko Bencun Date: Mon, 19 Feb 2024 17:26:38 +0100 Subject: [PATCH] api: add a custom BIP85 derivation for Lightning hot wallets For the generation of a mnemonic for Breez-SDK Lightning hot wallets in the BitBoxApp. Using the regular BIP85-BIP39 path like `m/83696968'/39'/0'/12'/0'` for this is problematic as a user might use the same mnemonics for something else, e.g. another cold storage wallet. In such a case, the derived mnemonic of the user would become hot without the user realizing it. For the purpose of generating a mnemonic for a lightning hot wallet using BIP85, we should instead use a different application number dedicated to this. We are going with number `19534'`, which is unlikely to interfere with other uses. It's hex representation `0x4c4e` spells `LN` in ASCII. The BIP85 derivation would then be `m/83696968'/{app_no}/{language}'/{words}'/` as in BIP85-BIP39, but use `19534'` for the `app_no` instead of `39'`, so: m/83696968'/19534'/0'/12'/0' We restrict to only 12 words for LN hot wallet mnemonics for simplicity and easier recovery for users. The index represents the account number - for now we only allow `0` (the first account), and can extend to multiple if there is ever a need. --- messages/keystore.proto | 6 +++ py/bitbox02/bitbox02/bitbox02/bitbox02.py | 23 +++++++++- .../communication/generated/keystore_pb2.py | 12 +++--- .../communication/generated/keystore_pb2.pyi | 29 ++++++++++--- py/send_message.py | 13 +++++- src/CMakeLists.txt | 1 + src/keystore.c | 33 ++++++++++++++ src/keystore.h | 12 ++++++ src/rust/bitbox02-rust/Cargo.toml | 2 +- src/rust/bitbox02-rust/src/hww/api.rs | 4 -- src/rust/bitbox02-rust/src/hww/api/bip85.rs | 43 ++++++++++++++++--- .../bitbox02-rust/src/shiftcrypto.bitbox02.rs | 14 +++++- src/rust/bitbox02/src/keystore.rs | 39 +++++++++++++++++ 13 files changed, 205 insertions(+), 26 deletions(-) diff --git a/messages/keystore.proto b/messages/keystore.proto index 4227a6c045..300180a818 100644 --- a/messages/keystore.proto +++ b/messages/keystore.proto @@ -15,13 +15,19 @@ message ElectrumEncryptionKeyResponse { } message BIP85Request { + message AppLn { + uint32 account_number = 1; + } + oneof app { google.protobuf.Empty bip39 = 1; + AppLn ln = 2; } } message BIP85Response { oneof app { google.protobuf.Empty bip39 = 1; + string ln = 2; } } diff --git a/py/bitbox02/bitbox02/bitbox02/bitbox02.py b/py/bitbox02/bitbox02/bitbox02/bitbox02.py index d9c19e14b5..03c5dc965f 100644 --- a/py/bitbox02/bitbox02/bitbox02/bitbox02.py +++ b/py/bitbox02/bitbox02/bitbox02/bitbox02.py @@ -690,7 +690,28 @@ def bip85_39(self) -> None: bip39=google.protobuf.empty_pb2.Empty(), ) ) - self._msg_query(request) + response = self._msg_query(request, expected_response="bip85").bip85 + assert response.WhichOneof("app") == "bip39" + + def bip85_ln(self) -> str: + """ + Generates and returns a mnemonic for a hot Lightning wallet from the device using BIP-85. + """ + self._require_atleast(semver.VersionInfo(9, 17, 0)) + + # Only account_number=0 is allowed for now. + account_number = 0 + + # pylint: disable=no-member + request = hww.Request() + request.bip85.CopyFrom( + keystore.BIP85Request( + ln=keystore.BIP85Request.AppLn(account_number=account_number), + ) + ) + response = self._msg_query(request, expected_response="bip85").bip85 + assert response.WhichOneof("app") == "ln" + return response.ln def enable_mnemonic_passphrase(self) -> None: """ diff --git a/py/bitbox02/bitbox02/communication/generated/keystore_pb2.py b/py/bitbox02/bitbox02/communication/generated/keystore_pb2.py index 78178e5d24..04f00d7e82 100644 --- a/py/bitbox02/bitbox02/communication/generated/keystore_pb2.py +++ b/py/bitbox02/bitbox02/communication/generated/keystore_pb2.py @@ -14,7 +14,7 @@ from google.protobuf import empty_pb2 as google_dot_protobuf_dot_empty__pb2 -DESCRIPTOR = _descriptor_pool.Default().AddSerializedFile(b'\n\x0ekeystore.proto\x12\x14shiftcrypto.bitbox02\x1a\x1bgoogle/protobuf/empty.proto\"/\n\x1c\x45lectrumEncryptionKeyRequest\x12\x0f\n\x07keypath\x18\x01 \x03(\r\",\n\x1d\x45lectrumEncryptionKeyResponse\x12\x0b\n\x03key\x18\x01 \x01(\t\">\n\x0c\x42IP85Request\x12\'\n\x05\x62ip39\x18\x01 \x01(\x0b\x32\x16.google.protobuf.EmptyH\x00\x42\x05\n\x03\x61pp\"?\n\rBIP85Response\x12\'\n\x05\x62ip39\x18\x01 \x01(\x0b\x32\x16.google.protobuf.EmptyH\x00\x42\x05\n\x03\x61ppb\x06proto3') +DESCRIPTOR = _descriptor_pool.Default().AddSerializedFile(b'\n\x0ekeystore.proto\x12\x14shiftcrypto.bitbox02\x1a\x1bgoogle/protobuf/empty.proto\"/\n\x1c\x45lectrumEncryptionKeyRequest\x12\x0f\n\x07keypath\x18\x01 \x03(\r\",\n\x1d\x45lectrumEncryptionKeyResponse\x12\x0b\n\x03key\x18\x01 \x01(\t\"\x97\x01\n\x0c\x42IP85Request\x12\'\n\x05\x62ip39\x18\x01 \x01(\x0b\x32\x16.google.protobuf.EmptyH\x00\x12\x36\n\x02ln\x18\x02 \x01(\x0b\x32(.shiftcrypto.bitbox02.BIP85Request.AppLnH\x00\x1a\x1f\n\x05\x41ppLn\x12\x16\n\x0e\x61\x63\x63ount_number\x18\x01 \x01(\rB\x05\n\x03\x61pp\"M\n\rBIP85Response\x12\'\n\x05\x62ip39\x18\x01 \x01(\x0b\x32\x16.google.protobuf.EmptyH\x00\x12\x0c\n\x02ln\x18\x02 \x01(\tH\x00\x42\x05\n\x03\x61ppb\x06proto3') _builder.BuildMessageAndEnumDescriptors(DESCRIPTOR, globals()) _builder.BuildTopDescriptorsAndMessages(DESCRIPTOR, 'keystore_pb2', globals()) @@ -25,8 +25,10 @@ _ELECTRUMENCRYPTIONKEYREQUEST._serialized_end=116 _ELECTRUMENCRYPTIONKEYRESPONSE._serialized_start=118 _ELECTRUMENCRYPTIONKEYRESPONSE._serialized_end=162 - _BIP85REQUEST._serialized_start=164 - _BIP85REQUEST._serialized_end=226 - _BIP85RESPONSE._serialized_start=228 - _BIP85RESPONSE._serialized_end=291 + _BIP85REQUEST._serialized_start=165 + _BIP85REQUEST._serialized_end=316 + _BIP85REQUEST_APPLN._serialized_start=278 + _BIP85REQUEST_APPLN._serialized_end=309 + _BIP85RESPONSE._serialized_start=318 + _BIP85RESPONSE._serialized_end=395 # @@protoc_insertion_point(module_scope) diff --git a/py/bitbox02/bitbox02/communication/generated/keystore_pb2.pyi b/py/bitbox02/bitbox02/communication/generated/keystore_pb2.pyi index 8bddce4595..87137ff25c 100644 --- a/py/bitbox02/bitbox02/communication/generated/keystore_pb2.pyi +++ b/py/bitbox02/bitbox02/communication/generated/keystore_pb2.pyi @@ -37,28 +37,45 @@ global___ElectrumEncryptionKeyResponse = ElectrumEncryptionKeyResponse class BIP85Request(google.protobuf.message.Message): DESCRIPTOR: google.protobuf.descriptor.Descriptor + class AppLn(google.protobuf.message.Message): + DESCRIPTOR: google.protobuf.descriptor.Descriptor + ACCOUNT_NUMBER_FIELD_NUMBER: builtins.int + account_number: builtins.int + def __init__(self, + *, + account_number: builtins.int = ..., + ) -> None: ... + def ClearField(self, field_name: typing_extensions.Literal["account_number",b"account_number"]) -> None: ... + BIP39_FIELD_NUMBER: builtins.int + LN_FIELD_NUMBER: builtins.int @property def bip39(self) -> google.protobuf.empty_pb2.Empty: ... + @property + def ln(self) -> global___BIP85Request.AppLn: ... def __init__(self, *, bip39: typing.Optional[google.protobuf.empty_pb2.Empty] = ..., + ln: typing.Optional[global___BIP85Request.AppLn] = ..., ) -> None: ... - def HasField(self, field_name: typing_extensions.Literal["app",b"app","bip39",b"bip39"]) -> builtins.bool: ... - def ClearField(self, field_name: typing_extensions.Literal["app",b"app","bip39",b"bip39"]) -> None: ... - def WhichOneof(self, oneof_group: typing_extensions.Literal["app",b"app"]) -> typing.Optional[typing_extensions.Literal["bip39"]]: ... + def HasField(self, field_name: typing_extensions.Literal["app",b"app","bip39",b"bip39","ln",b"ln"]) -> builtins.bool: ... + def ClearField(self, field_name: typing_extensions.Literal["app",b"app","bip39",b"bip39","ln",b"ln"]) -> None: ... + def WhichOneof(self, oneof_group: typing_extensions.Literal["app",b"app"]) -> typing.Optional[typing_extensions.Literal["bip39","ln"]]: ... global___BIP85Request = BIP85Request class BIP85Response(google.protobuf.message.Message): DESCRIPTOR: google.protobuf.descriptor.Descriptor BIP39_FIELD_NUMBER: builtins.int + LN_FIELD_NUMBER: builtins.int @property def bip39(self) -> google.protobuf.empty_pb2.Empty: ... + ln: typing.Text def __init__(self, *, bip39: typing.Optional[google.protobuf.empty_pb2.Empty] = ..., + ln: typing.Text = ..., ) -> None: ... - def HasField(self, field_name: typing_extensions.Literal["app",b"app","bip39",b"bip39"]) -> builtins.bool: ... - def ClearField(self, field_name: typing_extensions.Literal["app",b"app","bip39",b"bip39"]) -> None: ... - def WhichOneof(self, oneof_group: typing_extensions.Literal["app",b"app"]) -> typing.Optional[typing_extensions.Literal["bip39"]]: ... + def HasField(self, field_name: typing_extensions.Literal["app",b"app","bip39",b"bip39","ln",b"ln"]) -> builtins.bool: ... + def ClearField(self, field_name: typing_extensions.Literal["app",b"app","bip39",b"bip39","ln",b"ln"]) -> None: ... + def WhichOneof(self, oneof_group: typing_extensions.Literal["app",b"app"]) -> typing.Optional[typing_extensions.Literal["bip39","ln"]]: ... global___BIP85Response = BIP85Response diff --git a/py/send_message.py b/py/send_message.py index 330d2ac860..31e89c40f4 100755 --- a/py/send_message.py +++ b/py/send_message.py @@ -341,7 +341,17 @@ def _get_electrum_encryption_key(self) -> None: ) def _bip85_39(self) -> None: - self._device.bip85_39() + try: + self._device.bip85_39() + except UserAbortException: + print("Aborted by user") + + def _bip85_ln(self) -> None: + try: + mnemonic = self._device.bip85_ln() + print("Lightning hot wallet mnemonic:", mnemonic) + except UserAbortException: + print("Aborted by user") def _btc_address(self) -> None: def address(display: bool) -> str: @@ -1392,6 +1402,7 @@ def _menu_init(self) -> None: ("Cardano", self._cardano), ("Show Electrum wallet encryption key", self._get_electrum_encryption_key), ("BIP85 - BIP39", self._bip85_39), + ("BIP85 - LN", self._bip85_ln), ("Reset Device", self._reset_device), ) choice = ask_user(choices) diff --git a/src/CMakeLists.txt b/src/CMakeLists.txt index e0619e5d03..765e16002b 100644 --- a/src/CMakeLists.txt +++ b/src/CMakeLists.txt @@ -330,6 +330,7 @@ add_custom_target(rust-bindgen --allowlist-function keystore_get_bip39_word --allowlist-function keystore_get_ed25519_seed --allowlist-function keystore_bip85_bip39 + --allowlist-function keystore_bip85_ln --allowlist-function keystore_secp256k1_compressed_to_uncompressed --allowlist-function keystore_secp256k1_nonce_commit --allowlist-function keystore_secp256k1_sign diff --git a/src/keystore.c b/src/keystore.c index 321be2de49..eb14e696e3 100644 --- a/src/keystore.c +++ b/src/keystore.c @@ -864,6 +864,39 @@ bool keystore_bip85_bip39( return snprintf_result >= 0 && snprintf_result < (int)mnemonic_out_size; } +bool keystore_bip85_ln(uint32_t index, char* mnemonic_out, size_t mnemonic_out_size) +{ + uint32_t words = 12; + size_t seed_size = 16; + + if (index >= BIP32_INITIAL_HARDENED_CHILD) { + return false; + } + + const uint32_t keypath[] = { + 83696968 + BIP32_INITIAL_HARDENED_CHILD, + 19534 + BIP32_INITIAL_HARDENED_CHILD, + 0 + BIP32_INITIAL_HARDENED_CHILD, + words + BIP32_INITIAL_HARDENED_CHILD, + index + BIP32_INITIAL_HARDENED_CHILD, + }; + + uint8_t entropy[64] = {0}; + UTIL_CLEANUP_64(entropy); + if (!_bip85_entropy(keypath, sizeof(keypath) / sizeof(uint32_t), entropy)) { + return false; + } + + char* mnemonic = NULL; + if (bip39_mnemonic_from_bytes(NULL, entropy, seed_size, &mnemonic) != WALLY_OK) { + return false; + } + int snprintf_result = snprintf(mnemonic_out, mnemonic_out_size, "%s", mnemonic); + util_cleanup_str(&mnemonic); + free(mnemonic); + return snprintf_result >= 0 && snprintf_result < (int)mnemonic_out_size; +} + USE_RESULT bool keystore_encode_xpub_at_keypath( const uint32_t* keypath, size_t keypath_len, diff --git a/src/keystore.h b/src/keystore.h index 2fdce5c975..021fe0b26f 100644 --- a/src/keystore.h +++ b/src/keystore.h @@ -253,6 +253,18 @@ USE_RESULT bool keystore_bip85_bip39( char* mnemonic_out, size_t mnemonic_out_size); +/** + * Computes a 12 word BIP39 mnemonic specifically for Lightning hot wallets according to BIP-85. + * It is the same as BIP-85 with app number 39', but instead using app number 19534' (= 0x4c4e = + * 'LN'). https://github.com/bitcoin/bips/blob/master/bip-0085.mediawiki#bip39 Restricted to 12 word + * mnemonics. + * @param[in] index must be smaller than `BIP32_INITIAL_HARDENED_CHILD`. + * @param[out] mnemonic_out resulting mnemonic + * @param[in] mnemonic_out_size size of mnemonic_out. Should be at least 216 bytes (longest possible + * 24 word phrase plus null terminator). + */ +USE_RESULT bool keystore_bip85_ln(uint32_t index, char* mnemonic_out, size_t mnemonic_out_size); + /** * Encode an xpub at the given `keypath` as 78 bytes according to BIP32. The version bytes are * the ones corresponding to `xpub`, i.e. 0x0488B21E. diff --git a/src/rust/bitbox02-rust/Cargo.toml b/src/rust/bitbox02-rust/Cargo.toml index 3345d23981..087b63cd92 100644 --- a/src/rust/bitbox02-rust/Cargo.toml +++ b/src/rust/bitbox02-rust/Cargo.toml @@ -107,7 +107,7 @@ app-cardano = [ "ed25519" ] -app-bip85 = [] +app-bip85-bip39 = [] testing = [ # enable these deps diff --git a/src/rust/bitbox02-rust/src/hww/api.rs b/src/rust/bitbox02-rust/src/hww/api.rs index d3170a190f..ba528f22a8 100644 --- a/src/rust/bitbox02-rust/src/hww/api.rs +++ b/src/rust/bitbox02-rust/src/hww/api.rs @@ -26,7 +26,6 @@ pub mod bitcoin; mod cardano; mod backup; -#[cfg(feature = "app-bip85")] mod bip85; mod device_info; mod electrum; @@ -184,9 +183,6 @@ async fn process_api(request: &Request) -> Result { .map(|r| Response::Cardano(pb::CardanoResponse { response: Some(r) })), #[cfg(not(feature = "app-cardano"))] Request::Cardano(_) => Err(Error::Disabled), - #[cfg(not(feature = "app-bip85"))] - Request::Bip85(_) => Err(Error::Disabled), - #[cfg(feature = "app-bip85")] Request::Bip85(ref request) => bip85::process(request).await, _ => Err(Error::InvalidInput), } diff --git a/src/rust/bitbox02-rust/src/hww/api/bip85.rs b/src/rust/bitbox02-rust/src/hww/api/bip85.rs index 9ae7f69d88..883b809e4e 100644 --- a/src/rust/bitbox02-rust/src/hww/api/bip85.rs +++ b/src/rust/bitbox02-rust/src/hww/api/bip85.rs @@ -17,26 +17,35 @@ use super::Error; use pb::response::Response; -use bitbox02::keystore; +use crate::workflow::confirm; -use crate::workflow::trinary_choice::{choose, TrinaryChoice}; -use crate::workflow::{confirm, menu, mnemonic, status, trinary_input_string}; +use bitbox02::keystore; -use alloc::vec::Vec; +use alloc::string::String; /// Processes a BIP-85 API call. pub async fn process(request: &pb::Bip85Request) -> Result { - match request.app { + match &request.app { None => Err(Error::InvalidInput), + #[cfg(not(feature = "app-bip85-bip39"))] + Some(pb::bip85_request::App::Bip39(())) => Err(Error::Disabled), + #[cfg(feature = "app-bip85-bip39")] Some(pb::bip85_request::App::Bip39(())) => Ok(Response::Bip85(pb::Bip85Response { app: Some(pb::bip85_response::App::Bip39(process_bip39().await?)), })), + Some(pb::bip85_request::App::Ln(request)) => Ok(Response::Bip85(pb::Bip85Response { + app: Some(pb::bip85_response::App::Ln(process_ln(request).await?)), + })), } } /// Derives and displays a BIP-39 seed according to BIP-85: /// https://github.com/bitcoin/bips/blob/master/bip-0085.mediawiki#bip39. +#[cfg(feature = "app-bip85-bip39")] async fn process_bip39() -> Result<(), Error> { + use crate::workflow::trinary_choice::{choose, TrinaryChoice}; + use crate::workflow::{menu, mnemonic, status, trinary_input_string}; + confirm::confirm(&confirm::Params { title: "BIP-85", body: "Derive BIP-39\nmnemonic?", @@ -109,10 +118,32 @@ async fn process_bip39() -> Result<(), Error> { .await?; let mnemonic = keystore::bip85_bip39(num_words, index)?; - let words: Vec<&str> = mnemonic.split(' ').collect(); + let words: alloc::vec::Vec<&str> = mnemonic.split(' ').collect(); mnemonic::show_and_confirm_mnemonic(&words).await?; status::status("Finished", true).await; Ok(()) } + +/// Derives and displays a LN seed according to BIP-85. +/// It is the same as BIP-85 with app number 39', but instead using app number 19534' (= 0x4c4e = 'LN'), +/// and restricted to 12 word mnemonics. +/// https://github.com/bitcoin/bips/blob/master/bip-0085.mediawiki#bip39 +async fn process_ln( + &pb::bip85_request::AppLn { account_number }: &pb::bip85_request::AppLn, +) -> Result { + // We allow only one LN account until we see a reason to have more. + if account_number != 0 { + return Err(Error::InvalidInput); + } + confirm::confirm(&confirm::Params { + title: "", + body: "Create\nLightning wallet\non host device?", + longtouch: true, + ..Default::default() + }) + .await?; + + keystore::bip85_ln(account_number).map_err(|_| Error::Generic) +} diff --git a/src/rust/bitbox02-rust/src/shiftcrypto.bitbox02.rs b/src/rust/bitbox02-rust/src/shiftcrypto.bitbox02.rs index afb9e3081f..b87f1526d2 100644 --- a/src/rust/bitbox02-rust/src/shiftcrypto.bitbox02.rs +++ b/src/rust/bitbox02-rust/src/shiftcrypto.bitbox02.rs @@ -1513,22 +1513,30 @@ pub struct ElectrumEncryptionKeyResponse { #[allow(clippy::derive_partial_eq_without_eq)] #[derive(Clone, PartialEq, ::prost::Message)] pub struct Bip85Request { - #[prost(oneof = "bip85_request::App", tags = "1")] + #[prost(oneof = "bip85_request::App", tags = "1, 2")] pub app: ::core::option::Option, } /// Nested message and enum types in `BIP85Request`. pub mod bip85_request { + #[allow(clippy::derive_partial_eq_without_eq)] + #[derive(Clone, PartialEq, ::prost::Message)] + pub struct AppLn { + #[prost(uint32, tag = "1")] + pub account_number: u32, + } #[allow(clippy::derive_partial_eq_without_eq)] #[derive(Clone, PartialEq, ::prost::Oneof)] pub enum App { #[prost(message, tag = "1")] Bip39(()), + #[prost(message, tag = "2")] + Ln(AppLn), } } #[allow(clippy::derive_partial_eq_without_eq)] #[derive(Clone, PartialEq, ::prost::Message)] pub struct Bip85Response { - #[prost(oneof = "bip85_response::App", tags = "1")] + #[prost(oneof = "bip85_response::App", tags = "1, 2")] pub app: ::core::option::Option, } /// Nested message and enum types in `BIP85Response`. @@ -1538,6 +1546,8 @@ pub mod bip85_response { pub enum App { #[prost(message, tag = "1")] Bip39(()), + #[prost(string, tag = "2")] + Ln(::prost::alloc::string::String), } } #[allow(clippy::derive_partial_eq_without_eq)] diff --git a/src/rust/bitbox02/src/keystore.rs b/src/rust/bitbox02/src/keystore.rs index 3963a241b7..2aebeb20cb 100644 --- a/src/rust/bitbox02/src/keystore.rs +++ b/src/rust/bitbox02/src/keystore.rs @@ -321,6 +321,18 @@ pub fn bip85_bip39(words: u32, index: u32) -> Result, } } +pub fn bip85_ln(index: u32) -> Result { + let mut mnemonic = [0u8; 256]; + match unsafe { + bitbox02_sys::keystore_bip85_ln(index, mnemonic.as_mut_ptr(), mnemonic.len() as _) + } { + false => Err(()), + true => Ok(crate::util::str_from_null_terminated(&mnemonic[..]) + .unwrap() + .into()), + } +} + pub fn secp256k1_schnorr_bip86_sign(keypath: &[u32], msg: &[u8; 32]) -> Result<[u8; 64], ()> { let mut signature = [0u8; 64]; match unsafe { @@ -499,4 +511,31 @@ mod tests { // Index too high. assert!(bip85_bip39(12, util::bip32::HARDENED).is_err()); } + + #[test] + fn test_bip85_ln() { + lock(); + assert!(bip85_ln(0).is_err()); + + mock_unlocked_using_mnemonic( + "virtual weapon code laptop defy cricket vicious target wave leopard garden give", + "", + ); + + assert_eq!( + bip85_ln(0).unwrap().as_str(), + "demise what timber best review image pluck industry bread agree autumn raise", + ); + assert_eq!( + bip85_ln(1).unwrap().as_str(), + "treat solar output various ramp process define skin bless physical trial language", + ); + assert_eq!( + bip85_ln(util::bip32::HARDENED - 1).unwrap().as_str(), + "busy sweet kind engage innocent retreat chronic silly crucial emerge case eternal", + ); + + // Index too high. + assert!(bip85_ln(util::bip32::HARDENED).is_err()); + } }