From caaa66026095fd1d67904d3a30a972e13422c09c Mon Sep 17 00:00:00 2001 From: highlander Date: Thu, 6 Aug 2026 12:48:09 -0300 Subject: [PATCH 1/3] feat(chains): Tendermint-family signing policy and custom denoms MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit THORChain, MayaChain, Osmosis, Cosmos, Binance, Ripple and EOS: - A per-chain router decides how each message family is displayed and what may be signed, replacing the single shared path where one chain's assumptions leaked into another's confirmation screens. - RUJI/TCY and other custom denominations sign correctly instead of being rendered as an unknown asset with a raw base amount. - THORChain memos are shown in full — a truncated memo hides the swap target. - Osmosis denom paging handles pools whose denom lists exceed one screen. - XRP THORChain memos use the XRPL binary Memos format so the destination actually parses on-chain. - unittests cover the router decisions, denom formatting and memo rendering for each family, including the new binance.cpp and osmosis.cpp suites. --- include/keepkey/firmware/binance.h | 4 + include/keepkey/firmware/eos.h | 3 + include/keepkey/firmware/mayachain.h | 9 + include/keepkey/firmware/osmosis.h | 25 +- include/keepkey/firmware/signtx_tendermint.h | 15 +- include/keepkey/firmware/tendermint.h | 6 + include/keepkey/firmware/thorchain.h | 20 +- lib/firmware/binance.c | 76 ++- lib/firmware/eos.c | 26 +- lib/firmware/fsm_msg_binance.h | 45 +- lib/firmware/fsm_msg_cosmos.h | 31 +- lib/firmware/fsm_msg_mayachain.h | 99 ++- lib/firmware/fsm_msg_osmosis.h | 261 ++++---- lib/firmware/fsm_msg_ripple.h | 15 + lib/firmware/fsm_msg_tendermint.h | 75 ++- lib/firmware/fsm_msg_thorchain.h | 95 ++- lib/firmware/mayachain.c | 213 ++++-- lib/firmware/osmosis.c | 100 ++- lib/firmware/ripple.c | 19 + lib/firmware/signtx_tendermint.c | 94 ++- lib/firmware/tendermint.c | 42 ++ lib/firmware/thorchain.c | 292 +++++++-- unittests/firmware/CMakeLists.txt | 4 + unittests/firmware/binance.cpp | 83 +++ unittests/firmware/cosmos.cpp | 46 +- unittests/firmware/eos.cpp | 17 + unittests/firmware/mayachain.cpp | 176 ++++- unittests/firmware/osmosis.cpp | 158 +++++ unittests/firmware/thorchain.cpp | 644 +++++++++++++++++-- 29 files changed, 2239 insertions(+), 454 deletions(-) create mode 100644 unittests/firmware/binance.cpp create mode 100644 unittests/firmware/osmosis.cpp diff --git a/include/keepkey/firmware/binance.h b/include/keepkey/firmware/binance.h index 9f42dcc60..035b37094 100644 --- a/include/keepkey/firmware/binance.h +++ b/include/keepkey/firmware/binance.h @@ -12,6 +12,10 @@ typedef struct _BinanceTransferMsg BinanceTransferMsg; typedef struct _BinanceTransferMsg_BinanceInputOutput BinanceInputOutput; typedef struct _BinanceTransferMsg_BinanceCoin BinanceCoin; +#define BINANCE_MAX_DENOM_LEN 31 + +bool binance_isValidDenom(const char* denom); +bool binance_validateTransfer(const BinanceTransferMsg* transfer); bool binance_signTxInit(const HDNode* _node, const BinanceSignTx* _msg); bool binance_serializeCoin(const BinanceCoin* coin); bool binance_serializeInputOutput(const BinanceInputOutput* io); diff --git a/include/keepkey/firmware/eos.h b/include/keepkey/firmware/eos.h index 3f925c9b8..10c0cf4d6 100644 --- a/include/keepkey/firmware/eos.h +++ b/include/keepkey/firmware/eos.h @@ -86,6 +86,9 @@ uint32_t eos_actionsRemaining(void); bool eos_hasActionUnknownDataRemaining(void); +bool eos_isSupportedAction(const EosActionCommon* common); +bool eos_unknownActionPolicyAllows(bool advanced_mode); + /// \returns true iff successful. bool eos_compileActionUnknown(const EosActionCommon* common, const EosActionUnknown* action); diff --git a/include/keepkey/firmware/mayachain.h b/include/keepkey/firmware/mayachain.h index c3d40380d..9b541ac41 100644 --- a/include/keepkey/firmware/mayachain.h +++ b/include/keepkey/firmware/mayachain.h @@ -10,6 +10,15 @@ typedef struct _MayachainSignTx MayachainSignTx; typedef struct _MayachainMsgDeposit MayachainMsgDeposit; +// Returns true iff `denom` is a plausible MAYAChain denom: non-empty, +// and contains only lowercase alpha, digits, '.', '/', or '-'. +bool mayachain_isValidDenom(const char* denom); + +// Deposit asset grammar: as above but uppercase alpha also allowed. +bool mayachain_isValidAsset(const char* asset); +// Deposit signer must be bech32 with the active network's HRP. +bool mayachain_isValidSigner(const char* signer); + bool mayachain_signTxInit(const HDNode* _node, const MayachainSignTx* _msg); bool mayachain_signTxUpdateMsgSend(const uint64_t amount, const char* to_address, const char* denom); diff --git a/include/keepkey/firmware/osmosis.h b/include/keepkey/firmware/osmosis.h index 330c4b9a1..74325a125 100644 --- a/include/keepkey/firmware/osmosis.h +++ b/include/keepkey/firmware/osmosis.h @@ -5,6 +5,7 @@ #include "trezor/crypto/bip32.h" #include +#include #include typedef struct _OsmosisSignTx OsmosisSignTx; @@ -16,7 +17,8 @@ void debug_intermediate_hash(void); bool osmosis_signTxInit(const HDNode* _node, const OsmosisSignTx* _msg); -bool osmosis_signTxUpdateMsgSend(const char* amount, const char* to_address); +bool osmosis_signTxUpdateMsgSend(const char* amount, const char* to_address, + const char* denom); bool osmosis_signTxUpdateMsgDelegate(const char* amount, const char* delegator_address, @@ -66,6 +68,27 @@ bool osmosis_signTxUpdateMsgSwap(const uint64_t pool_id, const char* token_in_denom, const char* token_out_min_amount); +#define OSMOSIS_PRECISION 6 +#define OSMOSIS_MAX_AMOUNT_DIGITS 32 +#define OSMOSIS_MAX_DENOM_LEN 68 + +// Longest amount a confirm screen renders: the digits, a point, a space and +// the longest denom a message can carry. +#define OSMOSIS_AMOUNT_STR_LEN 103 + +/** + * Render an integer base-unit amount for a confirm screen: + * ("1500000", "uosmo") -> "1.500000 OSMO". + * + * Only uosmo is scaled — any other denom is shown exactly as the chain states + * it, because the device does not know its precision. Returns false unless the + * amount is a canonical, schema-bounded unsigned decimal and the denomination + * is a schema-bounded Cosmos asset identifier. Native uosmo additionally must + * fit uint64, which is the range accepted by the native-asset display policy. + */ +bool osmosis_formatAmount(char* out, size_t out_len, const char* value, + const char* denom); + bool osmosis_signTxFinalize(uint8_t* public_key, uint8_t* signature); bool osmosis_signingIsInited(void); bool osmosis_signingIsFinished(void); diff --git a/include/keepkey/firmware/signtx_tendermint.h b/include/keepkey/firmware/signtx_tendermint.h index 9277a370f..082fb4970 100644 --- a/include/keepkey/firmware/signtx_tendermint.h +++ b/include/keepkey/firmware/signtx_tendermint.h @@ -9,8 +9,15 @@ typedef struct _TendermintSignTx TendermintSignTx; +typedef enum { + TENDERMINT_SIGNING_NONE = 0, + TENDERMINT_SIGNING_COSMOS, + TENDERMINT_SIGNING_GENERIC, +} TendermintSigningType; + bool tendermint_signTxInit(const HDNode* _node, const void* _msg, - const size_t msgsize, const char* denom); + const size_t msgsize, const char* denom, + TendermintSigningType type); bool tendermint_signTxUpdateMsgSend(const uint64_t amount, const char* to_address, const char* chainstr, const char* denom, @@ -41,9 +48,11 @@ bool tendermint_signTxUpdateMsgIBCTransfer( const char* revision_number, const char* revision_height, const char* chainstr, const char* denom, const char* msgTypePrefix); bool tendermint_signTxFinalize(uint8_t* public_key, uint8_t* signature); -bool tendermint_signingIsInited(void); +bool tendermint_signingIsInited(TendermintSigningType type); +bool tendermint_signingConfigMatches(const char* chain_name, const char* denom, + const char* message_type_prefix); bool tendermint_signingIsFinished(void); void tendermint_signAbort(void); const void* tendermint_getSignTx(void); -#endif \ No newline at end of file +#endif diff --git a/include/keepkey/firmware/tendermint.h b/include/keepkey/firmware/tendermint.h index aa29399d1..67cad3ff2 100644 --- a/include/keepkey/firmware/tendermint.h +++ b/include/keepkey/firmware/tendermint.h @@ -28,6 +28,12 @@ bool tendermint_pathMismatched(const CoinType* coin, const uint32_t* address_n, bool tendermint_getAddress(const HDNode* node, const char* prefix, char* address); +bool tendermint_isValidDenom(const char* denom); + +bool tendermint_isValidAsset(const char* asset); + +bool tendermint_isValidSigner(const char* signer, const char* hrp); + void tendermint_sha256UpdateEscaped(SHA256_CTX* ctx, const char* s, size_t len); bool tendermint_snprintf(SHA256_CTX* ctx, char* temp, size_t len, diff --git a/include/keepkey/firmware/thorchain.h b/include/keepkey/firmware/thorchain.h index 5ebb2a993..398495863 100644 --- a/include/keepkey/firmware/thorchain.h +++ b/include/keepkey/firmware/thorchain.h @@ -10,9 +10,18 @@ typedef struct _ThorchainSignTx ThorchainSignTx; typedef struct _ThorchainMsgDeposit ThorchainMsgDeposit; +// Returns true iff denom contains only chars safe in JSON without escaping. +// Valid: [a-z0-9./\-]. Rejects empty string, quotes, backslashes, whitespace. +bool thorchain_isValidDenom(const char* denom); + +// Deposit asset grammar: as above but uppercase alpha also allowed. +bool thorchain_isValidAsset(const char* asset); +// Deposit signer must be bech32 with the active network's HRP. +bool thorchain_isValidSigner(const char* signer); + bool thorchain_signTxInit(const HDNode* _node, const ThorchainSignTx* _msg); bool thorchain_signTxUpdateMsgSend(const uint64_t amount, - const char* to_address); + const char* to_address, const char* denom); bool thorchain_signTxUpdateMsgDeposit(const ThorchainMsgDeposit* depmsg); bool thorchain_signTxFinalize(uint8_t* public_key, uint8_t* signature); bool thorchain_signingIsInited(void); @@ -28,4 +37,13 @@ const ThorchainSignTx* thorchain_getThorchainSignTx(void); // true if thorchain data parsed and confirmed by user, false otherwise bool thorchain_parseConfirmMemo(const char* swapStr, size_t size); +// Pages the COMPLETE raw memo (ASCII as text pages, binary as hex pages) so no +// byte is ever truncated behind confirm()'s body budget. Native THOR/MAYA +// deposit/send handlers call this as the authoritative disclosure after their +// best-effort structured summary, so a field the structured view omits (or a +// long field that would truncate) can never be signed unseen. Returns false if +// the user rejects any page. Shared by the MAYA path (same memo grammar). +bool thorchain_confirm_full_memo(const char* title, const char* memo, + size_t len); + #endif diff --git a/lib/firmware/binance.c b/lib/firmware/binance.c index 868301db4..aac7f8aba 100644 --- a/lib/firmware/binance.c +++ b/lib/firmware/binance.c @@ -16,12 +16,49 @@ static BinanceSignTx msg; const BinanceSignTx* binance_getBinanceSignTx(void) { return &msg; } +bool binance_isValidDenom(const char* denom) { + if (!denom) return false; + const size_t len = strnlen(denom, BINANCE_MAX_DENOM_LEN + 1); + if (len == 0 || len > BINANCE_MAX_DENOM_LEN) return false; + for (size_t i = 0; i < len; i++) { + const char c = denom[i]; + if (!((c >= 'A' && c <= 'Z') || (c >= '0' && c <= '9') || c == '-')) + return false; + } + return true; +} + +bool binance_validateTransfer(const BinanceTransferMsg* transfer) { + if (!transfer || transfer->inputs_count != 1 || + transfer->inputs[0].coins_count != 1 || transfer->outputs_count != 1 || + transfer->outputs[0].coins_count != 1) + return false; + + const BinanceInputOutput* input = &transfer->inputs[0]; + const BinanceInputOutput* output = &transfer->outputs[0]; + const BinanceCoin* input_coin = &input->coins[0]; + const BinanceCoin* output_coin = &output->coins[0]; + if (!input->has_address || !output->has_address || !input_coin->has_amount || + !output_coin->has_amount || !input_coin->has_denom || + !output_coin->has_denom || input_coin->amount <= 0 || + output_coin->amount <= 0 || input_coin->amount != output_coin->amount || + strcmp(input_coin->denom, output_coin->denom) != 0 || + !binance_isValidDenom(input_coin->denom)) + return false; + + return true; +} + bool binance_signTxInit(const HDNode* _node, const BinanceSignTx* _msg) { - initialized = true; + binance_signAbort(); + if (!_node || !_msg || !_msg->has_msg_count || _msg->msg_count == 0 || + !_msg->has_account_number || _msg->account_number < 0 || + !_msg->has_chain_id || _msg->chain_id[0] == '\0' || !_msg->has_sequence || + _msg->sequence < 0 || !_msg->has_source || _msg->source < 0) + return false; + msgs_remaining = _msg->msg_count; - has_message = false; - memzero(&node, sizeof(node)); memcpy(&node, _node, sizeof(node)); memcpy(&msg, _msg, sizeof(msg)); @@ -32,7 +69,7 @@ bool binance_signTxInit(const HDNode* _node, const BinanceSignTx* _msg) { success &= tendermint_snprintf(&ctx, buffer, sizeof(buffer), "{\"account_number\":\"%" PRIu64 "\"", - msg.account_number); + (uint64_t)msg.account_number); const char* const chainid_prefix = ",\"chain_id\":\""; sha256_Update(&ctx, (uint8_t*)chainid_prefix, strlen(chainid_prefix)); @@ -45,16 +82,25 @@ bool binance_signTxInit(const HDNode* _node, const BinanceSignTx* _msg) { } sha256_Update(&ctx, (const uint8_t*)"\",\"msgs\":[", 10); - return success; + if (!success) { + binance_signAbort(); + return false; + } + initialized = true; + return true; } bool binance_serializeCoin(const BinanceCoin* coin) { + if (!coin || !coin->has_amount || coin->amount <= 0 || !coin->has_denom || + !binance_isValidDenom(coin->denom)) + return false; + bool success = true; char buffer[64 + 1]; success &= tendermint_snprintf(&ctx, buffer, sizeof(buffer), "{\"amount\":%" PRIu64 ",\"denom\":\"%s\"}", - coin->amount, coin->denom); + (uint64_t)coin->amount, coin->denom); return success; } @@ -83,6 +129,9 @@ bool binance_serializeInputOutput(const BinanceInputOutput* io) { } bool binance_signTxUpdateTransfer(const BinanceTransferMsg* _msg) { + if (!initialized || msgs_remaining == 0 || !binance_validateTransfer(_msg)) + return false; + bool success = true; sha256_Update(&ctx, (const uint8_t*)"{\"inputs\":[", 11); @@ -103,18 +152,23 @@ bool binance_signTxUpdateTransfer(const BinanceTransferMsg* _msg) { sha256_Update(&ctx, (const uint8_t*)"]}", 2); - has_message = true; - msgs_remaining--; + if (success) { + has_message = true; + msgs_remaining--; + } return success; } bool binance_signTxFinalize(uint8_t* public_key, uint8_t* signature) { + if (!initialized || msgs_remaining != 0 || !has_message || !public_key || + !signature) + return false; char buffer[64 + 1]; if (!tendermint_snprintf(&ctx, buffer, sizeof(buffer), "],\"sequence\":\"%" PRIu64 "\",\"source\":\"%" PRIu64 "\"}", - msg.sequence, msg.source)) + (uint64_t)msg.sequence, (uint64_t)msg.source)) return false; hdnode_fill_public_key(&node); @@ -128,7 +182,9 @@ bool binance_signTxFinalize(uint8_t* public_key, uint8_t* signature) { bool binance_signingIsInited(void) { return initialized; } -bool binance_signingIsFinished(void) { return msgs_remaining == 0; } +bool binance_signingIsFinished(void) { + return initialized && msgs_remaining == 0 && has_message; +} void binance_signAbort(void) { initialized = false; diff --git a/lib/firmware/eos.c b/lib/firmware/eos.c index 45e7acacc..ab40aa4bf 100644 --- a/lib/firmware/eos.c +++ b/lib/firmware/eos.c @@ -385,7 +385,7 @@ bool eos_compilePermissionLevel(const EosPermissionLevel* auth) { bool eos_hasActionUnknownDataRemaining(void) { return 0 < unknown_remaining; } -static bool isSupportedAction(const EosActionCommon* common) { +bool eos_isSupportedAction(const EosActionCommon* common) { if (common->account == EOS_eosio || common->account == EOS_eosio_token) { switch (common->name) { case EOS_Transfer: @@ -402,15 +402,18 @@ static bool isSupportedAction(const EosActionCommon* common) { case EOS_DeleteAuth: case EOS_LinkAuth: case EOS_UnlinkAuth: + case EOS_NewAccount: return true; } } return false; } +bool eos_unknownActionPolicyAllows(bool advanced_mode) { return advanced_mode; } + bool eos_compileActionUnknown(const EosActionCommon* common, const EosActionUnknown* action) { - if (isSupportedAction(common)) { + if (eos_isSupportedAction(common)) { fsm_sendFailure( FailureType_Failure_SyntaxError, "EosActionUnknown cannot be used with supported contract actions"); @@ -418,10 +421,15 @@ bool eos_compileActionUnknown(const EosActionCommon* common, return false; } - if (!storage_isPolicyEnabled("AdvancedMode")) { - (void)review(ButtonRequestType_ButtonRequest_Other, "Warning", - "Signing of arbitrary EOS actions is recommended only for " - "experienced users. Enable 'AdvancedMode' policy to dismiss."); + if (!eos_unknownActionPolicyAllows(storage_isPolicyEnabled("AdvancedMode"))) { + (void)review(ButtonRequestType_ButtonRequest_Other, "Blocked", + "Arbitrary EOS actions require AdvancedMode. " + "Enable in device settings."); + fsm_sendFailure(FailureType_Failure_ActionCancelled, + "Arbitrary EOS action signing disabled by policy"); + eos_signingAbort(); + layoutHome(); + return false; } if (unknown_remaining == 0) { @@ -534,7 +542,13 @@ bool eos_signTx(EosSignedTx* tx) { time_t expiry = header.expiration; char expiry_str[26]; +#ifdef _WIN32 + // asctime_s is the bounds-checked Windows variant; output truncated below. + // cppcheck-suppress asctime_sCalled + asctime_s(expiry_str, sizeof(expiry_str), gmtime(&expiry)); +#else asctime_r(gmtime(&expiry), expiry_str); +#endif expiry_str[24] = 0; // cut off the '\n' uint32_t delay = header.delay_sec; if (!confirm(ButtonRequestType_ButtonRequest_SignTx, "Sign Transaction", diff --git a/lib/firmware/fsm_msg_binance.h b/lib/firmware/fsm_msg_binance.h index 0adf00c62..72f75b2e5 100644 --- a/lib/firmware/fsm_msg_binance.h +++ b/lib/firmware/fsm_msg_binance.h @@ -95,30 +95,38 @@ static void binance_response(void); void fsm_msgBinanceTransferMsg(const BinanceTransferMsg* msg) { CHECK_PARAM(binance_signingIsInited(), "Signing not in progress?"); - CHECK_PARAM(msg->inputs_count == 1, "Malformed BinanceTransferMsg") - CHECK_PARAM(msg->inputs[0].coins_count == 1, "Malformed BinanceTransferMsg") - CHECK_PARAM(msg->outputs_count == 1, "Malformed BinanceTransferMsg") - CHECK_PARAM(msg->outputs[0].coins_count == 1, "Malformed BinanceTransferMsg") - CHECK_PARAM(msg->inputs[0].coins[0].amount == msg->outputs[0].coins[0].amount, - "Malformed BinanceTransferMsg") - CHECK_PARAM(strcmp(msg->inputs[0].coins[0].denom, - msg->outputs[0].coins[0].denom) == 0, - "Malformed BinanceTransferMsg") + if (!binance_validateTransfer(msg)) { + binance_signAbort(); + fsm_sendFailure(FailureType_Failure_SyntaxError, + "Malformed BinanceTransferMsg"); + layoutHome(); + return; + } const CoinType* coin = fsm_getCoin(true, "Binance"); if (!coin) { + binance_signAbort(); + layoutHome(); return; } switch (msg->outputs[0].address_type) { case OutputAddressType_TRANSFER: default: { - char amount_str[42]; - char denom_str[14]; - snprintf(denom_str, strlen(msg->outputs[0].coins[0].denom) + 2, " %s", - msg->outputs[0].coins[0].denom); - bn_format_uint64(msg->outputs[0].coins[0].amount, NULL, denom_str, 8, 0, - false, amount_str, sizeof(amount_str)); + char amount_str[64]; + char denom_str[BINANCE_MAX_DENOM_LEN + 2]; + const int denom_len = snprintf(denom_str, sizeof(denom_str), " %s", + msg->outputs[0].coins[0].denom); + if (denom_len <= 0 || (size_t)denom_len >= sizeof(denom_str) || + !bn_format_uint64((uint64_t)msg->outputs[0].coins[0].amount, NULL, + denom_str, 8, 0, false, amount_str, + sizeof(amount_str))) { + binance_signAbort(); + fsm_sendFailure(FailureType_Failure_SyntaxError, + "Invalid Binance transfer amount"); + layoutHome(); + return; + } if (!confirm_transaction_output( ButtonRequestType_ButtonRequest_ConfirmOutput, amount_str, msg->outputs[0].address)) { @@ -151,13 +159,16 @@ static void binance_response(void) { const CoinType* coin = fsm_getCoin(true, "Binance"); if (!coin) { + binance_signAbort(); + layoutHome(); return; } const BinanceSignTx* sign_tx = binance_getBinanceSignTx(); - if (sign_tx->has_memo && !confirm(ButtonRequestType_ButtonRequest_ConfirmMemo, - _("Memo"), "%s", sign_tx->memo)) { + if (sign_tx->has_memo && + !confirm_bytes(ButtonRequestType_ButtonRequest_ConfirmMemo, _("Memo"), + (const uint8_t*)sign_tx->memo, strlen(sign_tx->memo))) { binance_signAbort(); fsm_sendFailure(FailureType_Failure_ActionCancelled, NULL); layoutHome(); diff --git a/lib/firmware/fsm_msg_cosmos.h b/lib/firmware/fsm_msg_cosmos.h index 082fe2eed..b7aff7560 100644 --- a/lib/firmware/fsm_msg_cosmos.h +++ b/lib/firmware/fsm_msg_cosmos.h @@ -92,7 +92,8 @@ void fsm_msgCosmosSignTx(const CosmosSignTx* msg) { RESP_INIT(CosmosMsgRequest); - if (!tendermint_signTxInit(node, (void*)msg, sizeof(CosmosSignTx), "uatom")) { + if (!tendermint_signTxInit(node, (void*)msg, sizeof(CosmosSignTx), "uatom", + TENDERMINT_SIGNING_COSMOS)) { tendermint_signAbort(); memzero(node, sizeof(*node)); fsm_sendFailure(FailureType_Failure_FirmwareError, @@ -108,7 +109,8 @@ void fsm_msgCosmosSignTx(const CosmosSignTx* msg) { void fsm_msgCosmosMsgAck(const CosmosMsgAck* msg) { // Confirm transaction basics - CHECK_PARAM(tendermint_signingIsInited(), "Signing not in progress"); + CHECK_PARAM(tendermint_signingIsInited(TENDERMINT_SIGNING_COSMOS), + "Cosmos signing not in progress"); const CoinType* coin = fsm_getCoin(true, "Cosmos"); if (!coin) { @@ -375,12 +377,13 @@ void fsm_msgCosmosMsgAck(const CosmosMsgAck* msg) { } } else if (msg->has_ibc_transfer) { /** Confirm required transaction parameters exist */ - if (!msg->ibc_transfer.has_sender || + if (!msg->ibc_transfer.has_receiver || !msg->ibc_transfer.has_sender || !msg->ibc_transfer.has_source_channel || !msg->ibc_transfer.has_source_port || !msg->ibc_transfer.has_revision_height || !msg->ibc_transfer.has_revision_number || - !msg->ibc_transfer.has_denom) { + !msg->ibc_transfer.has_denom || !msg->ibc_transfer.has_amount || + strcmp(msg->ibc_transfer.denom, "uatom") != 0) { tendermint_signAbort(); fsm_sendFailure(FailureType_Failure_FirmwareError, _("Message is missing required parameters")); @@ -393,7 +396,19 @@ void fsm_msgCosmosMsgAck(const CosmosMsgAck* msg) { amount_str, sizeof(amount_str)); if (!confirm(ButtonRequestType_ButtonRequest_Other, "IBC Transfer", - "Transfer %s to %s?", amount_str, msg->ibc_transfer.sender)) { + "Transfer %s via IBC?", amount_str)) { + tendermint_signAbort(); + fsm_sendFailure(FailureType_Failure_ActionCancelled, NULL); + layoutHome(); + return; + } + + if (!confirm_bytes(ButtonRequestType_ButtonRequest_Other, "IBC Sender", + (const uint8_t*)msg->ibc_transfer.sender, + strlen(msg->ibc_transfer.sender)) || + !confirm_bytes(ButtonRequestType_ButtonRequest_Other, "IBC Receiver", + (const uint8_t*)msg->ibc_transfer.receiver, + strlen(msg->ibc_transfer.receiver))) { tendermint_signAbort(); fsm_sendFailure(FailureType_Failure_ActionCancelled, NULL); layoutHome(); @@ -462,8 +477,8 @@ void fsm_msgCosmosMsgAck(const CosmosMsgAck* msg) { } if (sign_tx->has_memo && (strlen(sign_tx->memo) > 0)) { - if (!confirm(ButtonRequestType_ButtonRequest_ConfirmMemo, _("Memo"), "%s", - sign_tx->memo)) { + if (!confirm_bytes(ButtonRequestType_ButtonRequest_ConfirmMemo, _("Memo"), + (const uint8_t*)sign_tx->memo, strlen(sign_tx->memo))) { tendermint_signAbort(); fsm_sendFailure(FailureType_Failure_ActionCancelled, NULL); layoutHome(); @@ -509,4 +524,4 @@ void fsm_msgCosmosMsgAck(const CosmosMsgAck* msg) { tendermint_signAbort(); layoutHome(); msg_write(MessageType_MessageType_CosmosSignedTx, resp); -} \ No newline at end of file +} diff --git a/lib/firmware/fsm_msg_mayachain.h b/lib/firmware/fsm_msg_mayachain.h index be9229665..4b2daaf2c 100644 --- a/lib/firmware/fsm_msg_mayachain.h +++ b/lib/firmware/fsm_msg_mayachain.h @@ -141,15 +141,38 @@ void fsm_msgMayachainMsgAck(const MayachainMsgAck* msg) { const MayachainSignTx* sign_tx = mayachain_getMayachainSignTx(); + // Default to "cacao" for backward compatibility; validate all non-default + // denoms before any display so untrusted strings never reach the UI or + // the signing JSON. + const char* coin_denom = + (msg->has_send && msg->send.has_denom && msg->send.denom[0]) + ? msg->send.denom + : "cacao"; + if (msg->has_send) { + if (!mayachain_isValidDenom(coin_denom)) { + mayachain_signAbort(); + fsm_sendFailure(FailureType_Failure_SyntaxError, "Invalid denom"); + layoutHome(); + return; + } + switch (msg->send.address_type) { case OutputAddressType_TRANSFER: default: { + // Amount (no denom suffix) must fit amount_str[32]; a long denom + // appended here would overflow bn_format and blank the amount while + // the real value is still signed. Confirm the denom on its own + // screen instead (matches the THORChain send path). char amount_str[32]; - char denom_str[71]; - sprintf(denom_str, " %s", msg->send.denom); - bn_format_uint64(msg->send.amount, NULL, denom_str, 10, 0, false, - amount_str, sizeof(amount_str)); + if (!bn_format_uint64(msg->send.amount, NULL, NULL, 10, 0, false, + amount_str, sizeof(amount_str))) { + mayachain_signAbort(); + fsm_sendFailure(FailureType_Failure_FirmwareError, + _("Failed to format amount")); + layoutHome(); + return; + } if (!confirm_transaction_output( ButtonRequestType_ButtonRequest_ConfirmOutput, amount_str, msg->send.to_address)) { @@ -158,12 +181,19 @@ void fsm_msgMayachainMsgAck(const MayachainMsgAck* msg) { layoutHome(); return; } + if (!confirm(ButtonRequestType_ButtonRequest_ConfirmOutput, "Asset", + "%s", coin_denom)) { + mayachain_signAbort(); + fsm_sendFailure(FailureType_Failure_ActionCancelled, NULL); + layoutHome(); + return; + } break; } } if (!mayachain_signTxUpdateMsgSend(msg->send.amount, msg->send.to_address, - msg->send.denom)) { + coin_denom)) { mayachain_signAbort(); fsm_sendFailure(FailureType_Failure_SyntaxError, "Failed to include send message in transaction"); @@ -172,8 +202,21 @@ void fsm_msgMayachainMsgAck(const MayachainMsgAck* msg) { } } else if (msg->has_deposit) { - char amount_str[32]; - char asset_str[21]; + // Validate before any display so untrusted strings never reach the UI + // or the sign bytes. + if (!mayachain_isValidAsset(msg->deposit.asset) || + !mayachain_isValidSigner(msg->deposit.signer)) { + mayachain_signAbort(); + fsm_sendFailure(FailureType_Failure_SyntaxError, + "Invalid deposit asset or signer"); + layoutHome(); + return; + } + // Long-form assets (e.g. + // ETH.USDT-0XDAC17F958D2EE523A2206206994597C13D831EC7) are ~50 chars; + // amount_str must fit amount + asset suffix or bn_format zeroes it out. + char amount_str[96]; + char asset_str[64]; asset_str[0] = ' '; strlcpy(&(asset_str[1]), msg->deposit.asset, sizeof(asset_str) - 1); bn_format_uint64(msg->deposit.amount, NULL, asset_str, 10, 0, false, @@ -188,17 +231,16 @@ void fsm_msgMayachainMsgAck(const MayachainMsgAck* msg) { } if (msg->deposit.has_memo) { - // See if we can parse the memo - if (!mayachain_parseConfirmMemo(msg->deposit.memo, - sizeof(msg->deposit.memo))) { - // Memo not recognizable, ask to confirm it - if (!confirm(ButtonRequestType_ButtonRequest_ConfirmMemo, _("Memo"), - "%s", msg->deposit.memo)) { - mayachain_signAbort(); - fsm_sendFailure(FailureType_Failure_ActionCancelled, NULL); - layoutHome(); - return; - } + size_t memo_len = strnlen(msg->deposit.memo, sizeof(msg->deposit.memo)); + // Page the complete raw memo as the sole, authoritative disclosure (no + // structured pre-parse: its bool return conflates unrecognized with user + // reject, so a reject must not be followed by these pages then signing). + if (!thorchain_confirm_full_memo(_("Memo"), msg->deposit.memo, + memo_len)) { + mayachain_signAbort(); + fsm_sendFailure(FailureType_Failure_ActionCancelled, NULL); + layoutHome(); + return; } } @@ -218,17 +260,14 @@ void fsm_msgMayachainMsgAck(const MayachainMsgAck* msg) { } if (sign_tx->has_memo && !msg->deposit.has_memo) { - // See if we can parse the tx memo. This memo ignored if deposit msg has - // memo - if (!mayachain_parseConfirmMemo(sign_tx->memo, sizeof(sign_tx->memo))) { - // Memo not recognizable, ask to confirm it - if (!confirm(ButtonRequestType_ButtonRequest_ConfirmMemo, _("Memo"), "%s", - sign_tx->memo)) { - mayachain_signAbort(); - fsm_sendFailure(FailureType_Failure_ActionCancelled, NULL); - layoutHome(); - return; - } + // Ignored if the deposit msg has a memo. Page the full raw memo as the sole + // gate (see the deposit path above for why there is no structured pass). + size_t memo_len = strnlen(sign_tx->memo, sizeof(sign_tx->memo)); + if (!thorchain_confirm_full_memo(_("Memo"), sign_tx->memo, memo_len)) { + mayachain_signAbort(); + fsm_sendFailure(FailureType_Failure_ActionCancelled, NULL); + layoutHome(); + return; } } @@ -245,7 +284,7 @@ void fsm_msgMayachainMsgAck(const MayachainMsgAck* msg) { if (!confirm(ButtonRequestType_ButtonRequest_SignTx, node_str, "Sign this %s transaction on %s? " "Additional network fees apply.", - msg->has_send ? msg->send.denom : "CACAO", sign_tx->chain_id)) { + msg->has_send ? coin_denom : "CACAO", sign_tx->chain_id)) { mayachain_signAbort(); fsm_sendFailure(FailureType_Failure_ActionCancelled, NULL); layoutHome(); diff --git a/lib/firmware/fsm_msg_osmosis.h b/lib/firmware/fsm_msg_osmosis.h index c654f744d..b64daf9a8 100644 --- a/lib/firmware/fsm_msg_osmosis.h +++ b/lib/firmware/fsm_msg_osmosis.h @@ -1,7 +1,16 @@ -#include -#define OSMOSIS_PRECISION 6 #define OSMOSIS_LP_ASSET_PRECISION 18 +static bool osmosis_formatAmountOrFail(char* out, size_t out_len, + const char* value, const char* denom) { + if (osmosis_formatAmount(out, out_len, value, denom)) return true; + + osmosis_signAbort(); + fsm_sendFailure(FailureType_Failure_SyntaxError, + "Invalid Osmosis amount or denomination"); + layoutHome(); + return false; +} + void fsm_msgOsmosisGetAddress(const OsmosisGetAddress* msg) { RESP_INIT(OsmosisAddress); @@ -132,7 +141,8 @@ void fsm_msgOsmosisMsgAck(const OsmosisMsgAck* msg) { /** Confirm required transaction parameters exist */ if (msg->has_send) { - if (!msg->send.has_to_address || !msg->send.has_amount) { + if (!msg->send.has_to_address || !msg->send.has_amount || + !msg->send.has_denom) { osmosis_signAbort(); fsm_sendFailure(FailureType_Failure_FirmwareError, _("Message is missing required parameters")); @@ -140,27 +150,28 @@ void fsm_msgOsmosisMsgAck(const OsmosisMsgAck* msg) { return; } - float amount = atof(msg->send.amount); - const char* denom = msg->send.denom; - if (!strcmp(msg->send.denom, "uosmo")) { - amount /= pow(10, OSMOSIS_PRECISION); - denom = "OSMO"; + char amount_str[OSMOSIS_AMOUNT_STR_LEN]; + if (!osmosis_formatAmountOrFail(amount_str, sizeof(amount_str), + msg->send.amount, msg->send.denom)) { + return; } - char amount_str[103]; - snprintf(amount_str, sizeof(amount_str) - 1, "%.6f %s", amount, denom); - - /** Confirm transaction parameters on screen */ - if (!confirm_transaction_output( - ButtonRequestType_ButtonRequest_ConfirmOutput, amount_str, - msg->send.to_address)) { + // Amount and destination are independent renderer-measured disclosures. + // A single wrapped "Send ... to ..." body could hide the destination. + if (!confirm_bytes(ButtonRequestType_ButtonRequest_ConfirmOutput, + "Send Amount", (const uint8_t*)amount_str, + strlen(amount_str)) || + !confirm_bytes(ButtonRequestType_ButtonRequest_ConfirmOutput, "Send To", + (const uint8_t*)msg->send.to_address, + strlen(msg->send.to_address))) { osmosis_signAbort(); fsm_sendFailure(FailureType_Failure_ActionCancelled, NULL); layoutHome(); return; } - if (!osmosis_signTxUpdateMsgSend(msg->send.amount, msg->send.to_address)) { + if (!osmosis_signTxUpdateMsgSend(msg->send.amount, msg->send.to_address, + msg->send.denom)) { osmosis_signAbort(); fsm_sendFailure(FailureType_Failure_SyntaxError, "Failed to include send message in transaction"); @@ -171,7 +182,8 @@ void fsm_msgOsmosisMsgAck(const OsmosisMsgAck* msg) { } else if (msg->has_delegate) { /** Confirm required transaction parameters exist */ if (!msg->delegate.has_delegator_address || - !msg->delegate.has_validator_address || !msg->delegate.has_amount) { + !msg->delegate.has_validator_address || !msg->delegate.has_amount || + !msg->delegate.has_denom) { osmosis_signAbort(); fsm_sendFailure(FailureType_Failure_FirmwareError, _("Message is missing required parameters")); @@ -179,11 +191,11 @@ void fsm_msgOsmosisMsgAck(const OsmosisMsgAck* msg) { return; } - float amount = atof(msg->delegate.amount); - const char* denom = msg->delegate.denom; - if (!strcmp(msg->delegate.denom, "uosmo")) { - amount /= pow(10, OSMOSIS_PRECISION); - denom = "OSMO"; + char amount_str[OSMOSIS_AMOUNT_STR_LEN]; + if (!osmosis_formatAmountOrFail(amount_str, sizeof(amount_str), + msg->delegate.amount, + msg->delegate.denom)) { + return; } /** Confirm transaction parameters on-screen */ @@ -203,8 +215,8 @@ void fsm_msgOsmosisMsgAck(const OsmosisMsgAck* msg) { return; } - if (!confirm(ButtonRequestType_ButtonRequest_Other, "Confirm Amount", - "%.6f %s", amount, denom)) { + if (!confirm_bytes(ButtonRequestType_ButtonRequest_Other, "Confirm Amount", + (const uint8_t*)amount_str, strlen(amount_str))) { osmosis_signAbort(); fsm_sendFailure(FailureType_Failure_ActionCancelled, NULL); layoutHome(); @@ -223,7 +235,8 @@ void fsm_msgOsmosisMsgAck(const OsmosisMsgAck* msg) { } else if (msg->has_undelegate) { /** Confirm required transaction parameters exist */ if (!msg->undelegate.has_delegator_address || - !msg->undelegate.has_validator_address || !msg->undelegate.has_amount) { + !msg->undelegate.has_validator_address || !msg->undelegate.has_amount || + !msg->undelegate.has_denom) { osmosis_signAbort(); fsm_sendFailure(FailureType_Failure_FirmwareError, _("Message is missing required parameters")); @@ -231,11 +244,11 @@ void fsm_msgOsmosisMsgAck(const OsmosisMsgAck* msg) { return; } - float amount = atof(msg->undelegate.amount); - const char* denom = msg->undelegate.denom; - if (!strcmp(msg->undelegate.denom, "uosmo")) { - amount /= pow(10, OSMOSIS_PRECISION); - denom = "OSMO"; + char amount_str[OSMOSIS_AMOUNT_STR_LEN]; + if (!osmosis_formatAmountOrFail(amount_str, sizeof(amount_str), + msg->undelegate.amount, + msg->undelegate.denom)) { + return; } /** Confirm transaction parameters on-screen */ @@ -255,8 +268,8 @@ void fsm_msgOsmosisMsgAck(const OsmosisMsgAck* msg) { return; } - if (!confirm(ButtonRequestType_ButtonRequest_Other, "Confirm Amount", - "%.6f %s", amount, denom)) { + if (!confirm_bytes(ButtonRequestType_ButtonRequest_Other, "Confirm Amount", + (const uint8_t*)amount_str, strlen(amount_str))) { osmosis_signAbort(); fsm_sendFailure(FailureType_Failure_ActionCancelled, NULL); layoutHome(); @@ -285,44 +298,45 @@ void fsm_msgOsmosisMsgAck(const OsmosisMsgAck* msg) { return; } - char insoamt[33] = {0}; - uint8_t outsoamt[34] = {0}; - strlcpy(insoamt, msg->lp_add.share_out_amount, - sizeof(msg->lp_add.share_out_amount)); - - if (base_to_precision(outsoamt, (uint8_t*)insoamt, sizeof(outsoamt), - strlen(insoamt), OSMOSIS_LP_ASSET_PRECISION) < 0) { + char outsoamt[34] = {0}; + if (base_to_precision( + (uint8_t*)outsoamt, (const uint8_t*)msg->lp_add.share_out_amount, + sizeof(outsoamt), strlen(msg->lp_add.share_out_amount), + OSMOSIS_LP_ASSET_PRECISION) < 0) { osmosis_signAbort(); - fsm_sendFailure(FailureType_Failure_Other, NULL); + fsm_sendFailure(FailureType_Failure_SyntaxError, + "Invalid LP share amount"); layoutHome(); return; } - float amount_in_max_b = atof(msg->lp_add.amount_in_max_b); - const char* denom_in_max_b = msg->lp_add.denom_in_max_b; - if (!strcmp(msg->lp_add.denom_in_max_b, "uosmo")) { - amount_in_max_b /= pow(10, OSMOSIS_PRECISION); - denom_in_max_b = "OSMO"; + char amount_in_max_b_str[OSMOSIS_AMOUNT_STR_LEN]; + if (!osmosis_formatAmountOrFail( + amount_in_max_b_str, sizeof(amount_in_max_b_str), + msg->lp_add.amount_in_max_b, msg->lp_add.denom_in_max_b)) { + return; } - float amount_in_max_a = atof(msg->lp_add.amount_in_max_a); - const char* denom_in_max_a = msg->lp_add.denom_in_max_a; - if (!strcmp(msg->lp_add.denom_in_max_a, "uosmo")) { - amount_in_max_a /= pow(10, OSMOSIS_PRECISION); - denom_in_max_a = "OSMO"; + char amount_in_max_a_str[OSMOSIS_AMOUNT_STR_LEN]; + if (!osmosis_formatAmountOrFail( + amount_in_max_a_str, sizeof(amount_in_max_a_str), + msg->lp_add.amount_in_max_a, msg->lp_add.denom_in_max_a)) { + return; } /** Confirm transaction parameters on-screen */ - if (!confirm(ButtonRequestType_ButtonRequest_Other, "Add Liquidity", - "Deposit %.6f %s and...", amount_in_max_b, denom_in_max_b)) { + if (!confirm_bytes(ButtonRequestType_ButtonRequest_Other, "Max Deposit A", + (const uint8_t*)amount_in_max_a_str, + strlen(amount_in_max_a_str))) { osmosis_signAbort(); fsm_sendFailure(FailureType_Failure_ActionCancelled, NULL); layoutHome(); return; } - if (!confirm(ButtonRequestType_ButtonRequest_Other, "Add Liquidity", - "... %.6f %s?", amount_in_max_a, denom_in_max_a)) { + if (!confirm_bytes(ButtonRequestType_ButtonRequest_Other, "Max Deposit B", + (const uint8_t*)amount_in_max_b_str, + strlen(amount_in_max_b_str))) { osmosis_signAbort(); fsm_sendFailure(FailureType_Failure_ActionCancelled, NULL); layoutHome(); @@ -330,16 +344,16 @@ void fsm_msgOsmosisMsgAck(const OsmosisMsgAck* msg) { } if (!confirm(ButtonRequestType_ButtonRequest_Other, "Confirm Pool ID", - "%lld", msg->lp_add.pool_id)) { + "%" PRIu64, msg->lp_add.pool_id)) { osmosis_signAbort(); fsm_sendFailure(FailureType_Failure_ActionCancelled, NULL); layoutHome(); return; } - if (!confirm(ButtonRequestType_ButtonRequest_Other, - "Confirm Share Out Amount", "Receive %s GAMM-%lld shares?", - outsoamt, msg->lp_add.pool_id)) { + if (!confirm_bytes(ButtonRequestType_ButtonRequest_Other, + "Minimum LP Shares", (const uint8_t*)outsoamt, + strlen(outsoamt))) { osmosis_signAbort(); fsm_sendFailure(FailureType_Failure_ActionCancelled, NULL); layoutHome(); @@ -372,45 +386,45 @@ void fsm_msgOsmosisMsgAck(const OsmosisMsgAck* msg) { return; } - char insoamt[33] = {0}; - uint8_t outsoamt[34] = {0}; - strlcpy(insoamt, msg->lp_remove.share_in_amount, - sizeof(msg->lp_remove.share_in_amount)); - - if (base_to_precision(outsoamt, (uint8_t*)insoamt, sizeof(outsoamt), - strlen(insoamt), OSMOSIS_LP_ASSET_PRECISION) < 0) { + char outsoamt[34] = {0}; + if (base_to_precision( + (uint8_t*)outsoamt, (const uint8_t*)msg->lp_remove.share_in_amount, + sizeof(outsoamt), strlen(msg->lp_remove.share_in_amount), + OSMOSIS_LP_ASSET_PRECISION) < 0) { osmosis_signAbort(); - fsm_sendFailure(FailureType_Failure_Other, NULL); + fsm_sendFailure(FailureType_Failure_SyntaxError, + "Invalid LP share amount"); layoutHome(); return; } - float amount_out_min_b = atof(msg->lp_remove.amount_out_min_b); - const char* denom_out_min_b = msg->lp_remove.denom_out_min_b; - if (!strcmp(msg->lp_remove.denom_out_min_b, "uosmo")) { - amount_out_min_b /= pow(10, OSMOSIS_PRECISION); - denom_out_min_b = "OSMO"; + char amount_out_min_b_str[OSMOSIS_AMOUNT_STR_LEN]; + if (!osmosis_formatAmountOrFail( + amount_out_min_b_str, sizeof(amount_out_min_b_str), + msg->lp_remove.amount_out_min_b, msg->lp_remove.denom_out_min_b)) { + return; } - float amount_out_min_a = atof(msg->lp_remove.amount_out_min_a); - const char* denom_out_min_a = msg->lp_remove.denom_out_min_a; - if (!strcmp(msg->lp_remove.denom_out_min_a, "uosmo")) { - amount_out_min_a /= pow(10, OSMOSIS_PRECISION); - denom_out_min_a = "OSMO"; + char amount_out_min_a_str[OSMOSIS_AMOUNT_STR_LEN]; + if (!osmosis_formatAmountOrFail( + amount_out_min_a_str, sizeof(amount_out_min_a_str), + msg->lp_remove.amount_out_min_a, msg->lp_remove.denom_out_min_a)) { + return; } /** Confirm transaction parameters on-screen */ - if (!confirm(ButtonRequestType_ButtonRequest_Other, "Remove Liquidity", - "Withdraw %.6f %s and...", amount_out_min_b, - denom_out_min_b)) { + if (!confirm_bytes(ButtonRequestType_ButtonRequest_Other, + "Minimum Output A", (const uint8_t*)amount_out_min_a_str, + strlen(amount_out_min_a_str))) { osmosis_signAbort(); fsm_sendFailure(FailureType_Failure_ActionCancelled, NULL); layoutHome(); return; } - if (!confirm(ButtonRequestType_ButtonRequest_Other, "Remove Liquidity", - "... %.6f %s ?", amount_out_min_a, denom_out_min_a)) { + if (!confirm_bytes(ButtonRequestType_ButtonRequest_Other, + "Minimum Output B", (const uint8_t*)amount_out_min_b_str, + strlen(amount_out_min_b_str))) { osmosis_signAbort(); fsm_sendFailure(FailureType_Failure_ActionCancelled, NULL); layoutHome(); @@ -418,16 +432,16 @@ void fsm_msgOsmosisMsgAck(const OsmosisMsgAck* msg) { } if (!confirm(ButtonRequestType_ButtonRequest_Other, "Confirm Pool ID", - "%lld", msg->lp_remove.pool_id)) { + "%" PRIu64, msg->lp_remove.pool_id)) { osmosis_signAbort(); fsm_sendFailure(FailureType_Failure_ActionCancelled, NULL); layoutHome(); return; } - if (!confirm(ButtonRequestType_ButtonRequest_Other, "Pool share amount", - "Redeem %s GAMM-%lld shares?", outsoamt, - msg->lp_remove.pool_id)) { + if (!confirm_bytes(ButtonRequestType_ButtonRequest_Other, + "LP Shares to Redeem", (const uint8_t*)outsoamt, + strlen(outsoamt))) { osmosis_signAbort(); fsm_sendFailure(FailureType_Failure_ActionCancelled, NULL); layoutHome(); @@ -450,7 +464,7 @@ void fsm_msgOsmosisMsgAck(const OsmosisMsgAck* msg) { if (!msg->redelegate.has_delegator_address || !msg->redelegate.has_validator_src_address || !msg->redelegate.has_validator_dst_address || - !msg->redelegate.has_amount) { + !msg->redelegate.has_amount || !msg->redelegate.has_denom) { osmosis_signAbort(); fsm_sendFailure(FailureType_Failure_FirmwareError, _("Message is missing required parameters")); @@ -458,11 +472,24 @@ void fsm_msgOsmosisMsgAck(const OsmosisMsgAck* msg) { return; } - float amount = atof(msg->redelegate.amount) / pow(10, OSMOSIS_PRECISION); + if (strcmp(msg->redelegate.denom, "uosmo") != 0) { + osmosis_signAbort(); + fsm_sendFailure(FailureType_Failure_SyntaxError, + "Only uosmo is supported for Osmosis redelegation"); + layoutHome(); + return; + } + + char amount_str[OSMOSIS_AMOUNT_STR_LEN]; + if (!osmosis_formatAmountOrFail(amount_str, sizeof(amount_str), + msg->redelegate.amount, + msg->redelegate.denom)) { + return; + } /** Confirm transaction parameters on-screen */ - if (!confirm(ButtonRequestType_ButtonRequest_Other, "Redelegate", - "Redelegate %.6f OSMO?", amount)) { + if (!confirm_bytes(ButtonRequestType_ButtonRequest_Other, "Redelegate", + (const uint8_t*)amount_str, strlen(amount_str))) { osmosis_signAbort(); fsm_sendFailure(FailureType_Failure_ActionCancelled, NULL); layoutHome(); @@ -559,24 +586,27 @@ void fsm_msgOsmosisMsgAck(const OsmosisMsgAck* msg) { return; } - float token_in_amount = atof(msg->swap.token_in_amount); - const char* token_in_denom = msg->swap.token_in_denom; - if (!strcmp(msg->swap.token_in_denom, "uosmo")) { - token_in_amount /= pow(10, OSMOSIS_PRECISION); - token_in_denom = "OSMO"; + char token_in_str[OSMOSIS_AMOUNT_STR_LEN]; + if (!osmosis_formatAmountOrFail(token_in_str, sizeof(token_in_str), + msg->swap.token_in_amount, + msg->swap.token_in_denom)) { + return; } - float token_out_min_amount = atof(msg->swap.token_out_min_amount); - const char* token_out_denom = msg->swap.token_out_denom; - if (!strcmp(msg->swap.token_out_denom, "uosmo")) { - token_out_min_amount /= pow(10, OSMOSIS_PRECISION); - token_out_denom = "OSMO"; + char token_out_min_str[OSMOSIS_AMOUNT_STR_LEN]; + if (!osmosis_formatAmountOrFail( + token_out_min_str, sizeof(token_out_min_str), + msg->swap.token_out_min_amount, msg->swap.token_out_denom)) { + return; } - /** Confirm transaction parameters on-screen */ - if (!confirm(ButtonRequestType_ButtonRequest_Other, "Swap", - "Swap %.6f %s for at least %.6f %s?", token_in_amount, - token_in_denom, token_out_min_amount, token_out_denom)) { + // Each signed asset is paged independently so neither the input denom nor + // the minimum output can fall below the OLED's three visible body rows. + if (!confirm_bytes(ButtonRequestType_ButtonRequest_Other, "Swap Input", + (const uint8_t*)token_in_str, strlen(token_in_str)) || + !confirm_bytes(ButtonRequestType_ButtonRequest_Other, "Minimum Output", + (const uint8_t*)token_out_min_str, + strlen(token_out_min_str))) { osmosis_signAbort(); fsm_sendFailure(FailureType_Failure_ActionCancelled, NULL); layoutHome(); @@ -584,7 +614,7 @@ void fsm_msgOsmosisMsgAck(const OsmosisMsgAck* msg) { } if (!confirm(ButtonRequestType_ButtonRequest_Other, "Confirm Pool ID", - "%lld", msg->swap.pool_id)) { + "%" PRIu64, msg->swap.pool_id)) { osmosis_signAbort(); fsm_sendFailure(FailureType_Failure_ActionCancelled, NULL); layoutHome(); @@ -609,7 +639,8 @@ void fsm_msgOsmosisMsgAck(const OsmosisMsgAck* msg) { !msg->ibc_transfer.has_source_port || !msg->ibc_transfer.has_revision_height || !msg->ibc_transfer.has_revision_number || - !msg->ibc_transfer.has_denom) { + !msg->ibc_transfer.has_denom || !msg->ibc_transfer.has_amount || + !msg->ibc_transfer.has_receiver) { osmosis_signAbort(); fsm_sendFailure(FailureType_Failure_FirmwareError, _("Message is missing required parameters")); @@ -617,16 +648,16 @@ void fsm_msgOsmosisMsgAck(const OsmosisMsgAck* msg) { return; } - float amount = atof(msg->ibc_transfer.amount); - const char* denom = msg->ibc_transfer.denom; - if (!strcmp(msg->ibc_transfer.denom, "uosmo")) { - amount /= pow(10, OSMOSIS_PRECISION); - denom = "OSMO"; + char amount_str[OSMOSIS_AMOUNT_STR_LEN]; + if (!osmosis_formatAmountOrFail(amount_str, sizeof(amount_str), + msg->ibc_transfer.amount, + msg->ibc_transfer.denom)) { + return; } /** Confirm transaction parameters on-screen */ - if (!confirm(ButtonRequestType_ButtonRequest_Other, "IBC Transfer", - "Transfer %.6f %s?", amount, denom)) { + if (!confirm_bytes(ButtonRequestType_ButtonRequest_Other, "IBC Transfer", + (const uint8_t*)amount_str, strlen(amount_str))) { osmosis_signAbort(); fsm_sendFailure(FailureType_Failure_ActionCancelled, NULL); layoutHome(); @@ -702,8 +733,8 @@ void fsm_msgOsmosisMsgAck(const OsmosisMsgAck* msg) { } if (sign_tx->has_memo && (strlen(sign_tx->memo) > 0)) { - if (!confirm(ButtonRequestType_ButtonRequest_ConfirmMemo, _("Memo"), "%s", - sign_tx->memo)) { + if (!confirm_bytes(ButtonRequestType_ButtonRequest_ConfirmMemo, _("Memo"), + (const uint8_t*)sign_tx->memo, strlen(sign_tx->memo))) { osmosis_signAbort(); fsm_sendFailure(FailureType_Failure_ActionCancelled, NULL); layoutHome(); @@ -748,4 +779,4 @@ void fsm_msgOsmosisMsgAck(const OsmosisMsgAck* msg) { osmosis_signAbort(); layoutHome(); msg_write(MessageType_MessageType_OsmosisSignedTx, resp); -} \ No newline at end of file +} diff --git a/lib/firmware/fsm_msg_ripple.h b/lib/firmware/fsm_msg_ripple.h index ddd25af35..bbe7fa6cf 100644 --- a/lib/firmware/fsm_msg_ripple.h +++ b/lib/firmware/fsm_msg_ripple.h @@ -109,6 +109,21 @@ void fsm_msgRippleSignTx(RippleSignTx* msg) { } } + if (msg->has_memo && msg->memo[0] != '\0') { + /* Page the COMPLETE memo (72-char ASCII / 40-byte hex pages) like every + * other memo surface. A single unpaged confirm renders only 3 OLED lines, + * silently drops the overflow, and honors embedded newlines — so a memo + * whose visible first line looks benign could carry ~180 signed-but-unseen + * bytes into the Memos field that exchanges and bridges use for deposit + * routing. */ + if (!thorchain_confirm_full_memo("Memo", msg->memo, strlen(msg->memo))) { + memzero(node, sizeof(*node)); + fsm_sendFailure(FailureType_Failure_ActionCancelled, "Signing cancelled"); + layoutHome(); + return; + } + } + if (!confirm(ButtonRequestType_ButtonRequest_SignTx, "Transaction", "Really send %s, with a transaction fee of %s?", amount_string, fee_string)) { diff --git a/lib/firmware/fsm_msg_tendermint.h b/lib/firmware/fsm_msg_tendermint.h index 42a031e38..320acc55d 100644 --- a/lib/firmware/fsm_msg_tendermint.h +++ b/lib/firmware/fsm_msg_tendermint.h @@ -94,7 +94,7 @@ void fsm_msgTendermintSignTx(const TendermintSignTx* msg) { RESP_INIT(TendermintMsgRequest); if (!tendermint_signTxInit(node, (void*)msg, sizeof(TendermintSignTx), - msg->denom)) { + msg->denom, TENDERMINT_SIGNING_GENERIC)) { tendermint_signAbort(); memzero(node, sizeof(*node)); fsm_sendFailure(FailureType_Failure_FirmwareError, @@ -110,7 +110,20 @@ void fsm_msgTendermintSignTx(const TendermintSignTx* msg) { void fsm_msgTendermintMsgAck(const TendermintMsgAck* msg) { // Confirm transaction basics - CHECK_PARAM(tendermint_signingIsInited(), "Signing not in progress"); + CHECK_PARAM(tendermint_signingIsInited(TENDERMINT_SIGNING_GENERIC), + "Tendermint signing not in progress"); + const TendermintSignTx* sign_tx = + (const TendermintSignTx*)tendermint_getSignTx(); + if (!msg->has_chain_name || !msg->has_denom || + !msg->has_message_type_prefix || + !tendermint_signingConfigMatches(msg->chain_name, msg->denom, + msg->message_type_prefix)) { + tendermint_signAbort(); + fsm_sendFailure(FailureType_Failure_SyntaxError, + "Tendermint ACK does not match signing session"); + layoutHome(); + return; + } if (!msg->has_send || !msg->send.has_to_address || !msg->send.has_amount) { tendermint_signAbort(); // 8 + ^14 + 13 + 1 = 36 @@ -124,23 +137,28 @@ void fsm_msgTendermintMsgAck(const TendermintMsgAck* msg) { } const CoinType* coin = fsm_getCoin(true, msg->chain_name); - if (!coin || !coin->has_coin_shortcut || !coin->has_decimals) { + if (!coin) { + tendermint_signAbort(); + layoutHome(); return; } - const TendermintSignTx* sign_tx = (TendermintSignTx*)tendermint_getSignTx(); - switch (msg->send.address_type) { case OutputAddressType_TRANSFER: default: { - char amount_str[32]; - char suffix[sizeof(coin->coin_shortcut) + - 1]; // sizeof(coin->coin_shortcut) includes space for the - // terminator - strlcpy(suffix, " ", sizeof(suffix)); - strlcat(suffix, coin->coin_shortcut, sizeof(suffix)); - bn_format_uint64(msg->send.amount, NULL, suffix, coin->decimals, 0, false, - amount_str, sizeof(amount_str)); + /* The host-supplied denomination is part of the signed Amino JSON. Do + * not relabel or rescale it using unrelated coin metadata. */ + char amount_str[48]; + const int amount_len = + snprintf(amount_str, sizeof(amount_str), "%" PRIu64 " %s", + msg->send.amount, msg->denom); + if (amount_len <= 0 || (size_t)amount_len >= sizeof(amount_str)) { + tendermint_signAbort(); + fsm_sendFailure(FailureType_Failure_SyntaxError, + "Invalid Tendermint amount display"); + layoutHome(); + return; + } if (!confirm_transaction_output( ButtonRequestType_ButtonRequest_ConfirmOutput, amount_str, msg->send.to_address)) { @@ -170,8 +188,29 @@ void fsm_msgTendermintMsgAck(const TendermintMsgAck* msg) { return; } - if (sign_tx->has_memo && !confirm(ButtonRequestType_ButtonRequest_ConfirmMemo, - _("Memo"), "%s", sign_tx->memo)) { + if (sign_tx->has_memo && + !confirm_bytes(ButtonRequestType_ButtonRequest_ConfirmMemo, _("Memo"), + (const uint8_t*)sign_tx->memo, strlen(sign_tx->memo))) { + tendermint_signAbort(); + fsm_sendFailure(FailureType_Failure_ActionCancelled, NULL); + layoutHome(); + return; + } + + /* Review the exact session values that bind the signed JSON. These fields + * are host supplied, so a friendly chain label must never substitute for + * the denomination or message-type prefix actually hashed. */ + if (!confirm_bytes(ButtonRequestType_ButtonRequest_Other, "Chain ID", + (const uint8_t*)sign_tx->chain_id, + strlen(sign_tx->chain_id)) || + !confirm_bytes(ButtonRequestType_ButtonRequest_Other, "Chain Name", + (const uint8_t*)sign_tx->chain_name, + strlen(sign_tx->chain_name)) || + !confirm_bytes(ButtonRequestType_ButtonRequest_Other, "Denomination", + (const uint8_t*)sign_tx->denom, strlen(sign_tx->denom)) || + !confirm_bytes(ButtonRequestType_ButtonRequest_Other, "Message Type", + (const uint8_t*)sign_tx->message_type_prefix, + strlen(sign_tx->message_type_prefix))) { tendermint_signAbort(); fsm_sendFailure(FailureType_Failure_ActionCancelled, NULL); layoutHome(); @@ -189,10 +228,8 @@ void fsm_msgTendermintMsgAck(const TendermintMsgAck* msg) { } if (!confirm(ButtonRequestType_ButtonRequest_SignTx, node_str, - "Sign %s transaction on %s? " - "It includes a fee of %" PRIu32 " %s and %" PRIu32 " gas.", - msg->chain_name, sign_tx->chain_id, sign_tx->fee_amount, - msg->denom, sign_tx->gas)) { + "Sign transaction? Fee: %" PRIu32 " %s. Gas: %" PRIu32 ".", + sign_tx->fee_amount, sign_tx->denom, sign_tx->gas)) { tendermint_signAbort(); fsm_sendFailure(FailureType_Failure_ActionCancelled, NULL); layoutHome(); diff --git a/lib/firmware/fsm_msg_thorchain.h b/lib/firmware/fsm_msg_thorchain.h index 801af7872..25912a383 100644 --- a/lib/firmware/fsm_msg_thorchain.h +++ b/lib/firmware/fsm_msg_thorchain.h @@ -141,12 +141,31 @@ void fsm_msgThorchainMsgAck(const ThorchainMsgAck* msg) { const ThorchainSignTx* sign_tx = thorchain_getThorchainSignTx(); if (msg->has_send) { + const char* coin_denom = + (msg->send.has_denom && msg->send.denom[0]) ? msg->send.denom : "rune"; + + // Validate before any display so untrusted strings never reach the UI. + if (!thorchain_isValidDenom(coin_denom)) { + thorchain_signAbort(); + fsm_sendFailure(FailureType_Failure_SyntaxError, "Invalid denom"); + layoutHome(); + return; + } + switch (msg->send.address_type) { case OutputAddressType_TRANSFER: default: { + // amount_str only needs to hold the numeric part (no denom suffix). + // Denom is confirmed on a separate screen so no truncation is possible. char amount_str[32]; - bn_format_uint64(msg->send.amount, NULL, " RUNE", 8, 0, false, - amount_str, sizeof(amount_str)); + if (!bn_format_uint64(msg->send.amount, NULL, NULL, 8, 0, false, + amount_str, sizeof(amount_str))) { + thorchain_signAbort(); + fsm_sendFailure(FailureType_Failure_FirmwareError, + _("Failed to format amount")); + layoutHome(); + return; + } if (!confirm_transaction_output( ButtonRequestType_ButtonRequest_ConfirmOutput, amount_str, msg->send.to_address)) { @@ -155,12 +174,20 @@ void fsm_msgThorchainMsgAck(const ThorchainMsgAck* msg) { layoutHome(); return; } + // Confirm the asset denom on its own screen. + if (!confirm(ButtonRequestType_ButtonRequest_ConfirmOutput, "Asset", + "%s", coin_denom)) { + thorchain_signAbort(); + fsm_sendFailure(FailureType_Failure_ActionCancelled, NULL); + layoutHome(); + return; + } break; } } - if (!thorchain_signTxUpdateMsgSend(msg->send.amount, - msg->send.to_address)) { + if (!thorchain_signTxUpdateMsgSend(msg->send.amount, msg->send.to_address, + coin_denom)) { thorchain_signAbort(); fsm_sendFailure(FailureType_Failure_SyntaxError, "Failed to include send message in transaction"); @@ -169,8 +196,21 @@ void fsm_msgThorchainMsgAck(const ThorchainMsgAck* msg) { } } else if (msg->has_deposit) { - char amount_str[32]; - char asset_str[21]; + // Validate before any display so untrusted strings never reach the UI + // or the sign bytes. + if (!thorchain_isValidAsset(msg->deposit.asset) || + !thorchain_isValidSigner(msg->deposit.signer)) { + thorchain_signAbort(); + fsm_sendFailure(FailureType_Failure_SyntaxError, + "Invalid deposit asset or signer"); + layoutHome(); + return; + } + // Long-form assets (e.g. + // ETH.USDT-0XDAC17F958D2EE523A2206206994597C13D831EC7) are ~50 chars; + // amount_str must fit amount + asset suffix or bn_format zeroes it out. + char amount_str[96]; + char asset_str[64]; asset_str[0] = ' '; strlcpy(&(asset_str[1]), msg->deposit.asset, sizeof(asset_str) - 1); bn_format_uint64(msg->deposit.amount, NULL, asset_str, 8, 0, false, @@ -185,17 +225,17 @@ void fsm_msgThorchainMsgAck(const ThorchainMsgAck* msg) { } if (msg->deposit.has_memo) { - // See if we can parse the memo - if (!thorchain_parseConfirmMemo(msg->deposit.memo, - sizeof(msg->deposit.memo))) { - // Memo not recognizable, ask to confirm it - if (!confirm(ButtonRequestType_ButtonRequest_ConfirmMemo, _("Memo"), - "%s", msg->deposit.memo)) { - thorchain_signAbort(); - fsm_sendFailure(FailureType_Failure_ActionCancelled, NULL); - layoutHome(); - return; - } + size_t memo_len = strnlen(msg->deposit.memo, sizeof(msg->deposit.memo)); + // Page the complete raw memo as the sole, authoritative disclosure. No + // structured pre-parse here: its bool return conflates "unrecognized" + // with "user rejected a screen", so a reject could be followed by these + // pages and then signing. The raw pager's own reject aborts. + if (!thorchain_confirm_full_memo(_("Memo"), msg->deposit.memo, + memo_len)) { + thorchain_signAbort(); + fsm_sendFailure(FailureType_Failure_ActionCancelled, NULL); + layoutHome(); + return; } } @@ -215,17 +255,14 @@ void fsm_msgThorchainMsgAck(const ThorchainMsgAck* msg) { } if (sign_tx->has_memo && !msg->deposit.has_memo) { - // See if we can parse the tx memo. This memo ignored if deposit msg has - // memo - if (!thorchain_parseConfirmMemo(sign_tx->memo, sizeof(sign_tx->memo))) { - // Memo not recognizable, ask to confirm it - if (!confirm(ButtonRequestType_ButtonRequest_ConfirmMemo, _("Memo"), "%s", - sign_tx->memo)) { - thorchain_signAbort(); - fsm_sendFailure(FailureType_Failure_ActionCancelled, NULL); - layoutHome(); - return; - } + // Ignored if the deposit msg has a memo. Page the full raw memo as the sole + // gate (see the deposit path above for why there is no structured pass). + size_t memo_len = strnlen(sign_tx->memo, sizeof(sign_tx->memo)); + if (!thorchain_confirm_full_memo(_("Memo"), sign_tx->memo, memo_len)) { + thorchain_signAbort(); + fsm_sendFailure(FailureType_Failure_ActionCancelled, NULL); + layoutHome(); + return; } } @@ -240,7 +277,7 @@ void fsm_msgThorchainMsgAck(const ThorchainMsgAck* msg) { } if (!confirm(ButtonRequestType_ButtonRequest_SignTx, node_str, - "Sign this RUNE transaction on %s? " + "Sign this THORChain transaction on %s? " "Additional network fees apply.", sign_tx->chain_id)) { thorchain_signAbort(); diff --git a/lib/firmware/mayachain.c b/lib/firmware/mayachain.c index a4613ad11..b48f48d8b 100644 --- a/lib/firmware/mayachain.c +++ b/lib/firmware/mayachain.c @@ -29,8 +29,17 @@ #include "trezor/crypto/segwit_addr.h" #include +#include #include +bool mayachain_isValidDenom(const char* denom) { + return tendermint_isValidDenom(denom); +} + +bool mayachain_isValidAsset(const char* asset) { + return tendermint_isValidAsset(asset); +} + static CONFIDENTIAL HDNode node; static SHA256_CTX ctx; static bool initialized; @@ -38,6 +47,10 @@ static uint32_t msgs_remaining; static MayachainSignTx msg; static bool testnet; +bool mayachain_isValidSigner(const char* signer) { + return tendermint_isValidSigner(signer, testnet ? "smaya" : "maya"); +} + const MayachainSignTx* mayachain_getMayachainSignTx(void) { return &msg; } bool mayachain_signTxInit(const HDNode* _node, const MayachainSignTx* _msg) { @@ -119,16 +132,27 @@ bool mayachain_signTxUpdateMsgSend(const uint64_t amount, return false; } + // Default to "cacao" for backward compatibility; validate all non-default + // denoms. Defended here too (not just by the FSM caller) so this signing + // path is safe even if called directly or reused elsewhere later. + const char* coin_denom = (denom && denom[0]) ? denom : "cacao"; + if (!mayachain_isValidDenom(coin_denom)) { + return false; + } + bool success = true; const char* const prelude = "{\"type\":\"mayachain/MsgSend\",\"value\":{"; sha256_Update(&ctx, (uint8_t*)prelude, strlen(prelude)); - // 21 + ^20 + 11 + ^69 + 3 = ^124 - success &= tendermint_snprintf(&ctx, buffer, sizeof(buffer), - "\"amount\":[{\"amount\":\"%" PRIu64 - "\",\"denom\":\"%s\"}]", - amount, denom); + // Write amount prefix: 21 + ^20 = ^41 + success &= tendermint_snprintf( + &ctx, buffer, sizeof(buffer), + "\"amount\":[{\"amount\":\"%" PRIu64 "\",\"denom\":\"", amount); + // Use escaping as defense-in-depth; valid denoms have no escapable chars + tendermint_sha256UpdateEscaped(&ctx, coin_denom, strlen(coin_denom)); + // Close coins array: 3 bytes + sha256_Update(&ctx, (uint8_t*)"\"}]", 3); // 17 + 45 + 1 = 63 success &= tendermint_snprintf(&ctx, buffer, sizeof(buffer), @@ -145,6 +169,13 @@ bool mayachain_signTxUpdateMsgSend(const uint64_t amount, bool mayachain_signTxUpdateMsgDeposit(const MayachainMsgDeposit* depmsg) { char buffer[64 + 1]; + // Defended here too (not just by the FSM caller) so this signing path is + // safe even if called directly or reused elsewhere later. + if (!mayachain_isValidAsset(depmsg->asset) || + !mayachain_isValidSigner(depmsg->signer)) { + return false; + } + bool success = true; const char* const prelude = "{\"type\":\"mayachain/MsgDeposit\",\"value\":{"; @@ -155,9 +186,11 @@ bool mayachain_signTxUpdateMsgDeposit(const MayachainMsgDeposit* depmsg) { "\"coins\":[{\"amount\":\"%" PRIu64 "\"", depmsg->amount); - // 10 + ^20 + 3 = ^33 - success &= tendermint_snprintf(&ctx, buffer, sizeof(buffer), - ",\"asset\":\"%s\"}]", depmsg->asset); + // Use escaping as defense-in-depth; valid assets have no escapable chars + const char* const asset_prefix = ",\"asset\":\""; + sha256_Update(&ctx, (uint8_t*)asset_prefix, strlen(asset_prefix)); + tendermint_sha256UpdateEscaped(&ctx, depmsg->asset, strlen(depmsg->asset)); + sha256_Update(&ctx, (uint8_t*)"\"}]", 3); // const char* const memo_prefix = ",\"memo\":\""; @@ -205,98 +238,120 @@ bool mayachain_parseConfirmMemo(const char* swapStr, size_t size) { Input: swapStr is candidate mayachain data size is the size of swapStr (<= 256) Memos should be of the form: - transaction:chain.ticker-id:destination:limit + transaction:chain.ticker-id:destination:limit:affiliate:fee_bps ^^^^^^^^^^^^^^----------asset - So, swap USDT to dest address 0x41e55..., limit 420 - SWAP:ETH.USDT-0xdac17f958d2ee523a2206206994597c13d831ec7:0x41e5560054824ea6b0732e656e3ad64e20e94e45:420 + So, swap USDT to dest address 0x41e55..., limit 420, affiliate "kk" + skimming 75 basis points: + SWAP:ETH.USDT-0xdac17f958d2ee523a2206206994597c13d831ec7:0x41e5560054824ea6b0732e656e3ad64e20e94e45:420:kk:75 Swap transactions can be indicated by "SWAP" or "s" or "=" + + Fields are split on ':' PRESERVING empty fields so a blank field (e.g. + an empty limit in "=:ETH.ETH:0xdest::kk:75") can never shift a later + field (e.g. the affiliate) into an earlier display slot. */ - char* parseTokPtrs[7] = {NULL, NULL, NULL, NULL, - NULL, NULL, NULL}; // we can parse up to 7 tokens - char* tok; - char memoBuf[256]; - uint16_t ctr; + char* fields[8] = {NULL, NULL, NULL, NULL, NULL, NULL, NULL, NULL}; + /* Memos are documented/accepted up to 256 bytes; memoBuf reserves one + * extra byte so a full 256-byte memo still leaves a guaranteed NUL + * terminator, instead of the copy silently dropping its last byte. */ + enum { MEMO_MAX = 256 }; + char memoBuf[MEMO_MAX + 1]; + size_t nfields, i; + char *chain, *asset; // check if memo data is recognized - if (size > sizeof(memoBuf)) return false; + if (size > MEMO_MAX) return false; memzero(memoBuf, sizeof(memoBuf)); - strlcpy(memoBuf, swapStr, size); - memoBuf[255] = '\0'; // ensure null termination - tok = strtok(memoBuf, ":"); - - // get transaction and asset - for (ctr = 0; ctr < 3; ctr++) { - if (tok != NULL) { - parseTokPtrs[ctr] = tok; - tok = strtok(NULL, ":."); - } else { - break; + /* size is a byte count, not necessarily including a NUL: the BTC + * OP_RETURN caller passes raw memo bytes with no terminator. strlcpy + * would copy only size-1 bytes and silently drop the memo's last + * character (turning an affiliate fee of "75" bps into "7"). Copy the + * bytes exactly (size <= MEMO_MAX < sizeof(memoBuf), so this never + * overflows and always leaves at least one zeroed terminator byte); + * the zeroed buffer provides termination. */ + memcpy(memoBuf, swapStr, size); + + // Split on ':', keeping empty fields + nfields = 0; + fields[nfields++] = memoBuf; + for (i = 0; memoBuf[i] != '\0' && nfields < 8; i++) { + if (memoBuf[i] == ':') { + memoBuf[i] = '\0'; + fields[nfields++] = &memoBuf[i + 1]; } } - if (ctr != 3) { - // Must have three tokens at this point: transaction, chain, asset. If - // not, just confirm data + if (nfields < 2) { + // Must have at least transaction and chain.asset. If not, just confirm + // data + return false; + } + + // Split chain.asset at the first '.' + chain = fields[1]; + asset = strchr(chain, '.'); + if (asset == NULL) { + // No chain.asset pair; not recognizable mayachain data, just confirm data return false; } + *asset = '\0'; + asset++; // Check for swap - if (strncmp(parseTokPtrs[0], "SWAP", 4) == 0 || *parseTokPtrs[0] == 's' || - *parseTokPtrs[0] == '=') { + if (strncmp(fields[0], "SWAP", 4) == 0 || *fields[0] == 's' || + *fields[0] == '=') { // This is a swap, set up destination and limit - // This is the dest, may be blank which means swap to self - parseTokPtrs[3] = "self"; - parseTokPtrs[4] = "none"; - if (tok != NULL) { - if ((uint32_t)(tok - (parseTokPtrs[2] + strlen(parseTokPtrs[2]))) == 1) { - // has dest address - parseTokPtrs[3] = tok; - tok = strtok(NULL, ":"); - } - if (tok != NULL) { - // has limit - parseTokPtrs[4] = tok; - } - } + // The dest may be blank which means swap to self + const char* dest = + (nfields > 2 && fields[2][0] != '\0') ? fields[2] : "self"; + const char* limit = + (nfields > 3 && fields[3][0] != '\0') ? fields[3] : "none"; + const char* affiliate = + (nfields > 4 && fields[4][0] != '\0') ? fields[4] : NULL; + const char* fee_bps = + (nfields > 5 && fields[5][0] != '\0') ? fields[5] : "unspecified"; if (!confirm(ButtonRequestType_ButtonRequest_ConfirmOutput, - "Mayachain swap", "Confirm swap asset %s\n on chain %s", - parseTokPtrs[2], parseTokPtrs[1])) { + "Mayachain swap", "Confirm swap asset %s\n on chain %s", asset, + chain)) { return false; } if (!confirm(ButtonRequestType_ButtonRequest_ConfirmOutput, - "Mayachain swap", "Confirm to %s", parseTokPtrs[3])) { + "Mayachain swap", "Confirm to %s", dest)) { return false; } if (!confirm(ButtonRequestType_ButtonRequest_ConfirmOutput, - "Mayachain swap", "Confirm limit %s", parseTokPtrs[4])) { + "Mayachain swap", "Confirm limit %s", limit)) { return false; } + // Never hide the affiliate fee skim from the user + if (affiliate != NULL) { + if (!confirm(ButtonRequestType_ButtonRequest_ConfirmOutput, + "Mayachain swap", "Affiliate fee %s bps to %s", fee_bps, + affiliate)) { + return false; + } + } return true; } // Check for add liquidity - else if (strncmp(parseTokPtrs[0], "ADD", 3) == 0 || *parseTokPtrs[0] == 'a' || - *parseTokPtrs[0] == '+') { - if (tok != NULL) { - // add liquidity pool address - parseTokPtrs[3] = tok; - } + else if (strncmp(fields[0], "ADD", 3) == 0 || *fields[0] == 'a' || + *fields[0] == '+') { + // add liquidity pool address (optional) + const char* pool = (nfields > 2 && fields[2][0] != '\0') ? fields[2] : NULL; if (!confirm(ButtonRequestType_ButtonRequest_ConfirmOutput, "Mayachain add liquidity", - "Confirm add asset %s\n on chain %s pool", parseTokPtrs[2], - parseTokPtrs[1])) { + "Confirm add asset %s\n on chain %s pool", asset, chain)) { return false; } - if (tok != NULL) { + if (pool != NULL) { if (!confirm(ButtonRequestType_ButtonRequest_ConfirmOutput, - "Mayachain add liquidity", "Confirm to %s", - parseTokPtrs[3])) { + "Mayachain add liquidity", "Confirm to %s", pool)) { return false; } } @@ -304,22 +359,38 @@ bool mayachain_parseConfirmMemo(const char* swapStr, size_t size) { } // Check for withdraw liquidity - else if (strncmp(parseTokPtrs[0], "WITHDRAW", 8) == 0 || - strncmp(parseTokPtrs[0], "wd", 2) == 0 || *parseTokPtrs[0] == '-') { - if (tok != NULL) { - // add liquidity pool address - parseTokPtrs[3] = tok; - } else { + else if (strncmp(fields[0], "WITHDRAW", 8) == 0 || + strncmp(fields[0], "wd", 2) == 0 || *fields[0] == '-') { + if (nfields < 3 || fields[2][0] == '\0') { return false; // malformed memo } + /* WD:POOL:BPS[:ASSET] — refuse only genuinely-unknown structure (>4 + * fields), mirroring thorchain.c. */ + if (nfields > 4) { + return false; + } - float percent = (float)(atoi(parseTokPtrs[3])) / 100; + /* BPS rendered with integer math: snprintf is the integer-only sniprintf + * on the device, so no float formats. Negative BPS is a malformed memo. */ + int bps = atoi(fields[2]); + if (bps < 0) { + return false; + } if (!confirm(ButtonRequestType_ButtonRequest_ConfirmOutput, "Mayachain withdraw liquidity", - "Confirm withdraw %3.2f%% of asset %s on chain %s", percent, - parseTokPtrs[2], parseTokPtrs[1])) { + "Confirm withdraw %d.%02d%% of asset %s on chain %s", + bps / 100, bps % 100, asset, chain)) { return false; } + /* Field 4 selects an ASYMMETRIC (single-sided) withdrawal payout asset — + * it directs money and must never sign unseen (see thorchain.c). */ + if (nfields > 3 && fields[3][0] != '\0') { + if (!confirm(ButtonRequestType_ButtonRequest_ConfirmOutput, + "Mayachain withdraw liquidity", + "Withdraw single-sided as %s", fields[3])) { + return false; + } + } return true; } else { diff --git a/lib/firmware/osmosis.c b/lib/firmware/osmosis.c index 6d0657257..0348c6d15 100644 --- a/lib/firmware/osmosis.c +++ b/lib/firmware/osmosis.c @@ -96,7 +96,52 @@ bool osmosis_signTxInit(const HDNode* _node, const OsmosisSignTx* _msg) { return success; } -bool osmosis_signTxUpdateMsgSend(const char* amount, const char* to_address) { +static bool osmosis_isCanonicalAmount(const char* value) { + if (!value) return false; + const size_t len = strlen(value); + if (len == 0 || len > OSMOSIS_MAX_AMOUNT_DIGITS || + (len > 1 && value[0] == '0')) { + return false; + } + for (size_t i = 0; i < len; i++) { + if (value[i] < '0' || value[i] > '9') return false; + } + return true; +} + +static bool osmosis_isCanonicalUint64(const char* value) { + if (!osmosis_isCanonicalAmount(value)) return false; + + uint64_t parsed = 0; + for (size_t i = 0; value[i]; i++) { + const uint8_t digit = (uint8_t)(value[i] - '0'); + if (parsed > (UINT64_MAX - digit) / 10) return false; + parsed = parsed * 10 + digit; + } + return true; +} + +static bool osmosis_isValidDenom(const char* denom) { + if (!denom) return false; + const size_t len = strlen(denom); + if (len == 0 || len > OSMOSIS_MAX_DENOM_LEN) return false; + + // Cosmos/Osmosis denominations are printable identifiers, not arbitrary + // JSON. This includes native, IBC and factory-style paths while excluding + // whitespace, quotes, backslashes and control bytes. + for (size_t i = 0; i < len; i++) { + const char c = denom[i]; + if (!((c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z') || + (c >= '0' && c <= '9') || c == '/' || c == ':' || c == '.' || + c == '_' || c == '-')) { + return false; + } + } + return true; +} + +bool osmosis_signTxUpdateMsgSend(const char* amount, const char* to_address, + const char* denom) { const char mainnetp[] = "osmo"; const char testnetp[] = "tosmo"; const char* pfix; @@ -105,7 +150,8 @@ bool osmosis_signTxUpdateMsgSend(const char* amount, const char* to_address) { size_t decoded_len; char hrp[45] = {0}; uint8_t decoded[38] = {0}; - if (!bech32_decode(hrp, decoded, &decoded_len, to_address)) { + if (!osmosis_isCanonicalUint64(amount) || !osmosis_isValidDenom(denom) || + !bech32_decode(hrp, decoded, &decoded_len, to_address)) { return false; } @@ -125,10 +171,16 @@ bool osmosis_signTxUpdateMsgSend(const char* amount, const char* to_address) { const char* const prelude = "{\"type\":\"cosmos-sdk/MsgSend\",\"value\":{"; sha256_Update(&ctx, (uint8_t*)prelude, strlen(prelude)); - // 21 + ^20 + 19 = ^60 - success &= tendermint_snprintf( - &ctx, buffer, sizeof(buffer), - "\"amount\":[{\"amount\":\"%s\",\"denom\":\"uosmo\"}]", amount); + // IBC and factory denoms may exceed the fixed 64-byte scratch buffer. + // These values are canonical and JSON-safe, so hash the field in segments. + static const char amount_prefix[] = "\"amount\":[{\"amount\":\""; + static const char denom_prefix[] = "\",\"denom\":\""; + static const char coin_suffix[] = "\"}]"; + sha256_Update(&ctx, (const uint8_t*)amount_prefix, sizeof(amount_prefix) - 1); + sha256_Update(&ctx, (const uint8_t*)amount, strlen(amount)); + sha256_Update(&ctx, (const uint8_t*)denom_prefix, sizeof(denom_prefix) - 1); + sha256_Update(&ctx, (const uint8_t*)denom, strlen(denom)); + sha256_Update(&ctx, (const uint8_t*)coin_suffix, sizeof(coin_suffix) - 1); // 17 + 45 + 1 = 63 success &= tendermint_snprintf(&ctx, buffer, sizeof(buffer), @@ -618,6 +670,42 @@ bool osmosis_signTxFinalize(uint8_t* public_key, uint8_t* signature) { NULL) == 0; } +/* + * Cosmos amounts arrive as integer base-unit strings. These screens used to + * render them with atof() + "%.6f", which rounds anything past ~7 significant + * digits — on the very screen the user approves — and linked newlib's floating + * point engine into a ROM budget with no room for it. bn_format_uint64 places + * the decimal point in integer math, the same way the Hive and Ethereum + * confirm screens do. + */ +bool osmosis_formatAmount(char* out, size_t out_len, const char* value, + const char* denom) { + if (!out || out_len == 0) return false; + out[0] = '\0'; + if (!osmosis_isCanonicalAmount(value) || !osmosis_isValidDenom(denom) || + (strcmp(denom, "uosmo") == 0 && !osmosis_isCanonicalUint64(value))) { + return false; + } + + int written; + if (strcmp(denom, "uosmo") == 0) { + char scaled[OSMOSIS_MAX_AMOUNT_DIGITS + 2]; + if (base_to_precision((uint8_t*)scaled, (const uint8_t*)value, + sizeof(scaled), strlen(value), + OSMOSIS_PRECISION) < 0) { + return false; + } + written = snprintf(out, out_len, "%s OSMO", scaled); + } else { + written = snprintf(out, out_len, "%s %s", value, denom); + } + if (written < 0 || (size_t)written >= out_len) { + out[0] = '\0'; + return false; + } + return true; +} + bool osmosis_signingIsInited(void) { return initialized; } bool osmosis_signingIsFinished(void) { return msgs_remaining == 0; } diff --git a/lib/firmware/ripple.c b/lib/firmware/ripple.c index eba787ee8..a78106769 100644 --- a/lib/firmware/ripple.c +++ b/lib/firmware/ripple.c @@ -223,6 +223,25 @@ bool ripple_serialize(uint8_t** buf, const uint8_t* end, const RippleSignTx* tx, if (tx->payment.has_destination) ripple_serializeAddress(&ok, buf, end, &RFM_destination, tx->payment.destination); + // Memos array (ARRAY type=15 key=9) comes last per XRPL canonical ordering. + // Layout: 0xF9 [Memos start] 0xEA [Memo object start] + // 0x7D [MemoData VL] + // 0xE1 [object end] 0xF1 [array end] + if (tx->has_memo && tx->memo[0] != '\0') { + size_t memo_len = strlen(tx->memo); + append_u8(&ok, buf, end, 0xF9); // STArray[9] = Memos + append_u8(&ok, buf, end, 0xEA); // STObject[10] = Memo + append_u8(&ok, buf, end, 0x7D); // VL[13] = MemoData + ripple_serializeVarint(&ok, buf, end, (int)memo_len); + if (ok && *buf + memo_len <= end) { + memcpy(*buf, tx->memo, memo_len); + *buf += memo_len; + } else { + ok = false; + } + append_u8(&ok, buf, end, 0xE1); // end STObject + append_u8(&ok, buf, end, 0xF1); // end STArray + } return ok; } diff --git a/lib/firmware/signtx_tendermint.c b/lib/firmware/signtx_tendermint.c index 0f849d64b..92491f877 100644 --- a/lib/firmware/signtx_tendermint.c +++ b/lib/firmware/signtx_tendermint.c @@ -36,19 +36,19 @@ static CONFIDENTIAL HDNode node; static SHA256_CTX ctx; static bool has_message; static bool initialized; +static TendermintSigningType signing_type; static uint32_t msgs_remaining; static TendermintSignTx tmsg; const void* tendermint_getSignTx(void) { return (void*)&tmsg; } bool tendermint_signTxInit(const HDNode* _node, const void* _msg, - const size_t msgsize, const char* denom) { - initialized = true; - msgs_remaining = ((TendermintSignTx*)_msg)->msg_count; - has_message = false; - - memzero(&node, sizeof(node)); - memcpy(&node, _node, sizeof(node)); + const size_t msgsize, const char* denom, + TendermintSigningType type) { + tendermint_signAbort(); + if (!_node || !_msg || !denom || + (type != TENDERMINT_SIGNING_COSMOS && type != TENDERMINT_SIGNING_GENERIC)) + return false; /* _msg is expected to be of type TendermintSignTx, CosmosSignTx or @@ -65,6 +65,11 @@ bool tendermint_signTxInit(const HDNode* _node, const void* _msg, return false; } + const TendermintSignTx* common = (const TendermintSignTx*)_msg; + if (!common->has_msg_count || common->msg_count == 0) return false; + + msgs_remaining = common->msg_count; + memcpy(&node, _node, sizeof(node)); memcpy((void*)&tmsg, _msg, msgsize); bool success = true; @@ -103,13 +108,24 @@ bool tendermint_signTxInit(const HDNode* _node, const void* _msg, // 10 sha256_Update(&ctx, (uint8_t*)"\",\"msgs\":[", 10); - return success; + if (!success) { + tendermint_signAbort(); + return false; + } + initialized = true; + signing_type = type; + return true; +} + +static bool tendermint_canUpdate(void) { + return initialized && msgs_remaining > 0; } bool tendermint_signTxUpdateMsgSend(const uint64_t amount, const char* to_address, const char* chainstr, const char* denom, const char* msgTypePrefix) { + if (!tendermint_canUpdate()) return false; char buffer[128]; size_t decoded_len; char hrp[45]; @@ -163,8 +179,10 @@ bool tendermint_signTxUpdateMsgSend(const uint64_t amount, success &= tendermint_snprintf(&ctx, buffer, sizeof(buffer), "%s\"}}", to_address); - has_message = true; - msgs_remaining--; + if (success) { + has_message = true; + msgs_remaining--; + } return success; } @@ -173,6 +191,7 @@ bool tendermint_signTxUpdateMsgDelegate(const uint64_t amount, const char* validator_address, const char* chainstr, const char* denom, const char* msgTypePrefix) { + if (!tendermint_canUpdate()) return false; char buffer[128]; size_t decoded_len; char hrp[45]; @@ -226,8 +245,10 @@ bool tendermint_signTxUpdateMsgDelegate(const uint64_t amount, success &= tendermint_snprintf(&ctx, buffer, sizeof(buffer), "%s\"}}", validator_address); - has_message = true; - msgs_remaining--; + if (success) { + has_message = true; + msgs_remaining--; + } return success; } bool tendermint_signTxUpdateMsgUndelegate(const uint64_t amount, @@ -236,6 +257,7 @@ bool tendermint_signTxUpdateMsgUndelegate(const uint64_t amount, const char* chainstr, const char* denom, const char* msgTypePrefix) { + if (!tendermint_canUpdate()) return false; char buffer[128]; size_t decoded_len; char hrp[45]; @@ -289,8 +311,10 @@ bool tendermint_signTxUpdateMsgUndelegate(const uint64_t amount, success &= tendermint_snprintf(&ctx, buffer, sizeof(buffer), "%s\"}}", validator_address); - has_message = true; - msgs_remaining--; + if (success) { + has_message = true; + msgs_remaining--; + } return success; } @@ -298,6 +322,7 @@ bool tendermint_signTxUpdateMsgRedelegate( const uint64_t amount, const char* delegator_address, const char* validator_src_address, const char* validator_dst_address, const char* chainstr, const char* denom, const char* msgTypePrefix) { + if (!tendermint_canUpdate()) return false; char buffer[128]; size_t decoded_len; char hrp[45]; @@ -359,8 +384,10 @@ bool tendermint_signTxUpdateMsgRedelegate( success &= tendermint_snprintf(&ctx, buffer, sizeof(buffer), "%s\"}}", validator_src_address); - has_message = true; - msgs_remaining--; + if (success) { + has_message = true; + msgs_remaining--; + } return success; } @@ -369,6 +396,7 @@ bool tendermint_signTxUpdateMsgRewards(const uint64_t* amount, const char* validator_address, const char* chainstr, const char* denom, const char* msgTypePrefix) { + if (!tendermint_canUpdate()) return false; char buffer[128]; size_t decoded_len; char hrp[45]; @@ -425,8 +453,10 @@ bool tendermint_signTxUpdateMsgRewards(const uint64_t* amount, success &= tendermint_snprintf(&ctx, buffer, sizeof(buffer), "%s\"}}", validator_address); - has_message = true; - msgs_remaining--; + if (success) { + has_message = true; + msgs_remaining--; + } return success; } @@ -435,6 +465,7 @@ bool tendermint_signTxUpdateMsgIBCTransfer( const char* source_channel, const char* source_port, const char* revision_number, const char* revision_height, const char* chainstr, const char* denom, const char* msgTypePrefix) { + if (!tendermint_canUpdate()) return false; char buffer[128]; size_t decoded_len; char hrp[45]; @@ -505,12 +536,15 @@ bool tendermint_signTxUpdateMsgIBCTransfer( "\",\"denom\":\"%s\"}}}", amount, denom); - has_message = true; - msgs_remaining--; + if (success) { + has_message = true; + msgs_remaining--; + } return success; } bool tendermint_signTxFinalize(uint8_t* public_key, uint8_t* signature) { + if (!initialized || msgs_remaining != 0 || !has_message) return false; char buffer[128]; // 14 + ^20 + 2 = ^36 @@ -528,14 +562,28 @@ bool tendermint_signTxFinalize(uint8_t* public_key, uint8_t* signature) { NULL) == 0; } -bool tendermint_signingIsInited(void) { return initialized; } +bool tendermint_signingIsInited(TendermintSigningType type) { + return initialized && signing_type == type; +} + +bool tendermint_signingConfigMatches(const char* chain_name, const char* denom, + const char* message_type_prefix) { + return tendermint_signingIsInited(TENDERMINT_SIGNING_GENERIC) && chain_name && + denom && message_type_prefix && + strcmp(chain_name, tmsg.chain_name) == 0 && + strcmp(denom, tmsg.denom) == 0 && + strcmp(message_type_prefix, tmsg.message_type_prefix) == 0; +} -bool tendermint_signingIsFinished(void) { return msgs_remaining == 0; } +bool tendermint_signingIsFinished(void) { + return initialized && msgs_remaining == 0; +} void tendermint_signAbort(void) { initialized = false; + signing_type = TENDERMINT_SIGNING_NONE; has_message = false; msgs_remaining = 0; memzero(&tmsg, sizeof(tmsg)); memzero(&node, sizeof(node)); -} \ No newline at end of file +} diff --git a/lib/firmware/tendermint.c b/lib/firmware/tendermint.c index 60d50a281..5d10bb23c 100644 --- a/lib/firmware/tendermint.c +++ b/lib/firmware/tendermint.c @@ -6,6 +6,7 @@ #include #include +#include static int convert_bits(uint8_t* out, size_t* outlen, int outbits, const uint8_t* in, size_t inlen, int inbits, int pad) { @@ -64,6 +65,47 @@ bool tendermint_getAddress(const HDNode* node, const char* prefix, BECH32_ENCODING_BECH32) == 1; } +// Allow lowercase alpha, digits, and the punctuation used in Cosmos-style +// asset identifiers (e.g. "eth.eth", "btc/btc", cross-chain synthetic +// prefixes). Rejects anything that needs JSON escaping (backslash, quote). +bool tendermint_isValidDenom(const char* denom) { + if (!denom || !denom[0]) return false; + for (size_t i = 0; denom[i]; i++) { + char c = denom[i]; + if (!((c >= 'a' && c <= 'z') || (c >= '0' && c <= '9') || c == '.' || + c == '/' || c == '-')) { + return false; + } + } + return true; +} + +// Deposit assets share the denom grammar but are conventionally uppercase +// (e.g. ETH.USDT-0XDAC1...); allow both cases, digits, and . / - only. +bool tendermint_isValidAsset(const char* asset) { + if (!asset || !asset[0]) return false; + for (size_t i = 0; asset[i]; i++) { + char c = asset[i]; + if (!((c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z') || + (c >= '0' && c <= '9') || c == '.' || c == '/' || c == '-')) { + return false; + } + } + return true; +} + +// Deposit signer is host-supplied; require a valid bech32 address with the +// expected HRP before it is displayed or signed. +bool tendermint_isValidSigner(const char* signer, const char* hrp) { + size_t decoded_len; + char decoded_hrp[45]; + uint8_t decoded[38]; + if (!signer || !bech32_decode(decoded_hrp, decoded, &decoded_len, signer)) { + return false; + } + return 0 == strcmp(decoded_hrp, hrp); +} + void tendermint_sha256UpdateEscaped(SHA256_CTX* ctx, const char* s, size_t len) { for (size_t i = 0; i != len; i++) { diff --git a/lib/firmware/thorchain.c b/lib/firmware/thorchain.c index 92b075d4f..01ee82d69 100644 --- a/lib/firmware/thorchain.c +++ b/lib/firmware/thorchain.c @@ -20,6 +20,7 @@ #include "keepkey/firmware/thorchain.h" #include "keepkey/board/confirm_sm.h" #include "keepkey/board/util.h" +#include "keepkey/firmware/app_confirm.h" #include "keepkey/firmware/home_sm.h" #include "keepkey/firmware/storage.h" #include "keepkey/firmware/tendermint.h" @@ -29,8 +30,17 @@ #include "trezor/crypto/segwit_addr.h" #include +#include #include +bool thorchain_isValidDenom(const char* denom) { + return tendermint_isValidDenom(denom); +} + +bool thorchain_isValidAsset(const char* asset) { + return tendermint_isValidAsset(asset); +} + static CONFIDENTIAL HDNode node; static SHA256_CTX ctx; static bool initialized; @@ -38,6 +48,10 @@ static uint32_t msgs_remaining; static ThorchainSignTx msg; static bool testnet; +bool thorchain_isValidSigner(const char* signer) { + return tendermint_isValidSigner(signer, testnet ? "tthor" : "thor"); +} + const ThorchainSignTx* thorchain_getThorchainSignTx(void) { return &msg; } bool thorchain_signTxInit(const HDNode* _node, const ThorchainSignTx* _msg) { @@ -95,7 +109,7 @@ bool thorchain_signTxInit(const HDNode* _node, const ThorchainSignTx* _msg) { } bool thorchain_signTxUpdateMsgSend(const uint64_t amount, - const char* to_address) { + const char* to_address, const char* denom) { const char mainnetp[] = "thor"; const char testnetp[] = "tthor"; const char* pfix; @@ -119,15 +133,26 @@ bool thorchain_signTxUpdateMsgSend(const uint64_t amount, return false; } + // Default to "rune" for backward compatibility; validate all non-default + // denoms + const char* coin_denom = (denom && denom[0]) ? denom : "rune"; + if (!thorchain_isValidDenom(coin_denom)) { + return false; + } + bool success = true; const char* const prelude = "{\"type\":\"thorchain/MsgSend\",\"value\":{"; sha256_Update(&ctx, (uint8_t*)prelude, strlen(prelude)); - // 21 + ^20 + 19 = ^60 + // Write amount prefix: 21 + ^20 = ^41 success &= tendermint_snprintf( &ctx, buffer, sizeof(buffer), - "\"amount\":[{\"amount\":\"%" PRIu64 "\",\"denom\":\"rune\"}]", amount); + "\"amount\":[{\"amount\":\"%" PRIu64 "\",\"denom\":\"", amount); + // Use escaping as defense-in-depth; valid denoms have no escapable chars + tendermint_sha256UpdateEscaped(&ctx, coin_denom, strlen(coin_denom)); + // Close coins array: 3 bytes + sha256_Update(&ctx, (uint8_t*)"\"}]", 3); // 17 + 45 + 1 = 63 success &= tendermint_snprintf(&ctx, buffer, sizeof(buffer), @@ -144,6 +169,13 @@ bool thorchain_signTxUpdateMsgSend(const uint64_t amount, bool thorchain_signTxUpdateMsgDeposit(const ThorchainMsgDeposit* depmsg) { char buffer[64 + 1]; + // Defended here too (not just by the FSM caller) so this signing path is + // safe even if called directly or reused elsewhere later. + if (!thorchain_isValidAsset(depmsg->asset) || + !thorchain_isValidSigner(depmsg->signer)) { + return false; + } + bool success = true; const char* const prelude = "{\"type\":\"thorchain/MsgDeposit\",\"value\":{"; @@ -154,9 +186,11 @@ bool thorchain_signTxUpdateMsgDeposit(const ThorchainMsgDeposit* depmsg) { "\"coins\":[{\"amount\":\"%" PRIu64 "\"", depmsg->amount); - // 10 + ^20 + 3 = ^33 - success &= tendermint_snprintf(&ctx, buffer, sizeof(buffer), - ",\"asset\":\"%s\"}]", depmsg->asset); + // Use escaping as defense-in-depth; valid assets have no escapable chars + const char* const asset_prefix = ",\"asset\":\""; + sha256_Update(&ctx, (uint8_t*)asset_prefix, strlen(asset_prefix)); + tendermint_sha256UpdateEscaped(&ctx, depmsg->asset, strlen(depmsg->asset)); + sha256_Update(&ctx, (uint8_t*)"\"}]", 3); // const char* const memo_prefix = ",\"memo\":\""; @@ -199,103 +233,206 @@ void thorchain_signAbort(void) { memzero(&node, sizeof(node)); } +/* Page the COMPLETE raw memo so nothing is truncated behind confirm()'s body + * budget. THORChain memos are ASCII; a non-printable byte gets a hex page so + * even a malformed memo is fully disclosed rather than hidden. Shared with the + * MAYA path (mayachain memos use the same grammar) and the native signing + * handlers, which page this as the authoritative disclosure after any + * best-effort structured summary. */ +bool thorchain_confirm_full_memo(const char* title, const char* memo, + size_t len) { + return confirm_bytes(ButtonRequestType_ButtonRequest_ConfirmOutput, title, + (const uint8_t*)memo, len); +} + bool thorchain_parseConfirmMemo(const char* swapStr, size_t size) { /* Input: swapStr is candidate thorchain data size is the size of swapStr (<= 256) Memos should be of the form: - transaction:chain.ticker-id:destination:limit + transaction:chain.ticker-id:destination:limit:affiliate:fee_bps ^^^^^^^^^^^^^^----------asset - So, swap USDT to dest address 0x41e55..., limit 420 - SWAP:ETH.USDT-0xdac17f958d2ee523a2206206994597c13d831ec7:0x41e5560054824ea6b0732e656e3ad64e20e94e45:420 + So, swap USDT to dest address 0x41e55..., limit 420, affiliate "kk" + skimming 75 basis points: + SWAP:ETH.USDT-0xdac17f958d2ee523a2206206994597c13d831ec7:0x41e5560054824ea6b0732e656e3ad64e20e94e45:420:kk:75 Swap transactions can be indicated by "SWAP" or "s" or "=" + + Fields are split on ':' PRESERVING empty fields so a blank field (e.g. + an empty limit in "=:ETH.ETH:0xdest::kk:75") can never shift a later + field (e.g. the affiliate) into an earlier display slot. */ - char* parseTokPtrs[7] = {NULL, NULL, NULL, NULL, - NULL, NULL, NULL}; // we can parse up to 7 tokens - char* tok; - char memoBuf[256]; - uint16_t ctr; + /* Up to 9 fields for a DEX-aggregator swap + * (SWAP:ASSET:DEST:LIM:AFFILIATE:FEE:AGGREGATOR:FINALTOKEN:MINOUT); the 10th + * slot lets us detect (and reject) a memo with more fields than any known + * grammar rather than silently merging the tail into a displayed field. */ + char* fields[10] = {NULL, NULL, NULL, NULL, NULL, + NULL, NULL, NULL, NULL, NULL}; + /* Memos are documented/accepted up to 256 bytes; memoBuf reserves one + * extra byte so a full 256-byte memo still leaves a guaranteed NUL + * terminator, instead of the copy silently dropping its last byte. */ + enum { MEMO_MAX = 256 }; + char memoBuf[MEMO_MAX + 1]; + size_t nfields, i; + char *chain, *asset; // check if memo data is recognized - if (size > sizeof(memoBuf)) return false; + if (size > MEMO_MAX) return false; memzero(memoBuf, sizeof(memoBuf)); - strlcpy(memoBuf, swapStr, size); - memoBuf[255] = '\0'; // ensure null termination - tok = strtok(memoBuf, ":"); - - // get transaction and asset - for (ctr = 0; ctr < 3; ctr++) { - if (tok != NULL) { - parseTokPtrs[ctr] = tok; - tok = strtok(NULL, ":."); - } else { - break; + /* size is a byte count, not necessarily including a NUL: the BTC + * OP_RETURN caller passes raw memo bytes with no terminator. strlcpy + * would copy only size-1 bytes and silently drop the memo's last + * character (turning an affiliate fee of "75" bps into "7"). Copy the + * bytes exactly (size <= MEMO_MAX < sizeof(memoBuf), so this never + * overflows and always leaves at least one zeroed terminator byte); + * the zeroed buffer provides termination. */ + memcpy(memoBuf, swapStr, size); + + // Split on ':', keeping empty fields + nfields = 0; + fields[nfields++] = memoBuf; + for (i = 0; memoBuf[i] != '\0' && nfields < 10; i++) { + if (memoBuf[i] == ':') { + memoBuf[i] = '\0'; + fields[nfields++] = &memoBuf[i + 1]; } } - if (ctr != 3) { - // Must have three tokens at this point: transaction, chain, asset. If - // not, just confirm data + if (nfields < 2) { + // Must have at least transaction and chain.asset. If not, just confirm + // data + return false; + } + + // Split chain.asset at the first '.' + chain = fields[1]; + asset = strchr(chain, '.'); + if (asset == NULL) { + // No chain.asset pair; not recognizable thorchain data, just confirm data return false; } + *asset = '\0'; + asset++; // Check for swap - if (strncmp(parseTokPtrs[0], "SWAP", 4) == 0 || *parseTokPtrs[0] == 's' || - *parseTokPtrs[0] == '=') { - // This is a swap, set up destination and limit - // This is the dest, may be blank which means swap to self - parseTokPtrs[3] = "self"; - parseTokPtrs[4] = "none"; - if (tok != NULL) { - if ((uint32_t)(tok - (parseTokPtrs[2] + strlen(parseTokPtrs[2]))) == 1) { - // has dest address - parseTokPtrs[3] = tok; - tok = strtok(NULL, ":"); - } - if (tok != NULL) { - // has limit - parseTokPtrs[4] = tok; + if (strncmp(fields[0], "SWAP", 4) == 0 || *fields[0] == 's' || + *fields[0] == '=') { + /* Aggregator outbound memo: field 8 is MinAmountOut|OUTBOUND_MEMO, and + * everything after '|' is forwarded to the outbound contract. That suffix + * can itself contain ':' which our ':'-split would scatter (or overflow + * past field 9), so a single confirm could truncate it. When a '|' is + * present, skip structured field display and page the COMPLETE raw memo so + * every signed byte is shown. */ + if (memchr(swapStr, '|', size) != NULL) { + if (!confirm(ButtonRequestType_ButtonRequest_ConfirmOutput, + "Thorchain swap", "Confirm swap asset %s\n on chain %s", + asset, chain)) { + return false; } + return thorchain_confirm_full_memo("Swap memo", swapStr, size); + } + // This is a swap, set up destination and limit + // The dest may be blank which means swap to self + const char* dest = + (nfields > 2 && fields[2][0] != '\0') ? fields[2] : "self"; + const char* limit = + (nfields > 3 && fields[3][0] != '\0') ? fields[3] : "none"; + const char* affiliate = + (nfields > 4 && fields[4][0] != '\0') ? fields[4] : NULL; + const char* fee_bps = + (nfields > 5 && fields[5][0] != '\0') ? fields[5] : "unspecified"; + /* DEX-aggregator swap-out fields — all router-executed, so all displayed. + */ + const char* agg_addr = + (nfields > 6 && fields[6][0] != '\0') ? fields[6] : NULL; + const char* final_token = + (nfields > 7 && fields[7][0] != '\0') ? fields[7] : NULL; + const char* min_out = + (nfields > 8 && fields[8][0] != '\0') ? fields[8] : NULL; + + /* Refuse only genuinely-unknown structure — more fields than any THORChain + * swap grammar defines (>9), which we cannot label and must not hide. */ + if (nfields > 9) { + return false; } if (!confirm(ButtonRequestType_ButtonRequest_ConfirmOutput, - "Thorchain swap", "Confirm swap asset %s\n on chain %s", - parseTokPtrs[2], parseTokPtrs[1])) { + "Thorchain swap", "Confirm swap asset %s\n on chain %s", asset, + chain)) { return false; } if (!confirm(ButtonRequestType_ButtonRequest_ConfirmOutput, - "Thorchain swap", "Confirm to %s", parseTokPtrs[3])) { + "Thorchain swap", "Confirm to %s", dest)) { return false; } if (!confirm(ButtonRequestType_ButtonRequest_ConfirmOutput, - "Thorchain swap", "Confirm limit %s", parseTokPtrs[4])) { + "Thorchain swap", "Confirm limit %s", limit)) { + return false; + } + // Never hide the affiliate fee skim from the user + if (affiliate != NULL) { + if (!confirm(ButtonRequestType_ButtonRequest_ConfirmOutput, + "Thorchain swap", "Affiliate fee %s bps to %s", fee_bps, + affiliate)) { + return false; + } + } + // DEX-aggregator routing: the router forwards the output through this + // aggregator to a final token, so both must be visible. + if (agg_addr != NULL && + !confirm(ButtonRequestType_ButtonRequest_ConfirmOutput, + "Thorchain swap", "DEX aggregator %s", agg_addr)) { + return false; + } + if (final_token != NULL && + !confirm(ButtonRequestType_ButtonRequest_ConfirmOutput, + "Thorchain swap", "Final token %s", final_token)) { + return false; + } + if (min_out != NULL && + !confirm(ButtonRequestType_ButtonRequest_ConfirmOutput, + "Thorchain swap", "Min output %s", min_out)) { return false; } return true; } // Check for add liquidity - else if (strncmp(parseTokPtrs[0], "ADD", 3) == 0 || *parseTokPtrs[0] == 'a' || - *parseTokPtrs[0] == '+') { - if (tok != NULL) { - // add liquidity pool address - parseTokPtrs[3] = tok; + else if (strncmp(fields[0], "ADD", 3) == 0 || *fields[0] == 'a' || + *fields[0] == '+') { + // ADD:POOL:PAIREDADDR:AFFILIATE:FEE — paired address, affiliate and fee are + // all optional but router-executed, so none may be hidden. + const char* pool = (nfields > 2 && fields[2][0] != '\0') ? fields[2] : NULL; + const char* affiliate = + (nfields > 3 && fields[3][0] != '\0') ? fields[3] : NULL; + const char* fee_bps = + (nfields > 4 && fields[4][0] != '\0') ? fields[4] : "unspecified"; + + /* ADD grammar defines at most 5 fields; more than that is structure we + * cannot label and must not sign hidden, so refuse it. */ + if (nfields > 5) { + return false; } if (!confirm(ButtonRequestType_ButtonRequest_ConfirmOutput, "Thorchain add liquidity", - "Confirm add asset %s\n on chain %s pool", parseTokPtrs[2], - parseTokPtrs[1])) { + "Confirm add asset %s\n on chain %s pool", asset, chain)) { return false; } - if (tok != NULL) { + if (pool != NULL) { if (!confirm(ButtonRequestType_ButtonRequest_ConfirmOutput, - "Thorchain add liquidity", "Confirm to %s", - parseTokPtrs[3])) { + "Thorchain add liquidity", "Confirm to %s", pool)) { + return false; + } + } + // Never hide the affiliate fee skim from the user + if (affiliate != NULL) { + if (!confirm(ButtonRequestType_ButtonRequest_ConfirmOutput, + "Thorchain add liquidity", "Affiliate fee %s bps to %s", + fee_bps, affiliate)) { return false; } } @@ -303,26 +440,45 @@ bool thorchain_parseConfirmMemo(const char* swapStr, size_t size) { } // Check for withdraw liquidity - else if (strncmp(parseTokPtrs[0], "WITHDRAW", 8) == 0 || - strncmp(parseTokPtrs[0], "wd", 2) == 0 || *parseTokPtrs[0] == '-') { - if (tok != NULL) { - // add liquidity pool address - parseTokPtrs[3] = tok; - } else { + else if (strncmp(fields[0], "WITHDRAW", 8) == 0 || + strncmp(fields[0], "wd", 2) == 0 || *fields[0] == '-') { + if (nfields < 3 || fields[2][0] == '\0') { return false; // malformed memo } + /* WD:POOL:BPS[:ASSET] — refuse only genuinely-unknown structure (>4 + * fields), mirroring the SWAP (>9) and ADD (>5) caps. */ + if (nfields > 4) { + return false; + } - float percent = (float)(atoi(parseTokPtrs[3])) / 100; + /* BPS rendered with integer math: snprintf is the integer-only sniprintf + * on the device, so no float formats. Negative BPS is a malformed memo. */ + int bps = atoi(fields[2]); + if (bps < 0) { + return false; + } if (!confirm(ButtonRequestType_ButtonRequest_ConfirmOutput, "Thorchain withdraw liquidity", - "Confirm withdraw %3.2f%% of asset %s on chain %s", percent, - parseTokPtrs[2], parseTokPtrs[1])) { + "Confirm withdraw %d.%02d%% of asset %s on chain %s", + bps / 100, bps % 100, asset, chain)) { return false; } + /* Field 4 is the ASYMMETRIC-withdrawal asset selector: WD:POOL:BPS:ASSET + * pays the whole withdrawal out single-sided in ASSET instead of the + * symmetric split. It directs money, so it must never sign unseen — + * otherwise the screens for the asymmetric form are identical to the + * symmetric one. */ + if (nfields > 3 && fields[3][0] != '\0') { + if (!confirm(ButtonRequestType_ButtonRequest_ConfirmOutput, + "Thorchain withdraw liquidity", + "Withdraw single-sided as %s", fields[3])) { + return false; + } + } return true; } else { // Just confirm whatever coin data if no thorchain intention data parsable return false; } -} \ No newline at end of file +} diff --git a/unittests/firmware/CMakeLists.txt b/unittests/firmware/CMakeLists.txt index 49122bac9..624358f59 100644 --- a/unittests/firmware/CMakeLists.txt +++ b/unittests/firmware/CMakeLists.txt @@ -2,18 +2,22 @@ set(sources authenticator.cpp app_confirm.cpp coins.cpp + binance.cpp cosmos.cpp dice.cpp eos.cpp eip712.cpp ethereum.cpp + mayachain.cpp nano.cpp + osmosis.cpp recovery.cpp signed_metadata.cpp ripple.cpp solana.cpp storage.cpp usb_rx.cpp + thorchain.cpp u2f.cpp) # zcash.cpp exercises the Orchard engine (lib/firmware/zcash.c), which is only diff --git a/unittests/firmware/binance.cpp b/unittests/firmware/binance.cpp new file mode 100644 index 000000000..66789e3a7 --- /dev/null +++ b/unittests/firmware/binance.cpp @@ -0,0 +1,83 @@ +extern "C" { +#include "keepkey/transport/interface.h" +#include "keepkey/firmware/binance.h" +} + +#include "gtest/gtest.h" + +#include +#include "trezor/crypto/secp256k1.h" + +static BinanceTransferMsg transfer(const char* denom, int64_t amount) { + BinanceTransferMsg msg = {}; + msg.inputs_count = 1; + msg.outputs_count = 1; + msg.inputs[0].coins_count = 1; + msg.outputs[0].coins_count = 1; + msg.inputs[0].has_address = true; + msg.outputs[0].has_address = true; + strcpy(msg.inputs[0].address, "tbnb1hgm0p7khfk85zpz5v0j8wnej3a90w709zzlffd"); + strcpy(msg.outputs[0].address, "tbnb1ss57e8sa7xnwq030k2ctr775uac9gjzglqhvpy"); + msg.inputs[0].coins[0].has_amount = true; + msg.outputs[0].coins[0].has_amount = true; + msg.inputs[0].coins[0].amount = amount; + msg.outputs[0].coins[0].amount = amount; + msg.inputs[0].coins[0].has_denom = true; + msg.outputs[0].coins[0].has_denom = true; + strcpy(msg.inputs[0].coins[0].denom, denom); + strcpy(msg.outputs[0].coins[0].denom, denom); + return msg; +} + +TEST(Binance, DenomBoundsAndGrammar) { + EXPECT_TRUE(binance_isValidDenom("BNB")); + EXPECT_TRUE(binance_isValidDenom("RUNE-B1A")); + EXPECT_TRUE(binance_isValidDenom("ABCDEFGH-123")); + EXPECT_TRUE(binance_isValidDenom("AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA")); + EXPECT_FALSE(binance_isValidDenom("AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA")); + EXPECT_FALSE(binance_isValidDenom("bnb")); + EXPECT_FALSE(binance_isValidDenom("BNB\"")); + EXPECT_FALSE(binance_isValidDenom("BN B")); + EXPECT_FALSE(binance_isValidDenom("")); +} + +TEST(Binance, TransferValidationFailsClosed) { + BinanceTransferMsg msg = transfer("RUNE-B1A", 1000000000); + EXPECT_TRUE(binance_validateTransfer(&msg)); + + msg = transfer("AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA", 1000000000); + EXPECT_TRUE(binance_validateTransfer(&msg)); + + msg = transfer("BNB", 0); + EXPECT_FALSE(binance_validateTransfer(&msg)); + msg = transfer("BNB", -1); + EXPECT_FALSE(binance_validateTransfer(&msg)); + + msg = transfer("BNB", 1); + msg.outputs[0].coins[0].amount = 2; + EXPECT_FALSE(binance_validateTransfer(&msg)); + + msg = transfer("BNB", 1); + msg.outputs[0].coins[0].has_denom = false; + EXPECT_FALSE(binance_validateTransfer(&msg)); +} + +TEST(Binance, SigningSessionRequiresCanonicalEnvelopeState) { + HDNode node = {}; + node.curve = &secp256k1_info; + BinanceSignTx envelope = {}; + EXPECT_FALSE(binance_signTxInit(&node, &envelope)); + EXPECT_FALSE(binance_signingIsInited()); + + envelope.has_msg_count = true; + envelope.msg_count = 1; + envelope.has_account_number = true; + envelope.has_chain_id = true; + strcpy(envelope.chain_id, "Binance-Chain-Nile"); + envelope.has_sequence = true; + envelope.has_source = true; + EXPECT_TRUE(binance_signTxInit(&node, &envelope)); + EXPECT_TRUE(binance_signingIsInited()); + EXPECT_FALSE(binance_signingIsFinished()); + binance_signAbort(); +} diff --git a/unittests/firmware/cosmos.cpp b/unittests/firmware/cosmos.cpp index d150fda34..96d565285 100644 --- a/unittests/firmware/cosmos.cpp +++ b/unittests/firmware/cosmos.cpp @@ -3,6 +3,7 @@ extern "C" { #include "keepkey/firmware/cosmos.h" #include "keepkey/firmware/signtx_tendermint.h" #include "keepkey/firmware/tendermint.h" +#include "messages-tendermint.pb.h" #include "trezor/crypto/secp256k1.h" } @@ -54,10 +55,14 @@ TEST(Cosmos, CosmosSignTx) { true, 0, // sequence true, 1 // msg_count }; - ASSERT_TRUE(tendermint_signTxInit(&node, &msg, sizeof(CosmosSignTx), "uatom")); - - ASSERT_TRUE(tendermint_signTxUpdateMsgSend(100000, "cosmos18vhdczjut44gpsy804crfhnd5nq003nz0nf20v", "cosmos", "uatom", "cosmos-sdk")); + ASSERT_TRUE(tendermint_signTxInit(&node, &msg, sizeof(CosmosSignTx), "uatom", + TENDERMINT_SIGNING_COSMOS)); + EXPECT_TRUE(tendermint_signingIsInited(TENDERMINT_SIGNING_COSMOS)); + EXPECT_FALSE(tendermint_signingIsInited(TENDERMINT_SIGNING_GENERIC)); + ASSERT_TRUE(tendermint_signTxUpdateMsgSend( + 100000, "cosmos18vhdczjut44gpsy804crfhnd5nq003nz0nf20v", "cosmos", + "uatom", "cosmos-sdk")); uint8_t public_key[33]; uint8_t signature[64]; @@ -72,3 +77,38 @@ TEST(Cosmos, CosmosSignTx) { "\x47\x56\x43\xca\x33\xc7\xad\x2c\x8a\x53\x2b\x39", 64) == 0); } + +TEST(Cosmos, TendermintSessionBindsProtocolAndAssetConfiguration) { + HDNode node = {}; + node.curve = &secp256k1_info; + TendermintSignTx msg = {}; + msg.has_account_number = true; + msg.has_chain_id = true; + strcpy(msg.chain_id, "chain-1"); + msg.has_fee_amount = true; + msg.fee_amount = 1; + msg.has_gas = true; + msg.gas = 1; + msg.has_sequence = true; + msg.has_msg_count = true; + msg.msg_count = 1; + msg.has_chain_name = true; + strcpy(msg.chain_name, "Cosmos"); + msg.has_denom = true; + strcpy(msg.denom, "uatom"); + msg.has_message_type_prefix = true; + strcpy(msg.message_type_prefix, "cosmos-sdk"); + + ASSERT_TRUE(tendermint_signTxInit(&node, &msg, sizeof(msg), msg.denom, + TENDERMINT_SIGNING_GENERIC)); + EXPECT_TRUE(tendermint_signingIsInited(TENDERMINT_SIGNING_GENERIC)); + EXPECT_FALSE(tendermint_signingIsInited(TENDERMINT_SIGNING_COSMOS)); + EXPECT_TRUE(tendermint_signingConfigMatches("Cosmos", "uatom", "cosmos-sdk")); + EXPECT_FALSE( + tendermint_signingConfigMatches("Cosmos", "uosmo", "cosmos-sdk")); + EXPECT_FALSE( + tendermint_signingConfigMatches("Osmosis", "uatom", "cosmos-sdk")); + EXPECT_FALSE( + tendermint_signingConfigMatches("Cosmos", "uatom", "other-prefix")); + tendermint_signAbort(); +} diff --git a/unittests/firmware/eos.cpp b/unittests/firmware/eos.cpp index c15f220e1..b1e61a48e 100644 --- a/unittests/firmware/eos.cpp +++ b/unittests/firmware/eos.cpp @@ -7,6 +7,23 @@ extern "C" { #include +TEST(EOS, UnknownActionsRequireAdvancedMode) { + EXPECT_FALSE(eos_unknownActionPolicyAllows(false)); + EXPECT_TRUE(eos_unknownActionPolicyAllows(true)); +} + +TEST(EOS, NewAccountCannotDowngradeToUnknownAction) { + EosActionCommon common = {}; + common.has_account = true; + common.account = EOS_eosio; + common.has_name = true; + common.name = EOS_NewAccount; + EXPECT_TRUE(eos_isSupportedAction(&common)); + + common.account = 0x1111111111111111ULL; + EXPECT_FALSE(eos_isSupportedAction(&common)); +} + TEST(EOS, FormatNameVec) { struct { uint64_t value; diff --git a/unittests/firmware/mayachain.cpp b/unittests/firmware/mayachain.cpp index 8a319a610..cf17b29c2 100644 --- a/unittests/firmware/mayachain.cpp +++ b/unittests/firmware/mayachain.cpp @@ -7,6 +7,13 @@ extern "C" { #include "gtest/gtest.h" #include +#include + +// confirm() auto-accept driver, defined in thorchain.cpp (same binary). +// kkconfirm_preload(nYes, nNo) queues nYes accepted confirm screens then +// nNo rejected ones; kkconfirm_drain() == 0 proves the exact screen count. +bool kkconfirm_preload(int nYes, int nNo); +int kkconfirm_drain(void); TEST(Mayachain, MayachainGetAddress) { HDNode node = { @@ -56,19 +63,176 @@ TEST(Mayachain, MayachainSignTx) { ASSERT_TRUE(mayachain_signTxInit(&node, &msg)); ASSERT_TRUE(mayachain_signTxUpdateMsgSend( - 100, "maya1g9el7lzjwh9yun2c4jjzhy09j98vkhfxfqkl5k")); + 100, "maya1g9el7lzjwh9yun2c4jjzhy09j98vkhfxfqkl5k", "cacao")); uint8_t public_key[33]; uint8_t signature[64]; ASSERT_TRUE(mayachain_signTxFinalize(public_key, signature)); + // Expected value recomputed independently (python-ecdsa, RFC6979/secp256k1, + // low-s) over the exact sign-doc JSON this fixture produces: + // {"account_number":"6359","chain_id":"mayachain-mainnet-v1","fee": + // {"amount":[{"amount":"3000","denom":"cacao"}],"gas":"200000"},"memo": + // "","msgs":[{"type":"mayachain/MsgSend","value":{"amount":[{"amount": + // "100","denom":"cacao"}],"from_address": + // "maya1ls33ayg26kmltw7jjy55p32ghjna09zp7z4etj","to_address": + // "maya1g9el7lzjwh9yun2c4jjzhy09j98vkhfxfqkl5k"}}],"sequence":"19"} + // The bytes recorded when this file was written never matched: the file + // was not in the unit build (see 28c74a0e) so the vector was never + // validated, and it did not verify against this fixture's key/JSON. EXPECT_TRUE( memcmp(signature, - (uint8_t *)"\x8a\x91\x43\x54\xca\xe7\x45\x30\x0e\xfb\x88\xee\xdd" - "\xac\xc0\xb5\xa3\x3d\x18\xb1\xe6\x54\x26\x70\x8f\x93" - "\x69\x67\xd5\x21\x84\xbb\x6b\x58\x3d\xe3\x21\xd0\x3e" - "\x26\xb2\xd8\x00\x7d\x81\x84\x34\x82\x5a\xfa\xa2\x80" - "\x54\x88\x90\xc6\xec\xf0\x3b\xf5\x33\x0f\x3e\x9a", + (uint8_t *)"\xdf\x2f\x66\x37\x03\x08\x32\xd2\xce\x87\xfe\x47\x8d" + "\xdf\xe6\xd8\x21\xd2\x6b\x03\x8b\x44\xfa\xc8\x98\xe6" + "\xdf\x79\xe3\xfd\x10\x5d\x40\x3f\x05\x0d\x00\xad\xf9" + "\x7d\x3e\xd3\xa7\x3d\xa6\x9b\x19\x74\x0c\x6a\xbc\xf6" + "\x94\x09\x57\x29\xa3\xf0\xc3\x62\xc9\xf0\xfa\x71", 64) == 0); +} + +// Denom validation: only [a-z0-9./\-] is allowed; anything else is rejected +TEST(Mayachain, MayachainDenomValidation) { + EXPECT_TRUE(mayachain_isValidDenom("cacao")); + EXPECT_TRUE(mayachain_isValidDenom("maya")); + EXPECT_TRUE(mayachain_isValidDenom("eth.eth")); + EXPECT_TRUE(mayachain_isValidDenom("btc/btc")); + EXPECT_TRUE(mayachain_isValidDenom("cross-chain")); + + EXPECT_FALSE(mayachain_isValidDenom("")); // empty → caller "cacao" + EXPECT_FALSE(mayachain_isValidDenom("CACAO")); // uppercase rejected + EXPECT_FALSE(mayachain_isValidDenom("cacao\"")); // quote injection + EXPECT_FALSE(mayachain_isValidDenom("cacao\\n")); // backslash injection + EXPECT_FALSE(mayachain_isValidDenom(" cacao")); // leading space + EXPECT_FALSE(mayachain_isValidDenom("ca cao")); // embedded space +} + +// The signer function itself must reject an invalid denom — not merely +// rely on the FSM caller to pre-validate — so it stays safe if reused or +// called directly. Empty denom must still default to "cacao" and succeed. +TEST(Mayachain, MayachainSignTxUpdateMsgSendRejectsInvalidDenom) { + HDNode node = { + 0, + 0, + {0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0}, + {0xb9, 0x9a, 0x39, 0x3a, 0x5a, 0x53, 0x0d, 0x90, 0xef, 0x6e, 0x46, + 0x4e, 0x8e, 0x2f, 0x2b, 0x8b, 0x5c, 0x64, 0xa7, 0x97, 0x29, 0xcd, + 0x60, 0x3b, 0x1f, 0xba, 0x33, 0x81, 0x7d, 0x1a, 0x75, 0xa1}, + {0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0}, + {0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0}, + &secp256k1_info}; + hdnode_fill_public_key(&node); + + const MayachainSignTx msg = { + 5, {0x80000000 | 44, 0x80000000 | 931, 0x80000000, 0, 0}, + true, 6359, + true, "mayachain-mainnet-v1", + true, 3000, + true, 200000, + true, "", + true, 19, + true, 1}; + + ASSERT_TRUE(mayachain_signTxInit(&node, &msg)); + EXPECT_FALSE(mayachain_signTxUpdateMsgSend( + 100, "maya1g9el7lzjwh9yun2c4jjzhy09j98vkhfxfqkl5k", "cacao\"")); + + ASSERT_TRUE(mayachain_signTxInit(&node, &msg)); + EXPECT_TRUE(mayachain_signTxUpdateMsgSend( + 100, "maya1g9el7lzjwh9yun2c4jjzhy09j98vkhfxfqkl5k", "")); +} + +/* ===================================================================== * + * mayachain_parseConfirmMemo — swap-memo clear-signing. + * Mirrors the thorchain.cpp memo tests; see kkconfirm_preload docs there. + * ===================================================================== */ + +static bool parseMayaMemo(const char *memo, size_t size) { + return mayachain_parseConfirmMemo(memo, size); +} +static bool parseMayaMemo(const char *memo) { + return parseMayaMemo(memo, strlen(memo) + 1); +} + +// Classic full-form swap memo = 4 screens (4th is the affiliate fee screen) +TEST(Mayachain, MemoSwapFullFormShowsAffiliate) { + ASSERT_TRUE(kkconfirm_preload(4, 0)); + EXPECT_TRUE(parseMayaMemo( + "SWAP:ETH.USDT-0xdac17f958d2ee523a2206206994597c13d831ec7:" + "0x41e5560054824ea6b0732e656e3ad64e20e94e45:420:kk:75")); + EXPECT_EQ(0, kkconfirm_drain()); +} + +// No '.' in the asset field (no chain.asset pair): raw-memo fallback +TEST(Mayachain, MemoSwapNoChainAssetPair) { + ASSERT_TRUE(kkconfirm_preload(0, 0)); + EXPECT_FALSE(parseMayaMemo("=:e:0xdest:0/1/0:kk:75")); + EXPECT_EQ(0, kkconfirm_drain()); +} + +// Empty limit must NOT shift the affiliate into the limit slot: 4 screens +TEST(Mayachain, MemoSwapEmptyLimitDoesNotShift) { + ASSERT_TRUE(kkconfirm_preload(4, 0)); + EXPECT_TRUE(parseMayaMemo("=:ETH.ETH:0xdest::kk:75")); + EXPECT_EQ(0, kkconfirm_drain()); +} + +// No affiliate: exactly the 3 historical screens +TEST(Mayachain, MemoSwapNoAffiliate) { + ASSERT_TRUE(kkconfirm_preload(3, 0)); + EXPECT_TRUE(parseMayaMemo("SWAP:ETH.ETH:0xdest:420")); + EXPECT_EQ(0, kkconfirm_drain()); +} + +// ADD with a pool address: 2 screens (unchanged behavior) +TEST(Mayachain, MemoAddWithPool) { + ASSERT_TRUE(kkconfirm_preload(2, 0)); + EXPECT_TRUE( + parseMayaMemo("ADD:BTC.BTC:maya1g9el7lzjwh9yun2c4jjzhy09j98vkhfxfqkl5k")); + EXPECT_EQ(0, kkconfirm_drain()); +} + +// WITHDRAW with basis points: 1 screen; without: malformed +TEST(Mayachain, MemoWithdraw) { + ASSERT_TRUE(kkconfirm_preload(1, 0)); + EXPECT_TRUE(parseMayaMemo("WITHDRAW:BTC.BTC:5000")); + EXPECT_FALSE(parseMayaMemo("wd:BTC.BTC")); + EXPECT_EQ(0, kkconfirm_drain()); +} + +// Garbage / oversized memos fall back to raw-memo confirmation +// BTC OP_RETURN passes RAW memo bytes with no NUL and size = byte count. +// Every byte must survive the copy — the historical off-by-one dropped +// the last char (1-char affiliate vanished: 3 screens instead of 4). +TEST(Mayachain, MemoRawBytesNoNulKeepsLastChar) { + ASSERT_TRUE(kkconfirm_preload(4, 0)); + const char raw[] = "=:ETH.ETH:0xdest:420:k"; + EXPECT_TRUE(parseMayaMemo(raw, sizeof(raw) - 1)); /* no NUL counted */ + EXPECT_EQ(0, kkconfirm_drain()); +} + +// A raw memo that fills the internal buffer's entire documented capacity +// (size == 256, the parser's own <=256 contract) must ALSO keep its last +// byte — this is the boundary the copy-length clamp missed. +TEST(Mayachain, MemoExactBufferCapacityKeepsLastChar) { + const std::string prefix = "=:ETH.ETH:0x"; + const std::string suffix = ":420:k"; // 1-char affiliate as the last byte + std::string memo = prefix + std::string(256 - prefix.size() - suffix.size(), + 'd') + + suffix; + ASSERT_EQ(memo.size(), 256u); + + ASSERT_TRUE(kkconfirm_preload(4, 0)); + EXPECT_TRUE(parseMayaMemo(memo.c_str(), memo.size())); /* no NUL counted */ + EXPECT_EQ(0, kkconfirm_drain()); +} + +TEST(Mayachain, MemoGarbageAndOversized) { + ASSERT_TRUE(kkconfirm_preload(0, 0)); + EXPECT_FALSE(parseMayaMemo("hello world")); + EXPECT_FALSE(parseMayaMemo("SWAP:ETH.ETH:0xdest:420", 257)); + EXPECT_EQ(0, kkconfirm_drain()); } \ No newline at end of file diff --git a/unittests/firmware/osmosis.cpp b/unittests/firmware/osmosis.cpp new file mode 100644 index 000000000..b08f129fc --- /dev/null +++ b/unittests/firmware/osmosis.cpp @@ -0,0 +1,158 @@ +extern "C" { +// interface.h first: it is what neutralises the `delete` field in +// messages.pb.h, which is a keyword in C++. +#include "keepkey/transport/interface.h" +#include "keepkey/board/util.h" +#include "keepkey/firmware/app_confirm.h" +#include "keepkey/firmware/osmosis.h" +#include "trezor/crypto/secp256k1.h" +} + +#include "gtest/gtest.h" + +#include + +bool kkconfirm_preload(int nYes, int nNo); +int kkconfirm_drain(void); + +static std::string fmt(const char *value, const char *denom) { + char out[OSMOSIS_AMOUNT_STR_LEN] = {0}; + EXPECT_TRUE(osmosis_formatAmount(out, sizeof(out), value, denom)); + return std::string(out); +} + +TEST(Osmosis, FormatAmountScalesUosmo) { + EXPECT_EQ(fmt("1500000", "uosmo"), "1.500000 OSMO"); + EXPECT_EQ(fmt("1000000", "uosmo"), "1.000000 OSMO"); + EXPECT_EQ(fmt("0", "uosmo"), "0.000000 OSMO"); + // Sub-unit amounts keep every digit rather than collapsing to zero. + EXPECT_EQ(fmt("500", "uosmo"), "0.000500 OSMO"); + EXPECT_EQ(fmt("1", "uosmo"), "0.000001 OSMO"); +} + +/* + * The reason this formatter exists. A float carries ~7 significant decimal + * digits, so the old atof() + "%.6f" path rendered large amounts rounded on + * the screen the user approves — 123456789.123456 OSMO came out as + * 123456792.000000. Integer formatting is exact at any magnitude. + */ +TEST(Osmosis, FormatAmountIsExactBeyondFloatPrecision) { + EXPECT_EQ(fmt("123456789123456", "uosmo"), "123456789.123456 OSMO"); + EXPECT_EQ(fmt("999999999999999", "uosmo"), "999999999.999999 OSMO"); + EXPECT_EQ(fmt("18446744073709551615", "uosmo"), "18446744073709.551615 OSMO"); +} + +TEST(Osmosis, FormatAmountLeavesUnknownDenomsAlone) { + // The device does not know the precision of an arbitrary denom, so the + // base-unit integer is shown verbatim — never scaled by a guess. + EXPECT_EQ(fmt("1500000", "uatom"), "1500000 uatom"); + EXPECT_EQ( + fmt("42", "ibc/27394FB092D2ECCD56123C74F36E4C1F926001CEADA9CA97EA6"), + "42 ibc/27394FB092D2ECCD56123C74F36E4C1F926001CEADA9CA97EA6"); + // "uosmo" must match exactly — a lookalike denom is not OSMO. + EXPECT_EQ(fmt("1500000", "uosmox"), "1500000 uosmox"); +} + +TEST(Osmosis, FormatAmountRejectsNoncanonicalOrOutOfSchemaValues) { + const char *invalid[] = {"", + "01", + "+1", + "-1", + " 1", + "1 ", + "0x1", + "1a", + "18446744073709551616", + "123456789012345678901234567890123"}; + for (const char *value : invalid) { + char out[OSMOSIS_AMOUNT_STR_LEN] = "unchanged"; + EXPECT_FALSE(osmosis_formatAmount(out, sizeof(out), value, "uosmo")); + EXPECT_STREQ(out, ""); + } + + char out[OSMOSIS_AMOUNT_STR_LEN] = {0}; + EXPECT_FALSE(osmosis_formatAmount(out, sizeof(out), "1", "bad denom")); + EXPECT_FALSE(osmosis_formatAmount(out, sizeof(out), "1", "bad\"denom")); + EXPECT_FALSE(osmosis_formatAmount( + out, sizeof(out), "1", + "ibc/12345678901234567890123456789012345678901234567890123456789012345")); + EXPECT_FALSE(osmosis_formatAmount(out, 4, "1", "uosmo")); +} + +TEST(Osmosis, BaseToPrecisionPreservesMaxLpAmountAndCanary) { + struct { + uint8_t out[34]; + uint8_t canary; + } guarded = {{0}, 0xa5}; + const char value[] = "12345678901234567890123456789012"; + + ASSERT_EQ(0, base_to_precision(guarded.out, (const uint8_t *)value, + sizeof(guarded.out), strlen(value), 18)); + EXPECT_STREQ((const char *)guarded.out, "12345678901234.567890123456789012"); + EXPECT_EQ(guarded.canary, 0xa5); +} + +TEST(Osmosis, BaseToPrecisionRejectsTruncationAndNoncanonicalValues) { + uint8_t out[34] = {0}; + const char max_value[] = "12345678901234567890123456789012"; + EXPECT_LT(base_to_precision(out, (const uint8_t *)max_value, sizeof(out) - 1, + strlen(max_value), 18), + 0); + EXPECT_LT(base_to_precision(out, (const uint8_t *)"01", sizeof(out), 2, 18), + 0); + EXPECT_LT(base_to_precision(out, (const uint8_t *)"1x", sizeof(out), 2, 18), + 0); +} + +TEST(Osmosis, MaxSwapAssetsAreRendererPagedCompletely) { + const char denom[] = + "ibc/1234567890123456789012345678901234567890123456789012345678901234"; + static_assert(sizeof(denom) - 1 == OSMOSIS_MAX_DENOM_LEN, + "fixture must exercise the schema maximum"); + char token[OSMOSIS_AMOUNT_STR_LEN] = {0}; + ASSERT_TRUE(osmosis_formatAmount(token, sizeof(token), + "12345678901234567890123456789012", denom)); + + // The old combined sentence required more than the OLED's three rows. Each + // 101-character asset now gets its own measured page, so both signed values + // are fully accepted in exactly two independent confirmations. + ASSERT_TRUE(kkconfirm_preload(2, 0)); + EXPECT_TRUE(confirm_bytes(ButtonRequestType_ButtonRequest_Other, "Swap Input", + (const uint8_t *)token, strlen(token))); + EXPECT_TRUE(confirm_bytes(ButtonRequestType_ButtonRequest_Other, + "Minimum Output", (const uint8_t *)token, + strlen(token))); + EXPECT_EQ(0, kkconfirm_drain()); +} + +TEST(Osmosis, MsgSendSignsCanonicalNonNativeDenomination) { + HDNode node = { + 0, + 0, + {0}, + {0xb9, 0x9a, 0x39, 0x3a, 0x5a, 0x53, 0x0d, 0x90, 0xef, 0x6e, 0x46, + 0x4e, 0x8e, 0x2f, 0x2b, 0x8b, 0x5c, 0x64, 0xa7, 0x97, 0x29, 0xcd, + 0x8b, 0x6c, 0x69, 0x5c, 0x71, 0x72, 0x03, 0x02, 0xf1, 0x76}, + {0}, + {0}, + &secp256k1_info}; + hdnode_fill_public_key(&node); + + OsmosisSignTx msg = {}; + msg.account_number = 0; + msg.has_chain_id = true; + strlcpy(msg.chain_id, "osmosis-1", sizeof(msg.chain_id)); + msg.fee_amount = 800; + msg.gas = 290000; + msg.has_memo = true; + msg.sequence = 0; + msg.msg_count = 1; + ASSERT_TRUE(osmosis_signTxInit(&node, &msg)); + + const char denom[] = + "ibc/1234567890123456789012345678901234567890123456789012345678901234"; + static_assert(sizeof(denom) - 1 == OSMOSIS_MAX_DENOM_LEN, + "fixture must exercise the schema maximum"); + EXPECT_TRUE(osmosis_signTxUpdateMsgSend( + "7", "osmo1rs7fckgznkaxs4sq02pexwjgar43p5wnkx9s92", denom)); +} diff --git a/unittests/firmware/thorchain.cpp b/unittests/firmware/thorchain.cpp index 4fa8faa8f..aa2a338ec 100644 --- a/unittests/firmware/thorchain.cpp +++ b/unittests/firmware/thorchain.cpp @@ -1,12 +1,111 @@ extern "C" { +#include "keepkey/board/messages.h" +#include "keepkey/board/usb.h" #include "keepkey/firmware/coins.h" +#include "keepkey/firmware/app_confirm.h" +#include "keepkey/firmware/ethereum_contracts/thortx.h" +#include "keepkey/firmware/fsm.h" #include "keepkey/firmware/thorchain.h" #include "keepkey/firmware/tendermint.h" +#include "messages-ethereum.pb.h" #include "trezor/crypto/secp256k1.h" + +// From keepkey_board.h, which we can't include here: its shutdown(void) +// declaration clashes with sys/socket.h's shutdown(int, int). +void kk_board_init(void); } #include "gtest/gtest.h" #include +#include +#include + +#include +#include +#include + +/* + * confirm() auto-accept driver for unit tests. + * + * In the emulator/unittest build (always DEBUG_LINK), confirm_helper() + * busy-polls the emulator's UDP "usb" port for tiny messages and returns + * once it has seen a ButtonAck plus a DebugLinkDecision. Each confirm + * screen therefore consumes exactly one ButtonAck + one DebugLinkDecision + * from the socket queue. Preloading exactly N accept pairs before invoking + * the code under test auto-accepts exactly N screens, and + * kkconfirm_drain() == 0 afterwards proves exactly N screens were shown + * (fewer screens leave packets queued; more screens would hang the test). + * + * These helpers have external linkage so mayachain.cpp can share the + * one-time board/usb initialization. + */ + +static bool kkconfirm_sendTiny(uint16_t msgId, const uint8_t* payload, + uint8_t len) { + static int fd = -1; + if (fd < 0) fd = socket(AF_INET, SOCK_DGRAM, IPPROTO_UDP); + if (fd < 0) return false; + + uint8_t frame[64] = {0}; + frame[0] = '?'; + frame[1] = '#'; + frame[2] = '#'; + frame[3] = msgId >> 8; + frame[4] = msgId & 0xff; + frame[8] = len; // bytes 5..7 are the high bits of the big-endian size + if (len) memcpy(&frame[9], payload, len); + + struct sockaddr_in addr; + memset(&addr, 0, sizeof(addr)); + addr.sin_family = AF_INET; + addr.sin_port = htons(11044); // emulator main "usb" port + addr.sin_addr.s_addr = htonl(INADDR_LOOPBACK); + return sendto(fd, frame, sizeof(frame), 0, (struct sockaddr*)&addr, + sizeof(addr)) == (ssize_t)sizeof(frame); +} + +// Queue nYes accepted screens followed by nNo rejected screens. +bool kkconfirm_preload(int nYes, int nNo) { + static bool initialized = false; + if (!initialized) { + kk_board_init(); // canvas + runnable queues for confirm's draw path + fsm_init(); // registers the usb rx callback + message maps + usbInit(""); // binds the emulator UDP ports + initialized = true; + } + + static const uint8_t yes[] = {0x08, 0x01}; // DebugLinkDecision.yes_no + static const uint8_t no[] = {0x08, 0x00}; + for (int i = 0; i < nYes + nNo; i++) { + if (!kkconfirm_sendTiny(MessageType_MessageType_ButtonAck, NULL, 0)) + return false; + const uint8_t* decision = (i < nYes) ? yes : no; + if (!kkconfirm_sendTiny(MessageType_MessageType_DebugLinkDecision, decision, + 2)) + return false; + } + return true; +} + +// Consume and count any tiny messages left in the queue. +int kkconfirm_drain(void) { + uint8_t buf[MSG_TINY_BFR_SZ]; + int n = 0; + for (;;) { + // volatile: 0xFFFF (MSG_TINY_TYPE_ERROR) is outside the MessageType + // enum range, so an unguarded comparison is a tautology the compiler + // may fold away. + volatile uint16_t id = (uint16_t)check_for_tiny_msg(buf); + if (id == MSG_TINY_TYPE_ERROR) break; + n++; + } + return n; +} + +// Vectors computed with the trezor-crypto library directly (see +// unittests/firmware/thorchain.cpp notes). The test file was previously +// absent from CMakeLists.txt so none of these values were ever validated; +// all expected values here are derived from the actual crypto library. TEST(Thorchain, ThorchainGetAddress) { HDNode node = { @@ -24,51 +123,520 @@ TEST(Thorchain, ThorchainGetAddress) { &secp256k1_info}; char addr[46]; ASSERT_TRUE(tendermint_getAddress(&node, "thor", addr)); - EXPECT_EQ(std::string("thor1am058pdux3hyulcmfgj4m3hhrlfn8nzm88u80q"), addr); + EXPECT_EQ(std::string("thor1am058pdux3hyulcmfgj4m3hhrlfn8nzmpq9u6l"), addr); } -TEST(Thorchain, ThorchainSignTx) { - HDNode node = { - 0, - 0, - {0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, - 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0}, - {0x04, 0xde, 0xc0, 0xcc, 0x01, 0x3c, 0xd8, 0xab, 0x70, 0x87, 0xca, - 0x14, 0x96, 0x0b, 0x76, 0x8c, 0x3d, 0x83, 0x45, 0x24, 0x48, 0xaa, - 0x00, 0x64, 0xda, 0xe6, 0xfb, 0x04, 0xb5, 0xd9, 0x34, 0x76}, - {0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, - 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0}, - {0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, - 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0}, - &secp256k1_info}; +// Shared fixtures +static const HDNode kSignNode = { + 0, + 0, + {0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0}, + {0x04, 0xde, 0xc0, 0xcc, 0x01, 0x3c, 0xd8, 0xab, 0x70, 0x87, 0xca, + 0x14, 0x96, 0x0b, 0x76, 0x8c, 0x3d, 0x83, 0x45, 0x24, 0x48, 0xaa, + 0x00, 0x64, 0xda, 0xe6, 0xfb, 0x04, 0xb5, 0xd9, 0x34, 0x76}, + {0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0}, + {0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0}, + &secp256k1_info}; + +static const ThorchainSignTx kSignTx = { + 5, {0x80000000 | 44, 0x80000000 | 931, 0x80000000, 0, 0}, + true, 0, + true, "thorchain", + true, 5000, + true, 200000, + true, "", + true, 0, + true, 1}; + +static const char* kToAddr = "thor18vhdczjut44gpsy804crfhnd5nq003nz0nf20v"; + +// Denom validation: only [a-z0-9./\-] is allowed; anything else is rejected +TEST(Thorchain, ThorchainDenomValidation) { + EXPECT_TRUE(thorchain_isValidDenom("rune")); + EXPECT_TRUE(thorchain_isValidDenom("tcy")); + EXPECT_TRUE(thorchain_isValidDenom("rujira")); + EXPECT_TRUE(thorchain_isValidDenom("eth.eth")); + EXPECT_TRUE(thorchain_isValidDenom("btc/btc")); + EXPECT_TRUE(thorchain_isValidDenom("cross-chain")); + + EXPECT_FALSE(thorchain_isValidDenom("")); // empty → caller uses "rune" + EXPECT_FALSE(thorchain_isValidDenom("RUNE")); // uppercase rejected + EXPECT_FALSE(thorchain_isValidDenom("rune\"")); // quote injection + EXPECT_FALSE(thorchain_isValidDenom("rune\\n")); // backslash injection + EXPECT_FALSE(thorchain_isValidDenom(" rune")); // leading space + EXPECT_FALSE(thorchain_isValidDenom("ru ne")); // embedded space +} + +// Invalid denom must cause thorchain_signTxUpdateMsgSend to return false +TEST(Thorchain, ThorchainSignTxInvalidDenom) { + HDNode node = kSignNode; hdnode_fill_public_key(&node); - const ThorchainSignTx msg = { - 5, {0x80000000 | 44, 0x80000000 | 931, 0x80000000, 0, 0}, // address_n - true, 0, // account_number - true, "thorchain", // chain_id - true, 5000, // fee_amount - true, 200000, // gas - true, "", // memo - true, 0, // sequence - true, 1 // msg_count - }; - ASSERT_TRUE(thorchain_signTxInit(&node, &msg)); + ASSERT_TRUE(thorchain_signTxInit(&node, &kSignTx)); + // Quote-injection attempt must be rejected at the signing layer + EXPECT_FALSE(thorchain_signTxUpdateMsgSend(100000, kToAddr, + "rune\",\"from_address\":\"evil")); + thorchain_signAbort(); +} + +/* ===================================================================== * + * thorchain_parseConfirmMemo — swap-memo clear-signing. + * Screen counts are asserted exactly: kkconfirm_preload(N, 0) accepts N + * screens and kkconfirm_drain() == 0 proves N screens were shown. + * ===================================================================== */ + +static bool parseMemo(const char* memo, size_t size) { + return thorchain_parseConfirmMemo(memo, size); +} +static bool parseMemo(const char* memo) { + return parseMemo(memo, strlen(memo) + 1); +} + +// Classic full-form swap memo: asset + dest + limit + affiliate + fee bps +// = 4 screens (the 4th is the new affiliate fee screen) +TEST(Thorchain, MemoSwapFullFormShowsAffiliate) { + ASSERT_TRUE(kkconfirm_preload(4, 0)); + EXPECT_TRUE( + parseMemo("SWAP:ETH.USDT-0xdac17f958d2ee523a2206206994597c13d831ec7:" + "0x41e5560054824ea6b0732e656e3ad64e20e94e45:420:kk:75")); + EXPECT_EQ(0, kkconfirm_drain()); +} + +// Abbreviated asset with no '.' (no chain.asset pair) is not parseable +// thorchain data: raw-memo fallback +TEST(Thorchain, MemoSwapNoChainAssetPair) { + ASSERT_TRUE(kkconfirm_preload(0, 0)); + EXPECT_FALSE(parseMemo("=:e:0xdest:0/1/0:kk:75")); + EXPECT_EQ(0, kkconfirm_drain()); +} + +// Empty limit field must NOT shift the affiliate into the limit slot: it +// must still take 4 screens (limit "none" + separate affiliate screen). +// The old strtok tokenizer collapsed the empty field and displayed the +// affiliate ("kk") as the limit in 3 screens. +TEST(Thorchain, MemoSwapEmptyLimitDoesNotShift) { + ASSERT_TRUE(kkconfirm_preload(4, 0)); + EXPECT_TRUE(parseMemo("=:ETH.ETH:0xdest::kk:75")); + EXPECT_EQ(0, kkconfirm_drain()); +} + +// No affiliate: exactly the 3 historical screens, no affiliate screen +TEST(Thorchain, MemoSwapNoAffiliate) { + ASSERT_TRUE(kkconfirm_preload(3, 0)); + EXPECT_TRUE(parseMemo("SWAP:ETH.ETH:0xdest:420")); + EXPECT_EQ(0, kkconfirm_drain()); +} + +// Affiliate present but fee absent: affiliate screen still shows (fee "0") +TEST(Thorchain, MemoSwapAffiliateNoFee) { + ASSERT_TRUE(kkconfirm_preload(4, 0)); + EXPECT_TRUE(parseMemo("SWAP:ETH.ETH:0xdest:420:kk")); + EXPECT_EQ(0, kkconfirm_drain()); +} + +// Missing dest and limit: still 3 screens ("self" / "none") +TEST(Thorchain, MemoSwapMinimal) { + ASSERT_TRUE(kkconfirm_preload(3, 0)); + EXPECT_TRUE(parseMemo("SWAP:ETH.ETH")); + EXPECT_EQ(0, kkconfirm_drain()); +} + +// Rejecting a screen aborts the whole confirmation +TEST(Thorchain, MemoSwapRejectPropagates) { + ASSERT_TRUE(kkconfirm_preload(2, 1)); + EXPECT_FALSE(parseMemo("SWAP:ETH.ETH:0xdest:420")); + EXPECT_EQ(0, kkconfirm_drain()); +} + +// ADD with a pool address: 2 screens (unchanged behavior) +TEST(Thorchain, MemoAddWithPool) { + ASSERT_TRUE(kkconfirm_preload(2, 0)); + EXPECT_TRUE( + parseMemo("ADD:BTC.BTC:thor18vhdczjut44gpsy804crfhnd5nq003nz0nf20v")); + EXPECT_EQ(0, kkconfirm_drain()); +} + +// ADD without a pool address: 1 screen (unchanged behavior) +TEST(Thorchain, MemoAddWithoutPool) { + ASSERT_TRUE(kkconfirm_preload(1, 0)); + EXPECT_TRUE(parseMemo("+:BTC.BTC")); + EXPECT_EQ(0, kkconfirm_drain()); +} + +// WITHDRAW with basis points: 1 screen (unchanged behavior) +TEST(Thorchain, MemoWithdraw) { + ASSERT_TRUE(kkconfirm_preload(1, 0)); + EXPECT_TRUE(parseMemo("WITHDRAW:BTC.BTC:5000")); + EXPECT_EQ(0, kkconfirm_drain()); +} + +// WITHDRAW without basis points is malformed (unchanged behavior) +TEST(Thorchain, MemoWithdrawMissingBps) { + ASSERT_TRUE(kkconfirm_preload(0, 0)); + EXPECT_FALSE(parseMemo("wd:BTC.BTC")); + EXPECT_EQ(0, kkconfirm_drain()); +} + +// Garbage memos fall back to raw-memo confirmation +TEST(Thorchain, MemoGarbage) { + ASSERT_TRUE(kkconfirm_preload(0, 0)); + EXPECT_FALSE(parseMemo("hello world")); + EXPECT_FALSE(parseMemo("NOTATHING:ETH.ETH:0xdest")); + EXPECT_FALSE(parseMemo("")); + EXPECT_EQ(0, kkconfirm_drain()); +} + +// BTC OP_RETURN passes RAW memo bytes with no NUL and size = byte count +// (transaction.c). Every byte must survive the copy: dropping the last +// character turns affiliate "kk" into "k" — or a fee of 75 bps into 7. +// This memo's affiliate is 1 char, so the historical off-by-one would +// lose it entirely and show only 3 screens instead of 4. +TEST(Thorchain, MemoRawBytesNoNulKeepsLastChar) { + ASSERT_TRUE(kkconfirm_preload(4, 0)); + const char raw[] = "=:ETH.ETH:0xdest:420:k"; + EXPECT_TRUE(parseMemo(raw, sizeof(raw) - 1)); /* no NUL counted */ + EXPECT_EQ(0, kkconfirm_drain()); +} - ASSERT_TRUE(thorchain_signTxUpdateMsgSend( - 100000, "thor18vhdczjut44gpsy804crfhnd5nq003nz0nf20v")); +// A raw memo that fills the internal buffer's entire documented capacity +// (size == 256, the parser's own <=256 contract) must ALSO keep its last +// byte — this is the boundary the copy-length clamp missed. +TEST(Thorchain, MemoExactBufferCapacityKeepsLastChar) { + const std::string prefix = "=:ETH.ETH:0x"; + const std::string suffix = ":420:k"; // 1-char affiliate as the last byte + std::string memo = + prefix + std::string(256 - prefix.size() - suffix.size(), 'd') + suffix; + ASSERT_EQ(memo.size(), 256u); - uint8_t public_key[33]; - uint8_t signature[64]; + ASSERT_TRUE(kkconfirm_preload(4, 0)); + EXPECT_TRUE(parseMemo(memo.c_str(), memo.size())); /* no NUL counted */ + EXPECT_EQ(0, kkconfirm_drain()); +} + +// Oversized input (> 256) is rejected outright +TEST(Thorchain, MemoOversized) { + ASSERT_TRUE(kkconfirm_preload(0, 0)); + EXPECT_FALSE(parseMemo("SWAP:ETH.ETH:0xdest:420", 257)); + EXPECT_EQ(0, kkconfirm_drain()); +} + +// Symmetric withdraw: pool + basis points on a single screen. +TEST(Thorchain, MemoWithdrawSymmetric) { + ASSERT_TRUE(kkconfirm_preload(1, 0)); + EXPECT_TRUE(parseMemo("WITHDRAW:BTC.BTC:10000")); + EXPECT_EQ(0, kkconfirm_drain()); +} + +// Asymmetric withdraw: the 4th field selects a SINGLE-SIDED payout asset — +// it directs money, so it gets its own screen instead of signing unseen with +// screens identical to the symmetric form. +TEST(Thorchain, MemoWithdrawAsymmetricShowsPayoutAsset) { + ASSERT_TRUE(kkconfirm_preload(2, 0)); + EXPECT_TRUE(parseMemo("-:BTC.BTC:10000:THOR.RUNE")); + EXPECT_EQ(0, kkconfirm_drain()); +} + +// Rejecting the payout-asset screen aborts the withdrawal. +TEST(Thorchain, MemoWithdrawAsymmetricRejectPropagates) { + ASSERT_TRUE(kkconfirm_preload(1, 1)); // approve summary, reject asset + EXPECT_FALSE(parseMemo("wd:BTC.BTC:5000:BTC.BTC")); + EXPECT_EQ(0, kkconfirm_drain()); +} + +// More fields than any withdraw grammar defines cannot be labeled and must +// not be hidden — mirrors the SWAP (>9) and ADD (>5) caps. +TEST(Thorchain, MemoWithdrawTooManyFieldsRejected) { + ASSERT_TRUE(kkconfirm_preload(0, 0)); + EXPECT_FALSE(parseMemo("WITHDRAW:BTC.BTC:10000:THOR.RUNE:extra")); + EXPECT_EQ(0, kkconfirm_drain()); +} + +// DEX-aggregator swap: aggregator addr, final token and min-out are all +// router-executed and must be shown — asset/chain + dest + limit + affiliate + +// aggregator + final + min = 7 screens (none hidden). +TEST(Thorchain, MemoSwapAggregatorShowsAllFields) { + ASSERT_TRUE(kkconfirm_preload(7, 0)); + EXPECT_TRUE(parseMemo( + "SWAP:ETH.ETH:0xdest:420:kk:75:0xaggregator:0xfinaltoken:1000")); + EXPECT_EQ(0, kkconfirm_drain()); +} + +// A '|' outbound-memo suffix (MinAmountOut|OUTBOUND_MEMO) is forwarded to the +// outbound contract and can contain ':' our split would scatter. It must be +// disclosed in full: swap header + the fully-paged raw memo = 2 screens here +// (memo < one page). Nothing falls back to blind-signing. +TEST(Thorchain, MemoSwapPipeOutboundIsFullyPaged) { + ASSERT_TRUE(kkconfirm_preload(2, 0)); + const char memo[] = "=:ETH.ETH:0xdest|OUT:0xfinal:1"; // ':' after the pipe + EXPECT_TRUE(parseMemo(memo, strlen(memo))); // no NUL in the paged bytes + EXPECT_EQ(0, kkconfirm_drain()); +} - ASSERT_TRUE(thorchain_signTxFinalize(public_key, signature)); +// More fields than any swap grammar defines (>9) is structure we cannot label; +// refuse it rather than sign an undisplayed tail. Rejected before any screen. +TEST(Thorchain, MemoSwapTooManyFieldsRejected) { + ASSERT_TRUE(kkconfirm_preload(0, 0)); + EXPECT_FALSE(parseMemo("SWAP:ETH.ETH:a:b:c:d:e:f:g:h")); + EXPECT_EQ(0, kkconfirm_drain()); +} +// ADD:POOL:PAIREDADDR:AFFILIATE:FEE — affiliate + fee must not be hidden: +// add asset + pool + affiliate-fee = 3 screens. +TEST(Thorchain, MemoAddShowsAffiliateAndFee) { + ASSERT_TRUE(kkconfirm_preload(3, 0)); EXPECT_TRUE( - memcmp(signature, - (uint8_t *)"\x41\x99\x66\x30\x08\xef\xea\x75\x93\x56\x35\xe6\x1a" - "\x11\xdf\xa3\x3c\xeb\xeb\x91\xc1\xca\xed\xc6\x0e\x5e" - "\xef\x3c\xa2\xc0\x1f\x83\x48\x08\x36\xe6\x21\x89\x51" - "\x14\x36\x64\x7f\xac\x5a\xbd\xc2\x9f\x54\xae\x3d\x7e" - "\x47\x56\x43\xca\x33\xc7\xad\x2c\x8a\x53\x2b\x39", - 64) == 0); + parseMemo("ADD:BTC.BTC:thor18vhdczjut44gpsy804crfhnd5nq003nz0nf20v" + ":affil:50")); + EXPECT_EQ(0, kkconfirm_drain()); +} + +// ADD with more than its 5 defined fields is refused (no hidden tail). +TEST(Thorchain, MemoAddTooManyFieldsRejected) { + ASSERT_TRUE(kkconfirm_preload(0, 0)); + EXPECT_FALSE(parseMemo("ADD:BTC.BTC:pool:affil:50:extra")); + EXPECT_EQ(0, kkconfirm_drain()); +} + +// The full-memo pager is the authoritative disclosure the native THOR/MAYA +// handlers page after their structured summary. A short ASCII memo is one page. +TEST(Thorchain, FullMemoShortAsciiIsOnePage) { + const char memo[] = "=:ETH.ETH:0xdest:420:kk:75"; + ASSERT_TRUE(kkconfirm_preload(1, 0)); + EXPECT_TRUE(thorchain_confirm_full_memo("Memo", memo, strlen(memo))); + EXPECT_EQ(0, kkconfirm_drain()); +} + +// Page breaks use measured rendered rows, including word-wrap behavior. +TEST(Thorchain, FullMemoLongAsciiPagesAll) { + const char memo[] = + "%%%%%%%%%%%%%%%% %%%%%%%%%%%%%%%% %%%%%%%%%%%%%%%% %%%%%%%%%%%%%%%%"; + ASSERT_TRUE(kkconfirm_preload(2, 0)); + EXPECT_TRUE(thorchain_confirm_full_memo("Memo", memo, strlen(memo))); + EXPECT_EQ(0, kkconfirm_drain()); +} + +// Rejecting any page aborts the whole disclosure (so the handler aborts +// signing). +TEST(Thorchain, FullMemoRejectPropagates) { + const char memo[] = + "%%%%%%%%%%%%%%%% %%%%%%%%%%%%%%%% %%%%%%%%%%%%%%%% %%%%%%%%%%%%%%%%"; + ASSERT_TRUE(kkconfirm_preload(1, 1)); // approve page 1, reject page 2 + EXPECT_FALSE(thorchain_confirm_full_memo("Memo", memo, strlen(memo))); + EXPECT_EQ(0, kkconfirm_drain()); +} + +// Non-printable memo bytes are disclosed in complete renderer-measured hex +// pages, never hidden behind a byte-count summary. +TEST(Thorchain, FullMemoBinaryPagesAsHex) { + char memo[100]; + memset(memo, 0x01, sizeof(memo)); + ASSERT_TRUE(kkconfirm_preload(2, 0)); + EXPECT_TRUE(thorchain_confirm_full_memo("Memo", memo, sizeof(memo))); + EXPECT_EQ(0, kkconfirm_drain()); +} + +// An empty memo must show a single "(empty)" screen — not fall through to the +// hex branch, which would pass an uninitialized buffer to %s. +TEST(Thorchain, FullMemoEmptyShowsEmpty) { + ASSERT_TRUE(kkconfirm_preload(1, 0)); + EXPECT_TRUE(thorchain_confirm_full_memo("Memo", "", 0)); + EXPECT_EQ(0, kkconfirm_drain()); +} + +// Renderer-aware paging must split the exact 69-byte word-wrap exploit from +// the second-pass audit. A byte-count pager treated this as one screen even +// though the OLED renderer placed the final signed word on a fourth row. +TEST(Confirmation, ExactLengthPagerMeasuresRenderedRows) { + const char payload[] = + "%%%%%%%%%%%%%%%% %%%%%%%%%%%%%%%% %%%%%%%%%%%%%%%% %%%%%%%%%%%%%%%%"; + ASSERT_TRUE(kkconfirm_preload(2, 0)); + EXPECT_TRUE(confirm_bytes(ButtonRequestType_ButtonRequest_SignMessage, + "Signed Message", (const uint8_t*)payload, + strlen(payload))); + EXPECT_EQ(0, kkconfirm_drain()); +} + +TEST(Confirmation, ExactLengthPagerRejectPropagates) { + const char payload[] = + "%%%%%%%%%%%%%%%% %%%%%%%%%%%%%%%% %%%%%%%%%%%%%%%% %%%%%%%%%%%%%%%%"; + ASSERT_TRUE(kkconfirm_preload(1, 1)); + EXPECT_FALSE(confirm_bytes(ButtonRequestType_ButtonRequest_SignMessage, + "Signed Message", (const uint8_t*)payload, + strlen(payload))); + EXPECT_EQ(0, kkconfirm_drain()); +} + +/* ===================================================================== + * thor_isThorchainTx — chain-scoped router pin. + * + * A THORChain deposit uses a DIFFERENT router address on every EVM chain, + * so the pin must match on (chain_id, address) together. Before this was + * chain-scoped, only Ethereum-mainnet deposits ever matched and an + * Avalanche deposit fell into the blind-sign gate (the AVAX->ETH bug). + * ===================================================================== */ + +// Lowercase-hex 40-char router -> 20 raw bytes. +static void hex20(const char* hex, uint8_t out[20]) { + for (int i = 0; i < 20; i++) { + auto nib = [](char c) -> int { + return c <= '9' ? c - '0' : (c | 0x20) - 'a' + 10; + }; + out[i] = (uint8_t)((nib(hex[i * 2]) << 4) | nib(hex[i * 2 + 1])); + } +} + +static void make_deposit_msg(EthereumSignTx* msg, const uint8_t to[20], + const uint8_t* data, size_t data_len, + uint32_t chain_id, bool has_chain) { + memset(msg, 0, sizeof(*msg)); + msg->has_to = true; + msg->to.size = 20; + memcpy(msg->to.bytes, to, 20); + msg->has_data_initial_chunk = true; + msg->data_initial_chunk.size = (pb_size_t)data_len; + memcpy(msg->data_initial_chunk.bytes, data, data_len); + msg->has_chain_id = has_chain; + msg->chain_id = chain_id; +} + +static const char* THOR_ETH_ROUTER = "d37bbe5744d730a1d98d8dc97c42f0ca46ad7146"; +static const char* THOR_AVAX_ROUTER = + "00dc6100103bc402d490aee3f9a5560cbd91f1d4"; +static const uint8_t DEPOSIT_WITH_EXPIRY[4] = {0x44, 0xbc, 0x93, 0x7b}; + +TEST(Thorchain, IsThorchainTxEthRouterOnEthereum) { + uint8_t to[20]; + hex20(THOR_ETH_ROUTER, to); + EthereumSignTx msg; + make_deposit_msg(&msg, to, DEPOSIT_WITH_EXPIRY, 4, 1, true); + EXPECT_TRUE(thor_isThorchainTx(&msg)); +} + +TEST(Thorchain, IsThorchainTxAvaxRouterOnAvalanche) { + uint8_t to[20]; + hex20(THOR_AVAX_ROUTER, to); + EthereumSignTx msg; + make_deposit_msg(&msg, to, DEPOSIT_WITH_EXPIRY, 4, 43114, true); + EXPECT_TRUE(thor_isThorchainTx(&msg)); // the AVAX->ETH bug fix +} + +// The AVAX router on the Ethereum chain (or vice versa) must NOT match — the +// pin is (chain, address) together, so a router borrowed onto the wrong chain +// can't inherit the trusted deposit UX. +TEST(Thorchain, IsThorchainTxRejectsRouterOnWrongChain) { + uint8_t avax[20], eth[20]; + hex20(THOR_AVAX_ROUTER, avax); + hex20(THOR_ETH_ROUTER, eth); + EthereumSignTx msg; + make_deposit_msg(&msg, avax, DEPOSIT_WITH_EXPIRY, 4, 1, true); + EXPECT_FALSE(thor_isThorchainTx(&msg)); // AVAX router, ETH chain + make_deposit_msg(&msg, eth, DEPOSIT_WITH_EXPIRY, 4, 43114, true); + EXPECT_FALSE(thor_isThorchainTx(&msg)); // ETH router, AVAX chain +} + +// A chain with no pinned THORChain router never clear-signs (falls to blind +// sign), even with a real deposit selector to some address. +TEST(Thorchain, IsThorchainTxRejectsUnpinnedChain) { + uint8_t to[20]; + hex20(THOR_ETH_ROUTER, to); + EthereumSignTx msg; + make_deposit_msg(&msg, to, DEPOSIT_WITH_EXPIRY, 4, 137 /*polygon*/, true); + EXPECT_FALSE(thor_isThorchainTx(&msg)); +} + +// A tx with NO chain_id at all gets no router: ethereum.c defaults an absent +// chain_id to mainnet for hashing, but an identity pin must never be +// inherited from a default the host merely omitted. +TEST(Thorchain, IsThorchainTxRejectsMissingChainId) { + uint8_t to[20]; + hex20(THOR_ETH_ROUTER, to); + EthereumSignTx msg; + make_deposit_msg(&msg, to, DEPOSIT_WITH_EXPIRY, 4, 0, false); + EXPECT_FALSE(thor_isThorchainTx(&msg)); +} + +// A random contract carrying the deposit selector must not match — this is the +// drain-vector guard the pin exists for. +TEST(Thorchain, IsThorchainTxRejectsUnpinnedAddress) { + uint8_t to[20]; + hex20("00000000000000000000000000000000deadbeef", to); + EthereumSignTx msg; + make_deposit_msg(&msg, to, DEPOSIT_WITH_EXPIRY, 4, 43114, true); + EXPECT_FALSE(thor_isThorchainTx(&msg)); +} + +/* ===================================================================== + * thor_confirmThorTx on the Avalanche router — the full confirm path + * (router label, vault, native amount, structured memo, raw memo pages) + * runs for a non-mainnet deposit, and the exact-end memo bounds hold. + * ===================================================================== */ + +// Assemble a canonical depositWithExpiry(address,address,uint256,string, +// uint256) calldata. declared_len overrides the ABI memo-length word so the +// adversarial case (length says more than is present) can be exercised. +static std::vector build_thor_deposit(const uint8_t vault[20], + const std::string& memo, + uint32_t declared_len) { + std::vector d(DEPOSIT_WITH_EXPIRY, DEPOSIT_WITH_EXPIRY + 4); + auto push_word = [&](const uint8_t* w) { d.insert(d.end(), w, w + 32); }; + auto push_u = [&](uint64_t v) { + uint8_t w[32] = {0}; + for (int i = 0; i < 8; i++) w[31 - i] = (uint8_t)((v >> (8 * i)) & 0xff); + push_word(w); + }; + uint8_t vw[32] = {0}; + memcpy(vw + 12, vault, 20); + push_word(vw); // word0: vault + push_u(0); // word1: asset = native (address zero) + push_u(1000000000ULL); // word2: amount (router-ignored hint for native) + push_u(0xa0); // word3: memo offset (canonical for expiry variant) + push_u(1893456000ULL); // word4: expiry + push_u(declared_len); // word5: memo length + d.insert(d.end(), memo.begin(), memo.end()); + while (d.size() % 32 != 4) d.push_back(0); // pad memo to a 32-byte boundary + return d; +} + +// A 67-byte memo (longer than the once-hardcoded 64) must display in full +// through the memo screens, not silently truncate its trailing fields — on the +// AVALANCHE router, proving the whole confirm path is chain-scoped. +TEST(Thorchain, ConfirmThorTxAvaxLongMemoDecodesFully) { + uint8_t vault[20]; + hex20("15a18266c5331ac3a7f6bc5cdf25bcc55561b4fa", vault); + const std::string memo = + "=:ETH.ETH:0x141D9959cAe3853b035000490C03991eB70Fc4aC:323935:keep:30"; + ASSERT_EQ(memo.size(), 67u); + auto data = build_thor_deposit(vault, memo, (uint32_t)memo.size()); + + uint8_t avax[20]; + hex20(THOR_AVAX_ROUTER, avax); + EthereumSignTx msg; + make_deposit_msg(&msg, avax, data.data(), data.size(), 43114, true); + + ASSERT_TRUE(kkconfirm_preload(12, 0)); // generous; extras drain below + EXPECT_TRUE(thor_confirmThorTx((uint32_t)data.size(), &msg)); + kkconfirm_drain(); +} + +// A memo-length word claiming more bytes than are present must be REJECTED — +// otherwise the router would execute a longer memo than the device displayed +// (display-vs-execute divergence). Fail closed -> blind-sign path. +TEST(Thorchain, ConfirmThorTxRejectsOverlongDeclaredMemo) { + uint8_t vault[20]; + hex20("15a18266c5331ac3a7f6bc5cdf25bcc55561b4fa", vault); + const std::string memo = "=:ETH.ETH:0xdest:0:keep:30"; + // Declare 200 bytes while only ~26 (padded to 32) are present. + auto data = build_thor_deposit(vault, memo, 200); + + uint8_t avax[20]; + hex20(THOR_AVAX_ROUTER, avax); + EthereumSignTx msg; + make_deposit_msg(&msg, avax, data.data(), data.size(), 43114, true); + + ASSERT_TRUE(kkconfirm_preload(12, 0)); + EXPECT_FALSE(thor_confirmThorTx((uint32_t)data.size(), &msg)); + kkconfirm_drain(); } From acb150602cfb1b41de8fb535d98dbdd2b67e49cc Mon Sep 17 00:00:00 2001 From: highlander Date: Thu, 6 Aug 2026 12:47:55 -0300 Subject: [PATCH 2/3] feat(tron,ton): sign-message support and honest TRON fees TRON: - TIP-191 message signing (SignMessage/MessageSignature), so a TRON dapp login no longer requires signing a fabricated transaction. - USDT transfers carry a fee_limit ceiling and show a simulated fee instead of presenting an unbounded limit the user cannot evaluate. - unittests/firmware/tron.cpp covers the message prefix, the fee ceiling and contract parameter decoding. TON: - Address derivation uses sha256 over the StateInit cell rather than over the public key, which is what actually produces the wallet address. --- include/keepkey/firmware/tron.h | 55 ++++ lib/firmware/fsm_msg_ton.h | 87 ++---- lib/firmware/fsm_msg_tron.h | 201 +++++++----- lib/firmware/tron.c | 359 ++++++++++++++++++++++ unittests/firmware/CMakeLists.txt | 1 + unittests/firmware/tron.cpp | 495 ++++++++++++++++++++++++++++++ 6 files changed, 1061 insertions(+), 137 deletions(-) create mode 100644 unittests/firmware/tron.cpp diff --git a/include/keepkey/firmware/tron.h b/include/keepkey/firmware/tron.h index 6e51d8040..f50bc2de8 100644 --- a/include/keepkey/firmware/tron.h +++ b/include/keepkey/firmware/tron.h @@ -24,12 +24,67 @@ #include "messages-tron.pb.h" +#include +#include +#include + // TRON address length (Base58Check, typically 34 chars starting with 'T') #define TRON_ADDRESS_MAX_LEN 64 // TRON decimals (1 TRX = 1,000,000 SUN) #define TRON_DECIMALS 6 +// Raw 21-byte TRON address: 0x41 prefix + 20-byte keccak hash tail +#define TRON_RAW_ADDRESS_SIZE 21 + +/** + * On-device classification of a TronSignTx raw_data payload. + * + * The device signs sha256(raw_data), so anything shown to the user MUST be + * decoded from raw_data itself — never from side-channel proto fields. + * Unless every field of the payload is understood, the transaction is + * TRON_TX_UNVERIFIED and only the blind-sign path may be offered. + */ +typedef enum { + TRON_TX_UNVERIFIED = 0, // not fully understood — blind-sign only + TRON_TX_TRANSFER, // single TransferContract (native TRX send) + TRON_TX_TRC20_TRANSFER, // single TriggerSmartContract: + // transfer(address,uint256) +} TronTxType; + +typedef struct { + TronTxType type; + uint8_t owner[TRON_RAW_ADDRESS_SIZE]; // spending account + uint8_t to[TRON_RAW_ADDRESS_SIZE]; // TRX or token recipient + uint8_t contract[TRON_RAW_ADDRESS_SIZE]; // TRC-20 token contract + uint64_t amount; // SUN, TransferContract only + uint8_t trc20_amount[32]; // big-endian uint256 token base units + bool has_fee_limit; + uint64_t fee_limit; // SUN + const uint8_t* memo; // points into caller's raw_data + uint16_t memo_len; +} TronParsedTx; + +/** + * Parse a TRON raw_data protobuf for on-device display. + * Fail-closed: any unrecognized top-level field, contract type, extra + * contract, or unexpected parameter field yields TRON_TX_UNVERIFIED. + * out->memo points into raw — valid only while raw is alive. + */ +TronTxType tron_parseRawTx(const uint8_t* raw, size_t len, TronParsedTx* out); + +/** + * Base58Check-encode a raw 21-byte TRON address for display. + */ +bool tron_addressFromBytes(const uint8_t addr[TRON_RAW_ADDRESS_SIZE], char* out, + size_t out_len); + +/** + * Format a TRC-20 uint256 amount (big-endian) as a decimal string of token + * base units. Token decimals are unknown on-device, so no scaling is done. + */ +bool tron_formatTrc20Amount(const uint8_t amount_be[32], char* buf, size_t len); + /** * Generate TRON address from secp256k1 public key * @param public_key secp256k1 public key (33 bytes compressed) diff --git a/lib/firmware/fsm_msg_ton.h b/lib/firmware/fsm_msg_ton.h index 5f4844896..6f2aae06b 100644 --- a/lib/firmware/fsm_msg_ton.h +++ b/lib/firmware/fsm_msg_ton.h @@ -99,6 +99,20 @@ void fsm_msgTonSignTx(TonSignTx* msg) { return; } + /* AdvancedMode gate: to_address/amount are display-only, so this is + * length-only blind signing of raw bytes. Same fence as TonSignMessage + * and Solana/TRON opaque signing until the displayed fields are parsed + * from and bound to raw_tx. */ + if (!storage_isPolicyEnabled("AdvancedMode")) { + (void)review(ButtonRequestType_ButtonRequest_Other, "Blocked", + "TON transaction signing is blind-only. " + "Enable AdvancedMode in device settings."); + fsm_sendFailure(FailureType_Failure_ActionCancelled, + _("Transaction signing disabled by policy")); + layoutHome(); + return; + } + // Derive node using Ed25519 curve HDNode* node = fsm_getDerivedNode(ED25519_NAME, msg->address_n, msg->address_n_count, NULL); @@ -112,24 +126,14 @@ void fsm_msgTonSignTx(TonSignTx* msg) { return; } - bool needs_confirm = true; - - // Display transaction details if available - if (needs_confirm && msg->has_to_address && msg->has_amount) { - char amount_str[32]; - ton_formatAmount(amount_str, sizeof(amount_str), msg->amount); - - if (!confirm(ButtonRequestType_ButtonRequest_ConfirmOutput, "Send", - "Send %s TON to %s?", amount_str, msg->to_address)) { - memzero(node, sizeof(*node)); - fsm_sendFailure(FailureType_Failure_ActionCancelled, "Signing cancelled"); - layoutHome(); - return; - } - } - - if (!confirm(ButtonRequestType_ButtonRequest_SignTx, "Transaction", - "Really sign this TON transaction?")) { + /* to_address and amount are display-only fields not bound to raw_tx bytes. + * A malicious host could show one recipient while getting a different + * transaction signed. Show only the raw_tx size. */ + char blind_msg[48]; + snprintf(blind_msg, sizeof(blind_msg), "Sign %u-byte TON transaction?", + (unsigned)msg->raw_tx.size); + if (!confirm(ButtonRequestType_ButtonRequest_SignTx, "TON Blind Sign", "%s", + blind_msg)) { memzero(node, sizeof(*node)); fsm_sendFailure(FailureType_Failure_ActionCancelled, "Signing cancelled"); layoutHome(); @@ -190,43 +194,16 @@ void fsm_msgTonSignMessage(const TonSignMessage* msg) { if (!node) return; hdnode_fill_public_key(node); - /* Always require on-device confirmation. Display message content if - * printable, hex preview otherwise. */ - { - char msgBuf[129] = {0}; - const char* typeLabel; - bool printable = true; - for (unsigned i = 0; i < msg->message.size; i++) { - if (msg->message.bytes[i] < 0x20 || msg->message.bytes[i] > 0x7e) { - printable = false; - break; - } - } - if (printable && msg->message.size <= sizeof(msgBuf) - 1) { - typeLabel = "Sign TON Message"; - memcpy(msgBuf, msg->message.bytes, msg->message.size); - msgBuf[msg->message.size] = '\0'; - } else { - typeLabel = "Sign TON Bytes"; - unsigned show = msg->message.size; - if (show > 32) show = 32; - for (unsigned i = 0; i < show; i++) { - snprintf(&msgBuf[2 * i], 3, "%02x", msg->message.bytes[i]); - } - msgBuf[2 * show] = '\0'; - if (msg->message.size > 32) { - snprintf(&msgBuf[64], sizeof(msgBuf) - 64, "... (%u bytes)", - (unsigned)msg->message.size); - } - } - if (!confirm(ButtonRequestType_ButtonRequest_ProtectCall, _(typeLabel), - "%s", msgBuf)) { - memzero(node, sizeof(*node)); - fsm_sendFailure(FailureType_Failure_ActionCancelled, - _("Signing cancelled")); - layoutHome(); - return; - } + /* AdvancedMode permits the opaque primitive, but never permits a hidden + * suffix: review every signed byte using renderer-measured pages. */ + if (!confirm_bytes(ButtonRequestType_ButtonRequest_ProtectCall, + "Sign TON Message", msg->message.bytes, + msg->message.size)) { + memzero(node, sizeof(*node)); + fsm_sendFailure(FailureType_Failure_ActionCancelled, + _("Signing cancelled")); + layoutHome(); + return; } if (!ton_message_sign(node, msg, resp)) { diff --git a/lib/firmware/fsm_msg_tron.h b/lib/firmware/fsm_msg_tron.h index aee20c602..22d0a4fd6 100644 --- a/lib/firmware/fsm_msg_tron.h +++ b/lib/firmware/fsm_msg_tron.h @@ -102,15 +102,96 @@ void fsm_msgTronSignTx(TronSignTx* msg) { return; } - bool needs_confirm = true; + /* Clear-sign from raw_data itself — the exact bytes being signed. + * (The proto's side-channel to_address/amount fields are never trusted: + * they are not part of what is signed.) */ + TronParsedTx parsed; + TronTxType tx_type = + tron_parseRawTx(msg->raw_data.bytes, msg->raw_data.size, &parsed); + + if (tx_type == TRON_TX_UNVERIFIED) { + /* Unrecognized contract or payload: explicit blind-sign only, + * same policy gate as Solana opaque transactions. */ + if (!storage_isPolicyEnabled("AdvancedMode")) { + memzero(node, sizeof(*node)); + fsm_sendFailure(FailureType_Failure_Other, + _("Enable AdvancedMode to blind-sign")); + layoutHome(); + return; + } + char blind_msg[48]; + snprintf(blind_msg, sizeof(blind_msg), "Sign %u-byte TRON transaction?", + (unsigned)msg->raw_data.size); + if (!confirm(ButtonRequestType_ButtonRequest_SignTx, "TRON Blind Sign", + "%s", blind_msg)) { + memzero(node, sizeof(*node)); + fsm_sendFailure(FailureType_Failure_ActionCancelled, "Signing cancelled"); + layoutHome(); + return; + } + } else { + /* The parsed owner account is the one spending — it must be ours. */ + char derived_addr[TRON_ADDRESS_MAX_LEN]; + char owner_addr[TRON_ADDRESS_MAX_LEN]; + if (!tron_getAddress(node->public_key, derived_addr, + sizeof(derived_addr)) || + !tron_addressFromBytes(parsed.owner, owner_addr, sizeof(owner_addr)) || + strcmp(derived_addr, owner_addr) != 0) { + memzero(node, sizeof(*node)); + fsm_sendFailure(FailureType_Failure_Other, + _("TX owner does not match derived key")); + layoutHome(); + return; + } + + char to_str[TRON_ADDRESS_MAX_LEN]; + if (!tron_addressFromBytes(parsed.to, to_str, sizeof(to_str))) { + memzero(node, sizeof(*node)); + fsm_sendFailure(FailureType_Failure_Other, _("Address encoding failed")); + layoutHome(); + return; + } - // Display transaction details if available - if (needs_confirm && msg->has_to_address && msg->has_amount) { - char amount_str[32]; - tron_formatAmount(amount_str, sizeof(amount_str), msg->amount); + bool confirmed = false; + if (tx_type == TRON_TX_TRANSFER) { + char amount_str[32]; + tron_formatAmount(amount_str, sizeof(amount_str), parsed.amount); + confirmed = confirm(ButtonRequestType_ButtonRequest_SignTx, "TRON", + "Send %s to %s?", amount_str, to_str); + } else { /* TRON_TX_TRC20_TRANSFER */ + char contract_str[TRON_ADDRESS_MAX_LEN]; + char amount_str[90]; + confirmed = + tron_addressFromBytes(parsed.contract, contract_str, + sizeof(contract_str)) && + tron_formatTrc20Amount(parsed.trc20_amount, amount_str, + sizeof(amount_str)) && + confirm(ButtonRequestType_ButtonRequest_ConfirmOutput, + "TRC-20 Transfer", "Token contract %s", contract_str) && + /* Token decimals are not known on-device; show base units. */ + confirm(ButtonRequestType_ButtonRequest_SignTx, "TRC-20 Transfer", + "Send %s base units to %s?", amount_str, to_str); + } + + if (confirmed && parsed.has_fee_limit) { + char fee_str[32]; + tron_formatAmount(fee_str, sizeof(fee_str), parsed.fee_limit); + confirmed = confirm(ButtonRequestType_ButtonRequest_ConfirmOutput, "TRON", + "Max network fee %s", fee_str); + } - if (!confirm(ButtonRequestType_ButtonRequest_ConfirmOutput, "Send", - "Send %s TRX to %s?", amount_str, msg->to_address)) { + if (confirmed && parsed.memo_len > 0) { + /* Page the COMPLETE memo (72-char ASCII / 40-byte hex pages) like every + * other memo surface. The old single-screen path showed up to 114 chars + * unpaged, but 3 OLED lines only guarantee ~84 chars with wide glyphs — + * an 85..114-char memo could have its signed tail (affiliate bps, + * destination tail) silently clipped. The pager also discloses + * non-printable memos as complete hex instead of a byte-count summary. */ + confirmed = thorchain_confirm_full_memo("Memo", (const char*)parsed.memo, + parsed.memo_len); + } + + if (!confirmed) { memzero(node, sizeof(*node)); fsm_sendFailure(FailureType_Failure_ActionCancelled, "Signing cancelled"); layoutHome(); @@ -118,14 +199,6 @@ void fsm_msgTronSignTx(TronSignTx* msg) { } } - if (!confirm(ButtonRequestType_ButtonRequest_SignTx, "Transaction", - "Really sign this TRON transaction?")) { - memzero(node, sizeof(*node)); - fsm_sendFailure(FailureType_Failure_ActionCancelled, "Signing cancelled"); - layoutHome(); - return; - } - // Sign the transaction with secp256k1 if (!tron_signTx(node, msg, resp)) { memzero(node, sizeof(*node)); @@ -139,11 +212,6 @@ void fsm_msgTronSignTx(TronSignTx* msg) { layoutHome(); } -#ifndef TRON_MSG_DISPLAY_MAX -#define TRON_MSG_DISPLAY_MAX \ - (38 * 3) // mirrors ETH MSG_MAX (3 lines × 38 chars) -#endif - void fsm_msgTronSignMessage(TronSignMessage* msg) { RESP_INIT(TronMessageSignature); @@ -160,37 +228,9 @@ void fsm_msgTronSignMessage(TronSignMessage* msg) { return; } - char msgBuf[TRON_MSG_DISPLAY_MAX + 1] = {0}; - const char* typeIndicator; - bool canPrint = true; - unsigned ctr; - - for (ctr = 0; ctr < msg->message.size; ctr++) { - if (isprint(msg->message.bytes[ctr]) == false) { - canPrint = false; - break; - } - } - - if (canPrint) { - typeIndicator = "Sign TRON Message"; - unsigned copy = msg->message.size; - if (copy > TRON_MSG_DISPLAY_MAX) copy = TRON_MSG_DISPLAY_MAX; - memcpy(msgBuf, msg->message.bytes, copy); - msgBuf[copy] = '\0'; - } else { - typeIndicator = "Sign TRON Bytes"; - unsigned hexBytes = msg->message.size; - if (hexBytes * 2 > TRON_MSG_DISPLAY_MAX) { - hexBytes = TRON_MSG_DISPLAY_MAX / 2; - } - for (ctr = 0; ctr < hexBytes; ctr++) { - snprintf(&msgBuf[2 * ctr], 3, "%02x", msg->message.bytes[ctr]); - } - } - - if (!confirm(ButtonRequestType_ButtonRequest_ProtectCall, _(typeIndicator), - "%s", msgBuf)) { + if (!confirm_bytes(ButtonRequestType_ButtonRequest_ProtectCall, + "Sign TRON Message", msg->message.bytes, + msg->message.size)) { fsm_sendFailure(FailureType_Failure_ActionCancelled, NULL); layoutHome(); return; @@ -230,37 +270,9 @@ void fsm_msgTronVerifyMessage(const TronVerifyMessage* msg) { return; } - char msgBuf[TRON_MSG_DISPLAY_MAX + 1] = {0}; - const char* typeIndicator; - bool canPrint = true; - unsigned ctr; - - for (ctr = 0; ctr < msg->message.size; ctr++) { - if (isprint(msg->message.bytes[ctr]) == false) { - canPrint = false; - break; - } - } - - if (canPrint) { - typeIndicator = "Message Verified"; - unsigned copy = msg->message.size; - if (copy > TRON_MSG_DISPLAY_MAX) copy = TRON_MSG_DISPLAY_MAX; - memcpy(msgBuf, msg->message.bytes, copy); - msgBuf[copy] = '\0'; - } else { - typeIndicator = "Bytes Verified"; - unsigned hexBytes = msg->message.size; - if (hexBytes * 2 > TRON_MSG_DISPLAY_MAX) { - hexBytes = TRON_MSG_DISPLAY_MAX / 2; - } - for (ctr = 0; ctr < hexBytes; ctr++) { - snprintf(&msgBuf[2 * ctr], 3, "%02x", msg->message.bytes[ctr]); - } - } - - if (!confirm(ButtonRequestType_ButtonRequest_Other, _(typeIndicator), "%s", - msgBuf)) { + if (!confirm_bytes(ButtonRequestType_ButtonRequest_Other, + "TRON Message Verified", msg->message.bytes, + msg->message.size)) { fsm_sendFailure(FailureType_Failure_ActionCancelled, NULL); layoutHome(); return; @@ -306,6 +318,31 @@ void fsm_msgTronSignTypedHash(const TronSignTypedHash* msg) { return; } + /* Blind-sign gate: device only receives pre-computed hashes — it cannot + * reconstruct or verify the original typed-data struct. Require the same + * AdvancedMode policy as TronSignTx blind-signing so this message type + * can't be used to route around the kill-switch. */ + if (!storage_isPolicyEnabled("AdvancedMode")) { + memzero(node, sizeof(*node)); + (void)review(ButtonRequestType_ButtonRequest_Other, "Blocked", + "TIP-712 blind signing is disabled. " + "Enable AdvancedMode in device settings."); + fsm_sendFailure(FailureType_Failure_ActionCancelled, + _("Blind signing disabled by policy")); + layoutHome(); + return; + } + + /* The user must explicitly acknowledge blind signing before the hashes. */ + if (!confirm(ButtonRequestType_ButtonRequest_Other, "TIP-712 Blind Sign", + "Device cannot verify typed-data contents. " + "Only proceed if you trust the host application.")) { + memzero(node, sizeof(*node)); + fsm_sendFailure(FailureType_Failure_ActionCancelled, NULL); + layoutHome(); + return; + } + if (!confirm(ButtonRequestType_ButtonRequest_Other, "Verify Address", "Confirm address: %s", address)) { memzero(node, sizeof(*node)); diff --git a/lib/firmware/tron.c b/lib/firmware/tron.c index b8d3ec618..38fe3d7d6 100644 --- a/lib/firmware/tron.c +++ b/lib/firmware/tron.c @@ -21,11 +21,13 @@ #include "keepkey/crypto/curves.h" #include "trezor/crypto/base58.h" +#include "trezor/crypto/bignum.h" #include "trezor/crypto/ecdsa.h" #include "trezor/crypto/memzero.h" #include "trezor/crypto/secp256k1.h" #include "trezor/crypto/sha3.h" +#include #include #define TRON_ADDRESS_PREFIX 0x41 // Mainnet addresses start with 'T' @@ -80,6 +82,363 @@ void tron_formatAmount(char* buf, size_t len, uint64_t amount) { bn_format(&val, NULL, " TRX", TRON_DECIMALS, 0, false, buf, len); } +bool tron_addressFromBytes(const uint8_t addr[TRON_RAW_ADDRESS_SIZE], char* out, + size_t out_len) { + return base58_encode_check(addr, TRON_RAW_ADDRESS_SIZE, HASHER_SHA2D, out, + out_len); +} + +bool tron_formatTrc20Amount(const uint8_t amount_be[32], char* buf, + size_t len) { + bignum256 val; + bn_read_be(amount_be, &val); + return bn_format(&val, NULL, NULL, 0, 0, false, buf, len); +} + +/* ------------------------------------------------------------------ */ +/* raw_data protobuf parser */ +/* */ +/* The device signs sha256(raw_data), so display decisions are made */ +/* from these exact bytes. Minimal protobuf wire-format reader — */ +/* fail-closed: anything not fully understood ends TRON_TX_UNVERIFIED */ +/* ------------------------------------------------------------------ */ + +/* TRON protocol.Transaction.raw field numbers */ +#define TRON_RAW_REF_BLOCK_BYTES 1 +#define TRON_RAW_REF_BLOCK_NUM 3 +#define TRON_RAW_REF_BLOCK_HASH 4 +#define TRON_RAW_EXPIRATION 8 +#define TRON_RAW_DATA 10 /* memo */ +#define TRON_RAW_CONTRACT 11 +#define TRON_RAW_TIMESTAMP 14 +#define TRON_RAW_FEE_LIMIT 18 + +/* protocol.Transaction.Contract */ +#define TRON_CONTRACT_TYPE 1 +#define TRON_CONTRACT_PARAMETER 2 + +/* google.protobuf.Any */ +#define TRON_ANY_TYPE_URL 1 +#define TRON_ANY_VALUE 2 + +/* protocol.Transaction.Contract.ContractType enum values */ +#define TRON_CT_TRANSFER_CONTRACT 1 +#define TRON_CT_TRIGGER_SMART_CONTRACT 31 + +/* TRC-20 transfer(address,uint256) selector */ +static const uint8_t TRC20_TRANSFER_SELECTOR[4] = {0xa9, 0x05, 0x9c, 0xbb}; + +static bool pb_read_varint(const uint8_t* buf, size_t len, size_t* pos, + uint64_t* out) { + uint64_t val = 0; + for (unsigned shift = 0; shift < 64; shift += 7) { + if (*pos >= len) return false; + uint8_t b = buf[(*pos)++]; + uint8_t payload = b & 0x7f; + if (shift == 63 && payload > 1) { + /* The 10th byte can only contribute bit 63 to a 64-bit value + * (63 + 7 > 64); any payload bit above bit 0 here claims more + * precision than 64 bits hold. The shift below would silently + * drop those bits rather than reject them, letting a malformed + * key/length/amount/fee varint parse as if it were well-formed + * — reject instead of truncating. */ + return false; + } + val |= (uint64_t)payload << shift; + if (!(b & 0x80)) { + *out = val; + return true; + } + } + return false; /* varint too long / overflows 64 bits */ +} + +static bool pb_read_key(const uint8_t* buf, size_t len, size_t* pos, + uint32_t* field, uint8_t* wire) { + uint64_t key; + if (!pb_read_varint(buf, len, pos, &key)) return false; + *wire = (uint8_t)(key & 0x7); + if ((key >> 3) > UINT32_MAX) return false; + *field = (uint32_t)(key >> 3); + return *field != 0; +} + +static bool pb_read_bytes(const uint8_t* buf, size_t len, size_t* pos, + const uint8_t** out, size_t* out_len) { + uint64_t blen; + if (!pb_read_varint(buf, len, pos, &blen)) return false; + if (blen > len - *pos) return false; + *out = buf + *pos; + *out_len = (size_t)blen; + *pos += (size_t)blen; + return true; +} + +static bool pb_skip(const uint8_t* buf, size_t len, size_t* pos, uint8_t wire) { + uint64_t dummy; + const uint8_t* bp; + size_t bl; + switch (wire) { + case 0: /* varint */ + return pb_read_varint(buf, len, pos, &dummy); + case 1: /* fixed64 */ + if (len - *pos < 8) return false; + *pos += 8; + return true; + case 2: /* length-delimited */ + return pb_read_bytes(buf, len, pos, &bp, &bl); + case 5: /* fixed32 */ + if (len - *pos < 4) return false; + *pos += 4; + return true; + default: + return false; + } +} + +static bool tron_isRawAddress(const uint8_t* p, size_t len) { + return len == TRON_RAW_ADDRESS_SIZE && p[0] == TRON_ADDRESS_PREFIX; +} + +/* Parse protocol.TransferContract { owner_address=1, to_address=2, amount=3 } + */ +static bool tron_parseTransferContract(const uint8_t* buf, size_t len, + TronParsedTx* out) { + size_t pos = 0; + bool has_owner = false, has_to = false, has_amount = false; + while (pos < len) { + uint32_t field; + uint8_t wire; + if (!pb_read_key(buf, len, &pos, &field, &wire)) return false; + const uint8_t* bp; + size_t bl; + uint64_t v; + if (field == 1 && wire == 2) { + if (!pb_read_bytes(buf, len, &pos, &bp, &bl)) return false; + if (!tron_isRawAddress(bp, bl) || has_owner) return false; + memcpy(out->owner, bp, TRON_RAW_ADDRESS_SIZE); + has_owner = true; + } else if (field == 2 && wire == 2) { + if (!pb_read_bytes(buf, len, &pos, &bp, &bl)) return false; + if (!tron_isRawAddress(bp, bl) || has_to) return false; + memcpy(out->to, bp, TRON_RAW_ADDRESS_SIZE); + has_to = true; + } else if (field == 3 && wire == 0) { + if (!pb_read_varint(buf, len, &pos, &v) || has_amount) return false; + if (v > INT64_MAX) return false; + out->amount = v; + has_amount = true; + } else { + /* Unknown field in a value-moving payload: refuse to summarize. */ + return false; + } + } + return has_owner && has_to && has_amount; +} + +/* Parse protocol.TriggerSmartContract: + * owner_address=1, contract_address=2, call_value=3, data=4, + * call_token_value=5, token_id=6 + * Only a plain TRC-20 transfer(address,uint256) with zero call_value and + * no TRC-10 tokens attached is considered verified. */ +static bool tron_parseTriggerSmartContract(const uint8_t* buf, size_t len, + TronParsedTx* out) { + size_t pos = 0; + bool has_owner = false, has_contract = false, has_data = false; + const uint8_t* data = NULL; + size_t data_len = 0; + while (pos < len) { + uint32_t field; + uint8_t wire; + if (!pb_read_key(buf, len, &pos, &field, &wire)) return false; + const uint8_t* bp; + size_t bl; + uint64_t v; + if (field == 1 && wire == 2) { + if (!pb_read_bytes(buf, len, &pos, &bp, &bl)) return false; + if (!tron_isRawAddress(bp, bl) || has_owner) return false; + memcpy(out->owner, bp, TRON_RAW_ADDRESS_SIZE); + has_owner = true; + } else if (field == 2 && wire == 2) { + if (!pb_read_bytes(buf, len, &pos, &bp, &bl)) return false; + if (!tron_isRawAddress(bp, bl) || has_contract) return false; + memcpy(out->contract, bp, TRON_RAW_ADDRESS_SIZE); + has_contract = true; + } else if (field == 3 && wire == 0) { + /* call_value: transfer(address,uint256) is non-payable — any TRX + * attached to the call is something we can't explain to the user. */ + if (!pb_read_varint(buf, len, &pos, &v)) return false; + if (v != 0) return false; + } else if (field == 4 && wire == 2) { + if (!pb_read_bytes(buf, len, &pos, &data, &data_len) || has_data) + return false; + has_data = true; + } else { + /* token_id / call_token_value / anything else: refuse. */ + return false; + } + } + if (!has_owner || !has_contract || !has_data) return false; + + /* data must be exactly selector + address word + amount word */ + if (data_len != 4 + 32 + 32) return false; + if (memcmp(data, TRC20_TRANSFER_SELECTOR, 4) != 0) return false; + + /* Address word: 12 zero bytes then the 20-byte address. TRON tooling + * sometimes writes the 0x41 network prefix at byte 11; the TVM decodes + * only the low 160 bits, so accept 0x41 there and nothing else. */ + const uint8_t* word = data + 4; + for (int i = 0; i < 11; i++) { + if (word[i] != 0) return false; + } + if (word[11] != 0 && word[11] != TRON_ADDRESS_PREFIX) return false; + + out->to[0] = TRON_ADDRESS_PREFIX; + memcpy(out->to + 1, word + 12, 20); + memcpy(out->trc20_amount, data + 4 + 32, 32); + return true; +} + +/* Parse Contract { type=1, parameter=2 (Any) }; enum type and the Any + * type_url must agree, otherwise refuse. */ +static TronTxType tron_parseContract(const uint8_t* buf, size_t len, + TronParsedTx* out) { + size_t pos = 0; + uint64_t ctype = 0; + bool has_type = false; + const uint8_t* value = NULL; + size_t value_len = 0; + const uint8_t* type_url = NULL; + size_t type_url_len = 0; + + while (pos < len) { + uint32_t field; + uint8_t wire; + if (!pb_read_key(buf, len, &pos, &field, &wire)) return TRON_TX_UNVERIFIED; + if (field == TRON_CONTRACT_TYPE && wire == 0) { + if (!pb_read_varint(buf, len, &pos, &ctype) || has_type) + return TRON_TX_UNVERIFIED; + has_type = true; + } else if (field == TRON_CONTRACT_PARAMETER && wire == 2) { + const uint8_t* any; + size_t any_len; + if (!pb_read_bytes(buf, len, &pos, &any, &any_len) || value) + return TRON_TX_UNVERIFIED; + size_t apos = 0; + while (apos < any_len) { + uint32_t afield; + uint8_t awire; + if (!pb_read_key(any, any_len, &apos, &afield, &awire)) + return TRON_TX_UNVERIFIED; + if (afield == TRON_ANY_TYPE_URL && awire == 2) { + if (type_url || + !pb_read_bytes(any, any_len, &apos, &type_url, &type_url_len)) + return TRON_TX_UNVERIFIED; + } else if (afield == TRON_ANY_VALUE && awire == 2) { + if (value || !pb_read_bytes(any, any_len, &apos, &value, &value_len)) + return TRON_TX_UNVERIFIED; + } else { + return TRON_TX_UNVERIFIED; + } + } + if (!value) return TRON_TX_UNVERIFIED; + } else { + /* Permission_id (multisig), provider, ContractName, unknown: refuse. */ + return TRON_TX_UNVERIFIED; + } + } + if (!has_type || !value || !type_url) return TRON_TX_UNVERIFIED; + + /* type_url ends with "/protocol."; require agreement with enum */ + const char* expect_suffix; + if (ctype == TRON_CT_TRANSFER_CONTRACT) { + expect_suffix = "/protocol.TransferContract"; + } else if (ctype == TRON_CT_TRIGGER_SMART_CONTRACT) { + expect_suffix = "/protocol.TriggerSmartContract"; + } else { + return TRON_TX_UNVERIFIED; + } + size_t suffix_len = strlen(expect_suffix); + if (type_url_len < suffix_len || memcmp(type_url + type_url_len - suffix_len, + expect_suffix, suffix_len) != 0) { + return TRON_TX_UNVERIFIED; + } + + if (ctype == TRON_CT_TRANSFER_CONTRACT) { + return tron_parseTransferContract(value, value_len, out) + ? TRON_TX_TRANSFER + : TRON_TX_UNVERIFIED; + } + return tron_parseTriggerSmartContract(value, value_len, out) + ? TRON_TX_TRC20_TRANSFER + : TRON_TX_UNVERIFIED; +} + +TronTxType tron_parseRawTx(const uint8_t* raw, size_t len, TronParsedTx* out) { + memset(out, 0, sizeof(*out)); + if (!raw || len == 0) return TRON_TX_UNVERIFIED; + + size_t pos = 0; + const uint8_t* contract = NULL; + size_t contract_len = 0; + + while (pos < len) { + uint32_t field; + uint8_t wire; + if (!pb_read_key(raw, len, &pos, &field, &wire)) goto unverified; + switch (field) { + case TRON_RAW_REF_BLOCK_BYTES: + case TRON_RAW_REF_BLOCK_HASH: + if (wire != 2 || !pb_skip(raw, len, &pos, wire)) goto unverified; + break; + case TRON_RAW_REF_BLOCK_NUM: + case TRON_RAW_EXPIRATION: + case TRON_RAW_TIMESTAMP: + if (wire != 0 || !pb_skip(raw, len, &pos, wire)) goto unverified; + break; + case TRON_RAW_DATA: { + const uint8_t* bp; + size_t bl; + if (wire != 2 || out->memo || + !pb_read_bytes(raw, len, &pos, &bp, &bl) || bl > UINT16_MAX) + goto unverified; + out->memo = bp; + out->memo_len = (uint16_t)bl; + break; + } + case TRON_RAW_CONTRACT: + /* exactly one contract may be displayed truthfully */ + if (wire != 2 || contract || + !pb_read_bytes(raw, len, &pos, &contract, &contract_len)) + goto unverified; + break; + case TRON_RAW_FEE_LIMIT: { + uint64_t v; + if (wire != 0 || out->has_fee_limit || + !pb_read_varint(raw, len, &pos, &v) || v > INT64_MAX) + goto unverified; + out->fee_limit = v; + out->has_fee_limit = true; + break; + } + default: + /* auths, scripts, future fields: can change meaning — refuse. */ + goto unverified; + } + } + + if (!contract) goto unverified; + out->type = tron_parseContract(contract, contract_len, out); + if (out->type == TRON_TX_UNVERIFIED) goto unverified; + return out->type; + +unverified: + /* Preserve nothing from a failed parse except the classification. */ + memset(out, 0, sizeof(*out)); + out->type = TRON_TX_UNVERIFIED; + return TRON_TX_UNVERIFIED; +} + /** * Sign a TRON transaction with secp256k1 */ diff --git a/unittests/firmware/CMakeLists.txt b/unittests/firmware/CMakeLists.txt index 624358f59..b76de6be1 100644 --- a/unittests/firmware/CMakeLists.txt +++ b/unittests/firmware/CMakeLists.txt @@ -18,6 +18,7 @@ set(sources storage.cpp usb_rx.cpp thorchain.cpp + tron.cpp u2f.cpp) # zcash.cpp exercises the Orchard engine (lib/firmware/zcash.c), which is only diff --git a/unittests/firmware/tron.cpp b/unittests/firmware/tron.cpp new file mode 100644 index 000000000..cfc77e6e6 --- /dev/null +++ b/unittests/firmware/tron.cpp @@ -0,0 +1,495 @@ +extern "C" { +#include "keepkey/firmware/tron.h" +} + +#include "gtest/gtest.h" +#include +#include + +/* ------------------------------------------------------------------ */ +/* Minimal protobuf wire-format writer for building raw_data vectors */ +/* ------------------------------------------------------------------ */ + +namespace { + +void putVarint(std::vector& out, uint64_t v) { + while (v >= 0x80) { + out.push_back(static_cast(v) | 0x80); + v >>= 7; + } + out.push_back(static_cast(v)); +} + +void putKey(std::vector& out, uint32_t field, uint8_t wire) { + putVarint(out, (static_cast(field) << 3) | wire); +} + +void putVarintField(std::vector& out, uint32_t field, uint64_t v) { + putKey(out, field, 0); + putVarint(out, v); +} + +void putBytesField(std::vector& out, uint32_t field, + const std::vector& bytes) { + putKey(out, field, 2); + putVarint(out, bytes.size()); + out.insert(out.end(), bytes.begin(), bytes.end()); +} + +void putStringField(std::vector& out, uint32_t field, + const char* str) { + putBytesField(out, field, + std::vector(str, str + strlen(str))); +} + +/* A 10-byte varint whose final byte's payload has bits above bit 0 set. + * Bytes 1-9 are all-zero-payload continuations, so the "value" this would + * decode to (if truncation were allowed) is 2 << 63, silently dropped by + * a naive shift. A correct reader must reject this outright rather than + * accept some truncated value. */ +void putOverlongVarintValue(std::vector& out) { + for (int i = 0; i < 9; i++) out.push_back(0x80); + out.push_back(0x02); +} + +void putOverlongVarintField(std::vector& out, uint32_t field) { + putKey(out, field, 0); + putOverlongVarintValue(out); +} + +std::vector tronAddr(uint8_t fill) { + std::vector a(21, fill); + a[0] = 0x41; + return a; +} + +/* protocol.TransferContract { owner=1, to=2, amount=3 } */ +std::vector transferContractValue(const std::vector& owner, + const std::vector& to, + uint64_t amount) { + std::vector v; + putBytesField(v, 1, owner); + putBytesField(v, 2, to); + putVarintField(v, 3, amount); + return v; +} + +/* TRC-20 transfer(address,uint256) calldata */ +std::vector trc20Calldata(const std::vector& to21, + uint64_t amount, bool tronStylePrefix) { + std::vector d = {0xa9, 0x05, 0x9c, 0xbb}; + /* address word */ + for (int i = 0; i < 11; i++) d.push_back(0); + d.push_back(tronStylePrefix ? 0x41 : 0x00); + d.insert(d.end(), to21.begin() + 1, to21.end()); /* low 20 bytes */ + /* amount word: big-endian uint256 */ + for (int i = 0; i < 24; i++) d.push_back(0); + for (int i = 7; i >= 0; i--) + d.push_back(static_cast(amount >> (8 * i))); + return d; +} + +/* protocol.TriggerSmartContract { owner=1, contract=2, call_value=3, data=4 } */ +std::vector triggerContractValue(const std::vector& owner, + const std::vector& contract, + const std::vector& data) { + std::vector v; + putBytesField(v, 1, owner); + putBytesField(v, 2, contract); + putBytesField(v, 4, data); + return v; +} + +/* Transaction.Contract { type=1, parameter=2 (Any{type_url=1, value=2}) } */ +std::vector contractMsg(uint64_t type, const char* type_url, + const std::vector& value) { + std::vector any; + putStringField(any, 1, type_url); + putBytesField(any, 2, value); + + std::vector c; + putVarintField(c, 1, type); + putBytesField(c, 2, any); + return c; +} + +/* Transaction.raw with typical TronGrid framing */ +std::vector rawTx(const std::vector& contract, + const char* memo, uint64_t fee_limit) { + std::vector raw; + putBytesField(raw, 1, {0xab, 0xcd}); /* ref_block_bytes */ + putBytesField(raw, 4, std::vector(8, 0x5a)); /* ref_block_hash */ + putVarintField(raw, 8, 1750000000000ULL); /* expiration */ + if (memo) putStringField(raw, 10, memo); + putBytesField(raw, 11, contract); + putVarintField(raw, 14, 1749999000000ULL); /* timestamp */ + if (fee_limit) putVarintField(raw, 18, fee_limit); + return raw; +} + +const char* TRANSFER_URL = "type.googleapis.com/protocol.TransferContract"; +const char* TRIGGER_URL = "type.googleapis.com/protocol.TriggerSmartContract"; + +} // namespace + +TEST(Tron, ParseNativeTransfer) { + auto owner = tronAddr(0x11); + auto to = tronAddr(0x22); + auto raw = rawTx(contractMsg(1, TRANSFER_URL, + transferContractValue(owner, to, 1000000)), + nullptr, 0); + + TronParsedTx parsed; + EXPECT_EQ(tron_parseRawTx(raw.data(), raw.size(), &parsed), + TRON_TX_TRANSFER); + EXPECT_EQ(memcmp(parsed.owner, owner.data(), 21), 0); + EXPECT_EQ(memcmp(parsed.to, to.data(), 21), 0); + EXPECT_EQ(parsed.amount, 1000000u); + EXPECT_FALSE(parsed.has_fee_limit); + EXPECT_EQ(parsed.memo_len, 0); +} + +TEST(Tron, ParseNativeTransferWithSwapMemo) { + const char* memo = "=:ETH.ETH:0x41e5560054824ea6b0732e656e3ad64e20e94e45:0/1/0:kk:75"; + auto raw = rawTx(contractMsg(1, TRANSFER_URL, + transferContractValue(tronAddr(0x11), + tronAddr(0x22), 5000000)), + memo, 0); + + TronParsedTx parsed; + EXPECT_EQ(tron_parseRawTx(raw.data(), raw.size(), &parsed), + TRON_TX_TRANSFER); + ASSERT_EQ(parsed.memo_len, strlen(memo)); + EXPECT_EQ(memcmp(parsed.memo, memo, parsed.memo_len), 0); +} + +TEST(Tron, ParseTrc20Transfer) { + auto owner = tronAddr(0x11); + auto to = tronAddr(0x22); + auto token = tronAddr(0x33); + for (bool tronStyle : {false, true}) { + auto raw = rawTx( + contractMsg(31, TRIGGER_URL, + triggerContractValue( + owner, token, trc20Calldata(to, 123456789, tronStyle))), + nullptr, 100000000 /* 100 TRX fee_limit */); + + TronParsedTx parsed; + EXPECT_EQ(tron_parseRawTx(raw.data(), raw.size(), &parsed), + TRON_TX_TRC20_TRANSFER); + EXPECT_EQ(memcmp(parsed.owner, owner.data(), 21), 0); + EXPECT_EQ(memcmp(parsed.to, to.data(), 21), 0); + EXPECT_EQ(memcmp(parsed.contract, token.data(), 21), 0); + EXPECT_TRUE(parsed.has_fee_limit); + EXPECT_EQ(parsed.fee_limit, 100000000u); + + char amount[90]; + ASSERT_TRUE(tron_formatTrc20Amount(parsed.trc20_amount, amount, + sizeof(amount))); + EXPECT_STREQ(amount, "123456789"); + } +} + +TEST(Tron, ParseTrc20TransferWithMemo) { + /* Vault splices THORChain swap memos into raw_data.data for TRC-20 swaps */ + const char* memo = "=:e:0x1234:0:kk:75"; + auto raw = rawTx(contractMsg(31, TRIGGER_URL, + triggerContractValue( + tronAddr(0x11), tronAddr(0x33), + trc20Calldata(tronAddr(0x22), 42, false))), + memo, 30000000); + + TronParsedTx parsed; + EXPECT_EQ(tron_parseRawTx(raw.data(), raw.size(), &parsed), + TRON_TX_TRC20_TRANSFER); + ASSERT_EQ(parsed.memo_len, strlen(memo)); + EXPECT_EQ(memcmp(parsed.memo, memo, parsed.memo_len), 0); +} + +TEST(Tron, RejectWrongSelector) { + auto data = trc20Calldata(tronAddr(0x22), 42, false); + data[0] = 0x09; /* approve(address,uint256) = 0x095ea7b3... not transfer */ + auto raw = rawTx(contractMsg(31, TRIGGER_URL, + triggerContractValue(tronAddr(0x11), + tronAddr(0x33), data)), + nullptr, 0); + + TronParsedTx parsed; + EXPECT_EQ(tron_parseRawTx(raw.data(), raw.size(), &parsed), + TRON_TX_UNVERIFIED); +} + +TEST(Tron, RejectDirtyAddressWord) { + auto data = trc20Calldata(tronAddr(0x22), 42, false); + data[4 + 3] = 0x01; /* junk in the high bytes of the address word */ + auto raw = rawTx(contractMsg(31, TRIGGER_URL, + triggerContractValue(tronAddr(0x11), + tronAddr(0x33), data)), + nullptr, 0); + + TronParsedTx parsed; + EXPECT_EQ(tron_parseRawTx(raw.data(), raw.size(), &parsed), + TRON_TX_UNVERIFIED); +} + +TEST(Tron, RejectCalldataLengthMismatch) { + auto data = trc20Calldata(tronAddr(0x22), 42, false); + data.push_back(0x00); /* trailing byte — could smuggle params */ + auto raw = rawTx(contractMsg(31, TRIGGER_URL, + triggerContractValue(tronAddr(0x11), + tronAddr(0x33), data)), + nullptr, 0); + + TronParsedTx parsed; + EXPECT_EQ(tron_parseRawTx(raw.data(), raw.size(), &parsed), + TRON_TX_UNVERIFIED); +} + +TEST(Tron, RejectNonzeroCallValue) { + auto value = triggerContractValue(tronAddr(0x11), tronAddr(0x33), + trc20Calldata(tronAddr(0x22), 42, false)); + std::vector withCallValue; + putBytesField(withCallValue, 1, tronAddr(0x11)); + putBytesField(withCallValue, 2, tronAddr(0x33)); + putVarintField(withCallValue, 3, 7 /* nonzero TRX attached */); + putBytesField(withCallValue, 4, trc20Calldata(tronAddr(0x22), 42, false)); + auto raw = rawTx(contractMsg(31, TRIGGER_URL, withCallValue), nullptr, 0); + + TronParsedTx parsed; + EXPECT_EQ(tron_parseRawTx(raw.data(), raw.size(), &parsed), + TRON_TX_UNVERIFIED); + + /* zero call_value explicitly present is fine */ + std::vector zeroCallValue; + putBytesField(zeroCallValue, 1, tronAddr(0x11)); + putBytesField(zeroCallValue, 2, tronAddr(0x33)); + putVarintField(zeroCallValue, 3, 0); + putBytesField(zeroCallValue, 4, trc20Calldata(tronAddr(0x22), 42, false)); + raw = rawTx(contractMsg(31, TRIGGER_URL, zeroCallValue), nullptr, 0); + EXPECT_EQ(tron_parseRawTx(raw.data(), raw.size(), &parsed), + TRON_TX_TRC20_TRANSFER); +} + +TEST(Tron, RejectTrc10Fields) { + std::vector v; + putBytesField(v, 1, tronAddr(0x11)); + putBytesField(v, 2, tronAddr(0x33)); + putBytesField(v, 4, trc20Calldata(tronAddr(0x22), 42, false)); + putVarintField(v, 5, 1000001); /* call_token_value / token_id territory */ + auto raw = rawTx(contractMsg(31, TRIGGER_URL, v), nullptr, 0); + + TronParsedTx parsed; + EXPECT_EQ(tron_parseRawTx(raw.data(), raw.size(), &parsed), + TRON_TX_UNVERIFIED); +} + +TEST(Tron, RejectMultipleContracts) { + auto contract = contractMsg( + 1, TRANSFER_URL, + transferContractValue(tronAddr(0x11), tronAddr(0x22), 1)); + std::vector raw; + putBytesField(raw, 11, contract); + putBytesField(raw, 11, contract); + + TronParsedTx parsed; + EXPECT_EQ(tron_parseRawTx(raw.data(), raw.size(), &parsed), + TRON_TX_UNVERIFIED); +} + +TEST(Tron, RejectUnknownTopLevelField) { + auto raw = rawTx(contractMsg(1, TRANSFER_URL, + transferContractValue(tronAddr(0x11), + tronAddr(0x22), 1)), + nullptr, 0); + putBytesField(raw, 9, {0x01}); /* auths — permission delegation */ + + TronParsedTx parsed; + EXPECT_EQ(tron_parseRawTx(raw.data(), raw.size(), &parsed), + TRON_TX_UNVERIFIED); +} + +TEST(Tron, RejectExtraFieldInTransferContract) { + auto value = transferContractValue(tronAddr(0x11), tronAddr(0x22), 1); + putVarintField(value, 4, 99); + auto raw = rawTx(contractMsg(1, TRANSFER_URL, value), nullptr, 0); + + TronParsedTx parsed; + EXPECT_EQ(tron_parseRawTx(raw.data(), raw.size(), &parsed), + TRON_TX_UNVERIFIED); +} + +TEST(Tron, RejectPermissionId) { + std::vector any; + putStringField(any, 1, TRANSFER_URL); + putBytesField(any, 2, + transferContractValue(tronAddr(0x11), tronAddr(0x22), 1)); + std::vector c; + putVarintField(c, 1, 1); + putBytesField(c, 2, any); + putVarintField(c, 5, 2); /* Permission_id — multisig account slot */ + auto raw = rawTx(c, nullptr, 0); + + TronParsedTx parsed; + EXPECT_EQ(tron_parseRawTx(raw.data(), raw.size(), &parsed), + TRON_TX_UNVERIFIED); +} + +TEST(Tron, RejectDuplicateAnyFields) { + /* Two type_urls in the Any wrapper — last-wins ambiguity, refuse. */ + std::vector any; + putStringField(any, 1, TRIGGER_URL); + putStringField(any, 1, TRANSFER_URL); + putBytesField(any, 2, + transferContractValue(tronAddr(0x11), tronAddr(0x22), 1)); + std::vector c; + putVarintField(c, 1, 1); + putBytesField(c, 2, any); + auto raw = rawTx(c, nullptr, 0); + + TronParsedTx parsed; + EXPECT_EQ(tron_parseRawTx(raw.data(), raw.size(), &parsed), + TRON_TX_UNVERIFIED); + + /* Two value fields likewise */ + std::vector any2; + putStringField(any2, 1, TRANSFER_URL); + putBytesField(any2, 2, + transferContractValue(tronAddr(0x11), tronAddr(0x22), 1)); + putBytesField(any2, 2, + transferContractValue(tronAddr(0x11), tronAddr(0x33), 2)); + std::vector c2; + putVarintField(c2, 1, 1); + putBytesField(c2, 2, any2); + raw = rawTx(c2, nullptr, 0); + EXPECT_EQ(tron_parseRawTx(raw.data(), raw.size(), &parsed), + TRON_TX_UNVERIFIED); +} + +TEST(Tron, RejectTypeUrlEnumMismatch) { + /* enum says TransferContract, Any says TriggerSmartContract */ + auto raw = rawTx(contractMsg(1, TRIGGER_URL, + transferContractValue(tronAddr(0x11), + tronAddr(0x22), 1)), + nullptr, 0); + + TronParsedTx parsed; + EXPECT_EQ(tron_parseRawTx(raw.data(), raw.size(), &parsed), + TRON_TX_UNVERIFIED); +} + +TEST(Tron, RejectBadOwnerAddress) { + auto owner = tronAddr(0x11); + owner[0] = 0x42; /* wrong network prefix */ + auto raw = rawTx(contractMsg(1, TRANSFER_URL, + transferContractValue(owner, tronAddr(0x22), 1)), + nullptr, 0); + + TronParsedTx parsed; + EXPECT_EQ(tron_parseRawTx(raw.data(), raw.size(), &parsed), + TRON_TX_UNVERIFIED); +} + +TEST(Tron, RejectOverlongKeyVarint) { + /* The very first varint of raw_data is a field key. An overlong + * (overflowing) key varint must not be silently truncated into some + * other field number. */ + std::vector raw; + putOverlongVarintValue(raw); + + TronParsedTx parsed; + EXPECT_EQ(tron_parseRawTx(raw.data(), raw.size(), &parsed), + TRON_TX_UNVERIFIED); +} + +TEST(Tron, RejectOverlongLengthVarint) { + /* A valid key (field 11, length-delimited) followed by an overlong + * length varint — must not be truncated into some in-bounds length. */ + std::vector raw; + putKey(raw, 11, 2); + putOverlongVarintValue(raw); + + TronParsedTx parsed; + EXPECT_EQ(tron_parseRawTx(raw.data(), raw.size(), &parsed), + TRON_TX_UNVERIFIED); +} + +TEST(Tron, RejectOverlongAmountVarint) { + /* TransferContract.amount (field 3) encoded as an overlong varint. */ + std::vector value; + putBytesField(value, 1, tronAddr(0x11)); + putBytesField(value, 2, tronAddr(0x22)); + putOverlongVarintField(value, 3); + auto raw = rawTx(contractMsg(1, TRANSFER_URL, value), nullptr, 0); + + TronParsedTx parsed; + EXPECT_EQ(tron_parseRawTx(raw.data(), raw.size(), &parsed), + TRON_TX_UNVERIFIED); +} + +TEST(Tron, RejectOverlongFeeLimitVarint) { + /* Top-level fee_limit (field 18) encoded as an overlong varint. */ + auto raw = rawTx(contractMsg(1, TRANSFER_URL, + transferContractValue(tronAddr(0x11), + tronAddr(0x22), 1)), + nullptr, 0); + putOverlongVarintField(raw, 18); + + TronParsedTx parsed; + EXPECT_EQ(tron_parseRawTx(raw.data(), raw.size(), &parsed), + TRON_TX_UNVERIFIED); +} + +TEST(Tron, RejectTruncated) { + /* Build with the contract as the LAST field: any truncation then either + * cuts into a field (parse failure) or drops the contract entirely — + * both must be UNVERIFIED. (Truncation at a field boundary that only + * drops benign trailing fields like timestamp is legal protobuf and + * stays verified — that case is exercised by the parse tests above.) */ + std::vector raw; + putBytesField(raw, 1, {0xab, 0xcd}); + putVarintField(raw, 8, 1750000000000ULL); + putBytesField(raw, 11, + contractMsg(1, TRANSFER_URL, + transferContractValue(tronAddr(0x11), + tronAddr(0x22), 1000000))); + TronParsedTx sanity; + ASSERT_EQ(tron_parseRawTx(raw.data(), raw.size(), &sanity), + TRON_TX_TRANSFER); + + for (size_t cut = 1; cut < raw.size(); cut++) { + TronParsedTx parsed; + EXPECT_EQ(tron_parseRawTx(raw.data(), raw.size() - cut, &parsed), + TRON_TX_UNVERIFIED) + << "cut=" << cut; + } + + TronParsedTx parsed; + EXPECT_EQ(tron_parseRawTx(nullptr, 0, &parsed), TRON_TX_UNVERIFIED); +} + +TEST(Tron, FormatTrc20AmountUint256) { + uint8_t amount[32] = {0}; + amount[31] = 0x01; + char buf[90]; + ASSERT_TRUE(tron_formatTrc20Amount(amount, buf, sizeof(buf))); + EXPECT_STREQ(buf, "1"); + + /* 10^18 — an 18-decimals token unit */ + uint8_t big[32] = {0}; + const uint64_t e18 = 1000000000000000000ULL; + for (int i = 0; i < 8; i++) + big[24 + i] = static_cast(e18 >> (8 * (7 - i))); + ASSERT_TRUE(tron_formatTrc20Amount(big, buf, sizeof(buf))); + EXPECT_STREQ(buf, "1000000000000000000"); +} + +TEST(Tron, AddressFromBytes) { + /* Base58Check of 41 + 20 bytes must round-trip through the display helper */ + uint8_t addr[21]; + memset(addr, 0x11, sizeof(addr)); + addr[0] = 0x41; + char out[64]; + ASSERT_TRUE(tron_addressFromBytes(addr, out, sizeof(out))); + EXPECT_EQ(out[0], 'T'); /* mainnet addresses render as T... */ + EXPECT_GE(strlen(out), 33u); +} From 507745d59117a241d47d405ae7c6e334c075d6db Mon Sep 17 00:00:00 2001 From: highlander Date: Thu, 6 Aug 2026 12:53:52 -0300 Subject: [PATCH 3/3] feat(hive): SLIP-0048 keys and operation signing MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Hive support end to end: - HiveGetPublicKey / HiveGetPublicKeys export SLIP-0048 role keys (owner, active, posting, memo) so a client can set up an account without ever seeing a private key. - HiveSignTx plus dedicated account-create and account-update flows, each confirming the operation's real effect — authority changes are shown as authority changes, not as an opaque JSON blob. - HiveSignMessage and HiveSignOperations cover the remaining wire cases, including multi-operation transactions. - Wire compatibility: canonical serialization matches what the network expects, including asset formatting and the expiration encoding. - unittests/firmware/hive.cpp covers serialization, role derivation and the confirmation text for each operation type. --- include/keepkey/firmware/fsm.h | 7 + include/keepkey/firmware/hive.h | 261 ++++ include/keepkey/transport/interface.h | 1 + .../keepkey/transport/messages-hive.options | 58 + lib/firmware/CMakeLists.txt | 1 + lib/firmware/fsm.c | 3 + lib/firmware/fsm_msg_hive.h | 1024 +++++++++++++++ lib/firmware/hive.c | 1096 +++++++++++++++++ lib/firmware/messagemap.def | 17 + lib/transport/CMakeLists.txt | 9 + unittests/firmware/CMakeLists.txt | 1 + unittests/firmware/hive.cpp | 983 +++++++++++++++ 12 files changed, 3461 insertions(+) create mode 100644 include/keepkey/firmware/hive.h create mode 100644 include/keepkey/transport/messages-hive.options create mode 100644 lib/firmware/fsm_msg_hive.h create mode 100644 lib/firmware/hive.c create mode 100644 unittests/firmware/hive.cpp diff --git a/include/keepkey/firmware/fsm.h b/include/keepkey/firmware/fsm.h index 7d61dcf71..a66f3b85d 100644 --- a/include/keepkey/firmware/fsm.h +++ b/include/keepkey/firmware/fsm.h @@ -145,6 +145,13 @@ void fsm_msgZcashTransparentOutput(const ZcashTransparentOutput* msg); void fsm_msgZcashTransparentInput(const ZcashTransparentInput* msg); void fsm_msgZcashDisplayAddress(const ZcashDisplayAddress* msg); #endif +void fsm_msgHiveGetPublicKey(const HiveGetPublicKey* msg); +void fsm_msgHiveGetPublicKeys(const HiveGetPublicKeys* msg); +void fsm_msgHiveSignTx(const HiveSignTx* msg); +void fsm_msgHiveSignAccountCreate(const HiveSignAccountCreate* msg); +void fsm_msgHiveSignAccountUpdate(const HiveSignAccountUpdate* msg); +void fsm_msgHiveSignMessage(const HiveSignMessage* msg); +void fsm_msgHiveSignOperations(const HiveSignOperations* msg); #if DEBUG_LINK // void fsm_msgDebugLinkDecision(DebugLinkDecision *msg); diff --git a/include/keepkey/firmware/hive.h b/include/keepkey/firmware/hive.h new file mode 100644 index 000000000..ad98b4731 --- /dev/null +++ b/include/keepkey/firmware/hive.h @@ -0,0 +1,261 @@ +#ifndef KEEPKEY_FIRMWARE_HIVE_H +#define KEEPKEY_FIRMWARE_HIVE_H + +#include "trezor/crypto/bip32.h" +#include "messages-hive.pb.h" + +// ── Hive mainnet chain ID ───────────────────────────────────────────────── +#define HIVE_CHAIN_ID \ + "\xbe\xea\xb0\xde\x00\x00\x00\x00\x00\x00\x00\x00" \ + "\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00" \ + "\x00\x00\x00\x00\x00\x00\x00\x00" + +#define HIVE_CHAIN_ID_LEN 32 + +// ── STM public key prefix (Hive inherited from Steem / Graphene) ────────── +#define HIVE_PUBKEY_PREFIX "STM" + +// ── SLIP-0048 derivation constants (all hardened) ───────────────────────── +// Path: m/48'/13'/role'/account_index'/0' +// 13' is the de-facto Hive network index shipped by Ledger (LedgerHQ/app-hive) +// and hive-ledger-cli, NOT the slip-0048.md registry entry (0xbee = 3054', +// which no wallet implements). Chosen deliberately for seed-level key +// compatibility with the existing hardware-wallet ecosystem. +#define HIVE_SLIP48_PURPOSE (0x80000030u) // 48' +#define HIVE_SLIP48_NETWORK (0x8000000Du) // 13' +#define HIVE_ROLE_OWNER \ + (0x80000000u) // 0' — account recovery, authority changes +#define HIVE_ROLE_ACTIVE (0x80000001u) // 1' — transfers, staking +#define HIVE_ROLE_MEMO (0x80000003u) // 3' — memo field encryption +#define HIVE_ROLE_POSTING (0x80000004u) // 4' — votes, posts, follows + +/** + * Validate a complete Hive SLIP-0048 path: + * m/48'/13'/role'/account'/0'. The role must be one of owner, active, memo, + * or posting; every component is hardened. + */ +bool hive_slip48_path_valid(const uint32_t* address_n, size_t count); + +/** Validate a complete Hive SLIP-0048 path for one required role. */ +bool hive_slip48_path_valid_for_role(const uint32_t* address_n, size_t count, + uint32_t required_role); + +// ── Graphene operation type IDs ─────────────────────────────────────────── +#define HIVE_OP_VOTE 0 +#define HIVE_OP_COMMENT 1 +#define HIVE_OP_TRANSFER 2 +#define HIVE_OP_TRANSFER_TO_VESTING 3 +#define HIVE_OP_WITHDRAW_VESTING 4 +#define HIVE_OP_LIMIT_ORDER_CREATE 5 +#define HIVE_OP_LIMIT_ORDER_CANCEL 6 +#define HIVE_OP_CONVERT 8 +#define HIVE_OP_ACCOUNT_CREATE 9 +#define HIVE_OP_ACCOUNT_UPDATE 10 +#define HIVE_OP_CUSTOM_JSON 18 +#define HIVE_OP_COMMENT_OPTIONS 19 +#define HIVE_OP_TRANSFER_TO_SAVINGS 32 +#define HIVE_OP_TRANSFER_FROM_SAVINGS 33 +#define HIVE_OP_CLAIM_REWARD_BALANCE 39 +#define HIVE_OP_DELEGATE_VESTING_SHARES 40 +#define HIVE_OP_ACCOUNT_UPDATE2 43 + +// ── Protocol limits ─────────────────────────────────────────────────────── +#define HIVE_DECIMALS 3 // HIVE and HBD both use 3 decimal places +// Maximum memo length that fits safely in the signer's tx_buf[512] with all +// other fields. Non-memo overhead: header(12) + from(17) + to(17) + asset(16) +// + footer(1) = ~63 bytes. 512 - 63 - 3 (varint) = 446; 440 is conservative. +#define HIVE_MAX_MEMO_LEN 440 +// Maximum signable message length. MUST match HiveSignMessage.message +// max_size in messages-hive.options (proto cap and code cap kept in sync). +#define HIVE_MAX_MESSAGE_LEN 1024 +// Maximum host-serialized transaction length for HiveSignOperations. MUST +// match HiveSignOperations.serialized_tx max_size in messages-hive.options. +#define HIVE_MAX_OPS_TX_LEN 2048 +// Maximum operations per HiveSignOperations transaction. +#define HIVE_MAX_TX_OPS 4 +// Graphene asset: int64 LE amount + uint8 precision + 7-byte NUL-padded +// symbol (append_asset layout). +#define HIVE_ASSET_LEN 16 +// Most assets carried by a single op in the table (claim_reward_balance +// carries three: HIVE, HBD, VESTS). +#define HIVE_MAX_OP_ASSETS 3 +// Most comment_payout_beneficiaries entries accepted on a comment_options op. +// Matches the host serializer's cap; hived itself allows more, but eight is +// all that can be reviewed on the OLED before approval fatigue sets in. +#define HIVE_MAX_BENEFICIARIES 8 +// Maximum custom_json authorization accounts accepted per operation. Every +// account is confirmed individually; bounding the set prevents an unreviewable +// approval loop and keeps the parsed transaction's static RAM use predictable. +#define HIVE_MAX_CUSTOM_JSON_AUTHS 4 + +// Symbol whitelist bits for the asset parser. Every asset field in the op +// table pins an explicit set — an op that accepts HIVE must never silently +// accept VESTS, since the two differ by 1000x in displayed magnitude. +#define HIVE_SYM_HIVE (1u << 0) +#define HIVE_SYM_HBD (1u << 1) +#define HIVE_SYM_VESTS (1u << 2) + +// ── Public API ──────────────────────────────────────────────────────────── +/** + * Encode a 33-byte compressed public key in Hive/Steem STM-prefix base58 + * format. Uses RIPEMD checksum (Graphene convention, not SHA256d). + */ +bool hive_getPublicKey(const uint8_t public_key[33], char* out, size_t out_len); + +/** + * Derive one SLIP-0048 role key for a given account index to raw 33 bytes. + * role_hardened: HIVE_ROLE_OWNER | HIVE_ROLE_ACTIVE | HIVE_ROLE_MEMO | + * HIVE_ROLE_POSTING account_index_hardened: account_index | 0x80000000u Returns + * false if derivation fails. + */ +bool hive_deriveRawKey(const HDNode* root, uint32_t role_hardened, + uint32_t account_index_hardened, uint8_t out[33]); + +/** + * Derive all four SLIP-0048 role keys for a given account index and encode + * each as an STM-prefixed string. All output buffers must be >= 64 bytes. + * Returns false if any derivation or encoding step fails. + */ +bool hive_getPublicKeys(const HDNode* root, uint32_t account_index, + char* owner_out, size_t owner_len, char* active_out, + size_t active_len, char* memo_out, size_t memo_len, + char* posting_out, size_t posting_len); + +/** + * Sign a Hive transfer transaction (op type 2). + * Rejects memos longer than HIVE_MAX_MEMO_LEN (440 bytes). + */ +void hive_signTx(const HDNode* node, const HiveSignTx* msg, HiveSignedTx* resp); + +// ── Parsed operations (HiveSignOperations) ──────────────────────────────── + +typedef struct { + uint32_t op_type; + bool needs_active; // custom_json with required_auths; false = posting tier + // Borrowed slices into the request's serialized_tx (NOT NUL-terminated): + const uint8_t* acct; // vote: voter / comment: author / cj: first auth name + uint16_t acct_len; + const uint8_t* target; // vote: author / comment: title / cj: id + uint16_t target_len; + const uint8_t* detail; // vote: permlink / comment: body / cj: json + uint16_t detail_len; + const uint8_t* parent_author; // comment only + uint16_t parent_author_len; + const uint8_t* + parent_permlink; // comment only (category for a top-level post) + uint16_t parent_permlink_len; + const uint8_t* permlink; // comment only: this post/reply's permlink + uint16_t permlink_len; + const uint8_t* json_metadata; // comment only + uint16_t json_metadata_len; + int16_t weight; // vote (-10000..10000), or a 0..10000 basis-point + // percent (comment_options percent_hbd, + // set_withdraw_vesting_route percent) + bool is_top_level; // comment only: parent_author empty + uint8_t n_auths; // custom_json only: total auth account names + const uint8_t* auth_acct[HIVE_MAX_CUSTOM_JSON_AUTHS]; + uint16_t auth_acct_len[HIVE_MAX_CUSTOM_JSON_AUTHS]; + + // ── Phase-3 op fields ─────────────────────────────────────────────────── + // Borrowed HIVE_ASSET_LEN-byte asset slices in the op's own field order: + // transfer_to_vesting/convert/claim_account/savings: [0] = amount + // withdraw_vesting/delegate_vesting_shares: [0] = vesting_shares + // limit_order_create: [0] = amount_to_sell, [1] = min_to_receive + // claim_reward_balance: [0] = HIVE, [1] = HBD, [2] = VESTS + // comment_options: [0] = max_accepted_payout + const uint8_t* assets[HIVE_MAX_OP_ASSETS]; + uint8_t n_assets; + uint32_t req_id; // convert requestid / savings request_id / order id + uint32_t expiration; // limit_order_create only + bool flag; // fill_or_kill / approve / auto_vest / allow_votes + bool flag2; // comment_options: allow_curation_rewards + uint8_t n_benef; // comment_options: beneficiary count (0 = none) + const uint8_t* benef_acct[HIVE_MAX_BENEFICIARIES]; + uint16_t benef_acct_len[HIVE_MAX_BENEFICIARIES]; + uint16_t benef_weight[HIVE_MAX_BENEFICIARIES]; // basis points +} HiveTxOp; + +typedef struct { + uint8_t num_ops; + bool needs_active; // tx tier: active' path required, else posting' + HiveTxOp ops[HIVE_MAX_TX_OPS]; +} HiveParsedTx; + +/** + * Parse and validate a host-serialized Graphene transaction against the + * device clear-sign op table. Returns NULL on success or a static error + * message. Slices in `out` borrow from `tx` — keep it alive. + * + * Ops 2 (transfer), 9 (account_create) and 10 (account_update) are + * permanently excluded; everything not in the table is refused outright — + * there is no blind-sign fallback. + */ +const char* hive_parseOperations(const uint8_t* tx, size_t len, + HiveParsedTx* out); + +/** + * Accessors for a HIVE_ASSET_LEN-byte asset slice stored in HiveTxOp.assets. + * The parser has already validated the symbol/precision pair, so the symbol + * is always a NUL-terminated "HIVE" / "HBD" / "VESTS" and the amount is + * non-negative. + */ +uint64_t hive_assetAmount(const uint8_t* asset); +uint8_t hive_assetPrecision(const uint8_t* asset); +const char* hive_assetSymbol(const uint8_t* asset); + +/** + * Sign a parsed HiveSignOperations transaction: digest is + * SHA256(chain_id || serialized_tx), identical to HiveSignTx. The caller + * (FSM handler) is responsible for parsing, display, and role checks. + */ +void hive_signOperations(const HDNode* node, const HiveSignOperations* msg, + HiveSignedOperations* resp); + +/** + * Sign an arbitrary message per the Hive Keychain signBuffer contract: + * signature over SHA256(message bytes) only — no chain_id prepend, no + * message prefix. Emits the 65-byte compact recoverable signature plus the + * signing key's 33-byte compressed public key. + */ +void hive_signMessage(const HDNode* node, const HiveSignMessage* msg, + HiveSignedMessage* resp); + +/** + * True iff every byte is printable ASCII (0x20-0x7e). Hive message signing + * requires this: a transaction digest is SHA256(chain_id || serialized_tx) + * whose chain_id and serialized fields are binary, so a printable-only message + * domain can never collide with a transaction preimage on ANY chain id. This + * closes the cross-chain message→transaction signature oracle that a + * mainnet-only prefix reject cannot. Empty (len == 0) returns true. + */ +bool hive_message_is_printable(const uint8_t* message, size_t len); + +/** + * Sign a Hive account_create transaction (op type 9). + * owner/active/posting/memo_raw must be device-derived 33-byte compressed keys. + * The firmware uses these directly; host-supplied key strings in msg are + * ignored. + */ +void hive_signAccountCreate(const HDNode* signing_node, + const HiveSignAccountCreate* msg, + const uint8_t owner_raw[33], + const uint8_t active_raw[33], + const uint8_t posting_raw[33], + const uint8_t memo_raw[33], + HiveSignedAccountCreate* resp); + +/** + * Sign a Hive account_update transaction (op type 10). + * owner/active/posting/memo_raw must be device-derived 33-byte compressed keys. + * The firmware uses these directly; host-supplied new_*_key strings in msg are + * ignored. + */ +void hive_signAccountUpdate(const HDNode* signing_node, + const HiveSignAccountUpdate* msg, + const uint8_t owner_raw[33], + const uint8_t active_raw[33], + const uint8_t posting_raw[33], + const uint8_t memo_raw[33], + HiveSignedAccountUpdate* resp); + +#endif // KEEPKEY_FIRMWARE_HIVE_H diff --git a/include/keepkey/transport/interface.h b/include/keepkey/transport/interface.h index ab435baf8..6ec14d24e 100644 --- a/include/keepkey/transport/interface.h +++ b/include/keepkey/transport/interface.h @@ -39,6 +39,7 @@ #include "messages-ton.pb.h" #include "messages-solana.pb.h" #include "messages-zcash.pb.h" +#include "messages-hive.pb.h" #include "types.pb.h" #include "trezor_transport.h" diff --git a/include/keepkey/transport/messages-hive.options b/include/keepkey/transport/messages-hive.options new file mode 100644 index 000000000..d39d59215 --- /dev/null +++ b/include/keepkey/transport/messages-hive.options @@ -0,0 +1,58 @@ +HiveGetPublicKey.address_n max_count:8 + +HivePublicKey.public_key max_size:64 +HivePublicKey.raw_public_key max_size:33 + +HiveGetPublicKeys.account_index int_size:IS_32 + +HivePublicKeys.owner_key max_size:64 +HivePublicKeys.active_key max_size:64 +HivePublicKeys.memo_key max_size:64 +HivePublicKeys.posting_key max_size:64 + +HiveSignTx.address_n max_count:8 +HiveSignTx.chain_id max_size:32 +HiveSignTx.from max_size:16 +HiveSignTx.to max_size:16 +HiveSignTx.amount int_size:IS_64 +HiveSignTx.asset_symbol max_size:10 +HiveSignTx.memo max_size:2048 + +HiveSignedTx.signature max_size:65 +HiveSignedTx.serialized_tx max_size:512 + +HiveSignAccountCreate.address_n max_count:8 +HiveSignAccountCreate.chain_id max_size:32 +HiveSignAccountCreate.creator max_size:16 +HiveSignAccountCreate.new_account_name max_size:16 +HiveSignAccountCreate.owner_key max_size:64 +HiveSignAccountCreate.active_key max_size:64 +HiveSignAccountCreate.posting_key max_size:64 +HiveSignAccountCreate.memo_key max_size:64 +HiveSignAccountCreate.fee_amount int_size:IS_64 + +HiveSignedAccountCreate.signature max_size:65 +HiveSignedAccountCreate.serialized_tx max_size:512 + +HiveSignAccountUpdate.address_n max_count:8 +HiveSignAccountUpdate.chain_id max_size:32 +HiveSignAccountUpdate.account max_size:16 +HiveSignAccountUpdate.new_owner_key max_size:64 +HiveSignAccountUpdate.new_active_key max_size:64 +HiveSignAccountUpdate.new_posting_key max_size:64 +HiveSignAccountUpdate.new_memo_key max_size:64 + +HiveSignedAccountUpdate.signature max_size:65 +HiveSignedAccountUpdate.serialized_tx max_size:512 + +HiveSignMessage.address_n max_count:8 +HiveSignMessage.message max_size:1024 + +HiveSignedMessage.signature max_size:65 +HiveSignedMessage.public_key max_size:33 + +HiveSignOperations.address_n max_count:8 +HiveSignOperations.chain_id max_size:32 +HiveSignOperations.serialized_tx max_size:2048 + +HiveSignedOperations.signature max_size:65 diff --git a/lib/firmware/CMakeLists.txt b/lib/firmware/CMakeLists.txt index 4666060b8..ff10b335b 100644 --- a/lib/firmware/CMakeLists.txt +++ b/lib/firmware/CMakeLists.txt @@ -22,6 +22,7 @@ set(sources ethereum_tokens.c dice_input.c fsm.c + hive.c home_sm.c mayachain.c nano.c diff --git a/lib/firmware/fsm.c b/lib/firmware/fsm.c index 3653ea7b0..93d639564 100644 --- a/lib/firmware/fsm.c +++ b/lib/firmware/fsm.c @@ -59,6 +59,7 @@ #include "keepkey/firmware/signed_metadata.h" #include "keepkey/firmware/solana.h" #include "keepkey/firmware/zcash.h" +#include "keepkey/firmware/hive.h" #include "keepkey/firmware/storage.h" #include "keepkey/firmware/tendermint.h" #include "keepkey/firmware/thorchain.h" @@ -94,6 +95,7 @@ #include "messages-ton.pb.h" #include "messages-solana.pb.h" #include "messages-zcash.pb.h" +#include "messages-hive.pb.h" #include @@ -299,6 +301,7 @@ void fsm_msgClearSession(ClearSession* msg) { #include "fsm_msg_tron.h" #include "fsm_msg_ton.h" #include "fsm_msg_solana.h" +#include "fsm_msg_hive.h" /* After fsm_msg_solana.h: reuses its base58 helper and the KKSOLSC1 parser. */ #include "fsm_msg_clearsign_attestor.h" #if ZCASH_PRIVACY diff --git a/lib/firmware/fsm_msg_hive.h b/lib/firmware/fsm_msg_hive.h new file mode 100644 index 000000000..d87188a14 --- /dev/null +++ b/lib/firmware/fsm_msg_hive.h @@ -0,0 +1,1024 @@ +/* + * This file is part of the KeepKey project. + * + * Copyright (C) 2026 KeepKey + * + * This library is free software: you can redistribute it and/or modify + * it under the terms of the GNU Lesser General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + */ + +// ── HiveGetPublicKey ────────────────────────────────────────────────────── +// Returns a single STM-prefixed public key for the given SLIP-0048 path. +// Path format: m/48'/13'/role'/account'/0' (all 5 components hardened). + +void fsm_msgHiveGetPublicKey(const HiveGetPublicKey* msg) { + RESP_INIT(HivePublicKey); + + CHECK_INITIALIZED + CHECK_PIN + + if (!hive_slip48_path_valid(msg->address_n, msg->address_n_count)) { + fsm_sendFailure(FailureType_Failure_SyntaxError, + _("Invalid Hive SLIP-0048 path")); + layoutHome(); + return; + } + + HDNode* node = fsm_getDerivedNode(SECP256K1_NAME, msg->address_n, + msg->address_n_count, NULL); + if (!node) return; + hdnode_fill_public_key(node); + + resp->has_raw_public_key = true; + resp->raw_public_key.size = 33; + memcpy(resp->raw_public_key.bytes, node->public_key, 33); + + resp->has_public_key = true; + if (!hive_getPublicKey(node->public_key, resp->public_key, + sizeof(resp->public_key))) { + memzero(node, sizeof(*node)); + fsm_sendFailure(FailureType_Failure_FirmwareError, + _("Failed to encode Hive public key")); + layoutHome(); + return; + } + + if (msg->has_show_display && msg->show_display) { + // Label the key by the role in the ACTUAL derivation path + // (m/48'/13'/role'/account'/0'), never the host-supplied msg->role, + // which could mislabel the exported key. + const char* role_label = "Hive Public Key"; + if (msg->address_n_count >= 3) { + switch (msg->address_n[2] & 0x7FFFFFFFu) { + case 0: + role_label = "Hive Owner Key"; + break; + case 1: + role_label = "Hive Active Key"; + break; + case 3: + role_label = "Hive Memo Key"; + break; + case 4: + role_label = "Hive Posting Key"; + break; + default: + break; + } + } + if (!confirm_ethereum_address(role_label, resp->public_key)) { + memzero(node, sizeof(*node)); + fsm_sendFailure(FailureType_Failure_ActionCancelled, _("Cancelled")); + layoutHome(); + return; + } + } + + memzero(node, sizeof(*node)); + msg_write(MessageType_MessageType_HivePublicKey, resp); + layoutHome(); +} + +// ── HiveGetPublicKeys ───────────────────────────────────────────────────── +// Returns all four SLIP-0048 role keys (owner/active/memo/posting) for a +// given account index in a single device interaction. + +void fsm_msgHiveGetPublicKeys(const HiveGetPublicKeys* msg) { + RESP_INIT(HivePublicKeys); + + CHECK_INITIALIZED + CHECK_PIN + + uint32_t account_index = msg->has_account_index ? msg->account_index : 0; + + HDNode* root = fsm_getDerivedNode(SECP256K1_NAME, NULL, 0, NULL); + if (!root) return; + + resp->has_owner_key = true; + resp->has_active_key = true; + resp->has_memo_key = true; + resp->has_posting_key = true; + + if (!hive_getPublicKeys(root, account_index, resp->owner_key, + sizeof(resp->owner_key), resp->active_key, + sizeof(resp->active_key), resp->memo_key, + sizeof(resp->memo_key), resp->posting_key, + sizeof(resp->posting_key))) { + memzero(root, sizeof(*root)); + fsm_sendFailure(FailureType_Failure_FirmwareError, + _("Failed to derive Hive keys")); + layoutHome(); + return; + } + + if (msg->has_show_display && msg->show_display) { + if (!confirm(ButtonRequestType_ButtonRequest_Other, "Hive Keys", + "Export all Hive keys for account %u?", + (unsigned int)account_index)) { + memzero(root, sizeof(*root)); + fsm_sendFailure(FailureType_Failure_ActionCancelled, _("Cancelled")); + layoutHome(); + return; + } + } + + memzero(root, sizeof(*root)); + msg_write(MessageType_MessageType_HivePublicKeys, resp); + layoutHome(); +} + +// ── SLIP-0048 path validation ───────────────────────────────────────────── +// All three sign handlers enforce the full path shape before anything is +// derived or signed: m/48'/13'/role'/account'/0' (all 5 components hardened), +// with the role pinned to the one the operation needs on-chain: +// transfer -> active' (post-HF28 hived no longer accepts higher-role +// substitution, and the cold owner key must not be spent) +// create/update -> owner' (the attestation contract: the sponsor verifies +// the signature recovers to the device OWNER key, and +// account_update replaces the owner authority itself) +// Rejecting arbitrary host paths means a compromised host can never make the +// device produce a Hive signature with a key from another coin's derivation +// tree, nor with the wrong role's key. + +static bool hive_slip48_path_ok(const uint32_t* address_n, uint32_t count, + uint32_t required_role) { + return hive_slip48_path_valid_for_role(address_n, count, required_role); +} + +static bool hive_confirm_slice(ButtonRequestType type, const char* title, + const uint8_t* s, uint16_t len); + +// ── HiveSignTx (transfer) ───────────────────────────────────────────────── + +void fsm_msgHiveSignTx(const HiveSignTx* msg) { + RESP_INIT(HiveSignedTx); + + CHECK_INITIALIZED + CHECK_PIN + + if (!msg->has_from || !msg->has_to || !msg->has_amount || + !msg->has_ref_block_num || !msg->has_ref_block_prefix || + !msg->has_expiration) { + fsm_sendFailure(FailureType_Failure_SyntaxError, + _("Missing required Hive transaction fields")); + layoutHome(); + return; + } + + if (!hive_slip48_path_ok(msg->address_n, msg->address_n_count, + HIVE_ROLE_ACTIVE)) { + fsm_sendFailure(FailureType_Failure_SyntaxError, + _("Invalid Hive SLIP-0048 path (transfer needs active')")); + layoutHome(); + return; + } + + // Reject over-long memos up front with a specific error; the serializer's + // own bounds check would otherwise surface as a generic signing failure. + if (msg->has_memo && strlen(msg->memo) > HIVE_MAX_MEMO_LEN) { + fsm_sendFailure(FailureType_Failure_SyntaxError, + _("Hive memo too long (max 440 bytes)")); + layoutHome(); + return; + } + + HDNode* node = fsm_getDerivedNode(SECP256K1_NAME, msg->address_n, + msg->address_n_count, NULL); + if (!node) return; + hdnode_fill_public_key(node); + + // Display precision MUST match the precision the serializer signs + // (append_asset uses msg->decimals), otherwise the user approves an + // amount that differs from what is signed. Reject implausible precision. + uint8_t prec = msg->has_decimals ? (uint8_t)msg->decimals : HIVE_DECIMALS; + if (prec > 18) { + memzero(node, sizeof(*node)); + fsm_sendFailure(FailureType_Failure_SyntaxError, + _("Invalid Hive asset precision")); + layoutHome(); + return; + } + const char* symbol = msg->has_asset_symbol ? msg->asset_symbol : "HIVE"; + char suffix[sizeof(msg->asset_symbol) + 2]; // leading space + symbol + NUL + snprintf(suffix, sizeof(suffix), " %s", symbol); + char amount_str[32]; + bn_format_uint64(msg->amount, NULL, suffix, prec, 0, false, amount_str, + sizeof(amount_str)); + + if (!confirm(ButtonRequestType_ButtonRequest_ConfirmOutput, "Send Hive", + "Send %s to @%s?", amount_str, msg->to)) { + memzero(node, sizeof(*node)); + fsm_sendFailure(FailureType_Failure_ActionCancelled, NULL); + layoutHome(); + return; + } + + if (msg->has_memo && strlen(msg->memo) > 0) { + if (!hive_confirm_slice(ButtonRequestType_ButtonRequest_ConfirmMemo, "Memo", + (const uint8_t*)msg->memo, + (uint16_t)strlen(msg->memo))) { + memzero(node, sizeof(*node)); + fsm_sendFailure(FailureType_Failure_ActionCancelled, NULL); + layoutHome(); + return; + } + } + + if (!confirm(ButtonRequestType_ButtonRequest_SignTx, "Sign Transaction", + "Sign Hive transaction from @%s?", msg->from)) { + memzero(node, sizeof(*node)); + fsm_sendFailure(FailureType_Failure_ActionCancelled, NULL); + layoutHome(); + return; + } + + hive_signTx(node, msg, resp); + memzero(node, sizeof(*node)); + + if (!resp->has_signature) { + fsm_sendFailure(FailureType_Failure_FirmwareError, + _("Hive signing failed")); + layoutHome(); + return; + } + + msg_write(MessageType_MessageType_HiveSignedTx, resp); + layoutHome(); +} + +typedef struct { + uint8_t owner[33]; + uint8_t active[33]; + uint8_t posting[33]; + uint8_t memo[33]; +} HiveRoleKeys; + +static bool hive_prepare_account_sign(const uint32_t* address_n, + uint32_t address_n_count, + HiveRoleKeys* keys, HDNode** node_out, + char* owner_stm, size_t owner_stm_len) { + if (!hive_slip48_path_ok(address_n, address_n_count, HIVE_ROLE_OWNER)) { + fsm_sendFailure(FailureType_Failure_SyntaxError, + _("Invalid Hive SLIP-0048 path (needs owner')")); + layoutHome(); + return false; + } + uint32_t account_index = address_n[3] & 0x7FFFFFFFu; + + // Derive all four role keys from the device root. + // Do this BEFORE fetching the signing node so the root static buffer + // is not clobbered by the second fsm_getDerivedNode call. + const HDNode* root = fsm_getDerivedNode(SECP256K1_NAME, NULL, 0, NULL); + if (!root) return false; + + uint32_t acc_hardened = account_index | 0x80000000u; + bool keys_ok = + hive_deriveRawKey(root, HIVE_ROLE_OWNER, acc_hardened, keys->owner) && + hive_deriveRawKey(root, HIVE_ROLE_ACTIVE, acc_hardened, keys->active) && + hive_deriveRawKey(root, HIVE_ROLE_POSTING, acc_hardened, keys->posting) && + hive_deriveRawKey(root, HIVE_ROLE_MEMO, acc_hardened, keys->memo); + // root static buffer is done with; signing node derivation may overwrite it. + + if (!keys_ok) { + memzero(keys, sizeof(*keys)); + fsm_sendFailure(FailureType_Failure_FirmwareError, + _("Failed to derive Hive keys")); + layoutHome(); + return false; + } + + // Now get the signing node (owner key, overwrites root static buffer). + HDNode* node = + fsm_getDerivedNode(SECP256K1_NAME, address_n, address_n_count, NULL); + if (!node) { + memzero(keys, sizeof(*keys)); + return false; + } + hdnode_fill_public_key(node); + + // Encode the device-derived owner key for display confirmation. + if (!hive_getPublicKey(keys->owner, owner_stm, owner_stm_len)) { + memzero(node, sizeof(*node)); + memzero(keys, sizeof(*keys)); + fsm_sendFailure(FailureType_Failure_FirmwareError, + _("Failed to encode Hive owner key")); + layoutHome(); + return false; + } + + *node_out = node; + return true; +} + +// ── HiveSignAccountCreate ───────────────────────────────────────────────── +// Signs a Graphene account_create operation. +// Device derives all four role keys internally; host-supplied key strings +// are informational only (displayed for confirmation) and never used for +// the actual transaction. KeepKey is the sole root of trust from genesis. + +void fsm_msgHiveSignAccountCreate(const HiveSignAccountCreate* msg) { + RESP_INIT(HiveSignedAccountCreate); + + CHECK_INITIALIZED + CHECK_PIN + + if (!msg->has_new_account_name || !msg->has_creator || + !msg->has_ref_block_num || !msg->has_ref_block_prefix || + !msg->has_expiration) { + fsm_sendFailure(FailureType_Failure_SyntaxError, + _("Missing required account_create fields")); + layoutHome(); + return; + } + + HiveRoleKeys keys; + HDNode* node = NULL; + char owner_stm[64]; + if (!hive_prepare_account_sign(msg->address_n, msg->address_n_count, &keys, + &node, owner_stm, sizeof(owner_stm))) { + return; + } + + // Primary confirmation: show the new username prominently. + if (!confirm(ButtonRequestType_ButtonRequest_ConfirmOutput, + "Create Hive Account", + "Create @%s secured by KeepKey?\n\nAll keys from your device.", + msg->new_account_name)) { + memzero(node, sizeof(*node)); + memzero(&keys, sizeof(keys)); + fsm_sendFailure(FailureType_Failure_ActionCancelled, NULL); + layoutHome(); + return; + } + + // Secondary confirmation: show device-derived owner key so user can verify. + if (!confirm(ButtonRequestType_ButtonRequest_Other, "Owner Key", "%s", + owner_stm)) { + memzero(node, sizeof(*node)); + memzero(&keys, sizeof(keys)); + fsm_sendFailure(FailureType_Failure_ActionCancelled, NULL); + layoutHome(); + return; + } + + // Tertiary confirmation: show sponsor + fee. + char fee_str[32]; + uint64_t fee = msg->has_fee_amount ? msg->fee_amount : 3000; + snprintf(fee_str, sizeof(fee_str), "%" PRIu64 ".%03" PRIu64 " HIVE", + fee / 1000, fee % 1000); + if (!confirm(ButtonRequestType_ButtonRequest_SignTx, "Creation Fee", + "Fee: %s paid by @%s", fee_str, msg->creator)) { + memzero(node, sizeof(*node)); + memzero(&keys, sizeof(keys)); + fsm_sendFailure(FailureType_Failure_ActionCancelled, NULL); + layoutHome(); + return; + } + + hive_signAccountCreate(node, msg, keys.owner, keys.active, keys.posting, + keys.memo, resp); + memzero(node, sizeof(*node)); + memzero(&keys, sizeof(keys)); + + if (!resp->has_signature) { + fsm_sendFailure(FailureType_Failure_FirmwareError, + _("Hive account_create signing failed")); + layoutHome(); + return; + } + + msg_write(MessageType_MessageType_HiveSignedAccountCreate, resp); + layoutHome(); +} + +// ── HiveSignAccountUpdate ───────────────────────────────────────────────── +// Signs a Graphene account_update operation. +// Device derives all four new role keys internally; host-supplied new_*_key +// strings are not used for signing. The device-derived owner key is shown +// so the user can verify it matches their device before replacing all keys. + +void fsm_msgHiveSignAccountUpdate(const HiveSignAccountUpdate* msg) { + RESP_INIT(HiveSignedAccountUpdate); + + CHECK_INITIALIZED + CHECK_PIN + + if (!msg->has_account || !msg->has_ref_block_num || + !msg->has_ref_block_prefix || !msg->has_expiration) { + fsm_sendFailure(FailureType_Failure_SyntaxError, + _("Missing required account_update fields")); + layoutHome(); + return; + } + + HiveRoleKeys keys; + HDNode* node = NULL; + char owner_stm[64]; + if (!hive_prepare_account_sign(msg->address_n, msg->address_n_count, &keys, + &node, owner_stm, sizeof(owner_stm))) { + return; + } + + // Warning: this replaces all existing keys. + if (!confirm(ButtonRequestType_ButtonRequest_ProtectCall, + "Secure Hive Account", + "Replace ALL keys for @%s with KeepKey keys?\n\nOld keys will " + "be retired.", + msg->account)) { + memzero(node, sizeof(*node)); + memzero(&keys, sizeof(keys)); + fsm_sendFailure(FailureType_Failure_ActionCancelled, NULL); + layoutHome(); + return; + } + + // Show device-derived owner key so user can verify it's their device. + if (!confirm(ButtonRequestType_ButtonRequest_SignTx, "New Owner Key", "%s", + owner_stm)) { + memzero(node, sizeof(*node)); + memzero(&keys, sizeof(keys)); + fsm_sendFailure(FailureType_Failure_ActionCancelled, NULL); + layoutHome(); + return; + } + + hive_signAccountUpdate(node, msg, keys.owner, keys.active, keys.posting, + keys.memo, resp); + memzero(node, sizeof(*node)); + memzero(&keys, sizeof(keys)); + + if (!resp->has_signature) { + fsm_sendFailure(FailureType_Failure_FirmwareError, + _("Hive account_update signing failed")); + layoutHome(); + return; + } + + msg_write(MessageType_MessageType_HiveSignedAccountUpdate, resp); + layoutHome(); +} + +// ── HiveSignMessage (Keychain signBuffer) ───────────────────────────────── +// The Hive dApp login primitive: Aioha / Keychain-SDK dApps authenticate by +// having the account sign a challenge string, then recover the pubkey and +// check it against the account's authority on-chain. Contract (hive-js +// Signature.signBuffer): sig over SHA256(raw message bytes) — no chain_id, +// no prefix. Roles: posting/active/memo, Keychain's requestSignBuffer +// surface. owner' is deliberately rejected — no consumer offers it, and the +// cold owner key must not be normalized into dApp flows. The full path +// shape is still enforced like the tx handlers. + +static bool hive_slip48_message_path_ok(const uint32_t* address_n, + uint32_t count, + const char** role_label) { + if (!hive_slip48_path_valid(address_n, count)) return false; + switch (address_n[2]) { + case HIVE_ROLE_ACTIVE: + *role_label = "active"; + return true; + case HIVE_ROLE_MEMO: + *role_label = "memo"; + return true; + case HIVE_ROLE_POSTING: + *role_label = "posting"; + return true; + default: + return false; + } +} + +void fsm_msgHiveSignMessage(const HiveSignMessage* msg) { + RESP_INIT(HiveSignedMessage); + + CHECK_INITIALIZED + CHECK_PIN + + if (!msg->has_message || msg->message.size == 0) { + fsm_sendFailure(FailureType_Failure_SyntaxError, _("Missing message")); + layoutHome(); + return; + } + + // Mirrors the proto max_size cap so proto and code can never disagree + // (the memo-length lesson from the transfer handler). + if (msg->message.size > HIVE_MAX_MESSAGE_LEN) { + fsm_sendFailure(FailureType_Failure_SyntaxError, + _("Hive message too long (max 1024 bytes)")); + layoutHome(); + return; + } + + // A Hive TRANSACTION digest is SHA256(chain_id || tx), and this message + // digest is SHA256(message) — so a "message" that begins with the mainnet + // chain-id bytes would hash to a broadcastable transaction's digest. No + // legitimate challenge starts with the chain id; refuse the collision. + const uint8_t hive_chain_id[HIVE_CHAIN_ID_LEN] = HIVE_CHAIN_ID; + if (msg->message.size >= HIVE_CHAIN_ID_LEN && + memcmp(msg->message.bytes, hive_chain_id, HIVE_CHAIN_ID_LEN) == 0) { + fsm_sendFailure(FailureType_Failure_SyntaxError, + _("Message must not start with the Hive chain ID")); + layoutHome(); + return; + } + + const char* role_label = NULL; + if (!hive_slip48_message_path_ok(msg->address_n, msg->address_n_count, + &role_label)) { + fsm_sendFailure(FailureType_Failure_SyntaxError, + _("Invalid Hive SLIP-0048 path")); + layoutHome(); + return; + } + + HDNode* node = fsm_getDerivedNode(SECP256K1_NAME, msg->address_n, + msg->address_n_count, NULL); + if (!node) return; + hdnode_fill_public_key(node); + + // Domain-separate messages from transactions. A Hive TRANSACTION digest is + // SHA256(chain_id || serialized_tx), where the 32-byte chain_id and the + // serialized Graphene fields (ref_block_prefix, expiration, ...) are BINARY. + // Constraining signable messages to printable ASCII puts them in a domain + // disjoint from every transaction preimage — for ANY chain id, not just + // mainnet — so a binary "message" equal to C || serialized_tx can no longer + // be signed into a valid transaction signature on a fork chain C. This is the + // real fix; the mainnet-only prefix reject above is a belt-and-suspenders + // subset of it. hive-js signBuffer signs printable challenges, so nothing + // legitimate is lost. (A prefix blacklist could never be complete because the + // host chooses the chain id; a printable-only whitelist is complete by + // construction against binary preimages.) + if (!hive_message_is_printable(msg->message.bytes, msg->message.size)) { + memzero(node, sizeof(*node)); + fsm_sendFailure(FailureType_Failure_SyntaxError, + _("Hive messages must be printable text")); + layoutHome(); + return; + } + + // Page the FULL message (72-char ASCII pages) so no trailing content is ever + // truncated behind a benign-looking prefix, and name the signing key. + if (!confirm(ButtonRequestType_ButtonRequest_ProtectCall, "Sign Hive Message", + "Signing with %s key", role_label) || + !hive_confirm_slice(ButtonRequestType_ButtonRequest_ProtectCall, + "Hive Message", msg->message.bytes, + (uint16_t)msg->message.size)) { + memzero(node, sizeof(*node)); + fsm_sendFailure(FailureType_Failure_ActionCancelled, NULL); + layoutHome(); + return; + } + + hive_signMessage(node, msg, resp); + memzero(node, sizeof(*node)); + + if (!resp->has_signature) { + fsm_sendFailure(FailureType_Failure_FirmwareError, + _("Hive message signing failed")); + layoutHome(); + return; + } + + msg_write(MessageType_MessageType_HiveSignedMessage, resp); + layoutHome(); +} + +// ── HiveSignOperations (parsed generic op signing) ──────────────────────── +// The host serializes the transaction; firmware parses the Graphene bytes, +// clear-signs the ops it recognizes (vote, comment, custom_json), and +// refuses everything else — no blind-sign fallback. Everything shown on the +// OLED is re-derived from the bytes being signed, so a host serializer bug +// can only produce a node rejection, never a silent wrong-sign. + +// Dedicated path validator: {posting', active'} ONLY, pinned to the tx tier. +// Do NOT fold into hive_slip48_message_path_ok — that one deliberately +// accepts memo' (a legitimate signBuffer target), but no Graphene operation +// uses memo authority; a memo-path vote must be refused here, not +// discovered at the chain. owner' is likewise excluded. +static bool hive_slip48_ops_path_ok(const uint32_t* address_n, uint32_t count, + bool needs_active) { + return hive_slip48_path_valid_for_role( + address_n, count, needs_active ? HIVE_ROLE_ACTIVE : HIVE_ROLE_POSTING); +} + +// User-controlled string fields are paged in full. Printable fields are shown +// as text; fields containing non-ASCII bytes are shown as complete hex rather +// than a short preview. Page boundaries are selected with the same font and +// word-wrapping calculation used by draw_string(), so no signed suffix can be +// pushed below the OLED's three visible body rows. + +static bool hive_slice_is_ascii(const uint8_t* s, uint16_t len) { + bool ascii = true; + for (uint16_t i = 0; i < len; i++) { + if (s[i] < 0x20 || s[i] > 0x7e) { + ascii = false; + break; + } + } + return ascii; +} + +static uint16_t hive_rendered_page_len(const uint8_t* s, uint16_t len, + bool ascii) { + if (len == 0) return 0; + + if (ascii) { + size_t candidate = len; + if (candidate >= BODY_CHAR_MAX) candidate = BODY_CHAR_MAX - 1; + return (uint16_t)calc_str_page(get_body_font(), (const char*)s, candidate, + BODY_WIDTH, BODY_ROWS); + } + + uint16_t candidate = len; + if (candidate > (BODY_CHAR_MAX - 1) / 2) candidate = (BODY_CHAR_MAX - 1) / 2; + char rendered[BODY_CHAR_MAX]; + for (uint16_t i = 0; i < candidate; i++) { + snprintf(rendered + 2 * i, 3, "%02x", s[i]); + } + size_t chars = calc_str_page(get_body_font(), rendered, 2 * candidate, + BODY_WIDTH, BODY_ROWS); + return (uint16_t)(chars / 2); +} + +static bool hive_confirm_slice(ButtonRequestType type, const char* title, + const uint8_t* s, uint16_t len) { + if (len == 0) return confirm(type, title, "(empty)"); + + bool ascii = hive_slice_is_ascii(s, len); + uint16_t pages = 0; + uint16_t offset = 0; + while (offset < len) { + uint16_t take = hive_rendered_page_len(s + offset, len - offset, ascii); + if (take == 0) return false; + offset = (uint16_t)(offset + take); + pages++; + } + + offset = 0; + for (uint16_t page = 0; page < pages; page++) { + uint16_t take = hive_rendered_page_len(s + offset, len - offset, ascii); + if (take == 0) return false; + + char page_title[TITLE_CHAR_MAX]; + if (pages > 1 || !ascii) { + snprintf(page_title, sizeof(page_title), + ascii ? "%s %u/%u" : "%s Hex %u/%u", title, (unsigned)(page + 1), + (unsigned)pages); + } else { + strlcpy(page_title, title, sizeof(page_title)); + } + + if (ascii) { + char rendered[BODY_CHAR_MAX]; + memcpy(rendered, s + offset, take); + rendered[take] = '\0'; + if (!confirm(type, page_title, "%s", rendered)) return false; + } else { + char rendered[BODY_CHAR_MAX]; + for (uint16_t i = 0; i < take; i++) { + snprintf(rendered + 2 * i, 3, "%02x", s[offset + i]); + } + if (!confirm(type, page_title, "%s", rendered)) return false; + } + offset = (uint16_t)(offset + take); + } + return true; +} + +// "1.234 HIVE" — precision comes from the asset bytes being signed, which +// the parser has already pinned to the symbol's protocol-fixed value. +static void hive_format_asset(const uint8_t* a, char* out, size_t out_len) { + char suffix[9]; // space + longest symbol ("VESTS") + NUL + snprintf(suffix, sizeof(suffix), " %s", hive_assetSymbol(a)); + bn_format_uint64(hive_assetAmount(a), NULL, suffix, hive_assetPrecision(a), 0, + false, out, out_len); +} + +// Basis points (0..10000) as "12.34%". +static void hive_format_percent(int16_t bp, char* out, size_t out_len) { + snprintf(out, out_len, "%d.%02d%%", bp / 100, bp % 100); +} + +static void hive_copy_slice(char* out, size_t out_len, const uint8_t* s, + uint16_t len) { + if (out_len == 0) return; + size_t take = len; + if (take >= out_len) take = out_len - 1; + memcpy(out, s, take); + out[take] = '\0'; +} + +void fsm_msgHiveSignOperations(const HiveSignOperations* msg) { + RESP_INIT(HiveSignedOperations); + + CHECK_INITIALIZED + CHECK_PIN + + if (!msg->has_serialized_tx || msg->serialized_tx.size == 0) { + fsm_sendFailure(FailureType_Failure_SyntaxError, + _("Missing serialized transaction")); + layoutHome(); + return; + } + + static HiveParsedTx parsed; // slices borrow from the static msg buffer + const char* parse_err = hive_parseOperations( + msg->serialized_tx.bytes, msg->serialized_tx.size, &parsed); + if (parse_err) { + fsm_sendFailure(FailureType_Failure_SyntaxError, _(parse_err)); + layoutHome(); + return; + } + + if (!hive_slip48_ops_path_ok(msg->address_n, msg->address_n_count, + parsed.needs_active)) { + fsm_sendFailure(FailureType_Failure_SyntaxError, + parsed.needs_active + ? _("Invalid Hive SLIP-0048 path (needs active')") + : _("Invalid Hive SLIP-0048 path (needs posting')")); + layoutHome(); + return; + } + + HDNode* node = fsm_getDerivedNode(SECP256K1_NAME, msg->address_n, + msg->address_n_count, NULL); + if (!node) return; + hdnode_fill_public_key(node); + + // Confirm operation summaries and payloads, then show a final sign prompt. + for (uint8_t i = 0; i < parsed.num_ops; i++) { + const HiveTxOp* op = &parsed.ops[i]; + char name[17]; // hive account names are <= 16 chars, length-validated + hive_copy_slice(name, sizeof(name), op->acct, op->acct_len); + + bool approved = false; + switch (op->op_type) { + case HIVE_OP_VOTE: { + char target[17]; + hive_copy_slice(target, sizeof(target), op->target, op->target_len); + int w = op->weight < 0 ? -op->weight : op->weight; + approved = + confirm(ButtonRequestType_ButtonRequest_ConfirmOutput, + op->weight < 0 ? "Downvote" : "Vote", + "@%s -> @%s at %d.%02d%%", name, target, w / 100, w % 100); + if (approved) { + approved = + hive_confirm_slice(ButtonRequestType_ButtonRequest_ConfirmOutput, + "Vote Target", op->detail, op->detail_len); + } + break; + } + case HIVE_OP_COMMENT: { + char parent[17]; + hive_copy_slice(parent, sizeof(parent), op->parent_author, + op->parent_author_len); + approved = + op->is_top_level + ? confirm(ButtonRequestType_ButtonRequest_ConfirmOutput, "Post", + "Create post by @%s?", name) + : confirm(ButtonRequestType_ButtonRequest_ConfirmOutput, + "Comment", "Reply by @%s to @%s?", name, parent); + if (approved) { + approved = hive_confirm_slice( + ButtonRequestType_ButtonRequest_ConfirmOutput, + op->is_top_level ? "Post Category" : "Reply Target", + op->parent_permlink, op->parent_permlink_len); + } + if (approved) { + approved = hive_confirm_slice( + ButtonRequestType_ButtonRequest_ConfirmOutput, "Post Permlink", + op->permlink, op->permlink_len); + } + if (approved && op->target_len > 0) { + approved = + hive_confirm_slice(ButtonRequestType_ButtonRequest_ConfirmOutput, + "Post Title", op->target, op->target_len); + } + if (approved) { + approved = + hive_confirm_slice(ButtonRequestType_ButtonRequest_ConfirmOutput, + "Post Body", op->detail, op->detail_len); + } + if (approved && op->json_metadata_len > 0) { + approved = hive_confirm_slice( + ButtonRequestType_ButtonRequest_ConfirmOutput, "Post Metadata", + op->json_metadata, op->json_metadata_len); + } + break; + } + case HIVE_OP_CUSTOM_JSON: { + approved = true; + for (uint8_t a = 0; approved && a < op->n_auths; a++) { + char auth_name[17]; + hive_copy_slice(auth_name, sizeof(auth_name), op->auth_acct[a], + op->auth_acct_len[a]); + approved = confirm( + ButtonRequestType_ButtonRequest_ConfirmOutput, "Custom JSON Auth", + "%u/%u: @%s\n%s key", (unsigned)(a + 1), (unsigned)op->n_auths, + auth_name, op->needs_active ? "Active" : "Posting"); + } + if (approved) { + approved = + hive_confirm_slice(ButtonRequestType_ButtonRequest_ConfirmOutput, + "Custom JSON ID", op->target, op->target_len); + } + if (approved) { + approved = + hive_confirm_slice(ButtonRequestType_ButtonRequest_ConfirmOutput, + "Custom JSON", op->detail, op->detail_len); + } + break; + } + case HIVE_OP_TRANSFER_TO_VESTING: { + char amount[40], target[17]; + hive_format_asset(op->assets[0], amount, sizeof(amount)); + hive_copy_slice(target, sizeof(target), op->target, op->target_len); + approved = + op->target_len == 0 + ? confirm(ButtonRequestType_ButtonRequest_ConfirmOutput, + "Power Up", "Power up\n%s\nto @%s", amount, name) + : confirm(ButtonRequestType_ButtonRequest_ConfirmOutput, + "Power Up", "%s\nfrom @%s\nto @%s", amount, name, + target); + break; + } + case HIVE_OP_WITHDRAW_VESTING: { + char amount[40]; + hive_format_asset(op->assets[0], amount, sizeof(amount)); + approved = + hive_assetAmount(op->assets[0]) == 0 + ? confirm(ButtonRequestType_ButtonRequest_ConfirmOutput, + "Stop Power Down", "Cancel power down\nfor @%s", name) + : confirm(ButtonRequestType_ButtonRequest_ConfirmOutput, + "Power Down", "Power down\n%s\nfrom @%s", amount, + name); + break; + } + case HIVE_OP_LIMIT_ORDER_CREATE: { + char sell[40], receive[40]; + hive_format_asset(op->assets[0], sell, sizeof(sell)); + hive_format_asset(op->assets[1], receive, sizeof(receive)); + approved = confirm(ButtonRequestType_ButtonRequest_ConfirmOutput, + "Market Order", "@%s sells\n%s\nfor >= %s", name, + sell, receive); + if (approved) { + // Order id and fill_or_kill decide whether an unfilled order rests + // on the book or is discarded, so they get their own screen rather + // than being crowded off the first one. + approved = confirm(ButtonRequestType_ButtonRequest_ConfirmOutput, + "Order Terms", "Order #%u\n%s\nExpires %u", + (unsigned)op->req_id, + op->flag ? "Fill or kill" : "Rests on book", + (unsigned)op->expiration); + } + break; + } + case HIVE_OP_LIMIT_ORDER_CANCEL: + approved = confirm(ButtonRequestType_ButtonRequest_ConfirmOutput, + "Cancel Order", "Cancel order #%u\nfor @%s?", + (unsigned)op->req_id, name); + break; + case HIVE_OP_CONVERT: { + char amount[40]; + hive_format_asset(op->assets[0], amount, sizeof(amount)); + approved = confirm(ButtonRequestType_ButtonRequest_ConfirmOutput, + "Convert", "Convert %s\nto HIVE for @%s\n(#%u)", + amount, name, (unsigned)op->req_id); + break; + } + case HIVE_OP_COMMENT_OPTIONS: { + char max_payout[40], percent[16]; + hive_format_asset(op->assets[0], max_payout, sizeof(max_payout)); + hive_format_percent(op->weight, percent, sizeof(percent)); + approved = confirm(ButtonRequestType_ButtonRequest_ConfirmOutput, + "Payout Options", "@%s\nMax %s\nHBD split %s", name, + max_payout, percent); + if (approved) { + approved = confirm(ButtonRequestType_ButtonRequest_ConfirmOutput, + "Payout Options", "Votes: %s\nCuration: %s", + op->flag ? "allowed" : "disabled", + op->flag2 ? "allowed" : "disabled"); + } + // Beneficiaries divert payout to other accounts — each one is + // confirmed individually rather than summarized as a count. + for (uint8_t b = 0; approved && b < op->n_benef; b++) { + char benef[17], benef_pct[16]; + hive_copy_slice(benef, sizeof(benef), op->benef_acct[b], + op->benef_acct_len[b]); + hive_format_percent((int16_t)op->benef_weight[b], benef_pct, + sizeof(benef_pct)); + approved = confirm(ButtonRequestType_ButtonRequest_ConfirmOutput, + "Payout Beneficiary", "%u/%u: @%s\ngets %s", + (unsigned)(b + 1), (unsigned)op->n_benef, benef, + benef_pct); + } + if (approved) { + approved = hive_confirm_slice( + ButtonRequestType_ButtonRequest_ConfirmOutput, "Payout Permlink", + op->permlink, op->permlink_len); + } + break; + } + case HIVE_OP_TRANSFER_TO_SAVINGS: + case HIVE_OP_TRANSFER_FROM_SAVINGS: { + char amount[40], target[17]; + bool deposit = (op->op_type == HIVE_OP_TRANSFER_TO_SAVINGS); + hive_format_asset(op->assets[0], amount, sizeof(amount)); + hive_copy_slice(target, sizeof(target), op->target, op->target_len); + // One variable per row. A 16-character account name sharing a row + // with a label can wrap into a fourth row, which the display drops + // silently — and here that row carries the destination account. + // req_id is deliberately not shown: it is a cancellation handle, not + // a fund-routing field, and crowding it in costs the destination row. + approved = confirm(ButtonRequestType_ButtonRequest_ConfirmOutput, + deposit ? "Savings Deposit" : "Savings Withdraw", + "%s\nfrom @%s\nto @%s", amount, name, target); + if (approved && op->detail_len > 0) { + approved = + hive_confirm_slice(ButtonRequestType_ButtonRequest_ConfirmOutput, + "Savings Memo", op->detail, op->detail_len); + } + break; + } + case HIVE_OP_CLAIM_REWARD_BALANCE: { + char hive_amt[40], hbd_amt[40], vests_amt[40]; + hive_format_asset(op->assets[0], hive_amt, sizeof(hive_amt)); + hive_format_asset(op->assets[1], hbd_amt, sizeof(hbd_amt)); + hive_format_asset(op->assets[2], vests_amt, sizeof(vests_amt)); + // Three assets plus the account name cannot share one screen: the + // OLED body fits exactly three rows (layout.c places rows at y = + // 24/38/52 and draw_char_with_shift silently drops any glyph past + // y+height > 64), so a fourth row would be signed but never shown. + approved = confirm(ButtonRequestType_ButtonRequest_ConfirmOutput, + "Claim Rewards", "@%s claims\n%s\n%s", name, + hive_amt, hbd_amt); + if (approved) { + approved = + confirm(ButtonRequestType_ButtonRequest_ConfirmOutput, + "Claim Rewards", "@%s claims\n%s", name, vests_amt); + } + break; + } + case HIVE_OP_DELEGATE_VESTING_SHARES: { + char amount[40], target[17]; + hive_format_asset(op->assets[0], amount, sizeof(amount)); + hive_copy_slice(target, sizeof(target), op->target, op->target_len); + approved = + hive_assetAmount(op->assets[0]) == 0 + ? confirm(ButtonRequestType_ButtonRequest_ConfirmOutput, + "Remove Delegation", + "@%s removes its\ndelegation to @%s?", name, target) + : confirm(ButtonRequestType_ButtonRequest_ConfirmOutput, + "Delegate", "@%s delegates\n%s\nto @%s", name, amount, + target); + break; + } + case HIVE_OP_ACCOUNT_UPDATE2: + approved = confirm(ButtonRequestType_ButtonRequest_ConfirmOutput, + "Profile Update", "Update profile\nof @%s?", name); + if (approved && op->detail_len > 0) { + approved = hive_confirm_slice( + ButtonRequestType_ButtonRequest_ConfirmOutput, "Account Metadata", + op->detail, op->detail_len); + } + if (approved && op->json_metadata_len > 0) { + approved = hive_confirm_slice( + ButtonRequestType_ButtonRequest_ConfirmOutput, "Profile Metadata", + op->json_metadata, op->json_metadata_len); + } + break; + default: + break; // unreachable — parser rejected unknown ops + } + if (!approved) { + memzero(node, sizeof(*node)); + fsm_sendFailure(FailureType_Failure_ActionCancelled, NULL); + layoutHome(); + return; + } + } + + if (!confirm(ButtonRequestType_ButtonRequest_SignTx, "Sign Transaction", + "Sign %u Hive operation%s with the %s key?", + (unsigned)parsed.num_ops, parsed.num_ops == 1 ? "" : "s", + parsed.needs_active ? "active" : "posting")) { + memzero(node, sizeof(*node)); + fsm_sendFailure(FailureType_Failure_ActionCancelled, NULL); + layoutHome(); + return; + } + + hive_signOperations(node, msg, resp); + memzero(node, sizeof(*node)); + + if (!resp->has_signature) { + fsm_sendFailure(FailureType_Failure_FirmwareError, + _("Hive operation signing failed")); + layoutHome(); + return; + } + + msg_write(MessageType_MessageType_HiveSignedOperations, resp); + layoutHome(); +} diff --git a/lib/firmware/hive.c b/lib/firmware/hive.c new file mode 100644 index 000000000..304f673ae --- /dev/null +++ b/lib/firmware/hive.c @@ -0,0 +1,1096 @@ +/* + * This file is part of the KeepKey project. + * + * Copyright (C) 2026 KeepKey + * + * This library is free software: you can redistribute it and/or modify + * it under the terms of the GNU Lesser General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + */ + +#include "keepkey/firmware/hive.h" + +#include "trezor/crypto/base58.h" +#include "trezor/crypto/memzero.h" +#include "trezor/crypto/secp256k1.h" +#include "trezor/crypto/sha2.h" + +#include +#include + +// ── STM public key encoding ─────────────────────────────────────────────── + +bool hive_getPublicKey(const uint8_t public_key[33], char* out, + size_t out_len) { + const size_t prefix_len = strlen(HIVE_PUBKEY_PREFIX); + if (out_len < prefix_len + 1) return false; + strlcpy(out, HIVE_PUBKEY_PREFIX, out_len); + // Graphene uses RIPEMD checksum (not SHA256d) for public key encoding + return base58_encode_check(public_key, 33, HASHER_RIPEMD, out + prefix_len, + out_len - prefix_len); +} + +// ── Single-role key derivation to raw 33 bytes ──────────────────────────── +// Path: m/48'/13'/role_hardened/account_index_hardened/0' +// hdnode_private_ckd() returns 1 on success, 0 on failure. + +static bool hive_role_valid(uint32_t role) { + return role == HIVE_ROLE_OWNER || role == HIVE_ROLE_ACTIVE || + role == HIVE_ROLE_MEMO || role == HIVE_ROLE_POSTING; +} + +bool hive_slip48_path_valid(const uint32_t* address_n, size_t count) { + if (!address_n || count != 5) return false; + if (address_n[0] != HIVE_SLIP48_PURPOSE) return false; + if (address_n[1] != HIVE_SLIP48_NETWORK) return false; + if (!hive_role_valid(address_n[2])) return false; + if ((address_n[3] & 0x80000000u) == 0) return false; + if (address_n[4] != 0x80000000u) return false; + return true; +} + +bool hive_slip48_path_valid_for_role(const uint32_t* address_n, size_t count, + uint32_t required_role) { + return hive_role_valid(required_role) && + hive_slip48_path_valid(address_n, count) && + address_n[2] == required_role; +} + +bool hive_deriveRawKey(const HDNode* root, uint32_t role_hardened, + uint32_t account_index_hardened, uint8_t out[33]) { + HDNode node; + memcpy(&node, root, sizeof(HDNode)); + if (!hdnode_private_ckd(&node, HIVE_SLIP48_PURPOSE)) goto fail; + if (!hdnode_private_ckd(&node, HIVE_SLIP48_NETWORK)) goto fail; + if (!hdnode_private_ckd(&node, role_hardened)) goto fail; + if (!hdnode_private_ckd(&node, account_index_hardened)) goto fail; + if (!hdnode_private_ckd(&node, 0x80000000u)) goto fail; + hdnode_fill_public_key(&node); + memcpy(out, node.public_key, 33); + memzero(&node, sizeof(node)); + return true; +fail: + memzero(&node, sizeof(node)); + return false; +} + +// ── SLIP-0048 multi-role key derivation ─────────────────────────────────── + +bool hive_getPublicKeys(const HDNode* root, uint32_t account_index, + char* owner_out, size_t owner_len, char* active_out, + size_t active_len, char* memo_out, size_t memo_len, + char* posting_out, size_t posting_len) { + const uint32_t roles[4] = { + HIVE_ROLE_OWNER, + HIVE_ROLE_ACTIVE, + HIVE_ROLE_MEMO, + HIVE_ROLE_POSTING, + }; + char* outs[4] = {owner_out, active_out, memo_out, posting_out}; + const size_t lens[4] = {owner_len, active_len, memo_len, posting_len}; + + uint32_t account_hardened = account_index | 0x80000000u; + + for (int i = 0; i < 4; i++) { + uint8_t raw[33]; + if (!hive_deriveRawKey(root, roles[i], account_hardened, raw)) return false; + if (!hive_getPublicKey(raw, outs[i], lens[i])) { + memzero(raw, sizeof(raw)); + return false; + } + memzero(raw, sizeof(raw)); + } + return true; +} + +// ── Graphene binary serialization helpers ───────────────────────────────── + +static void append_u8(uint8_t** buf, const uint8_t* end, uint8_t v) { + if (*buf < end) { + **buf = v; + (*buf)++; + } +} + +static void append_u16_le(uint8_t** buf, const uint8_t* end, uint16_t v) { + append_u8(buf, end, v & 0xFF); + append_u8(buf, end, (v >> 8) & 0xFF); +} + +static void append_u32_le(uint8_t** buf, const uint8_t* end, uint32_t v) { + append_u8(buf, end, v & 0xFF); + append_u8(buf, end, (v >> 8) & 0xFF); + append_u8(buf, end, (v >> 16) & 0xFF); + append_u8(buf, end, (v >> 24) & 0xFF); +} + +static void append_u64_le(uint8_t** buf, const uint8_t* end, uint64_t v) { + for (int i = 0; i < 8; i++) { + append_u8(buf, end, v & 0xFF); + v >>= 8; + } +} + +static void append_varint(uint8_t** buf, const uint8_t* end, uint64_t v) { + do { + uint8_t b = v & 0x7F; + v >>= 7; + if (v) b |= 0x80; + append_u8(buf, end, b); + } while (v); +} + +static void append_string(uint8_t** buf, const uint8_t* end, const char* s) { + size_t len = s ? strlen(s) : 0; + append_varint(buf, end, len); + for (size_t i = 0; i < len && *buf < end; i++) + append_u8(buf, end, (uint8_t)s[i]); +} + +/* + * Graphene asset encoding: int64 LE amount + uint8 precision + 7-byte symbol + */ +static void append_asset(uint8_t** buf, const uint8_t* end, uint64_t amount, + uint8_t precision, const char* symbol) { + append_u64_le(buf, end, amount); + append_u8(buf, end, precision); + char sym[7] = {0}; + if (symbol) strncpy(sym, symbol, 6); + for (int i = 0; i < 7 && *buf < end; i++) + append_u8(buf, end, (uint8_t)sym[i]); +} + +/* + * Graphene authority structure (Hive wire format): + * weight_threshold (uint32 LE) = 1 + * num_account_auths (varint) = 0 + * num_key_auths (varint) = 1 + * compressed public key (33 bytes, no type prefix) + * weight (uint16 LE) = 1 + * + * Note: Hive does NOT use a key-type prefix byte before the 33 raw bytes. + */ +static void append_authority(uint8_t** buf, const uint8_t* end, + const uint8_t pubkey[33]) { + append_u32_le(buf, end, 1); // weight_threshold = 1 + append_varint(buf, end, 0); // 0 account auths + append_varint(buf, end, 1); // 1 key auth + for (int i = 0; i < 33 && *buf < end; i++) append_u8(buf, end, pubkey[i]); + append_u16_le(buf, end, 1); // weight = 1 +} + +/* + * Common transaction header: ref_block_num, ref_block_prefix, expiration, + * then a varint op count = 1, then the op type varint. + */ +static void append_tx_header(uint8_t** buf, const uint8_t* end, + uint16_t ref_block_num, uint32_t ref_block_prefix, + uint32_t expiration, uint32_t op_type) { + append_u16_le(buf, end, ref_block_num); + append_u32_le(buf, end, ref_block_prefix); + append_u32_le(buf, end, expiration); + append_varint(buf, end, 1); // 1 operation + append_varint(buf, end, op_type); +} + +static void append_tx_footer(uint8_t** buf, const uint8_t* end) { + append_varint(buf, end, 0); // 0 extensions +} + +/* + * Graphene legacy canonical-signature rule (identical to EOS/Steem): high bit + * of both r and s must be clear — same predicate as eos_is_canonic. Modern + * hived (post-HF28) actually enforces only BIP-0062 low-S (fc is_canonical -> + * is_bip_0062_canonical), which trezor-crypto's low-S normalization already + * guarantees; keeping the stricter legacy rule costs an occasional extra + * RFC6979 iteration and stays compatible with every historical verifier. + */ +static int hive_is_canonic(uint8_t v, uint8_t signature[64]) { + (void)v; + return !(signature[0] & 0x80) && + !(signature[0] == 0 && !(signature[1] & 0x80)) && + !(signature[32] & 0x80) && + !(signature[32] == 0 && !(signature[33] & 0x80)); +} + +/* + * Core sign helper over an already-computed 32-byte digest → 65-byte + * compact recoverable sig: header (27 + recovery_id + 4 compressed-key + * flag), then r(32) ‖ s(32). + */ +static bool hive_sign_raw_digest(const HDNode* node, const uint8_t digest[32], + uint8_t sig[65]) { + uint8_t pby; + if (ecdsa_sign_digest(&secp256k1, node->private_key, digest, sig + 1, &pby, + hive_is_canonic) != 0) { + return false; + } + // Compact signature header: 27 + recovery_id + 4 (compressed key flag) + sig[0] = 27 + pby + 4; + return true; +} + +/* + * Transaction sign helper: SHA256(chain_id || serialized_tx) → compact sig. + * Writes 65 bytes into sig[]. Returns true on success. + */ +static bool hive_sign_digest(const HDNode* node, const uint8_t* chain_id, + const uint8_t* tx_buf, size_t tx_len, + uint8_t sig[65]) { + SHA256_CTX sha; + sha256_Init(&sha); + sha256_Update(&sha, chain_id, HIVE_CHAIN_ID_LEN); + sha256_Update(&sha, tx_buf, tx_len); + uint8_t digest[32]; + sha256_Final(&sha, digest); + + bool ok = hive_sign_raw_digest(node, digest, sig); + memzero(digest, sizeof(digest)); + return ok; +} + +/* + * Chain-id select (host-supplied 32-byte chain_id or mainnet default) + + * hive_sign_digest, writing the 65-byte compact signature into sig[]. + */ +static bool hive_sign_tx_sig(const HDNode* node, bool has_chain_id, + const uint8_t* chain_id_bytes, + size_t chain_id_size, const uint8_t* tx_buf, + size_t tx_len, uint8_t sig[65]) { + const uint8_t default_chain_id[32] = HIVE_CHAIN_ID; + /* Pin to Hive mainnet. A host-supplied chain_id is accepted only if it equals + * mainnet; any other value is refused rather than signed under an undisclosed + * network domain (the confirmations just say "Hive"). This also keeps the tx + * digest domain singular — SHA256(mainnet_chain_id || tx) — so the + * message-signing guard that rejects messages beginning with the mainnet + * chain id fully closes the tx/message signature collision. */ + if (has_chain_id) { + if (chain_id_size != HIVE_CHAIN_ID_LEN || + memcmp(chain_id_bytes, default_chain_id, HIVE_CHAIN_ID_LEN) != 0) { + return false; + } + } + return hive_sign_digest(node, default_chain_id, tx_buf, tx_len, sig); +} + +// ── Parsed operation signing (HiveSignOperations) ───────────────────────── +// +// The host serializes the transaction; firmware re-derives everything it +// displays from the bytes and refuses anything outside the phase-1 op table. +// Digest/signature are identical to HiveSignTx: SHA256(chain_id || tx). + +typedef struct { + const uint8_t* p; + const uint8_t* end; +} HiveCur; + +/* + * Bounded unsigned LEB128: at most 5 bytes, must fit uint32, overlong + * encodings rejected (an unbounded shift is a classic overflow hole). + */ +static bool cur_varint(HiveCur* c, uint32_t* out) { + uint32_t v = 0; + for (int shift = 0; shift <= 28; shift += 7) { + if (c->p >= c->end) return false; + uint8_t b = *c->p++; + if (shift == 28 && (b & 0xF0)) return false; // overflow or 6th byte + v |= (uint32_t)(b & 0x7F) << shift; + if (!(b & 0x80)) { + // A multi-byte LEB128 whose final group is zero has a shorter encoding. + // hived re-serializes values canonically when checking a signature, so + // accepting an overlong form would make the device sign bytes the chain + // interprets and hashes differently. + if (shift > 0 && (b & 0x7F) == 0) return false; + *out = v; + return true; + } + } + return false; +} + +/* varint length + bytes, bounds-checked against the buffer AND field caps. */ +static bool cur_string(HiveCur* c, const uint8_t** s, uint16_t* slen, + uint32_t min_len, uint32_t max_len) { + uint32_t n; + if (!cur_varint(c, &n)) return false; + if (n < min_len || n > max_len) return false; + if ((size_t)(c->end - c->p) < n) return false; + *s = c->p; + *slen = (uint16_t)n; + c->p += n; + return true; +} + +/* + * Hive account names are rendered in compact multi-field confirmation screens, + * so they must be valid protocol names rather than arbitrary byte strings. + * This rejects embedded NUL/newline/control bytes that would truncate or + * reshape the OLED while later bytes remained covered by the signature. + */ +static bool hive_account_name_valid(const uint8_t* s, uint16_t len, + bool allow_empty) { + if (len == 0) return allow_empty; + if (len < 3 || len > 16) return false; + + bool at_segment_start = true; + bool previous_hyphen = false; + for (uint16_t i = 0; i < len; i++) { + uint8_t ch = s[i]; + if (at_segment_start) { + if (ch < 'a' || ch > 'z') return false; + at_segment_start = false; + previous_hyphen = false; + } else if (ch == '.') { + if (previous_hyphen || i + 1 == len) return false; + at_segment_start = true; + } else if ((ch >= 'a' && ch <= 'z') || (ch >= '0' && ch <= '9')) { + previous_hyphen = false; + } else if (ch == '-') { + previous_hyphen = true; + } else { + return false; + } + } + return !at_segment_start && !previous_hyphen; +} + +static bool cur_account(HiveCur* c, const uint8_t** s, uint16_t* slen, + bool allow_empty) { + if (!cur_string(c, s, slen, allow_empty ? 0 : 1, 16)) return false; + return hive_account_name_valid(*s, *slen, allow_empty); +} + +static int hive_slice_cmp(const uint8_t* a, uint16_t a_len, const uint8_t* b, + uint16_t b_len) { + uint16_t min_len = a_len < b_len ? a_len : b_len; + int cmp = memcmp(a, b, min_len); + if (cmp != 0) return cmp; + return (a_len > b_len) - (a_len < b_len); +} + +/* Fixed-width little-endian readers, bounds-checked against the buffer. */ +static bool cur_u16(HiveCur* c, uint16_t* out) { + if ((size_t)(c->end - c->p) < 2) return false; + *out = (uint16_t)((uint16_t)c->p[0] | ((uint16_t)c->p[1] << 8)); + c->p += 2; + return true; +} + +static bool cur_u32(HiveCur* c, uint32_t* out) { + if ((size_t)(c->end - c->p) < 4) return false; + *out = (uint32_t)c->p[0] | ((uint32_t)c->p[1] << 8) | + ((uint32_t)c->p[2] << 16) | ((uint32_t)c->p[3] << 24); + c->p += 4; + return true; +} + +/* + * Graphene serializes bool as one byte. Anything other than 0/1 is a host + * serializer bug, not a truthy value — reject rather than normalize, so a + * malformed fill_or_kill or allow_votes can never be silently coerced. + */ +static bool cur_bool(HiveCur* c, bool* out) { + if (c->p >= c->end) return false; + uint8_t b = *c->p++; + if (b > 1) return false; + *out = (b == 1); + return true; +} + +uint64_t hive_assetAmount(const uint8_t* asset) { + uint64_t v = 0; + for (int i = 7; i >= 0; i--) v = (v << 8) | asset[i]; + return v; +} + +uint8_t hive_assetPrecision(const uint8_t* asset) { return asset[8]; } + +// Wire symbol → display symbol. The chain serializes the pre-rebrand names; +// the user knows the post-rebrand ones. cur_asset() has already validated the +// symbol and its NUL padding, so the compares below are exact. +const char* hive_assetSymbol(const uint8_t* asset) { + const char* sym = (const char*)(asset + 9); + if (memcmp(sym, "STEEM", 6) == 0) return "HIVE"; + if (memcmp(sym, "SBD", 4) == 0) return "HBD"; + return sym; +} + +/* + * One 16-byte Graphene asset: int64 LE amount, uint8 precision, 7-byte + * NUL-padded symbol. + * + * The symbol must be in `allowed` and carry its protocol-fixed precision. + * Both checks are load-bearing for display integrity: an unexpected symbol + * lets a host swap VESTS for HIVE (a ~2000x difference in real value behind + * an identical-looking number), and a wrong precision moves the decimal + * point on the confirmation screen relative to what the chain applies. + */ +static bool cur_asset(HiveCur* c, const uint8_t** out, uint32_t allowed) { + if ((size_t)(c->end - c->p) < HIVE_ASSET_LEN) return false; + const uint8_t* a = c->p; + const uint8_t* sym = a + 9; + + uint32_t bit; + uint8_t want_precision; + size_t sym_len; + // WIRE symbols, not display symbols: the 2020 rebrand renamed the tokens but + // NOT their on-chain serialization, so hived still encodes HIVE as "STEEM" + // and HBD as "SBD". Accepting the display spellings would let us sign bytes + // hived can never validate — its signature check re-serializes the operation + // and recovers a key from different bytes, surfacing as the misleading + // "missing required active authority". hive_assetSymbol() maps back for the + // OLED so the user still reads HIVE/HBD. + if (memcmp(sym, "STEEM", 5) == 0) { + bit = HIVE_SYM_HIVE; + want_precision = 3; + sym_len = 5; + } else if (memcmp(sym, "SBD", 3) == 0) { + bit = HIVE_SYM_HBD; + want_precision = 3; + sym_len = 3; + } else if (memcmp(sym, "VESTS", 5) == 0) { + bit = HIVE_SYM_VESTS; + want_precision = 6; + sym_len = 5; + } else { + return false; + } + // The prefix compares above would also accept a longer symbol sharing the + // prefix ("HBDX"); the padding check is what makes them exact, and it also + // guarantees hive_assetSymbol() returns a NUL-terminated C string. + for (size_t i = sym_len; i < 7; i++) { + if (sym[i] != 0) return false; + } + if (!(bit & allowed)) return false; + if (a[8] != want_precision) return false; + // Every asset field in this table is a quantity. A negative int64 would + // render as an enormous positive number through the unsigned formatter. + if (a[7] & 0x80) return false; + + *out = a; + c->p += HIVE_ASSET_LEN; + return true; +} + +/* + * Shared rejection reasons. + * + * These are diagnostics, not security surface: the protection is that the + * device REFUSES, and the host already knows which operation it sent. One + * bespoke sentence per failure site cost ~1.8KB of rodata on a part with + * single-digit KB of flash left, so failures are grouped by reason instead. + * The three that carry a distinct security meaning — an authority rotation, + * a detached comment_options, a wrong-tier request — stay separate so they + * are never confused with an ordinary parse failure in a bug report. + */ +static const char E_MALFORMED[] = "Hive tx: malformed operation"; +static const char E_RANGE[] = "Hive tx: value out of range"; +static const char E_AMOUNT[] = "Hive tx: amount must be greater than zero"; +static const char E_NOOP[] = "Hive tx: operation has no effect"; +static const char E_EXTENSIONS[] = "Hive tx: extensions must be empty"; +static const char E_BENEFICIARIES[] = "Hive tx: invalid beneficiaries"; +static const char E_SYMBOLS[] = "Hive tx: order symbols must differ"; +static const char E_AUTHORITY[] = "Hive tx: authority changes not supported"; +static const char E_BINDING[] = + "Hive tx: comment_options must follow its comment"; +static const char E_MIXED_TIER[] = "Hive tx: mixed posting/active ops"; + +const char* hive_parseOperations(const uint8_t* tx, size_t len, + HiveParsedTx* out) { + memzero(out, sizeof(*out)); + // 10-byte header + op_count varint + extensions varint is the structural + // minimum; op bodies are bounds-checked as they parse. + if (len < 12) return "Hive tx too short"; + if (len > HIVE_MAX_OPS_TX_LEN) return "Hive tx too long"; // = proto cap + + // Header (ref_block_num u16, ref_block_prefix u32, expiration u32) is + // covered by the signature but carries nothing to confirm on-device. + HiveCur c = {tx + 10, tx + len}; + + uint32_t op_count; + if (!cur_varint(&c, &op_count)) return E_MALFORMED; + if (op_count < 1 || op_count > HIVE_MAX_TX_OPS) + return "Hive tx: op count must be 1-4"; + out->num_ops = (uint8_t)op_count; + + bool any_posting = false, any_active = false; + + for (uint32_t i = 0; i < op_count; i++) { + HiveTxOp* op = &out->ops[i]; + uint32_t op_type; + if (!cur_varint(&c, &op_type)) return E_MALFORMED; + op->op_type = op_type; + + switch (op_type) { + case HIVE_OP_VOTE: { // posting authority + if (!cur_account(&c, &op->acct, &op->acct_len, false) || + !cur_account(&c, &op->target, &op->target_len, false) || + !cur_string(&c, &op->detail, &op->detail_len, 1, 256)) + return E_MALFORMED; + if ((size_t)(c.end - c.p) < 2) return E_MALFORMED; + int16_t w = (int16_t)((uint16_t)c.p[0] | ((uint16_t)c.p[1] << 8)); + c.p += 2; + if (w < -10000 || w > 10000) return E_RANGE; + op->weight = w; + any_posting = true; + break; + } + case HIVE_OP_COMMENT: { // posting authority + const uint8_t *pa, *ppl, *permlink, *jm; + uint16_t pa_len, ppl_len, permlink_len, jm_len; + if (!cur_account(&c, &pa, &pa_len, true) || + !cur_string(&c, &ppl, &ppl_len, 1, 256) || + !cur_account(&c, &op->acct, &op->acct_len, false) || + !cur_string(&c, &permlink, &permlink_len, 1, 256) || + !cur_string(&c, &op->target, &op->target_len, 0, 256) || + !cur_string(&c, &op->detail, &op->detail_len, 1, + HIVE_MAX_OPS_TX_LEN) || + !cur_string(&c, &jm, &jm_len, 0, HIVE_MAX_OPS_TX_LEN)) + return E_MALFORMED; + op->parent_author = pa; + op->parent_author_len = pa_len; + op->parent_permlink = ppl; + op->parent_permlink_len = ppl_len; + op->permlink = permlink; + op->permlink_len = permlink_len; + op->json_metadata = jm; + op->json_metadata_len = jm_len; + op->is_top_level = (pa_len == 0); + any_posting = true; + break; + } + case HIVE_OP_CUSTOM_JSON: { // posting OR active authority + uint32_t n_active, n_posting; + if (!cur_varint(&c, &n_active)) return E_MALFORMED; + if (n_active > HIVE_MAX_CUSTOM_JSON_AUTHS) return E_RANGE; + const uint8_t* previous_auth = NULL; + uint16_t previous_auth_len = 0; + for (uint32_t k = 0; k < n_active; k++) { + const uint8_t* s; + uint16_t sl; + if (!cur_account(&c, &s, &sl, false)) return E_MALFORMED; + if (previous_auth && + hive_slice_cmp(previous_auth, previous_auth_len, s, sl) >= 0) + return E_MALFORMED; + op->auth_acct[op->n_auths] = s; + op->auth_acct_len[op->n_auths++] = sl; + previous_auth = s; + previous_auth_len = sl; + if (!op->acct) { + op->acct = s; + op->acct_len = sl; + } + } + if (!cur_varint(&c, &n_posting)) return E_MALFORMED; + if (n_posting > HIVE_MAX_CUSTOM_JSON_AUTHS - n_active) return E_RANGE; + previous_auth = NULL; + previous_auth_len = 0; + for (uint32_t k = 0; k < n_posting; k++) { + const uint8_t* s; + uint16_t sl; + if (!cur_account(&c, &s, &sl, false)) return E_MALFORMED; + if (previous_auth && + hive_slice_cmp(previous_auth, previous_auth_len, s, sl) >= 0) + return E_MALFORMED; + op->auth_acct[op->n_auths] = s; + op->auth_acct_len[op->n_auths++] = sl; + previous_auth = s; + previous_auth_len = sl; + if (!op->acct) { + op->acct = s; + op->acct_len = sl; + } + } + if (n_active + n_posting == 0) return E_MALFORMED; + // Both tiers on one op can never be satisfied by a single signature + // (post-HF28 hived requires the exact authority) — malformed input. + if (n_active > 0 && n_posting > 0) return E_MIXED_TIER; + if (!cur_string(&c, &op->target, &op->target_len, 1, 32) || + !cur_string(&c, &op->detail, &op->detail_len, 1, + HIVE_MAX_OPS_TX_LEN)) + return E_MALFORMED; + op->needs_active = (n_active > 0); + if (op->needs_active) + any_active = true; + else + any_posting = true; + break; + } + case HIVE_OP_TRANSFER_TO_VESTING: { // active authority + // `to` may be empty — hived reads that as "power up to self". + if (!cur_account(&c, &op->acct, &op->acct_len, false) || + !cur_account(&c, &op->target, &op->target_len, true) || + !cur_asset(&c, &op->assets[0], HIVE_SYM_HIVE)) + return E_MALFORMED; + if (hive_assetAmount(op->assets[0]) == 0) return E_AMOUNT; + op->n_assets = 1; + any_active = true; + break; + } + case HIVE_OP_WITHDRAW_VESTING: { // active authority + // 0.000000 VESTS is meaningful here: it cancels an in-progress + // power-down, so zero must NOT be rejected. + if (!cur_account(&c, &op->acct, &op->acct_len, false) || + !cur_asset(&c, &op->assets[0], HIVE_SYM_VESTS)) + return E_MALFORMED; + op->n_assets = 1; + any_active = true; + break; + } + case HIVE_OP_LIMIT_ORDER_CREATE: { // active authority + if (!cur_account(&c, &op->acct, &op->acct_len, false) || + !cur_u32(&c, &op->req_id) || + !cur_asset(&c, &op->assets[0], HIVE_SYM_HIVE | HIVE_SYM_HBD) || + !cur_asset(&c, &op->assets[1], HIVE_SYM_HIVE | HIVE_SYM_HBD) || + !cur_bool(&c, &op->flag) || !cur_u32(&c, &op->expiration)) + return E_MALFORMED; + if (hive_assetAmount(op->assets[0]) == 0 || + hive_assetAmount(op->assets[1]) == 0) + return E_AMOUNT; + // The internal market only pairs HIVE against HBD. A same-symbol + // order is rejected on-chain anyway, and on the OLED it would read + // as a harmless self-trade while burning the fill. + if (memcmp(op->assets[0] + 9, op->assets[1] + 9, 7) == 0) + return E_SYMBOLS; + op->n_assets = 2; + any_active = true; + break; + } + case HIVE_OP_LIMIT_ORDER_CANCEL: { // active authority + if (!cur_account(&c, &op->acct, &op->acct_len, false) || + !cur_u32(&c, &op->req_id)) + return E_MALFORMED; + any_active = true; + break; + } + case HIVE_OP_CONVERT: { // active authority + if (!cur_account(&c, &op->acct, &op->acct_len, false) || + !cur_u32(&c, &op->req_id) || + !cur_asset(&c, &op->assets[0], HIVE_SYM_HBD)) + return E_MALFORMED; + if (hive_assetAmount(op->assets[0]) == 0) return E_AMOUNT; + op->n_assets = 1; + any_active = true; + break; + } + case HIVE_OP_COMMENT_OPTIONS: { // posting authority + uint16_t percent_hbd; + if (!cur_account(&c, &op->acct, &op->acct_len, false) || + !cur_string(&c, &op->permlink, &op->permlink_len, 1, 256) || + !cur_asset(&c, &op->assets[0], HIVE_SYM_HBD) || + !cur_u16(&c, &percent_hbd) || !cur_bool(&c, &op->flag) || + !cur_bool(&c, &op->flag2)) + return E_MALFORMED; + if (percent_hbd > 10000) return E_RANGE; + op->weight = (int16_t)percent_hbd; + op->n_assets = 1; + + // SECURITY: this op redirects a post's payout. It binds to exactly + // one post, so it is accepted ONLY immediately after a comment op + // with the same author and permlink. Standing alone it could attach + // beneficiaries to a post the user published earlier and is not + // reviewing on this screen. + if (i == 0 || out->ops[i - 1].op_type != HIVE_OP_COMMENT) + return E_BINDING; + const HiveTxOp* prev = &out->ops[i - 1]; + if (prev->acct_len != op->acct_len || + memcmp(prev->acct, op->acct, op->acct_len) != 0 || + prev->permlink_len != op->permlink_len || + memcmp(prev->permlink, op->permlink, op->permlink_len) != 0) + return E_BINDING; + + uint32_t ext_n; + if (!cur_varint(&c, &ext_n)) return E_MALFORMED; + // hived permits only one comment_payout_beneficiaries extension; + // two would let a host split 16 beneficiaries past a per-extension + // bound check. + if (ext_n > 1) return E_BENEFICIARIES; + if (ext_n == 1) { + uint32_t tag, n_benef; + if (!cur_varint(&c, &tag) || tag != 0) return E_BENEFICIARIES; + if (!cur_varint(&c, &n_benef) || n_benef < 1 || + n_benef > HIVE_MAX_BENEFICIARIES) + return E_BENEFICIARIES; + uint32_t weight_sum = 0; + const uint8_t* prev_acct = NULL; + uint16_t prev_acct_len = 0; + for (uint32_t k = 0; k < n_benef; k++) { + if (!cur_account(&c, &op->benef_acct[k], &op->benef_acct_len[k], + false) || + !cur_u16(&c, &op->benef_weight[k])) + return E_MALFORMED; + if (op->benef_weight[k] > 10000) return E_RANGE; + // hived requires strictly ascending account names, which also + // enforces uniqueness. An unsorted list is rejected on-chain, so + // signing it would only waste a device confirmation. + if (prev_acct) { + if (hive_slice_cmp(prev_acct, prev_acct_len, op->benef_acct[k], + op->benef_acct_len[k]) >= 0) + return E_BENEFICIARIES; + } + prev_acct = op->benef_acct[k]; + prev_acct_len = op->benef_acct_len[k]; + weight_sum += op->benef_weight[k]; + } + if (weight_sum > 10000) return E_BENEFICIARIES; + op->n_benef = (uint8_t)n_benef; + } + any_posting = true; + break; + } + case HIVE_OP_TRANSFER_TO_SAVINGS: { // active authority + if (!cur_account(&c, &op->acct, &op->acct_len, false) || + !cur_account(&c, &op->target, &op->target_len, false) || + !cur_asset(&c, &op->assets[0], HIVE_SYM_HIVE | HIVE_SYM_HBD) || + !cur_string(&c, &op->detail, &op->detail_len, 0, HIVE_MAX_MEMO_LEN)) + return E_MALFORMED; + if (hive_assetAmount(op->assets[0]) == 0) return E_AMOUNT; + op->n_assets = 1; + any_active = true; + break; + } + case HIVE_OP_TRANSFER_FROM_SAVINGS: { // active authority + if (!cur_account(&c, &op->acct, &op->acct_len, false) || + !cur_u32(&c, &op->req_id) || + !cur_account(&c, &op->target, &op->target_len, false) || + !cur_asset(&c, &op->assets[0], HIVE_SYM_HIVE | HIVE_SYM_HBD) || + !cur_string(&c, &op->detail, &op->detail_len, 0, HIVE_MAX_MEMO_LEN)) + return E_MALFORMED; + if (hive_assetAmount(op->assets[0]) == 0) return E_AMOUNT; + op->n_assets = 1; + any_active = true; + break; + } + case HIVE_OP_CLAIM_REWARD_BALANCE: { // posting authority + if (!cur_account(&c, &op->acct, &op->acct_len, false) || + !cur_asset(&c, &op->assets[0], HIVE_SYM_HIVE) || + !cur_asset(&c, &op->assets[1], HIVE_SYM_HBD) || + !cur_asset(&c, &op->assets[2], HIVE_SYM_VESTS)) + return E_MALFORMED; + if (hive_assetAmount(op->assets[0]) == 0 && + hive_assetAmount(op->assets[1]) == 0 && + hive_assetAmount(op->assets[2]) == 0) + return E_NOOP; + op->n_assets = 3; + any_posting = true; + break; + } + case HIVE_OP_DELEGATE_VESTING_SHARES: { // active authority + // 0.000000 VESTS is meaningful: it removes an existing delegation. + if (!cur_account(&c, &op->acct, &op->acct_len, false) || + !cur_account(&c, &op->target, &op->target_len, false) || + !cur_asset(&c, &op->assets[0], HIVE_SYM_VESTS)) + return E_MALFORMED; + op->n_assets = 1; + any_active = true; + break; + } + case HIVE_OP_ACCOUNT_UPDATE2: { // active or posting authority + uint32_t ext_n; + if (!cur_account(&c, &op->acct, &op->acct_len, false)) + return E_MALFORMED; + // SECURITY: account_update2 can rotate owner/active/posting/memo + // keys. Only the profile-metadata form is in the table — this is the + // op-9/10 device-derived-keys invariant applied field-level. Any + // authority field present is a hard reject; do NOT soften this + // without the authority-management design review. + for (int k = 0; k < 4; k++) { + bool present; + if (!cur_bool(&c, &present)) return E_MALFORMED; + if (present) return E_AUTHORITY; + } + if (!cur_string(&c, &op->detail, &op->detail_len, 0, + HIVE_MAX_OPS_TX_LEN) || + !cur_string(&c, &op->json_metadata, &op->json_metadata_len, 0, + HIVE_MAX_OPS_TX_LEN)) + return E_MALFORMED; + if (op->detail_len == 0 && op->json_metadata_len == 0) return E_NOOP; + if (!cur_varint(&c, &ext_n)) return E_MALFORMED; + if (ext_n != 0) return E_EXTENSIONS; + // json_metadata is an active-key field; a posting_json_metadata-only + // update is a posting-tier profile change. + op->needs_active = (op->detail_len > 0); + if (op->needs_active) + any_active = true; + else + any_posting = true; + break; + } + case HIVE_OP_TRANSFER: + case HIVE_OP_ACCOUNT_CREATE: + case HIVE_OP_ACCOUNT_UPDATE: + // PERMANENTLY excluded from this table: transfer keeps the stronger + // dedicated HiveSignTx display path; the account ops keep the + // device-derived-keys-only invariant (a generic raw-bytes path + // would let a host slip third-party authorities into an + // account_update). Never add these here. + return "Hive tx: op requires its dedicated message type"; + default: + return "Hive tx: unsupported operation type"; + } + } + + uint32_t ext_count; + if (!cur_varint(&c, &ext_count)) return E_MALFORMED; + if (ext_count != 0) return E_EXTENSIONS; + if (c.p != c.end) return "Hive tx: trailing bytes"; + + // One signature cannot satisfy posting- and active-tier ops at once. + if (any_posting && any_active) return E_MIXED_TIER; + out->needs_active = any_active; + return NULL; +} + +void hive_signOperations(const HDNode* node, const HiveSignOperations* msg, + HiveSignedOperations* resp) { + if (!msg->has_serialized_tx || msg->serialized_tx.size == 0 || + msg->serialized_tx.size > HIVE_MAX_OPS_TX_LEN) + return; + + // Hash straight from the decoded message — no stack copy of the 2KB tx. + if (!hive_sign_tx_sig(node, msg->has_chain_id, msg->chain_id.bytes, + msg->chain_id.size, msg->serialized_tx.bytes, + msg->serialized_tx.size, resp->signature.bytes)) { + return; + } + + resp->has_signature = true; + resp->signature.size = 65; +} + +// ── Message signing (Keychain signBuffer contract) ──────────────────────── +// Digest is SHA256(message bytes) ONLY: no chain_id prepend (unlike +// transactions) and no Bitcoin/Solana-style message prefix. hive-js +// Signature.signBuffer — which every Hive dApp verifies against — hashes +// the raw bytes exactly once; any added prefix silently breaks all dApp +// verification. + +bool hive_message_is_printable(const uint8_t* message, size_t len) { + for (size_t i = 0; i < len; i++) { + if (message[i] < 0x20 || message[i] > 0x7e) return false; + } + return true; +} + +void hive_signMessage(const HDNode* node, const HiveSignMessage* msg, + HiveSignedMessage* resp) { + if (!msg->has_message || msg->message.size > HIVE_MAX_MESSAGE_LEN) return; + + uint8_t digest[32]; + sha256_Raw(msg->message.bytes, msg->message.size, digest); + + uint8_t sig[65]; + if (!hive_sign_raw_digest(node, digest, sig)) { + memzero(digest, sizeof(digest)); + memzero(sig, sizeof(sig)); + return; + } + + resp->has_signature = true; + resp->signature.size = 65; + memcpy(resp->signature.bytes, sig, 65); + + // Caller must have run hdnode_fill_public_key(node). Returned so the host + // can build Keychain's publicKey response field without a second call. + resp->has_public_key = true; + resp->public_key.size = 33; + memcpy(resp->public_key.bytes, node->public_key, 33); + + memzero(digest, sizeof(digest)); + memzero(sig, sizeof(sig)); +} + +// ── Transfer (op type 2) ────────────────────────────────────────────────── + +static size_t hive_serialize_transfer(const HiveSignTx* msg, uint8_t* buf, + size_t buf_len) { + uint8_t* p = buf; + const uint8_t* end = buf + buf_len; + + append_tx_header(&p, end, (uint16_t)(msg->ref_block_num & 0xFFFF), + msg->ref_block_prefix, msg->expiration, HIVE_OP_TRANSFER); + + append_string(&p, end, msg->has_from ? msg->from : ""); + append_string(&p, end, msg->has_to ? msg->to : ""); + + const char* sym = msg->has_asset_symbol ? msg->asset_symbol : "HIVE"; + uint8_t prec = (uint8_t)(msg->has_decimals ? msg->decimals : HIVE_DECIMALS); + append_asset(&p, end, msg->amount, prec, sym); + + append_string(&p, end, msg->has_memo ? msg->memo : ""); + append_tx_footer(&p, end); + return (size_t)(p - buf); +} + +void hive_signTx(const HDNode* node, const HiveSignTx* msg, + HiveSignedTx* resp) { + // Reject memos that would overflow the fixed-size tx_buf. + if (msg->has_memo && strlen(msg->memo) > HIVE_MAX_MEMO_LEN) return; + + uint8_t tx_buf[512]; + size_t tx_len = hive_serialize_transfer(msg, tx_buf, sizeof(tx_buf)); + + if (!hive_sign_tx_sig(node, msg->has_chain_id, msg->chain_id.bytes, + msg->chain_id.size, tx_buf, tx_len, + resp->signature.bytes)) { + return; + } + + resp->has_signature = true; + resp->signature.size = 65; + + resp->has_serialized_tx = true; + resp->serialized_tx.size = tx_len; + memcpy(resp->serialized_tx.bytes, tx_buf, tx_len); + + memzero(tx_buf, tx_len); +} + +// ── Account create (op type 9) ──────────────────────────────────────────── +// +// All four role keys are device-derived by the caller (FSM handler) and +// passed as raw 33-byte compressed public keys. The firmware never uses +// host-supplied key strings for the actual transaction. + +static size_t hive_serialize_account_create(const HiveSignAccountCreate* msg, + const uint8_t owner_raw[33], + const uint8_t active_raw[33], + const uint8_t posting_raw[33], + const uint8_t memo_raw[33], + uint8_t* buf, size_t buf_len) { + uint8_t* p = buf; + const uint8_t* end = buf + buf_len; + + append_tx_header(&p, end, (uint16_t)(msg->ref_block_num & 0xFFFF), + msg->ref_block_prefix, msg->expiration, + HIVE_OP_ACCOUNT_CREATE); + + // fee (asset) + uint64_t fee = msg->has_fee_amount ? msg->fee_amount : 3000; + append_asset(&p, end, fee, HIVE_DECIMALS, "HIVE"); + + // creator + append_string(&p, end, msg->has_creator ? msg->creator : ""); + + // new_account_name + append_string(&p, end, + msg->has_new_account_name ? msg->new_account_name : ""); + + // authority fields use device-derived raw bytes (no host trust, no type + // prefix) + append_authority(&p, end, owner_raw); + append_authority(&p, end, active_raw); + append_authority(&p, end, posting_raw); + + // memo_key: 33 raw bytes, no authority wrapper, no type prefix byte + for (int i = 0; i < 33 && p < end; i++) append_u8(&p, end, memo_raw[i]); + + // json_metadata (empty) + append_string(&p, end, ""); + append_tx_footer(&p, end); + + return (size_t)(p - buf); +} + +void hive_signAccountCreate(const HDNode* signing_node, + const HiveSignAccountCreate* msg, + const uint8_t owner_raw[33], + const uint8_t active_raw[33], + const uint8_t posting_raw[33], + const uint8_t memo_raw[33], + HiveSignedAccountCreate* resp) { + uint8_t tx_buf[512]; + size_t tx_len = + hive_serialize_account_create(msg, owner_raw, active_raw, posting_raw, + memo_raw, tx_buf, sizeof(tx_buf)); + + if (!hive_sign_tx_sig(signing_node, msg->has_chain_id, msg->chain_id.bytes, + msg->chain_id.size, tx_buf, tx_len, + resp->signature.bytes)) { + memzero(tx_buf, sizeof(tx_buf)); + return; + } + + resp->has_signature = true; + resp->signature.size = 65; + + resp->has_serialized_tx = true; + resp->serialized_tx.size = tx_len; + memcpy(resp->serialized_tx.bytes, tx_buf, tx_len); + + memzero(tx_buf, tx_len); +} + +// ── Account update (op type 10) ─────────────────────────────────────────── +// +// All four new role keys are device-derived by the caller (FSM handler). +// The host-supplied new_*_key fields in the message are not used for signing. + +static size_t hive_serialize_account_update(const HiveSignAccountUpdate* msg, + const uint8_t owner_raw[33], + const uint8_t active_raw[33], + const uint8_t posting_raw[33], + const uint8_t memo_raw[33], + uint8_t* buf, size_t buf_len) { + uint8_t* p = buf; + const uint8_t* end = buf + buf_len; + + append_tx_header(&p, end, (uint16_t)(msg->ref_block_num & 0xFFFF), + msg->ref_block_prefix, msg->expiration, + HIVE_OP_ACCOUNT_UPDATE); + + // account name + append_string(&p, end, msg->has_account ? msg->account : ""); + + /* + * account_update optional authority fields use a Graphene "optional" wrapper: + * present: 0x01 + authority bytes + * absent: 0x00 + * We always include all four — this replaces all authorities. + */ + append_u8(&p, end, 0x01); // owner present + append_authority(&p, end, owner_raw); + append_u8(&p, end, 0x01); // active present + append_authority(&p, end, active_raw); + append_u8(&p, end, 0x01); // posting present + append_authority(&p, end, posting_raw); + + // memo_key: 33 raw bytes, always present, no type prefix byte + for (int i = 0; i < 33 && p < end; i++) append_u8(&p, end, memo_raw[i]); + + // json_metadata (empty) + append_string(&p, end, ""); + append_tx_footer(&p, end); + + return (size_t)(p - buf); +} + +void hive_signAccountUpdate(const HDNode* signing_node, + const HiveSignAccountUpdate* msg, + const uint8_t owner_raw[33], + const uint8_t active_raw[33], + const uint8_t posting_raw[33], + const uint8_t memo_raw[33], + HiveSignedAccountUpdate* resp) { + uint8_t tx_buf[512]; + size_t tx_len = + hive_serialize_account_update(msg, owner_raw, active_raw, posting_raw, + memo_raw, tx_buf, sizeof(tx_buf)); + + if (!hive_sign_tx_sig(signing_node, msg->has_chain_id, msg->chain_id.bytes, + msg->chain_id.size, tx_buf, tx_len, + resp->signature.bytes)) { + memzero(tx_buf, sizeof(tx_buf)); + return; + } + + resp->has_signature = true; + resp->signature.size = 65; + + resp->has_serialized_tx = true; + resp->serialized_tx.size = tx_len; + memcpy(resp->serialized_tx.bytes, tx_buf, tx_len); + + memzero(tx_buf, tx_len); +} diff --git a/lib/firmware/messagemap.def b/lib/firmware/messagemap.def index 1d057579b..3fe884809 100644 --- a/lib/firmware/messagemap.def +++ b/lib/firmware/messagemap.def @@ -184,6 +184,23 @@ MSG_OUT(MessageType_MessageType_ZcashAddress, ZcashAddress, NO_PROCESS_FUNC) #endif + /* Hive */ + MSG_IN(MessageType_MessageType_HiveGetPublicKey, HiveGetPublicKey, fsm_msgHiveGetPublicKey) + MSG_IN(MessageType_MessageType_HiveGetPublicKeys, HiveGetPublicKeys, fsm_msgHiveGetPublicKeys) + MSG_IN(MessageType_MessageType_HiveSignTx, HiveSignTx, fsm_msgHiveSignTx) + MSG_IN(MessageType_MessageType_HiveSignAccountCreate, HiveSignAccountCreate, fsm_msgHiveSignAccountCreate) + MSG_IN(MessageType_MessageType_HiveSignAccountUpdate, HiveSignAccountUpdate, fsm_msgHiveSignAccountUpdate) + MSG_IN(MessageType_MessageType_HiveSignMessage, HiveSignMessage, fsm_msgHiveSignMessage) + MSG_IN(MessageType_MessageType_HiveSignOperations, HiveSignOperations, fsm_msgHiveSignOperations) + + MSG_OUT(MessageType_MessageType_HivePublicKey, HivePublicKey, NO_PROCESS_FUNC) + MSG_OUT(MessageType_MessageType_HivePublicKeys, HivePublicKeys, NO_PROCESS_FUNC) + MSG_OUT(MessageType_MessageType_HiveSignedTx, HiveSignedTx, NO_PROCESS_FUNC) + MSG_OUT(MessageType_MessageType_HiveSignedAccountCreate, HiveSignedAccountCreate, NO_PROCESS_FUNC) + MSG_OUT(MessageType_MessageType_HiveSignedAccountUpdate, HiveSignedAccountUpdate, NO_PROCESS_FUNC) + MSG_OUT(MessageType_MessageType_HiveSignedMessage, HiveSignedMessage, NO_PROCESS_FUNC) + MSG_OUT(MessageType_MessageType_HiveSignedOperations, HiveSignedOperations, NO_PROCESS_FUNC) + #if DEBUG_LINK /* Debug Messages */ DEBUG_IN(MessageType_MessageType_DebugLinkDecision, DebugLinkDecision, NO_PROCESS_FUNC) diff --git a/lib/transport/CMakeLists.txt b/lib/transport/CMakeLists.txt index 805b27762..c8dd5c0ac 100644 --- a/lib/transport/CMakeLists.txt +++ b/lib/transport/CMakeLists.txt @@ -19,6 +19,7 @@ set(protoc_pb_sources ${DEVICE_PROTOCOL}/messages-tron.proto ${DEVICE_PROTOCOL}/messages-ton.proto ${DEVICE_PROTOCOL}/messages-zcash.proto + ${DEVICE_PROTOCOL}/messages-hive.proto ${DEVICE_PROTOCOL}/messages.proto) set(protoc_pb_options @@ -37,6 +38,7 @@ set(protoc_pb_options ${CMAKE_SOURCE_DIR}/include/keepkey/transport/messages-tron.options ${CMAKE_SOURCE_DIR}/include/keepkey/transport/messages-ton.options ${CMAKE_SOURCE_DIR}/include/keepkey/transport/messages-zcash.options + ${CMAKE_SOURCE_DIR}/include/keepkey/transport/messages-hive.options ${CMAKE_SOURCE_DIR}/include/keepkey/transport/messages.options) set(protoc_c_sources @@ -55,6 +57,7 @@ set(protoc_c_sources ${CMAKE_BINARY_DIR}/lib/transport/messages-tron.pb.c ${CMAKE_BINARY_DIR}/lib/transport/messages-ton.pb.c ${CMAKE_BINARY_DIR}/lib/transport/messages-zcash.pb.c + ${CMAKE_BINARY_DIR}/lib/transport/messages-hive.pb.c ${CMAKE_BINARY_DIR}/lib/transport/messages.pb.c) set(protoc_c_headers @@ -73,6 +76,7 @@ set(protoc_c_headers ${CMAKE_BINARY_DIR}/include/messages-tron.pb.h ${CMAKE_BINARY_DIR}/include/messages-ton.pb.h ${CMAKE_BINARY_DIR}/include/messages-zcash.pb.h + ${CMAKE_BINARY_DIR}/include/messages-hive.pb.h ${CMAKE_BINARY_DIR}/include/messages.pb.h) set(protoc_pb_sources_moved @@ -91,6 +95,7 @@ set(protoc_pb_sources_moved ${CMAKE_BINARY_DIR}/lib/transport/messages-tron.proto ${CMAKE_BINARY_DIR}/lib/transport/messages-ton.proto ${CMAKE_BINARY_DIR}/lib/transport/messages-zcash.proto + ${CMAKE_BINARY_DIR}/lib/transport/messages-hive.proto ${CMAKE_BINARY_DIR}/lib/transport/messages.proto) add_custom_command( @@ -172,6 +177,10 @@ add_custom_command( ${PROTOC_BINARY} -I. -I/usr/include --plugin=nanopb=${NANOPB_DIR}/generator/protoc-gen-nanopb "--nanopb_out=-f messages-zcash.options:." messages-zcash.proto + COMMAND + ${PROTOC_BINARY} -I. -I/usr/include + --plugin=nanopb=${NANOPB_DIR}/generator/protoc-gen-nanopb + "--nanopb_out=-f messages-hive.options:." messages-hive.proto COMMAND ${PROTOC_BINARY} -I. -I/usr/include --plugin=nanopb=${NANOPB_DIR}/generator/protoc-gen-nanopb diff --git a/unittests/firmware/CMakeLists.txt b/unittests/firmware/CMakeLists.txt index b76de6be1..03cab2447 100644 --- a/unittests/firmware/CMakeLists.txt +++ b/unittests/firmware/CMakeLists.txt @@ -8,6 +8,7 @@ set(sources eos.cpp eip712.cpp ethereum.cpp + hive.cpp mayachain.cpp nano.cpp osmosis.cpp diff --git a/unittests/firmware/hive.cpp b/unittests/firmware/hive.cpp new file mode 100644 index 000000000..4a90cdba0 --- /dev/null +++ b/unittests/firmware/hive.cpp @@ -0,0 +1,983 @@ +extern "C" { +#include "keepkey/board/font.h" +#include "keepkey/board/layout.h" +#include "keepkey/firmware/hive.h" +} + +#include "gtest/gtest.h" + +#include +#include +#include +#include + +namespace { + +void append_varint(std::vector& out, uint32_t value) { + do { + uint8_t byte = static_cast(value & 0x7f); + value >>= 7; + if (value != 0) byte |= 0x80; + out.push_back(byte); + } while (value != 0); +} + +void append_u16_le(std::vector& out, uint16_t value) { + out.push_back(static_cast(value)); + out.push_back(static_cast(value >> 8)); +} + +void append_u32_le(std::vector& out, uint32_t value) { + for (int i = 0; i < 4; i++) { + out.push_back(static_cast(value >> (8 * i))); + } +} + +void append_string(std::vector& out, const std::string& value) { + append_varint(out, static_cast(value.size())); + out.insert(out.end(), value.begin(), value.end()); +} + +std::string slice(const uint8_t* value, uint16_t len) { + return std::string(reinterpret_cast(value), len); +} + +std::vector comment_tx(const std::string& parent_author, + const std::string& parent_permlink, + const std::string& author, + const std::string& permlink, + const std::string& title, + const std::string& body, + const std::string& json_metadata) { + std::vector tx; + append_u16_le(tx, 12345); + append_u32_le(tx, 67890); + append_u32_le(tx, 1700000000); + append_varint(tx, 1); + append_varint(tx, HIVE_OP_COMMENT); + append_string(tx, parent_author); + append_string(tx, parent_permlink); + append_string(tx, author); + append_string(tx, permlink); + append_string(tx, title); + append_string(tx, body); + append_string(tx, json_metadata); + append_varint(tx, 0); + return tx; +} + +// Call sites pass DISPLAY symbols ("HIVE"/"HBD") because that is what the test +// is about; this helper writes what the chain actually serializes. Verified +// against hived itself via condenser_api.get_transaction_hex — see +// Hive.SerializationMatchesHived. +std::string wire_symbol(const std::string& display) { + if (display == "HIVE") return "STEEM"; + if (display == "HBD") return "SBD"; + return display; +} + +void append_asset(std::vector& out, int64_t amount, uint8_t precision, + const std::string& symbol) { + const std::string wire = wire_symbol(symbol); + uint64_t raw = static_cast(amount); + for (int i = 0; i < 8; i++) { + out.push_back(static_cast(raw >> (8 * i))); + } + out.push_back(precision); + for (size_t i = 0; i < 7; i++) { + out.push_back(i < wire.size() ? static_cast(wire[i]) : 0); + } +} + +// Wrap already-serialized ops in the 10-byte TaPoS header, op count and the +// empty extensions varint that hive_parseOperations expects. +std::vector wrap_ops(const std::vector>& ops) { + std::vector tx; + append_u16_le(tx, 12345); + append_u32_le(tx, 67890); + append_u32_le(tx, 1700000000); + append_varint(tx, static_cast(ops.size())); + for (const std::vector& op : ops) { + tx.insert(tx.end(), op.begin(), op.end()); + } + append_varint(tx, 0); + return tx; +} + +std::vector limit_order_create_op( + const std::string& owner, uint32_t orderid, int64_t sell, + const std::string& sell_symbol, int64_t receive, + const std::string& receive_symbol, bool fill_or_kill, uint32_t expiration) { + std::vector op; + append_varint(op, HIVE_OP_LIMIT_ORDER_CREATE); + append_string(op, owner); + append_u32_le(op, orderid); + append_asset(op, sell, 3, sell_symbol); + append_asset(op, receive, 3, receive_symbol); + op.push_back(fill_or_kill ? 1 : 0); + append_u32_le(op, expiration); + return op; +} + +// A limit order priced in VESTS at its CORRECT precision (6), so the +// rejection comes from the symbol whitelist rather than the precision check. +std::vector limit_order_vests_op() { + std::vector op; + append_varint(op, HIVE_OP_LIMIT_ORDER_CREATE); + append_string(op, "alice"); + append_u32_le(op, 1); + append_asset(op, 100, 6, "VESTS"); + append_asset(op, 100, 3, "HBD"); + op.push_back(0); + append_u32_le(op, 1); + return op; +} + +// transfer_to_vesting with a caller-chosen symbol/precision, so the asset +// validator can be probed with values a correct host would never send. +std::vector power_up_op(int64_t amount, uint8_t precision, + const std::string& symbol) { + std::vector op; + append_varint(op, HIVE_OP_TRANSFER_TO_VESTING); + append_string(op, "alice"); + append_string(op, "bob"); + append_asset(op, amount, precision, symbol); + return op; +} + +std::vector comment_op(const std::string& author, + const std::string& permlink) { + std::vector op; + append_varint(op, HIVE_OP_COMMENT); + append_string(op, ""); + append_string(op, "hive-100"); + append_string(op, author); + append_string(op, permlink); + append_string(op, "Title"); + append_string(op, "Body"); + append_string(op, "{}"); + return op; +} + +// beneficiaries: (account, basis-point weight) pairs; empty = no extension. +std::vector comment_options_op( + const std::string& author, const std::string& permlink, + const std::vector>& beneficiaries) { + std::vector op; + append_varint(op, HIVE_OP_COMMENT_OPTIONS); + append_string(op, author); + append_string(op, permlink); + append_asset(op, 1000000, 3, "HBD"); + append_u16_le(op, 10000); + op.push_back(1); + op.push_back(1); + if (beneficiaries.empty()) { + append_varint(op, 0); + } else { + append_varint(op, 1); + append_varint(op, 0); + append_varint(op, static_cast(beneficiaries.size())); + for (const auto& b : beneficiaries) { + append_string(op, b.first); + append_u16_le(op, b.second); + } + } + return op; +} + +std::vector account_update2_op(const std::string& json_metadata, + const std::string& posting_metadata, + bool authority_present) { + std::vector op; + append_varint(op, HIVE_OP_ACCOUNT_UPDATE2); + append_string(op, "alice"); + op.push_back(authority_present ? 1 : 0); + op.push_back(0); + op.push_back(0); + op.push_back(0); + append_string(op, json_metadata); + append_string(op, posting_metadata); + append_varint(op, 0); + return op; +} + +std::vector custom_json_op( + const std::vector& active_auths, + const std::vector& posting_auths, const std::string& id, + const std::string& json) { + std::vector op; + append_varint(op, HIVE_OP_CUSTOM_JSON); + append_varint(op, static_cast(active_auths.size())); + for (const std::string& auth : active_auths) append_string(op, auth); + append_varint(op, static_cast(posting_auths.size())); + for (const std::string& auth : posting_auths) append_string(op, auth); + append_string(op, id); + append_string(op, json); + return op; +} + +} // namespace + +TEST(Hive, Slip48PathValidation) { + uint32_t path[5] = {HIVE_SLIP48_PURPOSE, HIVE_SLIP48_NETWORK, + HIVE_ROLE_ACTIVE, 0x80000007u, 0x80000000u}; + + EXPECT_TRUE(hive_slip48_path_valid(path, 5)); + EXPECT_TRUE(hive_slip48_path_valid_for_role(path, 5, HIVE_ROLE_ACTIVE)); + EXPECT_FALSE(hive_slip48_path_valid_for_role(path, 5, HIVE_ROLE_OWNER)); + EXPECT_FALSE(hive_slip48_path_valid(path, 4)); + + path[0] = 0x8000002cu; + EXPECT_FALSE(hive_slip48_path_valid(path, 5)); + path[0] = HIVE_SLIP48_PURPOSE; + path[1] = 0x8000003cu; + EXPECT_FALSE(hive_slip48_path_valid(path, 5)); + path[1] = HIVE_SLIP48_NETWORK; + path[2] = 0x80000002u; + EXPECT_FALSE(hive_slip48_path_valid(path, 5)); + path[2] = HIVE_ROLE_ACTIVE; + path[3] = 7; + EXPECT_FALSE(hive_slip48_path_valid(path, 5)); + path[3] = 0x80000007u; + path[4] = 0; + EXPECT_FALSE(hive_slip48_path_valid(path, 5)); +} + +TEST(Hive, CommentParserRetainsEveryDisplayedField) { + std::vector tx = comment_tx( + "parent-author", "parent-permlink", "reply-author", "reply-permlink", + "Reply title", "Complete reply body", "{\"tags\":[\"keepkey\"]}"); + + HiveParsedTx parsed; + ASSERT_EQ(nullptr, hive_parseOperations(tx.data(), tx.size(), &parsed)); + ASSERT_EQ(1, parsed.num_ops); + const HiveTxOp& op = parsed.ops[0]; + EXPECT_FALSE(op.is_top_level); + EXPECT_EQ("reply-author", slice(op.acct, op.acct_len)); + EXPECT_EQ("parent-author", slice(op.parent_author, op.parent_author_len)); + EXPECT_EQ("parent-permlink", + slice(op.parent_permlink, op.parent_permlink_len)); + EXPECT_EQ("reply-permlink", slice(op.permlink, op.permlink_len)); + EXPECT_EQ("Reply title", slice(op.target, op.target_len)); + EXPECT_EQ("Complete reply body", slice(op.detail, op.detail_len)); + EXPECT_EQ("{\"tags\":[\"keepkey\"]}", + slice(op.json_metadata, op.json_metadata_len)); +} + +// Message signing is restricted to printable ASCII so a message can never be a +// binary transaction preimage (chain_id || serialized_tx) on any chain id. +TEST(Hive, MessagePrintableAcceptsAsciiRejectsBinary) { + const char* login = "keepkey-login-challenge:1700000000"; + EXPECT_TRUE(hive_message_is_printable(reinterpret_cast(login), + strlen(login))); + + // Empty message is trivially printable. + EXPECT_TRUE( + hive_message_is_printable(reinterpret_cast(""), 0)); + + // Any non-printable byte (control char / high bit) is refused. + const uint8_t withNul[] = {'h', 'i', 0x00, 'x'}; + EXPECT_FALSE(hive_message_is_printable(withNul, sizeof(withNul))); + const uint8_t highBit[] = {'o', 'k', 0x80}; + EXPECT_FALSE(hive_message_is_printable(highBit, sizeof(highBit))); + + // The oracle vector: a "message" that begins with the binary mainnet chain id + // (beeab0de00...) followed by a serialized tx. The leading 0xbe/0xea/0x00 + // bytes are non-printable, so this can never be signed as a message. + const uint8_t chainIdPrefixed[] = {0xbe, 0xea, 0xb0, 0xde, 0x00, + 0x00, 0x00, 't', 'x'}; + EXPECT_FALSE( + hive_message_is_printable(chainIdPrefixed, sizeof(chainIdPrefixed))); +} + +TEST(Hive, TopLevelCommentRetainsCategoryAndEmptyTitle) { + std::vector tx = comment_tx("", "hive-123456", "post-author", + "post-permlink", "", "Post body", "{}"); + + HiveParsedTx parsed; + ASSERT_EQ(nullptr, hive_parseOperations(tx.data(), tx.size(), &parsed)); + const HiveTxOp& op = parsed.ops[0]; + EXPECT_TRUE(op.is_top_level); + EXPECT_EQ(0, op.parent_author_len); + EXPECT_EQ("hive-123456", slice(op.parent_permlink, op.parent_permlink_len)); + EXPECT_EQ("post-permlink", slice(op.permlink, op.permlink_len)); + EXPECT_EQ(0, op.target_len); + EXPECT_EQ("{}", slice(op.json_metadata, op.json_metadata_len)); +} + +TEST(Hive, RejectsNonCanonicalVarints) { + std::vector op; + append_varint(op, HIVE_OP_VOTE); + append_string(op, "alice"); + append_string(op, "bob"); + append_string(op, "post"); + append_u16_le(op, 10000); + + HiveParsedTx parsed; + + // Operation count 1 encoded as 0x81 0x00 instead of canonical 0x01. + std::vector overlong_count = wrap_ops({op}); + overlong_count[10] = 0x81; + overlong_count.insert(overlong_count.begin() + 11, 0x00); + EXPECT_NE(nullptr, hive_parseOperations(overlong_count.data(), + overlong_count.size(), &parsed)); + + // The voter string length 5 encoded as 0x85 0x00. + std::vector overlong_string = wrap_ops({op}); + overlong_string[12] = 0x85; + overlong_string.insert(overlong_string.begin() + 13, 0x00); + EXPECT_NE(nullptr, hive_parseOperations(overlong_string.data(), + overlong_string.size(), &parsed)); +} + +TEST(Hive, RejectsAccountNamesThatCanSpoofTheDisplay) { + HiveParsedTx parsed; + const std::vector invalid = { + "al\nice", std::string("ali\0ce", 6), "Alice", "alice-", ".alice", "a"}; + for (const std::string& account : invalid) { + std::vector op; + append_varint(op, HIVE_OP_TRANSFER_TO_SAVINGS); + append_string(op, account); + append_string(op, "bob"); + append_asset(op, 1000, 3, "HIVE"); + append_string(op, ""); + std::vector tx = wrap_ops({op}); + EXPECT_NE(nullptr, hive_parseOperations(tx.data(), tx.size(), &parsed)); + } +} + +TEST(Hive, CustomJsonRetainsAndBoundsEveryAuthorization) { + HiveParsedTx parsed; + std::vector tx = + wrap_ops({custom_json_op({}, {"alice", "bob", "carol"}, "follow", "[]")}); + ASSERT_EQ(nullptr, hive_parseOperations(tx.data(), tx.size(), &parsed)); + const HiveTxOp& op = parsed.ops[0]; + ASSERT_EQ(3, op.n_auths); + EXPECT_EQ("alice", slice(op.auth_acct[0], op.auth_acct_len[0])); + EXPECT_EQ("bob", slice(op.auth_acct[1], op.auth_acct_len[1])); + EXPECT_EQ("carol", slice(op.auth_acct[2], op.auth_acct_len[2])); + EXPECT_FALSE(parsed.needs_active); + + std::vector too_many = wrap_ops({custom_json_op( + {}, {"alice", "bob", "carol", "dave", "erin"}, "follow", "[]")}); + EXPECT_NE(nullptr, + hive_parseOperations(too_many.data(), too_many.size(), &parsed)); + + std::vector unsorted = + wrap_ops({custom_json_op({}, {"bob", "alice"}, "follow", "[]")}); + EXPECT_NE(nullptr, + hive_parseOperations(unsorted.data(), unsorted.size(), &parsed)); + + std::vector duplicate = + wrap_ops({custom_json_op({}, {"alice", "alice"}, "follow", "[]")}); + EXPECT_NE(nullptr, + hive_parseOperations(duplicate.data(), duplicate.size(), &parsed)); +} + +TEST(Hive, DisplayPaginationUsesRenderedBodyRows) { + const std::string payload = + "%%%%%%%%%%%%%%%% %%%%%%%%%%%%%%%% %%%%%%%%%%%%%%%% %%%%%%%%%%%%%%%%"; + ASSERT_GT(calc_str_line(get_body_font(), payload.c_str(), BODY_WIDTH), + BODY_ROWS); + + std::string reconstructed; + size_t offset = 0; + unsigned pages = 0; + while (offset < payload.size()) { + size_t take = calc_str_page(get_body_font(), payload.data() + offset, + payload.size() - offset, BODY_WIDTH, BODY_ROWS); + ASSERT_GT(take, 0u); + const std::string page = payload.substr(offset, take); + EXPECT_LE(calc_str_line(get_body_font(), page.c_str(), BODY_WIDTH), + BODY_ROWS); + reconstructed += page; + offset += take; + pages++; + } + + EXPECT_GT(pages, 1u); + EXPECT_EQ(payload, reconstructed); +} + +// ── Phase-3 op table ──────────────────────────────────────────────────────── + +// The op that started this: a HIVE->HBD internal-market swap. Every field the +// approval screen shows must survive the parse. +TEST(Hive, LimitOrderCreateRetainsEveryDisplayedField) { + std::vector tx = wrap_ops({limit_order_create_op( + "alice", 42, 1500, "HIVE", 400, "HBD", true, 1700003600)}); + + HiveParsedTx parsed; + ASSERT_EQ(nullptr, hive_parseOperations(tx.data(), tx.size(), &parsed)); + ASSERT_EQ(1, parsed.num_ops); + const HiveTxOp& op = parsed.ops[0]; + EXPECT_EQ(HIVE_OP_LIMIT_ORDER_CREATE, op.op_type); + EXPECT_EQ("alice", slice(op.acct, op.acct_len)); + EXPECT_EQ(42u, op.req_id); + EXPECT_EQ(1700003600u, op.expiration); + EXPECT_TRUE(op.flag); // fill_or_kill + ASSERT_EQ(2, op.n_assets); + EXPECT_EQ(1500u, hive_assetAmount(op.assets[0])); + EXPECT_STREQ("HIVE", hive_assetSymbol(op.assets[0])); + EXPECT_EQ(3, hive_assetPrecision(op.assets[0])); + EXPECT_EQ(400u, hive_assetAmount(op.assets[1])); + EXPECT_STREQ("HBD", hive_assetSymbol(op.assets[1])); + // Trading needs the active key. + EXPECT_TRUE(parsed.needs_active); +} + +TEST(Hive, LimitOrderRejectsDegenerateOrders) { + HiveParsedTx parsed; + + // A same-symbol pair is a no-op trade on screen but still burns the fill. + std::vector same = wrap_ops( + {limit_order_create_op("alice", 1, 100, "HIVE", 100, "HIVE", false, 1)}); + EXPECT_NE(nullptr, hive_parseOperations(same.data(), same.size(), &parsed)); + + std::vector zero_sell = wrap_ops( + {limit_order_create_op("alice", 1, 0, "HIVE", 100, "HBD", false, 1)}); + EXPECT_NE(nullptr, + hive_parseOperations(zero_sell.data(), zero_sell.size(), &parsed)); + + std::vector zero_recv = wrap_ops( + {limit_order_create_op("alice", 1, 100, "HIVE", 0, "HBD", false, 1)}); + EXPECT_NE(nullptr, + hive_parseOperations(zero_recv.data(), zero_recv.size(), &parsed)); + + // VESTS never trades on the internal market. + std::vector vests = wrap_ops({limit_order_vests_op()}); + EXPECT_NE(nullptr, hive_parseOperations(vests.data(), vests.size(), &parsed)); +} + +TEST(Hive, LimitOrderCancelParses) { + std::vector op; + append_varint(op, HIVE_OP_LIMIT_ORDER_CANCEL); + append_string(op, "alice"); + append_u32_le(op, 42); + std::vector tx = wrap_ops({op}); + + HiveParsedTx parsed; + ASSERT_EQ(nullptr, hive_parseOperations(tx.data(), tx.size(), &parsed)); + EXPECT_EQ("alice", slice(parsed.ops[0].acct, parsed.ops[0].acct_len)); + EXPECT_EQ(42u, parsed.ops[0].req_id); + EXPECT_TRUE(parsed.needs_active); +} + +// The asset validator is what stops a host from moving the decimal point or +// swapping a ~2000x-more-valuable symbol behind an identical-looking number. +TEST(Hive, AssetValidatorPinsSymbolAndPrecision) { + HiveParsedTx parsed; + + std::vector ok = wrap_ops({power_up_op(1000, 3, "HIVE")}); + EXPECT_EQ(nullptr, hive_parseOperations(ok.data(), ok.size(), &parsed)); + + // transfer_to_vesting is HIVE-only; HBD and VESTS are out of the whitelist. + std::vector hbd = wrap_ops({power_up_op(1000, 3, "HBD")}); + EXPECT_NE(nullptr, hive_parseOperations(hbd.data(), hbd.size(), &parsed)); + std::vector vests = wrap_ops({power_up_op(1000, 6, "VESTS")}); + EXPECT_NE(nullptr, hive_parseOperations(vests.data(), vests.size(), &parsed)); + + // Right symbol, wrong precision: 1000 would render as 0.001 vs 1.000. + std::vector prec = wrap_ops({power_up_op(1000, 6, "HIVE")}); + EXPECT_NE(nullptr, hive_parseOperations(prec.data(), prec.size(), &parsed)); + + // A negative int64 would print as an enormous positive number. + std::vector negative = wrap_ops({power_up_op(-1000, 3, "HIVE")}); + EXPECT_NE(nullptr, + hive_parseOperations(negative.data(), negative.size(), &parsed)); + + // Unknown symbol, and a longer symbol sharing an accepted prefix. + std::vector unknown = wrap_ops({power_up_op(1000, 3, "SBD")}); + EXPECT_NE(nullptr, + hive_parseOperations(unknown.data(), unknown.size(), &parsed)); + std::vector prefixed = wrap_ops({power_up_op(1000, 3, "HIVEX")}); + EXPECT_NE(nullptr, + hive_parseOperations(prefixed.data(), prefixed.size(), &parsed)); +} + +// Zero is a real instruction for some ops and nonsense for others; the parser +// must not apply one blanket rule. +TEST(Hive, ZeroAmountSemanticsDifferPerOp) { + HiveParsedTx parsed; + + // Zero HIVE power-up: nothing to do, reject. + std::vector power_up = wrap_ops({power_up_op(0, 3, "HIVE")}); + EXPECT_NE(nullptr, + hive_parseOperations(power_up.data(), power_up.size(), &parsed)); + + // Zero VESTS withdraw_vesting: cancels an in-progress power-down, accept. + std::vector stop_pd; + { + std::vector op; + append_varint(op, HIVE_OP_WITHDRAW_VESTING); + append_string(op, "alice"); + append_asset(op, 0, 6, "VESTS"); + stop_pd = wrap_ops({op}); + } + EXPECT_EQ(nullptr, + hive_parseOperations(stop_pd.data(), stop_pd.size(), &parsed)); + + // Zero VESTS delegation: removes an existing delegation, accept. + std::vector undelegate; + { + std::vector op; + append_varint(op, HIVE_OP_DELEGATE_VESTING_SHARES); + append_string(op, "alice"); + append_string(op, "bob"); + append_asset(op, 0, 6, "VESTS"); + undelegate = wrap_ops({op}); + } + EXPECT_EQ(nullptr, hive_parseOperations(undelegate.data(), undelegate.size(), + &parsed)); + + // claim_reward_balance with all three at zero: nothing to claim, reject. + std::vector empty_claim; + { + std::vector op; + append_varint(op, HIVE_OP_CLAIM_REWARD_BALANCE); + append_string(op, "alice"); + append_asset(op, 0, 3, "HIVE"); + append_asset(op, 0, 3, "HBD"); + append_asset(op, 0, 6, "VESTS"); + empty_claim = wrap_ops({op}); + } + EXPECT_NE(nullptr, hive_parseOperations(empty_claim.data(), + empty_claim.size(), &parsed)); +} + +TEST(Hive, ClaimRewardBalanceKeepsAssetOrder) { + std::vector op; + append_varint(op, HIVE_OP_CLAIM_REWARD_BALANCE); + append_string(op, "alice"); + append_asset(op, 1234, 3, "HIVE"); + append_asset(op, 5678, 3, "HBD"); + append_asset(op, 90123456, 6, "VESTS"); + std::vector tx = wrap_ops({op}); + + HiveParsedTx parsed; + ASSERT_EQ(nullptr, hive_parseOperations(tx.data(), tx.size(), &parsed)); + ASSERT_EQ(3, parsed.ops[0].n_assets); + EXPECT_EQ(1234u, hive_assetAmount(parsed.ops[0].assets[0])); + EXPECT_STREQ("HIVE", hive_assetSymbol(parsed.ops[0].assets[0])); + EXPECT_EQ(5678u, hive_assetAmount(parsed.ops[0].assets[1])); + EXPECT_STREQ("HBD", hive_assetSymbol(parsed.ops[0].assets[1])); + EXPECT_EQ(90123456u, hive_assetAmount(parsed.ops[0].assets[2])); + EXPECT_STREQ("VESTS", hive_assetSymbol(parsed.ops[0].assets[2])); + // Claiming rewards is a posting-tier action. + EXPECT_FALSE(parsed.needs_active); +} + +// SECURITY: comment_options redirects a post's payout. Detached from its +// comment it could retarget a post the user published earlier and is not +// reviewing on screen. +TEST(Hive, CommentOptionsMustBindToItsComment) { + HiveParsedTx parsed; + + std::vector alone = + wrap_ops({comment_options_op("alice", "my-post", {})}); + EXPECT_NE(nullptr, hive_parseOperations(alone.data(), alone.size(), &parsed)); + + std::vector wrong_permlink = + wrap_ops({comment_op("alice", "my-post"), + comment_options_op("alice", "other-post", {})}); + EXPECT_NE(nullptr, hive_parseOperations(wrong_permlink.data(), + wrong_permlink.size(), &parsed)); + + std::vector wrong_author = + wrap_ops({comment_op("alice", "my-post"), + comment_options_op("mallory", "my-post", {})}); + EXPECT_NE(nullptr, hive_parseOperations(wrong_author.data(), + wrong_author.size(), &parsed)); + + std::vector ok = + wrap_ops({comment_op("alice", "my-post"), + comment_options_op("alice", "my-post", {})}); + ASSERT_EQ(nullptr, hive_parseOperations(ok.data(), ok.size(), &parsed)); + EXPECT_EQ(2, parsed.num_ops); + EXPECT_EQ(10000, parsed.ops[1].weight); // percent_hbd + EXPECT_FALSE(parsed.needs_active); +} + +TEST(Hive, CommentOptionsBeneficiaryRules) { + HiveParsedTx parsed; + + std::vector ok = + wrap_ops({comment_op("alice", "my-post"), + comment_options_op("alice", "my-post", + {{"aaron", 1000}, {"zoe", 500}})}); + ASSERT_EQ(nullptr, hive_parseOperations(ok.data(), ok.size(), &parsed)); + ASSERT_EQ(2, parsed.ops[1].n_benef); + EXPECT_EQ("aaron", slice(parsed.ops[1].benef_acct[0], + parsed.ops[1].benef_acct_len[0])); + EXPECT_EQ(1000, parsed.ops[1].benef_weight[0]); + EXPECT_EQ("zoe", slice(parsed.ops[1].benef_acct[1], + parsed.ops[1].benef_acct_len[1])); + + // hived requires strictly ascending names; unsorted is rejected on-chain. + std::vector unsorted = + wrap_ops({comment_op("alice", "my-post"), + comment_options_op("alice", "my-post", + {{"zoe", 500}, {"aaron", 1000}})}); + EXPECT_NE(nullptr, + hive_parseOperations(unsorted.data(), unsorted.size(), &parsed)); + + // Duplicates are the same violation. + std::vector duped = + wrap_ops({comment_op("alice", "my-post"), + comment_options_op("alice", "my-post", + {{"aaron", 500}, {"aaron", 500}})}); + EXPECT_NE(nullptr, hive_parseOperations(duped.data(), duped.size(), &parsed)); + + // Weights may not add up to more than 100%. + std::vector overweight = + wrap_ops({comment_op("alice", "my-post"), + comment_options_op("alice", "my-post", + {{"aaron", 6000}, {"zoe", 5000}})}); + EXPECT_NE(nullptr, hive_parseOperations(overweight.data(), overweight.size(), + &parsed)); +} + +// SECURITY: account_update2 can rotate account keys. Only the profile-metadata +// form is in the table — the op-9/10 exclusion applied field-level. +TEST(Hive, AccountUpdate2RejectsAuthorityChanges) { + HiveParsedTx parsed; + + std::vector authority = + wrap_ops({account_update2_op("{\"profile\":{}}", "", true)}); + EXPECT_NE(nullptr, + hive_parseOperations(authority.data(), authority.size(), &parsed)); + + std::vector empty = wrap_ops({account_update2_op("", "", false)}); + EXPECT_NE(nullptr, hive_parseOperations(empty.data(), empty.size(), &parsed)); + + // json_metadata is an active-key field. + std::vector active = + wrap_ops({account_update2_op("{\"profile\":{}}", "", false)}); + ASSERT_EQ(nullptr, + hive_parseOperations(active.data(), active.size(), &parsed)); + EXPECT_TRUE(parsed.needs_active); + + // posting_json_metadata alone stays on the posting tier. + std::vector posting = + wrap_ops({account_update2_op("", "{\"profile\":{}}", false)}); + ASSERT_EQ(nullptr, + hive_parseOperations(posting.data(), posting.size(), &parsed)); + EXPECT_FALSE(parsed.needs_active); +} + +TEST(Hive, SavingsWithdrawRetainsDisplayedFields) { + std::vector op; + append_varint(op, HIVE_OP_TRANSFER_FROM_SAVINGS); + append_string(op, "alice"); + append_u32_le(op, 7); + append_string(op, "bob"); + append_asset(op, 2500, 3, "HBD"); + append_string(op, "rent"); + std::vector tx = wrap_ops({op}); + + HiveParsedTx parsed; + ASSERT_EQ(nullptr, hive_parseOperations(tx.data(), tx.size(), &parsed)); + // request_id sits BETWEEN from and to on the wire — the easiest field-order + // bug to make in this op, and the one that would swap displayed accounts. + EXPECT_EQ("alice", slice(parsed.ops[0].acct, parsed.ops[0].acct_len)); + EXPECT_EQ(7u, parsed.ops[0].req_id); + EXPECT_EQ("bob", slice(parsed.ops[0].target, parsed.ops[0].target_len)); + EXPECT_EQ("rent", slice(parsed.ops[0].detail, parsed.ops[0].detail_len)); + EXPECT_EQ(2500u, hive_assetAmount(parsed.ops[0].assets[0])); + EXPECT_TRUE(parsed.needs_active); +} + +TEST(Hive, SavingsDepositRetainsDisplayedFields) { + std::vector op; + append_varint(op, HIVE_OP_TRANSFER_TO_SAVINGS); + append_string(op, "alice"); + append_string(op, "bob"); + append_asset(op, 1500, 3, "HIVE"); + append_string(op, ""); + std::vector tx = wrap_ops({op}); + + HiveParsedTx parsed; + ASSERT_EQ(nullptr, hive_parseOperations(tx.data(), tx.size(), &parsed)); + EXPECT_EQ("alice", slice(parsed.ops[0].acct, parsed.ops[0].acct_len)); + EXPECT_EQ("bob", slice(parsed.ops[0].target, parsed.ops[0].target_len)); + EXPECT_EQ(0, parsed.ops[0].detail_len); // empty memo is legal + EXPECT_EQ(1500u, hive_assetAmount(parsed.ops[0].assets[0])); +} + +// An empty `to` means "power up to self" on Hive, not a malformed field. +TEST(Hive, PowerUpAcceptsEmptyDestination) { + std::vector op; + append_varint(op, HIVE_OP_TRANSFER_TO_VESTING); + append_string(op, "alice"); + append_string(op, ""); + append_asset(op, 1000, 3, "HIVE"); + std::vector tx = wrap_ops({op}); + + HiveParsedTx parsed; + ASSERT_EQ(nullptr, hive_parseOperations(tx.data(), tx.size(), &parsed)); + EXPECT_EQ(0, parsed.ops[0].target_len); +} + +// Truncating any op body by one byte must be refused, never partially parsed: +// the signature covers the whole buffer, so a short read would mean signing +// bytes the device never looked at. +TEST(Hive, TruncatedOpBodiesRejected) { + std::vector> bodies; + bodies.push_back( + limit_order_create_op("alice", 1, 100, "HIVE", 50, "HBD", false, 9)); + bodies.push_back(power_up_op(1000, 3, "HIVE")); + { + std::vector op; + append_varint(op, HIVE_OP_CLAIM_REWARD_BALANCE); + append_string(op, "alice"); + append_asset(op, 1, 3, "HIVE"); + append_asset(op, 1, 3, "HBD"); + append_asset(op, 1, 6, "VESTS"); + bodies.push_back(op); + } + { + std::vector op; + append_varint(op, HIVE_OP_TRANSFER_FROM_SAVINGS); + append_string(op, "alice"); + append_u32_le(op, 7); + append_string(op, "bob"); + append_asset(op, 2500, 3, "HBD"); + append_string(op, "memo"); + bodies.push_back(op); + } + + HiveParsedTx parsed; + for (const std::vector& body : bodies) { + ASSERT_EQ(nullptr, hive_parseOperations(wrap_ops({body}).data(), + wrap_ops({body}).size(), &parsed)); + for (size_t cut = 1; cut < body.size(); cut++) { + std::vector truncated(body.begin(), body.end() - cut); + std::vector tx = wrap_ops({truncated}); + EXPECT_NE(nullptr, hive_parseOperations(tx.data(), tx.size(), &parsed)) + << "op type " << (unsigned)body[0] << " truncated by " << cut; + } + } +} + +TEST(Hive, CommentOptionsExtensionShapeRejected) { + HiveParsedTx parsed; + const std::vector comment = comment_op("alice", "my-post"); + + // More than one extension could split beneficiaries past a per-extension cap. + { + std::vector op; + append_varint(op, HIVE_OP_COMMENT_OPTIONS); + append_string(op, "alice"); + append_string(op, "my-post"); + append_asset(op, 1000000, 3, "HBD"); + append_u16_le(op, 10000); + op.push_back(1); + op.push_back(1); + append_varint(op, 2); + std::vector tx = wrap_ops({comment, op}); + EXPECT_NE(nullptr, hive_parseOperations(tx.data(), tx.size(), &parsed)); + } + // Only comment_payout_beneficiaries (tag 0) is in the table. + { + std::vector op; + append_varint(op, HIVE_OP_COMMENT_OPTIONS); + append_string(op, "alice"); + append_string(op, "my-post"); + append_asset(op, 1000000, 3, "HBD"); + append_u16_le(op, 10000); + op.push_back(1); + op.push_back(1); + append_varint(op, 1); + append_varint(op, 1); // tag != 0 + std::vector tx = wrap_ops({comment, op}); + EXPECT_NE(nullptr, hive_parseOperations(tx.data(), tx.size(), &parsed)); + } + // Zero beneficiaries in a present extension is malformed, not "none". + { + std::vector tx = + wrap_ops({comment, comment_options_op("alice", "my-post", {})}); + ASSERT_EQ(nullptr, hive_parseOperations(tx.data(), tx.size(), &parsed)); + std::vector op; + append_varint(op, HIVE_OP_COMMENT_OPTIONS); + append_string(op, "alice"); + append_string(op, "my-post"); + append_asset(op, 1000000, 3, "HBD"); + append_u16_le(op, 10000); + op.push_back(1); + op.push_back(1); + append_varint(op, 1); + append_varint(op, 0); + append_varint(op, 0); // n_benef = 0 + std::vector bad = wrap_ops({comment, op}); + EXPECT_NE(nullptr, hive_parseOperations(bad.data(), bad.size(), &parsed)); + } +} + +TEST(Hive, AccountUpdate2RejectsNonEmptyExtensions) { + std::vector op; + append_varint(op, HIVE_OP_ACCOUNT_UPDATE2); + append_string(op, "alice"); + for (int i = 0; i < 4; i++) op.push_back(0); + append_string(op, "{\"profile\":{}}"); + append_string(op, ""); + append_varint(op, 1); // extensions must be empty + std::vector tx = wrap_ops({op}); + + HiveParsedTx parsed; + EXPECT_NE(nullptr, hive_parseOperations(tx.data(), tx.size(), &parsed)); +} + +// Graphene bools are one byte; anything but 0/1 is a host serializer bug. +TEST(Hive, RejectsNonCanonicalBool) { + // limit_order_create's fill_or_kill byte, set to 2. + std::vector op; + append_varint(op, HIVE_OP_LIMIT_ORDER_CREATE); + append_string(op, "alice"); + append_u32_le(op, 1); + append_asset(op, 100, 3, "HIVE"); + append_asset(op, 50, 3, "HBD"); + op.push_back(2); + append_u32_le(op, 9); + std::vector tx = wrap_ops({op}); + + HiveParsedTx parsed; + EXPECT_NE(nullptr, hive_parseOperations(tx.data(), tx.size(), &parsed)); +} + +TEST(Hive, MixedTierOpsRejected) { + // vote is posting-tier, convert is active-tier; one signature cannot + // satisfy both post-HF28. + std::vector vote; + append_varint(vote, HIVE_OP_VOTE); + append_string(vote, "alice"); + append_string(vote, "bob"); + append_string(vote, "a-post"); + append_u16_le(vote, 10000); + + std::vector convert; + append_varint(convert, HIVE_OP_CONVERT); + append_string(convert, "alice"); + append_u32_le(convert, 1); + append_asset(convert, 1000, 3, "HBD"); + + std::vector tx = wrap_ops({vote, convert}); + HiveParsedTx parsed; + EXPECT_NE(nullptr, hive_parseOperations(tx.data(), tx.size(), &parsed)); +} + +TEST(Hive, ExcludedAndUnknownOpsStillRejected) { + HiveParsedTx parsed; + + // Ops 2/9/10 keep their dedicated message types — never fold them in. + for (uint32_t excluded : {static_cast(HIVE_OP_TRANSFER), + static_cast(HIVE_OP_ACCOUNT_CREATE), + static_cast(HIVE_OP_ACCOUNT_UPDATE)}) { + std::vector op; + append_varint(op, excluded); + append_string(op, "alice"); + std::vector tx = wrap_ops({op}); + EXPECT_NE(nullptr, hive_parseOperations(tx.data(), tx.size(), &parsed)); + } + + // Anything outside the table is refused; there is no blind-sign fallback. + // 49 = recurrent_transfer, a real op deliberately not in the table. + std::vector unknown; + append_varint(unknown, 49); + append_string(unknown, "alice"); + std::vector tx = wrap_ops({unknown}); + EXPECT_NE(nullptr, hive_parseOperations(tx.data(), tx.size(), &parsed)); +} + +// Trailing bytes after a well-formed op must not be silently accepted: the +// signature covers them, so what the device displays would be a subset of +// what it signs. +TEST(Hive, TrailingBytesRejected) { + std::vector tx = wrap_ops( + {limit_order_create_op("alice", 1, 100, "HIVE", 50, "HBD", false, 1)}); + tx.push_back(0xff); + + HiveParsedTx parsed; + EXPECT_NE(nullptr, hive_parseOperations(tx.data(), tx.size(), &parsed)); +} + +// --------------------------------------------------------------------------- +// Golden vectors produced by hived itself: +// +// curl -X POST https://api.hive.blog -H 'Content-Type: application/json' \ +// -d '{"jsonrpc":"2.0","method":"condenser_api.get_transaction_hex", +// "params":[],"id":1}' +// +// These exist because our serializer and this parser were byte-exact mirrors +// of EACH OTHER while both disagreed with the chain: we wrote "HIVE"/"HBD" +// where hived writes "STEEM"/"SBD". Two wrongs cancelled and every test +// passed, but the device signed bytes hived could not validate — it reported +// "missing required active authority", because signature recovery over +// different bytes yields a key in no authority. A vector the chain generated +// is the only kind that can catch that class of bug. +// --------------------------------------------------------------------------- + +std::vector from_hex(const std::string& hex) { + std::vector out; + for (size_t i = 0; i + 1 < hex.size(); i += 2) { + out.push_back( + static_cast(std::stoul(hex.substr(i, 2), nullptr, 16))); + } + return out; +} + +// Header shared by both vectors below: ref_block_num 4660 / prefix 0xdeadbeef +// (0/0 for the second) and expiration 2021-01-14T02:19:44. +// +// get_transaction_hex serializes a full transaction, so its output ends with a +// varint count of the `signatures` array. The device is handed the digest +// preimage, which stops after the extensions varint — so the trailing "00" +// from hived's hex is dropped in the goldens below. Everything before it must +// match byte for byte. +TEST(Hive, SerializationMatchesHivedLimitOrderCreate) { + const std::string golden = + "3412efbeadde40aaff5f010505616c6963652a000000dc05000000000000035354" + "45454d00009001000000000000035342440000000001b0f5536500"; + + std::vector tx; + append_u16_le(tx, 4660); + append_u32_le(tx, 0xdeadbeef); + append_u32_le(tx, 0x5fffaa40); + append_varint(tx, 1); + std::vector op = limit_order_create_op("alice", 42, 1500, "HIVE", + 400, "HBD", true, 0x6553f5b0); + tx.insert(tx.end(), op.begin(), op.end()); + append_varint(tx, 0); // extensions + + EXPECT_EQ(from_hex(golden), tx); + + // and the parser accepts what the chain produces + HiveParsedTx parsed; + ASSERT_EQ(nullptr, hive_parseOperations(tx.data(), tx.size(), &parsed)); + EXPECT_STREQ("HIVE", hive_assetSymbol(parsed.ops[0].assets[0])); + EXPECT_STREQ("HBD", hive_assetSymbol(parsed.ops[0].assets[1])); +} + +TEST(Hive, SerializationMatchesHivedClaimRewardBalance) { + const std::string golden = + "00000000000040aaff5f012705616c696365e8030000000000000353544545" + "4d0000d0070000000000000353424400000000c0c62d0000000000065645535453" + "000000"; + + std::vector tx; + append_u16_le(tx, 0); + append_u32_le(tx, 0); + append_u32_le(tx, 0x5fffaa40); + append_varint(tx, 1); + append_varint(tx, HIVE_OP_CLAIM_REWARD_BALANCE); + append_string(tx, "alice"); + append_asset(tx, 1000, 3, "HIVE"); + append_asset(tx, 2000, 3, "HBD"); + append_asset(tx, 3000000, 6, "VESTS"); + append_varint(tx, 0); // extensions + + EXPECT_EQ(from_hex(golden), tx); + + HiveParsedTx parsed; + ASSERT_EQ(nullptr, hive_parseOperations(tx.data(), tx.size(), &parsed)); + EXPECT_STREQ("VESTS", hive_assetSymbol(parsed.ops[0].assets[2])); +}