- 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
352 lines
12 KiB
Python
352 lines
12 KiB
Python
#!/usr/bin/env python3
|
|
"""Full test suite for the Teensy 4.1 n_signer over USB CDC.
|
|
|
|
Tests all verbs: get_info, get_public_key (all 6 algorithms), sign (secp256k1,
|
|
ed25519, ml-dsa-65, slh-dsa-128s), verify, encrypt/decrypt (OTP pad),
|
|
encapsulate/decapsulate (ML-KEM-768), derive_shared_secret (X25519), derive,
|
|
nostr_get_public_key, nostr_sign_event, nostr_nip04_encrypt/decrypt,
|
|
nostr_nip44_encrypt/decrypt.
|
|
|
|
Ordering note: the PQ keygens (ml-dsa-65, slh-dsa-128s, ml-kem-768) are
|
|
heap-heavy on the Teensy 4.1 (~140 KB free heap). Running all three back to
|
|
back can exhaust/fragment the heap and crash the device. To keep the suite
|
|
useful, the classical + nostr verbs (the common case) are tested FIRST on a
|
|
fresh heap, then the PQ verbs are tested LAST. If a PQ verb crashes the
|
|
device, the earlier results still stand.
|
|
|
|
Sends requests in the canonical n_signer wire format (see api.md §4.3):
|
|
params is a JSON ARRAY of positional arguments, with the options object
|
|
(containing "algorithm", "index", "scheme", ...) as the LAST element.
|
|
Binary payloads (messages, pubkeys, ciphertexts) are hex-encoded.
|
|
|
|
Usage:
|
|
python3 firmware/teensy41/test_signer.py [--port /dev/ttyACM0]
|
|
"""
|
|
|
|
import serial
|
|
import struct
|
|
import json
|
|
import time
|
|
import sys
|
|
import argparse
|
|
import hashlib
|
|
import os
|
|
import base64
|
|
|
|
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} ---")
|
|
print(f" params: {json.dumps(params, indent=2) if params else '(none)'}")
|
|
|
|
try:
|
|
resp = send_request(ser, req)
|
|
except Exception as e:
|
|
print(f" ❌ FAIL: {e}")
|
|
return None
|
|
|
|
if "error" in resp:
|
|
print(f" ❌ FAIL: error code={resp['error'].get('code')} message={resp['error'].get('message')}")
|
|
return resp
|
|
elif "result" in resp:
|
|
result = resp["result"]
|
|
display = json.dumps(result, indent=2)
|
|
if len(display) > 500:
|
|
display = display[:500] + "... (truncated)"
|
|
print(f" ✅ PASS: {display}")
|
|
return resp
|
|
else:
|
|
print(f" ❌ FAIL: no result or error in response: {resp}")
|
|
return resp
|
|
|
|
|
|
def main():
|
|
parser = argparse.ArgumentParser(description="Test Teensy 4.1 n_signer")
|
|
parser.add_argument("--port", default=DEFAULT_PORT, help="Serial port (default: /dev/ttyACM0)")
|
|
args = parser.parse_args()
|
|
|
|
print(f"Connecting to {args.port}...")
|
|
ser = serial.Serial(args.port, BAUD, timeout=2.0)
|
|
time.sleep(5) # wait for auto-generate boot + key derivation
|
|
|
|
# Drain any boot messages
|
|
while ser.in_waiting:
|
|
boot_msg = ser.read(ser.in_waiting).decode("utf-8", errors="replace")
|
|
print(f"Boot: {boot_msg}", end="")
|
|
print()
|
|
|
|
passed = 0
|
|
failed = 0
|
|
pubkeys = {}
|
|
|
|
# 1. get_info
|
|
r = test_verb(ser, "get_info")
|
|
if r and "result" in r: passed += 1
|
|
else: failed += 1
|
|
|
|
# 2. get_public_key for classical algorithms (lightweight, tested first)
|
|
classical_algs = ["secp256k1", "ed25519", "x25519"]
|
|
for alg in classical_algs:
|
|
r = test_verb(ser, "get_public_key", [{"algorithm": alg, "index": 0}])
|
|
if r and "result" in r:
|
|
passed += 1
|
|
pubkeys[alg] = r["result"].get("public_key", "")
|
|
else:
|
|
failed += 1
|
|
|
|
# 3. sign + verify (secp256k1 schnorr)
|
|
msg32 = hashlib.sha256(b"test message for signing").digest()
|
|
msg_hex = msg32.hex()
|
|
r = test_verb(ser, "sign", [msg_hex, {"algorithm": "secp256k1", "index": 0, "scheme": "schnorr"}])
|
|
sig_secp = None
|
|
if r and "result" in r:
|
|
passed += 1
|
|
sig_secp = r["result"].get("signature", "")
|
|
else:
|
|
failed += 1
|
|
|
|
if sig_secp and "secp256k1" in pubkeys:
|
|
r = test_verb(ser, "verify", [msg_hex, sig_secp, {"algorithm": "secp256k1", "index": 0, "scheme": "schnorr"}])
|
|
if r and "result" in r and r["result"].get("valid") in (True, "true"):
|
|
passed += 1
|
|
else:
|
|
failed += 1
|
|
else:
|
|
print("\n--- verify (secp256k1) ---")
|
|
print(" ⏭️ SKIP: no signature or pubkey")
|
|
failed += 1
|
|
|
|
# 4. sign (ed25519)
|
|
msg_raw = b"test ed25519 message"
|
|
msg_raw_hex = msg_raw.hex()
|
|
r = test_verb(ser, "sign", [msg_raw_hex, {"algorithm": "ed25519", "index": 0}])
|
|
sig_ed = None
|
|
if r and "result" in r:
|
|
passed += 1
|
|
sig_ed = r["result"].get("signature", "")
|
|
else:
|
|
failed += 1
|
|
|
|
if sig_ed and "ed25519" in pubkeys:
|
|
r = test_verb(ser, "verify", [msg_raw_hex, sig_ed, {"algorithm": "ed25519", "index": 0}])
|
|
if r and "result" in r and r["result"].get("valid") in (True, "true"):
|
|
passed += 1
|
|
else:
|
|
failed += 1
|
|
else:
|
|
print("\n--- verify (ed25519) ---")
|
|
print(" ⏭️ SKIP: no signature or pubkey")
|
|
failed += 1
|
|
|
|
# 5. encrypt + decrypt (OTP pad)
|
|
plaintext = b"Hello, OTP pad encryption test!"
|
|
pt_b64 = base64.b64encode(plaintext).decode()
|
|
r = test_verb(ser, "encrypt", [pt_b64, {"algorithm": "otp"}])
|
|
ct_b64 = None
|
|
pad_off_before = None
|
|
if r and "result" in r:
|
|
passed += 1
|
|
ct_b64 = r["result"].get("result", "")
|
|
# The pad offset where this ciphertext's pad slice begins. decrypt
|
|
# must rewind to this offset so the same pad bytes are reused.
|
|
pad_off_before = r["result"].get("pad_offset_before")
|
|
else:
|
|
failed += 1
|
|
|
|
if ct_b64 and pad_off_before is not None:
|
|
r = test_verb(ser, "decrypt",
|
|
[ct_b64, {"algorithm": "otp", "pad_offset": int(pad_off_before)}])
|
|
if r and "result" in r:
|
|
pt_result = base64.b64decode(r["result"].get("result", ""))
|
|
if pt_result == plaintext:
|
|
print(f" ✅ decrypt matches original plaintext")
|
|
passed += 1
|
|
else:
|
|
print(f" ❌ decrypt mismatch: expected {plaintext}, got {pt_result}")
|
|
failed += 1
|
|
else:
|
|
failed += 1
|
|
else:
|
|
print("\n--- decrypt (otp) ---")
|
|
print(" ⏭️ SKIP: no ciphertext")
|
|
failed += 1
|
|
|
|
# 6. derive_shared_secret (X25519)
|
|
try:
|
|
from cryptography.hazmat.primitives.asymmetric.x25519 import X25519PrivateKey
|
|
peer_priv = X25519PrivateKey.generate()
|
|
peer_pub = peer_priv.public_key()
|
|
peer_pub_hex = peer_pub.public_bytes_raw().hex()
|
|
r = test_verb(ser, "derive_shared_secret", [peer_pub_hex, {"algorithm": "x25519", "index": 0}])
|
|
if r and "result" in r: passed += 1
|
|
else: failed += 1
|
|
except ImportError:
|
|
print("\n--- derive_shared_secret (x25519) ---")
|
|
print(" ⏭️ SKIP: 'cryptography' module not installed")
|
|
failed += 1
|
|
|
|
# 7. derive (secp256k1 at index 1)
|
|
r = test_verb(ser, "derive", ["derive-test-data", {"algorithm": "secp256k1", "index": 1}])
|
|
if r and "result" in r: passed += 1
|
|
else: failed += 1
|
|
|
|
# 8. nostr_get_public_key
|
|
r = test_verb(ser, "nostr_get_public_key", [{"nostr_index": 0}])
|
|
nostr_pub = None
|
|
if r and "result" in r:
|
|
passed += 1
|
|
nostr_pub = r["result"]
|
|
if isinstance(nostr_pub, dict):
|
|
nostr_pub = nostr_pub.get("public_key", "")
|
|
else:
|
|
failed += 1
|
|
|
|
# 9. nostr_sign_event
|
|
if nostr_pub:
|
|
event = {"kind": 1, "created_at": int(time.time()), "tags": [], "content": "hello from test_signer"}
|
|
r = test_verb(ser, "nostr_sign_event", [event, {"nostr_index": 0}])
|
|
if r and "result" in r: passed += 1
|
|
else: failed += 1
|
|
|
|
# 10. nostr_nip04_encrypt + decrypt (the bug we fixed)
|
|
if nostr_pub:
|
|
nip04_pt = "hello via nip04"
|
|
r = test_verb(ser, "nostr_nip04_encrypt", [nostr_pub, nip04_pt, {"nostr_index": 0}])
|
|
if r and "result" in r:
|
|
passed += 1
|
|
cipher = r["result"]
|
|
r = test_verb(ser, "nostr_nip04_decrypt", [nostr_pub, cipher, {"nostr_index": 0}])
|
|
if r and "result" in r and r["result"] == nip04_pt:
|
|
print(f" ✅ nip04 round-trip plaintext recovered")
|
|
passed += 1
|
|
else:
|
|
print(f" ❌ nip04 round-trip mismatch")
|
|
failed += 1
|
|
else:
|
|
failed += 1
|
|
|
|
# 11. nostr_nip44_encrypt + decrypt (the is_nip44 dispatch bug we fixed)
|
|
if nostr_pub:
|
|
nip44_pt = "hello via nip44"
|
|
r = test_verb(ser, "nostr_nip44_encrypt", [nostr_pub, nip44_pt, {"nostr_index": 0}])
|
|
if r and "result" in r:
|
|
passed += 1
|
|
cipher44 = r["result"]
|
|
r = test_verb(ser, "nostr_nip44_decrypt", [nostr_pub, cipher44, {"nostr_index": 0}])
|
|
if r and "result" in r and r["result"] == nip44_pt:
|
|
print(f" ✅ nip44 round-trip plaintext recovered")
|
|
passed += 1
|
|
else:
|
|
print(f" ❌ nip44 round-trip mismatch")
|
|
failed += 1
|
|
else:
|
|
failed += 1
|
|
|
|
# ---- PQ verbs (tested LAST: heap-heavy, may crash the device) ----
|
|
print("\n=== PQ verbs (heap-heavy; tested last) ===")
|
|
|
|
# 12. get_public_key for PQ algorithms
|
|
pq_algs = ["ml-dsa-65", "slh-dsa-128s", "ml-kem-768"]
|
|
for alg in pq_algs:
|
|
r = test_verb(ser, "get_public_key", [{"algorithm": alg, "index": 0}])
|
|
if r and "result" in r:
|
|
passed += 1
|
|
pubkeys[alg] = r["result"].get("public_key", "")
|
|
else:
|
|
failed += 1
|
|
|
|
# 13. sign (ml-dsa-65)
|
|
r = test_verb(ser, "sign", [msg_raw_hex, {"algorithm": "ml-dsa-65", "index": 0}])
|
|
if r and "result" in r: passed += 1
|
|
else: failed += 1
|
|
|
|
# 14. sign (slh-dsa-128s) — slow (~1-2s on Teensy)
|
|
print("\n--- sign (slh-dsa-128s) — may take 1-2 seconds ---")
|
|
r = test_verb(ser, "sign", [msg_raw_hex, {"algorithm": "slh-dsa-128s", "index": 0}])
|
|
if r and "result" in r: passed += 1
|
|
else: failed += 1
|
|
|
|
# 15. encapsulate + decapsulate (ML-KEM-768)
|
|
if "ml-kem-768" in pubkeys:
|
|
r = test_verb(ser, "encapsulate", [pubkeys["ml-kem-768"], {"algorithm": "ml-kem-768", "index": 0}])
|
|
ct_kem = None
|
|
ss_kem = None
|
|
if r and "result" in r:
|
|
passed += 1
|
|
ct_kem = r["result"].get("ciphertext", "")
|
|
ss_kem = r["result"].get("shared_secret", "")
|
|
else:
|
|
failed += 1
|
|
|
|
if ct_kem:
|
|
r = test_verb(ser, "decapsulate", [ct_kem, {"algorithm": "ml-kem-768", "index": 0}])
|
|
if r and "result" in r:
|
|
ss_result = r["result"].get("shared_secret", "")
|
|
if ss_result == ss_kem:
|
|
print(f" ✅ decapsulate shared secret matches encapsulate")
|
|
passed += 1
|
|
else:
|
|
print(f" ❌ shared secret mismatch")
|
|
failed += 1
|
|
else:
|
|
failed += 1
|
|
else:
|
|
print("\n--- decapsulate (ml-kem-768) ---")
|
|
print(" ⏭️ SKIP: no encapsulate ciphertext")
|
|
failed += 1
|
|
else:
|
|
print("\n--- encapsulate (ml-kem-768) ---")
|
|
print(" ⏭️ SKIP: no ml-kem-768 pubkey")
|
|
failed += 1
|
|
|
|
# Summary
|
|
print(f"\n{'='*60}")
|
|
print(f"RESULTS: {passed} passed, {failed} failed, {passed+failed} total")
|
|
print(f"{'='*60}")
|
|
|
|
ser.close()
|
|
return 0 if failed == 0 else 1
|
|
|
|
|
|
if __name__ == "__main__":
|
|
sys.exit(main())
|