jsecmon: ECIES public-key encrypt/decrypt runnable end-to-end

ober

1d97ee6aaeb8b70b27a82ed44c5360c519d492a2

diff --git a/Makefile b/Makefile
index 5cb0aa9..4809402 100644
--- a/Makefile
+++ b/Makefile
@@ -337,6 +337,14 @@ crypto-psk-check: rust
 	cd $(BUILD) && cargo build --release
 	$(SCHEME) --libdirs $(LIBDIRS) --script examples/crypto_psk_check.ss
 
+# ECIES public-key encryption end-to-end (secmon ecies.rs encrypt/decrypt): the
+# deterministic x25519+hkdf+aes-gcm core is the typed kernel (verified by `make
+# test`); this drives it through the FFI bridge + the ephemeral-keypair/nonce
+# orchestration. Needs the dylib and the OS CSPRNG via (std crypto random).
+crypto-ecies-check: rust
+	cd $(BUILD) && cargo build --release
+	$(SCHEME) --libdirs $(LIBDIRS) --script examples/crypto_ecies_check.ss
+
 # Everything that runs through the Jerboa side of the bridge, one shot.
 checks: kernels-check
 	$(SCHEME) --libdirs $(LIBDIRS) --script examples/triage_check.ss
@@ -384,6 +392,7 @@ checks: kernels-check
 	$(SCHEME) --libdirs $(LIBDIRS) --script examples/dga_check.ss
 	$(SCHEME) --libdirs $(LIBDIRS) --script examples/calendar_check.ss
 	$(SCHEME) --libdirs $(LIBDIRS) --script examples/crypto_psk_check.ss
+	$(SCHEME) --libdirs $(LIBDIRS) --script examples/crypto_ecies_check.ss
 
 clean:
 	rm -rf $(BUILD)
