Add self-signed Ed25519 certificate generation (std crypto x509)
ober
484dacd2171bbdc0aaa00bc3ba5ff5f1a9b6cddc
--- 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" --- 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" --- 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; new file mode 100644 --- /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 + }) +} new file mode 100644 --- /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