Skip to content

Commit e1f5e99

Browse files
committed
fix(security): bind signing authorities; disclose EOS permissions; fail early
Bind the authority to the key that signs: - thorchain, mayachain: MsgDeposit's `signer` must be the account this session derives, not merely a well-formed address on the right network. It is serialized verbatim as the message authority, and the confirmation labels it as though it were a destination, so a valid-but-foreign signer produced a signed document the device cannot authorize and the screen did not give it away. - binance: a transfer's input is its authority and no screen displays it, so network-correctness was not enough. It is now required to equal the session address. The output stays a plain address check -- it is the recipient, and it is shown. Disclose what is signed: - eos: show every actor@permission on an unknown action. The authorization list is compared across chunks and hashed into the preimage, but the approval named only the contract, action, byte count and data fingerprint -- omitting the one part of an opaque action that says how much it may do. Fail before approval: - thortx: resolve the asset, render the amount and validate the expiry before the first screen. They ran after the router, vault and asset screens, and ethereum.c reports a late false return as ActionCancelled -- so a large but valid amount told the owner they had cancelled what they had just approved. - saproxy: format both values before either screen, for the same reason. Don't leave a cancelled screen up: - Cancel and ClearSession now call layoutHome() after aborting. The Binance, Tendermint, Osmosis, THORChain, MAYAChain, EOS and Nano abort routines only clear state, so the cancelled transaction's approval screen stayed on the OLED. Placed at these two call sites rather than inside fsm_abort_workflows(), which toggle_screensaver() also calls immediately before drawing the screensaver.
1 parent de09b7b commit e1f5e99

14 files changed

Lines changed: 213 additions & 73 deletions

File tree

include/keepkey/firmware/binance.h

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,10 @@ bool binance_signTxUpdateTransfer(const BinanceTransferMsg* _msg);
2727
bool binance_signTxUpdateMsgSend(const uint64_t amount, const char* to_address);
2828
bool binance_signTxFinalize(uint8_t* public_key, uint8_t* signature);
2929
bool binance_signingIsInited(void);
30+
31+
/// True iff `address` is the account this session's key signs as. Use for a
32+
/// transfer's input, which is its authority.
33+
bool binance_addressIsSigner(const char* address);
3034
bool binance_signingIsFinished(void);
3135
void binance_signAbort(void);
3236
const BinanceSignTx* binance_getBinanceSignTx(void);

