Files
n_signer/tools/otp_roundtrip_test.py

214 lines
7.1 KiB
Python

#!/usr/bin/env python3
"""
otp_roundtrip_test.py — end-to-end test of otp_encrypt / otp_decrypt verbs.
Sends framed JSON-RPC requests to nsigner --listen stdio and checks the
round-trip: plaintext -> otp_encrypt -> otp_decrypt -> recovered plaintext.
Framing: 4-byte big-endian length prefix + JSON payload.
Usage: python3 tools/otp_roundtrip_test.py
"""
import base64
import json
import os
import struct
import subprocess
import sys
import tempfile
import time
NSIGNER = "./build/nsigner"
PAD_DIR = "/media/user/Music/pads"
PAD_SPEC = "333e9902db839d9d"
MNEMONIC_FILE = ".test_mnemonic"
def send_framed(proc, obj):
payload = json.dumps(obj).encode()
proc.stdin.write(struct.pack(">I", len(payload)))
proc.stdin.write(payload)
proc.stdin.flush()
def recv_framed(proc):
"""Read a framed response, skipping any banner text the signer writes
to stdout before the first frame. The banner is line-based ASCII; a
valid frame starts with a 4-byte big-endian length followed by '{'."""
# Read 4 bytes at a time, sliding window, until we find a frame header.
buf = b""
while True:
b = proc.stdout.read(1)
if not b:
return None
buf = (buf + b)[-4:]
if len(buf) < 4:
continue
(length,) = struct.unpack(">I", buf)
# Sanity: frame length should be reasonable (1..1MB) and the next
# byte after the header should be '{' (start of JSON).
if 1 <= length <= 1024 * 1024:
# Peek: read one more byte to check for '{'.
peek = proc.stdout.read(1)
if peek == b'{':
body = peek + proc.stdout.read(length - 1)
return json.loads(body.decode())
else:
# Not a frame; prepend peek to the stream by including it
# in the sliding window.
buf = (buf + peek)[-4:]
# Otherwise keep scanning.
def main():
if not os.path.isfile(NSIGNER):
print(f"ERROR: {NSIGNER} not found. Run 'make dev' first.")
return 1
with open(MNEMONIC_FILE) as f:
mnemonic = f.read().strip()
plaintext = b"Hello, OTP world!"
pt_b64 = base64.b64encode(plaintext).decode()
print(f"Plaintext: {plaintext.decode()}")
print(f"Plaintext base64: {pt_b64}")
# Write the mnemonic to a fixed temp file and pass it as fd 3 to nsigner
# via a bash wrapper (so stdin stays free for framed requests).
mnem_path = ".test_mnemonic_otp_roundtrip.tmp"
with open(mnem_path, "w") as f:
f.write(mnemonic + "\n")
def run_one_request(req_obj):
"""Run nsigner in stdio mode for a single framed request/response.
Returns the parsed JSON response or None."""
shell_cmd = (
f"exec 3<{mnem_path}; "
f"exec {NSIGNER} --listen stdio --mnemonic-fd 3 "
f"--otp-pad-dir {PAD_DIR} --otp-pad {PAD_SPEC} "
f"--otp-allow-blkback --allow-all"
)
proc = subprocess.Popen(
["bash", "-c", shell_cmd],
stdin=subprocess.PIPE, stdout=subprocess.PIPE, stderr=subprocess.PIPE,
text=False,
)
time.sleep(1.5) # let the signer bind the pad and be ready
if proc.poll() is not None:
err = proc.stderr.read().decode()
print(f"ERROR: nsigner exited early (code {proc.returncode})")
print(f"stderr: {err}")
return None
send_framed(proc, req_obj)
resp = recv_framed(proc)
proc.stdin.close()
try:
proc.wait(timeout=5)
except subprocess.TimeoutExpired:
proc.kill()
return resp
# --- otp_encrypt ---
print("\n=== Sending otp_encrypt ===")
resp = run_one_request({
"id": "1",
"method": "otp_encrypt",
"params": [pt_b64, {"encoding": "ascii"}],
})
print(f"Encrypt response: {json.dumps(resp)}")
if resp is None or "result" not in resp:
print("ERROR: no result in encrypt response")
try:
os.unlink(mnem_path)
except OSError:
pass
return 1
result_obj = json.loads(resp["result"])
ciphertext = result_obj["ciphertext"]
off_before = result_obj["pad_offset_before"]
off_after = result_obj["pad_offset_after"]
print(f"Pad offset: {off_before} -> {off_after} "
f"(consumed {off_after - off_before} bytes)")
print(f"Ciphertext (first 80 chars): {ciphertext[:80]}...")
# --- otp_decrypt (separate invocation; offset persists in .state) ---
print("\n=== Sending otp_decrypt ===")
resp2 = run_one_request({
"id": "2",
"method": "otp_decrypt",
"params": [ciphertext, {"encoding": "ascii"}],
})
print(f"Decrypt response: {json.dumps(resp2)}")
if resp2 is None or "result" not in resp2:
print("ERROR: no result in decrypt response")
return 1
result2 = json.loads(resp2["result"])
recovered_b64 = result2["plaintext"]
recovered = base64.b64decode(recovered_b64)
print(f"\nRecovered plaintext: {recovered.decode()}")
if recovered == plaintext:
print("\n=== ASCII ROUND-TRIP SUCCESS ===")
else:
print("\n=== ASCII ROUND-TRIP FAILED ===")
print(f"Expected: {plaintext.decode()}")
print(f"Got: {recovered.decode()}")
return 1
# --- Binary encoding round-trip ---
# Reset the pad offset for a clean binary test.
state_path = (f"{PAD_DIR}/{result_obj['pad_chksum']}.state")
with open(state_path, "w") as sf:
sf.write("offset=32\n")
print("\n=== Sending otp_encrypt (binary) ===")
resp3 = run_one_request({
"id": "3",
"method": "otp_encrypt",
"params": [pt_b64, {"encoding": "binary"}],
})
print(f"Binary encrypt response: {json.dumps(resp3)}")
if resp3 is None or "result" not in resp3:
print("ERROR: no result in binary encrypt response")
return 1
result3 = json.loads(resp3["result"])
bin_b64 = result3["ciphertext"]
# The binary ciphertext is base64-encoded in the JSON result.
bin_blob = base64.b64decode(bin_b64)
print(f"Binary blob size: {len(bin_blob)} bytes "
f"(header 58 + padded data {len(bin_blob) - 58})")
if not bin_blob[:4] == b"OTP\0":
print("ERROR: binary blob missing OTP magic")
return 1
print("\n=== Sending otp_decrypt (binary) ===")
resp4 = run_one_request({
"id": "4",
"method": "otp_decrypt",
"params": [bin_b64, {"encoding": "binary"}],
})
print(f"Binary decrypt response: {json.dumps(resp4)}")
if resp4 is None or "result" not in resp4:
print("ERROR: no result in binary decrypt response")
return 1
result4 = json.loads(resp4["result"])
recovered2 = base64.b64decode(result4["plaintext"])
print(f"\nRecovered plaintext (binary path): {recovered2.decode()}")
if recovered2 == plaintext:
print("\n=== BINARY ROUND-TRIP SUCCESS ===")
return 0
else:
print("\n=== BINARY ROUND-TRIP FAILED ===")
print(f"Expected: {plaintext.decode()}")
print(f"Got: {recovered2.decode()}")
return 1
if __name__ == "__main__":
sys.exit(main())