Add ChaCha20-Poly1305 AEAD, scrypt KDF, and build fixes

ober

92262b574f643e61ae4c3cae54ea76152ab72ed5

diff --git a/.gitignore b/.gitignore
index 05420ac..8692303 100644
--- a/.gitignore
+++ b/.gitignore
@@ -4,3 +4,12 @@
 *~
 \#*
 .#*
+
+# Rust build artifacts
+jerboa-native-rs/target/
+
+# Claude Code exports
+new.txt
+
+# External project stubs (belong in their own repos)
+lib/gerbil-litehtml/
diff --git a/jerboa-native-rs/Cargo.lock b/jerboa-native-rs/Cargo.lock
index 48fb89f..0e36b3b 100644
--- a/jerboa-native-rs/Cargo.lock
+++ b/jerboa-native-rs/Cargo.lock
@@ -47,6 +47,12 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6"
 
 [[package]]
+name = "base64ct"
+version = "1.8.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "2af50177e190e07a26ab74f8b1efbfe2ef87da2116221318cb1c2e82baf7de06"
+
+[[package]]
 name = "bitflags"
 version = "2.11.0"
 source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -96,6 +102,16 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801"
 
 [[package]]
+name = "cipher"
+version = "0.4.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "773f3b9af64447d2ce9850330c473515014aa235e6a783b02db81ff39e4a3dad"
+dependencies = [
+ "crypto-common",
+ "inout",
+]
+
+[[package]]
 name = "cpufeatures"
 version = "0.2.17"
 source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -289,6 +305,15 @@ dependencies = [
 ]
 
 [[package]]
+name = "inout"
+version = "0.1.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "879f10e63c20629ecabbb64a8010319738c66a5cd0c29b02d63d272b03751d01"
+dependencies = [
+ "generic-array",
+]
+
+[[package]]
 name = "jerboa-native"
 version = "0.1.0"
 dependencies = [
@@ -299,6 +324,7 @@ dependencies = [
  "regex",
  "ring",
  "rusqlite",
+ "scrypt",
 ]
 
 [[package]]
@@ -437,6 +463,27 @@ dependencies = [
 ]
 
 [[package]]
+name = "password-hash"
+version = "0.5.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "346f04948ba92c43e8469c1ee6736c7563d71012b17d40745260fe106aac2166"
+dependencies = [
+ "base64ct",
+ "rand_core 0.6.4",
+ "subtle",
+]
+
+[[package]]
+name = "pbkdf2"
+version = "0.12.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f8ed6a7761f76e3b9f92dfb0a60a6a6477c61024b775147ff0973a02653abaf2"
+dependencies = [
+ "digest",
+ "hmac",
+]
+
+[[package]]
 name = "percent-encoding"
 version = "2.3.2"
 source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -556,7 +603,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "6db2770f06117d490610c7488547d543617b21bfa07796d7a12f6f1bd53850d1"
 dependencies = [
  "rand_chacha",
- "rand_core",
+ "rand_core 0.9.5",
 ]
 
 [[package]]
@@ -566,7 +613,16 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "d3022b5f1df60f26e1ffddd6c66e8aa15de382ae63b3a0c1bfc0e4d3e3f325cb"
 dependencies = [
  "ppv-lite86",
- "rand_core",
+ "rand_core 0.9.5",
+]
+
+[[package]]
+name = "rand_core"
+version = "0.6.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c"
+dependencies = [
+ "getrandom 0.2.17",
 ]
 
 [[package]]
@@ -651,12 +707,33 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d"
 
 [[package]]
+name = "salsa20"
+version = "0.10.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "97a22f5af31f73a954c10289c93e8a50cc23d971e80ee446f1f6f7137a088213"
+dependencies = [
+ "cipher",
+]
+
+[[package]]
 name = "scopeguard"
 version = "1.2.0"
 source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49"
 
 [[package]]
+name = "scrypt"
+version = "0.11.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0516a385866c09368f0b5bcd1caff3366aace790fcd46e2bb032697bb172fd1f"
+dependencies = [
+ "password-hash",
+ "pbkdf2",
+ "salsa20",
+ "sha2",
+]
+
+[[package]]
 name = "serde"
 version = "1.0.228"
 source = "registry+https://github.com/rust-lang/crates.io-index"
diff --git a/jerboa-native-rs/Cargo.toml b/jerboa-native-rs/Cargo.toml
index 11ebdde..d42386b 100644
--- a/jerboa-native-rs/Cargo.toml
+++ b/jerboa-native-rs/Cargo.toml
@@ -8,6 +8,7 @@ crate-type = ["cdylib", "staticlib"]
 
 [dependencies]
 ring = "0.17"
+scrypt = "0.11"
 flate2 = "1"
 regex = "1"
 libc = "0.2"
@@ -19,3 +20,4 @@ inotify = { version = "0.11", default-features = false }
 lto = true
 codegen-units = 1
 strip = true
+panic = "abort"
diff --git a/jerboa-native-rs/src/crypto.rs b/jerboa-native-rs/src/crypto.rs
index 28c2f54..4b75776 100644
--- a/jerboa-native-rs/src/crypto.rs
+++ b/jerboa-native-rs/src/crypto.rs
@@ -259,6 +259,145 @@ pub extern "C" fn jerboa_aead_open(
     })
 }
 
