v0.1.3 - Fixed Teensy 4.1 ed25519 sign/verify stack overflow: moved ed_add's 12 gf arrays (1536B), ed_frombytes' 9 gf arrays (1152B), sc_reduce/sc_muladd int64_t x[64] (512B), and ed25519_sign/verify SHA-512 ctx (328B) from DTCM stack to DMAMEM (RAM2) workspace; added persistent crash diagnostics (DMAMEM op marker + stack/heap measurement); full classical+Nostr suite now passes 16/16 in one uninterrupted boot (get_info, secp256k1/ed25519/x25519 pubkeys, schnorr+ecdsa sign/verify, ed25519 sign/verify, x25519 shared secret, derive, nostr pubkey/sign_event, nip04+nip44 round-trips)

This commit is contained in:
Laan Tungir
2026-07-27 16:34:02 -04:00
parent 9282e22b00
commit ea2f1cd78e
5 changed files with 330 additions and 23 deletions
+50
View File
@@ -355,6 +355,43 @@ static int run_boot_flow() {
// output here does NOT corrupt the framed transport stream. They print the
// CrashReport from any prior hard fault (fault type, PC, LR, SP) plus the
// last-action marker so we can diagnose the get_public_key crash.
// Persistent operation marker (DMAMEM survives soft reboot). Stamped before
// each verb's crypto work so a post-crash reboot reports which op faulted.
DMAMEM volatile uint32_t g_last_op = 0;
DMAMEM volatile uint32_t g_last_op_seq = 0;
DMAMEM volatile uint32_t g_stack_min = 0xFFFFFFFFu;
DMAMEM volatile uint32_t g_heap_free_at_crash = 0;
#define OP_ID_GET_INFO 1
#define OP_ID_GPK 2
#define OP_ID_SIGN 3
#define OP_ID_VERIFY 4
#define OP_ID_DERIVE_SHARED 5
#define OP_ID_DERIVE 6
#define OP_ID_NOSTR_GPK 7
#define OP_ID_NOSTR_SIGN 8
#define OP_ID_NOSTR_MINE 9
#define OP_ID_NIP04 10
#define OP_ID_NIP44 11
#define OP_ID_ENCAPS 12
#define OP_ID_DECAPS 13
#define OP_ID_OTP 14
extern char _estack;
extern char _ebss;
static uint32_t stack_free_bytes(void) {
uint32_t sp;
asm volatile ("mov %0, sp\n" : "=r"(sp));
uint32_t bot = (uint32_t)&_ebss;
return (sp > bot) ? (sp - bot) : 0;
}
static uint32_t stack_high_water_free(void) {
uint32_t top = (uint32_t)&_estack;
return (g_stack_min <= top) ? (top - g_stack_min) : 0;
}
void setup() {
Serial.begin(115200);
while (!Serial && millis() < 3000) ;
@@ -372,6 +409,19 @@ void setup() {
CrashReport.clear();
}
// Report the last operation marker from before the crash.
if (g_last_op != 0) {
Serial.print("LAST OP BEFORE CRASH: id=");
Serial.print((int)g_last_op);
Serial.print(" seq=");
Serial.print((int)g_last_op_seq);
Serial.print(" stack_hw_free=");
Serial.print((int)stack_high_water_free());
Serial.print(" heap_free_at_crash=");
Serial.print((int)g_heap_free_at_crash);
Serial.println();
}
Serial.println("n_signer booting...");
tft.init(320, 480);
tft.setRotation(1);
+52
View File
@@ -70,6 +70,34 @@ char g_npub[128];
char g_pubkey_hex[65];
int g_signer_ready = 0;
/* ---- Persistent crash diagnostics (defined in signer.ino, DMAMEM) ---- */
extern "C" volatile uint32_t g_last_op;
extern "C" volatile uint32_t g_last_op_seq;
extern "C" volatile uint32_t g_stack_min;
extern "C" volatile uint32_t g_heap_free_at_crash;
extern "C" char _estack;
extern "C" char _ebss;
/* Stamp the operation marker and record stack/heap state before a verb's
* crypto work. If the device faults, the next boot reads these DMAMEM
* values and reports which op crashed and how close the stack was. */
static void stamp_op(uint32_t op_id) {
g_last_op_seq++;
g_last_op = op_id;
uint32_t sp;
asm volatile ("mov %0, sp\n" : "=r"(sp));
if (sp < g_stack_min) g_stack_min = sp;
/* Record a rough heap-free estimate: probe by allocating a 1-byte block
* and capturing the returned address relative to the heap start symbol.
* On Teensy the sbrk heap lives between _heap_start and _heap_end. */
extern char _heap_start[];
void *p = malloc(1);
if (p) {
g_heap_free_at_crash = (uint32_t)((char *)p - _heap_start);
free(p);
}
}
/* ====================================================================
* Constants
* ==================================================================== */
@@ -1013,6 +1041,30 @@ __attribute__((section(".flashmem"))) static void handle_request(cJSON *req, con
cJSON *params = cJSON_GetObjectItemCaseSensitive(req, "params");
char key_id[17];
/* Stamp the operation marker for post-crash diagnostics. Map the verb
* name to a compact id so the next boot can report which op faulted. */
{
uint32_t op = 0;
if (strcmp(method, VERB_GET_INFO) == 0) op = 1;
else if (strcmp(method, VERB_GET_PUBLIC_KEY) == 0) op = 2;
else if (strcmp(method, VERB_SIGN) == 0) op = 3;
else if (strcmp(method, VERB_VERIFY) == 0) op = 4;
else if (strcmp(method, VERB_DERIVE_SHARED) == 0) op = 5;
else if (strcmp(method, VERB_DERIVE) == 0) op = 6;
else if (strcmp(method, VERB_NOSTR_GET_PUBLIC_KEY) == 0) op = 7;
else if (strcmp(method, VERB_NOSTR_SIGN_EVENT) == 0) op = 8;
else if (strcmp(method, VERB_NOSTR_MINE_EVENT) == 0) op = 9;
else if (strcmp(method, VERB_NOSTR_NIP04_ENCRYPT) == 0 ||
strcmp(method, VERB_NOSTR_NIP04_DECRYPT) == 0) op = 10;
else if (strcmp(method, VERB_NOSTR_NIP44_ENCRYPT) == 0 ||
strcmp(method, VERB_NOSTR_NIP44_DECRYPT) == 0) op = 11;
else if (strcmp(method, VERB_ENCAPSULATE) == 0) op = 12;
else if (strcmp(method, VERB_DECAPSULATE) == 0) op = 13;
else if (strcmp(method, VERB_ENCRYPT) == 0 ||
strcmp(method, VERB_DECRYPT) == 0) op = 14;
if (op) stamp_op(op);
}
/* ===================== get_info (no key material, no approval) ====== */
if (strcmp(method, VERB_GET_INFO) == 0) {
(void)params;
+76 -21
View File
@@ -30,6 +30,14 @@
#define FLASHMEM_ATTR __attribute__((section(".flashmem")))
#endif
/* DMAMEM attribute for large working buffers that must live in RAM2
* (.dmabuffers) instead of the DTCM stack, to avoid stack overflow on
* the Teensy 4.1 (~9.6 KB free stack). Used by ed_frombytes, sc_reduce,
* sc_muladd, and the SHA-512 streaming ctx. */
#ifndef ED_DMAMEM
#define ED_DMAMEM __attribute__((section(".dmabuffers")))
#endif
typedef int64_t gf[16];
/* Constant 121665 as a field element (low limb only). Declared early so the
@@ -331,12 +339,22 @@ typedef struct { gf p[4]; } ed_p3;
typedef struct { gf p[3]; } ed_p2;
typedef struct { gf p[2]; } ed_p1;
/* DMAMEM workspace for ed_add (12 gf arrays = 1536 B on stack otherwise,
* called 256x per scalarmult -> overflows the 9.6 KB DTCM stack). */
ED_DMAMEM static int64_t ed_add_a0[16], ed_add_a1[16], ed_add_b0[16],
ed_add_b1[16], ed_add_c0[16], ed_add_c1[16],
ed_add_d0[16], ed_add_d1[16], ed_add_e[16],
ed_add_f[16], ed_add_g[16], ed_add_h[16];
FLASHMEM_ATTR static void ed_add(ed_p3 *o, const ed_p3 *a, const ed_p3 *b) {
/* Unified extended-coordinates addition for the twisted Edwards curve
* -x^2 + y^2 = 1 + d x^2 y^2 (a = -1). This formula is also valid when
* a == b, so ed_double() simply calls ed_add(o, a, a). All reads of the
* inputs happen into locals before any write to o, so o may alias a/b. */
gf a0, a1, b0, b1, c0, c1, d0, d1, e, f, g, h;
/* Use DMAMEM gf workspace (12 arrays = 1536 B) to avoid stack overflow. */
int64_t *a0 = ed_add_a0, *a1 = ed_add_a1, *b0 = ed_add_b0, *b1 = ed_add_b1,
*c0 = ed_add_c0, *c1 = ed_add_c1, *d0 = ed_add_d0, *d1 = ed_add_d1,
*e = ed_add_e, *f = ed_add_f, *g = ed_add_g, *h = ed_add_h;
int i;
FOR(i, 16) {
a0[i] = a->p[1][i] - a->p[0][i]; /* Y1 - X1 */
@@ -413,6 +431,14 @@ FLASHMEM_ATTR static void ed_p3_tobytes(uint8_t *o, const ed_p3 *p) {
o[31] ^= (uint8_t)((xb[0] & 1) << 7);
}
/* DMAMEM gf workspace for ed_frombytes (9 gf arrays = 1152 B on stack
* otherwise, which overflows during ed25519_verify). The gf type is
* int64_t[16]; we declare the DMAMEM arrays and access them through
* pointers so the functions that take `gf` (int64_t*) work unchanged. */
ED_DMAMEM static int64_t ed_fb_x[16], ed_fb_y[16], ed_fb_z[16], ed_fb_u[16],
ed_fb_v[16], ed_fb_v3[16], ed_fb_uv7[16],
ed_fb_w[16], ed_fb_c[16];
FLASHMEM_ATTR static int ed_frombytes(ed_p3 *p, const uint8_t *n) {
/* Decode a 32-byte point encoding (RFC 8032 section 5.1.3). Curve:
* -x^2 + y^2 = 1 + d x^2 y^2, with u = y^2 - 1, v = d y^2 + 1, so
@@ -420,7 +446,11 @@ FLASHMEM_ATTR static int ed_frombytes(ed_p3 *p, const uint8_t *n) {
* x = u * v^3 * (u * v^7)^((p-5)/8)
* which equals (u/v)^((p+3)/8) up to a 4th root of unity, then fix up
* the sign of the root with I = sqrt(-1) and the sign bit of n. */
gf x, y, z, u, v, v3, uv7, w, c;
/* Use DMAMEM gf workspace to avoid 1152 B on the stack.
* gf is int64_t[16]; use int64_t* pointers to the DMAMEM arrays. */
int64_t *x = ed_fb_x, *y = ed_fb_y, *z = ed_fb_z, *u = ed_fb_u,
*v = ed_fb_v, *v3 = ed_fb_v3, *uv7 = ed_fb_uv7,
*w = ed_fb_w, *c = ed_fb_c;
int i, a;
unpack25519(y, n);
FOR(i, 16) {
@@ -500,6 +530,16 @@ static const uint64_t ed_L[32] = {
0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0x10
};
/* ---- DMAMEM workspace for large ed25519 temporaries ----
* The Teensy 4.1 has only ~9.6 KB of free DTCM stack. The ed25519 sign/verify
* call chain needs several 512-byte int64_t x[64] buffers (sc_reduce,
* sc_muladd) plus SHA-512 contexts, which overflow the stack when nested
* inside handle_request -> ed25519_sign. Moving these to a persistent DMAMEM
* (RAM2) workspace eliminates the stack pressure. The signer is
* single-threaded so no locking is needed. */
ED_DMAMEM static int64_t ed_sc_x[64]; /* sc_reduce / sc_muladd working buffer (512 B) */
ED_DMAMEM static uint8_t ed_sha512ctx_buf[sizeof(ed_sha512ctx)]; /* SHA-512 streaming ctx (328 B) */
/* Reduce the 64-limb little-endian integer x[0..63] mod L into r[0..31].
* TweetNaCl modL, verbatim. */
FLASHMEM_ATTR static void sc_modL(uint8_t *r, int64_t x[64]) {
@@ -529,19 +569,23 @@ FLASHMEM_ATTR static void sc_modL(uint8_t *r, int64_t x[64]) {
/* Reduce a 64-byte little-endian integer mod L into 32 bytes. */
FLASHMEM_ATTR static void sc_reduce(uint8_t *o, const uint8_t s[64]) {
int64_t x[64], i;
int64_t i;
int64_t *x = ed_sc_x;
FOR(i, 64) x[i] = (uint64_t) s[i];
sc_modL(o, x);
FOR(i, 64) x[i] = 0; /* zeroize sensitive working buffer */
}
/* o = (a * b + c) mod L. Builds the 64-byte little-endian product+sum then
* reduces via sc_modL (TweetNaCl crypto_sign style). */
FLASHMEM_ATTR static void sc_muladd(uint8_t *o, const uint8_t a[32], const uint8_t b[32], const uint8_t c[32]) {
int64_t x[64], i, j;
int64_t i, j;
int64_t *x = ed_sc_x;
FOR(i, 64) x[i] = 0;
FOR(i, 32) x[i] = (uint64_t) c[i];
FOR(i, 32) FOR(j, 32) x[i + j] += (int64_t) a[i] * (uint64_t) b[j];
sc_modL(o, x);
FOR(i, 64) x[i] = 0; /* zeroize sensitive working buffer */
}
/* ====================================================================
@@ -572,12 +616,13 @@ FLASHMEM_ATTR int ed25519_sign(const uint8_t privkey[32],
az[31] &= 127;
az[31] |= 64;
/* r = SHA-512(az[32..64] || msg) mod L */
ed_sha512ctx ctx;
ed_sha512_init(&ctx);
ed_sha512_update(&ctx, az + 32, 32);
ed_sha512_update(&ctx, msg, msg_len);
ed_sha512_final(&ctx, nonce);
/* r = SHA-512(az[32..64] || msg) mod L.
* Use the DMAMEM SHA-512 ctx to avoid a 328-byte stack frame. */
ed_sha512ctx *ctx = (ed_sha512ctx *)ed_sha512ctx_buf;
ed_sha512_init(ctx);
ed_sha512_update(ctx, az + 32, 32);
ed_sha512_update(ctx, msg, msg_len);
ed_sha512_final(ctx, nonce);
sc_reduce(r, nonce);
/* R = r * B */
@@ -591,15 +636,23 @@ FLASHMEM_ATTR int ed25519_sign(const uint8_t privkey[32],
ed_p3_tobytes(sig + 32, &A);
/* k = SHA-512(R || A || msg) mod L */
ed_sha512_init(&ctx);
ed_sha512_update(&ctx, sig, 32);
ed_sha512_update(&ctx, sig + 32, 32);
ed_sha512_update(&ctx, msg, msg_len);
ed_sha512_final(&ctx, hram);
ed_sha512_init(ctx);
ed_sha512_update(ctx, sig, 32);
ed_sha512_update(ctx, sig + 32, 32);
ed_sha512_update(ctx, msg, msg_len);
ed_sha512_final(ctx, hram);
sc_reduce(k, hram);
/* S = (r + k * a) mod L */
sc_muladd(sig + 32, k, az, r);
/* Zeroize sensitive locals */
memset(az, 0, sizeof(az));
memset(nonce, 0, sizeof(nonce));
memset(hram, 0, sizeof(hram));
memset(r, 0, sizeof(r));
memset(k, 0, sizeof(k));
memset(ed_sha512ctx_buf, 0, sizeof(ed_sha512ctx_buf));
return 0;
}
@@ -627,13 +680,15 @@ FLASHMEM_ATTR int ed25519_verify(const uint8_t sig[64],
if (ed_frombytes(&A, pubkey) != 0) return 0;
ed_sha512ctx ctx;
ed_sha512_init(&ctx);
ed_sha512_update(&ctx, sig, 32);
ed_sha512_update(&ctx, pubkey, 32);
ed_sha512_update(&ctx, msg, msg_len);
ed_sha512_final(&ctx, hram);
/* Use the DMAMEM SHA-512 ctx to avoid a 328-byte stack frame. */
ed_sha512ctx *ctx = (ed_sha512ctx *)ed_sha512ctx_buf;
ed_sha512_init(ctx);
ed_sha512_update(ctx, sig, 32);
ed_sha512_update(ctx, pubkey, 32);
ed_sha512_update(ctx, msg, msg_len);
ed_sha512_final(ctx, hram);
sc_reduce(k, hram);
memset(ed_sha512ctx_buf, 0, sizeof(ed_sha512ctx_buf));
/* RFC 8032: verify S*B == R + k*A, i.e. S*B - k*A == R. We compute
* k*A, negate it ((X,Y,Z,T) -> (-X, Y, Z, -T)), add S*B, and compare
+150
View File
@@ -0,0 +1,150 @@
#!/usr/bin/env python3
"""Classical-only Teensy 4.1 n_signer test (no OTP, no PQ).
Runs the full classical + Nostr sequence in one uninterrupted boot and
prints exactly which verb succeeded and which one crashed the device.
After a crash, re-run to read the CrashReport from the next boot.
Usage:
python3 firmware/teensy41/test_classical.py [--port /dev/ttyACM0]
"""
import serial, struct, json, time, sys, argparse, hashlib
DEFAULT_PORT = "/dev/ttyACM0"
BAUD = 115200
def send_request(ser, req):
payload = json.dumps(req).encode("utf-8")
ser.write(struct.pack(">I", len(payload)) + payload)
ser.flush()
h = b""
deadline = time.time() + 30.0
while len(h) < 4 and time.time() < deadline:
c = ser.read(4 - len(h))
if c: h += c
else: time.sleep(0.01)
if len(h) < 4: raise TimeoutError("hdr timeout")
n = struct.unpack(">I", h)[0]
if n == 0 or n > 65536: raise ValueError("bad len %d" % n)
p = b""
while len(p) < n:
c = ser.read(n - len(p))
if c: p += c
else: time.sleep(0.01)
return json.loads(p.decode())
def call(ser, method, params=None, idx=[0]):
idx[0] += 1
r = {"jsonrpc": "2.0", "id": idx[0], "method": method}
if params is not None: r["params"] = params
print(f" -> {method} {json.dumps(params) if params else '[]'}", flush=True)
resp = send_request(ser, r)
ok = "result" in resp
print(f" <- {'OK' if ok else 'ERR'} {str(resp)[:160]}", flush=True)
return resp
def main():
ap = argparse.ArgumentParser()
ap.add_argument("--port", default=DEFAULT_PORT)
ap.add_argument("--read-crash", action="store_true", help="only drain and print boot/CrashReport text")
args = ap.parse_args()
ser = serial.Serial(args.port, BAUD, timeout=2.0)
time.sleep(5)
boot = b""
while ser.in_waiting:
boot += ser.read(ser.in_waiting)
if boot:
print("=== BOOT OUTPUT ===")
print(boot.decode("utf-8", errors="replace"))
print("=== END BOOT ===")
if args.read_crash:
ser.close(); return 0
passed = 0; failed = 0
def t(name, fn):
nonlocal passed, failed
print(f"\n[{name}]", flush=True)
try:
fn()
passed += 1
except Exception as e:
print(f" !! CRASH at {name}: {e}", flush=True)
failed += 1
raise
try:
t("get_info", lambda: call(ser, "get_info"))
# classical pubkeys
pubkeys = {}
def gpk(alg):
r = call(ser, "get_public_key", [{"algorithm": alg, "index": 0}])
pubkeys[alg] = r["result"]["public_key"]
t("gpk secp256k1", lambda: gpk("secp256k1"))
t("gpk ed25519", lambda: gpk("ed25519"))
t("gpk x25519", lambda: gpk("x25519"))
# secp256k1 schnorr sign + verify
msg = hashlib.sha256(b"test schnorr").hexdigest()
sig = [None]
def schnorr_sign():
r = call(ser, "sign", [msg, {"algorithm": "secp256k1", "index": 0, "scheme": "schnorr"}])
sig[0] = r["result"]["signature"]
t("sign schnorr", schnorr_sign)
def schnorr_verify():
call(ser, "verify", [msg, sig[0], {"algorithm": "secp256k1", "index": 0, "scheme": "schnorr"}])
t("verify schnorr", schnorr_verify)
# secp256k1 ecdsa sign + verify
msg2 = hashlib.sha256(b"test ecdsa").hexdigest()
sig2 = [None]
def ecdsa_sign():
r = call(ser, "sign", [msg2, {"algorithm": "secp256k1", "index": 0, "scheme": "ecdsa"}])
sig2[0] = r["result"]["signature"]
t("sign ecdsa", ecdsa_sign)
def ecdsa_verify():
call(ser, "verify", [msg2, sig2[0], {"algorithm": "secp256k1", "index": 0, "scheme": "ecdsa"}])
t("verify ecdsa", ecdsa_verify)
# ed25519 sign + verify
msg3 = b"test ed25519 message".hex()
sig3 = [None]
def ed_sign():
r = call(ser, "sign", [msg3, {"algorithm": "ed25519", "index": 0}])
sig3[0] = r["result"]["signature"]
t("sign ed25519", ed_sign)
def ed_verify():
call(ser, "verify", [msg3, sig3[0], {"algorithm": "ed25519", "index": 0}])
t("verify ed25519", ed_verify)
# x25519 shared secret
def x25519():
call(ser, "derive_shared_secret", [pubkeys["x25519"], {"algorithm": "x25519", "index": 0}])
t("derive_shared_secret x25519", x25519)
# derive (HMAC)
t("derive", lambda: call(ser, "derive", ["derive-test", {"algorithm": "secp256k1", "index": 1}]))
# nostr
npub = [None]
def ngpk():
r = call(ser, "nostr_get_public_key", [{"nostr_index": 0}])
npub[0] = r["result"]
t("nostr_get_public_key", ngpk)
def nse():
ev = {"kind": 1, "created_at": int(time.time()), "tags": [], "content": "hello event"}
call(ser, "nostr_sign_event", [ev, {"nostr_index": 0}])
t("nostr_sign_event", nse)
# nip04
def nip04():
r = call(ser, "nostr_nip04_encrypt", [npub[0], "hello via nip04", {"nostr_index": 0}])
call(ser, "nostr_nip04_decrypt", [npub[0], r["result"], {"nostr_index": 0}])
t("nip04 round-trip", nip04)
# nip44
def nip44():
r = call(ser, "nostr_nip44_encrypt", [npub[0], "hello via nip44", {"nostr_index": 0}])
call(ser, "nostr_nip44_decrypt", [npub[0], r["result"], {"nostr_index": 0}])
t("nip44 round-trip", nip44)
except Exception as e:
print(f"\n!! STOPPED: {e}", flush=True)
print(f"\nRESULTS: {passed} passed, {failed} failed")
ser.close()
return 0 if failed == 0 else 1
if __name__ == "__main__":
sys.exit(main())
+2 -2
View File
@@ -762,8 +762,8 @@ int socket_name_random(char *out, size_t out_len);
/* Version information (auto-updated by build/version tooling) */
#define NSIGNER_VERSION_MAJOR 0
#define NSIGNER_VERSION_MINOR 1
#define NSIGNER_VERSION_PATCH 2
#define NSIGNER_VERSION "v0.1.2"
#define NSIGNER_VERSION_PATCH 3
#define NSIGNER_VERSION "v0.1.3"
/* NSIGNER_HEADERLESS_DECLS_END */