penguin/homekey-relay

homekey-relay

commit cc5d8ab889512eea7fd29ca12dfc8367ea111d7b

author斟酌 鵬兄 <tgckpg@gmail.com>
date2026-10-05T08:28:37Z
subjectAdded Home Key ECP polling
commit cc5d8ab889512eea7fd29ca12dfc8367ea111d7b
Author: 斟酌 鵬兄 <tgckpg@gmail.com>
Date:   2026-10-05T08:28:37Z

    Added Home Key ECP polling
---
 README.md          |  9 ++++----
 esp32/PROTOCOL.md  | 32 ++++++++++++++++++++++++++++
 esp32/main/pn532.c | 61 +++++++++++++++++++++++++++++++++++++++++++++++++++++-
 esp32/main/relay.c | 28 +++++++++++++++++++++++++
 esp32/main/relay.h |  2 ++
 tests/pn532_test.c | 34 +++++++++++++++++++++++++++++-
 tests/relay_test.c | 17 +++++++++++++++
 7 files changed, 177 insertions(+), 6 deletions(-)

diff --git a/README.md b/README.md
index 57e51a3..22550e2 100644
--- a/README.md
+++ b/README.md
@@ -81,10 +81,11 @@ idf.py -B build-pn532-test -D PNC532_TEST=ON build flash monitor
 ## NFC APDU relay
 
 The firmware now exposes session status and an APDU mailbox for `homekey-go`
-0.0.3. Flash the matching firmware before updating the Go service. NFC runs in
+0.0.5. Deploy the matching firmware and Go service together. NFC runs in
 one UART owner task; the RF field stays on throughout authentication.
 
-The first test uses an existing Home Key opened manually in Wallet. Express-mode
-ECP wakeup, FAST authentication and unknown-device attestation are separate
-follow-up work. `homekey-go` verifies the key; UID detection alone never unlocks.
+Go now supplies the provisioned Home group identifier over BLE. The reader
+emits Home Key ECP before activation so iOS can select the key automatically.
+Polling waits for this configuration on every connection. FAST authentication
+and unknown-device attestation remain follow-up work. `homekey-go` verifies the key; UID detection alone never unlocks.
 See `esp32/PROTOCOL.md` for the mailbox format and `tests/run.sh` for host tests.
diff --git a/esp32/PROTOCOL.md b/esp32/PROTOCOL.md
index 70c7f42..8b46fb3 100644
--- a/esp32/PROTOCOL.md
+++ b/esp32/PROTOCOL.md
@@ -24,6 +24,7 @@ APDU packet header (12 bytes):
 | --- | --- | --- |
 | `01` | Go → ESP32 | Command fragment; payload follows the header |
 | `02` | Go → ESP32 | Release session; zero offset/total, no payload |
+| `03` | Go → ESP32 | ECP configuration; session/sequence/offset = 0, total = 8 (Home group ID) or 0 (disable) |
 | `10` | ESP32 → Go | Pending response; zero offset/total, no payload |
 | `11` | ESP32 → Go | Ready response; zero offset, total = payload size |
 | `12` | ESP32 → Go | Transport error; zero offset, total = 1, error byte |
@@ -50,3 +51,34 @@ CONTROL FLOW. It turns RF off after release, transport failure, disconnect or
 the existing UID presence state until removal; disconnected detections are
 retried after reconnection. GPIO3 TX / GPIO4 RX and startup wake packets are
 preserved.
+
+## Home Key ECP polling
+
+Matching firmware requires Go's `03` configuration on every BLE connection.
+The configuration is a single 20-byte ATT write, fitting the minimum MTU.
+It carries only the public 8-byte Home group identifier:
+`SHA256("key-identifier" || provisioned_reader_private_scalar)[0:8]`.
+This is the group half of the 16-byte NFC reader identifier, not its unique
+reader half or the Bluetooth MAC address. Private keys remain in Go.
+
+Go refreshes this value between sessions when provisioning changes; zero total
+disables polling until a reader key exists. Firmware clears configuration on
+connect/disconnect and keeps RF off until configured. GATT writes only update
+mutex-protected metadata; an active APDU session is never interrupted, and a
+configuration update applies to subsequent scans.
+
+Each scan transmits `6a02cb0206021100 || group[8] || CRC-A[2]` using PN532
+InCommunicateThru before InListPassiveTarget activates a card. The ESP32 selects
+106 kbps Type A/full-byte transmission and disables hardware CRC for this raw
+frame, since CRC-A is supplied in software. PN532 RF status `01` (no reply) is
+normal for this broadcast; host UART/frame timeouts and other PN532 errors
+remain failures. A short ECP response timeout is restored to the PN532 default
+before card activation. NFC polling/ECP remain suspended during APDU exchange.
+
+The ECP express flag is enabled; the phone's Home Key Express Mode setting
+still applies. Disabling Express Mode should present the key for user approval.
+ECP chooses the credential; STANDARD authentication still verifies it before
+unlocking. FAST and unknown-device attestation remain outside this release.
+
+Hardware validation is required: host tests cannot prove that a phone receives
+RF frames or selects its Home Key.
diff --git a/esp32/main/pn532.c b/esp32/main/pn532.c
index 62f0068..11f063d 100644
--- a/esp32/main/pn532.c
+++ b/esp32/main/pn532.c
@@ -317,6 +317,53 @@ static bool initialize_reader(void)
 	return true;
 }
 
