From f22b56da0202496ef2b088e180bd03402cb877e3 Mon Sep 17 00:00:00 2001 From: highlander Date: Wed, 15 Jul 2026 00:22:46 -0300 Subject: [PATCH 1/4] =?UTF-8?q?feat(hive):=20HiveSignMessage=20(1614/1615)?= =?UTF-8?q?=20=E2=80=94=20Keychain=20signBuffer,=20unblocks=20dApp=20login?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Digest is SHA256(raw message bytes) ONLY — no chain_id prepend, no message prefix (the hive-js Signature.signBuffer contract every Hive dApp verifies against). Output: 65-byte compact recoverable sig (27+recid+4) + the 33-byte compressed pubkey so the host can build Keychain's publicKey response field without a second round-trip. - Full SLIP-48 path shape enforced like the tx handlers; any of the four roles may sign (dApp login uses posting') - Messages beginning with the mainnet chain id are refused: such a buffer would hash to a broadcastable TRANSACTION digest (tx digest = SHA256(chain_id || tx)) - 1024-byte cap in proto AND handler (memo-length lesson) - Printable text shown as-is, other payloads as a hex preview - hive_sign_raw_digest factored out of hive_sign_digest (shared header/recid logic, no duplication) - IDs 1610-1613 skipped: NEAR holds them on device-protocol master - pyk: sign_message + 7 signBuffer tests (recovery, roles, boundary, oversize, path fence, chain-id collision) --- deps/device-protocol | 2 +- deps/python-keepkey | 2 +- include/keepkey/firmware/fsm.h | 1 + include/keepkey/firmware/hive.h | 12 ++ .../keepkey/transport/messages-hive.options | 6 + lib/firmware/fsm_msg_hive.h | 131 ++++++++++++++++++ lib/firmware/hive.c | 61 ++++++-- lib/firmware/messagemap.def | 2 + 8 files changed, 207 insertions(+), 10 deletions(-) diff --git a/deps/device-protocol b/deps/device-protocol index 33521a8fd..9e46aeb6d 160000 --- a/deps/device-protocol +++ b/deps/device-protocol @@ -1 +1 @@ -Subproject commit 33521a8fd6f012c8bbca3a8902eea6c1f5aa3389 +Subproject commit 9e46aeb6de95c3d12272bb6b48dcc06d40f1b436 diff --git a/deps/python-keepkey b/deps/python-keepkey index 35555d7f1..8e0607f43 160000 --- a/deps/python-keepkey +++ b/deps/python-keepkey @@ -1 +1 @@ -Subproject commit 35555d7f127d6be0b96d6b9e4a981130dba50d2b +Subproject commit 8e0607f43ebefc43850c5b58a83ed7163754d59c diff --git a/include/keepkey/firmware/fsm.h b/include/keepkey/firmware/fsm.h index 6f38457f3..f3b918c70 100644 --- a/include/keepkey/firmware/fsm.h +++ b/include/keepkey/firmware/fsm.h @@ -146,6 +146,7 @@ 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); #if DEBUG_LINK // void fsm_msgDebugLinkDecision(DebugLinkDecision *msg); diff --git a/include/keepkey/firmware/hive.h b/include/keepkey/firmware/hive.h index 6cad9cedc..2b1579b6e 100644 --- a/include/keepkey/firmware/hive.h +++ b/include/keepkey/firmware/hive.h @@ -42,6 +42,9 @@ // 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 // ── Public API ──────────────────────────────────────────────────────────── /** @@ -75,6 +78,15 @@ bool hive_getPublicKeys(const HDNode* root, uint32_t account_index, */ void hive_signTx(const HDNode* node, const HiveSignTx* msg, HiveSignedTx* 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); + /** * Sign a Hive account_create transaction (op type 9). * owner/active/posting/memo_raw must be device-derived 33-byte compressed keys. diff --git a/include/keepkey/transport/messages-hive.options b/include/keepkey/transport/messages-hive.options index e878f3e3b..04ba85d68 100644 --- a/include/keepkey/transport/messages-hive.options +++ b/include/keepkey/transport/messages-hive.options @@ -44,3 +44,9 @@ 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 diff --git a/lib/firmware/fsm_msg_hive.h b/lib/firmware/fsm_msg_hive.h index aa9291664..1d859904d 100644 --- a/lib/firmware/fsm_msg_hive.h +++ b/lib/firmware/fsm_msg_hive.h @@ -514,3 +514,134 @@ void fsm_msgHiveSignAccountUpdate(const HiveSignAccountUpdate* msg) { 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. Any of the four SLIP-0048 roles may sign (login uses posting'); +// 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 (count != 5) return false; + if (address_n[0] != HIVE_SLIP48_PURPOSE) return false; + if (address_n[1] != HIVE_SLIP48_NETWORK) return false; + if ((address_n[3] & 0x80000000u) == 0) return false; + if (address_n[4] != 0x80000000u) return false; // key index 0' + switch (address_n[2]) { + case HIVE_ROLE_OWNER: + *role_label = "owner"; + return true; + 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); + + // Printable UTF-8 shown as text; anything else as a hex preview + // (same convention as SolanaSignMessage). + bool printable = true; + for (uint32_t i = 0; i < msg->message.size; i++) { + if (msg->message.bytes[i] < 0x20 || msg->message.bytes[i] > 0x7e) { + printable = false; + break; + } + } + + bool approved; + if (printable) { + approved = confirm(ButtonRequestType_ButtonRequest_ProtectCall, + "Sign Hive Message", "(%s key) %.*s", role_label, + (int)msg->message.size, msg->message.bytes); + } else { + char preview[96]; // 64 hex chars + "... (1024 bytes)" + NUL + unsigned show = msg->message.size > 32 ? 32 : msg->message.size; + for (unsigned i = 0; i < show; i++) { + snprintf(&preview[2 * i], 3, "%02x", msg->message.bytes[i]); + } + if (msg->message.size > 32) { + snprintf(&preview[2 * show], sizeof(preview) - 2 * show, + "... (%u bytes)", (unsigned)msg->message.size); + } + approved = confirm(ButtonRequestType_ButtonRequest_ProtectCall, + "Sign Hive Bytes", "(%s key) %s", role_label, preview); + } + if (!approved) { + 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(); +} diff --git a/lib/firmware/hive.c b/lib/firmware/hive.c index 740ac7144..2f71afdc7 100644 --- a/lib/firmware/hive.c +++ b/lib/firmware/hive.c @@ -193,7 +193,24 @@ static int hive_is_canonic(uint8_t v, uint8_t signature[64]) { } /* - * Sign helper: SHA256(chain_id || serialized_tx) → secp256k1 recoverable sig. + * 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, @@ -206,16 +223,44 @@ static bool hive_sign_digest(const HDNode* node, const uint8_t* chain_id, uint8_t digest[32]; sha256_Final(&sha, digest); - uint8_t pby; - if (ecdsa_sign_digest(&secp256k1, node->private_key, digest, sig + 1, &pby, - hive_is_canonic) != 0) { + bool ok = hive_sign_raw_digest(node, digest, sig); + memzero(digest, sizeof(digest)); + return ok; +} + +// ── 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. + +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)); - return false; + memzero(sig, sizeof(sig)); + return; } - // Compact signature header: 27 + recovery_id + 4 (compressed key flag) - sig[0] = 27 + pby + 4; + + 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)); - return true; + memzero(sig, sizeof(sig)); } // ── Transfer (op type 2) ────────────────────────────────────────────────── diff --git a/lib/firmware/messagemap.def b/lib/firmware/messagemap.def index df3a6128d..18514f5ee 100644 --- a/lib/firmware/messagemap.def +++ b/lib/firmware/messagemap.def @@ -195,12 +195,14 @@ 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_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) #endif // !BITCOIN_ONLY #if DEBUG_LINK From 624b90b7bb74fc1fee02f7818e37801e6e55fd36 Mon Sep 17 00:00:00 2001 From: highlander Date: Wed, 15 Jul 2026 00:39:12 -0300 Subject: [PATCH 2/4] chore: bump python-keepkey pin (assertEqual test fix) --- deps/python-keepkey | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/deps/python-keepkey b/deps/python-keepkey index 8e0607f43..591dba832 160000 --- a/deps/python-keepkey +++ b/deps/python-keepkey @@ -1 +1 @@ -Subproject commit 8e0607f43ebefc43850c5b58a83ed7163754d59c +Subproject commit 591dba832be460960af07997e3c4804df0c75157 From ddade55d97a1c2252cd3e132d99459a1b19907bd Mon Sep 17 00:00:00 2001 From: highlander Date: Wed, 15 Jul 2026 01:24:11 -0300 Subject: [PATCH 3/4] =?UTF-8?q?fix(hive):=20review=20findings=20=E2=80=94?= =?UTF-8?q?=20display=20budget=20+=20drop=20owner'=20from=20signBuffer?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Finding 1: printable text over 128 bytes now routes through the hex preview + byte count instead of confirm()'s silently-truncating body buffer (BODY_CHAR_MAX) — a long message could otherwise show a benign prefix while the device signs hidden trailing content. Same budget as SolanaSignMessage. Finding 2: owner' removed from the accepted signBuffer roles. Keychain's requestSignBuffer surface is Posting | Active | Memo only; no consumer offers owner', and the cold owner key must not be normalized into dApp flows. Also: comment now says printable ASCII (0x20-0x7e), not UTF-8. Pins: device-protocol a793934 (role doc), python-keepkey ed8cbdf (19/19 on emulator incl. new >128B printable boundary + owner' reject). --- deps/device-protocol | 2 +- deps/python-keepkey | 2 +- lib/firmware/fsm_msg_hive.h | 22 ++++++++++++---------- 3 files changed, 14 insertions(+), 12 deletions(-) diff --git a/deps/device-protocol b/deps/device-protocol index 9e46aeb6d..a793934d0 160000 --- a/deps/device-protocol +++ b/deps/device-protocol @@ -1 +1 @@ -Subproject commit 9e46aeb6de95c3d12272bb6b48dcc06d40f1b436 +Subproject commit a793934d09a883c9944ba3b9da15d23969906343 diff --git a/deps/python-keepkey b/deps/python-keepkey index 591dba832..ed8cbdf92 160000 --- a/deps/python-keepkey +++ b/deps/python-keepkey @@ -1 +1 @@ -Subproject commit 591dba832be460960af07997e3c4804df0c75157 +Subproject commit ed8cbdf92097945239cb2a084edb909247c520b8 diff --git a/lib/firmware/fsm_msg_hive.h b/lib/firmware/fsm_msg_hive.h index 1d859904d..97573e266 100644 --- a/lib/firmware/fsm_msg_hive.h +++ b/lib/firmware/fsm_msg_hive.h @@ -520,8 +520,10 @@ void fsm_msgHiveSignAccountUpdate(const HiveSignAccountUpdate* msg) { // 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. Any of the four SLIP-0048 roles may sign (login uses posting'); -// the full path shape is still enforced like the tx handlers. +// 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, @@ -532,9 +534,6 @@ static bool hive_slip48_message_path_ok(const uint32_t* address_n, if ((address_n[3] & 0x80000000u) == 0) return false; if (address_n[4] != 0x80000000u) return false; // key index 0' switch (address_n[2]) { - case HIVE_ROLE_OWNER: - *role_label = "owner"; - return true; case HIVE_ROLE_ACTIVE: *role_label = "active"; return true; @@ -597,13 +596,16 @@ void fsm_msgHiveSignMessage(const HiveSignMessage* msg) { if (!node) return; hdnode_fill_public_key(node); - // Printable UTF-8 shown as text; anything else as a hex preview - // (same convention as SolanaSignMessage). - bool printable = true; - for (uint32_t i = 0; i < msg->message.size; i++) { + // Printable ASCII within the display budget is shown verbatim; anything + // longer — or with non-ASCII bytes — gets the hex preview + byte count. + // The budget matters: confirm()'s body buffer silently truncates at + // BODY_CHAR_MAX, so a long "printable" message could show a benign prefix + // while the device signs hidden trailing content. Same 128-byte budget as + // SolanaSignMessage. + bool printable = msg->message.size <= 128; + for (uint32_t i = 0; printable && i < msg->message.size; i++) { if (msg->message.bytes[i] < 0x20 || msg->message.bytes[i] > 0x7e) { printable = false; - break; } } From cbf33967c6805916dc14134ce1d3702a3d40176d Mon Sep 17 00:00:00 2001 From: highlander Date: Wed, 15 Jul 2026 15:28:51 -0300 Subject: [PATCH 4/4] style: clang-format pass on fsm_msg_hive.h (CI lint-format, clang-format 20) --- lib/firmware/fsm_msg_hive.h | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/lib/firmware/fsm_msg_hive.h b/lib/firmware/fsm_msg_hive.h index 97573e266..d12881ea8 100644 --- a/lib/firmware/fsm_msg_hive.h +++ b/lib/firmware/fsm_msg_hive.h @@ -621,8 +621,8 @@ void fsm_msgHiveSignMessage(const HiveSignMessage* msg) { snprintf(&preview[2 * i], 3, "%02x", msg->message.bytes[i]); } if (msg->message.size > 32) { - snprintf(&preview[2 * show], sizeof(preview) - 2 * show, - "... (%u bytes)", (unsigned)msg->message.size); + snprintf(&preview[2 * show], sizeof(preview) - 2 * show, "... (%u bytes)", + (unsigned)msg->message.size); } approved = confirm(ButtonRequestType_ButtonRequest_ProtectCall, "Sign Hive Bytes", "(%s key) %s", role_label, preview);