+// --- AEAD (ChaCha20-Poly1305) ---
+
+#[no_mangle]
+pub extern "C" fn jerboa_chacha20_seal(
+    key: *const u8, key_len: usize,
+    nonce: *const u8, nonce_len: usize,
+    plaintext: *const u8, pt_len: usize,
+    aad: *const u8, aad_len: usize,
+    output: *mut u8, output_max: usize,
+    output_len: *mut usize,
+) -> i32 {
+    ffi_wrap(|| {
+        if key.is_null() || nonce.is_null() || output.is_null() || output_len.is_null() {
+            return -1;
+        }
+        let needed = pt_len + aead::CHACHA20_POLY1305.tag_len();
+        if output_max < needed { return -1; }
+        if nonce_len != 12 { return -1; }
+        if key_len != 32 { return -1; }
+
+        let k = unsafe { std::slice::from_raw_parts(key, key_len) };
+        let n = unsafe { std::slice::from_raw_parts(nonce, nonce_len) };
+        let pt = if pt_len == 0 { &[] } else {
+            unsafe { std::slice::from_raw_parts(plaintext, pt_len) }
+        };
+        let ad = if aad_len == 0 || aad.is_null() { &[] } else {
+            unsafe { std::slice::from_raw_parts(aad, aad_len) }
+        };
+
+        let unbound_key = match aead::UnboundKey::new(&aead::CHACHA20_POLY1305, k) {
+            Ok(uk) => uk,
+            Err(_) => return -1,
+        };
+        let sealing_key = aead::LessSafeKey::new(unbound_key);
+        let nonce_val = match aead::Nonce::try_assume_unique_for_key(n) {
+            Ok(nv) => nv,
+            Err(_) => return -1,
+        };
+
+        let out = unsafe { std::slice::from_raw_parts_mut(output, output_max) };
+        out[..pt_len].copy_from_slice(pt);
+
+        let aad_obj = aead::Aad::from(ad);
+        match sealing_key.seal_in_place_separate_tag(nonce_val, aad_obj, &mut out[..pt_len]) {
+            Ok(tag) => {
+                out[pt_len..pt_len + tag.as_ref().len()].copy_from_slice(tag.as_ref());
+                unsafe { *output_len = needed; }
+                0
+            }
+            Err(_) => -1,
+        }
+    })
+}
+
+#[no_mangle]
+pub extern "C" fn jerboa_chacha20_open(
+    key: *const u8, key_len: usize,
+    nonce: *const u8, nonce_len: usize,
+    ciphertext: *const u8, ct_len: usize,
+    aad: *const u8, aad_len: usize,
+    output: *mut u8, output_max: usize,
+    output_len: *mut usize,
+) -> i32 {
+    ffi_wrap(|| {
+        if key.is_null() || nonce.is_null() || output.is_null() || output_len.is_null() {
+            return -1;
+        }
+        let tag_len = aead::CHACHA20_POLY1305.tag_len();
+        if ct_len < tag_len { return -1; }
+        let pt_len = ct_len - tag_len;
+        if output_max < pt_len { return -1; }
+        if nonce_len != 12 { return -1; }
+        if key_len != 32 { return -1; }
+
+        let k = unsafe { std::slice::from_raw_parts(key, key_len) };
+        let n = unsafe { std::slice::from_raw_parts(nonce, nonce_len) };
+        let ct = unsafe { std::slice::from_raw_parts(ciphertext, ct_len) };
+        let ad = if aad_len == 0 || aad.is_null() { &[] } else {
+            unsafe { std::slice::from_raw_parts(aad, aad_len) }
+        };
+
+        let unbound_key = match aead::UnboundKey::new(&aead::CHACHA20_POLY1305, k) {
+            Ok(uk) => uk,
+            Err(_) => return -1,
+        };
+        let opening_key = aead::LessSafeKey::new(unbound_key);
+        let nonce_val = match aead::Nonce::try_assume_unique_for_key(n) {
+            Ok(nv) => nv,
+            Err(_) => return -1,
+        };
+
+        let out = unsafe { std::slice::from_raw_parts_mut(output, output_max.max(ct_len)) };
+        out[..ct_len].copy_from_slice(ct);
+
+        let aad_obj = aead::Aad::from(ad);
+        match opening_key.open_in_place(nonce_val, aad_obj, &mut out[..ct_len]) {
+            Ok(plaintext) => {
+                let plen = plaintext.len();
+                unsafe { *output_len = plen; }
+                0
+            }
+            Err(_) => -1,
+        }
+    })
+}
+
+// --- Scrypt KDF ---
+
+#[no_mangle]
+pub extern "C" fn jerboa_scrypt(
+    password: *const u8, password_len: usize,
+    salt: *const u8, salt_len: usize,
+    log_n: u8, r: u32, p: u32,
+    output: *mut u8, output_len: usize,
+) -> i32 {
+    ffi_wrap(|| {
+        if password.is_null() || salt.is_null() || output.is_null() { return -1; }
+        if output_len == 0 { return -1; }
+        let pw = unsafe { std::slice::from_raw_parts(password, password_len) };
+        let s = unsafe { std::slice::from_raw_parts(salt, salt_len) };
+        let out = unsafe { std::slice::from_raw_parts_mut(output, output_len) };
+
+        let params = match scrypt::Params::new(log_n, r, p, output_len) {
+            Ok(p) => p,
+            Err(_) => {
+                set_last_error("invalid scrypt parameters".to_string());
+                return -1;
+            }
+        };
+        match scrypt::scrypt(pw, s, &params, out) {
+            Ok(()) => 0,
+            Err(_) => {
+                set_last_error("scrypt derivation failed".to_string());
+                -1
+            }
+        }
+    })
+}
+
 // --- PBKDF2 ---
 
 #[no_mangle]