include/keepkey/firmware/mayachain.h

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,10 @@ bool mayachain_signTxUpdateMsgSend(const uint64_t amount,
2727
bool mayachain_signTxUpdateMsgDeposit(const MayachainMsgDeposit* depmsg);
2828
bool mayachain_signTxFinalize(uint8_t* public_key, uint8_t* signature);
2929
bool mayachain_signingIsInited(void);
30+
31+
/// True iff `address` is the account this session's key signs as. Use for
32+
/// MsgDeposit's `signer`, which is serialized verbatim as the authority.
33+
bool mayachain_addressIsSigner(const char* address);
3034
bool mayachain_signingIsFinished(void);
3135
void mayachain_signAbort(void);
3236
const MayachainSignTx* mayachain_getMayachainSignTx(void);

include/keepkey/firmware/thorchain.h

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,10 @@ bool thorchain_signTxUpdateMsgSend(const uint64_t amount,
2424
bool thorchain_signTxUpdateMsgDeposit(const ThorchainMsgDeposit* depmsg);
2525
bool thorchain_signTxFinalize(uint8_t* public_key, uint8_t* signature);
2626
bool thorchain_signingIsInited(void);
27+
28+
/// True iff `address` is the account this session's key signs as. Use for
29+
/// MsgDeposit's `signer`, which is serialized verbatim as the authority.
30+
bool thorchain_addressIsSigner(const char* address);
2731
bool thorchain_signingIsFinished(void);
2832
void thorchain_signAbort(void);
2933
const ThorchainSignTx* thorchain_getThorchainSignTx(void);

lib/firmware/binance.c

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -236,6 +236,20 @@ bool binance_signTxFinalize(uint8_t* public_key, uint8_t* signature) {
236236
NULL) == 0;
237237
}
238238

239+
/* The account this session's key signs as.
240+
*
241+
* A transfer's input is its authority. Checking only that it is a well-formed
242+
* address on the session's network let a host obtain a signature over an input
243+
* the device cannot represent, and no screen shows the input address, so
244+
* nothing would have revealed it. */
245+
bool binance_addressIsSigner(const char* address) {
246+
if (!initialized || !address || !address_prefix) return false;
247+
248+
char expected[46] = {0};
249+
if (!tendermint_getAddress(&node, address_prefix, expected)) return false;
250+
return strcmp(address, expected) == 0;
251+
}
252+
239253
bool binance_signingIsInited(void) { return initialized; }
240254

241255
bool binance_signingIsFinished(void) {

lib/firmware/eos.c

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -530,6 +530,37 @@ bool eos_compileActionUnknown(const EosActionCommon* common,
530530
char title[MEDIUM_STR_BUF];
531531
snprintf(title, sizeof(title), "%s:%s", account, name);
532532

533+
/* Show the AUTHORITIES this action runs under.
534+
*
535+
* unknown_common.authorization[] is part of the signed transaction and is
536+
* compared across chunks and hashed into the preimage, but the screen
537+
* below names only the contract, the action, a byte count and a data
538+
* fingerprint. So an AdvancedMode owner approving an opaque action could
539+
* not see which of their permissions it was being executed with -- the one
540+
* part of an unknown action that says how much it is allowed to do.
541+
* EosActionCommon carries at most 16 of them. */
542+
for (pb_size_t i = 0; i < unknown_common.authorization_count; i++) {
543+
const EosPermissionLevel* auth = &unknown_common.authorization[i];
544+
char actor[EOS_NAME_STR_SIZE];
545+
char permission[EOS_NAME_STR_SIZE];
546+
CHECK_PARAM_RET(auth->has_actor && eos_formatName(auth->actor, actor),
547+
"Invalid authorization actor", false);
548+
CHECK_PARAM_RET(
549+
auth->has_permission && eos_formatName(auth->permission, permission),
550+
"Invalid authorization permission", false);
551+
552+
if (!confirm(ButtonRequestType_ButtonRequest_ConfirmEosAction, title,
553+
"Authorized by %s@%s (%u of %u)", actor, permission,
554+
(unsigned)(i + 1),
555+
(unsigned)unknown_common.authorization_count)) {
556+
fsm_sendFailure(FailureType_Failure_ActionCancelled,
557+
"Action Cancelled");
558+
eos_signingAbort();
559+
layoutHome();
560+
return false;
561+
}
562+
}
563+
533564
static uint8_t hash[32];
534565
hasher_Final(&hasher_unknown, hash);
535566

lib/firmware/ethereum_contracts/saproxy.c

Lines changed: 19 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -79,25 +79,31 @@ bool sa_confirmWithdrawFromSalary(uint32_t data_total,
7979
* be able to drift apart. See sa_withdrawFromSalaryExtentOk(). */
8080
if (!sa_withdrawFromSalaryExtentOk(msg)) return false;
8181

82-
char confStr[41];
83-
// confirm raw unformatted numbers
84-
/* bn_format() BLANKS its output buffer and returns 0 when the value does
85-
* not fit -- ignoring the return renders an EMPTY amount on the
86-
* confirmation screen, the one rendering a user cannot read as wrong. */
87-
if (!sa_formatUint256(msg->data_initial_chunk.bytes + 4, "", confStr,
88-
sizeof(confStr)))
82+
/* Format BOTH values before either screen.
83+
*
84+
* bn_format() blanks its output and returns 0 when the value does not fit,
85+
* so an unrenderable amount is a refusal. Doing the second format after the
86+
* first confirmation meant a large but perfectly valid uint256 amount failed
87+
* only once the salary ID had been approved -- and the Ethereum dispatcher
88+
* reports that late failure as ActionCancelled, so the owner is told they
89+
* cancelled something they had in fact approved. Non-interactive work
90+
* belongs before the first screen. */
91+
char idStr[41];
92+
char amountStr[41];
93+
if (!sa_formatUint256(msg->data_initial_chunk.bytes + 4, "", idStr,
94+
sizeof(idStr)))
8995
return false;
96+
if (!sa_formatUint256(msg->data_initial_chunk.bytes + 4 + 32, " Token Units",
97+
amountStr, sizeof(amountStr)))
98+
return false;
99+
90100
if (!confirm(ButtonRequestType_ButtonRequest_ConfirmOutput, "Sablier",
91-
"Salary ID %s", confStr)) {
101+
"Salary ID %s", idStr)) {
92102
return false;
93103
}
94104

95-
// confirm raw unformatted numbers
96-
if (!sa_formatUint256(msg->data_initial_chunk.bytes + 4 + 32, " Token Units",
97-
confStr, sizeof(confStr)))
98-
return false;
99105
if (!confirm(ButtonRequestType_ButtonRequest_ConfirmOutput, "Sablier",
100-
"Withdraw Amount %s", confStr)) {
106+
"Withdraw Amount %s", amountStr)) {
101107
return false;
102108
}
103109
return true;

lib/firmware/ethereum_contracts/thortx.c

Lines changed: 70 additions & 58 deletions
Original file line numberDiff line numberDiff line change
@@ -142,33 +142,17 @@ bool thor_confirmThorTx(uint32_t data_total, const EthereumSignTx* msg) {
142142
thorchainData =
143143
(uint8_t*)(msg->data_initial_chunk.bytes + 4 + (is_expiry ? 6 : 5) * 32);
144144

145-
// Start confirmations
146-
for (ctr = 0; ctr < 20; ctr++) {
147-
snprintf(&confStr[ctr * 2], 3, "%02x", msg->to.bytes[ctr]);
148-
}
149-
/* THOR_ROUTER is an Ethereum-mainnet identity. The same 20 bytes on another
150-
* EVM chain are an unrelated contract, so the trusted label has to be bound
151-
* to the chain; otherwise a host-chosen chain_id borrows it. */
152-
if (msg->has_chain_id && msg->chain_id == 1 &&
153-
strncmp(confStr, THOR_ROUTER, sizeof(THOR_ROUTER)) == 0) {
154-
conf = "Thorchain router";
155-
} else {
156-
conf = confStr;
157-
}
158-
if (!confirm(ButtonRequestType_ButtonRequest_ConfirmOutput, "Thorchain data",
159-
"Routing through %s", conf)) {
160-
return false;
161-
}
162-
163-
// just display token address and amount as string
164-
for (ctr = 0; ctr < 20; ctr++) {
165-
snprintf(&confStr[ctr * 2], 3, "%02x", vaultAddress[ctr]);
166-
}
167-
if (!confirm(ButtonRequestType_ButtonRequest_ConfirmOutput, "Thorchain data",
168-
"Using Asgard vault %s", confStr)) {
169-
return false;
170-
}
171-
145+
/* Everything non-interactive FIRST, so an unrenderable call fails before any
146+
* approval is taken.
147+
*
148+
* The amount used to be formatted after the router, vault and asset screens
149+
* had been approved, and the expiry word validated after that. bn_format()
150+
* refuses a value it cannot render, and ethereum.c turns a false return from
151+
* this decoder into ActionCancelled -- so a large but valid amount, or a
152+
* non-canonical expiry, told the owner they had cancelled a transaction they
153+
* had already approved three screens of. Resolve the asset, render the
154+
* amount, and check the expiry up here; the confirmations below then only
155+
* display what is already known to be displayable. */
172156
assetAddress = contractAssetAddress;
173157
/* The THORChain ABI uses the zero address to mean this signing chain's
174158
* native asset. Resolve that router-specific meaning directly instead of
@@ -181,40 +165,18 @@ bool thor_confirmThorTx(uint32_t data_total, const EthereumSignTx* msg) {
181165
assetToken = tokenByChainAddress(msg->chain_id, assetAddress);
182166
}
183167

168+
char amountStr[41];
184169
if (assetToken == UnknownToken) {
185-
// just display token address and amount as string
186-
for (ctr = 0; ctr < 20; ctr++) {
187-
snprintf(&confStr[ctr * 2], 3, "%02x", assetAddress[ctr]);
188-
}
189-
if (!confirm(ButtonRequestType_ButtonRequest_ConfirmOutput,
190-
"Thorchain data", "from asset %s", confStr)) {
191-
return false;
192-
}
193-
// We don't know what the exponent should be so just confirm raw unformatted
194-
// number
195-
/* bn_format() BLANKS its output buffer and returns 0 when the value
196-
* does not fit -- ignoring the return renders an EMPTY amount on the
197-
* confirmation screen, the one rendering a user cannot read as wrong.
198-
* Never leave the caller a blank amount. */
170+
/* We don't know what the exponent should be, so confirm the raw
171+
* unformatted number. */
199172
if (!thor_formatUnknownAssetAmount(
200-
msg->data_initial_chunk.bytes + 4 + 2 * 32, confStr,
201-
sizeof(confStr)))
202-
return false;
203-
204-
if (!confirm(ButtonRequestType_ButtonRequest_ConfirmOutput,
205-
"Thorchain data", "amount %s", confStr)) {
173+
msg->data_initial_chunk.bytes + 4 + 2 * 32, amountStr,
174+
sizeof(amountStr)))
206175
return false;
207-
}
208-
209176
} else {
210-
if (!ethereumFormatAmount(&Amount, assetToken, msg->chain_id, confStr,
211-
sizeof(confStr)))
177+
if (!ethereumFormatAmount(&Amount, assetToken, msg->chain_id, amountStr,
178+
sizeof(amountStr)))
212179
return false;
213-
214-
if (!confirm(ButtonRequestType_ButtonRequest_ConfirmOutput,
215-
"Thorchain data", "Confirm sending %s", confStr)) {
216-
return false;
217-
}
218180
}
219181

