Skip to content

Commit 79c471b

Browse files
Track 1: constant-time crypto FFI, memory zeroing, PSK resumption
Practical-security hardening for production readiness: - 1a: sml-crypto-ffi shim wrapping libsodium (X25519, ChaCha20-Poly1305) via MLton _import and Poly/ML Foreign. Cross-impl tests assert byte-identical output to the pure-SML oracles on RFC 8439 / RFC 7748. FFI path lives behind dedicated targets; default suite stays pure-SML. - 1b: SecureZero (sodium_memzero / pure fallback) and TlsClient.zeroize / TlsServer.zeroize / zeroizeConfig to overwrite traffic keys, secrets, and rsaPrivateKeyDer. Best-effort given SML string immutability (documented). - 1c: server-side PSK resumption accept path (pure SML): in-memory ticket store, binder verification over the truncated ClientHello, selected_identity echo, PSK key schedule, Cert/CertVerify omitted on resume. Binder mismatch -> fatal illegal_parameter. Both compilers green: 306 passed, 0 failed (was 280). Co-authored-by: Cursor <cursoragent@cursor.com>
1 parent d86bf37 commit 79c471b

20 files changed

Lines changed: 1457 additions & 34 deletions

Makefile

Lines changed: 45 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,31 @@
11
# sml-tls build
22
MLTON ?= mlton
3+
CC ?= clang
34
BIN := bin
45
LIBDIR := lib/github.com/sjqtentacles/sml-tls
6+
FFIDIR := lib/github.com/sjqtentacles/sml-crypto-ffi
57
TEST_MLB := test/sources.mlb
68
SRCS := $(wildcard $(LIBDIR)/*.sml $(LIBDIR)/*.sig) \
79
$(wildcard test/*.sml) $(TEST_MLB) $(LIBDIR)/sources.mlb
810

9-
.PHONY: all test poly test-poly all-tests clean
11+
# --- Track 1a/1b: libsodium constant-time crypto FFI shim ---
12+
# libsodium (Homebrew default on Apple Silicon). Override on other systems,
13+
# e.g. SODIUM_PREFIX=/usr or =/usr/local.
14+
SODIUM_PREFIX ?= /opt/homebrew
15+
SODIUM_INC := $(SODIUM_PREFIX)/include
16+
SODIUM_LIB := $(SODIUM_PREFIX)/lib
17+
# Shared-library extension: .dylib on macOS, .so elsewhere.
18+
ifeq ($(shell uname -s),Darwin)
19+
SHLIB_EXT := dylib
20+
else
21+
SHLIB_EXT := so
22+
endif
23+
FFI_SHIM := $(BIN)/libsmlcryptoffi.$(SHLIB_EXT)
24+
# MLton link flags to resolve the shim's _import symbols against the dylib
25+
# (and libsodium itself).
26+
FFI_LINK := -link-opt "-L$(BIN) -L$(SODIUM_LIB) -lsmlcryptoffi -lsodium -Wl,-rpath,$(BIN) -Wl,-rpath,$(SODIUM_LIB)"
27+
28+
.PHONY: all test poly test-poly all-tests ffi-shim test-ffi test-ffi-poly test-ffi-all clean
1029

1130
all: $(BIN)/test-mlton
1231

@@ -26,6 +45,31 @@ test-poly: $(BIN)/test-poly
2645

2746
all-tests: test test-poly
2847

48+
# --- FFI shim (Track 1a/1b) ---------------------------------------------
49+
# Build the libsodium constant-time crypto shim into a shared library that
50+
# MLton links against and Poly/ML loads at runtime via Foreign.loadLibrary.
51+
ffi-shim: $(FFI_SHIM)
52+
53+
$(FFI_SHIM): $(FFIDIR)/shim.c | $(BIN)
54+
$(CC) -O2 -fPIC -shared -I$(SODIUM_INC) $< \
55+
-L$(SODIUM_LIB) -lsodium -o $@
56+
57+
# MLton FFI test: byte-identity (shim == pure-SML oracle) on RFC vectors.
58+
test-ffi: $(FFI_SHIM) $(SRCS) test/sources-ffi-mlton.mlb \
59+
$(wildcard $(FFIDIR)/*.sml $(FFIDIR)/*.sig) | $(BIN)
60+
$(MLTON) -default-ann 'allowFFI true' $(FFI_LINK) \
61+
-output $(BIN)/test-ffi-mlton test/sources-ffi-mlton.mlb
62+
$(BIN)/test-ffi-mlton
63+
64+
# Poly/ML FFI test: same suite via Foreign.loadLibrary on the shim.
65+
test-ffi-poly: $(FFI_SHIM) $(SRCS) test/sources-ffi-poly.mlb tools/polybuild \
66+
$(wildcard $(FFIDIR)/*.sml $(FFIDIR)/*.sig) | $(BIN)
67+
SML_CRYPTO_FFI_LIB=$(FFI_SHIM) \
68+
sh tools/polybuild -o $(BIN)/test-ffi-poly test/sources-ffi-poly.mlb
69+
SML_CRYPTO_FFI_LIB=$(FFI_SHIM) $(BIN)/test-ffi-poly
70+
71+
test-ffi-all: test-ffi test-ffi-poly
72+
2973
$(BIN):
3074
mkdir -p $(BIN)
3175

Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,57 @@
1+
(* crypto_ffi.sig
2+
3+
Constant-time crypto via an FFI shim over libsodium (Track 1a/1b).
4+
5+
This signature is implemented twice -- once with MLton's `_import`
6+
(crypto_ffi_mlton.sml) and once with Poly/ML's `Foreign` structure
7+
(crypto_ffi_poly.sml) -- so the same `CryptoFfi` structure is available
8+
on both compilers. Each build selects exactly one implementation file.
9+
10+
The interface intentionally mirrors the SHAPE of the pure-SML modules it
11+
replaces so it can be used as a near drop-in:
12+
13+
- `dh` / `base` match X25519.dh / X25519.base
14+
- `seal` / `open'` match Aead.seal / Aead.open' for
15+
ChaCha20-Poly1305 (record-form: ct||tag)
16+
- `memzero` best-effort secure wipe (Track 1b)
17+
18+
All byte material is RAW byte strings (one char per byte, 0-255), the
19+
same convention as the rest of the sjqtentacles crypto family. *)
20+
21+
signature CRYPTO_FFI =
22+
sig
23+
(* Raised on a length/contract violation or an underlying libsodium error
24+
(distinct from an AEAD authentication failure, which is `NONE`). *)
25+
exception CryptoFfi of string
26+
27+
(* Idempotent libsodium initialisation. The implementations call this
28+
lazily on first use, but it is exposed for explicit warm-up/tests. *)
29+
val init : unit -> unit
30+
31+
(* X25519 (RFC 7748). `scalar` and `point` are 32-byte little-endian
32+
strings; the result is the 32-byte shared u-coordinate. libsodium
33+
clamps the scalar internally, matching X25519.dh. *)
34+
val dh : string -> string -> string
35+
36+
(* Public key: dh scalar 9. 32-byte little-endian result. *)
37+
val base : string -> string
38+
39+
(* 32, for symmetry with X25519.keySize. *)
40+
val keySize : int
41+
42+
structure ChaCha20Poly1305 :
43+
sig
44+
(* seal key nonce aad plaintext -> ciphertext || 16-byte tag.
45+
`key` is 32 bytes, `nonce` is 12 bytes. Raises CryptoFfi on a
46+
length violation. *)
47+
val seal : string -> string -> string -> string -> string
48+
49+
(* open' key nonce aad (ciphertext||tag) -> SOME plaintext | NONE.
50+
NONE iff the tag does not verify or the input is too short. *)
51+
val open' : string -> string -> string -> string -> string option
52+
end
53+
54+
(* Overwrite the bytes currently held by a Word8Array using libsodium's
55+
sodium_memzero, which the optimizer may not elide (Track 1b). *)
56+
val memzero : Word8Array.array -> unit
57+
end
Lines changed: 111 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,111 @@
1+
(* crypto_ffi_mlton.sml
2+
3+
MLton implementation of CRYPTO_FFI using `_import` to call the libsodium
4+
shim (lib/.../sml-crypto-ffi/shim.c). The shim object/library is linked
5+
into the executable by the Makefile via `-link-opt`.
6+
7+
This file uses MLton-only `_import` syntax and is NOT loaded under
8+
Poly/ML (which uses crypto_ffi_poly.sml instead). *)
9+
10+
structure CryptoFfi :> CRYPTO_FFI =
11+
struct
12+
exception CryptoFfi of string
13+
14+
val keySize = 32
15+
16+
(* --- libsodium shim imports (see shim.c) --- *)
17+
val cInit = _import "sml_sodium_init" public : unit -> int;
18+
val cX25519 = _import "sml_x25519" public :
19+
Word8Array.array * Word8Array.array * Word8Array.array -> int;
20+
val cX25519Base = _import "sml_x25519_base" public :
21+
Word8Array.array * Word8Array.array -> int;
22+
val cSeal = _import "sml_chacha20poly1305_seal" public :
23+
Word8Array.array * Word8Array.array * int
24+
* Word8Array.array * int * Word8Array.array * Word8Array.array -> int;
25+
val cOpen = _import "sml_chacha20poly1305_open" public :
26+
Word8Array.array * Word8Array.array * int
27+
* Word8Array.array * int * Word8Array.array * Word8Array.array -> int;
28+
val cMemzero = _import "sml_memzero" public :
29+
Word8Array.array * int -> unit;
30+
31+
(* --- byte string <-> Word8Array marshalling --- *)
32+
fun toArr (s : string) : Word8Array.array =
33+
Word8Array.tabulate (String.size s,
34+
fn i => Byte.charToByte (String.sub (s, i)))
35+
36+
fun fromArr (a : Word8Array.array) : string =
37+
CharVector.tabulate (Word8Array.length a,
38+
fn i => Byte.byteToChar (Word8Array.sub (a, i)))
39+
40+
val initialised = ref false
41+
fun init () =
42+
if !initialised then ()
43+
else
44+
let val r = cInit () in
45+
if r < 0 then raise CryptoFfi "sodium_init failed"
46+
else initialised := true
47+
end
48+
49+
fun checkLen (what, s, n) =
50+
if String.size s <> n then
51+
raise CryptoFfi (what ^ " must be " ^ Int.toString n ^ " bytes, got "
52+
^ Int.toString (String.size s))
53+
else ()
54+
55+
fun dh scalar point =
56+
( init ()
57+
; checkLen ("scalar", scalar, 32)
58+
; checkLen ("point", point, 32)
59+
; let
60+
val out = Word8Array.array (32, 0w0)
61+
val _ = cX25519 (out, toArr scalar, toArr point)
62+
(* A zero result (small-order point) returns -1; the pure-SML
63+
oracle just returns the zero output, so we mirror that and
64+
return the (zeroed) output rather than raising. *)
65+
in fromArr out end )
66+
67+
fun base scalar =
68+
( init ()
69+
; checkLen ("scalar", scalar, 32)
70+
; let
71+
val out = Word8Array.array (32, 0w0)
72+
val _ = cX25519Base (out, toArr scalar)
73+
in fromArr out end )
74+
75+
structure ChaCha20Poly1305 =
76+
struct
77+
fun seal key nonce aad plaintext =
78+
( init ()
79+
; checkLen ("key", key, 32)
80+
; checkLen ("nonce", nonce, 12)
81+
; let
82+
val m = toArr plaintext
83+
val ad = toArr aad
84+
val out = Word8Array.array (String.size plaintext + 16, 0w0)
85+
val n = cSeal (out, m, String.size plaintext,
86+
ad, String.size aad, toArr nonce, toArr key)
87+
in
88+
if n < 0 then raise CryptoFfi "chacha20poly1305 seal failed"
89+
else fromArr out
90+
end )
91+
92+
fun open' key nonce aad sealed =
93+
( init ()
94+
; checkLen ("key", key, 32)
95+
; checkLen ("nonce", nonce, 12)
96+
; if String.size sealed < 16 then NONE
97+
else
98+
let
99+
val c = toArr sealed
100+
val ad = toArr aad
101+
val out = Word8Array.array (String.size sealed - 16, 0w0)
102+
val n = cOpen (out, c, String.size sealed,
103+
ad, String.size aad, toArr nonce, toArr key)
104+
in
105+
if n < 0 then NONE else SOME (fromArr out)
106+
end )
107+
end
108+
109+
fun memzero a =
110+
( init (); cMemzero (a, Word8Array.length a) )
111+
end
Lines changed: 137 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,137 @@
1+
(* crypto_ffi_poly.sml
2+
3+
Poly/ML implementation of CRYPTO_FFI using the `Foreign` structure to
4+
call the libsodium shim (lib/.../sml-crypto-ffi/shim.c), compiled to a
5+
shared library that this module loads at runtime via
6+
`Foreign.loadLibrary`.
7+
8+
This file uses Poly/ML-only `Foreign` and is NOT loaded under MLton
9+
(which uses crypto_ffi_mlton.sml instead).
10+
11+
Poly/ML's `cArrayPointer cUint8` marshals an SML `int array` to/from a
12+
C `unsigned char *`, so byte material crosses the boundary as `int
13+
array` (values 0-255). *)
14+
15+
structure CryptoFfi :> CRYPTO_FFI =
16+
struct
17+
exception CryptoFfi of string
18+
19+
val keySize = 32
20+
21+
(* Locate the shim shared library. The Makefile builds it next to the
22+
test binary as bin/libsmlcryptoffi.dylib; we also try a few common
23+
spots so tests run from the repo root work regardless of cwd. *)
24+
local
25+
open Foreign
26+
fun ext () =
27+
case OS.Process.getEnv "SML_CRYPTO_FFI_LIB" of
28+
SOME p => [p]
29+
| NONE => []
30+
val candidates =
31+
ext () @
32+
[ "bin/libsmlcryptoffi.dylib",
33+
"bin/libsmlcryptoffi.so",
34+
"./libsmlcryptoffi.dylib",
35+
"libsmlcryptoffi.dylib",
36+
"libsmlcryptoffi.so" ]
37+
fun tryLoad [] = raise CryptoFfi "could not load libsmlcryptoffi shim library"
38+
| tryLoad (p :: ps) =
39+
(loadLibrary p handle _ => tryLoad ps)
40+
val lib = tryLoad candidates
41+
val byteArr = cArrayPointer cUint8
42+
in
43+
val cInit = buildCall0 (getSymbol lib "sml_sodium_init", (), cInt)
44+
val cX25519 = buildCall3 (getSymbol lib "sml_x25519",
45+
(byteArr, byteArr, byteArr), cInt)
46+
val cX25519Base = buildCall2 (getSymbol lib "sml_x25519_base",
47+
(byteArr, byteArr), cInt)
48+
val cSeal = buildCall7 (getSymbol lib "sml_chacha20poly1305_seal",
49+
(byteArr, byteArr, cInt, byteArr, cInt, byteArr, byteArr), cInt)
50+
val cOpen = buildCall7 (getSymbol lib "sml_chacha20poly1305_open",
51+
(byteArr, byteArr, cInt, byteArr, cInt, byteArr, byteArr), cInt)
52+
val cMemzeroI = buildCall2 (getSymbol lib "sml_memzero",
53+
(byteArr, cInt), cVoid)
54+
end
55+
56+
(* --- byte string <-> int array (0-255) marshalling --- *)
57+
fun toArr (s : string) : int array =
58+
Array.tabulate (String.size s, fn i => Char.ord (String.sub (s, i)))
59+
60+
fun fromArr (a : int array) : string =
61+
CharVector.tabulate (Array.length a, fn i => Char.chr (Array.sub (a, i)))
62+
63+
val initialised = ref false
64+
fun init () =
65+
if !initialised then ()
66+
else
67+
let val r = cInit () in
68+
if r < 0 then raise CryptoFfi "sodium_init failed"
69+
else initialised := true
70+
end
71+
72+
fun checkLen (what, s, n) =
73+
if String.size s <> n then
74+
raise CryptoFfi (what ^ " must be " ^ Int.toString n ^ " bytes, got "
75+
^ Int.toString (String.size s))
76+
else ()
77+
78+
fun dh scalar point =
79+
( init ()
80+
; checkLen ("scalar", scalar, 32)
81+
; checkLen ("point", point, 32)
82+
; let
83+
val out = Array.array (32, 0)
84+
val _ = cX25519 (out, toArr scalar, toArr point)
85+
in fromArr out end )
86+
87+
fun base scalar =
88+
( init ()
89+
; checkLen ("scalar", scalar, 32)
90+
; let
91+
val out = Array.array (32, 0)
92+
val _ = cX25519Base (out, toArr scalar)
93+
in fromArr out end )
94+
95+
structure ChaCha20Poly1305 =
96+
struct
97+
fun seal key nonce aad plaintext =
98+
( init ()
99+
; checkLen ("key", key, 32)
100+
; checkLen ("nonce", nonce, 12)
101+
; let
102+
val out = Array.array (String.size plaintext + 16, 0)
103+
val n = cSeal (out, toArr plaintext, String.size plaintext,
104+
toArr aad, String.size aad, toArr nonce, toArr key)
105+
in
106+
if n < 0 then raise CryptoFfi "chacha20poly1305 seal failed"
107+
else fromArr out
108+
end )
109+
110+
fun open' key nonce aad sealed =
111+
( init ()
112+
; checkLen ("key", key, 32)
113+
; checkLen ("nonce", nonce, 12)
114+
; if String.size sealed < 16 then NONE
115+
else
116+
let
117+
val out = Array.array (String.size sealed - 16, 0)
118+
val n = cOpen (out, toArr sealed, String.size sealed,
119+
toArr aad, String.size aad, toArr nonce, toArr key)
120+
in
121+
if n < 0 then NONE else SOME (fromArr out)
122+
end )
123+
end
124+
125+
(* Word8Array path: copy into an int array, zero via the shim, then copy
126+
the zeros back so the caller's array is wiped too. (sodium_memzero on
127+
the temporary guarantees the value-zeroing is not elided.) *)
128+
fun memzero a =
129+
let
130+
val () = init ()
131+
val n = Word8Array.length a
132+
val tmp = Array.array (n, 0)
133+
val () = cMemzeroI (tmp, n)
134+
in
135+
Word8Array.modify (fn _ => 0w0) a
136+
end
137+
end

0 commit comments

Comments
 (0)