typed/ecies: deterministic ECIES seal/open kernel
ober
83f669708997eb485d323fdab948b3b5b57a710e
new file mode 100644 --- /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); +} new file mode 100644 --- /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 ""))))