Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
21 commits
Select commit Hold shift + click to select a range
6d56127
feat(build): one-command reproducible-build verification script
BitHighlander Jul 8, 2026
698f656
fix(verify-repro): reject unrecognized args; correct device-hash cros…
BitHighlander Jul 8, 2026
dee5d10
fix(solana): restore exact-byte and priority-fee review
BitHighlander Aug 26, 2026
8332df3
build(deps): pin reconciled python presign tests
BitHighlander Aug 26, 2026
3fb9b0d
feat(clearsign): decode certified Portals native swaps
BitHighlander Aug 26, 2026
13770b2
feat(eip712): enable canonical structured review without blind signing
BitHighlander Aug 26, 2026
3a9534b
test(eip712): pin Permit2 batch acceptance coverage
BitHighlander Aug 26, 2026
65e1659
fix(clearsign): allow certified EVM metadata without advanced mode
BitHighlander Aug 26, 2026
2673a89
Merge pull request #612 from BitHighlander/feat/716-certified-evm-por…
BitHighlander Aug 27, 2026
662a09d
test(report): pin corrected EIP-712 evidence catalog
BitHighlander Aug 27, 2026
b6783c7
test(deps): pin corrected alpha integration gates
BitHighlander Aug 27, 2026
bf1e70b
test(deps): pin unified alpha companion gates
BitHighlander Aug 27, 2026
aea794b
merge(develop): sync audited 7.16 baseline into alpha
BitHighlander Aug 27, 2026
01bc413
merge(alpha): include Solana disclosure hardening
BitHighlander Aug 27, 2026
b6ac5bf
merge(alpha): include structured EIP-712 review
BitHighlander Aug 27, 2026
6eed3a9
test(alpha): pin unified companion matrix
BitHighlander Aug 27, 2026
e07a95e
merge(ci): carry corrected 7.16 report gate
BitHighlander Aug 27, 2026
a52faa3
test(alpha): pin corrected exact companion matrix
BitHighlander Aug 27, 2026
78ee3af
test(alpha): pin supported sign-message evidence
BitHighlander Aug 27, 2026
c6e9c08
test(alpha): pin the aligned companion matrix
BitHighlander Aug 27, 2026
cf1a653
Merge pull request #625 from BitHighlander/sync/alpha-audited-develop…
BitHighlander Aug 27, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
83 changes: 83 additions & 0 deletions docs/ReproducibleBuilds.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,83 @@
# Reproducible Builds

KeepKey firmware releases are built deterministically: anyone can rebuild a
release tag from source and confirm, byte for byte, that it matches the binary
we ship. This page explains how to verify that, and why the naive "hash both
files" comparison is guaranteed to fail even for a perfect build.

## One-command verification

```bash
git clone https://github.com/keepkey/keepkey-firmware
cd keepkey-firmware
./scripts/build/verify-repro.sh v7.15.0
```

The script clones the tag into a temporary directory, builds it with the
official Docker image (`kktech/firmware:v15`, the same pinned image used for
releases), downloads the signed release binary from GitHub, and compares the
device-verifiable payload hashes. It prints all four hashes and `PASS`/`FAIL`.

Requirements: `git`, `docker`, `curl`, ~2 GB of disk, and about 15 minutes.

## The 256-byte header, or: why full-file hashes never match

`firmware.keepkey.bin` starts with a 256-byte metadata header
(`tools/firmware/header.s`):

| offset (bytes) | size | contents |
|---------------:|-----:|------------------------------|
| 0 | 4 | magic `KPKY` |
| 4 | 4 | payload length |
| 8 | 3 | signature key indexes |
| 11 | 1 | flags |
| 12 | 52 | reserved (zero) |
| 64 | 192 | three 64-byte signatures |
| 256 | — | payload (the actual firmware)|

In a freshly built binary the signature indexes and signature slots are all
**zeros**. The release process signs that exact binary, filling in the key
indexes and the three secp256k1 signatures (3-of-5 against the public keys in
`include/keepkey/board/pubkeys.h`). Nothing after byte 256 changes.

Consequently, for any correctly reproduced build:

- `sha256(built file)` **never** equals `sha256(signed release file)` — the
header bytes differ by construction;
- `sha256` of everything **after** byte 256 is identical on both sides — this
payload hash is the reproducibility check.

## Manual verification

```bash
git clone https://github.com/keepkey/keepkey-firmware
cd keepkey-firmware
git checkout v7.15.0
git submodule update --init --recursive
./scripts/build/docker/device/release.sh

# payload hash of your build:
tail -c +257 bin/firmware.keepkey.bin | sha256sum

# payload hash of the official release:
curl -fsSL -o official.bin \
https://github.com/keepkey/keepkey-firmware/releases/download/v7.15.0/firmware.keepkey.bin
tail -c +257 official.bin | sha256sum
```

