upleb.uk

Public git repos — served from a NIP-34 GRASP relay at git.upleb.uk

summaryrefslogtreecommitdiff
path: root/PLAN.md
diff options
context:
space:
mode:
Diffstat (limited to 'PLAN.md')
-rw-r--r--PLAN.md311
1 files changed, 286 insertions, 25 deletions
diff --git a/PLAN.md b/PLAN.md
index 2af8a39..0fcecac 100644
--- a/PLAN.md
+++ b/PLAN.md
@@ -7,20 +7,23 @@ Build a TollGate firmware for two ESP32 devices, following the [TollGate protoco
7## Architecture Decision: C/C++ (ESP-IDF) 7## Architecture Decision: C/C++ (ESP-IDF)
8 8
9- Existing working captive portal is in C (ESP-IDF) 9- Existing working captive portal is in C (ESP-IDF)
10- On-device Cashu wallet uses mbedTLS secp256k1 (hardware RNG, software ECP) 10- On-device Cashu wallet uses nucula library (libsecp256k1)
11- ESP-IDF is already installed at `~/esp/esp-idf` 11- ESP-IDF is already installed at `~/esp/esp-idf`
12- No Rust/ESP32 toolchain installed 12- No Rust/ESP32 toolchain installed
13- Nostr keypair as root identity — derive AP MAC, SSID, IP from nsec
13 14
14## Technology Stack 15## Technology Stack
15 16
16| Layer | Technology | 17| Layer | Technology |
17|-------|-----------| 18|-------|-----------|
18| Framework | ESP-IDF v5.4.1 (C/C++) | 19| Framework | ESP-IDF v5.4.1 (C/C++) |
19| Cashu wallet | Custom mbedTLS secp256k1 wallet (hash_to_curve, blind signing, swap, send) | 20| Identity | Nostr nsec → HMAC-SHA512 derivation → MAC/SSID/IP; Schnorr signing for Nostr events |
21| Cashu wallet | nucula library (libsecp256k1, NVS persistence) |
22| Service discovery | wifistr (Nostr kind 38787) via WebSocket to relays |
20| HTTP server | `esp_http_server` (port 80 captive portal, port 2121 TollGate API + wallet) | 23| HTTP server | `esp_http_server` (port 80 captive portal, port 2121 TollGate API + wallet) |
21| DNS | Custom UDP task (hijack unauthenticated, forward authenticated) | 24| DNS | Custom UDP task (hijack unauthenticated, forward authenticated) |
22| NAT | lwIP NAPT | 25| NAT | lwIP NAPT |
23| Persistence | SPIFFS (960K partition) with threshold-based write protection | 26| Persistence | NVS (nucula built-in) for wallet; SPIFFS for config.json |
24| Testing | Playwright + curl + nutshell CLI | 27| Testing | Playwright + curl + nutshell CLI |
25| Build | Makefile | 28| Build | Makefile |
26 29
@@ -82,20 +85,52 @@ Build a TollGate firmware for two ESP32 devices, following the [TollGate protoco
82 85
83**Captive Portal Detection:** DoT reject server on port 853, NXDOMAIN for non-A queries, 302 redirects for captive URIs. Verified working on GrapheneOS (commit `236b61d`). 86**Captive Portal Detection:** DoT reject server on port 853, NXDOMAIN for non-A queries, 302 redirects for captive URIs. Verified working on GrapheneOS (commit `236b61d`).
84 87
85### Phase 3: On-Device Wallet + ESP32-to-ESP32 Payments — IN PROGRESS 88### Phase 3: On-Device Wallet + Nostr Identity + Wifistr — IN PROGRESS
86 89
87**Goal:** On-device Cashu wallet using mbedTLS secp256k1. ESP32 holds balance, can swap proofs, create tokens for P2P payments. Proof persistence via SPIFFS with threshold-based write protection. 90**Goal:** On-device Cashu wallet using [nucula](https://github.com/zeugmaster/nucula) library (libsecp256k1). Nostr keypair as root identity — derive AP MAC, SSID, IP deterministically. Publish service via wifistr (Nostr kind 38787).
88 91
89#### Wallet Architecture 92#### Wallet Architecture — nucula Integration
90 93
91- **Crypto**: mbedTLS secp256k1 (software ECP, hardware RNG via `esp_fill_random`) 94**Decision: Use nucula as a git submodule instead of custom mbedTLS wallet.**
92- **Blind signing**: `hash_to_curve()` (SHA256 try-and-increment), `scalar_mul()`, `point_add()` 95
93- **Unblinding**: `C = C_ + (order - r) * G` — avoids needing mint's public key K, avoids point negation 96Why nucula over our custom mbedTLS wallet:
94- **Proof storage**: In-memory array (50 max), persisted to SPIFFS JSON 97- **libsecp256k1** vs mbedTLS ECP: purpose-built C library with precomputed tables, ~10x less stack usage, no stack overflow
95- **Persistence**: SPIFFS `/spiffs/wallet.json`, only written when `balance >= persist_threshold_sats` 98- **Production-quality**: NUT-00 through NUT-13, DLEQ verification, P2PK, deterministic secrets (BIP-39)
96- **Keyset fetch**: GET /v1/keys from mint on boot 99- **No maintenance burden**: upstream at `zeugmaster/nucula`, pull updates via `git submodule update`
97- **Swap**: POST /v1/swap — reissues proofs with new secrets 100- **NVS persistence**: more reliable than SPIFFS, no wear-leveling concerns
98- **Token creation**: Encode proofs as `cashuA` base64url token 101
102Integration structure:
103```
104esp32-tollgate/
105├── components/
106│ ├── nucula_src/ # git submodule → zeugmaster/nucula
107│ ├── secp256k1/ # copied from nucula_src/components/secp256k1/
108│ └── nucula_lib/ # wrapper component
109│ ├── CMakeLists.txt # compiles nucula sources from ../nucula_src/main/
110│ ├── nucula_wallet.h # C API for TollGate
111│ └── nucula_wallet.cpp # C++ bridge → nucula::Wallet
112├── main/
113│ ├── wallet.c # REMOVED
114│ ├── wallet_persist.c # REMOVED
115│ ├── cashu.c # simplified (token decode delegates to nucula)
116│ ├── tollgate_api.c # updated to use nucula_wallet.h
117```
118
119Files compiled from nucula (via `../nucula_src/main/`):
120- `crypto.c` — hash_to_curve, blind_message, unblind, DLEQ verification
121- `wallet.cpp` — full Cashu wallet (swap, receive, send, mint, melt)
122- `cashu_json.cpp` — JSON serialization (cJSON-based)
123- `nut10.cpp` — NUT-10 structured secret parsing
124- `hex.c` — hex encode/decode
125- `http.c` — HTTP client wrapper (uses esp_http_client)
126
127NOT compiled (TollGate doesn't need them):
128- `nucula.cpp` — nucula's own app_main
129- `cashu_cbor.cpp` — CBOR/V4 token support (we only use V3/cashuA)
130- `console.cpp`, `display.cpp`, `nfc.cpp`, `ndef.cpp`, `keypad.c` — hardware UI
131- `bip39.c` — mnemonic generation (we use random secrets)
132- `wifi.c` — nucula's own WiFi manager
133- `crypto_test.c` — test code
99 134
100#### Wallet Endpoints (on :2121) 135#### Wallet Endpoints (on :2121)
101 136
@@ -117,6 +152,124 @@ Config parameter `persist_threshold_sats` (default: 1) controls when wallet stat
117- Rationale: flash has finite write cycles (~100K erase per sector); only persist when e-cash value justifies the wear cost 152- Rationale: flash has finite write cycles (~100K erase per sector); only persist when e-cash value justifies the wear cost
118- SPIFFS wear-leveling spreads writes across the 960K partition 153- SPIFFS wear-leveling spreads writes across the 960K partition
119 154
155#### C API Bridge (`nucula_wallet.h`)
156
157The TollGate firmware is C; nucula is C++. A thin C bridge exposes the wallet operations needed:
158
159```c
160// Initialize wallet with secp256k1 context and mint URL
161esp_err_t nucula_wallet_init(const char *mint_url);
162
163// Receive a cashuA token string into wallet (swap + store proofs)
164esp_err_t nucula_wallet_receive(const char *token_str);
165
166// Create a cashuA token for the given amount
167esp_err_t nucula_wallet_send(uint64_t amount_sat, char *token_out, size_t token_out_size);
168
169// Get current balance in sats
170uint64_t nucula_wallet_balance(void);
171
172// Get proof count
173int nucula_wallet_proof_count(void);
174
175// Get JSON array of proofs (for /wallet endpoint)
176char *nucula_wallet_proofs_json(void);
177
178// Swap all proofs for fresh ones
179esp_err_t nucula_wallet_swap_all(void);
180
181// Print wallet status to log
182void nucula_wallet_print_status(void);
183```
184
185#### Persistence
186
187nucula uses NVS (Non-Volatile Storage) for persistence — proofs stored as JSON blobs in flash, keysets stored individually. This is more reliable than SPIFFS:
188- No filesystem overhead
189- Atomic writes via NVS key-value API
190- Wear leveling handled by NVS internally
191- No `persist_threshold_sats` needed — NVS handles flash wear automatically
192
193#### Nostr Identity Derivation
194
195**Root of trust:** A Nostr private key (`nsec`, 32 bytes hex) stored in `config.json`. All device identifiers are deterministically derived from this single key. Rotating nsec rotates the entire identity (MAC, SSID, IP, Nostr pubkey).
196
197**Derivation function: `tollgate_derive()`**
198
199Simplified HMAC-SHA512 derivation (not full BIP85 — ~50 lines, same security model):
200```
201tollgate_derive(nsec_bytes, label, index) → bytes
202 HMAC-SHA512(key=nsec_bytes, msg=label || uint32_le(index))
203 truncate output to needed length
204```
205
206**Derived values:**
207
208| Value | Derivation | Output |
209|-------|-----------|--------|
210| npub | `secp256k1_ec_pubkey_create(nsec)` → x-only pubkey | 32 bytes hex |
211| STA MAC | `tollgate_derive(nsec, "sta-mac", 0)` | 6 bytes, `byte[0] \|= 0x02` |
212| AP MAC | `tollgate_derive(nsec, "ap-mac", 0)` | 6 bytes, `byte[0] \|= 0x02` |
213| SSID | `"TollGate-" + hex(AP_MAC[3:6])` | last 3 bytes = 6 hex chars |
214| AP IP | `10.(AP_MAC[3]).((AP_MAC[4]^AP_MAC[5])%200+10).1` | hash-based from AP MAC |
215
216**Implementation: `identity.c/h`**
217
218- Uses `mbedtls/md.h` for HMAC-SHA512 (already linked)
219- Uses `secp256k1.h` + `secp256k1_extrakeys.h` from the secp256k1 component
220- Creates its own `secp256k1_context` (SIGN only) — destroyed after init
221- `identity_init(nsec_hex)` called before WiFi start in `app_main()`
222- Sets derived MACs via `esp_wifi_set_mac(WIFI_IF_STA/AP, mac)` after `esp_wifi_init()`
223
224**Boot sequence:**
225```
226nvs_flash_init()
227 → tollgate_config_init() // loads config.json with nsec
228 → identity_init(nsec) // derives npub, MACs, SSID, IP
229 → esp_netif_init()
230 → esp_event_loop_create_default()
231 → wifi_init_sta()
232 → wifi_create_ap_netif() // uses derived AP IP
233 → esp_wifi_init(&cfg)
234 → esp_wifi_set_mac(STA/AP) // sets derived MACs
235 → wifi_configure_ap() // uses derived SSID
236 → esp_wifi_start()
237```
238
239**Config.json format (new):**
240```json
241{
242 "nsec": "hex_64_chars",
243 "wifi_networks": [{"ssid":"...", "password":"..."}],
244 "ap_password": "",
245 "mint_url": "https://testnut.cashu.space",
246 "price_per_step": 21,
247 "step_size_ms": 60000,
248 "nostr_geohash": "u281w0dfz",
249 "nostr_relays": ["wss://relay.damus.io", "wss://nos.lol"],
250 "nostr_publish_interval_s": 21600
251}
252```
253
254Removed from config: `ap_ssid`, `ap_ip`, `ap_channel`, `ap_max_conn` (all derived or hardcoded).
255
256#### Nostr Event Signing (`nostr_event.c/h`)
257
258NIP-01 event serialization and Schnorr signing:
259- Canonical JSON: `[0, pubkey, created_at, kind, tags, content]`
260- Event ID: SHA-256 of canonical JSON serialization
261- Signature: `secp256k1_schnorrsig_sign32()` (BIP-340)
262- Uses own `secp256k1_context` (created on demand, destroyed after use)
263
264#### Wifistr Service Discovery (`wifistr.c/h`)
265
266Publishes TollGate node to Nostr as kind 38787 (wifistr):
267- Tags: `["d", npub]`, `["ssid", ssid]`, `["h", "cashu-testnut"]`, `["security", "open"]`, `["g", geohash]`, `["c", "cashu"]`
268- Content: human-readable description with price info
269- Publishes on boot + periodic timer (default 6 hours)
270- WebSocket client for relay communication (raw TCP + TLS + HTTP Upgrade)
271- Uses `esp_tls.h` for TLS connections to `wss://` relays
272
120#### Test Cases 273#### Test Cases
121 274
122| # | Test | Method | Pass Criteria | Status | 275| # | Test | Method | Pass Criteria | Status |
@@ -133,7 +286,99 @@ Config parameter `persist_threshold_sats` (default: 1) controls when wallet stat
133| 37 | 5 consecutive payments | Loop | All authenticated | TODO | 286| 37 | 5 consecutive payments | Loop | All authenticated | TODO |
134| 38 | Stress: rapid pay/expire | Loop with short sessions | No crash/leak | TODO | 287| 38 | Stress: rapid pay/expire | Loop with short sessions | No crash/leak | TODO |
135 288
136### Phase 4: ESP32-to-OpenWRT TollGate Interop — NOT STARTED 289### Phase 4: Mesh Service Discovery + ESP32-to-OpenWRT Interop — NOT STARTED
290
291**Goal:** Two capabilities: (1) Pre-association price discovery between mesh nodes using Wi-Fi Vendor IE beacons, (2) ESP32-to-OpenWRT TollGate interoperability with Cashu tokens.
292
293#### 4A: Pre-Association Service Discovery via Vendor IE Beacons
294
295**Problem:** In a tollgate mesh network, a client router needs to know an upstream gateway's price before investing in Wi-Fi connection setup/teardown. Standard 802.11u ANQP is not supported by ESP-IDF.
296
297**Solution: Vendor-Specific Information Elements in Beacon/Probe Response frames**
298
299ESP-IDF provides `esp_wifi_set_vendor_ie()` to inject custom data into 802.11 management frames. This allows passive price discovery during normal Wi-Fi scanning — no connection required.
300
301```
302┌─────────────────────────────────────────────────────────────┐
303│ Layer 2 (Pre-Association) │
304│ │
305│ Gateway AP broadcasts price in every Beacon (~100ms) │
306│ Client STA scans, reads price from beacon before connect │
307│ │
308│ ┌─────────────┐ ┌─────────────┐ │
309│ │ Gateway AP │ Beacon ──────────► │ Client STA │ │
310│ │ │ (with price IE) │ │ │
311│ │ Vendor IE: │ │ Scan result │ │
312│ │ OUI:TG │ │ includes │ │
313│ │ price/sats │ │ price data │ │
314│ │ step_ms │ └──────┬──────┘ │
315│ │ mint_url │ │ │
316│ └─────────────┘ Decision: connect? │
317│ │ │
318└──────────────────────────────────────────────┼──────────────┘
319
320 ┌────────────────▼──────────────┐
321 │ Layer 3+ (Connected) │
322 │ POST / with Cashu token │
323 └───────────────────────────────┘
324```
325
326**Beacon IE Payload Format (Vendor-Specific, Element ID 0xDD):**
327
328```
329┌──────────┬────────┬─────────────┬──────────────┬──────────────────┐
330│ element_id│ length │ vendor_oui │ oui_type │ payload │
331│ (0xDD) │ │ (3 bytes) │ (1 byte) │ (variable) │
332├──────────┼────────┼─────────────┼──────────────┼──────────────────┤
333│ 0xDD │ N │ "TG" │ 0x01 (price) │ See below │
334│ │ │ 0x54:0x47 │ │ │
335└──────────┴────────┴─────────────┴──────────────┴──────────────────┘
336
337Price Payload (oui_type 0x01):
338┌─────────────┬─────────────┬──────────────┬───────────────┬────────────┐
339│ version (1B)│ price (2B) │ step_ms (2B) │ fee_ppk (2B) │ hop_count │
340│ = 0x01 │ sat/step │ ms/step │ or 0 │ (1B) │
341├─────────────┼─────────────┼──────────────┼───────────────┼────────────┤
342│ 0x01 │ uint16_le │ uint16_le │ uint16_le │ uint8 │
343└─────────────┴─────────────┴──────────────┴───────────────┴────────────┘
344Total payload: 9 bytes (fits easily in beacon, typical budget ~200 bytes)
345```
346
347**Implementation:**
348
349**AP Side (Gateway — `beacon_price.c/h`):**
350- `beacon_price_start()` — calls `esp_wifi_set_vendor_ie(true, WIFI_VND_IE_TYPE_BEACON, WIFI_VND_IE_ID_0, &ie_data)` and also for `WIFI_VND_IE_TYPE_PROBE_RESP`
351- `beacon_price_update(uint16_t price_sat, uint16_t step_ms, uint16_t fee_ppk, uint8_t hop_count)` — dynamically updates the IE in-place (no reconnect, no user kick; next beacon frame carries new price)
352- Price derived from `tollgate_config_t` fields (`price_per_step`, `step_size_ms`)
353- Can be called on-the-fly when market conditions change (e.g., upstream price changes)
354
355**STA Side (Client — `beacon_scan.c/h`):**
356- `beacon_scan_prices(wifi_ap_record_t *aps, int count, tollgate_price_t *prices, int *price_count)` — given scan results, extract price IEs
357- Uses `esp_wifi_set_vendor_ie_cb()` to register a callback that fires during scan
358- Or parses `vendor_ie_data_t` from scan results if available in `wifi_ap_record_t`
359- Returns array of `{bssid, ssid, price_sat, step_ms, fee_ppk, hop_count}`
360- Client selects cheapest/upstream gateway from scan results before connecting
361
362**Integration with existing config:**
363- OUI: `0x54, 0x47` ("TG" in ASCII) — unique to TollGate
364- oui_type: `0x01` = price advertisement, `0x02` = mesh routing (future)
365- `hop_count`: indicates network depth (0 = directly connected to internet, 1 = one hop away)
366- Price updates are rate-limited to once per 5 seconds to avoid beacon churn
367
368**GL-MT3000 (OpenWrt) Compatibility:**
369- OpenWrt supports vendor IEs via `hostapd_cli -i wlan0 set vendor_elements <hex>` + `hostapd_cli -i wlan0 update_beacon`
370- Client scans via `iw dev wlan0 scan` show vendor elements
371- Requires stock OpenWrt 24 firmware (not GL.iNet default) for mac80211 driver access
372- Same OUI/payload format ensures ESP32 ↔ OpenWrt interop
373
374**Key Benefits:**
375- Zero connection overhead for price discovery
376- Works during normal passive/active scanning (no extra frames)
377- Prices update live without disconnecting clients
378- Supports multi-hop mesh routing via `hop_count`
379- Compatible with both ESP32 and Linux (OpenWrt) platforms
380
381#### 4B: ESP32-to-OpenWRT TollGate Interop
137 382
138**Goal:** ESP32 can pay OpenWRT TollGate using Cashu tokens. Full interoperability with existing OpenWRT-based TollGate infrastructure. 383**Goal:** ESP32 can pay OpenWRT TollGate using Cashu tokens. Full interoperability with existing OpenWRT-based TollGate infrastructure.
139 384
@@ -141,15 +386,31 @@ Config parameter `persist_threshold_sats` (default: 1) controls when wallet stat
141 386
142## Key Technical Notes 387## Key Technical Notes
143 388
144### mbedTLS 3.x Compatibility 389### nucula / libsecp256k1
145- `mbedtls_ecp_point` is opaque — cannot access `.X`, `.Y`, `.Z` directly 390- nucula uses **libsecp256k1** (Bitcoin Core's C library) for all curve operations
146- Use `mbedtls_ecp_muladd`, `mbedtls_ecp_mul`, `mbedtls_ecp_point_read/write_binary` 391- Stack-efficient: precomputed tables in `precomputed_ecmult.c` (compile-time), small runtime stack
147- No point negation needed with `C = C_ + (order - r) * G` unblinding approach 392- No `mbedtls_ecp_mul` → no stack overflow — runs fine on default 32K httpd task
393- ESP-IDF component at `components/secp256k1/` with `ECMULT_WINDOW_SIZE=8`, `ECMULT_GEN_PREC_BITS=4`
394- git submodule at `components/nucula_src/` — pull updates via `git submodule update --remote`
395
396### Token Format
397- TollGate uses **cashuA (V3)** tokens — base64url-encoded JSON
398- nucula's `deserialize_token_v3()` / `serialize_token_v3()` handle encoding
399- cashuB (V4/CBOR) not needed; CBOR dependency excluded from build
400
401### Vendor IE Beacon (Service Discovery)
402- ESP-IDF: `esp_wifi_set_vendor_ie(enable, type, idx, data)` — injects into Beacon/ProbeResp
403- `esp_wifi_set_vendor_ie_cb(cb, ctx)` — receives vendor IEs during scan
404- Element ID 0xDD (Vendor Specific), max ~200 bytes per IE
405- Updates are in-place in RAM; next beacon carries new data (~100ms interval)
406- No client disconnect or AP restart required for updates
407- OUI `0x54:0x47` ("TG") registered for TollGate protocol
148 408
149### Board Configuration 409### Board Configuration
150- Board A: `/dev/ttyACM0`, MAC `94:a9:90:2e:37:7c`, SSID `TollGate-377C`, AP IP `10.55.85.1` 410- Board A: `/dev/ttyACM0`, factory MAC `94:a9:90:2e:37:7c`
151- Board B: `/dev/ttyACM1`, MAC `fc:01:2c:c5:50:50`, unique SSID/IP derived from MAC 411- Board B: `/dev/ttyACM1`, factory MAC `fc:01:2c:c5:50:50`
152- Both boards run identical firmware, unique config derived at boot from factory MAC 412- Both boards run identical firmware; unique identity derived from nsec in config.json
413- SSID, AP IP, STA/AP MAC all derived from nsec via HMAC-SHA512
153 414
154### Test Mint 415### Test Mint
155- `testnut.cashu.space` — auto-pays lightning invoices for testing 416- `testnut.cashu.space` — auto-pays lightning invoices for testing