typed/crypto: X25519 ECDH + ECIES key derivation

ober

1de109086ffcbfc7da11886f4b2c1e4a2b2113ed

diff --git a/tests/crypto_vectors.rs b/tests/crypto_vectors.rs
index 6a2ed90..26000e5 100644
--- a/tests/crypto_vectors.rs
+++ b/tests/crypto_vectors.rs
@@ -11,7 +11,8 @@
 //! reproduces HKDF's "salt not provided" path that secmon's PSK relies on.
 
 use jerboa_typed_generated::jsecmon_typed_crypto::{
-    hkdf_sha256_derive, hmac_sha256_tag, sha256_digest,
+    derive_ecies_key, hkdf_sha256_derive, hmac_sha256_tag, sha256_digest, x25519_dh_shared,
+    x25519_public_key,
 };
 
 fn hex(s: &str) -> Vec<u8> {
@@ -67,3 +68,42 @@ fn hkdf_sha256_rfc5869_tc3_empty_salt_is_none() {
         hex("8da4e775a563c18f715f802a063c5a31b8a11f5c5ee1879ec3454e5f3c738d2d9d201395faa4b61a96c8")
     );
 }
+
+// RFC 7748 §5.2 (raw X25519) and §6.1 (Alice/Bob ECDH) test vectors.
+const ALICE_SK: &str = "77076d0a7318a57d3c16c17251b26645df4c2f87ebc0992ab177fba51db92c2a";
+const ALICE_PK: &str = "8520f0098930a754748b7ddcb43ef75a0dbf3a0d26381af4eba4a98eaa9b4e6a";
+const BOB_SK: &str = "5dab087e624a8a4b79e17f8b83800ee66f3bb1292618b6fd1c2f8b27ff88e0eb";
+const BOB_PK: &str = "de9edb7d7b7dc1b4d35b61c2ece435373f8343c85b78674dadfc7e146f882b4f";
+const SHARED: &str = "4a5d9d5ba4ce2de1728e3bf480350f25e07e21c947d19e3376f09b3c1e161742";
+
+#[test]
+fn x25519_public_key_rfc7748_keygen() {
+    // public = secret * basepoint, matching PublicKey::from(&secret).
+    assert_eq!(x25519_public_key(hex(ALICE_SK)), hex(ALICE_PK));
+    assert_eq!(x25519_public_key(hex(BOB_SK)), hex(BOB_PK));
+}
+
+#[test]
+fn x25519_dh_rfc7748_raw_and_agreement() {
+    // raw §5.2 vector: x25519(k, u)
+    let k = hex("a546e36bf0527c9d3b16154b82465edd62144c0ac1fc5a18506a2244ba449ac4");
+    let u = hex("e6db6867583030db3594c1a424b15f7c726624ec26b3353b10a903a6d0ab1c4c");
+    assert_eq!(
+        x25519_dh_shared(k, u),
+        hex("c3da55379de9c6908e94ea4df28d084f32eccf03491c71f754b4075577a28552")
+    );
+    // both sides of the Alice/Bob exchange derive the same shared secret.
+    assert_eq!(x25519_dh_shared(hex(ALICE_SK), hex(BOB_PK)), hex(SHARED));
+    assert_eq!(x25519_dh_shared(hex(BOB_SK), hex(ALICE_PK)), hex(SHARED));
+}
+
+#[test]
+fn derive_ecies_key_matches_reference() {
+    // secmon derive_aes_key: HKDF(salt=ephemeral_pub, ikm=shared, "secmon-ecies-v1").
+    // Reference computed independently with Python hashlib/hmac.
+    let key = derive_ecies_key(hex(SHARED), hex(ALICE_PK));
+    assert_eq!(
+        key,
+        hex("20603cc4d87eda03c246e38a9250eb66611272bf80a7d5b2d5b8a39ea5b4fcb7")
+    );
+}
diff --git a/typed/crypto.ss b/typed/crypto.ss
index 8ed5b3d..33b2d04 100644
--- a/typed/crypto.ss
+++ b/typed/crypto.ss
@@ -18,7 +18,8 @@
 ;;; 5869 Test Case 3 in tests/crypto_vectors.rs — which is what psk uses.
 
 (typed-library (jsecmon typed crypto)
-  (export sha256-digest hmac-sha256-tag hkdf-sha256-derive)
+  (export sha256-digest hmac-sha256-tag hkdf-sha256-derive
+          x25519-dh-shared x25519-public-key derive-ecies-key)
 
   ;; SHA-256 of an arbitrary byte string → 32-byte digest.
   (def (sha256-digest (data : Bytes)) : Bytes
@@ -31,4 +32,20 @@
   ;; HKDF-SHA256 (extract-then-expand): derive `length` bytes of output keying
   ;; material from `ikm` under `salt` and context `info`. Empty salt == None.
   (def (hkdf-sha256-derive (salt : Bytes) (ikm : Bytes) (info : Bytes) (length : Nat)) : Bytes
-    (hkdf-sha256 salt ikm info length)))
+    (hkdf-sha256 salt ikm info length))
+
+  ;; --- X25519 (ECIES key agreement, secmon src/crypto/ecies.rs) ---
+
+  ;; ECDH shared secret: our 32-byte secret scalar against a 32-byte peer
+  ;; public point (ephemeral_secret.diffie_hellman(recipient_pubkey)).
+  (def (x25519-dh-shared (secret : Bytes) (peer-public : Bytes)) : Bytes
+    (x25519-dh secret peer-public))
+
+  ;; Public key for a secret scalar: secret * basepoint (PublicKey::from(&secret)).
+  (def (x25519-public-key (secret : Bytes)) : Bytes
+    (x25519-base secret))
+
+  ;; secmon derive_aes_key: HKDF-SHA256 with the ephemeral public key as salt,
+  ;; the ECDH shared secret as IKM, info "secmon-ecies-v1" → a 32-byte AES key.
+  (def (derive-ecies-key (shared-secret : Bytes) (ephemeral-public : Bytes)) : Bytes
+    (hkdf-sha256 ephemeral-public shared-secret (string->utf8 "secmon-ecies-v1") 32)))