cryptnox-sdk-arduino 1.0.0
Arduino library for Cryptnox Hardware Wallet
Loading...
Searching...
No Matches
UsdcSigning.ino
Go to the documentation of this file.
1/*
2 * SPDX-License-Identifier: LGPL-3.0-or-later
3 * Copyright (c) 2026 Cryptnox SA
4 */
5
20
21#include <Arduino.h>
22#include <SPI.h>
23#include "keccak256.h"
24#include <WiFiS3.h>
25#include <ArduinoHttpClient.h>
26#include "util.h"
27#include "config.h"
28#include <CryptnoxWallet.h>
29
31#define PN532_SS_PIN (10U)
32
33/* Fallback PIN — define CARD_PIN and CARD_PIN_LEN in config.h to override.
34 * L-04: hardcoded PIN lives in flash, recoverable via SWD/JTAG — OK for
35 * demo, not for prod. See config.template.h for safer patterns. */
36#ifndef CARD_PIN
37# define CARD_PIN "000000000"
38# define CARD_PIN_LEN (9U)
39#endif
40
41/* Default RPC path — PublicNode accepts JSON-RPC at root.
42 * Infura requires /v3/{PROJECT_ID}: define RPC_PATH in config.h for that case. */
43#ifndef RPC_PATH
44# define RPC_PATH "/"
45#endif
46
47/* M-04: TLS server-certificate pinning.
48 *
49 * Without setCACert(), WiFiSSLClient accepts ANY certificate the RPC endpoint
50 * presents — a network attacker can MITM the connection and feed crafted
51 * nonces / gas prices to make the device sign incorrect transactions, or
52 * exfiltrate the basic-auth credentials sent to Infura.
53 *
54 * The default below is ISRG Root X1 (Let's Encrypt), which signs the chains
55 * used by PublicNode (the template's default provider). For a different
56 * provider (Infura/DigiCert, Cloudflare, etc.), override WIFI_CA_CERT in
57 * config.h with the appropriate root in PEM form. */
58#ifndef WIFI_CA_CERT
59# define WIFI_CA_CERT \
60"-----BEGIN CERTIFICATE-----\n" \
61"MIIFazCCA1OgAwIBAgIRAIIQz7DSQONZRGPgu2OCiwAwDQYJKoZIhvcNAQELBQAw\n" \
62"TzELMAkGA1UEBhMCVVMxKTAnBgNVBAoTIEludGVybmV0IFNlY3VyaXR5IFJlc2Vh\n" \
63"cmNoIEdyb3VwMRUwEwYDVQQDEwxJU1JHIFJvb3QgWDEwHhcNMTUwNjA0MTEwNDM4\n" \
64"WhcNMzUwNjA0MTEwNDM4WjBPMQswCQYDVQQGEwJVUzEpMCcGA1UEChMgSW50ZXJu\n" \
65"ZXQgU2VjdXJpdHkgUmVzZWFyY2ggR3JvdXAxFTATBgNVBAMTDElTUkcgUm9vdCBY\n" \
66"MTCCAiIwDQYJKoZIhvcNAQEBBQADggIPADCCAgoCggIBAK3oJHP0FDfzm54rVygc\n" \
67"h77ct984kIxuPOZXoHj3dcKi/vVqbvYATyjb3miGbESTtrFj/RQSa78f0uoxmyF+\n" \
68"0TM8ukj13Xnfs7j/EvEhmkvBioZxaUpmZmyPfjxwv60pIgbz5MDmgK7iS4+3mX6U\n" \
69"A5/TR5d8mUgjU+g4rk8Kb4Mu0UlXjIB0ttov0DiNewNwIRt18jA8+o+u3dpjq+sW\n" \
70"T8KOEUt+zwvo/7V3LvSye0rgTBIlDHCNAymg4VMk7BPZ7hm/ELNKjD+Jo2FR3qyH\n" \
71"B5T0Y3HsLuJvW5iB4YlcNHlsdu87kGJ55tukmi8mxdAQ4Q7e2RCOFvu396j3x+UC\n" \
72"B5iPNgiV5+I3lg02dZ77DnKxHZu8A/lJBdiB3QW0KtZB6awBdpUKD9jf1b0SHzUv\n" \
73"KBds0pjBqAlkd25HN7rOrFleaJ1/ctaJxQZBKT5ZPt0m9STJEadao0xAH0ahmbWn\n" \
74"OlFuhjuefXKnEgV4We0+UXgVCwOPjdAvBbI+e0ocS3MFEvzG6uBQE3xDk3SzynTn\n" \
75"jh8BCNAw1FtxNrQHusEwMFxIt4I7mKZ9YIqioymCzLq9gwQbooMDQaHWBfEbwrbw\n" \
76"qHyGO0aoSCqI3Haadr8faqU9GY/rOPNk3sgrDQoo//fb4hVC1CLQJ13hef4Y53CI\n" \
77"rU7m2Ys6xt0nUW7/vGT1M0NPAgMBAAGjQjBAMA4GA1UdDwEB/wQEAwIBBjAPBgNV\n" \
78"HRMBAf8EBTADAQH/MB0GA1UdDgQWBBR5tFnme7bl5AFzgAiIyBpY9umbbjANBgkq\n" \
79"hkiG9w0BAQsFAAOCAgEAVR9YqbyyqFDQDLHYGmkgJykIrGF1XIpu+ILlaS/V9lZL\n" \
80"ubhzEFnTIZd+50xx+7LSYK05qAvqFyFWhfFQDlnrzuBZ6brJFe+GnY+EgPbk6ZGQ\n" \
81"3BebYhtF8GaV0nxvwuo77x/Py9auJ/GpsMiu/X1+mvoiBOv/2X/qkSsisRcOj/KK\n" \
82"NFtY2PwByVS5uCbMiogziUwthDyC3+6WVwW6LLv3xLfHTjuCvjHIInNzktHCgKQ5\n" \
83"ORAzI4JMPJ+GslWYHb4phowim57iaztXOoJwTdwJx4nLCgdNbOhdjsnvzqvHu7Ur\n" \
84"TkXWStAmzOVyyghqpZXjFaH3pO3JLF+l+/+sKAIuvtd7u+Nxe5AW0wdeRlN8NwdC\n" \
85"jNPElpzVmbUq4JUagEiuTDkHzsxHpFKVK7q4+63SM1N95R1NbdWhscdCb+ZAJzVc\n" \
86"oyi3B43njTOQ5yOf+1CceWxG1bQVs5ZufpsMljq4Ui0/1lvh+wjChP4kqKOJ2qxq\n" \
87"4RgqsahDYVvTH9w7jXbyLeiNdd8XM2w9U/t7y0Ff/9yi0GE44Za4rF2LN9d11TPA\n" \
88"mRGunUHBcnWEvgJBQl9nJEiU0Zsnvgc/ubhPgXRR4Xq37Z0j4r7g1SgEEzwxA57d\n" \
89"emyPxgcYxn/eR44/KJ4EBs+lVDR3veyJm+kXQ99b21/+jh5Xos1AnX5iItreGCc=\n" \
90"-----END CERTIFICATE-----\n"
91#endif
92
93/* L-05 — PRODUCTION: USB-CDC Serial is readable by any host process.
94 * Logs leak RPC URL, basic-auth header, nonce, recipient, tx hash, and
95 * (if CW_DEBUG_LOGGING=1) secure-channel ciphertext + IVs. Before ship:
96 * swap for `NullLoggerAdapter serialAdapter;` and drop Serial.begin(). */
102
104#define ERC20_TRANSFER_SEL_0 0xa9U
105#define ERC20_TRANSFER_SEL_1 0x05U
106#define ERC20_TRANSFER_SEL_2 0x9cU
107#define ERC20_TRANSFER_SEL_3 0xbbU
108
109#define ERC20_INDEX_OFFSET 64U
110
112#define YPARITY_UNKNOWN 0xFFU
113
115#define HTTP_OK 200
116
118#define TX_MAX_RETRIES 3U
120#define TX_RETRY_DELAY_MS 2000U
122#define WIFI_RETRY_MAX 20U
124#define HEX_CHAR_BUF_SIZE 3U
126#define ECRECOVER_V_PAD_CHARS 62U
128#define ECRECOVER_V_BASE 27U
129
133struct Tx2 {
134 uint64_t nonce;
136 uint64_t maxFeePerGas;
137 uint64_t gasLimit;
138 const char* to;
139 uint64_t value;
140 const uint8_t* data;
141 size_t dataLen;
142 uint32_t chainId;
143};
144
145static const char hexChars[] = "0123456789abcdef";
146
147static void printHex(const char* label, const uint8_t* data, size_t len) {
148 Serial.print(label);
149 Serial.print(F(": 0x"));
150 char buf[3]; buf[2] = '\0';
151 for (size_t i = 0; i < len; i++) {
152 buf[0] = hexChars[data[i] >> 4];
153 buf[1] = hexChars[data[i] & 0x0f];
154 Serial.print(buf);
155 }
156 Serial.println();
157}
158
159#if defined(RPC_PROJECT_ID) && defined(RPC_API_SECRET)
161#define AUTH_CRED_BUF_SIZE 128U
163#define AUTH_HEADER_BUF_SIZE 200U
164
165/* NEW-3: base64Encode now requires the output capacity and returns false
166 * if it cannot write the full (ceil(inputLen/3)*4 + 1) bytes. Prevents
167 * silent stack overflow if AUTH_HEADER_BUF_SIZE is later reduced or a
168 * caller passes a too-small buffer. */
169static bool base64Encode(const char* input, size_t inputLen, char* output, size_t outputCap) {
170 static const char kAlphabet[] =
171 "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
172 const size_t required = (((inputLen + 2U) / 3U) * 4U) + 1U; /* +1 for NUL */
173 if (outputCap < required) {
174 return false;
175 }
176 size_t i = 0U;
177 size_t o = 0U;
178 while (i < inputLen) {
179 uint32_t u0 = static_cast<uint32_t>(static_cast<uint8_t>(input[i]));
180 uint32_t u1 = 0U;
181 uint32_t u2 = 0U;
182 if ((i + 1U) < inputLen) {
183 u1 = static_cast<uint32_t>(static_cast<uint8_t>(input[i + 1U]));
184 }
185 if ((i + 2U) < inputLen) {
186 u2 = static_cast<uint32_t>(static_cast<uint8_t>(input[i + 2U]));
187 }
188 output[o] = kAlphabet[static_cast<uint8_t>(u0 >> 2U)];
189 o++;
190 output[o] = kAlphabet[static_cast<uint8_t>(((u0 & 0x03U) << 4U) | (u1 >> 4U))];
191 o++;
192 if ((i + 1U) < inputLen) {
193 output[o] = kAlphabet[static_cast<uint8_t>(((u1 & 0x0FU) << 2U) | (u2 >> 6U))];
194 } else {
195 output[o] = '=';
196 }
197 o++;
198 if ((i + 2U) < inputLen) {
199 output[o] = kAlphabet[static_cast<uint8_t>(u2 & 0x3FU)];
200 } else {
201 output[o] = '=';
202 }
203 o++;
204 i += 3U;
205 }
206 output[o] = '\0';
207 return true;
208}
209
210static void buildBasicAuthHeader(char* buf, size_t bufSize) {
211 static const char kPrefix[] = "Basic ";
212 const size_t prefixLen = sizeof(kPrefix) - 1U;
213 char cred[AUTH_CRED_BUF_SIZE];
214 // cppcheck-suppress invalidPrintfArgType_s -- macros are char* when defined in config.h; --force evaluates this block without knowing their types
215 int written = snprintf(cred, sizeof(cred), "%s:%s", RPC_PROJECT_ID, RPC_API_SECRET);
216 /* H-04: snprintf returns the number of bytes it WOULD have written
217 * (excluding NUL). A value >= sizeof(cred) means the project id +
218 * secret were truncated — sending a truncated Authorization header
219 * causes opaque 401s and could leak credentials in retry traces. */
220 if ((written < 0) || ((size_t)written >= sizeof(cred))) {
221 Serial.println(F("[fatal] RPC_PROJECT_ID + RPC_API_SECRET exceed AUTH_CRED_BUF_SIZE"));
222 while (true) { /* halt — do not send a truncated basic-auth header */ }
223 }
224 /* NEW-1: ensure the output buffer can hold prefix + base64(cred) + NUL.
225 * base64 expands by ~4/3; the helper rejects undersized buffers. */
226 if (prefixLen >= bufSize) {
227 Serial.println(F("[fatal] AUTH_HEADER_BUF_SIZE too small for prefix"));
228 while (true) {}
229 }
230 (void)CW_Utils::safe_memcpy(reinterpret_cast<uint8_t*>(buf), bufSize,
231 reinterpret_cast<const uint8_t*>(kPrefix), prefixLen);
232 if (!base64Encode(cred, strlen(cred), buf + prefixLen, bufSize - prefixLen)) {
233 Serial.println(F("[fatal] base64 output exceeds AUTH_HEADER_BUF_SIZE"));
234 while (true) {}
235 }
236}
237#endif /* defined(RPC_PROJECT_ID) && defined(RPC_API_SECRET) */
238
239static bool ensureWiFi() {
240 if (WiFi.status() == WL_CONNECTED) return true;
241 WiFi.begin(WIFI_SSID, WIFI_PASSWORD);
242 uint8_t retries = WIFI_RETRY_MAX;
243 while ((retries > 0U) && (WiFi.status() != WL_CONNECTED)) {
244 retries--;
245 delay(500U);
246 }
247 return WiFi.status() == WL_CONNECTED;
248}
249
250/* Each RlpEncodeItem call returns 0 on overflow; bail out immediately so
251 * the caller sees rlpLen == 0 and halts before broadcasting garbage. */
252#define RLP_ITEM_OR_FAIL(BUF, CAP, OFF, IN, IN_LEN) \
253 do { \
254 uint32_t _w = RlpEncodeItem((BUF) + (OFF), (CAP) - (OFF), \
255 (IN), (IN_LEN)); \
256 if (_w == 0U) { return 0U; } \
257 (OFF) += _w; \
258 } while (0)
259
260static size_t rlpEncodeTxBody(uint8_t* buf, size_t bufCap, const Tx2& tx) {
261 size_t off = 0;
262 uint8_t tmp[8];
263 size_t tmpLen;
264 tmpLen = ConvertNumberToUintArray(tmp, tx.chainId);
265 RLP_ITEM_OR_FAIL(buf, bufCap, off, tmp, (uint32_t)tmpLen);
266 tmpLen = ConvertNumberToUintArray(tmp, tx.nonce);
267 RLP_ITEM_OR_FAIL(buf, bufCap, off, tmp, (uint32_t)tmpLen);
269 RLP_ITEM_OR_FAIL(buf, bufCap, off, tmp, (uint32_t)tmpLen);
270 tmpLen = ConvertNumberToUintArray(tmp, tx.maxFeePerGas);
271 RLP_ITEM_OR_FAIL(buf, bufCap, off, tmp, (uint32_t)tmpLen);
272 tmpLen = ConvertNumberToUintArray(tmp, tx.gasLimit);
273 RLP_ITEM_OR_FAIL(buf, bufCap, off, tmp, (uint32_t)tmpLen);
274 uint8_t addr[20];
275 if (!hexToBytes(tx.to, addr, 20)) {
276 Serial.println(F("[fatal] tx.to is not a valid 40-char hex string"));
277 while (true) { /* halt to avoid broadcasting a malformed transaction */ }
278 }
279 RLP_ITEM_OR_FAIL(buf, bufCap, off, addr, 20U);
280 tmpLen = ConvertNumberToUintArray(tmp, tx.value);
281 RLP_ITEM_OR_FAIL(buf, bufCap, off, tmp, (uint32_t)tmpLen);
282 RLP_ITEM_OR_FAIL(buf, bufCap, off, tx.data, (uint32_t)tx.dataLen);
283 if (off >= bufCap) { return 0U; } /* room for the access-list terminator 0xC0 */
284 buf[off++] = 0xC0;
285 return off;
286}
287
288static size_t rlpFinalize(uint8_t* out, size_t outCap, const uint8_t* buf, size_t off) {
289 uint8_t header[8];
290 size_t header_len = RlpEncodeWholeHeader(header, sizeof(header), off);
291 if (header_len == 0U) { return 0U; }
292 /* Reject early if the total wouldn't fit (1 type byte + header + body). */
293 if ((1U + header_len + off) > outCap) {
294 return 0U;
295 }
296 size_t out_off = 0U;
297 out[out_off++] = 0x02;
298 (void)CW_Utils::safe_memcpy(out + out_off, outCap - out_off, header, header_len);
299 out_off += header_len;
300 (void)CW_Utils::safe_memcpy(out + out_off, outCap - out_off, buf, off);
301 return out_off + off;
302}
303
304size_t rlpEncodeUnsignedTx(const Tx2& tx, uint8_t* out, size_t outCap) {
305 uint8_t buf[1024];
306 size_t off = rlpEncodeTxBody(buf, sizeof(buf), tx);
307 if (off == 0U) { return 0U; }
308 return rlpFinalize(out, outCap, buf, off);
309}
310
311size_t rlpEncodeSignedTx(const Tx2& tx, const uint8_t* r, const uint8_t* s, const uint8_t* v,
312 uint8_t* out, size_t outCap) {
313 uint8_t buf[1024];
314 size_t off = rlpEncodeTxBody(buf, sizeof(buf), tx);
315 if (off == 0U) { return 0U; }
316 uint32_t w;
317 w = RlpEncodeItem(buf + off, sizeof(buf) - off, v, 1U);
318 if (w == 0U) { return 0U; }
319 off += w;
320 uint8_t tmp_r[32];
321 size_t tmp_len = trimLeadingZeros(tmp_r, sizeof(tmp_r), r, 32U);
322 if (tmp_len == 0U) { return 0U; }
323 w = RlpEncodeItem(buf + off, sizeof(buf) - off, tmp_r, (uint32_t)tmp_len);
324 if (w == 0U) { return 0U; }
325 off += w;
326 uint8_t tmp_s[32];
327 tmp_len = trimLeadingZeros(tmp_s, sizeof(tmp_s), s, 32U);
328 if (tmp_len == 0U) { return 0U; }
329 w = RlpEncodeItem(buf + off, sizeof(buf) - off, tmp_s, (uint32_t)tmp_len);
330 if (w == 0U) { return 0U; }
331 off += w;
332 size_t ret = rlpFinalize(out, outCap, buf, off);
333 /* L-01: wipe the signature scratch buffers for hygiene uniformity with
334 * the rest of the codebase. Signatures are public (broadcast on-chain)
335 * so this is style/consistency only, not a real secret leak. */
336 CW_Utils::secure_wipe(tmp_r, sizeof(tmp_r));
337 CW_Utils::secure_wipe(tmp_s, sizeof(tmp_s));
338 return ret;
339}
340
346size_t encodeERC20Transfer(uint8_t* out) {
347 out[0] = ERC20_TRANSFER_SEL_0; /* transfer(address,uint256) selector byte 0 */
348 out[1] = ERC20_TRANSFER_SEL_1; /* transfer(address,uint256) selector byte 1 */
349 out[2] = ERC20_TRANSFER_SEL_2; /* transfer(address,uint256) selector byte 2 */
350 out[3] = ERC20_TRANSFER_SEL_3; /* transfer(address,uint256) selector byte 3 */
351 CW_Utils::secure_wipe(out+4, 12U); /* bytes 4-15: ABI word padding before address (12 zero bytes) */
352 if (!hexToBytes(ADDR_TO, out+16, 20)) { /* bytes 16-35: recipient address (20 bytes) */
353 Serial.println(F("[fatal] ADDR_TO is not a valid 40-char hex string"));
354 while (true) { /* halt to avoid broadcasting a wrong-recipient transfer */ }
355 }
356 CW_Utils::secure_wipe(out+36, 28U); /* bytes 36-63: ABI word padding before amount (28 zero bytes) */
357 out[ERC20_INDEX_OFFSET] = (uint8_t)((AMOUNT_USDC >> 24U) & 0xFFU);
358 out[ERC20_INDEX_OFFSET+1] = (uint8_t)((AMOUNT_USDC >> 16U) & 0xFFU);
359 out[ERC20_INDEX_OFFSET+2] = (uint8_t)((AMOUNT_USDC >> 8U) & 0xFFU);
360 out[ERC20_INDEX_OFFSET+3] = (uint8_t)( AMOUNT_USDC & 0xFFU);
361 return 68;
362}
363
370bool sendRawTx(const uint8_t* raw, size_t len) {
371 static const char requestPrefix[] =
372 "{\"jsonrpc\":\"2.0\",\"id\":1,\"method\":\"eth_sendRawTransaction\","
373 "\"params\":[\"0x";
374 /* Fixed closing of the JSON-RPC body that follows the hex transaction bytes. */
375 static const char requestSuffix[] = "\"]}";
376 bool sent = false;
377 for (uint8_t attempt = 0U; (attempt < TX_MAX_RETRIES) && !sent; attempt++) {
378 if (attempt != 0U) {
379 delay(TX_RETRY_DELAY_MS);
380 }
381 if (!ensureWiFi()) {
382 Serial.println(F("sendRawTx: WiFi reconnect failed"));
383 continue;
384 }
385 WiFiSSLClient wifiClient;
386#ifndef WIFI_DISABLE_CA_PINNING
387 /* M-04: pin the RPC server's CA so a network MITM cannot serve a
388 * forged certificate and feed crafted nonces/gas prices.
389 * Define WIFI_DISABLE_CA_PINNING in config.h to skip this — DEV ONLY,
390 * leaves the connection vulnerable to MITM. */
391 wifiClient.setCACert(WIFI_CA_CERT);
392#endif
393 HttpClient client(wifiClient, RPC_HOST, RPC_PORT);
394 client.beginRequest();
395 int err = client.post(RPC_PATH);
396 if (err != HTTP_SUCCESS) {
397 Serial.print(F("sendRawTx: POST failed, err="));
398 Serial.println(err);
399 client.stop();
400 continue;
401 }
402 /* Content-Length = prefix + 2 hex chars per raw byte + suffix. */
403 client.sendHeader("Content-Type", "application/json");
404 client.sendHeader("Content-Length",
405 (int)(sizeof(requestPrefix)-1) + 2*(int)len + (int)(sizeof(requestSuffix)-1));
406#if defined(RPC_PROJECT_ID) && defined(RPC_API_SECRET)
407 {
408 char authBuf[AUTH_HEADER_BUF_SIZE];
409 buildBasicAuthHeader(authBuf, sizeof(authBuf));
410 client.sendHeader("Authorization", authBuf);
411 }
412#endif
413 client.beginBody();
414 client.print(requestPrefix);
415 /* Encode each raw transaction byte as two hex characters and stream it
416 * directly to the HTTP client, avoiding a large intermediate buffer. */
417 char byteHexStr[HEX_CHAR_BUF_SIZE];
418 byteHexStr[2] = '\0';
419 for (size_t i = 0; i < len; i++) {
420 byteHexStr[0] = hexChars[raw[i] >> 4]; /* high nibble */
421 byteHexStr[1] = hexChars[raw[i] & 0x0f]; /* low nibble */
422 client.print(byteHexStr);
423 }
424 client.print(requestSuffix);
425 client.endRequest();
426 int status = client.responseStatusCode();
427 String responseBody = client.responseBody();
428 Serial.print(F("[RPC] HTTP ")); Serial.println(status);
429 /* NEW-2: dumping the full RPC response leaks tx metadata (nonce,
430 * recipient, gas) over USB-CDC. Gate behind CW_DEBUG_LOGGING so a
431 * production build (CW_DEBUG_LOGGING=0) stays silent. */
432#if CW_DEBUG_LOGGING
433 Serial.print(F("[RPC] ")); Serial.println(responseBody);
434#endif
435 bool statusOk = (status == HTTP_OK);
436 /* L-03 (accepted): coarse substring search instead of a JSON parser.
437 * Not a security issue — at worst a false positive triggers a retry.
438 * ArduinoJson would cost +15-25 KB flash for marginal robustness. */
439 bool noJsonError = (responseBody.indexOf("\"error\"") == -1);
440 sent = statusOk && noJsonError;
441 /* eth_sendRawTransaction returns {"jsonrpc":"2.0","id":1,"result":"0x<txhash>"}.
442 * The tx hash is on-chain public info — extract and print so the user can
443 * track the broadcast on a block explorer. */
444 if (sent) {
445 int r = responseBody.indexOf("\"result\":\"0x");
446 if (r >= 0) {
447 int start = r + 10; /* skip past "\"result\":\"" */
448 int end = responseBody.indexOf("\"", start);
449 if (end > start) {
450 Serial.print(F("[tx] hash="));
451 Serial.println(responseBody.substring(start, end));
452 }
453 }
454 }
455 client.stop();
456 }
457 return sent;
458}
459
471uint8_t determineYParity(const uint8_t* hash, const uint8_t* r, const uint8_t* s) {
472 /* ecrecover calldata: "0x" + hash(64) + v(64) + r(64) + s(64) = 258 chars + NUL */
473 char hexBuf[260];
474 uint16_t pos = 0U;
475 hexBuf[pos++] = '0';
476 hexBuf[pos++] = 'x';
477 for (uint8_t i = 0U; i < 32U; i++) {
478 hexBuf[pos++] = hexChars[hash[i] >> 4];
479 hexBuf[pos++] = hexChars[hash[i] & 0x0f];
480 }
481 /* v field: ECRECOVER_V_PAD_CHARS zero chars, then 1 value byte — filled per iteration */
482 const uint16_t vOffset = pos;
483 for (uint8_t i = 0U; i < ECRECOVER_V_PAD_CHARS; i++) {
484 hexBuf[pos++] = '0';
485 }
486 pos += 2U; /* placeholder for v byte */
487 for (uint8_t i = 0U; i < 32U; i++) {
488 hexBuf[pos++] = hexChars[r[i] >> 4];
489 hexBuf[pos++] = hexChars[r[i] & 0x0f];
490 }
491 for (uint8_t i = 0U; i < 32U; i++) {
492 hexBuf[pos++] = hexChars[s[i] >> 4];
493 hexBuf[pos++] = hexChars[s[i] & 0x0f];
494 }
495 hexBuf[pos] = '\0'; /* pos == 258 */
496
497 static const char requestPrefix[] =
498 "{\"jsonrpc\":\"2.0\",\"id\":1,\"method\":\"eth_call\","
499 "\"params\":[{\"to\":\"0x0000000000000000000000000000000000000001\","
500 "\"data\":\"";
501 static const char requestSuffix[] = "\"},\"latest\"]}";
502 const int bodyLen = (int)(sizeof(requestPrefix) - 1) + 258 + (int)(sizeof(requestSuffix) - 1);
503
504 uint8_t result = YPARITY_UNKNOWN;
505 for (uint8_t yp = 0U; (yp <= 1U) && (result == YPARITY_UNKNOWN); yp++) {
506 /* Patch v byte into last two chars of the v field.
507 * Ethereum ecrecover: v=27 means yParity=0, v=28 means yParity=1. */
508 const uint8_t v = ECRECOVER_V_BASE + yp;
509 hexBuf[vOffset + ECRECOVER_V_PAD_CHARS] = hexChars[(v & 0xFFU) >> 4U];
510 hexBuf[vOffset + ECRECOVER_V_PAD_CHARS + 1U] = hexChars[(v & 0xFFU) & 0x0FU];
511
512 if (!ensureWiFi()) {
513 Serial.println(F("determineYParity: WiFi reconnect failed"));
514 continue;
515 }
516 WiFiSSLClient wifiClient;
517#ifndef WIFI_DISABLE_CA_PINNING
518 /* M-04: pin the RPC server's CA so a network MITM cannot serve a
519 * forged certificate and feed crafted nonces/gas prices.
520 * Define WIFI_DISABLE_CA_PINNING in config.h to skip this — DEV ONLY,
521 * leaves the connection vulnerable to MITM. */
522 wifiClient.setCACert(WIFI_CA_CERT);
523#endif
524 HttpClient client(wifiClient, RPC_HOST, RPC_PORT);
525 client.beginRequest();
526 int err = client.post(RPC_PATH);
527 if (err != HTTP_SUCCESS) {
528 Serial.print(F("determineYParity: POST failed, err="));
529 Serial.println(err);
530 client.stop();
531 continue;
532 }
533 client.sendHeader("Content-Type", "application/json");
534 client.sendHeader("Content-Length", bodyLen);
535#if defined(RPC_PROJECT_ID) && defined(RPC_API_SECRET)
536 {
537 char authBuf[AUTH_HEADER_BUF_SIZE];
538 buildBasicAuthHeader(authBuf, sizeof(authBuf));
539 client.sendHeader("Authorization", authBuf);
540 }
541#endif
542 client.beginBody();
543 client.print(requestPrefix);
544 client.print(hexBuf);
545 client.print(requestSuffix);
546 client.endRequest();
547
548 int status = client.responseStatusCode();
549 String response = client.responseBody(); /* consume response body */
550 client.stop();
551 if (status != HTTP_OK) {
552 continue;
553 }
554 int resultIdx = response.indexOf("\"result\"");
555 if (resultIdx < 0) {
556 continue;
557 }
558 int hexIdx = response.indexOf("0x", resultIdx);
559 if (hexIdx < 0) {
560 continue;
561 }
562 /* H-05: validate the response is long enough before substring(). ecrecover
563 * returns a 32-byte (64-hex-char) word; with the "0x" prefix the slice
564 * spans hexIdx+2 .. hexIdx+66. A truncated RPC response would silently
565 * give us an empty/garbage recovered address and let the loop progress. */
566 if (response.length() < (unsigned int)(hexIdx + 66)) {
567 continue;
568 }
569 /* ecrecover returns 32-byte word; address = last 20 bytes = last 40 hex chars */
570 String recovered = response.substring(hexIdx + 26, hexIdx + 66);
571 Serial.print(F("[ecrecover] v=")); Serial.print(v);
572 Serial.print(F(" recovered=0x")); Serial.println(recovered);
573 Serial.print(F("[ecrecover] expected=0x")); Serial.println(F(ADDR_FROM));
574 if (recovered.equalsIgnoreCase(ADDR_FROM)) {
575 result = yp;
576 }
577 }
578 return result;
579}
580
586uint8_t fetchNonce(uint64_t* nonce) {
587 static const char requestPrefix[] =
588 "{\"jsonrpc\":\"2.0\",\"id\":2,\"method\":\"eth_getTransactionCount\","
589 "\"params\":[\"0x";
590 static const char requestSuffix[] = "\",\"pending\"]}";
591 const int bodyLen = (int)(sizeof(requestPrefix)-1) + 40 + (int)(sizeof(requestSuffix)-1);
592
593 uint8_t result = 1U;
594 for (uint8_t attempt = 0U; (attempt < TX_MAX_RETRIES) && (result != 0U); attempt++) {
595 if (attempt != 0U) {
596 delay(1000U);
597 }
598 if (!ensureWiFi()) {
599 Serial.println(F("fetchNonce: WiFi reconnect failed"));
600 continue;
601 }
602 WiFiSSLClient wifiClient;
603#ifndef WIFI_DISABLE_CA_PINNING
604 /* M-04: pin the RPC server's CA so a network MITM cannot serve a
605 * forged certificate and feed crafted nonces/gas prices.
606 * Define WIFI_DISABLE_CA_PINNING in config.h to skip this — DEV ONLY,
607 * leaves the connection vulnerable to MITM. */
608 wifiClient.setCACert(WIFI_CA_CERT);
609#endif
610 HttpClient client(wifiClient, RPC_HOST, RPC_PORT);
611 client.beginRequest();
612 Serial.print(F("fetchNonce: connecting to "));
613 Serial.print(F(RPC_HOST));
614 Serial.println(F(RPC_PATH));
615 int err = client.post(RPC_PATH);
616 if (err != HTTP_SUCCESS) {
617 Serial.print(F("fetchNonce: POST failed, err="));
618 Serial.println(err);
619 client.stop();
620 continue;
621 }
622 client.sendHeader("Content-Type", "application/json");
623 client.sendHeader("Content-Length", bodyLen);
624#if defined(RPC_PROJECT_ID) && defined(RPC_API_SECRET)
625 {
626 char authBuf[AUTH_HEADER_BUF_SIZE];
627 buildBasicAuthHeader(authBuf, sizeof(authBuf));
628 client.sendHeader("Authorization", authBuf);
629 }
630#endif
631 client.beginBody();
632 client.print(requestPrefix);
633 client.print(ADDR_FROM);
634 client.print(requestSuffix);
635 client.endRequest();
636
637 int status = client.responseStatusCode();
638 String resp = client.responseBody();
639 client.stop();
640 if (status == HTTP_OK) {
641 int ri = resp.indexOf("\"result\"");
642 int xi = (ri >= 0) ? resp.indexOf("0x", ri) : -1;
643 if (xi >= 0) {
644 uint64_t parsed = 0U;
645 /* H-06: cap at 16 hex digits (uint64 capacity). A malicious or
646 * malformed RPC returning a longer hex string would silently
647 * overflow the uint64 and wrap to a small (replayed) nonce. */
648 int digitCount = 0;
649 bool overflowed = false;
650 for (int i = xi + 2; i < (int)resp.length(); i++) {
651 char c = resp[i];
652 if (!((c>='0'&&c<='9')||(c>='a'&&c<='f')||(c>='A'&&c<='F'))) break;
653 if (digitCount >= 16) { overflowed = true; break; }
654 parsed = (parsed << 4) | fromHex(c);
655 digitCount++;
656 }
657 /* NEW-4: require >= 1 hex digit so "0x" / "0xZZZ" is not
658 * treated as a valid nonce=0 result on parse failure. */
659 if (!overflowed && (digitCount > 0)) {
660 *nonce = parsed;
661 result = 0U;
662 }
663 }
664 }
665 }
666 return result;
667}
668
672void setup() {
673 Serial.begin(115200);
674 delay(2000);
675
676 /* Init SPI and PN532 */
677 SPI.begin();
678 if (!wallet.begin()) {
679 Serial.println(F("PN532 init failed! Halting."));
680 while(1);
681 }
682 Serial.println(F("PN532 OK"));
683
684#ifdef WIFI_DISABLE_CA_PINNING
685 Serial.println(F("⚠️ WIFI_DISABLE_CA_PINNING is set — TLS certificate is NOT validated."));
686 Serial.println(F(" Connection is vulnerable to MITM. DEV ONLY, do not use in production."));
687#endif
688
689 /* Connect to WiFi */
690 Serial.print(F("Connecting to WiFi"));
691 WiFi.begin(WIFI_SSID, WIFI_PASSWORD);
692 uint8_t retries = 20U;
693 while ((retries > 0U) && (WiFi.status() != WL_CONNECTED)) {
694 retries--;
695 delay(500U);
696 Serial.print(F("."));
697 }
698 Serial.println();
699 if (WiFi.status() != WL_CONNECTED) {
700 Serial.println(F("WiFi failed!"));
701 while(1);
702 }
703 delay(2000); /* Allow network stack to stabilise before first SSL connection */
704
705 /* Build ERC-20 calldata */
706 uint8_t calldata[68];
707 size_t calLen = encodeERC20Transfer(calldata);
708
709 /* Build unsigned EIP-1559 transaction */
710 Tx2 tx2;
711 uint64_t fetchedNonce = 0U;
712 if (fetchNonce(&fetchedNonce) != 0U) {
713 Serial.println(F("fetchNonce failed! Halting."));
714 while(1);
715 }
716 tx2.nonce = fetchedNonce;
718 tx2.maxFeePerGas = MAX_FEE;
720 tx2.to = ADDR_USDC;
721 tx2.value = 0;
722 tx2.data = calldata;
723 tx2.dataLen = calLen;
725
726 /* RLP encode unsigned tx */
727 static const size_t kRlpBufSize = 512U;
728 uint8_t rlpUnsigned[kRlpBufSize];
729 size_t rlpLen = rlpEncodeUnsignedTx(tx2, rlpUnsigned, kRlpBufSize);
730 /* L-02: defense-in-depth. A USDC transfer EIP-1559 tx is ~150-200 bytes,
731 * so 512 is plenty for the current shape — but if a future change adds
732 * an access list, auth list (EIP-7702), or larger tx.data, a silent
733 * stack overflow would corrupt the return address. Halt explicitly. */
734 /* rlpFinalize returns 0 on overflow (via safe_memcpy bounds check). The
735 * post-check stays as defense-in-depth in case a future change skips
736 * rlpFinalize's gate. */
737 if ((rlpLen == 0U) || (rlpLen > kRlpBufSize)) {
738 Serial.print(F("[fatal] rlpUnsigned overflow or empty: ")); Serial.println(rlpLen);
739 while (true) {}
740 }
741
742 /* Keccak-256 hash */
743 uint8_t hashKeccak[32];
744 keccak256(static_cast<const uint8_t*>(rlpUnsigned), rlpLen, hashKeccak);
745 printHex("[HASH]", hashKeccak, 32);
746
747 /* BIP44 Ethereum path: m/44'/60'/0'/0/0 */
748 static const uint8_t eth_path[20] = {
749 0x80U,0x00U,0x00U,0x2CU, /* 44' */
750 0x80U,0x00U,0x00U,0x3CU, /* 60' */
751 0x80U,0x00U,0x00U,0x00U, /* 0' */
752 0x00U,0x00U,0x00U,0x00U, /* 0 */
753 0x00U,0x00U,0x00U,0x00U, /* 0 */
754 };
755
756 /* === Sign with Cryptnox card over NFC (with retry on NFC dropout) === */
757 Serial.println(F("Place Cryptnox card on PN532 reader..."));
758 CW_SignResult signResult;
759 for (uint8_t attempt = 0U; attempt < 3U; attempt++) {
760 if (attempt != 0U) {
761 delay(1000U);
762 }
763 CW_SecureSession session;
764 if (!wallet.connect(session)) {
765 continue;
766 }
767 Serial.println(F("Card connected, secure channel established."));
768
770 signReq.hash = hashKeccak;
771 signReq.hashLength = static_cast<uint8_t>(CW_HASH_SIZE);
772 signReq.derivePath = eth_path;
773 signReq.derivePathLength = static_cast<uint8_t>(sizeof(eth_path));
774 (void)CW_Utils::safe_memcpy(signReq.pin, sizeof(signReq.pin),
775 reinterpret_cast<const uint8_t*>(CARD_PIN), CARD_PIN_LEN);
776
777 signResult = wallet.sign(signReq);
778 wallet.disconnect(session);
779 if (signResult.errorCode == CW_OK) {
780 break;
781 }
782 if (signResult.errorCode == CW_SIGN_PIN_INCORRECT ||
783 signResult.errorCode == CW_SIGN_NO_KEY_LOADED) {
784 Serial.print(F("Card rejected sign command (error=0x"));
785 Serial.print(signResult.errorCode, HEX);
786 Serial.println(F(") — check PIN and card initialisation. Halting."));
787 /* M-05: explicitly wipe the PIN before halting. The CW_SignRequest
788 * destructor would normally do this on scope exit, but the while(1)
789 * below stays inside the for() body so the destructor never runs. */
790 CW_Utils::secure_wipe(signReq.pin, sizeof(signReq.pin));
791 while(1);
792 }
793 Serial.print(F("Sign attempt "));
794 Serial.print(attempt + 1U);
795 Serial.print(F(" failed, error=0x"));
796 Serial.println(signResult.errorCode, HEX);
797 }
798
799 if (signResult.errorCode != CW_OK) {
800 Serial.print(F("Sign failed: error=0x"));
801 Serial.println(signResult.errorCode, HEX);
802 while(1);
803 }
804 Serial.println(F("Signed."));
805
806 const uint8_t* r = signResult.signature; /* first 32 bytes */
807 const uint8_t* s = signResult.signature + 32; /* last 32 bytes */
808 printHex("[SIG r]", r, 32);
809 printHex("[SIG s]", s, 32);
810
811 /* Determine yParity */
812 uint8_t yParity = determineYParity(hashKeccak, r, s);
813 if (yParity == YPARITY_UNKNOWN) {
814 Serial.println(F("yParity determination failed! Halting."));
815 while(1);
816 }
817 Serial.print(F("yParity: "));
818 Serial.println(yParity);
819
820 /* RLP encode signed tx and send */
821 uint8_t rlpSigned[kRlpBufSize];
822 size_t rlpSignedLen = rlpEncodeSignedTx(tx2, r, s, &yParity, rlpSigned, kRlpBufSize);
823 /* L-02 + safe_memcpy gate inside rlpFinalize. */
824 if ((rlpSignedLen == 0U) || (rlpSignedLen > kRlpBufSize)) {
825 Serial.print(F("[fatal] rlpSigned overflow or empty: ")); Serial.println(rlpSignedLen);
826 while (true) {}
827 }
828
829 Serial.println(F("Sending..."));
830 if (sendRawTx(rlpSigned, rlpSignedLen)) {
831 Serial.println(F("Transaction sent successfully!"));
832 } else {
833 Serial.println(F("Transaction FAILED."));
834 }
835}
836
840void loop() {}
void setup()
Arduino setup function.
CryptnoxWallet wallet(nfc, serialAdapter, cryptoProvider, platform)
PN532Adapter nfc(serialAdapter, PN532_SS, &SPI)
ArduinoLoggerAdapter serialAdapter
ArduinoPlatform platform
ArduinoCryptoProvider cryptoProvider
void loop()
Arduino main loop.
#define CW_HASH_SIZE
Definition CW_Defs.h:108
#define CW_OK
Definition CW_Defs.h:80
#define CW_SIGN_WITH_PIN
Definition CW_Defs.h:92
#define CW_SIGN_NO_KEY_LOADED
Definition CW_Defs.h:102
#define CW_SIGN_SIG_ECDSA_LOW_S
Definition CW_Defs.h:96
#define CW_SIGN_DERIVE_K1
Definition CW_Defs.h:87
#define CW_SIGN_PIN_INCORRECT
Definition CW_Defs.h:103
#define PN532_SS_PIN
SPI slave-select (CS) pin connected to the PN532 module.
Definition Connect.ino:31
#define HTTP_OK
Expected HTTP 200 OK status code.
size_t rlpEncodeSignedTx(const Tx2 &tx, const uint8_t *r, const uint8_t *s, const uint8_t *v, uint8_t *out, size_t outCap)
#define ECRECOVER_V_PAD_CHARS
Number of leading zero hex characters in the ecrecover v-field padding.
size_t encodeERC20Transfer(uint8_t *out)
Encode calldata for ERC-20 transfer(address to, uint256 amount).
static const char hexChars[]
static void printHex(const char *label, const uint8_t *data, size_t len)
#define RLP_ITEM_OR_FAIL(BUF, CAP, OFF, IN, IN_LEN)
#define YPARITY_UNKNOWN
Sentinel returned by determineYParity() when recovery fails.
#define WIFI_RETRY_MAX
Maximum WiFi reconnect poll iterations (each iteration waits 500 ms).
#define TX_MAX_RETRIES
Maximum number of send-transaction attempts before giving up.
#define ERC20_INDEX_OFFSET
#define HEX_CHAR_BUF_SIZE
Buffer size for a two-hex-char + NUL string used in byte-to-hex conversion.
static size_t rlpFinalize(uint8_t *out, size_t outCap, const uint8_t *buf, size_t off)
static bool ensureWiFi()
#define ERC20_TRANSFER_SEL_3
#define ECRECOVER_V_BASE
Base value for Ethereum ecrecover v parameter (yParity=0 → v=27, yParity=1 → v=28).
#define ERC20_TRANSFER_SEL_1
size_t rlpEncodeUnsignedTx(const Tx2 &tx, uint8_t *out, size_t outCap)
bool sendRawTx(const uint8_t *raw, size_t len)
Send a raw signed transaction via JSON-RPC.
uint8_t determineYParity(const uint8_t *hash, const uint8_t *r, const uint8_t *s)
Determine EIP-1559 yParity by calling the Ethereum ecrecover precompile.
#define TX_RETRY_DELAY_MS
Delay in ms between send-transaction retry attempts.
uint8_t fetchNonce(uint64_t *nonce)
Fetch the current nonce for ADDR_FROM via eth_getTransactionCount.
#define ERC20_TRANSFER_SEL_0
static size_t rlpEncodeTxBody(uint8_t *buf, size_t bufCap, const Tx2 &tx)
#define ERC20_TRANSFER_SEL_2
CW_CryptoProvider implementation for the Arduino UNO R4 (RA4M1).
CW_Logger implementation wrapping Arduino's HardwareSerial.
CW_Platform implementation using Arduino's blocking delay().
static bool safe_memcpy(uint8_t *dst, size_t dstSize, const uint8_t *src, size_t count)
Safe memcpy — validates pointers, sizes, and checks for overlap.
Definition CW_Utils.cpp:50
static void secure_wipe(uint8_t *buf, size_t len)
Securely zero a buffer, guaranteed not to be optimised away.
Definition CW_Utils.cpp:37
High-level interface for interacting with a Cryptnox Hardware Wallet over NFC.
CW_NfcTransport implementation over the Adafruit_PN532 driver.
#define ADDR_USDC
#define AMOUNT_USDC
#define CARD_PIN
#define RPC_PORT
#define WIFI_SSID
#define ADDR_FROM
#define WIFI_PASSWORD
#define CHAIN_ID_SEPOLIA
#define WIFI_CA_CERT
#define MAX_FEE
#define GAS_LIMIT_ERC20
#define RPC_PATH
#define MAX_PRIORITY_FEE
#define ADDR_TO
#define RPC_HOST
#define CARD_PIN_LEN
void keccak256(const uint8_t *in, size_t inlen, uint8_t out[32])
Compute Keccak-256 hash of input data.
Keccak-256 (SHA3 variant) hash function for Ethereum.
#define F(string_literal)
#define HEX
Holds cryptographic session state for reentrant secure channel operations.
Definition CW_Defs.h:168
Request parameters for CryptnoxWallet::sign.
uint8_t derivePathLength
const uint8_t * hash
const uint8_t * derivePath
uint8_t pin[CW_MAX_PIN_LENGTH]
Result of CryptnoxWallet::sign.
uint8_t signature[CW_RAW_SIGNATURE_SIZE]
Ethereum EIP-1559 transaction structure.
uint64_t value
const char * to
size_t dataLen
uint64_t gasLimit
uint32_t chainId
uint64_t maxPriorityFeePerGas
const uint8_t * data
uint64_t maxFeePerGas
uint64_t nonce
size_t trimLeadingZeros(uint8_t *out, size_t out_cap, const uint8_t *in, size_t in_len)
Trims leading zeros from a byte array.
Definition util.cpp:220
uint32_t RlpEncodeWholeHeader(uint8_t *header_output, size_t header_cap, uint32_t total_len)
Encodes the RLP list header for a sequence of items.
Definition util.cpp:76
int fromHex(char c)
Convert a hexadecimal character to a byte value.
Definition util.cpp:15
bool hexToBytes(const char *hex, uint8_t *out, size_t len)
Convert a hex string to a byte array.
Definition util.cpp:32
uint32_t RlpEncodeItem(uint8_t *output, size_t output_cap, const uint8_t *input, uint32_t input_len)
Encodes a single RLP item.
Definition util.cpp:123
uint32_t ConvertNumberToUintArray(uint8_t *str, uint64_t val)
Converts an unsigned integer into a big-endian byte array.
Definition util.cpp:190