diff --git a/lib/std/actor/transport.sls b/lib/std/actor/transport.sls
index 6154222..950adad 100644
--- a/lib/std/actor/transport.sls
+++ b/lib/std/actor/transport.sls
@@ -214,7 +214,7 @@
                   (unless (native-crypto-memcmp server-proof expected)
                     (tcp-close fd)
                     (error 'open-connection! "server auth failed — possible MITM" node-id))
-                  (vector fd write-mutex))))))))))
+                  (vector fd write-mutex)))))))))
 
   ;; -------- 7E: Remote send --------
 
@@ -287,7 +287,7 @@
                           (unless (eq? msg 'eof)
                             (dispatch-remote-message! msg)
                             (loop))))
-                      (tcp-close fd))))))))))))
+                      (tcp-close fd)))))))))))
 
   ;; Dispatch an inbound message to a local actor.
   ;; Expected wire format: (send local-actor-id payload)
diff --git a/lib/std/crypto/native-rust.sls b/lib/std/crypto/native-rust.sls
index 7edff2d..0c1dbe9 100644
--- a/lib/std/crypto/native-rust.sls
+++ b/lib/std/crypto/native-rust.sls
@@ -14,8 +14,12 @@
     rust-hmac-sha256 rust-hmac-sha256-verify
     ;; Timing-safe comparison
     rust-timing-safe-equal?
-    ;; AEAD
+    ;; AEAD (AES-256-GCM)
     rust-aead-seal rust-aead-open
+    ;; AEAD (ChaCha20-Poly1305)
+    rust-chacha20-seal rust-chacha20-open
+    ;; Scrypt KDF
+    rust-scrypt
     ;; PBKDF2
     rust-pbkdf2-derive rust-pbkdf2-verify
     ;; Error
@@ -23,11 +27,12 @@
 
   (import (chezscheme))
 
