commit cc5d8ab889512eea7fd29ca12dfc8367ea111d7b
| author | 斟酌 鵬兄 <tgckpg@gmail.com> |
| date | 2026-10-05T08:28:37Z |
| subject | Added 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;