Skip to content

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:

  1. 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.
  2. 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:

  1. Verify the master key signed the sub-key certificate (and the certificate hasn't expired)
  2. 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

  1. Admin generates master keypair offline: openlatch-keytool generate-master --out master.key
  2. Master public key is provisioned to ESP32 NVS (same as today's single key)
  3. Admin generates first sub-key: openlatch-keytool generate-subkey --master-key master.key --key-id 0 --valid-days 90
  4. Sub-key certificate and private key stay on the admin's machine (never uploaded to backend)

Signing Workflow (air-gapped)

  1. Admin modifies member list / schedules via backend UI
  2. Admin fetches unsigned payload: GET /api/v1/admin/allowlist/unsigned
  3. Admin signs locally: openlatch-keytool sign --key subkey.key --cert subkey.cert --in unsigned.bin --out signed.bin
  4. Admin uploads signed payload: PUT /api/v1/admin/allowlist/signed
  5. 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)

  1. Admin generates new sub-key offline (key_id increments)
  2. Admin uses new sub-key for subsequent signing operations
  3. Old sub-key can be deleted or left to expire
  4. No ESP32 changes needed

Sub-Key Compromise

  1. Compromised sub-key was only on the admin's machine, so the compromise scope is limited to that machine
  2. Admin generates new sub-key offline, re-signs the allow-list
  3. ESP32s pick up new list within 5 minutes (normal sync)
  4. No device flashing needed — master key is unchanged
  5. Attacker's stolen sub-key expires naturally at valid_until

Master Key Compromise (worst case)

  1. Generate new master keypair offline
  2. Flash new master public key to all ESP32s (physical access or OTA)
  3. Re-certify sub-keys with new master
  4. 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:

GET /api/v1/admin/allowlist/unsigned

Returns the unsigned binary payload (header + entries, no certificate or signature). The admin downloads this, signs locally, and uploads the signed version.

PUT /api/v1/admin/allowlist/signed

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-keytool CLI 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.