220182
/* depositWithExpiry() carries a fifth head word the deposit() variant does
@@ -230,6 +192,7 @@ bool thor_confirmThorTx(uint32_t data_total, const EthereumSignTx* msg) {
230192
* because a far-future expiry displayed as a small epoch is worse than no
231193
* screen at all -- it reads as "already expired" when it means the
232194
* opposite. */
195+
char expiry_str[21] = {0};
233196
if (is_expiry) {
234197
const uint8_t* expiry_word = msg->data_initial_chunk.bytes + 4 + 4 * 32;
235198
for (size_t i = 0; i < 24; i++) {
@@ -240,7 +203,6 @@ bool thor_confirmThorTx(uint32_t data_total, const EthereumSignTx* msg) {
240203
expiry = (expiry << 8) | expiry_word[i];
241204
}
242205

243-
char expiry_str[21] = {0};
244206
char tmp[21];
245207
int len = 0;
246208
if (expiry == 0) {
@@ -254,10 +216,60 @@ bool thor_confirmThorTx(uint32_t data_total, const EthereumSignTx* msg) {
254216
for (int i = 0; i < len; i++) {
255217
expiry_str[i] = tmp[len - 1 - i];
256218
}
219+
}
220+
221+
// Start confirmations
222+
for (ctr = 0; ctr < 20; ctr++) {
223+
snprintf(&confStr[ctr * 2], 3, "%02x", msg->to.bytes[ctr]);
224+
}
225+
/* THOR_ROUTER is an Ethereum-mainnet identity. The same 20 bytes on another
226+
* EVM chain are an unrelated contract, so the trusted label has to be bound
227+
* to the chain; otherwise a host-chosen chain_id borrows it. */
228+
if (msg->has_chain_id && msg->chain_id == 1 &&
229+
strncmp(confStr, THOR_ROUTER, sizeof(THOR_ROUTER)) == 0) {
230+
conf = "Thorchain router";
231+
} else {
232+
conf = confStr;
233+
}
234+
if (!confirm(ButtonRequestType_ButtonRequest_ConfirmOutput, "Thorchain data",
235+
"Routing through %s", conf)) {
236+
return false;
237+
}
238+
239+
// just display token address and amount as string
240+
for (ctr = 0; ctr < 20; ctr++) {
241+
snprintf(&confStr[ctr * 2], 3, "%02x", vaultAddress[ctr]);
242+
}
243+
if (!confirm(ButtonRequestType_ButtonRequest_ConfirmOutput, "Thorchain data",
244+
"Using Asgard vault %s", confStr)) {
245+
return false;
246+
}
247+
248+
if (assetToken == UnknownToken) {
249+
// just display token address and amount as string
250+
for (ctr = 0; ctr < 20; ctr++) {
251+
snprintf(&confStr[ctr * 2], 3, "%02x", assetAddress[ctr]);
252+
}
257253
if (!confirm(ButtonRequestType_ButtonRequest_ConfirmOutput,
258-
"Thorchain data", "Expiry epoch %s", expiry_str)) {
254+
"Thorchain data", "from asset %s", confStr)) {
259255
return false;
260256
}
257+
258+
if (!confirm(ButtonRequestType_ButtonRequest_ConfirmOutput,
259+
"Thorchain data", "amount %s", amountStr)) {
260+
return false;
261+
}
262+
263+
} else {
264+
if (!confirm(ButtonRequestType_ButtonRequest_ConfirmOutput,
265+
"Thorchain data", "Confirm sending %s", amountStr)) {
266+
return false;
267+
}
268+
}
269+
270+
if (is_expiry && !confirm(ButtonRequestType_ButtonRequest_ConfirmOutput,
271+
"Thorchain data", "Expiry epoch %s", expiry_str)) {
272+
return false;
261273
}
262274

263275
/* Pass the memo's true ABI length, not a fixed 64. There is no raw-memo

lib/firmware/fsm.c

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -321,6 +321,15 @@ void fsm_msgClearSession(ClearSession* msg) {
321321
(void)msg;
322322
fsm_abort_workflows();
323323
session_clear(/*clear_pin=*/true);
324+
/* Several abort routines -- Binance, Tendermint, Osmosis, THORChain,
325+
MAYAChain, EOS, Nano -- only clear state and touch no layout, so without
326+
this the approval screen of the transaction just cancelled stays on the
327+
OLED, describing an operation that no longer exists.
328+
329+
Done here and in fsm_msgCancel() rather than inside fsm_abort_workflows(),
330+
because that is also called from toggle_screensaver(), which draws the
331+
screensaver immediately afterwards. */
332+
layoutHome();
324333
fsm_sendSuccess("Session cleared");
325334
}
326335

