jsecmon: ECIES public-key encrypt/decrypt runnable end-to-end
ober
1d97ee6aaeb8b70b27a82ed44c5360c519d492a2
--- 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) new file mode 100644 --- /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))) new file mode 100644 --- /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)))))) --- 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"