typed/ecies: deterministic ECIES seal/open kernel

ober

83f669708997eb485d323fdab948b3b5b57a710e

diff --git a/tests/ecies_vectors.rs b/tests/ecies_vectors.rs
new file mode 100644
index 0000000..784eff4
--- /dev/null
+++ b/tests/ecies_vectors.rs
@@ -0,0 +1,61 @@
+//! Parity vectors for the ECIES kernel (jsecmon/typed/ecies.ss), which composes
+//! X25519 + HKDF-SHA256 + AES-256-GCM — each an FFI call into a vetted crate.
+//!
+//! secmon's ecies encrypt draws a random ephemeral keypair and nonce, so it has
+//! no fixed KAT; instead we pin the DETERMINISTIC core (ephemeral secret + nonce
+//! supplied) against an independent Python implementation (cryptography's X25519
+//! + HKDF + AESGCM). Matching it proves the raw x25519()/HKDF composition here
+//! reproduces secmon's StaticSecret/EphemeralSecret + derive_aes_key path
+//! byte-for-byte, and that ECDH agreement makes seal/open interoperate.
+
+use jerboa_typed_generated::jsecmon_typed_ecies::{ecies_open, ecies_seal};
+
+fn hex(s: &str) -> Vec<u8> {
+    (0..s.len())
+        .step_by(2)
+        .map(|i| u8::from_str_radix(&s[i..i + 2], 16).unwrap())
+        .collect()
+}
+
+// RFC 7748 keypairs: Alice is the (ephemeral) sender, Bob the recipient.
+const ALICE_SK: &str = "77076d0a7318a57d3c16c17251b26645df4c2f87ebc0992ab177fba51db92c2a";
+const ALICE_PK: &str = "8520f0098930a754748b7ddcb43ef75a0dbf3a0d26381af4eba4a98eaa9b4e6a";
+const BOB_SK: &str = "5dab087e624a8a4b79e17f8b83800ee66f3bb1292618b6fd1c2f8b27ff88e0eb";
+const BOB_PK: &str = "de9edb7d7b7dc1b4d35b61c2ece435373f8343c85b78674dadfc7e146f882b4f";
+const NONCE: &str = "cafebabefacedbaddecaf888";
+const PLAINTEXT: &[u8] = b"secmon-ecies probe";
+
+#[test]
+fn ecies_seal_matches_python_reference() {
+    // ecies_seal(ephemeral_secret, recipient_public, nonce, plaintext); the
+    // expected ciphertext‖tag was produced independently with Python's
+    // cryptography (X25519 exchange + HKDF-SHA256 salt=ephemeral_pub + AESGCM).
+    let ct = ecies_seal(hex(ALICE_SK), hex(BOB_PK), hex(NONCE), PLAINTEXT.to_vec());
+    assert_eq!(
+        ct,
+        hex("21b7c562d306b855e5ca0c33c1c0720cce55951a766e82e5cc765c825671b9252b99")
+    );
+}
+
+#[test]
+fn ecies_open_round_trips_via_ecdh_agreement() {
+    // Bob opens with his secret + Alice's ephemeral public; ECDH agreement means
+    // DH(bob_sk, alice_pk) == DH(alice_sk, bob_pk), so the AES key matches.
+    let ct = ecies_seal(hex(ALICE_SK), hex(BOB_PK), hex(NONCE), PLAINTEXT.to_vec());
+    assert_eq!(
+        ecies_open(hex(BOB_SK), hex(ALICE_PK), hex(NONCE), ct),
+        Some(PLAINTEXT.to_vec())
+    );
+}
+
+#[test]
+fn ecies_open_rejects_tampering_and_wrong_recipient() {
+    let ct = ecies_seal(hex(ALICE_SK), hex(BOB_PK), hex(NONCE), PLAINTEXT.to_vec());
+    // a flipped ciphertext byte fails the GCM tag → None.
+    let mut tampered = ct.clone();
+    tampered[0] ^= 0x01;
+    assert_eq!(ecies_open(hex(BOB_SK), hex(ALICE_PK), hex(NONCE), tampered), None);
+    // the wrong recipient secret derives a different key → None (not plaintext).
+    let wrong_sk = hex(ALICE_SK); // Alice's own secret is not the recipient's
+    assert_eq!(ecies_open(wrong_sk, hex(ALICE_PK), hex(NONCE), ct), None);
+}
diff --git a/typed/ecies.ss b/typed/ecies.ss
new file mode 100644
index 0000000..6a7a125
--- /dev/null
+++ b/typed/ecies.ss
@@ -0,0 +1,48 @@
+;;; jsecmon — ECIES (secmon src/crypto/ecies.rs) as a Typed Jerboa kernel that
+;;; composes the vetted-crate primitives: X25519 ECDH for key agreement,
+;;; HKDF-SHA256 to derive the AES key, and AES-256-GCM for the AEAD seal. None
+;;; of these are reimplemented — each is a crypto-prim lowered to sha2 / hkdf /
+;;; x25519-dalek / aes-gcm.
+;;;
+;;; secmon's encrypt/decrypt are split here into the DETERMINISTIC core: given
+;;; the ephemeral secret (encrypt) or the peer's ephemeral public (decrypt) plus
+;;; the GCM nonce, the rest is a pure function. The randomness secmon's encrypt
+;;; draws (a fresh ephemeral keypair + 12-byte nonce) and the EncryptedPayload
+;;; bincode framing stay in the untyped/FFI layer; this kernel is what they wrap.
+;;;
+;;;   key = HKDF-SHA256(salt = ephemeral_public, ikm = ECDH(secret, peer),
+;;;                     info = "secmon-ecies-v1") -> 32-byte AES-256 key
+;;;   ecies-seal  = AES-256-GCM encrypt under that key (no AAD)
+;;;   ecies-open  = AES-256-GCM decrypt+verify (None on a bad tag)
+;;;
+;;; Cross-checked in tests/ecies_vectors.rs against an independent Python
+;;; implementation (cryptography's X25519 + HKDF + AESGCM), proving the raw
+;;; x25519()/HKDF composition reproduces secmon's StaticSecret/EphemeralSecret
+;;; + derive_aes_key path byte-for-byte.
+
+(typed-library (jsecmon typed ecies)
+  (export ecies-seal ecies-open)
+
+  ;; Encrypt for a recipient: our 32-byte `ephemeral-secret`, the recipient's
+  ;; 32-byte X25519 public key, a 12-byte `nonce`, and the plaintext. Returns
+  ;; ciphertext‖tag. The ephemeral public key (salt) is derived from our secret,
+  ;; matching secmon's PublicKey::from(&ephemeral_secret).
+  (def (ecies-seal (ephemeral-secret : Bytes) (recipient-public : Bytes)
+                   (nonce : Bytes) (plaintext : Bytes)) : Bytes
+    (aes-256-gcm-seal
+      (hkdf-sha256 (x25519-base ephemeral-secret)
+                   (x25519-dh ephemeral-secret recipient-public)
+                   (string->utf8 "secmon-ecies-v1") 32)
+      nonce plaintext (string->utf8 "")))
+
+  ;; Decrypt as the recipient: our 32-byte `recipient-secret`, the sender's
+  ;; ephemeral public key (carried in the payload), the nonce, and the
+  ;; ciphertext‖tag. (Some plaintext) on success, None when the tag fails —
+  ;; secmon's decrypt error. The salt is the received ephemeral public key.
+  (def (ecies-open (recipient-secret : Bytes) (ephemeral-public : Bytes)
+                   (nonce : Bytes) (ciphertext : Bytes)) : (Option Bytes)
+    (aes-256-gcm-open
+      (hkdf-sha256 ephemeral-public
+                   (x25519-dh recipient-secret ephemeral-public)
+                   (string->utf8 "secmon-ecies-v1") 32)
+      nonce ciphertext (string->utf8 ""))))