# lolipop-ztl — Lolipop Zero Trust Link client for ESP32
日本語のドキュメントは [README_ja.md](README_ja.md) を参照してください。
This ESP-IDF component makes an ESP32 device a first-class node of [Lolipop! Zero Trust Link](https://ztna.lolipop.jp/) (LolipopZTL), a WireGuard-based zero trust network service. The control protocol (ts2021), WireGuard, DERP relay, and DISCO NAT traversal all run on the microcontroller, so robots, IoT devices, and sensors can talk to cloud services and other nodes (PCs, phones, servers) securely without exposing any ports.
It is a **library**, not a dedicated firmware: add one dependency to an existing ESP-IDF app and call a few functions. The host app is only responsible for Wi-Fi management and showing the device-authorization code (screen, serial log, or QR).
## Requirements
- ESP-IDF v5.0 or later (verified with v5.4)
- ESP32-S3 with 8MB PSRAM recommended (verified on a Stack-chan robot — M5Stack official kit "K151", main unit M5Stack CoreS3)
- A Zero Trust Link subscription (credentials are issued automatically by the device authorization flow — nothing to prepare by hand)
## Usage
Provisioning uses the **device authorization flow** (OAuth 2.0 Device Authorization
Grant, RFC 8628) — no keys or URLs are ever typed into the device:
```
device: shows a short code user: opens the verification page on a
(screen / serial log) ---> phone or PC, enters the code, checks
the device name, and approves
device: polls for approval <--- service issues the credentials
device: saves them to NVS and connects — done
```
```c
#include "ztl.h"
// After Wi-Fi (STA) is up:
static void show_code(const char *user_code, const char *verification_uri,
const char *verification_uri_complete, void *arg) {
// Show these to the user however your device can (LCD, serial log, QR, ...)
printf("open %s and enter %s\n", verification_uri, user_code);
}
ztl_config_t cfg;
if (!ztl_config_resolve(&cfg)) {
// First boot: run the device authorization flow. Right after boot the
// network may not be ready yet, so retry until it succeeds.
while (ztl_device_auth("my-device", "esp32", show_code, NULL, &cfg) != ESP_OK) {
vTaskDelay(pdMS_TO_TICKS(10000));
}
}
ztl_t *z = ztl_connect(&cfg, NULL);
// Then talk to peers over the tunnel:
// ztl_tcp_connect(z, ip, port, timeout_ms)
// ztl_udp_create(z, port)
// ztl_resolve(z, "peer-hostname")
```
- Credentials are persisted in NVS (namespace `ztlcfg`); after that the device reconnects on power-up by itself
- The token response includes the controller URL, so there is nothing to configure by hand
- The controller's Noise public key is fetched from `/key` on first connect and cached
- Track connection state via `ztl_callbacks_t.state_cb` (`ZTL_STATE_CONNECTED`) or `ztl_is_connected()`
- When the stored key is rejected (`ZTL_STATE_AUTH_FAILED`), run `ztl_device_auth` again — the same flow doubles as the "re-login" path
- To abort while waiting for approval (e.g. the user leaves the setup screen), call `ztl_device_auth_cancel()` from another task
- `ztl_device_auth` / `ztl_connect` are safe to call from tasks whose stack lives in PSRAM: flash writes cannot touch PSRAM while the cache is disabled, so the component delegates its NVS writes to a short-lived internal-RAM-stack task internally
- The implementation underneath is the bundled [microlink](https://github.com/pepabo/microlink) protocol stack; advanced features not covered by the `ztl_*` API can be used directly through `microlink.h` with the same handle
A complete example — Wi-Fi bring-up, device authorization, connection, and a status loop — lives in [`examples/basic`](../../examples/basic/) (builds as-is with `idf.py build`).
## How it works
The full lifecycle, from power-on to steady-state operation:
```
power-on (host app brings up Wi-Fi STA)
│
├── credentials in NVS? ── yes ──────────────────────────────┐
│ │
no │
│ │
▼ │
device authorization flow (ztl_device_auth) │
POST /api/auth/device/code ── issues user_code │
show the code + verification URL to the user (prompt_cb) │
poll /api/auth/device/token ── until the user approves │
save credentials to NVS │
│ │
▼ ▼
ztl_connect ◄──────────────────────────────────────────────────┘
│
fetch the controller Noise public key from /key
(first connect only; TLS certificate verification, then cached in NVS)
│
▼
control plane: ts2021 Noise handshake → node registration → streaming netmap
│
▼
data plane: DERP relay (TLS) + DISCO direct-path discovery
│
▼
WireGuard tunnel up ══ ZTL_STATE_CONNECTED ══ application traffic (TCP/UDP)
│
├── link lost ──► exponential backoff reconnect (ZTL_STATE_RECONNECTING),
│ retries forever; tunnel restored when the link returns
│
└── key revoked ─► ZTL_STATE_AUTH_FAILED: host app re-runs ztl_device_auth
(the user approves a new code in the browser)
```
## Required sdkconfig
```
CONFIG_ML_CTRL_TLS=y # the controller only serves HTTPS (443)
CONFIG_MBEDTLS_CERTIFICATE_BUNDLE=y # certificate verification for /key fetch
CONFIG_MBEDTLS_EXTERNAL_MEM_ALLOC=y # allocate TLS buffers from PSRAM (see below)
CONFIG_SPIRAM=y
```
`CONFIG_MBEDTLS_EXTERNAL_MEM_ALLOC` matters: each TLS connection needs ~20KB of I/O buffers, and holding two TLS sessions (controller + DERP relay) in internal RAM exhausts it, making `mbedtls_ssl_setup` fail (the error surfaces as "Bad input parameters").
## Security model
- For regular connections TLS server certificates are **not** verified; the controller is authenticated by the **pinned Noise public key** handshake instead. This matches the trust model of Tailscale clients, which speak the same control protocol over plaintext port 80 — TLS here only exists to pass networks that allow nothing but 443
- The one-time `/key` fetch, which happens before any key is pinned, **does** require certificate verification (certificate bundle, redirects disabled)
- The auth key is stored in NVS in plaintext by default. Anyone who can read the flash can extract it, so **remove the device from the dashboard (revoking its key) when it leaves your hands**. For production devices, enable ESP-IDF [flash encryption](https://docs.espressif.com/projects/esp-idf/en/stable/esp32s3/security/flash-encryption.html) and [NVS encryption](https://docs.espressif.com/projects/esp-idf/en/stable/esp32s3/api-reference/storage/nvs_encryption.html) (`CONFIG_SECURE_FLASH_ENC_ENABLED=y` + `CONFIG_NVS_ENCRYPTION=y`) so the stored key cannot be read out of the flash chip
- Call `ztl_config_erase()` to factory-reset the stored configuration
## Availability and reconnection
- Control-plane disconnects (stream loss, registration or netmap failures) enter a reconnect loop with exponential backoff (1s doubling up to 16s) that **retries indefinitely** — the stack never gives up on its own
- The DERP relay connection is re-established automatically by its I/O task; WireGuard re-handshakes periodically
- When the node key expires, the stack automatically tries to re-register with the stored auth key. Keys issued by the device authorization flow are single-use and short-lived, so this normally ends in `ZTL_STATE_AUTH_FAILED` — handle it with the re-login path below
- If NTP is unreachable at boot, time sync keeps retrying in the background and the WireGuard handshake follows once the clock is set
- **Wi-Fi recovery is the host app's responsibility**: the component does not manage Wi-Fi. Re-connect on `WIFI_EVENT_STA_DISCONNECTED` as `examples/basic` does; once the link is back, the reconnect loop above restores the tunnel
- Reconnection progress is reported via `state_cb` (`ZTL_STATE_RECONNECTING` → `ZTL_STATE_CONNECTED`)
- **When the auth key is rejected** (expired or revoked), `state_cb` receives `ZTL_STATE_AUTH_FAILED` — the embedded equivalent of a desktop client's "re-login" prompt. Handle it by running `ztl_device_auth` again and showing the new code to the user. The stack keeps retrying at maximum backoff in case the key is re-enabled server-side
- The device authorization endpoints are accessed with mandatory certificate verification and no redirect following. The approval page shows the device's self-reported name for the user to eyeball against the device (anti-phishing)
## Verified environment
| | |
|---|---|
| Hardware | Stack-chan robot (M5Stack official kit "K151"); the main unit is an M5Stack CoreS3 (ESP32-S3, 16MB flash / 8MB PSRAM) |
| Host app | A conversational robot firmware in the popular "[Stack-chan](https://github.com/stack-chan/stack-chan)" configuration, with this component embedded alongside LVGL and audio I/O |
| Service | Connected to the Lolipop! Zero Trust Link production service (this component presumes that service) |
| ESP-IDF | v5.4.4 (CI builds with the `espressif/idf:release-v5.4` container) |
| Verified | `/key` fetch → control-plane TLS + Noise handshake → node registration → streaming netmap → DERP relay TLS + DISCO direct path → sustained TCP/UDP application traffic (voice and API round-trips) over the WireGuard tunnel |
## Known limitations
- The host app must be native ESP-IDF (Arduino-based apps need the "Arduino as an ESP-IDF component" setup)
- The device does not act as a subnet router or exit node (traffic to/from the node itself only)
- If the stored key is rejected (expired or revoked), re-run `ztl_device_auth` — the user approves again from a browser
## Support and reporting issues
Bug reports, questions, and service inquiries all go through the [Lolipop! Zero Trust Link support form](https://support.ztna.lolipop.jp/hc/ja/requests/new) (help center: [support.ztna.lolipop.jp](https://support.ztna.lolipop.jp/hc/ja)). For security vulnerabilities, use the same form and do not post details in public.
## License
MIT (see [LICENSE](LICENSE)). Bundles [pepabo/microlink](https://github.com/pepabo/microlink) — a fork of [CamM2325/microlink](https://github.com/CamM2325/microlink) (MIT) with ZTL support — and wireguard-lwip (BSD-3-Clause). See the LICENSE files in each directory.
375246fb81d54d1db65db05114278a0f77e8bb3a
idf.py add-dependency "gmo-pepabo/lolipop-ztl^0.1.0"