+/* ECP v2 Home Key, with software CRC-A. These bytes identify a Home;
+ * they are public routing metadata, never authentication credentials. */
+static void build_ecp(uint8_t frame[18], const uint8_t group[8])
+{
+	static const uint8_t prefix[] = {0x6a, 0x02, 0xcb, 0x02, 0x06, 0x02, 0x11, 0x00};
+	memcpy(frame, prefix, sizeof(prefix));
+	memcpy(frame + 8, group, 8);
+	uint16_t crc = 0x6363;
+	for (unsigned i = 0; i < 16; ++i) {
+		uint8_t b = frame[i] ^ (uint8_t)crc;
+		b ^= (uint8_t)(b << 4);
+		crc = (crc >> 8) ^ ((uint16_t)b << 8) ^ ((uint16_t)b << 3) ^ (b >> 4);
+	}
+	frame[16] = (uint8_t)crc;
+	frame[17] = (uint8_t)(crc >> 8);
+}
+
+static bool broadcast_ecp(const uint8_t group[8])
+{
+	/* 106 kbps Type A, full bytes, software CRC (no duplicate hardware CRC).
+	 * InListPassiveTarget restores Type A activation/CRC settings afterward. */
+	static const uint8_t registers[] = {0x08,
+		0x63, 0x02, 0x00, /* CIU_TxMode */
+		0x63, 0x03, 0x00, /* CIU_RxMode */
+		0x63, 0x05, 0x40, /* CIU_TxAuto: 100% ASK */
+		0x63, 0x3c, 0x10, /* CIU_Control: initiator, including first scan */
+		0x63, 0x3d, 0x00}; /* CIU_BitFraming */
+	static const uint8_t rf_on[] = {0x32, 0x01, 0x03};
+	static const uint8_t short_timeout[] = {0x32, 0x02, 0x00, 0x0b, 0x08};
+	static const uint8_t normal_timeout[] = {0x32, 0x02, 0x00, 0x0b, 0x0a};
+	uint8_t response[128], cmd[19] = {0x42};
+	if (command(registers, sizeof(registers), response) != 2 ||
+		command(rf_on, sizeof(rf_on), response) != 2 ||
+		command(short_timeout, sizeof(short_timeout), response) != 2)
+		return false;
+	/* Allow RF to settle before transmitting the proprietary polling frame. */
+	vTaskDelay(pdMS_TO_TICKS(5));
+	build_ecp(cmd + 1, group);
+	int n = command_full(cmd, sizeof(cmd), response, sizeof(response), 250);
+	/* ECP is a broadcast: PN532 RF timeout 01 is expected, not a UART timeout. */
+	bool ok = n >= 3 && (response[2] == 0 || (n == 3 && response[2] == 1));
+	if (!ok)
+		ESP_LOGW(TAG, "ECP broadcast failed: length=%d", n);
+	bool restored = command(normal_timeout, sizeof(normal_timeout), response) == 2;
+	return ok && restored;
+}
+
 /* 1 = card, 0 = confirmed no target, -1 = transport/protocol failure. */
 static int poll_card(nfc_card_t *card)
 {
@@ -352,7 +399,19 @@ void start_polling(pn532_card_callback_t callback)
 	nfc_presence_t presence = {0};
 	for (;;) {
 		nfc_card_t card = {0};
-		int result = poll_card(&card);
+		uint8_t group[8];
+		bool ecp = relay_ecp_group(group);
+		if (!ecp) {
+			/* Do not wake Wallet with ordinary polls before Go supplies
+			 * this connection's provisioned Home identifier. */
+			presence = (nfc_presence_t){0};
+			release_rf();
+			vTaskDelay(pdMS_TO_TICKS(POLL_INTERVAL_MS));
+			continue;
+		}
+		/* Announce before activation, including when a phone would otherwise
+		 * answer with its default payment/transit applet. */
+		int result = !broadcast_ecp(group) ? -1 : poll_card(&card);
 		bool was_present = presence.present;
 		if (nfc_presence_update(&presence, result, &card)) {
 			ESP_LOGI(TAG, "card detected, UID length=%u", card.uid_len);
diff --git a/esp32/main/relay.c b/esp32/main/relay.c
index a805823..e1049f7 100644
--- a/esp32/main/relay.c
+++ b/esp32/main/relay.c
@@ -16,6 +16,8 @@ typedef struct {
 static SemaphoreHandle_t mutex;
 static QueueHandle_t requests;
 static bool connected, active, busy, ending;
+static bool ecp_configured;
+static uint8_t ecp_group[8];
 static uint32_t session_counter, session;
 static nfc_card_t current;
 static request_t assembling;
@@ -63,6 +65,8 @@ void relay_connected(bool value)
 	ESP_LOGI("homekey-reader", "relay_connected(%s)", value ? "true" : "false");
 	xSemaphoreTake(mutex, portMAX_DELAY);
 	connected = value;
+	ecp_configured = false;
+	memset(ecp_group, 0, sizeof(ecp_group));
 	active = false;
 	ending = true;
 	busy = false;
@@ -71,6 +75,15 @@ void relay_connected(bool value)
 	xQueueReset(requests);
 	xSemaphoreGive(mutex);
 }
+bool relay_ecp_group(uint8_t out[8])
+{
+	xSemaphoreTake(mutex, portMAX_DELAY);
+	bool ready = connected && ecp_configured;
+	if (ready)
+		memcpy(out, ecp_group, 8);
+	xSemaphoreGive(mutex);
+	return ready;
+}
 size_t relay_status(uint8_t out[18])
 {
 	xSemaphoreTake(mutex, portMAX_DELAY);
@@ -102,6 +115,21 @@ bool relay_write(const uint8_t *p, size_t n)
 	uint16_t seq = be16(p + 6), off = be16(p + 8), total = be16(p + 10);
 	bool ok = false;
 	xSemaphoreTake(mutex, portMAX_DELAY);
+	/* Configuration is connection-scoped and fits the minimum ATT MTU.
+	 * No UART work here; the NFC owner snapshots it before polling. */
+	if (p[1] == 3) {
+		if (connected && sid == 0 && seq == 0 && off == 0 &&
+			(total == 0 || total == 8) && n == (size_t)RELAY_HEADER + total) {
+			ecp_configured = total == 8;
+			memset(ecp_group, 0, sizeof(ecp_group));
+			if (ecp_configured)
+				memcpy(ecp_group, p + RELAY_HEADER, 8);
+			ok = true;
+			ESP_LOGI("homekey-reader", "Home Key ECP %s",
+					 ecp_configured ? "configured" : "disabled");
+		}
+		goto done;
+	}
 	if (!connected || !active || ending || sid != session)
 		goto done;
 	if (p[1] == 2) {
diff --git a/esp32/main/relay.h b/esp32/main/relay.h
index cc640a5..32187f3 100644
--- a/esp32/main/relay.h
+++ b/esp32/main/relay.h
@@ -7,6 +7,8 @@
 #define RELAY_VALUE_MAX (RELAY_HEADER + RELAY_APDU_MAX)
 /* GATT callbacks are nonblocking. PN532/UART work stays in its owner task. */
 void relay_init(void);
+/* Snapshot the public Home group ID; false until Go configures this connection. */
+bool relay_ecp_group(uint8_t out[8]);
 void relay_connected(bool connected);
 size_t relay_status(uint8_t out[18]);
 size_t relay_response(uint8_t out[RELAY_VALUE_MAX]);
diff --git a/tests/pn532_test.c b/tests/pn532_test.c
index 2e88dc7..e02b95b 100644
--- a/tests/pn532_test.c
+++ b/tests/pn532_test.c
@@ -6,6 +6,8 @@ static int64_t now;
 static unsigned writes;
 static uint8_t last_tx[300];
 static size_t last_tx_len;
+static uint8_t tx_history[16][300];
+bool relay_ecp_group(uint8_t out[8]) { (void)out; return false; }
 int64_t esp_timer_get_time(void) { return now; }
 void vTaskDelay(TickType_t t) { now += t * 1000; }
 int uart_read_bytes(int u, void *dst, size_t len, TickType_t t)
@@ -31,6 +33,8 @@ int uart_write_bytes(int u, const void *d, size_t n)
 	assert(n <= sizeof(last_tx));
 	memcpy(last_tx, d, n);
 	last_tx_len = n;
+	assert(writes < 16);
+	memcpy(tx_history[writes], d, n);
 	++writes;
 	return (int)n;
 }
@@ -170,6 +174,34 @@ int main(void)
 	response(reply, sizeof(reply));
 	assert(pn532_exchange(1, apdu, sizeof(apdu), out, sizeof(out), &out_length) == 3);
 	assert(pn532_exchange(1, apdu, RELAY_APDU_MAX + 1, out, sizeof(out), &out_length) == 1);
-	puts("PN532: startup prefix preserved; APDU >64 bytes; status/chaining rejection; fragmented "
+	const uint8_t gid[] = {0x94, 0x62, 0x5f, 0xd2, 0xc3, 0x2c, 0xf3, 0xc2};
+	uint8_t ecp[18];
+	build_ecp(ecp, gid);
+	assert(memcmp(ecp, "\x6a\x02\xcb\x02\x06\x02\x11\x00", 8) == 0);
+	assert(memcmp(ecp + 8, gid, 8) == 0);
+	/* Independent CRC-A vector for the Home Key subtype 06. */
+	assert(ecp[16] == 0x66 && ecp[17] == 0x3a);
+	const uint8_t reg_ok[] = {0xd5, 0x09}, timeout[] = {0xd5, 0x43, 1};
+	reset();
+	response(reg_ok, 2);
+	response(rf, 2);
+	response(rf, 2);
+	response(timeout, 3);
+	response(rf, 2);
+	assert(broadcast_ecp(gid));
+	assert(writes == 5); /* Expected RF timeout never causes an abort ACK. */
+	assert(tx_history[0][6] == 0x08 && tx_history[0][9] == 0);
+	assert(tx_history[3][6] == 0x42);
+	assert(memcmp(tx_history[3] + 7, ecp, 18) == 0);
+	reset();
+	response(reg_ok, 2);
+	response(rf, 2);
+	response(rf, 2);
+	const uint8_t bad_status[] = {0xd5, 0x43, 2};
+	response(bad_status, 3);
+	response(rf, 2);
+	assert(!broadcast_ecp(gid));
+	assert(writes == 5);
+	puts("PN532: ECP frame/CRC-A, raw transmit and expected timeout; startup prefix preserved; APDU >64 bytes; status/chaining rejection; fragmented "
 		 "frames, checksums, timeout and UID parsing passed");
 }
diff --git a/tests/relay_test.c b/tests/relay_test.c
index 0fa85a8..4bd9150 100644
--- a/tests/relay_test.c
+++ b/tests/relay_test.c
@@ -108,6 +108,23 @@ int main(void)
 	relay_init();
 	nfc_card_t card = {.uid = {1, 2, 3, 4}, .uid_len = 4, .target = 1, .sak = 0x20};
 	assert(!relay_card(&card));
+	uint8_t group[8], b[32], gid[8] = {1,2,3,4,5,6,7,8};
+	assert(!relay_ecp_group(group));
+	assert(!relay_write(b, make_request(b, 3, 0, 0, 0, 8, gid, 8)));
+	relay_connected(true);
+	assert(!relay_write(b, make_request(b, 3, 1, 0, 0, 8, gid, 8)));
+	assert(!relay_write(b, make_request(b, 3, 0, 0, 0, 7, gid, 7)));
+	assert(relay_write(b, make_request(b, 3, 0, 0, 0, 8, gid, 8)));
+	assert(relay_ecp_group(group) && memcmp(group, gid, 8) == 0);
+	active = true;
+	assert(relay_write(b, make_request(b, 3, 0, 0, 0, 8, gid, 8)));
+	assert(active); /* Configuration never interrupts the APDU session. */
+	active = false;
+	assert(relay_write(b, make_request(b, 3, 0, 0, 0, 0, NULL, 0)));
+	assert(!relay_ecp_group(group));
+	assert(relay_write(b, make_request(b, 3, 0, 0, 0, 8, gid, 8)));
+	relay_connected(false);
+	assert(!relay_ecp_group(group));
 	for (scenario = 0; scenario < 3; scenario++) {
 		relay_connected(true);
 		phase = exchanges = 0;