diff --git a/examples/crypto_ecies_check.ss b/examples/crypto_ecies_check.ss
new file mode 100644
index 0000000..e089a65
--- /dev/null
+++ b/examples/crypto_ecies_check.ss
@@ -0,0 +1,77 @@
+;;; Parity + behaviour check for ECIES public-key encryption end-to-end.
+;;;
+;;; The deterministic core is the verified Typed-Jerboa kernel `ecies-seal`/
+;;; `-open` (tests/ecies_vectors.rs pins it against an independent Python
+;;; implementation). Here we drive it through the FFI bridge `(jsecmon kernels)`
+;;; and the keypair/nonce-generating orchestration `(jsecmon crypto-ecies)` —
+;;; proving the wrappers marshal correctly and that ecies-encrypt/-decrypt
+;;; behave like secmon's EciesEncryptor::encrypt / EciesDecryptor::decrypt.
+;;;
+;;; Run from the repo root with the dylib built and the repo on the libdir path:
+;;;   (cd build/rust && cargo build --release)
+;;;   scheme --libdirs $JERBOA/lib --libdirs . --script examples/crypto_ecies_check.ss
+
+(import (jerboa prelude)
+        (jsecmon kernels)
+        (jsecmon crypto-ecies))
+
+(def fails 0)
+(def (check label got want)
+  (let ((ok (equal? got want)))
+    (unless ok (set! fails (+ fails 1)))
+    (displayln (if ok "  ok   " "  FAIL ") label " => " got
+               (if ok "" (str "  (want " want ")")))))
+
+;; RFC 7748 / tests/ecies_vectors.rs reference scalars. Alice plays the
+;; "ephemeral" role; Bob is the recipient.
+(def alice-sk (hex-decode "77076d0a7318a57d3c16c17251b26645df4c2f87ebc0992ab177fba51db92c2a"))
+(def alice-pk (hex-decode "8520f0098930a754748b7ddcb43ef75a0dbf3a0d26381af4eba4a98eaa9b4e6a"))
+(def bob-sk   (hex-decode "5dab087e624a8a4b79e17f8b83800ee66f3bb1292618b6fd1c2f8b27ff88e0eb"))
+(def bob-pk   (hex-decode "de9edb7d7b7dc1b4d35b61c2ece435373f8343c85b78674dadfc7e146f882b4f"))
+(def nonce    (hex-decode "cafebabefacedbaddecaf888"))
+(def msg      (string->utf8 "secmon-ecies probe"))
+
+(displayln "ecies (deterministic kernel vectors):")
+;; x25519 base-point mult: secret -> public (RFC 7748 known answers)
+(check "x25519-public alice"
+       (hex-encode (x25519-public-key alice-sk))
+       "8520f0098930a754748b7ddcb43ef75a0dbf3a0d26381af4eba4a98eaa9b4e6a")
+(check "x25519-public bob"
+       (hex-encode (x25519-public-key bob-sk))
+       "de9edb7d7b7dc1b4d35b61c2ece435373f8343c85b78674dadfc7e146f882b4f")
+;; the very seal vector tests/ecies_vectors.rs pins, through the FFI path
+(def ct (ecies-seal alice-sk bob-pk nonce msg))
+(check "ecies-seal vector"
+       (hex-encode ct)
+       "21b7c562d306b855e5ca0c33c1c0720cce55951a766e82e5cc765c825671b9252b99")
+;; the recipient (Bob) recovers it using the ephemeral public (Alice's)
+(check "ecies-open round-trip" (ecies-open bob-sk alice-pk nonce ct) msg)
+;; the ephemeral secret is NOT the recipient secret -> cannot open
+(check "ecies-open wrong sk"   (ecies-open alice-sk alice-pk nonce ct) #f)
+
+(displayln "ecies (orchestration, random ephemeral + nonce):")
+(def kp (ecies-generate-keypair))
+(def sk (car kp))
+(def pk (cadr kp))
+(def frame1 (ecies-encrypt pk msg))
+(def frame2 (ecies-encrypt pk msg))
+;; decrypt(encrypt(pk, m)) == m
+(check "round-trip"  (ecies-decrypt sk frame1) msg)
+(check "both decrypt" (ecies-decrypt sk frame2) msg)
+;; a fresh ephemeral keypair + nonce per call -> two encryptions differ
+(check "ephemeral randomized" (equal? frame1 frame2) #f)
+;; the frame carries ephemeral_public(32) ‖ nonce(12) ‖ ciphertext‖tag(16+)
+(check "frame >= 32+12+tag" (>= (bytevector-length frame1) (+ 32 12 16)) #t)
+;; a different recipient secret cannot open it
+(def other (ecies-generate-keypair))
+(check "wrong recipient -> #f" (ecies-decrypt (car other) frame1) #f)
+;; a flipped byte anywhere fails the GCM tag -> #f
+(def tampered (bytevector-copy frame1))
+(def end (- (bytevector-length tampered) 1))
+(bytevector-u8-set! tampered end (bitwise-xor (bytevector-u8-ref tampered end) 1))
+(check "tamper -> #f" (ecies-decrypt sk tampered) #f)
+
+(newline)
+(if (= fails 0)
+    (displayln "OK: ecies round-trips, randomizes ephemerals, and rejects tampering.")
+    (begin (displayln fails " FAILURES") (exit 1)))
diff --git a/jsecmon/crypto-ecies.ss b/jsecmon/crypto-ecies.ss
new file mode 100644
index 0000000..477647a
--- /dev/null
+++ b/jsecmon/crypto-ecies.ss
@@ -0,0 +1,65 @@
+#!chezscheme
+;;; jsecmon — ECIES public-key encryption orchestration.
+;;;
+;;; secmon's ecies.rs EciesEncryptor::encrypt / EciesDecryptor::decrypt. The
+;;; deterministic core — X25519 ECDH, HKDF-SHA256 key derivation, AES-256-GCM —
+;;; is the verified Typed-Jerboa kernel `ecies-seal`/`-open` (parity-checked
+;;; against an independent Python implementation in tests/ecies_vectors.rs).
+;;; What lives here is the effectful shell secmon's encrypt() wraps around it:
+;;; generating a fresh ephemeral X25519 keypair and a random nonce per message.
+;;;
+;;; Wire framing: secmon serialises EncryptedPayload {ephemeral_pubkey, nonce,
+;;; ciphertext} with bincode. jsecmon need not be wire-compatible with secmon,
+;;; so rather than reimplement bincode (a format the crate owns) we use a plain
+;;; fixed-prefix concat — ephemeral_public(32) ‖ nonce(12) ‖ ciphertext‖tag —
+;;; which is unambiguous because the two leading fields are fixed-size.
+
+(library (jsecmon crypto-ecies)
+  (export ecies-encrypt ecies-decrypt ecies-generate-keypair)
+  (import (except (chezscheme)
+                  make-hash-table hash-table?
+                  sort sort!
+                  printf fprintf
+                  path-extension path-absolute?
+                  with-input-from-string with-output-to-string
+                  iota 1+ 1-
+                  partition
+                  make-date make-time)
+          (except (jerboa prelude) meta atom?)
+          (jsecmon kernels)
+          (std crypto random))
+
+  ;; a fresh [start,end) slice of bv (explicit loop — avoids the R6RS/R7RS
+  ;; bytevector-copy! argument-order ambiguity).
+  (define (bytes-slice bv start end)
+    (let* ((n (- end start)) (out (make-bytevector n)))
+      (let loop ((i 0))
+        (if (< i n)
+            (begin (bytevector-u8-set! out i (bytevector-u8-ref bv (+ start i)))
+                   (loop (+ i 1)))
+            out))))
+
+  ;; an X25519 identity for a recipient: a random 32-byte secret scalar and its
+  ;; derived public key, returned as the list (secret public).
+  (define (ecies-generate-keypair)
+    (let ((secret (random-bytes 32)))
+      (list secret (x25519-public-key secret))))
+
+  ;; encrypt: ephemeral keypair + random nonce, seal under the ECDH-derived key,
+  ;; and frame the ephemeral public and nonce ahead of the ciphertext so the
+  ;; holder of the recipient secret can reconstruct everything.
+  (define (ecies-encrypt recipient-public plaintext)
+    (let* ((ephemeral-secret (random-bytes 32))
+           (ephemeral-public (x25519-public-key ephemeral-secret))
+           (nonce            (random-bytes 12))
+           (ct (ecies-seal ephemeral-secret recipient-public nonce plaintext)))
+      (bytevector-append ephemeral-public nonce ct)))
+
+  ;; decrypt: split the fixed-size prefix back off and AEAD-open; #f on a bad
+  ;; tag, the wrong recipient secret, or a frame too short to hold the prefix.
+  (define (ecies-decrypt recipient-secret frame)
+    (and (>= (bytevector-length frame) (+ 32 12))
+         (ecies-open recipient-secret
+                     (bytes-slice frame 0 32)
+                     (bytes-slice frame 32 44)
+                     (bytes-slice frame 44 (bytevector-length frame))))))
diff --git a/jsecmon/kernels.ss b/jsecmon/kernels.ss
index 1d702c6..52de711 100644
--- a/jsecmon/kernels.ss
+++ b/jsecmon/kernels.ss
@@ -20,6 +20,8 @@
           derive-auth-key derive-transport-key
           compute-proof verify-proof
           psk-transport-seal psk-transport-open
+          ;; ecies crypto primitives
+          x25519-public-key ecies-seal ecies-open
           ;; analytics
           host-risk-score
           ;; dga
@@ -207,6 +209,38 @@
       (%transport-open transport-key (bytevector-length transport-key)
                        sealed (bytevector-length sealed) pp pl))))
 
+  ;; ── ecies (x25519 ECDH + HKDF-SHA256 + AES-256-GCM, all vetted crates) ─────
+  ;; The recipient's X25519 public key from a 32-byte secret scalar (clamped in
+  ;; the dalek crate), used to derive an ephemeral public for the payload.
+  (define %x25519-public
+    (fp "jt_jsecmon_typed_crypto_x25519_public_key" (u8* size_t void* void*) unsigned-8))
+  (define (x25519-public-key secret)
+    (call->bytes (lambda (pp pl) (%x25519-public secret (bytevector-length secret) pp pl))))
+
+  ;; seal: ECDH(ephemeral-secret, recipient-public) -> HKDF (salt=ephemeral
+  ;; public, info "secmon-ecies-v1") -> AES-256-GCM over the plaintext.
+  (define %ecies-seal
+    (fp "jt_jsecmon_typed_ecies_ecies_seal"
+        (u8* size_t u8* size_t u8* size_t u8* size_t void* void*) unsigned-8))
+  (define (ecies-seal ephemeral-secret recipient-public nonce plaintext)
+    (call->bytes (lambda (pp pl)
+      (%ecies-seal ephemeral-secret (bytevector-length ephemeral-secret)
+                   recipient-public (bytevector-length recipient-public)
+                   nonce (bytevector-length nonce)
+                   plaintext (bytevector-length plaintext) pp pl))))
+
+  ;; open: the inverse with the recipient's secret and the carried ephemeral
+  ;; public; #f on a bad tag / wrong key (the Option Bytes None arm).
+  (define %ecies-open
+    (fp "jt_jsecmon_typed_ecies_ecies_open"
+        (u8* size_t u8* size_t u8* size_t u8* size_t void* void*) unsigned-8))
+  (define (ecies-open recipient-secret ephemeral-public nonce ciphertext)
+    (call->maybe-bytes (lambda (pp pl)
+      (%ecies-open recipient-secret (bytevector-length recipient-secret)
+                   ephemeral-public (bytevector-length ephemeral-public)
+                   nonce (bytevector-length nonce)
+                   ciphertext (bytevector-length ciphertext) pp pl))))
+
   ;; ── analytics ─────────────────────────────────────────────────────────────
   (define %host-risk
     (fp "jt_jsecmon_analytics_host_risk_score"