diff --git a/.github/workflows/m2-identity.yml b/.github/workflows/m2-identity.yml new file mode 100644 index 0000000..999c2ba --- /dev/null +++ b/.github/workflows/m2-identity.yml @@ -0,0 +1,35 @@ +name: Teensy 4.1 M2 identity + +on: + push: + paths: + - 'firmware/teensy41/include/ts_auth.h' + - 'firmware/teensy41/src/ts_auth.cpp' + - 'firmware/teensy41/include/ts_state.h' + - 'firmware/teensy41/src/ts_state.cpp' + - 'firmware/teensy41/test/m2_identity_tests.cpp' + - '.github/workflows/m2-identity.yml' + pull_request: + paths: + - 'firmware/teensy41/include/ts_auth.h' + - 'firmware/teensy41/src/ts_auth.cpp' + - 'firmware/teensy41/include/ts_state.h' + - 'firmware/teensy41/src/ts_state.cpp' + - 'firmware/teensy41/test/m2_identity_tests.cpp' + - '.github/workflows/m2-identity.yml' + +jobs: + host-test: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - name: Compile M2 identity tests + run: | + g++ -std=c++17 -Wall -Wextra -Werror \ + -Ifirmware/teensy41/include \ + firmware/teensy41/src/ts_auth.cpp \ + firmware/teensy41/src/ts_state.cpp \ + firmware/teensy41/test/m2_identity_tests.cpp \ + -o /tmp/m2-identity-test + - name: Run M2 identity tests + run: /tmp/m2-identity-test diff --git a/docs/superpowers/plans/2026-08-12-teensy41-tailscale-m2-identity.md b/docs/superpowers/plans/2026-08-12-teensy41-tailscale-m2-identity.md new file mode 100644 index 0000000..caffbad --- /dev/null +++ b/docs/superpowers/plans/2026-08-12-teensy41-tailscale-m2-identity.md @@ -0,0 +1,105 @@ +# Teensy 4.1 Tailscale M2 Identity Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Establish persistent Tailscale-style machine and node identity on Teensy 4.1 and a safe headless auth-key provisioning path without implementing control-plane registration yet. + +**Architecture:** Generate two independent X25519 private keys: one persistent machine key and one persistent node key. Store only those private keys in the Teensy EEPROM-backed state record; derive public keys as needed. Auth keys are provisioning credentials held only in RAM and explicitly cleared after use. M2 stops before the `/machine/register` network protocol. + +**Tech Stack:** Teensy 4.1, Arduino EEPROM, existing M1 crypto API, C++17-compatible Arduino toolchain, host-side C++ tests. + +## Global Constraints + +- Never commit an auth key, machine private key, or node private key. +- Machine and node private keys are independent 32-byte X25519 secrets. +- Public keys are derived, not persisted separately. +- Persistent state has a magic value, schema version, and CRC32 integrity check. +- Auth keys must start with `tskey-auth-`, be case-sensitive, and remain in RAM only. +- M2 does not implement `/machine/register`, Noise control transport, or network-map parsing. +- Private key material is wiped from temporary buffers after use. + +--- + +### Task 1: Add failing host tests for auth-key validation and state integrity + +**Files:** +- Create: `firmware/teensy41/test/m2_identity_tests.cpp` +- Create: `firmware/teensy41/include/ts_auth.h` + +**Interfaces:** +- `bool ts::auth::isValidAuthKey(const char*, size_t)`. +- `uint32_t ts::state::crc32(const uint8_t*, size_t)`. + +- [ ] **Step 1: Write failing tests** for valid `tskey-auth-*`, wrong prefix, empty key, overlong key, and CRC32 known vector for `123456789`. +- [ ] **Step 2: Run the tests** and verify they fail because the functions do not exist yet. +- [ ] **Step 3: Commit the red tests.** + +--- + +### Task 2: Implement auth-key validation and integrity primitives + +**Files:** +- Create: `firmware/teensy41/src/ts_auth.cpp` +- Create: `firmware/teensy41/include/ts_state.h` +- Create: `firmware/teensy41/src/ts_state.cpp` + +**Interfaces:** +- `ts::auth::isValidAuthKey(...)` validates without storing the key. +- `ts::state::crc32(...)` returns standard CRC-32/ISO-HDLC. + +- [ ] **Step 1:** Implement the minimal validation and CRC32. +- [ ] **Step 2:** Run host tests and verify green. +- [ ] **Step 3:** Ensure validation never logs the secret value. +- [ ] **Step 4:** Commit. + +--- + +### Task 3: Implement persistent machine/node key storage + +**Files:** +- Create: `firmware/teensy41/include/ts_identity.h` +- Create: `firmware/teensy41/src/ts_identity.cpp` + +**Interfaces:** +- `class TsIdentity { bool begin(); bool initialized() const; const uint8_t* machinePrivate() const; const uint8_t* nodePrivate() const; bool machinePublic(uint8_t[32]) const; bool nodePublic(uint8_t[32]) const; bool reset(); bool setAuthKey(const char*, size_t); bool hasAuthKey() const; const char* authKey() const; void clearAuthKey(); }` + +- [ ] **Step 1:** Define a fixed EEPROM state record with magic, version, flags, 32-byte machine private key, 32-byte node private key, and CRC32. +- [ ] **Step 2:** On boot, validate magic/version/CRC; if invalid, generate two independent keys with the M1 RNG and persist them once. +- [ ] **Step 3:** Derive public keys through M1 X25519 functions. +- [ ] **Step 4:** Implement reset by wiping RAM copies and replacing EEPROM state with a fresh key pair. +- [ ] **Step 5:** Keep auth keys in a fixed-size RAM buffer only; validate before copying and wipe on `clearAuthKey()`. +- [ ] **Step 6:** Run the Teensy build through CI. +- [ ] **Step 7:** Commit. + +--- + +### Task 4: Add M2 physical self-test and provisioning example + +**Files:** +- Create: `firmware/teensy41/examples/m2_identity/m2_identity.ino` +- Create: `firmware/teensy41/docs/m2-identity.md` + +**Interfaces:** +- The example prints only public key material. +- Serial command `AUTH ` provisions a RAM-only auth key. +- Serial command `CLEAR` wipes the auth key buffer. + +- [ ] **Step 1:** On boot, report whether state was loaded or freshly generated without printing private keys. +- [ ] **Step 2:** Print machine/node public keys in hex. +- [ ] **Step 3:** Accept a single `AUTH tskey-auth-...` command and report only `AUTH KEY ACCEPTED` or `AUTH KEY REJECTED`. +- [ ] **Step 4:** `CLEAR` wipes the RAM auth buffer. +- [ ] **Step 5:** Document that M3 will consume the RAM auth key for `/machine/register` and that M2 does not yet contact the control server. +- [ ] **Step 6:** Commit. + +--- + +## M2 Acceptance Criteria + +1. Two independent persistent private keys exist after first boot. +2. Reboot preserves both key pairs. +3. Corrupting the state record causes safe regeneration instead of loading corrupted keys. +4. Public keys can be derived deterministically from the stored private keys. +5. Auth keys are accepted only with the current `tskey-auth-` prefix and never written to EEPROM. +6. Auth key memory can be explicitly wiped. +7. Serial logs never reveal private keys or auth-key contents. +8. No `/machine/register` networking is claimed or implemented yet. diff --git a/firmware/teensy41/docs/m2-identity.md b/firmware/teensy41/docs/m2-identity.md new file mode 100644 index 0000000..5a6b770 --- /dev/null +++ b/firmware/teensy41/docs/m2-identity.md @@ -0,0 +1,46 @@ +# M2 — identity and headless provisioning + +M2 establishes the local identity material needed for a real Tailscale node. + +Tailscale uses separate machine and node key pairs. The machine key identifies the physical device to the control plane; the node key identifies the authenticated node and is used for network access and WireGuard. Private keys stay on the device, while public keys are distributed by the control plane. + +References: +- https://tailscale.com/docs/concepts/node-keys +- https://tailscale.com/docs/concepts/tailscale-identity + +## Storage + +The Teensy stores two independent 32-byte private keys in its EEPROM-backed state record. The record contains: + +- magic value +- schema version +- machine private key +- node private key +- CRC32 integrity value + +Public keys are derived when needed and are never stored as authoritative state. + +## Auth key + +M2 accepts a Tailscale auth key beginning with `tskey-auth-`. Tailscale documents auth keys as headless/pre-authentication credentials for devices such as IoT hardware. + +Reference: https://tailscale.com/docs/features/access-control/auth-keys + +The implementation intentionally does **not** write the auth key to EEPROM. It stays in RAM until cleared. This limits the lifetime of the provisioning credential and avoids turning persistent firmware state into a credential store. + +M3 will consume the RAM-only auth key for the actual `/machine/register` flow. + +## Serial example + +```text +KEYS +AUTH tskey-auth-... +CLEAR +RESET +``` + +The example only prints public keys. It never prints a private key or the auth-key value. + +## Security boundary + +M2 does not yet connect to the Tailscale control plane. It provides the persistent cryptographic identity and safe credential handoff required by M3. diff --git a/firmware/teensy41/examples/m2_identity/m2_identity.ino b/firmware/teensy41/examples/m2_identity/m2_identity.ino new file mode 100644 index 0000000..cabfadb --- /dev/null +++ b/firmware/teensy41/examples/m2_identity/m2_identity.ino @@ -0,0 +1,82 @@ +#include + +#include "ts_identity.h" + +TsIdentity identity; + +static void printHex(const char *label, const uint8_t *data, size_t length) { + Serial.print(label); + for (size_t i = 0; i < length; ++i) { + if (data[i] < 0x10) Serial.print('0'); + Serial.print(data[i], HEX); + } + Serial.println(); +} + +static void printPublicKeys() { + uint8_t machinePublic[32]; + uint8_t nodePublic[32]; + + if (!identity.machinePublic(machinePublic) || !identity.nodePublic(nodePublic)) { + Serial.println("[FAIL] public-key derivation"); + return; + } + + printHex("machine-public=", machinePublic, sizeof(machinePublic)); + printHex("node-public=", nodePublic, sizeof(nodePublic)); +} + +static void handleCommand(String line) { + line.trim(); + if (line.startsWith("AUTH ")) { + const char *value = line.c_str() + 5; + const size_t length = line.length() - 5; + Serial.println(identity.setAuthKey(value, length) + ? "AUTH KEY ACCEPTED" + : "AUTH KEY REJECTED"); + return; + } + + if (line == "CLEAR") { + identity.clearAuthKey(); + Serial.println("AUTH KEY CLEARED"); + return; + } + + if (line == "KEYS") { + printPublicKeys(); + return; + } + + if (line == "RESET") { + Serial.println(identity.reset() ? "IDENTITY RESET" : "IDENTITY RESET FAILED"); + if (identity.initialized()) printPublicKeys(); + return; + } + + Serial.println("COMMANDS: AUTH | CLEAR | KEYS | RESET"); +} + +void setup() { + Serial.begin(115200); + delay(1000); + + Serial.println(); + Serial.println("=== Tailscale Teensy 4.1 / M2 ==="); + Serial.println("Persistent machine/node identity"); + + if (!identity.begin()) { + Serial.println("[FAIL] identity initialization"); + return; + } + + Serial.println("[PASS] identity initialized"); + printPublicKeys(); + Serial.println("AUTH key is RAM-only; M3 will consume it for registration."); +} + +void loop() { + if (Serial.available()) { + handleCommand(Serial.readStringUntil('\n')); + } +} diff --git a/firmware/teensy41/include/ts_auth.h b/firmware/teensy41/include/ts_auth.h new file mode 100644 index 0000000..a9120cd --- /dev/null +++ b/firmware/teensy41/include/ts_auth.h @@ -0,0 +1,11 @@ +#pragma once + +#include + +namespace ts::auth { + +constexpr size_t kMaxAuthKeySize = 128; + +bool isValidAuthKey(const char *key, size_t length); + +} // namespace ts::auth diff --git a/firmware/teensy41/include/ts_identity.h b/firmware/teensy41/include/ts_identity.h new file mode 100644 index 0000000..b71fc58 --- /dev/null +++ b/firmware/teensy41/include/ts_identity.h @@ -0,0 +1,36 @@ +#pragma once + +#include +#include + +namespace ts { + +class TsIdentity { + public: + static constexpr size_t kKeySize = 32; + static constexpr size_t kMaxAuthKeySize = 128; + + bool begin(); + bool initialized() const; + + const uint8_t *machinePrivate() const; + const uint8_t *nodePrivate() const; + + bool machinePublic(uint8_t out[kKeySize]) const; + bool nodePublic(uint8_t out[kKeySize]) const; + + bool setAuthKey(const char *authKey, size_t length); + bool hasAuthKey() const; + const char *authKey() const; + void clearAuthKey(); + + bool reset(); + + private: + bool initialized_ = false; + uint8_t machinePrivate_[kKeySize] = {}; + uint8_t nodePrivate_[kKeySize] = {}; + char authKey_[kMaxAuthKeySize + 1] = {}; +}; + +} // namespace ts diff --git a/firmware/teensy41/include/ts_state.h b/firmware/teensy41/include/ts_state.h new file mode 100644 index 0000000..9bd27f7 --- /dev/null +++ b/firmware/teensy41/include/ts_state.h @@ -0,0 +1,10 @@ +#pragma once + +#include +#include + +namespace ts::state { + +uint32_t crc32(const uint8_t *data, size_t length); + +} // namespace ts::state diff --git a/firmware/teensy41/src/ts_auth.cpp b/firmware/teensy41/src/ts_auth.cpp new file mode 100644 index 0000000..0769b03 --- /dev/null +++ b/firmware/teensy41/src/ts_auth.cpp @@ -0,0 +1,17 @@ +#include "ts_auth.h" + +#include + +namespace ts::auth { + +bool isValidAuthKey(const char *key, size_t length) { + constexpr char prefix[] = "tskey-auth-"; + constexpr size_t prefixLength = sizeof(prefix) - 1; + + if (key == nullptr || length <= prefixLength || length > kMaxAuthKeySize) { + return false; + } + return std::memcmp(key, prefix, prefixLength) == 0; +} + +} // namespace ts::auth diff --git a/firmware/teensy41/src/ts_identity.cpp b/firmware/teensy41/src/ts_identity.cpp new file mode 100644 index 0000000..7844f78 --- /dev/null +++ b/firmware/teensy41/src/ts_identity.cpp @@ -0,0 +1,130 @@ +#include "ts_identity.h" + +#include +#include +#include + +#include "ts_auth.h" +#include "ts_crypto.h" +#include "ts_state.h" + +namespace ts { +namespace { + +constexpr uint32_t kMagic = 0x54534D32u; // "TSM2" +constexpr uint16_t kVersion = 1; +constexpr int kEepromAddress = 0; + +struct StoredState { + uint32_t magic; + uint16_t version; + uint16_t reserved; + uint8_t machinePrivate[32]; + uint8_t nodePrivate[32]; + uint32_t crc; +}; + +uint32_t stateCrc(const StoredState &state) { + return ts::state::crc32(reinterpret_cast(&state), + offsetof(StoredState, crc)); +} + +bool validState(const StoredState &state) { + return state.magic == kMagic && state.version == kVersion && + stateCrc(state) == state.crc; +} + +void wipe(void *ptr, size_t length) { + volatile uint8_t *p = static_cast(ptr); + while (length--) *p++ = 0; +} + +bool loadState(StoredState &state) { + EEPROM.get(kEepromAddress, state); + return validState(state); +} + +bool saveState(const StoredState &state) { + EEPROM.put(kEepromAddress, state); + StoredState check{}; + EEPROM.get(kEepromAddress, check); + return validState(check) && + std::memcmp(check.machinePrivate, state.machinePrivate, 32) == 0 && + std::memcmp(check.nodePrivate, state.nodePrivate, 32) == 0; +} + +bool generateState(StoredState &state) { + state = {}; + state.magic = kMagic; + state.version = kVersion; + + if (!ts::crypto::randomBytes(state.machinePrivate, 32) || + !ts::crypto::randomBytes(state.nodePrivate, 32)) { + return false; + } + + state.crc = stateCrc(state); + return saveState(state); +} + +} // namespace + +bool TsIdentity::begin() { + StoredState state{}; + if (!loadState(state)) { + if (!generateState(state)) { + initialized_ = false; + return false; + } + } + + std::memcpy(machinePrivate_, state.machinePrivate, sizeof(machinePrivate_)); + std::memcpy(nodePrivate_, state.nodePrivate, sizeof(nodePrivate_)); + initialized_ = true; + return true; +} + +bool TsIdentity::initialized() const { return initialized_; } + +const uint8_t *TsIdentity::machinePrivate() const { return machinePrivate_; } +const uint8_t *TsIdentity::nodePrivate() const { return nodePrivate_; } + +bool TsIdentity::machinePublic(uint8_t out[kKeySize]) const { + return initialized_ && ts::crypto::x25519PublicKey(out, machinePrivate_); +} + +bool TsIdentity::nodePublic(uint8_t out[kKeySize]) const { + return initialized_ && ts::crypto::x25519PublicKey(out, nodePrivate_); +} + +bool TsIdentity::setAuthKey(const char *authKey, size_t length) { + if (!ts::auth::isValidAuthKey(authKey, length) || + length > kMaxAuthKeySize) { + return false; + } + clearAuthKey(); + std::memcpy(authKey_, authKey, length); + authKey_[length] = '\0'; + return true; +} + +bool TsIdentity::hasAuthKey() const { return authKey_[0] != '\0'; } +const char *TsIdentity::authKey() const { return hasAuthKey() ? authKey_ : nullptr; } + +void TsIdentity::clearAuthKey() { wipe(authKey_, sizeof(authKey_)); } + +bool TsIdentity::reset() { + StoredState state{}; + if (!generateState(state)) { + return false; + } + clearAuthKey(); + wipe(machinePrivate_, sizeof(machinePrivate_)); + wipe(nodePrivate_, sizeof(nodePrivate_)); + std::memcpy(machinePrivate_, state.machinePrivate, sizeof(machinePrivate_)); + std::memcpy(nodePrivate_, state.nodePrivate, sizeof(nodePrivate_)); + initialized_ = true; + return true; +} + +} // namespace ts diff --git a/firmware/teensy41/src/ts_state.cpp b/firmware/teensy41/src/ts_state.cpp new file mode 100644 index 0000000..7f50674 --- /dev/null +++ b/firmware/teensy41/src/ts_state.cpp @@ -0,0 +1,17 @@ +#include "ts_state.h" + +namespace ts::state { + +uint32_t crc32(const uint8_t *data, size_t length) { + uint32_t crc = 0xffffffffu; + for (size_t i = 0; i < length; ++i) { + crc ^= data[i]; + for (unsigned bit = 0; bit < 8; ++bit) { + const uint32_t mask = static_cast(-(crc & 1u)); + crc = (crc >> 1) ^ (0xedb88320u & mask); + } + } + return ~crc; +} + +} // namespace ts::state diff --git a/firmware/teensy41/test/m2_identity_tests.cpp b/firmware/teensy41/test/m2_identity_tests.cpp new file mode 100644 index 0000000..6bff2c8 --- /dev/null +++ b/firmware/teensy41/test/m2_identity_tests.cpp @@ -0,0 +1,26 @@ +#include +#include +#include +#include + +#include "ts_auth.h" +#include "ts_state.h" + +int main() { + const char *valid = "tskey-auth-0123456789abcdef"; + assert(ts::auth::isValidAuthKey(valid, std::strlen(valid))); + + const char *wrongPrefix = "tskey-api-0123456789abcdef"; + assert(!ts::auth::isValidAuthKey(wrongPrefix, std::strlen(wrongPrefix))); + assert(!ts::auth::isValidAuthKey("", 0)); + + char longKey[130] = {}; + std::memcpy(longKey, "tskey-auth-", 11); + for (size_t i = 11; i < sizeof(longKey) - 1; ++i) longKey[i] = 'a'; + assert(!ts::auth::isValidAuthKey(longKey, sizeof(longKey) - 1)); + + const uint8_t vector[] = {'1','2','3','4','5','6','7','8','9'}; + assert(ts::state::crc32(vector, sizeof(vector)) == 0xcbf43926u); + + return 0; +}