Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
35 changes: 35 additions & 0 deletions .github/workflows/m2-identity.yml
Original file line number Diff line number Diff line change
@@ -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
105 changes: 105 additions & 0 deletions docs/superpowers/plans/2026-08-12-teensy41-tailscale-m2-identity.md
Original file line number Diff line number Diff line change
@@ -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 <key>` 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.
46 changes: 46 additions & 0 deletions firmware/teensy41/docs/m2-identity.md
Original file line number Diff line number Diff line change
@@ -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.
82 changes: 82 additions & 0 deletions firmware/teensy41/examples/m2_identity/m2_identity.ino
Original file line number Diff line number Diff line change
@@ -0,0 +1,82 @@
#include <Arduino.h>

#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 <tskey-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'));
}
}
11 changes: 11 additions & 0 deletions firmware/teensy41/include/ts_auth.h
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
#pragma once

#include <stddef.h>

namespace ts::auth {

constexpr size_t kMaxAuthKeySize = 128;

bool isValidAuthKey(const char *key, size_t length);

} // namespace ts::auth
36 changes: 36 additions & 0 deletions firmware/teensy41/include/ts_identity.h
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
#pragma once

#include <stddef.h>
#include <stdint.h>

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
10 changes: 10 additions & 0 deletions firmware/teensy41/include/ts_state.h
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
#pragma once

#include <stddef.h>
#include <stdint.h>

namespace ts::state {

uint32_t crc32(const uint8_t *data, size_t length);

} // namespace ts::state
17 changes: 17 additions & 0 deletions firmware/teensy41/src/ts_auth.cpp
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
#include "ts_auth.h"

#include <cstring>

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
Loading
Loading