lib/firmware/fsm_msg_binance.h

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -141,8 +141,14 @@ void fsm_msgBinanceTransferMsg(const BinanceTransferMsg* msg) {
141141
rejected. */
142142
{
143143
const char* const pfix = binance_sessionAddressPrefix();
144+
/* The input is the transfer's AUTHORITY, and no screen displays it,
145+
so being well formed on the right network is not enough -- it has to
146+
be the account this session's key signs as. The output is the
147+
recipient and is shown, so it only needs to be a valid address on
148+
the same network. */
144149
if (!pfix ||
145150
!tendermint_validateBech32Address(msg->inputs[0].address, pfix) ||
151+
!binance_addressIsSigner(msg->inputs[0].address) ||
146152
!tendermint_validateBech32Address(msg->outputs[0].address, pfix)) {
147153
binance_signAbort();
148154
fsm_sendFailure(FailureType_Failure_SyntaxError,

lib/firmware/fsm_msg_common.h

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -563,6 +563,10 @@ void fsm_msgEntropyAck(EntropyAck* msg) {
563563
void fsm_msgCancel(Cancel* msg) {
564564
(void)msg;
565565
fsm_abort_workflows();
566+
/* See fsm_msgClearSession(): the abort routines for Binance, Tendermint,
567+
Osmosis, THORChain, MAYAChain, EOS and Nano have no layout side effect, so
568+
the cancelled transaction's approval screen would otherwise stay up. */
569+
layoutHome();
566570
fsm_sendFailure(FailureType_Failure_ActionCancelled, "Aborted");
567571
}
568572

0 commit comments

Comments
 (0)