The two payload hashes must be equal. Each release's `HASHES.txt` asset lists
the expected values.

## Cross-checks

- The firmware embeds the git commit it was built from
(`lib/firmware/scm_revision.h.in`); a device reports it in
`Features.revision`, so you can confirm which commit produced the binary a
device is actually running.
- The device also reports `Features.firmware_hash`
(`memory_firmware_hash()` in `lib/board/memory.c`), a sha256 over the
**installed header (including the filled-in signatures) plus payload** —
i.e. the full signed file, not the payload alone. It matches the
"full signed file" line in `HASHES.txt`, **not** the payload line above.
Host software comparing `Features.firmware_hash` against a release must use
that full-file hash.
5 changes: 5 additions & 0 deletions include/keepkey/firmware/eip712_stream.h
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,11 @@
* not occur in practice and every one costs EIP712_MAX_STRUCTS bytes. */
#define EIP712_MAX_STRUCT_NAME 32

/* Canonical ASCII Solidity identifier. Besides being part of encodeType, a
* member name is also the review-screen title, so this guarantees the exact
* bytes hashed are the exact bytes rendered (no truncation/control glyphs). */
bool eip712_identifier_ok(const char *name);

/* Fetch one struct's member list by name. Returns NULL if the host has not
* supplied it. Firmware backs this with the streaming state machine; the unit
* tests back it with a fixture table, which is what makes encodeType testable
Expand Down
3 changes: 3 additions & 0 deletions include/keepkey/firmware/ethereum.h
Original file line number Diff line number Diff line change
Expand Up @@ -72,6 +72,9 @@ void ethereum_typed_hash_sign(const EthereumSignTypedHash* msg,
const HDNode* node,
EthereumTypedDataSignature* resp);
bool ethereum_typed_hash_policy_allows(bool advanced_mode);
/* Canonical device-driven EIP-712 streaming is available without blind-sign
policy. The withdrawn whole-JSON parser remains separately disabled. */
bool ethereum_streamed_eip712_enabled(void);
bool ethereum_structured_eip712_enabled(void);
/* True only for the exact string "EIP712Domain" -- the primaryType whose
signature legitimately carries no message hash. Never a prefix match. */
Expand Down
32 changes: 28 additions & 4 deletions include/keepkey/firmware/signed_metadata.h
Original file line number Diff line number Diff line change
Expand Up @@ -47,11 +47,26 @@ typedef enum {
#define METADATA_VERSION_LEGACY 0x01
#define METADATA_VERSION_SCHEMA 0x02

/* 7.16: a KeepKey-delegated envelope. [0x03][cert 139][inner v2 payload].
/* 7.16: a KeepKey-delegated envelope. [0x03][cert 139][device-decoded schema].
* The certificate is verified and DISCARDED inside one message -- nothing about
* a delegation survives into the next transaction. */
#define METADATA_VERSION_CERTIFIED 0x03

/* DYNAMIC_SCHEMA (v4): a certified, firmware-owned decoder selected by a
* signed one-byte decoder id. Unlike v1, the signer supplies no displayed
* values; unlike v2, the calldata need not be a flat list of ABI words. The
* device's named decoder validates and extracts every displayed value from the
* transaction it is signing. v4 is initially used only for the exact Portals
* OrderPayload ABI, whose dynamic call array is larger than the 1024-byte
* initial protobuf chunk while its safety-defining outer order is wholly in
* that chunk. */
#define METADATA_VERSION_DYNAMIC_SCHEMA 0x04

typedef enum {
METADATA_DECODER_NONE = 0,
METADATA_DECODER_PORTALS_NATIVE_ORDER_V1 = 1,
} MetadataDecoder;

/* The delegate is addressed by a sentinel that is >= METADATA_MAX_KEYS BY
* CONSTRUCTION, so it can never name a runtime slot.
*
Expand Down Expand Up @@ -114,6 +129,7 @@ typedef struct {

typedef struct {
uint8_t version;
uint8_t decoder_id;
uint32_t chain_id;
uint8_t contract_address[20];
uint8_t selector[4];
Expand All @@ -130,6 +146,14 @@ typedef struct {

bool signed_metadata_available(void);

/* True only for the reserved KeepKey-certified envelope shape. This is the
* narrow pre-verification predicate used by the FSM to let a v3 certificate
* reach signed_metadata_process() while AdvancedMode is off. It grants no
* trust by itself: the compiled root, certificate, delegate signature, and
* device-decoded schema are still verified by signed_metadata_process(). */
bool signed_metadata_is_certified_envelope(const uint8_t* payload,
size_t payload_len, uint32_t key_id);

/* True when the stored v2 (schema) metadata was decoded from the current tx's
* calldata by the most recent signed_metadata_matches_tx() call. Reset at the
* top of every matches_tx() so it reflects only that call (never a stale prior
Expand All @@ -145,14 +169,14 @@ bool signed_metadata_schema_moves_value(void);
void signed_metadata_clear(void);

/*
* Runtime-loaded clearsign signers (phase 1: the ONLY verification path).
* Runtime-loaded clearsign signers (the development/self-service path).
*
* A signer is a compressed secp256k1 pubkey + display alias loaded into a
* key slot at the host's request, gated by a mandatory on-device confirm
* (see fsm_msgLoadClearsignSigner). Loaded signers live in RAM only and are
* gone on reboot. Metadata verified by a loaded signer always shows a
* warning screen naming the alias before any clearsign page — only the
* built-in (phase 2) keys sign warning-free.
* warning screen naming the alias before any clearsign page. The production
* path instead carries a root-certified delegate in each v3 envelope.
*/

/* Pure validation: slot in range and not occupied by a built-in key, pubkey a
Expand Down
54 changes: 48 additions & 6 deletions lib/firmware/eip712_stream.c
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,25 @@
typedef EthereumTypedDataStructAck_EthereumFieldType Eip712FieldType;
typedef EthereumTypedDataStructAck_EthereumDataType Eip712DataType;

bool eip712_identifier_ok(const char *name) {
if (!name) return false;
size_t len = strlen(name);
if (len == 0 || len + 1 > EIP712_MAX_STRUCT_NAME) return false;
char first = name[0];
if (!((first >= 'A' && first <= 'Z') || (first >= 'a' && first <= 'z') ||
first == '_' || first == '$')) {
return false;
}
for (size_t i = 1; i < len; i++) {
char c = name[i];
if (!((c >= 'A' && c <= 'Z') || (c >= 'a' && c <= 'z') ||
(c >= '0' && c <= '9') || c == '_' || c == '$')) {
return false;
}
}
return true;
}

/* ── encodeType spelling ─────────────────────────────────────────────
*
* The type string is hashed into typeHash, so getting a character wrong here
Expand Down Expand Up @@ -92,7 +111,7 @@ bool eip712_type_name(const Eip712FieldType *field, char *out, size_t out_len) {
base = "address";
break;
case EthereumTypedDataStructAck_EthereumDataType_STRUCT:
if (!field->has_struct_name || field->struct_name[0] == '\0')
if (!field->has_struct_name || !eip712_identifier_ok(field->struct_name))
return false;
base = field->struct_name;
break;
Expand Down Expand Up @@ -300,7 +319,7 @@ static bool closure_contains(const Eip712Closure *c, const char *name) {

static bool closure_add(Eip712Closure *c, const char *name) {
size_t len = strlen(name);
if (len == 0 || len + 1 > EIP712_MAX_STRUCT_NAME) return false;
if (!eip712_identifier_ok(name)) return false;
if (closure_contains(c, name)) return true;
if (c->count >= EIP712_MAX_STRUCTS) return false;
memcpy(c->names[c->count], name, len + 1);
Expand Down Expand Up @@ -362,7 +381,7 @@ static bool closure_collect(const char *name, Eip712StructLookup lookup,
static bool hash_segment_from_ack(const char *name,
const EthereumTypedDataStructAck *def,
SHA3_CTX *hash) {
if (!def) return false;
if (!def || !eip712_identifier_ok(name)) return false;

keccak_Update(hash, (const uint8_t *)name, strlen(name));
keccak_Update(hash, (const uint8_t *)"(", 1);
Expand All @@ -372,7 +391,10 @@ static bool hash_segment_from_ack(const char *name,
if (!eip712_type_name(&def->members[m].type, type_name, sizeof(type_name)))
return false;
const char *member_name = def->members[m].name;
if (member_name[0] == '\0') return false;
if (!eip712_identifier_ok(member_name)) return false;
for (size_t prior = 0; prior < m; prior++) {
if (strcmp(member_name, def->members[prior].name) == 0) return false;
}

if (m > 0) keccak_Update(hash, (const uint8_t *)",", 1);
keccak_Update(hash, (const uint8_t *)type_name, strlen(type_name));
Expand Down Expand Up @@ -757,8 +779,7 @@ bool eip712_stream_begin(const EthereumSignTypedData *msg) {
}
/* primary_type is `required` on the wire, so nanopb emits no has_ flag --
* an absent one cannot decode at all. Empty and over-long still can. */
if (msg->primary_type[0] == '\0' ||
strlen(msg->primary_type) + 1 > EIP712_MAX_STRUCT_NAME) {
if (!eip712_identifier_ok(msg->primary_type)) {
fail("EIP-712 primary type missing or too long");
return false;
}
Expand Down Expand Up @@ -797,6 +818,27 @@ bool eip712_stream_on_struct(const EthereumTypedDataStructAck *ack) {
return false;
}

/* Names are both encodeType bytes and screen titles. Restrict them to the
* canonical ASCII identifier subset that fits pending_name exactly; a host
* cannot smuggle controls, Unicode lookalikes or a truncated label onto the
* review screen. Duplicate members are not a canonical struct definition. */
for (size_t i = 0; i < ack->members_count; i++) {
const char *member_name = ack->members[i].name;
char type_name[EIP712_MAX_TYPE_NAME];
if (!eip712_identifier_ok(member_name) ||
!eip712_type_name(&ack->members[i].type, type_name,
sizeof(type_name))) {
fail("EIP-712 struct member is malformed");
return false;
}
for (size_t j = 0; j < i; j++) {
if (strcmp(member_name, ack->members[j].name) == 0) {
fail("EIP-712 struct has duplicate members");
return false;
}
}
}

switch (e712.phase) {
case PH_DISCOVER: {
/* Note every struct this one references, then move to the next unvisited
Expand Down
5 changes: 5 additions & 0 deletions lib/firmware/ethereum.c
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,11 @@ bool ethereum_typed_hash_policy_allows(bool advanced_mode) {
return advanced_mode;
}

/* The device-driven stream validates, renders and hashes each leaf from the
* same byte buffer. It is not blind signing and therefore does not inherit the
* AdvancedMode requirement of the precomputed-hash endpoint. */
bool ethereum_streamed_eip712_enabled(void) { return true; }

/* The legacy JSON parser cannot guarantee that every displayed value is the
* canonical value hashed by EIP-712. Keep the protocol symbol for compatibility
* but fail closed in the FSM until the complete parser hardening is backported.
Expand Down
31 changes: 22 additions & 9 deletions lib/firmware/fsm_msg_ethereum.h
Original file line number Diff line number Diff line change
Expand Up @@ -138,14 +138,27 @@ void fsm_msgEthereumTxMetadata(const EthereumTxMetadata* msg) {
return;
}

CHECK_PARAM(storage_isPolicyEnabled("AdvancedMode"),
_("AdvancedMode required for clearsign metadata"));
CHECK_PARAM(!msg->has_key_id || msg->key_id <= 0xff,
_("clearsign metadata key_id out of range"));

/* Runtime/self-service signers remain behind AdvancedMode. A production v3
* envelope is allowed through only when it uses the reserved delegate key
* id and has enough bytes to contain a certificate plus inner payload. This
* shape check grants no trust: signed_metadata_process() still verifies the
* compiled root, certificate, delegate signature, and device-owned decode
* before the metadata can affect signing or suppress raw review. */
bool certified =
msg->has_signed_payload && msg->has_key_id &&
signed_metadata_is_certified_envelope(
msg->signed_payload.bytes, msg->signed_payload.size, msg->key_id);
CHECK_PARAM(storage_isPolicyEnabled("AdvancedMode") || certified,
_("AdvancedMode required for uncertified clearsign metadata"));

RESP_INIT(EthereumMetadataAck);

MetadataClassification result = signed_metadata_process(
msg->signed_payload.bytes, msg->signed_payload.size,
msg->has_key_id ? msg->key_id : 0);
msg->has_key_id ? (uint8_t)msg->key_id : 0);

resp->classification = (uint32_t)result;
resp->has_display_summary = true;
Expand Down Expand Up @@ -618,13 +631,13 @@ void fsm_msgEthereumSignTypedData(const EthereumSignTypedData* msg) {
CHECK_INITIALIZED
CHECK_PIN

/* Same gate the hashed path carries. Structured display is strictly more
* information than the blind path it replaces, but this is new parser
* surface reachable from a website, so it stays behind AdvancedMode until it
* has hardware evidence behind it. */
if (!storage_isPolicyEnabled("AdvancedMode")) {
/* This is the canonical device-driven stream, not the withdrawn whole-JSON
* parser and not the blind typed-hash endpoint. Every leaf is validated,
* rendered and hashed from the same bytes, so AdvancedMode is neither needed
* nor consulted. */
if (!ethereum_streamed_eip712_enabled()) {
fsm_sendFailure(FailureType_Failure_Other,
_("Enable AdvancedMode to sign typed data"));
_("Structured EIP-712 is unavailable"));
layout_home();
return;
}
Expand Down
Loading
Loading