Files
n_signer/firmware/teensy41/test_otp_sd.py
T
Laan Tungir ac2e6347a2 v0.1.6 - Teensy 4.1 SD-card OTP pad: real implementation working on hardware
- otppad_embedded: bit-compatible port of libotppad (2386/2386 host tests pass)
- otp_pad_sd: SdFat-direct SD card pad reader (FAT-only, ASCII armor + binary .otp)
- pad_gen.ino: TRNG-sourced 1 MB pad generator using i.MX RT1062 TRNG registers
- Linker script: moved .rodata from DTCM to FLASH (EXCLUDE_FILE ed25519),
  reclaiming 124 KB DTCM, free stack 5.9 KB -> 130.9 KB
- check_stack.sh: build-time FlexRAM stack gauge, wired into build_signer.sh
- test_otp_sd.py: 8/9 hardware tests pass (ASCII + binary round-trips,
  offset advance, tamper detection; 10 KB plaintext times out on perf)
- test_classical.py: 16/16 pass with new memory layout (ed25519 OK)
- Memory evaluation document: plans/teensy41_memory_evaluation.md
2026-07-30 17:10:50 -04:00

376 lines
13 KiB
Python

#!/usr/bin/env python3
"""test_otp_sd.py — OTP SD-card pad round-trip test for the Teensy 4.1 signer.
Tests the encrypt/decrypt verbs against the real SD-card pad:
1. ASCII armor round-trip (encrypt -> decrypt -> recovered plaintext matches)
2. Pad-Offset in the armor header starts at 32 (reserved header)
3. Pad-ChkSum in the armor matches the bound pad
4. Second encrypt advances the offset by the first chunk size
5. Binary .otp round-trip (encrypt binary -> decrypt binary -> matches)
6. Binary blob starts with OTP\0 magic + correct header
7. Large plaintext (10 KB) round-trip (Padme bucket doubles to 16 KB)
8. Tamper test: flip a byte in the armor base64 -> decrypt fails or wrong output
Usage:
python3 firmware/teensy41/test_otp_sd.py [--port /dev/ttyACM0]
"""
import serial
import struct
import json
import time
import sys
import argparse
import base64
import re
DEFAULT_PORT = "/dev/ttyACM0"
BAUD = 115200
def send_request(ser, req: dict) -> dict:
"""Send a JSON-RPC request with 4-byte big-endian length prefix, read response."""
payload = json.dumps(req).encode("utf-8")
header = struct.pack(">I", len(payload))
ser.write(header + payload)
ser.flush()
resp_header = b""
deadline = time.time() + 30.0
while len(resp_header) < 4 and time.time() < deadline:
chunk = ser.read(4 - len(resp_header))
if chunk:
resp_header += chunk
else:
time.sleep(0.01)
if len(resp_header) < 4:
raise TimeoutError("Timeout reading response header")
resp_len = struct.unpack(">I", resp_header)[0]
if resp_len == 0 or resp_len > 65536:
raise ValueError(f"Invalid response length: {resp_len}")
resp_payload = b""
while len(resp_payload) < resp_len:
chunk = ser.read(resp_len - len(resp_payload))
if chunk:
resp_payload += chunk
else:
time.sleep(0.01)
return json.loads(resp_payload.decode("utf-8"))
def test_verb(ser, name, params=None, id_counter=[0]):
id_counter[0] += 1
req = {"jsonrpc": "2.0", "id": id_counter[0], "method": name}
if params is not None:
req["params"] = params
print(f"\n--- {name} ---")
if params:
# Don't print huge payloads
display_params = []
for p in params:
if isinstance(p, str) and len(p) > 100:
display_params.append(p[:80] + f"... ({len(p)} chars)")
else:
display_params.append(p)
print(f" params: {json.dumps(display_params, indent=2)}")
try:
resp = send_request(ser, req)
except Exception as e:
print(f" ❌ FAIL: {e}")
return None
if "error" in resp:
err = resp["error"]
print(f" ❌ FAIL: error code={err.get('code')} message={err.get('message')}")
return resp
elif "result" in resp:
result = resp["result"]
# result is a JSON string — parse it
if isinstance(result, str):
try:
result_obj = json.loads(result)
except json.JSONDecodeError:
result_obj = result
else:
result_obj = result
display = json.dumps(result_obj, indent=2) if isinstance(result_obj, dict) else str(result_obj)
if len(display) > 500:
display = display[:500] + "... (truncated)"
print(f" ✅ PASS: {display}")
return resp
else:
print(f" ❌ FAIL: no result or error: {resp}")
return resp
def parse_armor(armor_text):
"""Parse ASCII armor to extract Pad-ChkSum, Pad-Offset, and base64 data."""
chksum = None
offset = None
b64_lines = []
in_data = False
for line in armor_text.split("\n"):
line = line.strip()
if line == "-----BEGIN OTP MESSAGE-----":
continue
if line == "-----END OTP MESSAGE-----":
break
if line.startswith("Pad-ChkSum:"):
chksum = line.split(":", 1)[1].strip()
elif line.startswith("Pad-Offset:"):
offset = int(line.split(":", 1)[1].strip())
elif line.startswith("Version:"):
continue
elif line == "":
in_data = True
elif in_data:
b64_lines.append(line)
return chksum, offset, "".join(b64_lines)
def main():
parser = argparse.ArgumentParser(description="Test Teensy 4.1 OTP SD pad")
parser.add_argument("--port", default=DEFAULT_PORT, help="Serial port")
args = parser.parse_args()
print(f"Connecting to {args.port}...")
ser = serial.Serial(args.port, BAUD, timeout=2.0)
time.sleep(6) # wait for boot + SD mount + pad bind
# Drain boot messages
boot = ""
while ser.in_waiting:
boot += ser.read(ser.in_waiting).decode("utf-8", errors="replace")
if boot:
print(f"Boot output:\n{boot}")
passed = 0
failed = 0
# ---- Test 1: ASCII armor round-trip ----
print("\n=== Test 1: ASCII armor round-trip ===")
plaintext = b"Hello, OTP SD card pad!"
pt_b64 = base64.b64encode(plaintext).decode()
r = test_verb(ser, "encrypt", [pt_b64, {"encoding": "ascii"}])
if not r or "result" not in r:
print("❌ encrypt failed")
failed += 1
ser.close()
return 1
result = r["result"] if isinstance(r["result"], dict) else json.loads(r["result"])
armor = result["ciphertext"]
pad_chksum = result["pad_chksum"]
off_before = int(result["pad_offset_before"])
off_after = int(result["pad_offset_after"])
print(f" pad_chksum: {pad_chksum}")
print(f" offset: {off_before} -> {off_after} (consumed {off_after - off_before} bytes)")
# Parse the armor
armor_chksum, armor_offset, armor_b64 = parse_armor(armor)
print(f" armor Pad-ChkSum: {armor_chksum}")
print(f" armor Pad-Offset: {armor_offset}")
if armor_chksum != pad_chksum:
print(f" ❌ FAIL: armor chksum {armor_chksum} != result chksum {pad_chksum}")
failed += 1
else:
print(f" ✅ armor chksum matches")
passed += 1
if armor_offset != off_before:
print(f" ❌ FAIL: armor offset {armor_offset} != result offset_before {off_before}")
failed += 1
else:
print(f" ✅ armor offset matches")
passed += 1
# Decrypt
r2 = test_verb(ser, "decrypt", [armor, {"encoding": "ascii"}])
if not r2 or "result" not in r2:
print("❌ decrypt failed")
failed += 1
ser.close()
return 1
result2 = r2["result"] if isinstance(r2["result"], dict) else json.loads(r2["result"])
recovered = base64.b64decode(result2["plaintext"])
print(f" recovered: {recovered}")
if recovered == plaintext:
print(f" ✅ ASCII round-trip SUCCESS")
passed += 1
else:
print(f" ❌ ASCII round-trip FAILED: expected {plaintext}, got {recovered}")
failed += 1
# ---- Test 2: Offset advance ----
print("\n=== Test 2: Offset advance ===")
r3 = test_verb(ser, "encrypt", [pt_b64, {"encoding": "ascii"}])
if r3 and "result" in r3:
result3 = r3["result"] if isinstance(r3["result"], dict) else json.loads(r3["result"])
off2_before = int(result3["pad_offset_before"])
off2_after = int(result3["pad_offset_after"])
print(f" second encrypt offset: {off2_before} -> {off2_after}")
if off2_before == off_after:
print(f" ✅ offset advanced from first encrypt's end ({off_after})")
passed += 1
else:
print(f" ❌ FAIL: expected offset_before={off_after}, got {off2_before}")
failed += 1
else:
failed += 1
# ---- Test 3: Binary .otp round-trip ----
print("\n=== Test 3: Binary .otp round-trip ===")
# Reset offset to 32 for a clean binary test by re-binding
# (We can't reset via the API, so just use the current offset)
r4 = test_verb(ser, "encrypt", [pt_b64, {"encoding": "binary"}])
if not r4 or "result" not in r4:
print("❌ binary encrypt failed")
failed += 1
ser.close()
return 1
result4 = r4["result"] if isinstance(r4["result"], dict) else json.loads(r4["result"])
bin_b64 = result4["ciphertext"]
bin_blob = base64.b64decode(bin_b64)
print(f" binary blob size: {len(bin_blob)} bytes (header 58 + padded data {len(bin_blob) - 58})")
if bin_blob[:4] != b"OTP\0":
print(f" ❌ FAIL: binary blob missing OTP magic: {bin_blob[:4]}")
failed += 1
else:
print(f" ✅ binary blob has OTP magic")
passed += 1
# Check header pad_chksum (bytes 6..38, binary)
bin_chksum_bytes = bin_blob[6:38]
bin_chksum_hex = bin_chksum_bytes.hex()
if bin_chksum_hex == pad_chksum:
print(f" ✅ binary header chksum matches")
passed += 1
else:
print(f" ❌ FAIL: binary header chksum {bin_chksum_hex} != {pad_chksum}")
failed += 1
# Decrypt binary
r5 = test_verb(ser, "decrypt", [bin_b64, {"encoding": "binary"}])
if not r5 or "result" not in r5:
print("❌ binary decrypt failed")
failed += 1
ser.close()
return 1
result5 = r5["result"] if isinstance(r5["result"], dict) else json.loads(r5["result"])
recovered2 = base64.b64decode(result5["plaintext"])
print(f" recovered (binary): {recovered2}")
if recovered2 == plaintext:
print(f" ✅ Binary round-trip SUCCESS")
passed += 1
else:
print(f" ❌ Binary round-trip FAILED: expected {plaintext}, got {recovered2}")
failed += 1
# ---- Test 4: Large plaintext (10 KB) ----
print("\n=== Test 4: Large plaintext (10 KB) ===")
large_pt = bytes(range(256)) * 40 # 10240 bytes
large_b64 = base64.b64encode(large_pt).decode()
r6 = test_verb(ser, "encrypt", [large_b64, {"encoding": "ascii"}])
if r6 and "result" in r6:
result6 = r6["result"] if isinstance(r6["result"], dict) else json.loads(r6["result"])
large_off_before = int(result6["pad_offset_before"])
large_off_after = int(result6["pad_offset_after"])
large_consumed = large_off_after - large_off_before
print(f" 10 KB plaintext: offset {large_off_before} -> {large_off_after} (consumed {large_consumed} bytes)")
# Padme: 10 KB -> chunk doubles to 16384 bytes
if large_consumed == 16384:
print(f" ✅ Padme bucket = 16384 (correct for 10 KB)")
passed += 1
else:
print(f" ⚠️ Padme bucket = {large_consumed} (expected 16384)")
# Not a hard fail — just note it
# Decrypt
large_armor = result6["ciphertext"]
r7 = test_verb(ser, "decrypt", [large_armor, {"encoding": "ascii"}])
if r7 and "result" in r7:
result7 = r7["result"] if isinstance(r7["result"], dict) else json.loads(r7["result"])
large_recovered = base64.b64decode(result7["plaintext"])
if large_recovered == large_pt:
print(f" ✅ Large plaintext round-trip SUCCESS")
passed += 1
else:
print(f" ❌ Large plaintext round-trip FAILED (len {len(large_recovered)} vs {len(large_pt)})")
failed += 1
else:
failed += 1
else:
failed += 1
# ---- Test 5: Tamper test ----
print("\n=== Test 5: Tamper test ===")
# Re-encrypt a small message for the tamper test
r8 = test_verb(ser, "encrypt", [pt_b64, {"encoding": "ascii"}])
if r8 and "result" in r8:
result8 = r8["result"] if isinstance(r8["result"], dict) else json.loads(r8["result"])
tamper_armor = result8["ciphertext"]
# Flip a character in the base64 data section
lines = tamper_armor.split("\n")
tampered = False
for i, line in enumerate(lines):
if re.match(r'^[A-Za-z0-9+/=]+$', line) and len(line) > 10:
# Flip the first base64 char
c = line[0]
if c == 'A':
lines[i] = 'B' + line[1:]
else:
lines[i] = 'A' + line[1:]
tampered = True
break
tamper_armor = "\n".join(lines)
if tampered:
r9 = test_verb(ser, "decrypt", [tamper_armor, {"encoding": "ascii"}])
if r9 and "error" in r9:
print(f" ✅ Tampered armor correctly rejected (error)")
passed += 1
elif r9 and "result" in r9:
result9 = r9["result"] if isinstance(r9["result"], dict) else json.loads(r9["result"])
tampered_recovered = base64.b64decode(result9["plaintext"])
if tampered_recovered != plaintext:
print(f" ✅ Tampered armor produced wrong plaintext (detected)")
passed += 1
else:
print(f" ⚠️ Tampered armor still decrypted correctly (unlikely but possible if flip was in padding)")
# Not a hard fail
else:
print(f" ❌ Tamper test: unexpected response")
failed += 1
else:
print(f" ⚠️ Could not find base64 data to tamper")
else:
failed += 1
# ---- Summary ----
print(f"\n{'='*50}")
print(f"OTP SD pad test: {passed} passed, {failed} failed")
print(f"{'='*50}")
ser.close()
return 1 if failed else 0
if __name__ == "__main__":
sys.exit(main())