ADR-007: Sub-Key Delegation for Allow-List Signing¶
Status: Accepted Date: 2026-03-09 Supersedes: Partially updates ADR-003 (key management section)
Context¶
ADR-003 established Ed25519 for allow-list signing. The current design uses a single keypair: the private key lives on the backend server (as an environment variable), and the public key is provisioned to each ESP32's NVS.
This has two operational problems:
- Key rotation requires device flashing. If the signing key needs to be changed (routine rotation, personnel change, or compromise), every ESP32 must be physically updated with the new public key.
- The private key sits on an internet-facing server. If the backend is compromised, the attacker can sign arbitrary allow-lists. Recovery requires flashing all devices.
Decision¶
Always use sub-key delegation with air-gapped signing. Neither the master key nor any sub-key private key ever resides on the backend server. All signing happens offline on an admin's local machine. See ADR-008 for the full air-gapped rationale and how real-time approval works without server-side signing.
The ESP32 stores only the master public key in NVS (32 bytes, same as today). Verification becomes two steps instead of one:
- Verify the master key signed the sub-key certificate (and the certificate hasn't expired)
- Verify the sub-key signed the allow-list payload
There is no fallback to direct master-key signing. The master key's only purpose is certifying sub-keys.
Sub-Key Certificate Format (114 bytes)¶
| Offset | Size | Field | Description |
|---|---|---|---|
| 0 | 32 | sub_pubkey | Ed25519 public key of the sub-key |
| 32 | 1 | key_id | Identifier (0-255), logged by ESP32 for audit |
| 33 | 8 | valid_from | uint64 Unix timestamp |
| 41 | 8 | valid_until | uint64 Unix timestamp (0 = no expiry) |
| 49 | 1 | flags | Reserved, must be 0 |
| 50 | 64 | master_sig | Ed25519 signature by master key over bytes 0-49 |
The master signature covers the sub-key's public key, identity, and validity window. This binds the sub-key to a specific time range — even if an attacker steals a sub-key, it becomes useless after valid_until.
Allow-List Payload (updated)¶
[AllowListHeader (40 bytes)] ← key_id + totp_count in header
[AllowEntry × entry_count (56 bytes)] ← NFC card entries
[TotpEntry × totp_count (40 bytes)] ← per-user TOTP/HOTP entries (see ADR-008)
[SubKeyCertificate (114 bytes)] ← always present
[Ed25519 Signature (64 bytes)] ← signed by sub-key over everything above
Header change: Use 3 bytes from the 7-byte reserved block for key_id and totp_count:
| Offset | Size | Field | Description |
|---|---|---|---|
| 24 | 1 | default_grace_minutes | (unchanged) |
| 25 | 1 | key_id | Sub-key ID that signed this payload |
| 26 | 2 | totp_count | Number of TotpEntry records |
| 28 | 4 | reserved | Must be zero |
Signature coverage: The sub-key signature covers header + NFC entries + TOTP entries + certificate (everything before the final 64-byte signature). This prevents an attacker from swapping certificates between payloads.
Total size = 40 + (56 × N) + (40 × M) + 114 + 64 bytes (N = NFC entries, M = TOTP entries)
| NFC | TOTP | Size |
|---|---|---|
| 0 | 0 | 218 B |
| 50 | 10 | 3,418 B |
| 200 | 30 | 12,618 B |
| 500 | 50 | 30,218 B |
ESP32 Verification¶
1. Parse header → extract key_id
2. Parse entries
3. Parse sub-key certificate (106 bytes after entries)
4. Verify: crypto_sign_verify_detached(cert.master_sig, cert[0:50], master_pubkey)
→ Reject if invalid (forged certificate)
5. Check: cert.valid_from <= now <= cert.valid_until
→ Reject if expired (stale sub-key)
6. Check: cert.key_id == header.key_id
→ Reject if mismatch
7. Verify: crypto_sign_verify_detached(payload_sig, header+entries+cert, cert.sub_pubkey)
→ Reject if invalid (forged payload)
8. Check: header.version > current_version
→ Reject if replay
Performance: Two crypto_sign_verify_detached calls instead of one. On ESP32 with libsodium, each takes ~2ms. Total verification goes from ~2ms to ~4ms — negligible.
Key Lifecycle¶
Initial Setup¶
- Admin generates master keypair offline:
openlatch-keytool generate-master --out master.key - Master public key is provisioned to ESP32 NVS (same as today's single key)
- Admin generates first sub-key:
openlatch-keytool generate-subkey --master-key master.key --key-id 0 --valid-days 90 - Sub-key certificate and private key stay on the admin's machine (never uploaded to backend)
Signing Workflow (air-gapped)¶
- Admin modifies member list / schedules via backend UI
- Admin fetches unsigned payload:
GET /api/v1/admin/allowlist/unsigned - Admin signs locally:
openlatch-keytool sign --key subkey.key --cert subkey.cert --in unsigned.bin --out signed.bin - Admin uploads signed payload:
PUT /api/v1/admin/allowlist/signed - ESP32s pick up new list within 5 minutes (normal sync)
The backend never holds a signing key. It only stores and serves the pre-signed binary payload.
Routine Rotation (every 60-90 days)¶
- Admin generates new sub-key offline (key_id increments)
- Admin uses new sub-key for subsequent signing operations
- Old sub-key can be deleted or left to expire
- No ESP32 changes needed
Sub-Key Compromise¶
- Compromised sub-key was only on the admin's machine, so the compromise scope is limited to that machine
- Admin generates new sub-key offline, re-signs the allow-list
- ESP32s pick up new list within 5 minutes (normal sync)
- No device flashing needed — master key is unchanged
- Attacker's stolen sub-key expires naturally at
valid_until
Master Key Compromise (worst case)¶
- Generate new master keypair offline
- Flash new master public key to all ESP32s (physical access or OTA)
- Re-certify sub-keys with new master
- Same effort as today's single-key compromise — but this scenario is far less likely since the master key never touches a server
Backend API (air-gapped model)¶
The backend does not hold signing keys. It provides endpoints for the offline signing workflow:
Returns the unsigned binary payload (header + entries, no certificate or signature). The admin downloads this, signs locally, and uploads the signed version.
Accepts a fully signed binary payload (header + entries + sub-key certificate + signature). The backend stores this and serves it to ESP32 devices.
The backend can and should verify the uploaded payload using the master public key (which is safe to share — it's a public key). This catches corrupted uploads, accidental re-uploads of old payloads, and provides defense-in-depth against admin tooling bugs. The backend performs the same two-step verification as the ESP32: (1) verify master signature on the sub-key certificate, (2) verify sub-key signature on the payload. The master public key is configured on the backend during initial setup (same key provisioned to ESP32 NVS).
CLI Tool¶
openlatch-keytool is a small Python CLI for offline key operations:
# Generate master keypair (do this once, store securely)
openlatch-keytool generate-master --out master.key
# Extract master public key (for ESP32 provisioning)
openlatch-keytool show-pubkey --key master.key
# Generate and certify a sub-key
openlatch-keytool generate-subkey \
--master-key master.key \
--key-id 1 \
--valid-days 90 \
--out-cert subkey-1.cert \
--out-key subkey-1.key
The master private key never leaves the admin's secure machine.
Consequences¶
- Protocol v2 payload grows by 114 bytes (sub-key certificate). Negligible for typical list sizes.
- ESP32 verification takes ~4ms instead of ~2ms. Negligible.
- ESP32 NVS stores a master public key (32 bytes, same as before). No firmware struct changes.
- Backend gains two endpoints (
GET .../unsigned,PUT .../signed) but no signing key storage. - New dependency:
openlatch-keytoolCLI for offline key ceremonies and signing. - Key rotation requires no device flashing — only the admin's local sub-key changes.
- Sub-key compromise is recoverable without physical access to devices.
- Admin workflow adds one step: signing offline after modifying the member list. Acceptable for a hackerspace with changes a few times per week.
- Real-time approval (escalation, conditional access) uses TOTP/HOTP keypad codes instead of allow-list updates. See ADR-008.
- ADR-003 remains valid for the choice of Ed25519. This ADR updates the key management approach.