-  ;; Load the Rust native library
+  ;; Load the Rust native library (for dynamic builds).
+  ;; In static builds, symbols are pre-registered via Sforeign_symbol — skip loading.
   (define _native-loaded
     (or (guard (e [#t #f]) (load-shared-object "libjerboa_native.so") #t)
         (guard (e [#t #f]) (load-shared-object "lib/libjerboa_native.so") #t)
-        (error 'std/crypto/native-rust "libjerboa_native.so not found")))
+        #t))
 
   ;; Helper: extract sub-bytevector (avoids Chez extension warning)
   (define (bv-sub bv start len)
@@ -169,6 +174,70 @@
         (let ([actual-len (bytevector-u64-native-ref len-buf 0)])
           (bv-sub out 0 actual-len)))))
 
+  ;; --- AEAD (ChaCha20-Poly1305) ---
+
+  (define c-jerboa-chacha20-seal
+    (foreign-procedure "jerboa_chacha20_seal"
+      (u8* size_t u8* size_t u8* size_t u8* size_t u8* size_t u8*) int))
+
+  ;; Encrypt with ChaCha20-Poly1305. Returns ciphertext||tag bytevector.
+  (define (rust-chacha20-seal key nonce plaintext aad)
+    (let* ([pt-len (bytevector-length plaintext)]
+           [out-max (+ pt-len 16)]
+           [out (make-bytevector out-max)]
+           [len-buf (make-bytevector 8)])
+      (let ([rc (c-jerboa-chacha20-seal key (bytevector-length key)
+                                         nonce (bytevector-length nonce)
+                                         plaintext pt-len
+                                         aad (bytevector-length aad)
+                                         out out-max
+                                         len-buf)])
+        (when (< rc 0) (error 'rust-chacha20-seal "seal failed" (rust-last-error)))
+        (let ([actual-len (bytevector-u64-native-ref len-buf 0)])
+          (if (= actual-len out-max)
+            out
+            (bv-sub out 0 actual-len))))))
+
+  (define c-jerboa-chacha20-open
+    (foreign-procedure "jerboa_chacha20_open"
+      (u8* size_t u8* size_t u8* size_t u8* size_t u8* size_t u8*) int))
+
+  ;; Decrypt with ChaCha20-Poly1305. Returns plaintext or raises error.
+  (define (rust-chacha20-open key nonce ciphertext aad)
+    (let* ([ct-len (bytevector-length ciphertext)]
+           [out-max ct-len]
+           [out (make-bytevector out-max)]
+           [len-buf (make-bytevector 8)])
+      (let ([rc (c-jerboa-chacha20-open key (bytevector-length key)
+                                         nonce (bytevector-length nonce)
+                                         ciphertext ct-len
+                                         aad (bytevector-length aad)
+                                         out out-max
+                                         len-buf)])
+        (when (< rc 0) (error 'rust-chacha20-open "open failed" (rust-last-error)))
+        (let ([actual-len (bytevector-u64-native-ref len-buf 0)])
+          (bv-sub out 0 actual-len)))))
+
+  ;; --- Scrypt KDF ---
+
+  (define c-jerboa-scrypt
+    (foreign-procedure "jerboa_scrypt"
+      (u8* size_t u8* size_t unsigned-8 unsigned-32 unsigned-32 u8* size_t) int))
+
+  ;; Derive key using scrypt. Takes N (power of 2, e.g. 16384), r, p.
+  ;; Converts N to log2(N) for the Rust API.
+  (define (rust-scrypt password salt output-len n r p)
+    (let* ([pw (if (string? password) (string->utf8 password) password)]
+           [s (if (string? password) (string->utf8 salt) salt)]
+           [log-n (bitwise-length (- n 1))]  ;; log2(16384) = 14
+           [out (make-bytevector output-len)])
+      (let ([rc (c-jerboa-scrypt pw (bytevector-length pw)
+                                  s (bytevector-length s)
+                                  log-n r p
+                                  out output-len)])
+        (when (< rc 0) (error 'rust-scrypt "scrypt failed" (rust-last-error)))
+        out)))
+
   ;; --- PBKDF2 ---
 
   (define c-jerboa-pbkdf2-derive
diff --git a/lib/std/net/tcp-raw.sls b/lib/std/net/tcp-raw.sls
index ba438f3..f58c5ed 100644
--- a/lib/std/net/tcp-raw.sls
+++ b/lib/std/net/tcp-raw.sls
@@ -15,6 +15,13 @@
 
   ;; ========== FFI ==========
 
+  ;; Load libc so POSIX socket functions are available at compile time.
+  ;; Without this, compile-library fails because foreign-procedure can't
+  ;; resolve "socket" etc. during ahead-of-time compilation.
+  (define _libc
+    (guard (exn [#t (void)])
+      (load-shared-object "libc.so.6")))
+
   (define c-socket    (foreign-procedure "socket" (int int int) int))
   (define c-bind      (foreign-procedure "bind" (int void* int) int))
   (define c-listen    (foreign-procedure "listen" (int int) int))