typed/crypto: X25519 ECDH + ECIES key derivation
ober
1de109086ffcbfc7da11886f4b2c1e4a2b2113ed
--- 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") + ); +} --- 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)))