Add self-signed Ed25519 certificate generation (std crypto x509)

ober

484dacd2171bbdc0aaa00bc3ba5ff5f1a9b6cddc

diff --git a/jerboa-native-rs/Cargo.lock b/jerboa-native-rs/Cargo.lock
index 1958f77..3706545 100644
--- a/jerboa-native-rs/Cargo.lock
+++ b/jerboa-native-rs/Cargo.lock
@@ -140,6 +140,15 @@ dependencies = [
 ]
 
 [[package]]
+name = "deranged"
+version = "0.5.8"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7cd812cc2bc1d69d4764bd80df88b4317eaef9e773c75226407d9bc0876b211c"
+dependencies = [
+ "powerfmt",
+]
+
+[[package]]
 name = "digest"
 version = "0.10.7"
 source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -321,6 +330,7 @@ dependencies = [
  "inotify",
  "libc",
  "postgres",
+ "rcgen",
  "regex",
  "ring",
  "rusqlite",
@@ -328,6 +338,7 @@ dependencies = [
  "rustls-pemfile",
  "rustls-pki-types",
  "scrypt",
+ "time",
  "webpki-roots 0.26.11",
 ]
 
@@ -420,6 +431,12 @@ dependencies = [
 ]
 
 [[package]]
+name = "num-conv"
+version = "0.2.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "cf97ec579c3c42f953ef76dbf8d55ac91fb219dde70e49aa4a6b7d74e9919050"
+
+[[package]]
 name = "objc2-core-foundation"
 version = "0.3.2"
 source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -488,6 +505,16 @@ dependencies = [
 ]
 
 [[package]]
+name = "pem"
+version = "3.0.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1d30c53c26bc5b31a98cd02d20f25a7c8567146caf63ed593a9d87b2775291be"
+dependencies = [
+ "base64",
+ "serde_core",
+]
+
+[[package]]
 name = "percent-encoding"
 version = "2.3.2"
 source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -568,6 +595,12 @@ dependencies = [
 ]
 
 [[package]]
+name = "powerfmt"
+version = "0.2.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "439ee305def115ba05938db6eb1644ff94165c5ab5e9420d1c1bcedbba909391"
+
+[[package]]
 name = "ppv-lite86"
 version = "0.2.21"
 source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -639,6 +672,19 @@ dependencies = [
 ]
 
 [[package]]
+name = "rcgen"
+version = "0.13.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "75e669e5202259b5314d1ea5397316ad400819437857b90861765f24c4cf80a2"
+dependencies = [
+ "pem",
+ "ring",
+ "rustls-pki-types",
+ "time",
+ "yasna",
+]
+
+[[package]]
 name = "redox_syscall"
 version = "0.5.18"
 source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -884,6 +930,25 @@ dependencies = [
 ]
 
 [[package]]
+name = "time"
+version = "0.3.47"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "743bd48c283afc0388f9b8827b976905fb217ad9e647fae3a379a9283c4def2c"
+dependencies = [
+ "deranged",
+ "num-conv",
+ "powerfmt",
+ "serde_core",
+ "time-core",
+]
+
+[[package]]
+name = "time-core"
+version = "0.1.8"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7694e1cfe791f8d31026952abf09c69ca6f6fa4e1a1229e18988f06a04a12dca"
+
+[[package]]
 name = "tinyvec"
 version = "1.11.0"
 source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -1216,6 +1281,15 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "d7249219f66ced02969388cf2bb044a09756a083d0fab1e566056b04d9fbcaa5"
 
 [[package]]
+name = "yasna"
+version = "0.5.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e17bb3549cc1321ae1296b9cdc2698e2b6cb1992adfa19a8c72e5b7a738f44cd"
+dependencies = [
+ "time",
+]
+
+[[package]]
 name = "zerocopy"
 version = "0.8.47"
 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 92ccfb2..05c545d 100644
--- a/jerboa-native-rs/Cargo.toml
+++ b/jerboa-native-rs/Cargo.toml
@@ -15,6 +15,8 @@ libc = "0.2"
 rusqlite = { version = "0.32", features = ["bundled"] }
 postgres = "0.19"
 inotify = { version = "0.11", default-features = false }
+rcgen = { version = "0.13", features = ["ring", "pem"] }
+time = "0.3"
 rustls = { version = "0.23", default-features = false, features = ["ring", "logging", "std", "tls12"] }
 rustls-pki-types = "1"
 rustls-pemfile = "2"
diff --git a/jerboa-native-rs/src/lib.rs b/jerboa-native-rs/src/lib.rs
index ff27ee3..1ad3a02 100644
--- a/jerboa-native-rs/src/lib.rs
+++ b/jerboa-native-rs/src/lib.rs
@@ -9,3 +9,4 @@ mod epoll;
 mod inotify_native;
 mod landlock;
 mod tls;
+mod x509;
diff --git a/jerboa-native-rs/src/x509.rs b/jerboa-native-rs/src/x509.rs
new file mode 100644
index 0000000..77e4690
--- /dev/null
+++ b/jerboa-native-rs/src/x509.rs
@@ -0,0 +1,174 @@
+use crate::panic::{ffi_wrap, set_last_error};
+use rcgen::{CertificateParams, KeyPair, SanType, PKCS_ED25519};
+use ring::digest;
+use std::net::IpAddr;
+use time::{Duration, OffsetDateTime};
+
+/// Generate a self-signed Ed25519 certificate with IP address SANs.
+///
+/// ip_addrs_csv: comma-separated IP addresses (e.g., "192.168.1.1,10.0.0.5")
+/// validity_days: certificate lifetime in days (e.g., 365)
+/// cert_path: filesystem path to write PEM certificate
+/// key_path: filesystem path to write PEM private key
+///
+/// Returns 0 on success, -1 on error (call jerboa_last_error for details).
+#[no_mangle]
+pub extern "C" fn jerboa_x509_generate_self_signed(
+    ip_addrs_csv: *const u8,
+    ip_addrs_len: usize,
+    validity_days: i32,
+    cert_path: *const u8,
+    cert_path_len: usize,
+    key_path: *const u8,
+    key_path_len: usize,
+) -> i32 {
+    ffi_wrap(|| {
+        // Validate inputs
+        if ip_addrs_csv.is_null() || cert_path.is_null() || key_path.is_null() {
+            set_last_error("null pointer argument".to_string());
+            return -1;
+        }
+        if validity_days <= 0 {
+            set_last_error("validity_days must be positive".to_string());
+            return -1;
+        }
+
+        // Parse strings from FFI
+        let ip_str = unsafe { std::str::from_utf8_unchecked(
+            std::slice::from_raw_parts(ip_addrs_csv, ip_addrs_len)) };
+        let cert_path_str = unsafe { std::str::from_utf8_unchecked(
+            std::slice::from_raw_parts(cert_path, cert_path_len)) };
+        let key_path_str = unsafe { std::str::from_utf8_unchecked(
+            std::slice::from_raw_parts(key_path, key_path_len)) };
+
+        // Parse IP addresses
+        let mut sans: Vec<SanType> = Vec::new();
+        for addr_str in ip_str.split(',') {
+            let trimmed = addr_str.trim();
+            if trimmed.is_empty() {
+                continue;
+            }
+            match trimmed.parse::<IpAddr>() {
+                Ok(ip) => sans.push(SanType::IpAddress(ip)),
+                Err(e) => {
+                    set_last_error(format!("invalid IP address '{}': {}", trimmed, e));
+                    return -1;
+                }
+            }
+        }
+        if sans.is_empty() {
+            set_last_error("at least one IP address is required".to_string());
+            return -1;
+        }
+
+        // Generate Ed25519 key pair
+        let key_pair = match KeyPair::generate_for(&PKCS_ED25519) {
+            Ok(kp) => kp,
+            Err(e) => {
+                set_last_error(format!("key generation failed: {}", e));
+                return -1;
+            }
+        };
+
+        // Build certificate parameters
+        let mut params = CertificateParams::default();
+        params.subject_alt_names = sans;
+        params.not_before = OffsetDateTime::now_utc();
+        params.not_after = OffsetDateTime::now_utc() + Duration::days(validity_days as i64);
+
+        // Generate self-signed certificate
+        let cert = match params.self_signed(&key_pair) {
+            Ok(c) => c,
+            Err(e) => {
+                set_last_error(format!("certificate generation failed: {}", e));
+                return -1;
+            }
+        };
+
+        // Write PEM certificate
+        let cert_pem = cert.pem();
+        if let Err(e) = std::fs::write(cert_path_str, cert_pem.as_bytes()) {
+            set_last_error(format!("failed to write cert file '{}': {}", cert_path_str, e));
+            return -1;
+        }
+
+        // Write PEM private key
+        let key_pem = key_pair.serialize_pem();
+        if let Err(e) = std::fs::write(key_path_str, key_pem.as_bytes()) {
+            // Clean up cert file on key write failure
+            let _ = std::fs::remove_file(cert_path_str);
+            set_last_error(format!("failed to write key file '{}': {}", key_path_str, e));
+            return -1;
+        }
+
+        // Set restrictive permissions on key file (Unix only)
+        #[cfg(unix)]
+        {
+            use std::os::unix::fs::PermissionsExt;
+            let _ = std::fs::set_permissions(
+                key_path_str,
+                std::fs::Permissions::from_mode(0o600),
+            );
+        }
+
+        0
+    })
+}
+
+/// Compute SHA-256 fingerprint of a PEM certificate file.
+///
+/// Reads the PEM file, extracts the DER certificate, and computes SHA-256.
+/// Output is written as 32 raw bytes to the output buffer.
+///
+/// Returns 32 (fingerprint length) on success, -1 on error.
+#[no_mangle]
+pub extern "C" fn jerboa_x509_cert_fingerprint(
+    cert_path: *const u8,
+    cert_path_len: usize,
+    output: *mut u8,
+    output_len: usize,
+) -> i32 {
+    ffi_wrap(|| {
+        if cert_path.is_null() || output.is_null() {
+            set_last_error("null pointer argument".to_string());
+            return -1;
+        }
+        if output_len < 32 {
+            set_last_error("output buffer too small (need 32 bytes)".to_string());
+            return -1;
+        }
+
+        let path_str = unsafe { std::str::from_utf8_unchecked(
+            std::slice::from_raw_parts(cert_path, cert_path_len)) };
+
+        // Read and parse PEM file
+        let pem_data = match std::fs::read(path_str) {
+            Ok(d) => d,
+            Err(e) => {
+                set_last_error(format!("failed to read cert file '{}': {}", path_str, e));
+                return -1;
+            }
+        };
+
+        let mut cursor = std::io::Cursor::new(&pem_data);
+        let certs: Vec<_> = match rustls_pemfile::certs(&mut cursor).collect::<Result<Vec<_>, _>>() {
+            Ok(c) => c,
+            Err(e) => {
+                set_last_error(format!("failed to parse PEM: {}", e));
+                return -1;
+            }
+        };
+
+        if certs.is_empty() {
+            set_last_error("no certificates found in PEM file".to_string());
+            return -1;
+        }
+
+        // SHA-256 of the first certificate's DER encoding
+        let hash = digest::digest(&digest::SHA256, certs[0].as_ref());
+        unsafe {
+            std::ptr::copy_nonoverlapping(hash.as_ref().as_ptr(), output, 32);
+        }
+        32
+    })
+}
diff --git a/lib/std/crypto/x509.sls b/lib/std/crypto/x509.sls
new file mode 100644
index 0000000..9758a4d
--- /dev/null
+++ b/lib/std/crypto/x509.sls
@@ -0,0 +1,140 @@
+#!chezscheme
+;;; (std crypto x509) — Self-signed certificate generation via Rust rcgen
+;;;
+;;; Generates Ed25519 self-signed certificates with IP address SANs.
+;;; Intended for TOFU (trust-on-first-use) scenarios like remote mux servers.
+;;; Certificates and keys are written as PEM files.
+
+(library (std crypto x509)
+  (export
+    generate-self-signed-cert!
+    cert-fingerprint)
+
+  (import (chezscheme))
+
+  ;; Load the Rust native library
+  (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)
+        (guard (e [#t #f]) (load-shared-object "./lib/libjerboa_native.so") #t)
+        #f))
+
+  ;; --- FFI bindings ---
+
+  (define c-jerboa-last-error
+    (foreign-procedure "jerboa_last_error" (u8* size_t) size_t))
+
+  (define c-jerboa-x509-generate
+    (foreign-procedure "jerboa_x509_generate_self_signed"
+      (u8* size_t int u8* size_t u8* size_t) int))
+
+  (define c-jerboa-x509-fingerprint
+    (foreign-procedure "jerboa_x509_cert_fingerprint"
+      (u8* size_t u8* size_t) int))
+
+  ;; --- Helpers ---
+
+  (define (native-last-error)
+    (let ([buf (make-bytevector 1024)])
+      (let ([len (c-jerboa-last-error buf 1024)])
+        (if (> len 0)
+          (utf8->string
+            (let ([out (make-bytevector (min len 1023))])
+              (bytevector-copy! buf 0 out 0 (min len 1023))
+              out))
+          ""))))
+
+  (define (ensure-native! who)
+    (unless _native-loaded
+      (error who "libjerboa_native.so not available — build with `make native`")))
+
+  (define (bytevector->hex bv)
+    (let* ([len (bytevector-length bv)]
+           [out (make-string (* len 2))])
+      (do ([i 0 (+ i 1)])
+          ((= i len) out)
+        (let* ([b (bytevector-u8-ref bv i)]
+               [hi (bitwise-arithmetic-shift-right b 4)]
+               [lo (bitwise-and b #xf)])
+          (string-set! out (* i 2) (string-ref "0123456789abcdef" hi))
+          (string-set! out (+ (* i 2) 1) (string-ref "0123456789abcdef" lo))))))
+
+  (define (ensure-parent-dirs! path)
+    (let ([dir (x509-path-parent path)])
+      (when (and dir (not (string=? dir "")) (not (file-exists? dir)))
+        (x509-mkdir dir))))
+
+  (define (x509-path-parent path)
+    (let ([idx (string-last-index path #\/)])
+      (if idx
+        (substring path 0 idx)
+        #f)))
+
+  (define (string-last-index s ch)
+    (let loop ([i (- (string-length s) 1)])
+      (cond
+        [(< i 0) #f]
+        [(char=? (string-ref s i) ch) i]
+        [else (loop (- i 1))])))
+
+  (define (x509-mkdir path)
+    (guard (e [#t (void)])
+      (mkdir path)))
+
+  ;; --- Public API ---
+
+  ;; Generate a self-signed Ed25519 certificate with IP address SANs.
+  ;;
+  ;; ip-addresses: a string ("1.2.3.4") or list of strings ("1.2.3.4" "::1")
+  ;; cert-path: filesystem path for PEM certificate output
+  ;; key-path: filesystem path for PEM private key output
+  ;; validity-days: certificate lifetime (default 365)
+  ;;
+  ;; Returns the SHA-256 fingerprint of the certificate as a hex string.
+  ;; Creates parent directories if needed. Key file gets 0600 permissions.
+  (define generate-self-signed-cert!
+    (case-lambda
+      [(ip-addresses cert-path key-path)
+       (generate-self-signed-cert! ip-addresses cert-path key-path 365)]
+      [(ip-addresses cert-path key-path validity-days)
+       (ensure-native! 'generate-self-signed-cert!)
+       (let* ([ips (if (list? ip-addresses)
+                     (apply string-append
+                       (let loop ([addrs ip-addresses] [acc '()])
+                         (if (null? addrs)
+                           (reverse acc)
+                           (loop (cdr addrs)
+                                 (if (null? acc)
+                                   (list (car addrs))
+                                   (cons (car addrs) (cons "," acc)))))))
+                     ip-addresses)]
+              [ips-bv (string->utf8 ips)]
+              [cert-bv (string->utf8 cert-path)]
+              [key-bv (string->utf8 key-path)])
+         ;; Ensure parent directories exist
+         (ensure-parent-dirs! cert-path)
+         (ensure-parent-dirs! key-path)
+         (let ([rc (c-jerboa-x509-generate
+                     ips-bv (bytevector-length ips-bv)
+                     validity-days
+                     cert-bv (bytevector-length cert-bv)
+                     key-bv (bytevector-length key-bv))])
+           (when (< rc 0)
+             (error 'generate-self-signed-cert! (native-last-error)))
+           ;; Return the fingerprint
+           (cert-fingerprint cert-path)))]))
+
+  ;; Compute the SHA-256 fingerprint of a PEM certificate file.
+  ;; Returns a 64-character hex string (e.g., "a1b2c3...").
+  (define (cert-fingerprint cert-path)
+    (ensure-native! 'cert-fingerprint)
+    (let ([cert-bv (string->utf8 cert-path)]
+          [out (make-bytevector 32)])
+      (let ([rc (c-jerboa-x509-fingerprint
+                  cert-bv (bytevector-length cert-bv)
+                  out 32)])
+        (when (< rc 0)
+          (error 'cert-fingerprint (native-last-error)))
+        (bytevector->hex out))))
+
+  ) ;; end library