ADR-008: TOTP/HOTP Keypad Access and Air-Gapped Signing¶
Status: Accepted Date: 2026-03-11 Updates: ADR-007 (signing workflow), replaces burst polling from escalation design
Context¶
ADR-007 introduced sub-key delegation for allow-list signing, but the sub-key private key still resided on the backend server. A compromised backend could sign arbitrary allow-lists — and adding just one UID is enough to gain physical access to the space. Anomaly detection cannot reliably distinguish this from legitimate admin activity.
Additionally, the denied card escalation flow (UC-011) relied on "burst polling" — the ESP32 polling the backend every 5 seconds for an updated allow-list after admin approval. This required the backend to hold a signing key to re-sign the list in real time.
The CONLAN M1200 NFC terminal has a keypad in addition to the NFC reader, which opens a new input channel.
Decision¶
1. Air-gapped signing only¶
No signing keys on the backend. The allow-list signing workflow becomes:
- Backend assembles unsigned allow-list (entries from database)
- Admin pulls unsigned payload to their local machine
- Admin signs offline with
openlatch-keytool - Admin uploads signed payload back to backend
- ESP32 fetches signed payload on next sync
The backend never holds a sub-key private key. It only stores and serves the pre-signed binary payload.
2. Per-user TOTP/HOTP keypad access¶
Each user with TOTP access has their own secret, delivered as part of the allow-list. This provides:
- Identity: The ESP32 knows who entered (logs the user, not just "someone with a code")
- Schedule enforcement: The user's access rules (time slots, date range) apply to TOTP access, same as NFC
- Individual revocation: Remove a user's TOTP entry without affecting others
The user enters their key-ID first, then the code: <key_id>#<code>#. This tells the ESP32 which secret to verify against, eliminating brute-force amplification from searching all secrets.
3. Roles¶
A single-byte role bitfield encodes user permissions. One role bit is set; bit 0 is the disabled flag:
| Value | Binary | Role | Description |
|---|---|---|---|
| 0x80 | 1000 0000 |
Admin | Full system administration |
| 0x81 | 1000 0001 |
Admin (disabled) | Temporarily suspended |
| 0x40 | 0100 0000 |
Guarantor | Can vouch for others' entry via TOTP |
| 0x41 | 0100 0001 |
Guarantor (disabled) | Temporarily suspended |
| 0x20 | 0010 0000 |
Host | Can host events, open space |
| 0x21 | 0010 0001 |
Host (disabled) | Temporarily suspended |
| 0x10 | 0001 0000 |
User | Standard member |
| 0x11 | 0001 0001 |
User (disabled) | Temporarily suspended |
Permission checks:
role & 0x01→ disabled (odd values = disabled)role >= 0x80→ adminrole >= 0x40→ guarantor or admin (can share codes for others)role >= 0x20→ host or above
Bits 1-3 are reserved for future roles.
4. Three input modes¶
| Mode | Input | When |
|---|---|---|
| NFC card | Card scan | Primary daily access |
| Keypad code | <key_id>#<totp/hotp># |
Forgotten card, escalation, visitors |
| NFC + keypad | Card scan then code | Future: high-security two-factor (not implemented now) |
5. Guarantor-mediated access (escalation, conditional)¶
When a person needs access and can't use their own credentials (denied card, visitor without NFC), a guarantor (role >= 0x40) can authorize entry by sharing their TOTP code over the phone:
- Person at the door enters the guarantor's key-ID and the guarantor's TOTP code
- ESP32 looks up the guarantor's entry, verifies the code against their secret
- If valid: door unlocks. Event logged as "guarantor X authorized entry"
- The guarantor's access schedule is checked — guarantors are typically UNRESTRICTED, so this always passes
The guarantor does not need to be physically present. They vouch for the entry remotely.
TOTP/HOTP Specification¶
Per-user secrets in the allow-list¶
TOTP entries are a separate section in the allow-list payload, after NFC entries:
[AllowListHeader (40 bytes)]
[AllowEntry × nfc_count (56 bytes each)] ← NFC card entries
[TotpEntry × totp_count (40 bytes each)] ← TOTP/HOTP entries (new)
[SubKeyCertificate (114 bytes)]
[Ed25519 Signature (64 bytes)]
Each TOTP entry (40 bytes):
| Offset | Size | Field | Description |
|---|---|---|---|
| 0 | 1 | key_id | User's keypad ID (1-255, entered before #) |
| 1 | 1 | role | Role bitfield (see above) |
| 2 | 1 | access_type | Same AccessType enum as NFC entries |
| 3 | 20 | totp_secret | HMAC-SHA1 secret (RFC 6238) |
| 23 | 1 | hotp_used | Bitmap of consumed HOTP backup codes (8 codes, bit=used) |
| 24 | 8 | valid_from | uint64 Unix timestamp, 0 = no start restriction |
| 32 | 8 | valid_until | uint64 Unix timestamp, 0 = no expiry |
The hotp_used bitmap tracks which of the 8 pre-generated backup codes have been consumed. The ESP32 updates this in RAM when a backup code is used, and reports the usage via event sync so the backend can update the next allow-list.
TOTP parameters¶
- Algorithm: HMAC-SHA1 (standard for authenticator app compatibility)
- Digits: 6
- Time step: 30 seconds
- Own code: Acceptance window T-1 to T+1 (90-second tolerance for clock drift)
- Guarantor code (shared by phone): Acceptance window T-10 to T+1 (5 minutes back, to account for phone communication delay)
- The ESP32 tracks the last accepted TOTP counter per key-ID to prevent replay
Indexed HOTP (backup codes)¶
Each user gets 8 pre-generated backup codes (indices 0-7). Unlike standard counter-based HOTP, codes can be used in any order:
- At provisioning: generate HOTP codes for indices 0-7 using the same
totp_secret, print as a numbered list - ESP32 stores:
hotp_usedbitmap (1 byte per user, in the TOTP entry) - On code entry: try all indices where the bitmap bit is 0 (unused). If match → set the bit
- When all 8 bits set → codes exhausted, admin generates a new batch via allow-list update
Security: at most 8 valid codes at any time per user. With key-ID prefix, only one user's codes are checked: 8/1,000,000 = 0.0008% per guess.
HOTP code generation: Uses the same totp_secret but with counter values 0-7 (the TOTP time-based counter space is much larger, so there's no collision).
Keypad input format¶
Examples:
- Own TOTP:
42#287082#(key-ID 42, TOTP code 287082) - Guarantor TOTP:
5#969429#(guarantor's key-ID 5, guarantor's current TOTP) - Backup code:
42#755224#(key-ID 42, one of the 8 backup codes)
The ESP32 assembles the key-ID from 4-bit Wiegand keypresses until #, then collects 6 digits until the second #.
Verification flow¶
When the ESP32 receives <key_id>#<code># from the keypad:
1. Look up key_id in TOTP entry list
→ Not found: REJECT
2. Check entry: disabled (role & 0x01)? → REJECT
3. Check entry: valid_from / valid_until date range
→ Outside range: REJECT
4. Determine TOTP window based on context:
→ Own code (key_id matches person at door): window T-1 to T+1
→ Guarantor code (role >= 0x40): window T-10 to T+1
5. Try TOTP: compute expected codes for the window
→ If match and step > last_totp_step for this key_id: ACCEPT
→ If matched step < T-1 (code older than 60s): log code age
6. Try indexed HOTP: check indices 0-7 where hotp_used bit is 0
→ If match: ACCEPT, set bit in hotp_used bitmap
7. No match: REJECT, increment failed_attempts
Rate limiting¶
- Max 3 failed attempts in a 5-minute window
- After 3 failures: keypad locked for 5 minutes (NFC still works)
- Escalating lockout: subsequent lockouts double (5 min → 10 min → 20 min)
- Rate limit state is in RAM (resets on reboot — acceptable trade-off)
- A successful code entry resets the failure counter and lockout escalation
Security properties¶
- Per-user, per-code: With key-ID prefix, only 12 TOTP codes (own) or 12 + 8 HOTP codes are valid per attempt. Brute force: 12/1,000,000 = 0.0012% per guess.
- 3-attempt rate limiting: ~0.0036% chance across 3 attempts before lockout. Equivalent to ~19,000 lockout cycles (~66 days at 5-min lockouts).
- Escalating lockout: Doubling lockout duration makes sustained attacks impractical.
- Schedule enforcement: TOTP access respects the same time slots and date ranges as NFC.
- No network needed: TOTP/HOTP verification is purely local.
- No signing key on server: Backend compromise cannot forge allow-lists or generate access codes.
- Codes are ephemeral: TOTP valid for 30-90 seconds (own) or up to 5 minutes (guarantor), HOTP single-use.
- Code age logging: If a guarantor code is older than T-1 (>60 seconds), the age is logged for auditing communication delay.
Impact on Existing Features¶
Denied card escalation (UC-011)¶
Before: Card denied 3x → notification → admin adds temp entry → backend re-signs → burst poll → card scan again.
After: Card denied 3x → member calls guarantor → guarantor opens authenticator app → tells member the key-ID + code by phone → member types <guarantor_key_id>#<code># on keypad → door opens.
Changes:
- Remove burst polling (ESCALATION_POLL_INTERVAL_MS, ESCALATION_TIMEOUT_MS)
- Remove has_pending_escalation() function
- record_denial() still triggers the notification; resolution is now via keypad, not allow-list update
- Simpler, faster (no polling loop), works offline
Conditional access (UC-006)¶
Before: Card scan → ESP32 connects to network → POST to backend → waits 60s for organizer response.
After: Card scan → keyholder calls guarantor → guarantor provides TOTP code by phone → keyholder types <guarantor_key_id>#<code># on keypad. ESP32 queues event for backend sync (if network available).
Changes: - No synchronous 60-second network connection needed - Works even without network connectivity (guarantor can still share TOTP code by phone) - ACCESS_CONDITIONAL type still exists in allow-list, but the approval mechanism changes from backend polling to keypad TOTP
Allow-list sync¶
Before: ESP32 polls backend every 5 minutes, backend signs on demand.
After: ESP32 polls backend every 5 minutes, backend serves pre-signed payload. Admin must sign offline when entries change.
For routine operations (add/remove member, schedule changes), the admin signs a new allow-list whenever they make changes — probably a few times per week. This is acceptable for a hackerspace.
Wiegand protocol¶
The CONLAN M1200 sends keypad input over Wiegand. The WiegandDecoder needs to handle:
- Card mode: 26-bit or 34-bit frames (existing)
- PIN mode: 4-bit frames per keypress, assembled into <key_id>#<code>#, terminated by second # key
Allow-List Header Changes¶
The header gains a totp_count field using 2 bytes from the reserved block:
| Offset | Size | Field | Description |
|---|---|---|---|
| 32 | 1 | default_grace_minutes | (unchanged) |
| 33 | 1 | key_id | Sub-key ID that signed this payload |
| 34 | 2 | totp_count | uint16 number of TotpEntry records |
| 36 | 4 | reserved | Must be zero |
ESP32 NVS Layout (updated)¶
| Key | Size | Description |
|---|---|---|
master_pubkey |
32 B | Ed25519 master public key |
TOTP secrets are no longer in NVS — they are part of the signed allow-list payload. This means:
- Secrets are cryptographically bound to the allow-list (can't be tampered independently)
- Secret rotation happens via a new signed allow-list (same workflow as NFC changes)
- No separate provisioning step for TOTP secrets
The ESP32 tracks per-user last-accepted TOTP step and HOTP used bitmap in RAM. The HOTP bitmap is synced to the backend via events so the next allow-list reflects consumed codes.
CLI Tool (updated)¶
# Generate TOTP secret and QR code for a user
openlatch-keytool generate-totp --key-id 42 --out user42.secret --qr user42-qr.png
# Generate and print 8 HOTP backup codes for a user
openlatch-keytool generate-hotp --secret user42.secret --codes 8 --print backup-codes-42.txt
# Sign an allow-list offline
openlatch-keytool sign \
--key subkey.key \
--cert subkey.cert \
--in unsigned.bin \
--out signed.bin
Backend Changes¶
Removed¶
signing_keystable (no signing keys on backend)POST/GET/DELETE /api/v1/admin/signing-keysendpointsOPENLATCH_KEY_ENCRYPTION_KEYenv var
Added¶
GET /api/v1/admin/allowlist/unsigned— returns unsigned binary payload for offline signingPUT /api/v1/admin/allowlist/signed— accepts signed binary payload from admin; backend verifies signature using master public key (defense-in-depth)totp_entriestable: key_id, role, access_type, totp_secret (encrypted at rest), hotp_used, valid_from, valid_until
Changed¶
GET /api/v1/device/allowlist— serves the pre-signed payload as-is (no signing step)- Backend needs PyNaCl at runtime only for upload verification (master public key), not for signing
Consequences¶
- No signing key on any server. Backend compromise cannot forge allow-lists.
- Per-user TOTP with schedule enforcement. Same access rules as NFC cards.
- Key-ID prefix eliminates brute-force amplification. Only one user's codes checked per attempt.
- Role bitfield enables guarantor-mediated access. Trusted members can authorize entry remotely.
- Real-time approval doesn't need allow-list changes. Guarantor TOTP codes are verified locally.
- Admin workflow adds one step. Signing offline after modifying the member list.
- Burst polling removed. Simpler ESP32 code, less network/radio usage.
- Keypad input adds ~50 lines to Wiegand decoder (PIN mode handling).
- TOTP/HOTP adds ~150 lines to firmware (verification + rate limiting + per-user lookup).
- Allow-list grows by 40 bytes per TOTP user. 50 TOTP users = +2,000 bytes.
- 8 indexed HOTP backup codes per user, tracked with 1-byte bitmap. Any-order usage.
- Escalating lockout (5 min → 10 min → 20 min) for repeated keypad failures.
- ESP32 needs accurate time for TOTP — already required for scheduling.