diff --git a/deps/device-protocol b/deps/device-protocol
index bf8646b81..c7a824d01 160000
--- a/deps/device-protocol
+++ b/deps/device-protocol
@@ -1 +1 @@
-Subproject commit bf8646b817401d4623e0977ddb4c961b1101121f
+Subproject commit c7a824d011c913aaaf7a44eee5febcfe772ebc49
diff --git a/include/keepkey/firmware/fsm.h b/include/keepkey/firmware/fsm.h
index a33c2703b..630ad578c 100644
--- a/include/keepkey/firmware/fsm.h
+++ b/include/keepkey/firmware/fsm.h
@@ -80,6 +80,11 @@ void fsm_msgApplyPolicies(ApplyPolicies* msg);
// BIP-85 (chain-agnostic, available in all builds)
void fsm_msgGetBip85Mnemonic(const GetBip85Mnemonic* msg);
+// Insight metadata key management (slots 4..6 user-provisioned)
+void fsm_msgAddMetadataKey(const AddMetadataKey* msg);
+void fsm_msgRemoveMetadataKey(const RemoveMetadataKey* msg);
+void fsm_msgListMetadataKeys(const ListMetadataKeys* msg);
+
#ifndef BITCOIN_ONLY
// ethereum
void fsm_msgEthereumGetAddress(EthereumGetAddress* msg);
diff --git a/include/keepkey/firmware/storage.h b/include/keepkey/firmware/storage.h
index 1a57603b2..b49657da5 100644
--- a/include/keepkey/firmware/storage.h
+++ b/include/keepkey/firmware/storage.h
@@ -26,7 +26,14 @@
#include "keepkey/firmware/authenticator.h"
#define STORAGE_VERSION \
- 17 /* Must add case fallthrough in storage_fromFlash after increment*/
+ 18 /* Must add case fallthrough in storage_fromFlash after increment*/
+
+/* Number of user-provisioned metadata signing key slots (slots 4..6).
+ * Firmware-baked slots 0..3 live in signed_metadata.c and don't count here. */
+#define METADATA_USER_KEY_COUNT 3
+#define METADATA_USER_KEY_FIRST 4
+#define METADATA_USER_KEY_LAST \
+ (METADATA_USER_KEY_FIRST + METADATA_USER_KEY_COUNT - 1)
#define STORAGE_RETRIES 3
#define RANDOM_SALT_LEN 32
@@ -165,6 +172,30 @@ void storage_getPolicies(PolicyType* policy_data);
/// \brief Status of policy in storage
bool storage_isPolicyEnabled(const char* policy_name);
+typedef struct _MetadataKeyType MetadataKeyType;
+
+/// \brief Add or replace a user-provisioned metadata signing key.
+/// \param slot Must be in [METADATA_USER_KEY_FIRST, METADATA_USER_KEY_LAST].
+/// \param pubkey 33-byte compressed secp256k1 public key.
+/// \param label Null-terminated, ≤16 chars. Displayed during verification.
+/// \returns true on success, false on invalid slot/pubkey/label.
+bool storage_setMetadataKey(uint32_t slot, const uint8_t* pubkey,
+ const char* label);
+
+/// \brief Remove a user-provisioned metadata signing key.
+/// \returns true if a key was removed, false if slot was empty/invalid.
+bool storage_removeMetadataKey(uint32_t slot);
+
+/// \brief Look up a user-provisioned metadata signing key.
+/// \param pubkey_out Receives 33 bytes on success.
+/// \param label_out Receives null-terminated string (buffer must be ≥17 bytes).
+/// \returns true if slot is populated.
+bool storage_getMetadataKey(uint32_t slot, uint8_t pubkey_out[33],
+ char label_out[17]);
+
+/// \brief Number of populated user-provisioned metadata key slots (0..3).
+uint32_t storage_getMetadataKeyCount(void);
+
uint32_t storage_getAutoLockDelayMs(void);
void storage_setAutoLockDelayMs(uint32_t auto_lock_delay_ms);
diff --git a/include/keepkey/transport/messages.options b/include/keepkey/transport/messages.options
index 29a1fcea6..0ab4073ff 100644
--- a/include/keepkey/transport/messages.options
+++ b/include/keepkey/transport/messages.options
@@ -105,6 +105,10 @@ SignedIdentity.signature max_size:65
ApplyPolicies.policy max_count:6
+AddMetadataKey.pubkey max_size:33
+AddMetadataKey.label max_size:17
+MetadataKeysList.keys max_count:7
+
# not used in firmware
FirmwareUpload.payload_hash max_size:32
FirmwareUpload.payload max_size:0
diff --git a/include/keepkey/transport/types.options b/include/keepkey/transport/types.options
index 307732f4b..b1a126a26 100644
--- a/include/keepkey/transport/types.options
+++ b/include/keepkey/transport/types.options
@@ -46,3 +46,6 @@ IdentityType.port max_size:6
IdentityType.path max_size:256
PolicyType.policy_name max_size:15
+
+MetadataKeyType.pubkey max_size:33
+MetadataKeyType.label max_size:17
diff --git a/lib/firmware/fsm.c b/lib/firmware/fsm.c
index 394bdbb49..31ec9ab12 100644
--- a/lib/firmware/fsm.c
+++ b/lib/firmware/fsm.c
@@ -294,3 +294,4 @@ void fsm_msgClearSession(ClearSession* msg) {
#include "fsm_msg_tron.h"
#include "fsm_msg_solana.h"
#include "fsm_msg_bip85.h"
+#include "fsm_msg_metadata.h"
diff --git a/lib/firmware/fsm_msg_metadata.h b/lib/firmware/fsm_msg_metadata.h
new file mode 100644
index 000000000..f6fd2dd12
--- /dev/null
+++ b/lib/firmware/fsm_msg_metadata.h
@@ -0,0 +1,165 @@
+/*
+ * 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.
+ *
+ * This library is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU Lesser General Public License for more details.
+ *
+ * You should have received a copy of the GNU Lesser General Public License
+ * along with this library. If not, see .
+ */
+
+/* User-provisioned metadata signing key management. Handlers for
+ * AddMetadataKey, RemoveMetadataKey, ListMetadataKeys. Slots 4..6 are
+ * user-writable; slots 0..3 are firmware-baked and not enumerated here. */
+
+static void metadata_pubkey_fingerprint(const uint8_t pubkey[33],
+ char fp_out[9]) {
+ uint8_t digest[32];
+ sha256_Raw(pubkey, 33, digest);
+ /* First 4 bytes → 8 hex chars. Stable, short enough for OLED. */
+ for (int i = 0; i < 4; i++) {
+ snprintf(fp_out + i * 2, 3, "%02x", digest[i]);
+ }
+ fp_out[8] = '\0';
+}
+
+void fsm_msgAddMetadataKey(const AddMetadataKey *msg) {
+ CHECK_INITIALIZED
+
+ if (msg->slot < METADATA_USER_KEY_FIRST ||
+ msg->slot > METADATA_USER_KEY_LAST) {
+ fsm_sendFailure(FailureType_Failure_SyntaxError,
+ "slot must be in user range (4..6)");
+ layoutHome();
+ return;
+ }
+ if (msg->pubkey.size != 33 ||
+ (msg->pubkey.bytes[0] != 0x02 && msg->pubkey.bytes[0] != 0x03)) {
+ fsm_sendFailure(FailureType_Failure_SyntaxError,
+ "pubkey must be 33-byte compressed secp256k1");
+ layoutHome();
+ return;
+ }
+ if (msg->label[0] == '\0') {
+ fsm_sendFailure(FailureType_Failure_SyntaxError, "label must not be empty");
+ layoutHome();
+ return;
+ }
+
+ char fp[9];
+ metadata_pubkey_fingerprint(msg->pubkey.bytes, fp);
+
+ /* Confirm 1: slot + label. Lets user abort cheaply before PIN. */
+ if (!confirm(ButtonRequestType_ButtonRequest_Other, "Add Insight Key",
+ "Slot %lu\n%s", (unsigned long)msg->slot, msg->label)) {
+ fsm_sendFailure(FailureType_Failure_ActionCancelled,
+ "Add metadata key cancelled");
+ layoutHome();
+ return;
+ }
+
+ /* Confirm 2: fingerprint. User verifies against company-published value
+ * out-of-band. Truncating to 8 hex chars is intentional — full SHA256
+ * doesn't fit and the threat model here is mistake/typo, not collision. */
+ if (!confirm(ButtonRequestType_ButtonRequest_Other, "Verify Key Fingerprint",
+ "fp:\n%s", fp)) {
+ fsm_sendFailure(FailureType_Failure_ActionCancelled,
+ "Add metadata key cancelled");
+ layoutHome();
+ return;
+ }
+
+ CHECK_PIN_UNCACHED
+
+ if (!storage_setMetadataKey(msg->slot, msg->pubkey.bytes, msg->label)) {
+ fsm_sendFailure(FailureType_Failure_ActionCancelled,
+ "Could not store metadata key");
+ layoutHome();
+ return;
+ }
+ storage_commit();
+
+ fsm_sendSuccess("Metadata key added");
+ layoutHome();
+}
+
+void fsm_msgRemoveMetadataKey(const RemoveMetadataKey *msg) {
+ CHECK_INITIALIZED
+
+ if (msg->slot < METADATA_USER_KEY_FIRST ||
+ msg->slot > METADATA_USER_KEY_LAST) {
+ fsm_sendFailure(FailureType_Failure_SyntaxError,
+ "slot must be in user range (4..6)");
+ layoutHome();
+ return;
+ }
+
+ uint8_t pubkey[33];
+ char label[17];
+ if (!storage_getMetadataKey(msg->slot, pubkey, label)) {
+ fsm_sendFailure(FailureType_Failure_Other, "slot is empty");
+ layoutHome();
+ return;
+ }
+
+ /* Show fingerprint of the key being removed — defends against stealth
+ * removal where an attacker briefly accesses an unlocked device. */
+ char fp[9];
+ metadata_pubkey_fingerprint(pubkey, fp);
+
+ if (!confirm(ButtonRequestType_ButtonRequest_Other, "Remove Insight Key",
+ "Slot %lu: %s\nfp: %s", (unsigned long)msg->slot, label, fp)) {
+ fsm_sendFailure(FailureType_Failure_ActionCancelled,
+ "Remove metadata key cancelled");
+ layoutHome();
+ return;
+ }
+
+ CHECK_PIN_UNCACHED
+
+ if (!storage_removeMetadataKey(msg->slot)) {
+ fsm_sendFailure(FailureType_Failure_ActionCancelled,
+ "Could not remove metadata key");
+ layoutHome();
+ return;
+ }
+ storage_commit();
+
+ fsm_sendSuccess("Metadata key removed");
+ layoutHome();
+}
+
+void fsm_msgListMetadataKeys(const ListMetadataKeys *msg) {
+ (void)msg;
+ RESP_INIT(MetadataKeysList);
+
+ size_t count = 0;
+ for (uint32_t slot = METADATA_USER_KEY_FIRST; slot <= METADATA_USER_KEY_LAST;
+ ++slot) {
+ uint8_t pubkey[33];
+ char label[17];
+ if (!storage_getMetadataKey(slot, pubkey, label)) continue;
+
+ MetadataKeyType *k = &resp->keys[count++];
+ memzero(k, sizeof(*k));
+ k->has_slot = true;
+ k->slot = slot;
+ k->has_pubkey = true;
+ k->pubkey.size = 33;
+ memcpy(k->pubkey.bytes, pubkey, 33);
+ k->has_label = true;
+ strlcpy(k->label, label, sizeof(k->label));
+ }
+ resp->keys_count = count;
+
+ msg_write(MessageType_MessageType_MetadataKeysList, resp);
+}
diff --git a/lib/firmware/messagemap.def b/lib/firmware/messagemap.def
index 39792f28e..29c953816 100644
--- a/lib/firmware/messagemap.def
+++ b/lib/firmware/messagemap.def
@@ -36,6 +36,10 @@
MSG_IN(MessageType_MessageType_GetBip85Mnemonic, GetBip85Mnemonic, fsm_msgGetBip85Mnemonic)
+ MSG_IN(MessageType_MessageType_AddMetadataKey, AddMetadataKey, fsm_msgAddMetadataKey)
+ MSG_IN(MessageType_MessageType_RemoveMetadataKey, RemoveMetadataKey, fsm_msgRemoveMetadataKey)
+ MSG_IN(MessageType_MessageType_ListMetadataKeys, ListMetadataKeys, fsm_msgListMetadataKeys)
+
#ifndef BITCOIN_ONLY
MSG_IN(MessageType_MessageType_EthereumGetAddress, EthereumGetAddress, fsm_msgEthereumGetAddress)
MSG_IN(MessageType_MessageType_EthereumSignTx, EthereumSignTx, fsm_msgEthereumSignTx)
@@ -119,6 +123,8 @@
MSG_OUT(MessageType_MessageType_Bip85Mnemonic, Bip85Mnemonic, NO_PROCESS_FUNC)
+ MSG_OUT(MessageType_MessageType_MetadataKeysList, MetadataKeysList, NO_PROCESS_FUNC)
+
#ifndef BITCOIN_ONLY
MSG_OUT(MessageType_MessageType_EthereumAddress, EthereumAddress, NO_PROCESS_FUNC)
MSG_OUT(MessageType_MessageType_EthereumTxRequest, EthereumTxRequest, NO_PROCESS_FUNC)
diff --git a/lib/firmware/signed_metadata.c b/lib/firmware/signed_metadata.c
index f8d4b0ad6..709d70a9a 100644
--- a/lib/firmware/signed_metadata.c
+++ b/lib/firmware/signed_metadata.c
@@ -3,6 +3,7 @@
#include "keepkey/board/confirm_sm.h"
#include "keepkey/board/util.h"
#include "keepkey/firmware/ethereum.h"
+#include "keepkey/firmware/storage.h"
#include "trezor/crypto/address.h"
#include "trezor/crypto/bignum.h"
#include "trezor/crypto/ecdsa.h"
@@ -17,30 +18,33 @@
static bool metadata_available = false;
static SignedMetadata stored_metadata;
+static char stored_metadata_label[17];
/*
- * Metadata verification public keys.
- * Slot 0: active production key
- * Slot 1: rotation target
- * Slots 2-3: reserved
+ * Firmware-baked metadata verification public keys (slots 0..3).
+ * User-provisioned keys live in storage at slots 4..6 (see storage.h).
*
- * Keys are derived via KeepKey SignIdentity at keepkey.com/insight.
- * Only the public key is stored here — the signing mnemonic is held
- * offline and never appears in source code.
+ * Slot 0: active production key — the "Insight" key managed by KeepKey
+ * Slot 1: rotation target
+ * Slot 2: reserved
+ * Slot 3: CI test key, DEBUG_LINK only
*
- * To rotate: generate new key with pioneer-insight keygen,
- * replace the slot below, ship firmware update.
+ * Slot 0 is derived via KeepKey SignIdentity at keepkey.com/insight.
+ * Only public keys are stored here; the signing mnemonic is held offline.
+ * To rotate: generate new key with pioneer-insight keygen, replace the
+ * slot below, ship firmware update.
*/
-static const uint8_t METADATA_PUBKEYS[METADATA_MAX_KEYS][33] = {
- /* Key 0: production */
+static const uint8_t METADATA_FIRMWARE_PUBKEYS[METADATA_MAX_KEYS][33] = {
+ /* Slot 0: production */
{0x02, 0x18, 0x62, 0x1d, 0x9c, 0x14, 0x47, 0x34, 0x58, 0x71, 0x3b,
0xd3, 0xe6, 0x72, 0xe5, 0x34, 0x80, 0xaa, 0x70, 0x32, 0xca, 0x9b,
0x67, 0x35, 0x63, 0x95, 0xe8, 0x87, 0x09, 0xbb, 0x45, 0x22, 0x6a},
- /* Key 1: rotation slot */
+ /* Slot 1: rotation */
{0x00},
+ /* Slot 2: reserved */
{0x00},
#if DEBUG_LINK
- /* Key 3: CI test key — only available in emulator/debug builds */
+ /* Slot 3: CI test key — only available in emulator/debug builds */
{0x02, 0xe3, 0xb3, 0x01, 0x5c, 0x47, 0xdd, 0xca, 0xab, 0xe4, 0xf8,
0xe8, 0x72, 0xf1, 0xed, 0x8f, 0x09, 0xca, 0x14, 0x5a, 0x8d, 0x81,
0x77, 0x0d, 0x92, 0x21, 0x3d, 0x56, 0xda, 0x31, 0xab, 0x51, 0x07},
@@ -49,6 +53,33 @@ static const uint8_t METADATA_PUBKEYS[METADATA_MAX_KEYS][33] = {
#endif
};
+/* Display labels for firmware-baked slots. User slots get their label from
+ * storage. Empty string means "no label" — confirm screen falls back to a
+ * generic header. */
+static const char METADATA_FIRMWARE_LABELS[METADATA_MAX_KEYS][17] = {
+ "Insight",
+ "",
+ "",
+#if DEBUG_LINK
+ "CI Test",
+#else
+ "",
+#endif
+};
+
+/* Look up a pubkey + label by slot. Slots 0..3 are firmware-baked,
+ * 4..6 come from storage. Returns false if the slot is unprovisioned. */
+static bool metadata_get_pubkey(uint8_t slot, uint8_t pubkey_out[33],
+ char label_out[17]) {
+ if (slot < METADATA_MAX_KEYS) {
+ if (METADATA_FIRMWARE_PUBKEYS[slot][0] == 0x00) return false;
+ memcpy(pubkey_out, METADATA_FIRMWARE_PUBKEYS[slot], 33);
+ strlcpy(label_out, METADATA_FIRMWARE_LABELS[slot], 17);
+ return true;
+ }
+ return storage_getMetadataKey(slot, pubkey_out, label_out);
+}
+
static bool read_u8(const uint8_t **cursor, const uint8_t *end, uint8_t *out) {
if ((size_t)(end - *cursor) < 1) {
return false;
@@ -192,6 +223,7 @@ bool signed_metadata_available(void) { return metadata_available; }
void signed_metadata_clear(void) {
memzero(&stored_metadata, sizeof(stored_metadata));
+ memzero(stored_metadata_label, sizeof(stored_metadata_label));
metadata_available = false;
}
@@ -199,12 +231,14 @@ MetadataClassification signed_metadata_process(const uint8_t *payload,
size_t payload_len,
uint8_t key_id) {
uint8_t digest[32];
+ uint8_t pubkey[33];
+ char label[17];
size_t signed_len;
signed_metadata_clear();
- if (key_id >= METADATA_MAX_KEYS || METADATA_PUBKEYS[key_id][0] == 0x00 ||
- !payload || payload_len < 65) {
+ if (!payload || payload_len < 65 ||
+ !metadata_get_pubkey(key_id, pubkey, label)) {
return METADATA_MALFORMED;
}
@@ -217,12 +251,13 @@ MetadataClassification signed_metadata_process(const uint8_t *payload,
signed_len = payload_len - sizeof(stored_metadata.signature) - 1;
sha256_Raw(payload, signed_len, digest);
- if (ecdsa_verify_digest(&secp256k1, METADATA_PUBKEYS[key_id],
- stored_metadata.signature, digest) != 0) {
+ if (ecdsa_verify_digest(&secp256k1, pubkey, stored_metadata.signature,
+ digest) != 0) {
signed_metadata_clear();
return METADATA_MALFORMED;
}
+ strlcpy(stored_metadata_label, label, sizeof(stored_metadata_label));
metadata_available = true;
return stored_metadata.classification;
}
@@ -272,12 +307,22 @@ bool signed_metadata_confirm(void) {
return false;
}
- /* Screen 1: Verified method — use review_with_icon for trust indicator */
+ /* Screen 1: Verified method — use review_with_icon for trust indicator.
+ * Header reflects the key source: firmware slot 0 → "Insight Verified",
+ * user slots → "