diff --git a/Cargo.lock b/Cargo.lock index 392b9d3b..e505db19 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -10,13 +10,30 @@ dependencies = [ "serde", ] +[[package]] +name = "ace-pke" +version = "0.1.0" +dependencies = [ + "anyhow", + "bcs", + "chacha20poly1305", + "curve25519-dalek", + "hkdf", + "hpke", + "ml-kem", + "rand", + "serde", + "sha2", + "sha3 0.10.8", +] + [[package]] name = "aead" version = "0.5.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d122413f284cf2d62fb1b7db97e02edb8cda96d769b16e443a4f6195e35662b0" dependencies = [ - "crypto-common", + "crypto-common 0.1.7", "generic-array", ] @@ -28,7 +45,7 @@ checksum = "b169f7a6d4742236a0a00c541b845991d0ac43e546831af1249753ab4c3aa3a0" dependencies = [ "cfg-if", "cipher", - "cpufeatures", + "cpufeatures 0.2.17", ] [[package]] @@ -137,7 +154,7 @@ dependencies = [ "serde_bytes", "serde_json", "sha2", - "sha3", + "sha3 0.10.8", "thiserror", ] @@ -178,7 +195,7 @@ dependencies = [ "ark-std", "blake2", "derivative", - "digest", + "digest 0.10.7", "sha2", ] @@ -210,7 +227,7 @@ dependencies = [ "ark-serialize", "ark-std", "derivative", - "digest", + "digest 0.10.7", "itertools", "num-bigint", "num-traits", @@ -289,7 +306,7 @@ checksum = "adb7b85a02b83d2f22f89bd5cac66c9c89474240cb6207cb1efc16d098e822a5" dependencies = [ "ark-serialize-derive", "ark-std", - "digest", + "digest 0.10.7", "num-bigint", ] @@ -450,7 +467,7 @@ version = "0.10.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "46502ad458c9a52b69d4d4d32775c788b7a1b85e8bc9d482d92250fc0e3f8efe" dependencies = [ - "digest", + "digest 0.10.7", ] [[package]] @@ -462,6 +479,15 @@ dependencies = [ "generic-array", ] +[[package]] +name = "block-buffer" +version = "0.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cdd35008169921d80bc60d3d0ab416eecb028c4cd653352907921d95084790be" +dependencies = [ + "hybrid-array", +] + [[package]] name = "bumpalo" version = "3.20.2" @@ -498,7 +524,7 @@ checksum = "c3613f74bd2eac03dad61bd53dbe620703d4371614fe0bc3b9f04dd36fe4e818" dependencies = [ "cfg-if", "cipher", - "cpufeatures", + "cpufeatures 0.2.17", ] [[package]] @@ -520,7 +546,7 @@ version = "0.4.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "773f3b9af64447d2ce9850330c473515014aa235e6a783b02db81ff39e4a3dad" dependencies = [ - "crypto-common", + "crypto-common 0.1.7", "inout", "zeroize", ] @@ -565,6 +591,12 @@ version = "1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c8d4a3bb8b1e0c1050499d1815f5ab16d04f0959b233085fb31653fbfc9d98f9" +[[package]] +name = "cmov" +version = "0.5.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c9ea0ac24bc397ab3c98583a3c9ba74fa56b09a4449bbe172b9b1ddb016027a" + [[package]] name = "colorchoice" version = "1.0.5" @@ -577,6 +609,12 @@ version = "0.9.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c2459377285ad874054d797f3ccebf984978aa39129f6eafde5cdc8315b612f8" +[[package]] +name = "const-oid" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a6ef517f0926dd24a1582492c791b6a4818a4d94e789a334894aa15b0d12f55c" + [[package]] name = "core-foundation" version = "0.9.4" @@ -612,6 +650,15 @@ dependencies = [ "libc", ] +[[package]] +name = "cpufeatures" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b2a41393f66f16b0823bb79094d54ac5fbd34ab292ddafb9a0456ac9f87d201" +dependencies = [ + "libc", +] + [[package]] name = "crypto-bigint" version = "0.5.5" @@ -619,7 +666,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0dc92fb57ca44df6db8059111ab3af99a63d5d0f8375d9972e319a379c6bab76" dependencies = [ "generic-array", - "rand_core", + "rand_core 0.6.4", "subtle", "zeroize", ] @@ -631,10 +678,21 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a" dependencies = [ "generic-array", - "rand_core", + "rand_core 0.6.4", "typenum", ] +[[package]] +name = "crypto-common" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ce6e4c961d6cd6c9a86db418387425e8bdeaf05b3c8bc1411e6dca4c252f1453" +dependencies = [ + "getrandom 0.4.2", + "hybrid-array", + "rand_core 0.10.1", +] + [[package]] name = "ctr" version = "0.9.2" @@ -644,6 +702,15 @@ dependencies = [ "cipher", ] +[[package]] +name = "ctutils" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7d5515a3834141de9eafb9717ad39eea8247b5674e6066c404e8c4b365d2a29e" +dependencies = [ + "cmov", +] + [[package]] name = "curve25519-dalek" version = "4.1.3" @@ -651,11 +718,11 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "97fb8b7c4503de7d6ae7b42ab72a5a59857b4c937ec27a3d4539dba95b5ab2be" dependencies = [ "cfg-if", - "cpufeatures", + "cpufeatures 0.2.17", "curve25519-dalek-derive", - "digest", + "digest 0.10.7", "fiat-crypto", - "rand_core", + "rand_core 0.6.4", "rustc_version", "subtle", "zeroize", @@ -678,7 +745,17 @@ version = "0.7.10" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e7c1832837b905bbfb5101e07cc24c8deddf52f93225eee6ead5f4d63d53ddcb" dependencies = [ - "const-oid", + "const-oid 0.9.6", + "zeroize", +] + +[[package]] +name = "der" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "71fd89660b2dc699704064e59e9dba0147b903e85319429e131620d022be411b" +dependencies = [ + "const-oid 0.10.2", "zeroize", ] @@ -699,12 +776,22 @@ version = "0.10.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" dependencies = [ - "block-buffer", - "const-oid", - "crypto-common", + "block-buffer 0.10.4", + "const-oid 0.9.6", + "crypto-common 0.1.7", "subtle", ] +[[package]] +name = "digest" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f1dd6dbb5841937940781866fa1281a1ff7bd3bf827091440879f9994983d5c2" +dependencies = [ + "block-buffer 0.12.0", + "crypto-common 0.2.2", +] + [[package]] name = "displaydoc" version = "0.2.5" @@ -762,12 +849,12 @@ version = "0.16.9" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ee27f32b5c5292967d2d4a9d7f1e0b0aed2c15daded5a60300e4abb9d8020bca" dependencies = [ - "der", - "digest", + "der 0.7.10", + "digest 0.10.7", "elliptic-curve", "rfc6979", "signature", - "spki", + "spki 0.7.3", ] [[package]] @@ -776,7 +863,7 @@ version = "2.2.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "115531babc129696a58c64a4fef0a8bf9e9698629fb97e9e40767d235cfbcd53" dependencies = [ - "pkcs8", + "pkcs8 0.10.2", "signature", ] @@ -808,12 +895,12 @@ checksum = "b5e6043086bf7973472e0c7dff2142ea0b680d30e18d9cc40f267efbf222bd47" dependencies = [ "base16ct", "crypto-bigint", - "digest", + "digest 0.10.7", "ff", "generic-array", "group", - "pkcs8", - "rand_core", + "pkcs8 0.10.2", + "rand_core 0.6.4", "sec1", "subtle", "zeroize", @@ -881,7 +968,7 @@ version = "0.13.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c0b50bfb653653f9ca9095b427bed08ab8d75a137839d9ad64eb11810d5b6393" dependencies = [ - "rand_core", + "rand_core 0.6.4", "subtle", ] @@ -1052,6 +1139,7 @@ dependencies = [ "cfg-if", "libc", "r-efi", + "rand_core 0.10.1", "wasip2", "wasip3", ] @@ -1073,7 +1161,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f0f9ef7462f7c099f518d754361858f86d8a07af53ba9af0fe635bbccb151a63" dependencies = [ "ff", - "rand_core", + "rand_core 0.6.4", "subtle", ] @@ -1153,7 +1241,7 @@ version = "0.12.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6c49c37c09c17a53d937dfbb742eb3a961d65a994e6bcdcf37e7399d0cc8ab5e" dependencies = [ - "digest", + "digest 0.10.7", ] [[package]] @@ -1165,11 +1253,11 @@ dependencies = [ "aead", "aes-gcm", "chacha20poly1305", - "digest", + "digest 0.10.7", "generic-array", "hkdf", "hmac", - "rand_core", + "rand_core 0.6.4", "sha2", "subtle", "x25519-dalek", @@ -1221,6 +1309,17 @@ version = "1.0.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9" +[[package]] +name = "hybrid-array" +version = "0.4.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9155a582abd142abc056962c29e3ce5ff2ad5469f4246b537ed42c5deba857da" +dependencies = [ + "ctutils", + "typenum", + "zeroize", +] + [[package]] name = "hyper" version = "1.9.0" @@ -1497,7 +1596,27 @@ version = "0.1.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "cb26cec98cce3a3d96cbb7bced3c4b16e3d13f27ec56dbd62cbc8f39cfb9d653" dependencies = [ - "cpufeatures", + "cpufeatures 0.2.17", +] + +[[package]] +name = "keccak" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9e24a010dd405bd7ed803e5253182815b41bf2e6a80cc3bfc066658e03a198aa" +dependencies = [ + "cfg-if", + "cpufeatures 0.3.0", +] + +[[package]] +name = "kem" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "01737161ba802849cfd486b5bd209d38ba4943494c249a8126005170c7621edd" +dependencies = [ + "crypto-common 0.2.2", + "rand_core 0.10.1", ] [[package]] @@ -1559,6 +1678,33 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "ml-kem" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5e15f3e5b957493873e396a66914e83e616b6afe335cdef7efe5c6e1216aba66" +dependencies = [ + "hybrid-array", + "kem", + "module-lattice", + "pkcs8 0.11.0", + "rand_core 0.10.1", + "sha3 0.11.0", + "zeroize", +] + +[[package]] +name = "module-lattice" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c61b87c9683ab7cb1c6871d261ad5479b6b10ceb52c4352aaca3b5d35a8febe" +dependencies = [ + "ctutils", + "hybrid-array", + "num-traits", + "zeroize", +] + [[package]] name = "native-tls" version = "0.2.18" @@ -1603,7 +1749,7 @@ dependencies = [ "serde_bytes", "serde_json", "sha2", - "sha3", + "sha3 0.10.8", "tokio", "tower-http", "uuid", @@ -1736,8 +1882,18 @@ version = "0.10.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f950b2377845cebe5cf8b5165cb3cc1a5e0fa5cfa3e1f7f55707d8fd82e0a7b7" dependencies = [ - "der", - "spki", + "der 0.7.10", + "spki 0.7.3", +] + +[[package]] +name = "pkcs8" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "451913da69c775a56034ea8d9003d27ee8948e12443eae7c038ba100a4f21cb7" +dependencies = [ + "der 0.8.0", + "spki 0.8.0", ] [[package]] @@ -1752,7 +1908,7 @@ version = "0.8.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8159bd90725d2df49889a078b54f4f79e87f1f8a8444194cdca81d38f5393abf" dependencies = [ - "cpufeatures", + "cpufeatures 0.2.17", "opaque-debug", "universal-hash", ] @@ -1764,7 +1920,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9d1fe60d06143b2430aa532c94cfe9e29783047f06c0d7fd359a9a51b729fa25" dependencies = [ "cfg-if", - "cpufeatures", + "cpufeatures 0.2.17", "opaque-debug", "universal-hash", ] @@ -1848,7 +2004,7 @@ checksum = "34af8d1a0e25924bc5b7c43c079c942339d8f0a8b57c39049bef581b46327404" dependencies = [ "libc", "rand_chacha", - "rand_core", + "rand_core 0.6.4", ] [[package]] @@ -1858,7 +2014,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e6c10a63a0fa32252be49d21e7709d4d4baf8d231c2dbce1eaa8141b9b127d88" dependencies = [ "ppv-lite86", - "rand_core", + "rand_core 0.6.4", ] [[package]] @@ -1870,6 +2026,12 @@ dependencies = [ "getrandom 0.2.17", ] +[[package]] +name = "rand_core" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63b8176103e19a2643978565ca18b50549f6101881c443590420e4dc998a3c69" + [[package]] name = "reqwest" version = "0.12.28" @@ -2017,9 +2179,9 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d3e97a565f76233a6003f9f5c54be1d9c5bdfa3eccfb189469f11ec4901c47dc" dependencies = [ "base16ct", - "der", + "der 0.7.10", "generic-array", - "pkcs8", + "pkcs8 0.10.2", "subtle", "zeroize", ] @@ -2145,8 +2307,8 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283" dependencies = [ "cfg-if", - "cpufeatures", - "digest", + "cpufeatures 0.2.17", + "digest 0.10.7", ] [[package]] @@ -2155,8 +2317,18 @@ version = "0.10.8" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "75872d278a8f37ef87fa0ddbda7802605cb18344497949862c0d4dcb291eba60" dependencies = [ - "digest", - "keccak", + "digest 0.10.7", + "keccak 0.1.6", +] + +[[package]] +name = "sha3" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "be176f1a57ce4e3d31c1a166222d9768de5954f811601fb7ca06fc8203905ce1" +dependencies = [ + "digest 0.11.3", + "keccak 0.2.0", ] [[package]] @@ -2181,8 +2353,8 @@ version = "2.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "77549399552de45a898a580c1b41d445bf730df867cc44e6c0233bbc4b8329de" dependencies = [ - "digest", - "rand_core", + "digest 0.10.7", + "rand_core 0.6.4", ] [[package]] @@ -2214,7 +2386,17 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d91ed6c858b01f942cd56b37a94b3e0a1798290327d1236e4d9cf4eaca44d29d" dependencies = [ "base64ct", - "der", + "der 0.7.10", +] + +[[package]] +name = "spki" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d9efca8738c78ee9484207732f728b1ef517bbb1833d6fc0879ca898a522f6f" +dependencies = [ + "base64ct", + "der 0.8.0", ] [[package]] @@ -2475,9 +2657,9 @@ checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b" [[package]] name = "typenum" -version = "1.19.0" +version = "1.20.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "562d481066bde0658276a35467c4af00bdc6ee726305698a55b86e61d7ad82bb" +checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20" [[package]] name = "unicode-ident" @@ -2497,7 +2679,7 @@ version = "0.5.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "fc1de2c688dc15305988b563c3854064043356019f97a4b46276fe734c4f07ea" dependencies = [ - "crypto-common", + "crypto-common 0.1.7", "subtle", ] @@ -2558,22 +2740,21 @@ checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" name = "vss-common" version = "0.1.0" dependencies = [ + "ace-pke", "anyhow", "ark-bls12-381", "ark-ec", "ark-ff", "ark-serialize", "bcs", - "curve25519-dalek", "ed25519-dalek", "hex", - "hpke", "rand", "reqwest", "serde", "serde_json", "sha2", - "sha3", + "sha3 0.10.8", "tokio", ] @@ -2958,7 +3139,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c7e468321c81fb07fa7f4c636c3972b9100f0346e5b6a9f2bd0603a52f7ed277" dependencies = [ "curve25519-dalek", - "rand_core", + "rand_core 0.6.4", ] [[package]] diff --git a/Cargo.toml b/Cargo.toml index 8433071e..20de5003 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -5,6 +5,7 @@ resolver = "2" members = [ "ace-anchor-kit", + "worker-components/pke", "worker-components/vss-common", "worker-components/vss-dealer", "worker-components/vss-recipient", diff --git a/contracts/pke/sources/pke.move b/contracts/pke/sources/pke.move index 20fdd1d3..16b717f7 100644 --- a/contracts/pke/sources/pke.move +++ b/contracts/pke/sources/pke.move @@ -4,12 +4,12 @@ /// PKE abstract layer — scheme-dispatching enums for encryption keys and ciphertexts. /// Mirrors ts-sdk/src/pke/index.ts. /// -/// Each enum currently has one variant (Simple ElGamal over Ristretto255 = scheme 0). /// Adding a new scheme is an additive change: add a variant here and a sibling module. -/// Scheme-specific types and serde live in ace::pke_elgamal_otp_ristretto255. +/// Scheme-specific types and serde live in the sibling ace::pke_* modules. module ace::pke { use aptos_std::bcs_stream::{Self, BCSStream}; use ace::pke_elgamal_otp_ristretto255; + use ace::pke_hybrid_x25519_mlkem768_chacha20poly1305; use ace::pke_hpke_x25519_chacha20poly1305; // ── Error codes ────────────────────────────────────────────────────────── @@ -23,6 +23,7 @@ module ace::pke { const SCHEME_ELGAMAL_OTP_RISTRETTO255: u8 = 0; const SCHEME_HPKE_X25519_HKDF_SHA256_CHACHA20POLY1305: u8 = 1; + const SCHEME_HYBRID_X25519_MLKEM768_CHACHA20POLY1305: u8 = 2; // ── Outer enum types ───────────────────────────────────────────────────── @@ -30,12 +31,14 @@ module ace::pke { enum EncryptionKey has copy, drop, store { ElGamalOtpRistretto255(pke_elgamal_otp_ristretto255::EncryptionKey), HpkeX25519ChaCha20Poly1305(pke_hpke_x25519_chacha20poly1305::EncryptionKey), + HybridX25519MlKem768ChaCha20Poly1305(pke_hybrid_x25519_mlkem768_chacha20poly1305::EncryptionKey), } /// Wire: [u8 scheme] [inner Ciphertext bytes] enum Ciphertext has copy, drop, store { ElGamalOtpRistretto255(pke_elgamal_otp_ristretto255::Ciphertext), HpkeX25519ChaCha20Poly1305(pke_hpke_x25519_chacha20poly1305::Ciphertext), + HybridX25519MlKem768ChaCha20Poly1305(pke_hybrid_x25519_mlkem768_chacha20poly1305::Ciphertext), } // ── Public scheme constants ─────────────────────────────────────────────── @@ -48,6 +51,10 @@ module ace::pke { SCHEME_HPKE_X25519_HKDF_SHA256_CHACHA20POLY1305 } + public fun scheme_hybrid_x25519_mlkem768_chacha20poly1305(): u8 { + SCHEME_HYBRID_X25519_MLKEM768_CHACHA20POLY1305 + } + // ── EncryptionKey parse ─────────────────────────────────────────────────── /// Parse an `EncryptionKey` from a BCS stream (reads the leading scheme byte). @@ -61,6 +68,10 @@ module ace::pke { EncryptionKey::HpkeX25519ChaCha20Poly1305( pke_hpke_x25519_chacha20poly1305::deserialize_enc_key(stream) ) + } else if (scheme == SCHEME_HYBRID_X25519_MLKEM768_CHACHA20POLY1305) { + EncryptionKey::HybridX25519MlKem768ChaCha20Poly1305( + pke_hybrid_x25519_mlkem768_chacha20poly1305::deserialize_enc_key(stream) + ) } else { abort EUNSUPPORTED_SCHEME } @@ -81,6 +92,7 @@ module ace::pke { match (ek) { EncryptionKey::ElGamalOtpRistretto255(_) => SCHEME_ELGAMAL_OTP_RISTRETTO255, EncryptionKey::HpkeX25519ChaCha20Poly1305(_) => SCHEME_HPKE_X25519_HKDF_SHA256_CHACHA20POLY1305, + EncryptionKey::HybridX25519MlKem768ChaCha20Poly1305(_) => SCHEME_HYBRID_X25519_MLKEM768_CHACHA20POLY1305, } } @@ -90,6 +102,7 @@ module ace::pke { match (ek) { EncryptionKey::ElGamalOtpRistretto255(inner) => inner, EncryptionKey::HpkeX25519ChaCha20Poly1305(_) => abort EUNSUPPORTED_SCHEME, + EncryptionKey::HybridX25519MlKem768ChaCha20Poly1305(_) => abort EUNSUPPORTED_SCHEME, } } @@ -99,6 +112,17 @@ module ace::pke { match (ek) { EncryptionKey::HpkeX25519ChaCha20Poly1305(inner) => inner, EncryptionKey::ElGamalOtpRistretto255(_) => abort EUNSUPPORTED_SCHEME, + EncryptionKey::HybridX25519MlKem768ChaCha20Poly1305(_) => abort EUNSUPPORTED_SCHEME, + } + } + + /// Downcast an `EncryptionKey` to its `HybridX25519MlKem768ChaCha20Poly1305` inner type. + /// Aborts with `EUNSUPPORTED_SCHEME` if the variant does not match. + public fun enc_key_as_hybrid_x25519_mlkem768_chacha20poly1305(ek: EncryptionKey): pke_hybrid_x25519_mlkem768_chacha20poly1305::EncryptionKey { + match (ek) { + EncryptionKey::HybridX25519MlKem768ChaCha20Poly1305(inner) => inner, + EncryptionKey::ElGamalOtpRistretto255(_) => abort EUNSUPPORTED_SCHEME, + EncryptionKey::HpkeX25519ChaCha20Poly1305(_) => abort EUNSUPPORTED_SCHEME, } } @@ -115,6 +139,10 @@ module ace::pke { Ciphertext::HpkeX25519ChaCha20Poly1305( pke_hpke_x25519_chacha20poly1305::deserialize_ciphertext(stream) ) + } else if (scheme == SCHEME_HYBRID_X25519_MLKEM768_CHACHA20POLY1305) { + Ciphertext::HybridX25519MlKem768ChaCha20Poly1305( + pke_hybrid_x25519_mlkem768_chacha20poly1305::deserialize_ciphertext(stream) + ) } else { abort EUNSUPPORTED_SCHEME } @@ -135,6 +163,7 @@ module ace::pke { match (ct) { Ciphertext::ElGamalOtpRistretto255(_) => SCHEME_ELGAMAL_OTP_RISTRETTO255, Ciphertext::HpkeX25519ChaCha20Poly1305(_) => SCHEME_HPKE_X25519_HKDF_SHA256_CHACHA20POLY1305, + Ciphertext::HybridX25519MlKem768ChaCha20Poly1305(_) => SCHEME_HYBRID_X25519_MLKEM768_CHACHA20POLY1305, } } @@ -144,6 +173,7 @@ module ace::pke { match (ct) { Ciphertext::ElGamalOtpRistretto255(inner) => inner, Ciphertext::HpkeX25519ChaCha20Poly1305(_) => abort EUNSUPPORTED_SCHEME, + Ciphertext::HybridX25519MlKem768ChaCha20Poly1305(_) => abort EUNSUPPORTED_SCHEME, } } @@ -153,6 +183,17 @@ module ace::pke { match (ct) { Ciphertext::HpkeX25519ChaCha20Poly1305(inner) => inner, Ciphertext::ElGamalOtpRistretto255(_) => abort EUNSUPPORTED_SCHEME, + Ciphertext::HybridX25519MlKem768ChaCha20Poly1305(_) => abort EUNSUPPORTED_SCHEME, + } + } + + /// Downcast a `Ciphertext` to its `HybridX25519MlKem768ChaCha20Poly1305` inner type. + /// Aborts with `EUNSUPPORTED_SCHEME` if the variant does not match. + public fun ciphertext_as_hybrid_x25519_mlkem768_chacha20poly1305(ct: Ciphertext): pke_hybrid_x25519_mlkem768_chacha20poly1305::Ciphertext { + match (ct) { + Ciphertext::HybridX25519MlKem768ChaCha20Poly1305(inner) => inner, + Ciphertext::ElGamalOtpRistretto255(_) => abort EUNSUPPORTED_SCHEME, + Ciphertext::HpkeX25519ChaCha20Poly1305(_) => abort EUNSUPPORTED_SCHEME, } } diff --git a/contracts/pke/sources/pke_hybrid_x25519_mlkem768_chacha20poly1305.move b/contracts/pke/sources/pke_hybrid_x25519_mlkem768_chacha20poly1305.move new file mode 100644 index 00000000..65957b08 --- /dev/null +++ b/contracts/pke/sources/pke_hybrid_x25519_mlkem768_chacha20poly1305.move @@ -0,0 +1,200 @@ +// Copyright (c) Aptos Labs +// SPDX-License-Identifier: Apache-2.0 + +/// Hybrid PKE wire wrappers: +/// inner: HPKE-X25519-HKDF-SHA256-ChaCha20Poly1305 +/// outer: ML-KEM-768 shared secret -> HKDF-SHA256 -> ChaCha20-Poly1305 +/// +/// Move performs no cryptographic operations here. This module only decodes BCS +/// structs and checks lengths so worker config and VSS sessions can carry the +/// bytes on chain without losing structure. +module ace::pke_hybrid_x25519_mlkem768_chacha20poly1305 { + use aptos_std::bcs_stream::{Self, BCSStream}; + use ace::pke_hpke_x25519_chacha20poly1305; + + // ── Error codes ────────────────────────────────────────────────────────── + + const EINVALID_ENC_KEY: u64 = 1; + const EINVALID_CIPHERTEXT: u64 = 2; + const ETRAILING_BYTES: u64 = 3; + + // ── Constants ──────────────────────────────────────────────────────────── + + const MLKEM768_EK_BYTES: u64 = 1184; + const MLKEM768_CT_BYTES: u64 = 1088; + const AEAD_NONCE_BYTES: u64 = 12; + const AEAD_TAG_BYTES: u64 = 16; + + // ── Types ──────────────────────────────────────────────────────────────── + + /// Wire (no outer scheme prefix): + /// HpkeEncryptionKey || [ULEB128(1184)] [1184B ML-KEM ek] + struct EncryptionKey has copy, drop, store { + hpke_x25519: pke_hpke_x25519_chacha20poly1305::EncryptionKey, + mlkem768_ek: vector, + } + + /// Wire (no outer scheme prefix): + /// [ULEB128(1088)] [1088B ML-KEM ct] + /// [ULEB128(12)] [12B nonce] + /// [ULEB128(len)] [len B outer AEAD ct] + struct Ciphertext has copy, drop, store { + mlkem768_ct: vector, + aead_nonce: vector, + aead_ct: vector, + } + + // ── Internal helpers ───────────────────────────────────────────────────── + + fun deserialize_bytes_field(stream: &mut BCSStream): vector { + bcs_stream::deserialize_vector(stream, |s| bcs_stream::deserialize_u8(s)) + } + + // ── EncryptionKey public API ────────────────────────────────────────────── + + public fun deserialize_enc_key(stream: &mut BCSStream): EncryptionKey { + let hpke_x25519 = pke_hpke_x25519_chacha20poly1305::deserialize_enc_key(stream); + let mlkem768_ek = deserialize_bytes_field(stream); + assert!(mlkem768_ek.length() == MLKEM768_EK_BYTES, EINVALID_ENC_KEY); + EncryptionKey { hpke_x25519, mlkem768_ek } + } + + public fun enc_key_from_bytes(data: vector): EncryptionKey { + let stream = bcs_stream::new(data); + let ek = deserialize_enc_key(&mut stream); + assert!(!bcs_stream::has_remaining(&mut stream), ETRAILING_BYTES); + ek + } + + public fun unpack_enc_key(ek: EncryptionKey): (pke_hpke_x25519_chacha20poly1305::EncryptionKey, vector) { + let EncryptionKey { hpke_x25519, mlkem768_ek } = ek; + (hpke_x25519, mlkem768_ek) + } + + // ── Ciphertext public API ───────────────────────────────────────────────── + + public fun deserialize_ciphertext(stream: &mut BCSStream): Ciphertext { + let mlkem768_ct = deserialize_bytes_field(stream); + assert!(mlkem768_ct.length() == MLKEM768_CT_BYTES, EINVALID_CIPHERTEXT); + let aead_nonce = deserialize_bytes_field(stream); + assert!(aead_nonce.length() == AEAD_NONCE_BYTES, EINVALID_CIPHERTEXT); + let aead_ct = deserialize_bytes_field(stream); + assert!(aead_ct.length() >= AEAD_TAG_BYTES, EINVALID_CIPHERTEXT); + Ciphertext { mlkem768_ct, aead_nonce, aead_ct } + } + + public fun ciphertext_from_bytes(data: vector): Ciphertext { + let stream = bcs_stream::new(data); + let ct = deserialize_ciphertext(&mut stream); + assert!(!bcs_stream::has_remaining(&mut stream), ETRAILING_BYTES); + ct + } + + public fun unpack_ciphertext(ct: Ciphertext): (vector, vector, vector) { + let Ciphertext { mlkem768_ct, aead_nonce, aead_ct } = ct; + (mlkem768_ct, aead_nonce, aead_ct) + } + + // ── Tests ───────────────────────────────────────────────────────────────── + + #[test_only] + fun push_bytes(out: &mut vector, len: u64, seed: u8) { + let i = 0; + while (i < len) { + out.push_back(seed ^ ((i % 251) as u8)); + i = i + 1; + } + } + + #[test_only] + fun push_uleb_len(out: &mut vector, len: u64) { + if (len < 128) { + out.push_back(len as u8); + } else if (len == MLKEM768_EK_BYTES) { + out.push_back(0xa0); + out.push_back(0x09); + } else if (len == MLKEM768_CT_BYTES) { + out.push_back(0xc0); + out.push_back(0x08); + } else { + abort 999 + } + } + + #[test_only] + fun build_enc_key_bytes(mlkem_len: u64): vector { + let out = vector[]; + // HPKE inner key: [ULEB128(32)] [32B pk] + out.push_back(0x20); + push_bytes(&mut out, 32, 0x11); + push_uleb_len(&mut out, mlkem_len); + push_bytes(&mut out, mlkem_len, 0x42); + out + } + + #[test_only] + fun build_ciphertext_bytes(mlkem_ct_len: u64, nonce_len: u64, aead_ct_len: u64): vector { + let out = vector[]; + push_uleb_len(&mut out, mlkem_ct_len); + push_bytes(&mut out, mlkem_ct_len, 0x21); + push_uleb_len(&mut out, nonce_len); + push_bytes(&mut out, nonce_len, 0x31); + push_uleb_len(&mut out, aead_ct_len); + push_bytes(&mut out, aead_ct_len, 0x41); + out + } + + #[test] + fun test_enc_key_round_trip() { + let bytes = build_enc_key_bytes(MLKEM768_EK_BYTES); + let ek = enc_key_from_bytes(bytes); + let (_, mlkem768_ek) = unpack_enc_key(ek); + assert!(mlkem768_ek.length() == MLKEM768_EK_BYTES, 100); + } + + #[test] + #[expected_failure(abort_code = EINVALID_ENC_KEY)] + fun test_enc_key_wrong_mlkem_length_rejected() { + let bytes = build_enc_key_bytes(32); + enc_key_from_bytes(bytes); + } + + #[test] + #[expected_failure(abort_code = ETRAILING_BYTES)] + fun test_enc_key_trailing_bytes_rejected() { + let bytes = build_enc_key_bytes(MLKEM768_EK_BYTES); + bytes.push_back(0xff); + enc_key_from_bytes(bytes); + } + + #[test] + fun test_ciphertext_round_trip() { + let bytes = build_ciphertext_bytes(MLKEM768_CT_BYTES, AEAD_NONCE_BYTES, AEAD_TAG_BYTES + 8); + let ct = ciphertext_from_bytes(bytes); + let (mlkem768_ct, nonce, aead_ct) = unpack_ciphertext(ct); + assert!(mlkem768_ct.length() == MLKEM768_CT_BYTES, 200); + assert!(nonce.length() == AEAD_NONCE_BYTES, 201); + assert!(aead_ct.length() == AEAD_TAG_BYTES + 8, 202); + } + + #[test] + #[expected_failure(abort_code = EINVALID_CIPHERTEXT)] + fun test_ciphertext_wrong_mlkem_length_rejected() { + let bytes = build_ciphertext_bytes(32, AEAD_NONCE_BYTES, AEAD_TAG_BYTES); + ciphertext_from_bytes(bytes); + } + + #[test] + #[expected_failure(abort_code = EINVALID_CIPHERTEXT)] + fun test_ciphertext_wrong_nonce_length_rejected() { + let bytes = build_ciphertext_bytes(MLKEM768_CT_BYTES, 11, AEAD_TAG_BYTES); + ciphertext_from_bytes(bytes); + } + + #[test] + #[expected_failure(abort_code = EINVALID_CIPHERTEXT)] + fun test_ciphertext_short_aead_rejected() { + let bytes = build_ciphertext_bytes(MLKEM768_CT_BYTES, AEAD_NONCE_BYTES, AEAD_TAG_BYTES - 1); + ciphertext_from_bytes(bytes); + } +} diff --git a/docs/auditor/cryptography/README.md b/docs/auditor/cryptography/README.md index 8be53a35..6a083db1 100644 --- a/docs/auditor/cryptography/README.md +++ b/docs/auditor/cryptography/README.md @@ -84,7 +84,7 @@ Defined in `contracts/group/sources/group.move` and mirrored in `worker-componen The following were called out in earlier discussions and are **not** in the current codebase. Auditors should not flag their absence; they're tracked as future work. -- **Post-quantum PKE.** No PQ-hybrid or PQ-only scheme is currently shipped. (Future: HPKE-X-Wing or Kyber-hybrid; tracked separately.) +- **Production post-quantum PKE.** Scheme `0x02` is a TS/Rust PQ-hybrid prototype with Move-side decoding, but no audited production PQ-hybrid or PQ-only PKE is currently shipped. (Future: HPKE-X-Wing or another standardized hybrid; tracked separately.) - **256-bit security level PKE.** Both PKE schemes are ~128-bit. (Future: HPKE-X448-HKDF-SHA512-ChaCha20Poly1305 or similar.) - **t-IBE share proof.** The `IdentityDecryptionKeyShare` wire format reserves a 1-byte "proof" flag for a future per-share Schnorr proof; today it is always `0x00` (no proof). The verification check in [`t-ibe.md`](./t-ibe.md) §1 uses on-chain `share_pks` instead, which is sufficient for honest-majority assumptions but not for accountability under accusatory failure. - **Move-side HPKE / shortsig-aead encrypt-decrypt.** Move only decodes these formats; the on-chain side never holds a private key for either, so no on-chain encrypt or decrypt is needed. diff --git a/docs/auditor/cryptography/pke.md b/docs/auditor/cryptography/pke.md index ad307a65..fbafa2f3 100644 --- a/docs/auditor/cryptography/pke.md +++ b/docs/auditor/cryptography/pke.md @@ -1,13 +1,14 @@ # Public-Key Encryption (`pke::*`) -The PKE layer is used to encrypt **VSS share messages** (dealer → recipient) and **decryption-request bodies** (client ↔ worker). Two schemes exist in the codebase, selected by a 1-byte scheme tag: +The PKE layer is used to encrypt **VSS share messages** (dealer → recipient) and **decryption-request bodies** (client ↔ worker). Three schemes exist in the codebase, selected by a 1-byte scheme tag: | Scheme | Tag | Status | Defined | |--------|-----|--------|---------| -| ElGamal-OTP-Ristretto255 | `0x00` | **test-only** (see below) | `ts-sdk/src/pke/elgamal_otp_ristretto255.ts`, `worker-components/vss-common/src/{pke.rs,crypto.rs}`, `contracts/pke/sources/pke_elgamal_otp_ristretto255.move` | -| HPKE-X25519-HKDF-SHA256-ChaCha20Poly1305 | `0x01` | **production, default** | `ts-sdk/src/pke/hpke_x25519_chacha20poly1305.ts`, `worker-components/vss-common/src/pke_hpke_x25519_chacha20poly1305.rs`, `contracts/pke/sources/pke_hpke_x25519_chacha20poly1305.move` | +| ElGamal-OTP-Ristretto255 | `0x00` | **test-only** (see below) | `ts-sdk/src/pke/elgamal_otp_ristretto255.ts`, `worker-components/pke/src/lib.rs`, `contracts/pke/sources/pke_elgamal_otp_ristretto255.move` | +| HPKE-X25519-HKDF-SHA256-ChaCha20Poly1305 | `0x01` | **production, default** | `ts-sdk/src/pke/hpke_x25519_chacha20poly1305.ts`, `worker-components/pke/src/pke_hpke_x25519_chacha20poly1305.rs`, `contracts/pke/sources/pke_hpke_x25519_chacha20poly1305.move` | +| Hybrid-X25519-MLKEM768-HKDF-SHA256-ChaCha20Poly1305 | `0x02` | **prototype, unaudited** | `ts-sdk/src/pke/hybrid_x25519_mlkem768_chacha20poly1305.ts`, `worker-components/pke/src/pke_hybrid_x25519_mlkem768_chacha20poly1305.rs`, `contracts/pke/sources/pke_hybrid_x25519_mlkem768_chacha20poly1305.move` | -> **Audit scope.** Only **scheme `0x01`** is audited. Scheme `0x00` is **test-only** — a hand-rolled ElGamal-in-the-exponent + custom OTP/HMAC DEM construction that has no formal security proof and uses non-standard primitives (notably the 64-byte-block HMAC-SHA3-256 of [`symmetric.md`](./symmetric.md) §2). It is the default of nothing today, used by no example, and referenced only by the regression scenario `scenarios/test-network-protocol-shortpk.ts` and an internal SDK test. Production deployments must use scheme `0x01`. A follow-up PR may delete scheme `0x00` from the codebase; until then, the BCS decoder still recognizes the discriminant — see [`../wire-formats.md`](../wire-formats.md) §1.1 / §1.2. +> **Audit scope.** Only **scheme `0x01`** is audited. Scheme `0x00` is **test-only** — a hand-rolled ElGamal-in-the-exponent + custom OTP/HMAC DEM construction that has no formal security proof and uses non-standard primitives (notably the 64-byte-block HMAC-SHA3-256 of [`symmetric.md`](./symmetric.md) §2). Scheme `0x02` is a post-quantum/hybrid prototype for on-chain share transport and is not production-audited. Production deployments must use scheme `0x01` unless a deployment explicitly opts into the prototype. ## 1. HPKE-X25519-HKDF-SHA256-ChaCha20Poly1305 (scheme `0x01`, default) @@ -22,7 +23,7 @@ info = b"" (empty) aad = b"" (empty by default; callers do NOT pass AAD) ``` -**TS implementation** uses [`@hpke/core`](https://www.npmjs.com/package/@hpke/core) for browser+node WebCrypto-backed primitives (`ts-sdk/src/pke/hpke_x25519_chacha20poly1305.ts`). **Rust implementation** uses [`hpke`](https://docs.rs/hpke/latest/hpke/) crate (`worker-components/vss-common/src/pke_hpke_x25519_chacha20poly1305.rs:19-23`). **Move implementation** is decoder-only (`contracts/pke/sources/pke_hpke_x25519_chacha20poly1305.move`); no on-chain encrypt/decrypt is needed. +**TS implementation** uses [`@hpke/core`](https://www.npmjs.com/package/@hpke/core) for browser+node WebCrypto-backed primitives (`ts-sdk/src/pke/hpke_x25519_chacha20poly1305.ts`). **Rust implementation** uses [`hpke`](https://docs.rs/hpke/latest/hpke/) crate (`worker-components/pke/src/pke_hpke_x25519_chacha20poly1305.rs:19-23`). **Move implementation** is decoder-only (`contracts/pke/sources/pke_hpke_x25519_chacha20poly1305.move`); no on-chain encrypt/decrypt is needed. **Wire shapes.** Byte layouts for `EncryptionKey`, `DecryptionKey`, and `Ciphertext` (HPKE rows) live in [`../wire-formats.md`](../wire-formats.md) §1.1-§1.3. @@ -30,4 +31,26 @@ aad = b"" (empty by default; callers do NOT pass AAD) **Caveats / audit notes.** - AAD is hardcoded empty; callers cannot bind external context to a ciphertext via this layer. The application layer (Aptos full-message signature, Solana txn simulation) provides binding instead. -- Implementations across TS/Rust/Move use **independent** HPKE libraries — wire-compatibility is verified by the round-trip tests in `worker-components/vss-common/src/pke_hpke_x25519_chacha20poly1305.rs:166-307` and `contracts/pke/tests/`. +- Implementations across TS/Rust/Move use **independent** HPKE libraries — wire-compatibility is verified by the round-trip tests in `worker-components/pke/src/pke_hpke_x25519_chacha20poly1305.rs:166-307` and `contracts/pke/tests/`. + +## 2. Hybrid-X25519-MLKEM768-HKDF-SHA256-ChaCha20Poly1305 (scheme `0x02`, prototype) + +Nested hybrid PKE for harvest-now-decrypt-later protection of long-lived on-chain share ciphertexts. TS and Rust encrypt the plaintext first with scheme `0x01`, then encapsulate with ML-KEM-768, derive an outer ChaCha20-Poly1305 key via HKDF-SHA256, and encrypt the serialized inner HPKE ciphertext. Move is decoder-only. + +**Sources / standards.** +- Inner encryption is HPKE base mode from [RFC 9180](https://www.rfc-editor.org/rfc/rfc9180), using DHKEM(X25519, HKDF-SHA256) and ChaCha20-Poly1305. +- Outer encapsulation uses ML-KEM-768 from [NIST FIPS 203](https://csrc.nist.gov/pubs/fips/203/final). +- The outer KDF is HKDF-SHA256 from [RFC 5869](https://www.rfc-editor.org/rfc/rfc5869); the outer AEAD is ChaCha20-Poly1305 from [RFC 8439](https://www.rfc-editor.org/rfc/rfc8439). + +**Security model / intent.** This exact nested construction is not a standardized HPKE hybrid, HPKE-X-Wing, or a scheme with an ACE-specific production proof. Treat it as a prototype KEM-DEM composition in the public-key IND-CCA confidentiality setting: inner HPKE relies on the RFC 9180 HPKE security analysis; outer confidentiality relies on ML-KEM-768 IND-CCA KEM security, HKDF as a KDF, and ChaCha20-Poly1305 AEAD security. The intended harvest-now-decrypt-later property is that historical ciphertext confidentiality survives if the outer ML-KEM-768 layer remains secure against quantum adversaries; the nested inner HPKE layer is a classical fallback if the ML-KEM layer were later broken. The Rust ML-KEM implementation is the unaudited RustCrypto `ml-kem` crate; this scheme is not production-audited. + +**Ciphertext size.** For a plaintext of `P` bytes, the inner HPKE ciphertext struct is `33 + uleb_len(P + 16) + P + 16` bytes, where `uleb_len(x)` is the byte length of the BCS ULEB128 length prefix for `x`. The outer AEAD plaintext is that inner struct, and the outer AEAD adds a 16-byte tag. Including the scheme byte, ML-KEM ciphertext, nonce, and BCS length prefixes, scheme `0x02` ciphertext size is: + +``` +1104 + uleb_len(outer_len) + outer_len +where outer_len = 49 + uleb_len(P + 16) + P + 16 +``` + +Examples: `P=32` -> **1203 B**, `P=1024` -> **2197 B**, `P=65536` -> **66711 B**. See [`../wire-formats.md`](../wire-formats.md) §1.2 for the field-level byte layout. + +**Current limitations.** Scheme `0x02` is not standardized HPKE-X-Wing and is not audited. It is suitable for VSS share-transport prototyping and performance evaluation, not default production use. diff --git a/docs/auditor/trust-model.md b/docs/auditor/trust-model.md index d8a27328..85a32aa1 100644 --- a/docs/auditor/trust-model.md +++ b/docs/auditor/trust-model.md @@ -150,6 +150,7 @@ Out of scope for the protocol-level trust model: |-----------|------------|---------| | ElGamal-OTP-Ristretto255 | DDH on Ristretto255 + ROM (KDF, HMAC) | PKE scheme 0 *(test-only; see [`cryptography/pke.md`](./cryptography/pke.md))* | | HPKE-X25519-HKDF-SHA256-ChaCha20Poly1305 | RFC 9180 base-mode security: GapDH on X25519, HKDF-SHA256, ChaCha20-Poly1305 IND-CCA | PKE scheme 1 *(production)* | +| Hybrid-X25519-MLKEM768-HKDF-SHA256-ChaCha20Poly1305 | Nested hybrid confidentiality if either X25519 HPKE or ML-KEM-768 remains secure; RustCrypto `ml-kem` crate is unaudited | PKE scheme 2 *(prototype)* | | BFIBE-BLS12381-ShortPK-OTP-HMAC | BDH on BLS12-381 + ROM, threshold via Shamir | t-IBE scheme 0 *(test-only; see [`cryptography/t-ibe.md`](./cryptography/t-ibe.md))* | | BFIBE-BLS12381-ShortSig-AEAD | BDH on BLS12-381 + ROM, ChaCha20-Poly1305 IND-CCA | t-IBE scheme 1 *(production)* | | Pedersen PCS + sigma linear-DLog | DLog on BLS12-381, unknown log relation between PCS generators, Fiat-Shamir ROM | VSS opening verification and public-key binding | diff --git a/docs/auditor/wire-formats.md b/docs/auditor/wire-formats.md index 62a8f919..040a1d3f 100644 --- a/docs/auditor/wire-formats.md +++ b/docs/auditor/wire-formats.md @@ -31,7 +31,7 @@ ULEB128 of 0..127 is exactly one byte. All ACE enum tags fit in one byte today. ## 1. PKE wire formats -Defined in `worker-components/vss-common/src/pke.rs`, mirrored in `ts-sdk/src/pke/index.ts` and `contracts/pke/sources/pke.move`. +Defined in `worker-components/pke/src/lib.rs`, mirrored in `ts-sdk/src/pke/index.ts` and `contracts/pke/sources/pke.move`. ### 1.1 `pke::EncryptionKey` @@ -39,16 +39,19 @@ Defined in `worker-components/vss-common/src/pke.rs`, mirrored in `ts-sdk/src/pk enum EncryptionKey { ElGamalOtpRistretto255(ElGamalOtpRistretto255EncKey), // tag 0 HpkeX25519ChaCha20Poly1305(HpkeEncryptionKey), // tag 1 + HybridX25519MlKem768ChaCha20Poly1305(HybridEncryptionKey), // tag 2 } struct ElGamalOtpRistretto255EncKey { enc_base: Vec, public_point: Vec } struct HpkeEncryptionKey { pk: Vec } // (pke_hpke_x25519_chacha20poly1305::EncryptionKey) +struct HybridEncryptionKey { hpke_x25519: HpkeEncryptionKey, mlkem768_ek: Vec } ``` | Variant | Wire bytes (total) | |---------|---------------------| | ElGamalOtpRistretto255 | `00 \| 20 \| 32B enc_base \| 20 \| 32B public_point` = **67 B** | | HpkeX25519ChaCha20Poly1305 | `01 \| 20 \| 32B pk` = **34 B** | +| HybridX25519MlKem768ChaCha20Poly1305 | `02 \| 20 \| 32B x25519_pk \| a0 09 \| 1184B mlkem768_ek` = **1220 B** | Where `20` = ULEB128(32). Inner `Vec` is sized but variable in principle; canonical lengths are 32 for all current uses. @@ -58,6 +61,7 @@ Where `20` = ULEB128(32). Inner `Vec` is sized but variable in principle; ca enum Ciphertext { ElGamalOtpRistretto255(ElGamalOtpRistretto255Ciphertext), // tag 0 HpkeX25519ChaCha20Poly1305(HpkeCiphertext), // tag 1 + HybridX25519MlKem768ChaCha20Poly1305(HybridCiphertext), // tag 2 } struct ElGamalOtpRistretto255Ciphertext { @@ -66,12 +70,26 @@ struct ElGamalOtpRistretto255Ciphertext { struct HpkeCiphertext { enc: Vec, aead_ct: Vec } +struct HybridCiphertext { + mlkem768_ct: Vec, aead_nonce: Vec, aead_ct: Vec +} ``` | Variant | Wire bytes | |---------|------------| | ElGamalOtpRistretto255 | `00 \| 20 \| 32B c0 \| 20 \| 32B c1 \| ULEB(L) \| L B sym_ciph \| 20 \| 32B mac` | | HpkeX25519ChaCha20Poly1305 | `01 \| 20 \| 32B enc \| ULEB(L) \| L B aead_ct` (aead_ct = ct \|\| 16B Poly1305 tag) | +| HybridX25519MlKem768ChaCha20Poly1305 | `02 \| c0 08 \| 1088B mlkem768_ct \| 0c \| 12B nonce \| ULEB(L) \| L B outer_aead_ct` | + +For the hybrid scheme, `outer_aead_ct` encrypts the serialized inner HPKE ciphertext and includes a 16-byte Poly1305 tag. For `P` plaintext bytes: + +```text +inner_hpke_len = 33 + uleb_len(P + 16) + P + 16 +outer_aead_len = inner_hpke_len + 16 +total_len = 1 + 2 + 1088 + 1 + 12 + uleb_len(outer_aead_len) + outer_aead_len +``` + +Examples: `P=32` -> **1203 B**, `P=1024` -> **2197 B**, `P=65536` -> **66711 B**. ### 1.3 `pke::DecryptionKey` (per scheme) @@ -81,8 +99,9 @@ These never appear on the wire as part of a request — they live only on the wo |--------|--------|------:| | ElGamalOtpRistretto255 | `00 \| 20 \| 32B enc_base \| 20 \| 32B priv_scalar` | **67 B** | | HpkeX25519ChaCha20Poly1305 | `01 \| 20 \| 32B sk` | **34 B** | +| HybridX25519MlKem768ChaCha20Poly1305 | `02 \| 20 \| 32B x25519_sk \| 40 \| 64B mlkem768_seed` | **99 B** | -The leading scheme byte is consumed by `pke_decrypt_bytes` (`worker-components/vss-common/src/pke.rs`) to dispatch. +The leading scheme byte is consumed by `pke_decrypt_bytes` (`worker-components/pke/src/lib.rs`) to dispatch. --- @@ -523,7 +542,7 @@ Round-trip / cross-implementation tests that gate wire-format changes: | Test | Location | Asserts | |------|----------|---------| -| `pke::tests::round_trip` (Rust) | `worker-components/vss-common/src/pke_hpke_x25519_chacha20poly1305.rs:170-180` | HPKE encrypt then decrypt yields original plaintext | +| `pke::tests::round_trip` (Rust) | `worker-components/pke/src/pke_hpke_x25519_chacha20poly1305.rs:170-180` | HPKE encrypt then decrypt yields original plaintext | | `pke::tests::*_bcs_round_trip` (Rust) | same | EncryptionKey / Ciphertext BCS round-trip with byte-count assertions | | Move `test_*_from_bytes_golden` | `contracts/pke/tests/` | Move decoder matches a TS-produced golden vector | | `pnpm vitest` (TS) | `ts-sdk/tests/` | t-IBE schemes 0 and 1 round-trip; AEAD tamper rejection; wire-shape size assertions | diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 54e9e090..26f41e29 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -261,6 +261,9 @@ importers: '@noble/hashes': specifier: ^1.7.1 version: 1.8.0 + '@noble/post-quantum': + specifier: ^0.6.1 + version: 0.6.1 devDependencies: '@aptos-labs/ts-sdk': specifier: ^5.2.0 @@ -901,14 +904,30 @@ packages: resolution: {integrity: sha512-2I0gnIVPtfnMw9ee9h1dJG7tp81+8Ob3OJb3Mv37rx5L40/b0i7djjCVvGOVqc9AEIQyvyu1i6ypKdFw8R8gQw==} engines: {node: ^14.21.3 || >=16} + '@noble/ciphers@2.2.0': + resolution: {integrity: sha512-Z6pjIZ/8IJcCGzb2S/0Px5J81yij85xASuk1teLNeg75bfT07MV3a/O2Mtn1I2se43k3lkVEcFaR10N4cgQcZA==} + engines: {node: '>= 20.19.0'} + '@noble/curves@1.9.7': resolution: {integrity: sha512-gbKGcRUYIjA3/zCCNaWDciTMFI0dCkvou3TL8Zmy5Nc7sJ47a0jtOeZoTaMxkuqRo9cRhjOdZJXegxYE5FN/xw==} engines: {node: ^14.21.3 || >=16} + '@noble/curves@2.2.0': + resolution: {integrity: sha512-T/BoHgFXirb0ENSPBquzX0rcjXeM6Lo892a2jlYJkqk83LqZx0l1Of7DzlKJ6jkpvMrkHSnAcgb5JegL8SeIkQ==} + engines: {node: '>= 20.19.0'} + '@noble/hashes@1.8.0': resolution: {integrity: sha512-jCs9ldd7NwzpgXDIf6P3+NrHh9/sD6CQdxHyjQI+h/6rDNo88ypBxxz45UDuZHz9r3tNz7N/VInSVoVdtXEI4A==} engines: {node: ^14.21.3 || >=16} + '@noble/hashes@2.2.0': + resolution: {integrity: sha512-IYqDGiTXab6FniAgnSdZwgWbomxpy9FtYvLKs7wCUs2a8RkITG+DFGO1DM9cr+E3/RgADRpFjrKVaJ1z6sjtEg==} + engines: {node: '>= 20.19.0'} + + '@noble/post-quantum@0.6.1': + resolution: {integrity: sha512-+pormrDZwjRw05U8ADK4JpHejo87+gBd+muRBB/ozztH5yhDLMDF4jHQWN3NQQAsu1zBNPWTG0ZwVI0CR29H0A==} + engines: {node: '>= 20.19.0'} + '@rollup/rollup-android-arm-eabi@4.55.2': resolution: {integrity: sha512-21J6xzayjy3O6NdnlO6aXi/urvSRjm6nCI6+nF6ra2YofKruGixN9kfT+dt55HVNwfDmpDHJcaS3JuP/boNnlA==} cpu: [arm] @@ -3101,12 +3120,26 @@ snapshots: '@noble/ciphers@1.3.0': {} + '@noble/ciphers@2.2.0': {} + '@noble/curves@1.9.7': dependencies: '@noble/hashes': 1.8.0 + '@noble/curves@2.2.0': + dependencies: + '@noble/hashes': 2.2.0 + '@noble/hashes@1.8.0': {} + '@noble/hashes@2.2.0': {} + + '@noble/post-quantum@0.6.1': + dependencies: + '@noble/ciphers': 2.2.0 + '@noble/curves': 2.2.0 + '@noble/hashes': 2.2.0 + '@rollup/rollup-android-arm-eabi@4.55.2': optional: true diff --git a/ts-sdk/package.json b/ts-sdk/package.json index 0fd196fe..d7737864 100644 --- a/ts-sdk/package.json +++ b/ts-sdk/package.json @@ -42,7 +42,8 @@ "@hpke/core": "^1.7.0", "@noble/ciphers": "^1.2.1", "@noble/curves": "^1.8.1", - "@noble/hashes": "^1.7.1" + "@noble/hashes": "^1.7.1", + "@noble/post-quantum": "^0.6.1" }, "peerDependencies": { "@aptos-labs/ts-sdk": "^5.2.0", diff --git a/ts-sdk/src/pke/hybrid_x25519_mlkem768_chacha20poly1305.ts b/ts-sdk/src/pke/hybrid_x25519_mlkem768_chacha20poly1305.ts new file mode 100644 index 00000000..dfebafce --- /dev/null +++ b/ts-sdk/src/pke/hybrid_x25519_mlkem768_chacha20poly1305.ts @@ -0,0 +1,305 @@ +// Copyright (c) Aptos Labs +// SPDX-License-Identifier: Apache-2.0 + +/** + * Hybrid PKE for long-lived on-chain share transport: + * inner: HPKE-X25519-HKDF-SHA256-ChaCha20Poly1305 + * outer: ML-KEM-768 shared secret -> HKDF-SHA256 -> ChaCha20-Poly1305 + * + * BCS wire format (no leading scheme byte; the abstract `pke` outer enum prepends it): + * EncryptionKey = HpkeEncryptionKey || [ULEB128(1184)] [1184B ML-KEM ek] + * DecryptionKey = HpkeDecryptionKey || [ULEB128(64)] [64B ML-KEM seed] + * Ciphertext = [ULEB128(1088)] [1088B ML-KEM ct] + * [ULEB128(12)] [12B nonce] + * [ULEB128(len)] [len B outer AEAD ct] + */ +import { Deserializer, Serializer } from "@aptos-labs/ts-sdk"; +import { chacha20poly1305 } from "@noble/ciphers/chacha"; +import { hkdf } from "@noble/hashes/hkdf"; +import { sha256 } from "@noble/hashes/sha2"; +import { bytesToHex, concatBytes, hexToBytes } from "@noble/hashes/utils"; +import { ml_kem768 } from "@noble/post-quantum/ml-kem.js"; +import { randomBytes } from "@noble/post-quantum/utils.js"; +import { Result } from "../result"; +import * as HpkeX25519ChaCha20Poly1305 from "./hpke_x25519_chacha20poly1305"; + +const MLKEM768_EK_BYTES = 1184; +const MLKEM768_DK_SEED_BYTES = 64; +const MLKEM768_CT_BYTES = 1088; +const AEAD_KEY_BYTES = 32; +const AEAD_NONCE_BYTES = 12; +const AEAD_TAG_BYTES = 16; +const HKDF_SALT = new TextEncoder().encode( + "ACE-PKE-HYBRID-X25519-MLKEM768-CHACHA20POLY1305/v0", +); + +function assertConsumed(d: Deserializer, label: string): void { + if (d.remaining() !== 0) throw new Error(`${label}: trailing bytes`); +} + +function hexStringToBytes(hex: string): Uint8Array { + const h = hex.trim(); + return hexToBytes(h.startsWith("0x") || h.startsWith("0X") ? h.slice(2) : h); +} + +function le64(n: number): Uint8Array { + if (!Number.isSafeInteger(n) || n < 0) { + throw new Error(`le64: invalid length ${n}`); + } + const out = new Uint8Array(8); + let x = BigInt(n); + for (let i = 0; i < out.length; i++) { + out[i] = Number(x & 0xffn); + x >>= 8n; + } + return out; +} + +function deriveOuterKey(sharedSecret: Uint8Array, mlkemCt: Uint8Array, aad: Uint8Array): Uint8Array { + const info = concatBytes(HKDF_SALT, le64(mlkemCt.length), mlkemCt, le64(aad.length), aad); + return hkdf(sha256, sharedSecret, HKDF_SALT, info, AEAD_KEY_BYTES); +} + +export class EncryptionKey { + hpkeX25519: HpkeX25519ChaCha20Poly1305.EncryptionKey; + mlkem768Ek: Uint8Array; + + constructor( + hpkeX25519: HpkeX25519ChaCha20Poly1305.EncryptionKey, + mlkem768Ek: Uint8Array, + ) { + if (mlkem768Ek.length !== MLKEM768_EK_BYTES) { + throw new Error(`EncryptionKey: mlkem768Ek must be ${MLKEM768_EK_BYTES} bytes, got ${mlkem768Ek.length}`); + } + this.hpkeX25519 = hpkeX25519; + this.mlkem768Ek = mlkem768Ek; + } + + static deserialize(d: Deserializer): Result { + return Result.capture({ + recordsExecutionTimeMs: false, + task: () => { + const hpkeX25519 = HpkeX25519ChaCha20Poly1305.EncryptionKey.deserialize(d) + .unwrapOrThrow("Hybrid EncryptionKey.deserialize: HPKE-X25519"); + return new EncryptionKey(hpkeX25519, d.deserializeBytes()); + }, + }); + } + + serialize(s: Serializer): void { + this.hpkeX25519.serialize(s); + s.serializeBytes(this.mlkem768Ek); + } + + toBytes(): Uint8Array { + const s = new Serializer(); + this.serialize(s); + return s.toUint8Array(); + } + + static fromBytes(bytes: Uint8Array): Result { + return Result.capture({ + recordsExecutionTimeMs: false, + task: () => { + const d = new Deserializer(bytes); + const ek = EncryptionKey.deserialize(d).unwrapOrThrow("Hybrid EncryptionKey.fromBytes"); + assertConsumed(d, "Hybrid EncryptionKey.fromBytes"); + return ek; + }, + }); + } + + toHex(): string { return bytesToHex(this.toBytes()); } + + static fromHex(hex: string): Result { + return Result.capture({ + recordsExecutionTimeMs: false, + task: () => EncryptionKey.fromBytes(hexStringToBytes(hex)) + .unwrapOrThrow("Hybrid EncryptionKey.fromHex"), + }); + } +} + +export class DecryptionKey { + hpkeX25519: HpkeX25519ChaCha20Poly1305.DecryptionKey; + mlkem768Seed: Uint8Array; + + constructor( + hpkeX25519: HpkeX25519ChaCha20Poly1305.DecryptionKey, + mlkem768Seed: Uint8Array, + ) { + if (mlkem768Seed.length !== MLKEM768_DK_SEED_BYTES) { + throw new Error(`DecryptionKey: mlkem768Seed must be ${MLKEM768_DK_SEED_BYTES} bytes, got ${mlkem768Seed.length}`); + } + this.hpkeX25519 = hpkeX25519; + this.mlkem768Seed = mlkem768Seed; + } + + static deserialize(d: Deserializer): Result { + return Result.capture({ + recordsExecutionTimeMs: false, + task: () => { + const hpkeX25519 = HpkeX25519ChaCha20Poly1305.DecryptionKey.deserialize(d) + .unwrapOrThrow("Hybrid DecryptionKey.deserialize: HPKE-X25519"); + return new DecryptionKey(hpkeX25519, d.deserializeBytes()); + }, + }); + } + + serialize(s: Serializer): void { + this.hpkeX25519.serialize(s); + s.serializeBytes(this.mlkem768Seed); + } + + toBytes(): Uint8Array { + const s = new Serializer(); + this.serialize(s); + return s.toUint8Array(); + } + + static fromBytes(bytes: Uint8Array): Result { + return Result.capture({ + recordsExecutionTimeMs: false, + task: () => { + const d = new Deserializer(bytes); + const dk = DecryptionKey.deserialize(d).unwrapOrThrow("Hybrid DecryptionKey.fromBytes"); + assertConsumed(d, "Hybrid DecryptionKey.fromBytes"); + return dk; + }, + }); + } + + toHex(): string { return bytesToHex(this.toBytes()); } + + static fromHex(hex: string): Result { + return Result.capture({ + recordsExecutionTimeMs: false, + task: () => DecryptionKey.fromBytes(hexStringToBytes(hex)) + .unwrapOrThrow("Hybrid DecryptionKey.fromHex"), + }); + } +} + +export class Ciphertext { + mlkem768Ct: Uint8Array; + aeadNonce: Uint8Array; + aeadCt: Uint8Array; + + constructor(mlkem768Ct: Uint8Array, aeadNonce: Uint8Array, aeadCt: Uint8Array) { + if (mlkem768Ct.length !== MLKEM768_CT_BYTES) { + throw new Error(`Ciphertext: mlkem768Ct must be ${MLKEM768_CT_BYTES} bytes, got ${mlkem768Ct.length}`); + } + if (aeadNonce.length !== AEAD_NONCE_BYTES) { + throw new Error(`Ciphertext: aeadNonce must be ${AEAD_NONCE_BYTES} bytes, got ${aeadNonce.length}`); + } + if (aeadCt.length < AEAD_TAG_BYTES) { + throw new Error(`Ciphertext: aeadCt must be >= ${AEAD_TAG_BYTES} bytes, got ${aeadCt.length}`); + } + this.mlkem768Ct = mlkem768Ct; + this.aeadNonce = aeadNonce; + this.aeadCt = aeadCt; + } + + static deserialize(d: Deserializer): Result { + return Result.capture({ + recordsExecutionTimeMs: false, + task: () => new Ciphertext(d.deserializeBytes(), d.deserializeBytes(), d.deserializeBytes()), + }); + } + + serialize(s: Serializer): void { + s.serializeBytes(this.mlkem768Ct); + s.serializeBytes(this.aeadNonce); + s.serializeBytes(this.aeadCt); + } + + toBytes(): Uint8Array { + const s = new Serializer(); + this.serialize(s); + return s.toUint8Array(); + } + + static fromBytes(bytes: Uint8Array): Result { + return Result.capture({ + recordsExecutionTimeMs: false, + task: () => { + const d = new Deserializer(bytes); + const ct = Ciphertext.deserialize(d).unwrapOrThrow("Hybrid Ciphertext.fromBytes"); + assertConsumed(d, "Hybrid Ciphertext.fromBytes"); + return ct; + }, + }); + } + + toHex(): string { return bytesToHex(this.toBytes()); } + + static fromHex(hex: string): Result { + return Result.capture({ + recordsExecutionTimeMs: false, + task: () => Ciphertext.fromBytes(hexStringToBytes(hex)) + .unwrapOrThrow("Hybrid Ciphertext.fromHex"), + }); + } +} + +export async function keygen(): Promise<{ encryptionKey: EncryptionKey; decryptionKey: DecryptionKey }> { + const { encryptionKey: hpkeEk, decryptionKey: hpkeDk } = + await HpkeX25519ChaCha20Poly1305.keygen(); + const mlkem768Seed = randomBytes(MLKEM768_DK_SEED_BYTES); + const mlkemKeys = ml_kem768.keygen(mlkem768Seed); + return { + encryptionKey: new EncryptionKey(hpkeEk, mlkemKeys.publicKey), + decryptionKey: new DecryptionKey(hpkeDk, mlkem768Seed), + }; +} + +export function deriveEncryptionKey(dk: DecryptionKey): EncryptionKey { + const hpkeEk = HpkeX25519ChaCha20Poly1305.deriveEncryptionKey(dk.hpkeX25519); + const mlkemKeys = ml_kem768.keygen(dk.mlkem768Seed); + return new EncryptionKey(hpkeEk, mlkemKeys.publicKey); +} + +export async function encrypt({ + encryptionKey, + plaintext, + aad, +}: { + encryptionKey: EncryptionKey; + plaintext: Uint8Array; + aad?: Uint8Array; +}): Promise { + const innerHpkeCt = await HpkeX25519ChaCha20Poly1305.encrypt({ + encryptionKey: encryptionKey.hpkeX25519, + plaintext, + aad, + }); + const innerHpkeBytes = innerHpkeCt.toBytes(); + const { cipherText: mlkem768Ct, sharedSecret } = ml_kem768.encapsulate(encryptionKey.mlkem768Ek); + const context = aad ?? new Uint8Array(); + const key = deriveOuterKey(sharedSecret, mlkem768Ct, context); + const nonce = randomBytes(AEAD_NONCE_BYTES); + const aeadCt = chacha20poly1305(key, nonce, context).encrypt(innerHpkeBytes); + return new Ciphertext(mlkem768Ct, nonce, aeadCt); +} + +export function decrypt( + dk: DecryptionKey, + ciphertext: Ciphertext, + aad?: Uint8Array, +): Promise> { + return Result.captureAsync({ + recordsExecutionTimeMs: false, + task: async () => { + const mlkemKeys = ml_kem768.keygen(dk.mlkem768Seed); + const sharedSecret = ml_kem768.decapsulate(ciphertext.mlkem768Ct, mlkemKeys.secretKey); + const context = aad ?? new Uint8Array(); + const key = deriveOuterKey(sharedSecret, ciphertext.mlkem768Ct, context); + const innerHpkeBytes = chacha20poly1305(key, ciphertext.aeadNonce, context) + .decrypt(ciphertext.aeadCt); + const innerHpkeCt = HpkeX25519ChaCha20Poly1305.Ciphertext.fromBytes(innerHpkeBytes) + .unwrapOrThrow("Hybrid decrypt: parse inner HPKE ciphertext"); + const pt = await HpkeX25519ChaCha20Poly1305.decrypt(dk.hpkeX25519, innerHpkeCt, aad); + return pt.unwrapOrThrow("Hybrid decrypt: inner HPKE open failed"); + }, + }); +} diff --git a/ts-sdk/src/pke/index.ts b/ts-sdk/src/pke/index.ts index d377ca28..f664191d 100644 --- a/ts-sdk/src/pke/index.ts +++ b/ts-sdk/src/pke/index.ts @@ -5,20 +5,25 @@ import { Deserializer, Serializer } from "@aptos-labs/ts-sdk"; import { bytesToHex, hexToBytes } from "@noble/hashes/utils"; import { Result } from "../result"; import * as ElGamalOtpRistretto255 from "./elgamal_otp_ristretto255"; +import * as HybridX25519MlKem768ChaCha20Poly1305 from "./hybrid_x25519_mlkem768_chacha20poly1305"; import * as HpkeX25519ChaCha20Poly1305 from "./hpke_x25519_chacha20poly1305"; export const SCHEME_ELGAMAL_OTP_RISTRETTO255 = 0; export const SCHEME_HPKE_X25519_HKDF_SHA256_CHACHA20POLY1305 = 1; +export const SCHEME_HYBRID_X25519_MLKEM768_CHACHA20POLY1305 = 2; type InnerEk = | ElGamalOtpRistretto255.EncryptionKey - | HpkeX25519ChaCha20Poly1305.EncryptionKey; + | HpkeX25519ChaCha20Poly1305.EncryptionKey + | HybridX25519MlKem768ChaCha20Poly1305.EncryptionKey; type InnerDk = | ElGamalOtpRistretto255.DecryptionKey - | HpkeX25519ChaCha20Poly1305.DecryptionKey; + | HpkeX25519ChaCha20Poly1305.DecryptionKey + | HybridX25519MlKem768ChaCha20Poly1305.DecryptionKey; type InnerCt = | ElGamalOtpRistretto255.Ciphertext - | HpkeX25519ChaCha20Poly1305.Ciphertext; + | HpkeX25519ChaCha20Poly1305.Ciphertext + | HybridX25519MlKem768ChaCha20Poly1305.Ciphertext; function hexStringToBytes(hex: string): Uint8Array { const h = hex.trim(); @@ -60,6 +65,11 @@ export class EncryptionKey { .unwrapOrThrow("EncryptionKey.deserialize: HPKE-X25519"); return new EncryptionKey(scheme, inner); } + if (scheme === SCHEME_HYBRID_X25519_MLKEM768_CHACHA20POLY1305) { + const inner = HybridX25519MlKem768ChaCha20Poly1305.EncryptionKey.deserialize(deserializer) + .unwrapOrThrow("EncryptionKey.deserialize: Hybrid-X25519-MLKEM768"); + return new EncryptionKey(scheme, inner); + } throw new Error(`Unknown PKE scheme: ${scheme}`); }, }); @@ -124,6 +134,11 @@ export class DecryptionKey { .unwrapOrThrow("DecryptionKey.deserialize: HPKE-X25519"); return new DecryptionKey(scheme, inner); } + if (scheme === SCHEME_HYBRID_X25519_MLKEM768_CHACHA20POLY1305) { + const inner = HybridX25519MlKem768ChaCha20Poly1305.DecryptionKey.deserialize(deserializer) + .unwrapOrThrow("DecryptionKey.deserialize: Hybrid-X25519-MLKEM768"); + return new DecryptionKey(scheme, inner); + } throw new Error(`Unknown PKE scheme: ${scheme}`); }, }); @@ -188,6 +203,11 @@ export class Ciphertext { .unwrapOrThrow("Ciphertext.deserialize: HPKE-X25519"); return new Ciphertext(scheme, inner); } + if (scheme === SCHEME_HYBRID_X25519_MLKEM768_CHACHA20POLY1305) { + const inner = HybridX25519MlKem768ChaCha20Poly1305.Ciphertext.deserialize(deserializer) + .unwrapOrThrow("Ciphertext.deserialize: Hybrid-X25519-MLKEM768"); + return new Ciphertext(scheme, inner); + } throw new Error(`Unknown PKE scheme: ${scheme}`); }, }); @@ -244,6 +264,12 @@ export async function deriveEncryptionKey(decryptionKey: DecryptionKey): Promise ); return new EncryptionKey(decryptionKey.scheme, ek); } + if (decryptionKey.scheme === SCHEME_HYBRID_X25519_MLKEM768_CHACHA20POLY1305) { + const ek = HybridX25519MlKem768ChaCha20Poly1305.deriveEncryptionKey( + decryptionKey.inner as HybridX25519MlKem768ChaCha20Poly1305.DecryptionKey, + ); + return new EncryptionKey(decryptionKey.scheme, ek); + } throw new Error(`deriveEncryptionKey: unknown scheme ${decryptionKey.scheme}`); } @@ -270,6 +296,14 @@ export async function keygen( encryptionKey: new EncryptionKey(scheme, ekInner), }; } + if (scheme === SCHEME_HYBRID_X25519_MLKEM768_CHACHA20POLY1305) { + const { encryptionKey: ekInner, decryptionKey: dkInner } = + await HybridX25519MlKem768ChaCha20Poly1305.keygen(); + return { + decryptionKey: new DecryptionKey(scheme, dkInner), + encryptionKey: new EncryptionKey(scheme, ekInner), + }; + } throw new Error(`keygen: unknown scheme ${scheme}`); } @@ -294,6 +328,13 @@ export async function encrypt({ }); return new Ciphertext(encryptionKey.scheme, ct); } + if (encryptionKey.scheme === SCHEME_HYBRID_X25519_MLKEM768_CHACHA20POLY1305) { + const ct = await HybridX25519MlKem768ChaCha20Poly1305.encrypt({ + encryptionKey: encryptionKey.inner as HybridX25519MlKem768ChaCha20Poly1305.EncryptionKey, + plaintext, + }); + return new Ciphertext(encryptionKey.scheme, ct); + } throw new Error(`encrypt: unknown scheme ${encryptionKey.scheme}`); } @@ -327,6 +368,13 @@ export async function decrypt({ ); return r.unwrapOrThrow("HPKE-X25519.decrypt failed"); } + if (decryptionKey.scheme === SCHEME_HYBRID_X25519_MLKEM768_CHACHA20POLY1305) { + const r = await HybridX25519MlKem768ChaCha20Poly1305.decrypt( + decryptionKey.inner as HybridX25519MlKem768ChaCha20Poly1305.DecryptionKey, + ciphertext.inner as HybridX25519MlKem768ChaCha20Poly1305.Ciphertext, + ); + return r.unwrapOrThrow("Hybrid-X25519-MLKEM768.decrypt failed"); + } throw new Error(`decrypt: unknown scheme ${decryptionKey.scheme}`); }, }); diff --git a/ts-sdk/tests/hybrid_x25519_mlkem768_chacha20poly1305.bench.ts b/ts-sdk/tests/hybrid_x25519_mlkem768_chacha20poly1305.bench.ts new file mode 100644 index 00000000..d459a3e7 --- /dev/null +++ b/ts-sdk/tests/hybrid_x25519_mlkem768_chacha20poly1305.bench.ts @@ -0,0 +1,63 @@ +// Copyright (c) Aptos Labs +// SPDX-License-Identifier: Apache-2.0 + +/** + * Microbenchmarks for Hybrid-X25519-MLKEM768-HKDF-SHA256-ChaCha20Poly1305. + * + * Run with: pnpm vitest bench --run hybrid_x25519_mlkem768_chacha20poly1305.bench + */ +import { bench, describe } from "vitest"; +import * as hybrid from "../src/pke/hybrid_x25519_mlkem768_chacha20poly1305"; + +const SIZES = [ + { label: "32B", bytes: 32 }, + { label: "1KB", bytes: 1024 }, + { label: "64KB", bytes: 64 * 1024 }, +]; + +describe("Hybrid PKE bench: keygen", () => { + bench("keygen", async () => { + await hybrid.keygen(); + }); +}); + +for (const { label, bytes } of SIZES) { + describe(`Hybrid PKE bench: encrypt ${label}`, () => { + let pinned: { ek: hybrid.EncryptionKey; pt: Uint8Array }; + + bench( + `encrypt ${label}`, + async () => { + await hybrid.encrypt({ encryptionKey: pinned.ek, plaintext: pinned.pt }); + }, + { + setup: async () => { + const { encryptionKey } = await hybrid.keygen(); + const pt = new Uint8Array(bytes); + for (let i = 0; i < bytes; i++) pt[i] = i & 0xff; + pinned = { ek: encryptionKey, pt }; + }, + }, + ); + }); + + describe(`Hybrid PKE bench: decrypt ${label}`, () => { + let pinned: { dk: hybrid.DecryptionKey; ct: hybrid.Ciphertext }; + + bench( + `decrypt ${label}`, + async () => { + await hybrid.decrypt(pinned.dk, pinned.ct); + }, + { + setup: async () => { + const { encryptionKey, decryptionKey } = await hybrid.keygen(); + const pt = new Uint8Array(bytes); + for (let i = 0; i < bytes; i++) pt[i] = i & 0xff; + const ct = await hybrid.encrypt({ encryptionKey, plaintext: pt }); + pinned = { dk: decryptionKey, ct }; + }, + }, + ); + }); +} diff --git a/ts-sdk/tests/hybrid_x25519_mlkem768_chacha20poly1305.test.ts b/ts-sdk/tests/hybrid_x25519_mlkem768_chacha20poly1305.test.ts new file mode 100644 index 00000000..a8d4c31a --- /dev/null +++ b/ts-sdk/tests/hybrid_x25519_mlkem768_chacha20poly1305.test.ts @@ -0,0 +1,137 @@ +// Copyright (c) Aptos Labs +// SPDX-License-Identifier: Apache-2.0 + +import { describe, expect, it } from "vitest"; +import * as hybrid from "../src/pke/hybrid_x25519_mlkem768_chacha20poly1305"; + +const utf8 = (s: string) => new TextEncoder().encode(s); + +describe("Hybrid-X25519-MLKEM768-HKDF-SHA256-ChaCha20Poly1305", () => { + it("keygen, encrypt, decrypt round-trip", async () => { + const { encryptionKey, decryptionKey } = await hybrid.keygen(); + const plaintext = utf8("hello hybrid pke"); + + const ct = await hybrid.encrypt({ encryptionKey, plaintext }); + const r = await hybrid.decrypt(decryptionKey, ct); + + expect(r.isOk).toBe(true); + expect(r.okValue).toEqual(plaintext); + }); + + it("derives public key from private key consistently", async () => { + const { encryptionKey, decryptionKey } = await hybrid.keygen(); + const derived = hybrid.deriveEncryptionKey(decryptionKey); + expect(derived.hpkeX25519.pk).toEqual(encryptionKey.hpkeX25519.pk); + expect(derived.mlkem768Ek).toEqual(encryptionKey.mlkem768Ek); + }); + + it("encrypts and decrypts a 64KB plaintext", async () => { + const { encryptionKey, decryptionKey } = await hybrid.keygen(); + const plaintext = new Uint8Array(64 * 1024); + for (let i = 0; i < plaintext.length; i++) plaintext[i] = i & 0xff; + const ct = await hybrid.encrypt({ encryptionKey, plaintext }); + const r = await hybrid.decrypt(decryptionKey, ct); + expect(r.isOk).toBe(true); + expect(r.okValue).toEqual(plaintext); + }); + + it("AAD must match", async () => { + const { encryptionKey, decryptionKey } = await hybrid.keygen(); + const ct = await hybrid.encrypt({ + encryptionKey, + plaintext: utf8("msg"), + aad: utf8("ctx-A"), + }); + + const ok = await hybrid.decrypt(decryptionKey, ct, utf8("ctx-A")); + expect(ok.isOk).toBe(true); + expect(ok.okValue).toEqual(utf8("msg")); + + const bad = await hybrid.decrypt(decryptionKey, ct, utf8("ctx-B")); + expect(bad.isOk).toBe(false); + }); + + it("rejects tampered outer AEAD ciphertext", async () => { + const { encryptionKey, decryptionKey } = await hybrid.keygen(); + const ct = await hybrid.encrypt({ encryptionKey, plaintext: utf8("secret") }); + const bad = new hybrid.Ciphertext( + ct.mlkem768Ct, + ct.aeadNonce, + new Uint8Array(ct.aeadCt), + ); + bad.aeadCt[0] ^= 1; + const r = await hybrid.decrypt(decryptionKey, bad); + expect(r.isOk).toBe(false); + }); + + it("rejects tampered ML-KEM ciphertext via outer AEAD", async () => { + const { encryptionKey, decryptionKey } = await hybrid.keygen(); + const ct = await hybrid.encrypt({ encryptionKey, plaintext: utf8("secret") }); + const badMlkemCt = new Uint8Array(ct.mlkem768Ct); + badMlkemCt[0] ^= 1; + const bad = new hybrid.Ciphertext(badMlkemCt, ct.aeadNonce, ct.aeadCt); + const r = await hybrid.decrypt(decryptionKey, bad); + expect(r.isOk).toBe(false); + }); + + it("decryption with the wrong private key fails", async () => { + const { encryptionKey } = await hybrid.keygen(); + const { decryptionKey: wrongDk } = await hybrid.keygen(); + const ct = await hybrid.encrypt({ encryptionKey, plaintext: utf8("secret") }); + const r = await hybrid.decrypt(wrongDk, ct); + expect(r.isOk).toBe(false); + }); + + it("BCS round-trips and checks lengths", async () => { + const { encryptionKey, decryptionKey } = await hybrid.keygen(); + + const ekBytes = encryptionKey.toBytes(); + // HPKE key: 1 + 32, then ML-KEM-768 ek: ULEB128(1184) + 1184. + expect(ekBytes.length).toBe(33 + 2 + 1184); + expect(ekBytes[0]).toBe(0x20); + expect(ekBytes[33]).toBe(0xa0); + expect(ekBytes[34]).toBe(0x09); + expect(hybrid.EncryptionKey.fromBytes(ekBytes).unwrapOrThrow("ek").toBytes()) + .toEqual(ekBytes); + + const dkBytes = decryptionKey.toBytes(); + expect(dkBytes.length).toBe(33 + 1 + 64); + expect(dkBytes[0]).toBe(0x20); + expect(dkBytes[33]).toBe(0x40); + expect(hybrid.DecryptionKey.fromBytes(dkBytes).unwrapOrThrow("dk").toBytes()) + .toEqual(dkBytes); + + const ct = await hybrid.encrypt({ encryptionKey, plaintext: utf8("xyzzy") }); + const ctBytes = ct.toBytes(); + expect(ctBytes[0]).toBe(0xc0); + expect(ctBytes[1]).toBe(0x08); + expect(ctBytes[2 + 1088]).toBe(0x0c); + expect(hybrid.Ciphertext.fromBytes(ctBytes).unwrapOrThrow("ct").toBytes()) + .toEqual(ctBytes); + }); + + it("rejects invalid lengths", async () => { + const { encryptionKey, decryptionKey } = await hybrid.keygen(); + expect(() => new hybrid.EncryptionKey( + encryptionKey.hpkeX25519, + encryptionKey.mlkem768Ek.slice(0, 1183), + )).toThrow(); + expect(() => new hybrid.DecryptionKey( + decryptionKey.hpkeX25519, + decryptionKey.mlkem768Seed.slice(0, 63), + )).toThrow(); + const ct = await hybrid.encrypt({ encryptionKey, plaintext: utf8("secret") }); + expect(() => new hybrid.Ciphertext( + ct.mlkem768Ct.slice(0, 1087), + ct.aeadNonce, + ct.aeadCt, + )).toThrow(); + }); + + it("rejects trailing bytes", async () => { + const { encryptionKey } = await hybrid.keygen(); + const bytes = encryptionKey.toBytes(); + const trailing = new Uint8Array([...bytes, 0xff]); + expect(hybrid.EncryptionKey.fromBytes(trailing).isOk).toBe(false); + }); +}); diff --git a/ts-sdk/tests/pke.test.ts b/ts-sdk/tests/pke.test.ts index 707e1e6b..aba32b41 100644 --- a/ts-sdk/tests/pke.test.ts +++ b/ts-sdk/tests/pke.test.ts @@ -112,3 +112,41 @@ describe("PKE (ElGamal OTP Ristretto255)", () => { expect(pke.DecryptionKey.fromHex(`${GOLDEN_DEC_KEY_HEX}00`).isOk).toBe(false); }); }); + +describe("PKE hybrid scheme dispatch", () => { + it("keygen(2), encrypt, decrypt round-trip", async () => { + const { encryptionKey, decryptionKey } = await pke.keygen( + pke.SCHEME_HYBRID_X25519_MLKEM768_CHACHA20POLY1305, + ); + const plaintext = new TextEncoder().encode("hello hybrid pke dispatch"); + + const ciphertext = await pke.encrypt({ encryptionKey, plaintext }); + const result = await pke.decrypt({ decryptionKey, ciphertext }); + + expect(encryptionKey.scheme).toBe(pke.SCHEME_HYBRID_X25519_MLKEM768_CHACHA20POLY1305); + expect(ciphertext.scheme).toBe(pke.SCHEME_HYBRID_X25519_MLKEM768_CHACHA20POLY1305); + expect(result.isOk).toBe(true); + expect(new Uint8Array(result.okValue!)).toEqual(plaintext); + }); + + it("hybrid keys and ciphertexts round-trip through outer BCS", async () => { + const { encryptionKey, decryptionKey } = await pke.keygen( + pke.SCHEME_HYBRID_X25519_MLKEM768_CHACHA20POLY1305, + ); + const ciphertext = await pke.encrypt({ + encryptionKey, + plaintext: new TextEncoder().encode("outer wire"), + }); + + expect(encryptionKey.toBytes()[0]).toBe(pke.SCHEME_HYBRID_X25519_MLKEM768_CHACHA20POLY1305); + expect(decryptionKey.toBytes()[0]).toBe(pke.SCHEME_HYBRID_X25519_MLKEM768_CHACHA20POLY1305); + expect(ciphertext.toBytes()[0]).toBe(pke.SCHEME_HYBRID_X25519_MLKEM768_CHACHA20POLY1305); + + expect(pke.EncryptionKey.fromBytes(encryptionKey.toBytes()).unwrapOrThrow("ek").toBytes()) + .toEqual(encryptionKey.toBytes()); + expect(pke.DecryptionKey.fromBytes(decryptionKey.toBytes()).unwrapOrThrow("dk").toBytes()) + .toEqual(decryptionKey.toBytes()); + expect(pke.Ciphertext.fromBytes(ciphertext.toBytes()).unwrapOrThrow("ct").toBytes()) + .toEqual(ciphertext.toBytes()); + }); +}); diff --git a/worker-components/pke/Cargo.toml b/worker-components/pke/Cargo.toml new file mode 100644 index 00000000..f8303706 --- /dev/null +++ b/worker-components/pke/Cargo.toml @@ -0,0 +1,21 @@ +# Copyright (c) Aptos Labs +# SPDX-License-Identifier: Apache-2.0 + +[package] +name = "ace-pke" +version = "0.1.0" +edition = "2021" +description = "ACE public-key encryption schemes and BCS wire types." + +[dependencies] +anyhow = "1" +serde = { version = "1", features = ["derive"] } +bcs = "0.1" +curve25519-dalek = { version = "4", features = ["rand_core"] } +rand = "0.8" +sha2 = "0.10" +sha3 = "0.10" +hpke = { version = "0.12", default-features = false, features = ["std", "x25519"] } +ml-kem = { version = "0.3.2", features = ["getrandom", "zeroize"] } +chacha20poly1305 = { version = "0.10", features = ["alloc"] } +hkdf = "0.12" diff --git a/worker-components/pke/src/lib.rs b/worker-components/pke/src/lib.rs new file mode 100644 index 00000000..a51aff44 --- /dev/null +++ b/worker-components/pke/src/lib.rs @@ -0,0 +1,335 @@ +// Copyright (c) Aptos Labs +// SPDX-License-Identifier: Apache-2.0 + +//! PKE types mirroring `contracts/pke/sources/pke.move`. +//! Schemes 0/1/2 are also mirrored in `ts-sdk/src/pke/index.ts`; scheme 2 is a +//! post-quantum/hybrid prototype and is not production-audited. +//! +//! `EncryptionKey` and `Ciphertext` are serde-derived enums. BCS encodes an enum as +//! `[ULEB128 variant tag][variant fields...]`; for tags 0/1 the ULEB128 is a single +//! byte, so the on-wire format matches `[scheme byte][inner]` exactly. Adding a new +//! scheme is one new variant + one match arm — no per-scheme size table, no manual +//! byte-walking. + +pub mod pke_hybrid_x25519_mlkem768_chacha20poly1305; +pub mod pke_hpke_x25519_chacha20poly1305; + +use anyhow::{anyhow, Result}; +use curve25519_dalek::{ristretto::CompressedRistretto, RistrettoPoint, Scalar}; +use rand::rngs::OsRng; +use serde::{Deserialize, Serialize}; +use sha3::{Digest, Sha3_256}; + +use crate::pke_hybrid_x25519_mlkem768_chacha20poly1305 as hybrid_pq_scheme; +use crate::pke_hpke_x25519_chacha20poly1305 as hpke_scheme; + +pub const SCHEME_ELGAMAL_OTP_RISTRETTO255: u8 = 0; +pub const SCHEME_HPKE_X25519_HKDF_SHA256_CHACHA20POLY1305: u8 = 1; +pub const SCHEME_HYBRID_X25519_MLKEM768_CHACHA20POLY1305: u8 = 2; + +// ── Legacy ElGamal KDF/MAC helpers ─────────────────────────────────────────── + +/// KDF matching `ts-sdk/src/utils.ts::kdf`. +/// +/// Each 32-byte block: SHA3-256(BCS(seed) ++ BCS(dst) ++ LE64(target_len) ++ LE64(block_idx)) +/// where BCS(bytes) = ULEB128(len) ++ bytes. +/// +/// `seed` must be >= 32 bytes (it is the BCS-encoded Ristretto255 group element = 33 bytes). +pub fn kdf(seed: &[u8], dst: &[u8], target_len: usize) -> Vec { + let mut output = Vec::with_capacity(target_len); + let mut block_idx: u64 = 0; + let mut remaining = target_len; + while remaining > 0 { + let mut hasher = Sha3_256::new(); + // serializeBytes(seed) = ULEB128(seed.len()) ++ seed + update_with_bcs_bytes(&mut hasher, seed); + // serializeBytes(dst) = ULEB128(dst.len()) ++ dst + update_with_bcs_bytes(&mut hasher, dst); + // serializeU64(target_len) = LE64 + hasher.update((target_len as u64).to_le_bytes()); + // serializeU64(block_idx) = LE64 + hasher.update(block_idx.to_le_bytes()); + + let block = hasher.finalize(); + let take = remaining.min(32); + output.extend_from_slice(&block[..take]); + remaining -= take; + block_idx += 1; + } + output +} + +/// HMAC-SHA3-256 matching `ts-sdk/src/utils.ts::hmac_sha3_256`. +/// +/// 32-byte key is padded to 64 bytes by appending 32 zero bytes. +/// Standard HMAC construction with ipad=0x36 and opad=0x5c. +pub fn hmac_sha3_256(key: &[u8; 32], msg: &[u8]) -> [u8; 32] { + let mut padded = [0u8; 64]; + padded[..32].copy_from_slice(key); + // padded[32..64] = 0x00 (already zero) + + let ipad = [0x36u8; 64]; + let opad = [0x5cu8; 64]; + + let inner_key: Vec = padded.iter().zip(ipad.iter()).map(|(a, b)| a ^ b).collect(); + let outer_key: Vec = padded.iter().zip(opad.iter()).map(|(a, b)| a ^ b).collect(); + + let mut inner = Sha3_256::new(); + inner.update(&inner_key); + inner.update(msg); + let inner_hash = inner.finalize(); + + let mut outer = Sha3_256::new(); + outer.update(&outer_key); + outer.update(inner_hash); + outer.finalize().into() +} + +/// Write ULEB128(bytes.len()) ++ bytes into the hasher. +fn update_with_bcs_bytes(hasher: &mut Sha3_256, bytes: &[u8]) { + let mut len_buf = Vec::new(); + write_uleb128(&mut len_buf, bytes.len() as u64); + hasher.update(&len_buf); + hasher.update(bytes); +} + +fn write_uleb128(out: &mut Vec, mut v: u64) { + loop { + let byte = (v & 0x7f) as u8; + v >>= 7; + if v == 0 { + out.push(byte); + break; + } + out.push(byte | 0x80); + } +} + +// ── EncryptionKey ───────────────────────────────────────────────────────────── + +#[derive(Clone, Debug, Serialize, Deserialize)] +pub enum EncryptionKey { + ElGamalOtpRistretto255(ElGamalOtpRistretto255EncKey), + HpkeX25519ChaCha20Poly1305(hpke_scheme::EncryptionKey), + HybridX25519MlKem768ChaCha20Poly1305(hybrid_pq_scheme::EncryptionKey), +} + +#[derive(Clone, Debug, Serialize, Deserialize)] +pub struct ElGamalOtpRistretto255EncKey { + pub enc_base: Vec, // 32 bytes + pub public_point: Vec, // 32 bytes +} + +// ── Ciphertext ──────────────────────────────────────────────────────────────── + +#[derive(Clone, Debug, Serialize, Deserialize)] +pub enum Ciphertext { + ElGamalOtpRistretto255(ElGamalOtpRistretto255Ciphertext), + HpkeX25519ChaCha20Poly1305(hpke_scheme::Ciphertext), + HybridX25519MlKem768ChaCha20Poly1305(hybrid_pq_scheme::Ciphertext), +} + +#[derive(Clone, Debug, Serialize, Deserialize)] +pub struct ElGamalOtpRistretto255Ciphertext { + pub c0: Vec, // 32 bytes + pub c1: Vec, // 32 bytes + pub sym_ciph: Vec, + pub mac: Vec, // 32 bytes +} + +// ── PKE encrypt ─────────────────────────────────────────────────────────────── + +/// Encrypt `plaintext` under `key`, matching the TypeScript SDK PKE schemes. +pub fn pke_encrypt(key: &EncryptionKey, plaintext: &[u8]) -> Ciphertext { + match key { + EncryptionKey::ElGamalOtpRistretto255(inner) => { + let enc_base_arr: [u8; 32] = inner + .enc_base + .as_slice() + .try_into() + .expect("enc_base must be 32 bytes"); + let public_point_arr: [u8; 32] = inner + .public_point + .as_slice() + .try_into() + .expect("public_point must be 32 bytes"); + + let enc_base_pt = CompressedRistretto(enc_base_arr) + .decompress() + .expect("enc_base is not a valid Ristretto point"); + let public_point_pt = CompressedRistretto(public_point_arr) + .decompress() + .expect("public_point is not a valid Ristretto point"); + + // elgamalPtxt = random Ristretto point (the "message" in ElGamal) + let ephemeral_pt = RistrettoPoint::random(&mut OsRng); + // elgamalRand = random scalar (ElGamal randomness) + let r = Scalar::random(&mut OsRng); + + // c0 = r * enc_base_pt + let c0 = (r * enc_base_pt).compress().to_bytes(); + // c1 = ephemeral_pt + r * public_point_pt + let c1 = (ephemeral_pt + r * public_point_pt).compress().to_bytes(); + + // seed = element.toBytes() = serializeBytes(compressed_point) = [ULEB128(32)][32B] + let ephemeral_compressed = ephemeral_pt.compress().to_bytes(); + let mut seed = Vec::with_capacity(33); + seed.push(0x20u8); // ULEB128(32) = 0x20 + seed.extend_from_slice(&ephemeral_compressed); + + let otp = kdf(&seed, b"OTP/ELGAMAL_OTP_RISTRETTO255", plaintext.len()); + let sym_ciph: Vec = otp + .iter() + .zip(plaintext.iter()) + .map(|(a, b)| a ^ b) + .collect(); + + let hmac_key_vec = kdf(&seed, b"HMAC/ELGAMAL_OTP_RISTRETTO255", 32); + let hmac_key: [u8; 32] = hmac_key_vec.try_into().expect("kdf produced 32 bytes"); + let mac = hmac_sha3_256(&hmac_key, &sym_ciph); + + Ciphertext::ElGamalOtpRistretto255(ElGamalOtpRistretto255Ciphertext { + c0: c0.to_vec(), + c1: c1.to_vec(), + sym_ciph, + mac: mac.to_vec(), + }) + } + EncryptionKey::HpkeX25519ChaCha20Poly1305(ek) => { + let ct = hpke_scheme::encrypt(ek, plaintext, b"") + .expect("HPKE encrypt: invalid public key"); + Ciphertext::HpkeX25519ChaCha20Poly1305(ct) + } + EncryptionKey::HybridX25519MlKem768ChaCha20Poly1305(ek) => { + let ct = hybrid_pq_scheme::encrypt(ek, plaintext, b"") + .expect("hybrid PKE encrypt: invalid public key"); + Ciphertext::HybridX25519MlKem768ChaCha20Poly1305(ct) + } + } +} + +// ── PKE decrypt ─────────────────────────────────────────────────────────────── + +/// Decrypt a `Ciphertext` using the given decryption key. `dk_bytes` is the wire-format +/// decryption key (`[scheme byte][inner BCS]`); the leading scheme byte must match `ct`. +pub fn pke_decrypt(dk_bytes: &[u8], ct: &Ciphertext) -> Result> { + if dk_bytes.is_empty() { + return Err(anyhow!("empty decryption-key bytes")); + } + match ct { + Ciphertext::ElGamalOtpRistretto255(inner) => { + if dk_bytes[0] != SCHEME_ELGAMAL_OTP_RISTRETTO255 { + return Err(anyhow!( + "PKE scheme mismatch: dk={}, ct=ElGamalOtpRistretto255", + dk_bytes[0] + )); + } + elgamal_otp_ristretto255_decrypt(dk_bytes, inner) + } + Ciphertext::HpkeX25519ChaCha20Poly1305(inner) => { + if dk_bytes[0] != SCHEME_HPKE_X25519_HKDF_SHA256_CHACHA20POLY1305 { + return Err(anyhow!( + "PKE scheme mismatch: dk={}, ct=HpkeX25519ChaCha20Poly1305", + dk_bytes[0] + )); + } + let dk = hpke_scheme::DecryptionKey::from_bytes(&dk_bytes[1..])?; + hpke_scheme::decrypt(&dk, inner, b"") + } + Ciphertext::HybridX25519MlKem768ChaCha20Poly1305(inner) => { + if dk_bytes[0] != SCHEME_HYBRID_X25519_MLKEM768_CHACHA20POLY1305 { + return Err(anyhow!( + "PKE scheme mismatch: dk={}, ct=HybridX25519MlKem768ChaCha20Poly1305", + dk_bytes[0] + )); + } + let dk = hybrid_pq_scheme::DecryptionKey::from_bytes(&dk_bytes[1..])?; + hybrid_pq_scheme::decrypt(&dk, inner, b"") + } + } +} + +/// Decrypt a ciphertext from its on-wire BCS bytes. +pub fn pke_decrypt_bytes(dk_bytes: &[u8], ct_bytes: &[u8]) -> Result> { + let ct: Ciphertext = bcs::from_bytes(ct_bytes) + .map_err(|e| anyhow!("pke_decrypt_bytes: BCS parse: {}", e))?; + pke_decrypt(dk_bytes, &ct) +} + +/// Decrypt the ElGamal-OTP-Ristretto255 variant. +/// +/// `dk_bytes` format: `[0x00 scheme][0x20 ULEB128(32)][32B encBase][0x20 ULEB128(32)][32B privateScalar]` — 67 bytes +/// (matches `pke.DecryptionKey.toBytes()` from the TypeScript SDK). +/// Decryption only uses `privateScalar` (bytes [35..67]). +fn elgamal_otp_ristretto255_decrypt( + dk_bytes: &[u8], + ct: &ElGamalOtpRistretto255Ciphertext, +) -> Result> { + if dk_bytes.len() < 67 || dk_bytes[0] != SCHEME_ELGAMAL_OTP_RISTRETTO255 { + return Err(anyhow!("invalid dk format (expected 67 bytes with scheme 0x00)")); + } + // privateScalar is at bytes[35..67]; bytes[2..34] is encBase (not needed for decryption) + let dk = Scalar::from_canonical_bytes( + dk_bytes[35..67].try_into().map_err(|_| anyhow!("dk scalar slice wrong length"))?, + ) + .into_option() + .ok_or_else(|| anyhow!("invalid dk scalar"))?; + + let c0_arr: [u8; 32] = ct.c0.as_slice().try_into().map_err(|_| anyhow!("c0 must be 32 bytes"))?; + let c1_arr: [u8; 32] = ct.c1.as_slice().try_into().map_err(|_| anyhow!("c1 must be 32 bytes"))?; + let mac_arr: [u8; 32] = ct.mac.as_slice().try_into().map_err(|_| anyhow!("mac must be 32 bytes"))?; + + let c0_pt = CompressedRistretto(c0_arr) + .decompress() + .ok_or_else(|| anyhow!("c0 not a valid Ristretto point"))?; + let c1_pt = CompressedRistretto(c1_arr) + .decompress() + .ok_or_else(|| anyhow!("c1 not a valid Ristretto point"))?; + + // ephemeral = c1 - dk * c0 + let ephemeral = c1_pt - dk * c0_pt; + let mut seed = vec![0x20u8]; // ULEB128(32) + seed.extend_from_slice(&ephemeral.compress().to_bytes()); + + // HMAC verify + let hmac_key_vec = kdf(&seed, b"HMAC/ELGAMAL_OTP_RISTRETTO255", 32); + let hmac_key: [u8; 32] = hmac_key_vec.try_into().unwrap(); + let expected_mac = hmac_sha3_256(&hmac_key, &ct.sym_ciph); + if expected_mac != mac_arr { + return Err(anyhow!("PKE decryption failed: HMAC mismatch")); + } + + // Decrypt + let otp = kdf(&seed, b"OTP/ELGAMAL_OTP_RISTRETTO255", ct.sym_ciph.len()); + Ok(otp.iter().zip(ct.sym_ciph.iter()).map(|(a, b)| a ^ b).collect()) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn hybrid_scheme_dispatch_round_trip() { + let (ek_inner, dk_inner) = hybrid_pq_scheme::keygen(); + let ek = EncryptionKey::HybridX25519MlKem768ChaCha20Poly1305(ek_inner); + let ct = pke_encrypt(&ek, b"hybrid dispatch plaintext"); + + let mut dk_bytes = vec![SCHEME_HYBRID_X25519_MLKEM768_CHACHA20POLY1305]; + dk_bytes.extend_from_slice(&dk_inner.to_bytes()); + + let got = pke_decrypt(&dk_bytes, &ct).unwrap(); + assert_eq!(got, b"hybrid dispatch plaintext"); + } + + #[test] + fn hybrid_scheme_mismatch_rejected() { + let (ek_inner, dk_inner) = hybrid_pq_scheme::keygen(); + let ek = EncryptionKey::HybridX25519MlKem768ChaCha20Poly1305(ek_inner); + let ct = pke_encrypt(&ek, b"hybrid dispatch plaintext"); + + let mut dk_bytes = vec![SCHEME_HPKE_X25519_HKDF_SHA256_CHACHA20POLY1305]; + dk_bytes.extend_from_slice(&dk_inner.to_bytes()); + + assert!(pke_decrypt(&dk_bytes, &ct).is_err()); + } +} diff --git a/worker-components/vss-common/src/pke_hpke_x25519_chacha20poly1305.rs b/worker-components/pke/src/pke_hpke_x25519_chacha20poly1305.rs similarity index 99% rename from worker-components/vss-common/src/pke_hpke_x25519_chacha20poly1305.rs rename to worker-components/pke/src/pke_hpke_x25519_chacha20poly1305.rs index 585f86ee..6ec1481b 100644 --- a/worker-components/vss-common/src/pke_hpke_x25519_chacha20poly1305.rs +++ b/worker-components/pke/src/pke_hpke_x25519_chacha20poly1305.rs @@ -257,7 +257,7 @@ mod tests { } /// Easy-to-run benchmark. - /// cargo test -p vss-common --release bench_hpke -- --ignored --nocapture + /// cargo test -p ace-pke --release bench_hpke -- --ignored --nocapture #[test] #[ignore] fn bench_hpke() { diff --git a/worker-components/pke/src/pke_hybrid_x25519_mlkem768_chacha20poly1305.rs b/worker-components/pke/src/pke_hybrid_x25519_mlkem768_chacha20poly1305.rs new file mode 100644 index 00000000..406004b2 --- /dev/null +++ b/worker-components/pke/src/pke_hybrid_x25519_mlkem768_chacha20poly1305.rs @@ -0,0 +1,433 @@ +// Copyright (c) Aptos Labs +// SPDX-License-Identifier: Apache-2.0 + +//! Hybrid PKE for long-lived on-chain share transport: +//! inner: HPKE-X25519-HKDF-SHA256-ChaCha20Poly1305 +//! outer: ML-KEM-768 shared secret -> HKDF-SHA256 -> ChaCha20-Poly1305 +//! +//! This is intentionally a nested construction. The plaintext is first encrypted +//! with the existing X25519 HPKE scheme, and the serialized HPKE ciphertext is then +//! encrypted with a key derived from ML-KEM-768. Historical ciphertexts remain +//! confidential if either X25519 or ML-KEM-768 remains secure. +//! +//! BCS layout (no leading scheme byte; the abstract `pke` outer enum prepends it): +//! EncryptionKey = HpkeEncryptionKey || [ULEB128(1184)] [1184B ML-KEM ek] +//! DecryptionKey = HpkeDecryptionKey || [ULEB128(64)] [64B ML-KEM seed] +//! Ciphertext = [ULEB128(1088)] [1088B ML-KEM ct] +//! [ULEB128(12)] [12B nonce] +//! [ULEB128(len)] [len B outer AEAD ct] + +use anyhow::{anyhow, Result}; +use chacha20poly1305::{ + aead::{Aead, KeyInit, Payload}, + ChaCha20Poly1305, Key, Nonce, +}; +use hkdf::Hkdf; +use ml_kem::{ + kem::{Decapsulate, Encapsulate, Kem, KeyExport}, + MlKem768, Seed, +}; +use rand::{rngs::OsRng, RngCore}; +use serde::{Deserialize, Serialize}; +use sha2::Sha256; + +use crate::pke_hpke_x25519_chacha20poly1305 as hpke_scheme; + +const MLKEM768_EK_BYTES: usize = 1184; +const MLKEM768_DK_SEED_BYTES: usize = 64; +const MLKEM768_CT_BYTES: usize = 1088; +const AEAD_KEY_BYTES: usize = 32; +const AEAD_NONCE_BYTES: usize = 12; +const AEAD_TAG_BYTES: usize = 16; +const HKDF_SALT: &[u8] = b"ACE-PKE-HYBRID-X25519-MLKEM768-CHACHA20POLY1305/v0"; + +// ── BCS wire structs ────────────────────────────────────────────────────────── + +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub struct EncryptionKey { + pub hpke_x25519: hpke_scheme::EncryptionKey, + pub mlkem768_ek: Vec, +} + +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub struct DecryptionKey { + pub hpke_x25519: hpke_scheme::DecryptionKey, + pub mlkem768_seed: Vec, +} + +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub struct Ciphertext { + pub mlkem768_ct: Vec, + pub aead_nonce: Vec, + pub aead_ct: Vec, +} + +impl EncryptionKey { + pub fn to_bytes(&self) -> Vec { + bcs::to_bytes(self).expect("BCS serialization cannot fail for Vec") + } + + pub fn from_bytes(data: &[u8]) -> Result { + let ek: EncryptionKey = + bcs::from_bytes(data).map_err(|e| anyhow!("Hybrid EncryptionKey BCS decode: {}", e))?; + ek.validate()?; + Ok(ek) + } + + fn validate(&self) -> Result<()> { + hpke_scheme::EncryptionKey::from_bytes(&self.hpke_x25519.to_bytes())?; + if self.mlkem768_ek.len() != MLKEM768_EK_BYTES { + return Err(anyhow!( + "Hybrid EncryptionKey: mlkem768_ek must be {} bytes, got {}", + MLKEM768_EK_BYTES, + self.mlkem768_ek.len() + )); + } + let _ = mlkem768_encapsulation_key(&self.mlkem768_ek)?; + Ok(()) + } +} + +impl DecryptionKey { + pub fn to_bytes(&self) -> Vec { + bcs::to_bytes(self).expect("BCS serialization cannot fail for Vec") + } + + pub fn from_bytes(data: &[u8]) -> Result { + let dk: DecryptionKey = + bcs::from_bytes(data).map_err(|e| anyhow!("Hybrid DecryptionKey BCS decode: {}", e))?; + dk.validate()?; + Ok(dk) + } + + fn validate(&self) -> Result<()> { + hpke_scheme::DecryptionKey::from_bytes(&self.hpke_x25519.to_bytes())?; + if self.mlkem768_seed.len() != MLKEM768_DK_SEED_BYTES { + return Err(anyhow!( + "Hybrid DecryptionKey: mlkem768_seed must be {} bytes, got {}", + MLKEM768_DK_SEED_BYTES, + self.mlkem768_seed.len() + )); + } + let _ = mlkem768_decapsulation_key(&self.mlkem768_seed)?; + Ok(()) + } +} + +impl Ciphertext { + pub fn to_bytes(&self) -> Vec { + bcs::to_bytes(self).expect("BCS serialization cannot fail for Vec") + } + + pub fn from_bytes(data: &[u8]) -> Result { + let ct: Ciphertext = + bcs::from_bytes(data).map_err(|e| anyhow!("Hybrid Ciphertext BCS decode: {}", e))?; + ct.validate()?; + Ok(ct) + } + + fn validate(&self) -> Result<()> { + if self.mlkem768_ct.len() != MLKEM768_CT_BYTES { + return Err(anyhow!( + "Hybrid Ciphertext: mlkem768_ct must be {} bytes, got {}", + MLKEM768_CT_BYTES, + self.mlkem768_ct.len() + )); + } + if self.aead_nonce.len() != AEAD_NONCE_BYTES { + return Err(anyhow!( + "Hybrid Ciphertext: aead_nonce must be {} bytes, got {}", + AEAD_NONCE_BYTES, + self.aead_nonce.len() + )); + } + if self.aead_ct.len() < AEAD_TAG_BYTES { + return Err(anyhow!( + "Hybrid Ciphertext: aead_ct must be >= {} bytes, got {}", + AEAD_TAG_BYTES, + self.aead_ct.len() + )); + } + Ok(()) + } +} + +// ── Hybrid PKE ops ──────────────────────────────────────────────────────────── + +/// Generate a fresh hybrid keypair using OS randomness. +pub fn keygen() -> (EncryptionKey, DecryptionKey) { + let (hpke_ek, hpke_dk) = hpke_scheme::keygen(); + let (mlkem_dk, mlkem_ek) = MlKem768::generate_keypair(); + let mlkem_seed = mlkem_dk + .to_seed() + .expect("ML-KEM keygen returns seed-backed decapsulation keys"); + + ( + EncryptionKey { + hpke_x25519: hpke_ek, + mlkem768_ek: mlkem_ek.to_bytes().as_slice().to_vec(), + }, + DecryptionKey { + hpke_x25519: hpke_dk, + mlkem768_seed: mlkem_seed.as_slice().to_vec(), + }, + ) +} + +/// Derive the public hybrid key from a private hybrid key. +pub fn derive_encryption_key(dk: &DecryptionKey) -> Result { + dk.validate()?; + let hpke_x25519 = hpke_scheme::derive_encryption_key(&dk.hpke_x25519)?; + let mlkem_dk = mlkem768_decapsulation_key(&dk.mlkem768_seed)?; + let mlkem768_ek = mlkem_dk + .encapsulation_key() + .to_bytes() + .as_slice() + .to_vec(); + Ok(EncryptionKey { + hpke_x25519, + mlkem768_ek, + }) +} + +pub fn encrypt(ek: &EncryptionKey, plaintext: &[u8], aad: &[u8]) -> Result { + ek.validate()?; + + let inner_hpke_ct = hpke_scheme::encrypt(&ek.hpke_x25519, plaintext, aad)?; + let inner_hpke_bytes = inner_hpke_ct.to_bytes(); + + let mlkem_ek = mlkem768_encapsulation_key(&ek.mlkem768_ek)?; + let (mlkem_ct, shared_secret) = mlkem_ek.encapsulate(); + + let key = derive_outer_key(shared_secret.as_slice(), mlkem_ct.as_slice(), aad)?; + let mut nonce = [0u8; AEAD_NONCE_BYTES]; + OsRng.fill_bytes(&mut nonce); + + let cipher = ChaCha20Poly1305::new(Key::from_slice(&key)); + let aead_ct = cipher + .encrypt( + Nonce::from_slice(&nonce), + Payload { + msg: &inner_hpke_bytes, + aad, + }, + ) + .map_err(|_| anyhow!("Hybrid outer AEAD encrypt failed"))?; + + Ok(Ciphertext { + mlkem768_ct: mlkem_ct.as_slice().to_vec(), + aead_nonce: nonce.to_vec(), + aead_ct, + }) +} + +pub fn decrypt(dk: &DecryptionKey, ct: &Ciphertext, aad: &[u8]) -> Result> { + dk.validate()?; + ct.validate()?; + + let mlkem_dk = mlkem768_decapsulation_key(&dk.mlkem768_seed)?; + let mlkem_ct = mlkem768_ciphertext(&ct.mlkem768_ct)?; + let shared_secret = mlkem_dk.decapsulate(&mlkem_ct); + + let key = derive_outer_key(shared_secret.as_slice(), &ct.mlkem768_ct, aad)?; + let cipher = ChaCha20Poly1305::new(Key::from_slice(&key)); + let inner_hpke_bytes = cipher + .decrypt( + Nonce::from_slice(&ct.aead_nonce), + Payload { + msg: &ct.aead_ct, + aad, + }, + ) + .map_err(|_| anyhow!("Hybrid outer AEAD open failed"))?; + + let inner_hpke_ct = hpke_scheme::Ciphertext::from_bytes(&inner_hpke_bytes)?; + hpke_scheme::decrypt(&dk.hpke_x25519, &inner_hpke_ct, aad) +} + +fn derive_outer_key(shared_secret: &[u8], mlkem_ct: &[u8], aad: &[u8]) -> Result<[u8; 32]> { + let mut info = Vec::with_capacity(HKDF_SALT.len() + mlkem_ct.len() + aad.len() + 16); + info.extend_from_slice(HKDF_SALT); + info.extend_from_slice(&(mlkem_ct.len() as u64).to_le_bytes()); + info.extend_from_slice(mlkem_ct); + info.extend_from_slice(&(aad.len() as u64).to_le_bytes()); + info.extend_from_slice(aad); + + let hk = Hkdf::::new(Some(HKDF_SALT), shared_secret); + let mut key = [0u8; AEAD_KEY_BYTES]; + hk.expand(&info, &mut key) + .map_err(|_| anyhow!("Hybrid HKDF expand failed"))?; + Ok(key) +} + +fn mlkem768_encapsulation_key( + bytes: &[u8], +) -> Result<::EncapsulationKey> { + let key = ml_kem_key_array::from_slice::<::EncapsulationKey>(bytes)?; + <::EncapsulationKey>::new(&key) + .map_err(|_| anyhow!("invalid ML-KEM-768 encapsulation key")) +} + +fn mlkem768_decapsulation_key( + bytes: &[u8], +) -> Result<::DecapsulationKey> { + let seed: Seed = bytes + .try_into() + .map_err(|_| anyhow!("invalid ML-KEM-768 seed length"))?; + Ok(<::DecapsulationKey>::from_seed(seed)) +} + +fn mlkem768_ciphertext(bytes: &[u8]) -> Result> { + bytes + .try_into() + .map_err(|_| anyhow!("invalid ML-KEM-768 ciphertext length")) +} + +mod ml_kem_key_array { + use anyhow::{anyhow, Result}; + use ml_kem::kem::{Key, KeySizeUser}; + + pub fn from_slice(bytes: &[u8]) -> Result> + where + T: KeySizeUser, + { + bytes + .try_into() + .map_err(|_| anyhow!("invalid ML-KEM key length")) + } +} + +// ── Tests ───────────────────────────────────────────────────────────────────── + +#[cfg(test)] +mod tests { + use super::*; + use std::time::Instant; + + #[test] + fn round_trip() { + let (ek, dk) = keygen(); + let pt = b"hello hybrid pke"; + let ct = encrypt(&ek, pt, b"").unwrap(); + let got = decrypt(&dk, &ct, b"").unwrap(); + assert_eq!(got, pt); + } + + #[test] + fn derive_pk_matches_keygen() { + let (ek, dk) = keygen(); + let derived = derive_encryption_key(&dk).unwrap(); + assert_eq!(derived, ek); + } + + #[test] + fn aad_must_match() { + let (ek, dk) = keygen(); + let ct = encrypt(&ek, b"msg", b"ctx-A").unwrap(); + assert_eq!(decrypt(&dk, &ct, b"ctx-A").unwrap(), b"msg"); + assert!(decrypt(&dk, &ct, b"ctx-B").is_err()); + } + + #[test] + fn tamper_outer_aead_rejected() { + let (ek, dk) = keygen(); + let mut ct = encrypt(&ek, b"secret", b"").unwrap(); + ct.aead_ct[0] ^= 1; + assert!(decrypt(&dk, &ct, b"").is_err()); + } + + #[test] + fn tamper_mlkem_ciphertext_rejected_by_outer_aead() { + let (ek, dk) = keygen(); + let mut ct = encrypt(&ek, b"secret", b"").unwrap(); + ct.mlkem768_ct[0] ^= 1; + assert!(decrypt(&dk, &ct, b"").is_err()); + } + + #[test] + fn wrong_key_rejected() { + let (ek, _) = keygen(); + let (_, wrong_dk) = keygen(); + let ct = encrypt(&ek, b"secret", b"").unwrap(); + assert!(decrypt(&wrong_dk, &ct, b"").is_err()); + } + + #[test] + fn bcs_round_trips_and_checks_lengths() { + let (ek, dk) = keygen(); + assert_eq!(ek.mlkem768_ek.len(), MLKEM768_EK_BYTES); + assert_eq!(dk.mlkem768_seed.len(), MLKEM768_DK_SEED_BYTES); + + let ek_back = EncryptionKey::from_bytes(&ek.to_bytes()).unwrap(); + let dk_back = DecryptionKey::from_bytes(&dk.to_bytes()).unwrap(); + assert_eq!(ek_back, ek); + assert_eq!(dk_back, dk); + + let ct = encrypt(&ek, b"xyzzy", b"").unwrap(); + assert_eq!(ct.mlkem768_ct.len(), MLKEM768_CT_BYTES); + assert_eq!(ct.aead_nonce.len(), AEAD_NONCE_BYTES); + assert!(ct.aead_ct.len() >= AEAD_TAG_BYTES); + let ct_back = Ciphertext::from_bytes(&ct.to_bytes()).unwrap(); + assert_eq!(ct_back, ct); + } + + #[test] + fn invalid_lengths_rejected() { + let (mut ek, mut dk) = keygen(); + ek.mlkem768_ek.pop(); + assert!(ek.validate().is_err()); + dk.mlkem768_seed.pop(); + assert!(dk.validate().is_err()); + + let (ek, _) = keygen(); + let mut ct = encrypt(&ek, b"secret", b"").unwrap(); + ct.mlkem768_ct.pop(); + assert!(ct.validate().is_err()); + } + + /// Easy-to-run benchmark. + /// cargo test -p ace-pke --release bench_hybrid_x25519_mlkem768 -- --ignored --nocapture + #[test] + #[ignore] + fn bench_hybrid_x25519_mlkem768() { + const ITERS: u32 = 300; + const SIZES: &[(&str, usize)] = &[("32B", 32), ("1KB", 1024), ("64KB", 64 * 1024)]; + + let t = Instant::now(); + for _ in 0..ITERS { + let _ = keygen(); + } + let per = t.elapsed().as_secs_f64() * 1000.0 / ITERS as f64; + println!("keygen: {:8.3} ms ({:>8.0} ops/s)", per, 1000.0 / per); + + for (label, n) in SIZES { + let (ek, dk) = keygen(); + let pt = vec![0xa5u8; *n]; + + let t = Instant::now(); + for _ in 0..ITERS { + let _ = encrypt(&ek, &pt, b"").unwrap(); + } + let per = t.elapsed().as_secs_f64() * 1000.0 / ITERS as f64; + println!( + "encrypt {:>4}: {:8.3} ms ({:>8.0} ops/s)", + label, + per, + 1000.0 / per + ); + + let ct = encrypt(&ek, &pt, b"").unwrap(); + let t = Instant::now(); + for _ in 0..ITERS { + let _ = decrypt(&dk, &ct, b"").unwrap(); + } + let per = t.elapsed().as_secs_f64() * 1000.0 / ITERS as f64; + println!( + "decrypt {:>4}: {:8.3} ms ({:>8.0} ops/s)", + label, + per, + 1000.0 / per + ); + } + } +} diff --git a/worker-components/vss-common/Cargo.toml b/worker-components/vss-common/Cargo.toml index b0a69f52..bb2ca6fc 100644 --- a/worker-components/vss-common/Cargo.toml +++ b/worker-components/vss-common/Cargo.toml @@ -8,6 +8,7 @@ edition = "2021" description = "Shared Aptos REST + on-chain VSS Session parsing for vss-dealer / vss-recipient." [dependencies] +ace-pke = { path = "../pke" } anyhow = "1" serde = { version = "1", features = ["derive"] } serde_json = "1" @@ -22,9 +23,7 @@ ark-bls12-381 = { version = "0.4", default-features = false, features = ["curve" ark-ec = { version = "0.4", default-features = false } ark-ff = { version = "0.4", default-features = false } ark-serialize = { version = "0.4", default-features = false } -curve25519-dalek = { version = "4", features = ["rand_core"] } rand = "0.8" -hpke = { version = "0.12", default-features = false, features = ["std", "x25519"] } [dev-dependencies] tokio = { version = "1", features = ["rt-multi-thread"] } diff --git a/worker-components/vss-common/src/crypto.rs b/worker-components/vss-common/src/crypto.rs index 1614ca1a..51136a57 100644 --- a/worker-components/vss-common/src/crypto.rs +++ b/worker-components/vss-common/src/crypto.rs @@ -1,166 +1,18 @@ // Copyright (c) Aptos Labs // SPDX-License-Identifier: Apache-2.0 -//! Cryptographic primitives matching `ts-sdk/src/utils.ts` and `ts-sdk/src/pke/elgamal_otp_ristretto255.ts`. +//! VSS cryptographic helpers. //! -//! All byte formats are wire-compatible with the TypeScript implementation. +//! PKE lives in the `ace-pke` crate; selected helpers are re-exported here for +//! legacy call sites that still import `vss_common::crypto::pke_encrypt`. use ark_bls12_381::Fr; use ark_ec::{AffineRepr, CurveGroup}; use ark_ff::{PrimeField, Zero}; use ark_serialize::CanonicalSerialize; -use curve25519_dalek::{ristretto::CompressedRistretto, RistrettoPoint, Scalar}; -use rand::rngs::OsRng; use sha3::{Digest, Sha3_256}; -use crate::pke; - -// ── KDF ─────────────────────────────────────────────────────────────────────── - -/// KDF matching `ts-sdk/src/utils.ts::kdf`. -/// -/// Each 32-byte block: SHA3-256(BCS(seed) ++ BCS(dst) ++ LE64(target_len) ++ LE64(block_idx)) -/// where BCS(bytes) = ULEB128(len) ++ bytes. -/// -/// `seed` must be >= 32 bytes (it is the BCS-encoded Ristretto255 group element = 33 bytes). -pub fn kdf(seed: &[u8], dst: &[u8], target_len: usize) -> Vec { - let mut output = Vec::with_capacity(target_len); - let mut block_idx: u64 = 0; - let mut remaining = target_len; - while remaining > 0 { - let mut hasher = Sha3_256::new(); - // serializeBytes(seed) = ULEB128(seed.len()) ++ seed - update_with_bcs_bytes(&mut hasher, seed); - // serializeBytes(dst) = ULEB128(dst.len()) ++ dst - update_with_bcs_bytes(&mut hasher, dst); - // serializeU64(target_len) = LE64 - hasher.update((target_len as u64).to_le_bytes()); - // serializeU64(block_idx) = LE64 - hasher.update(block_idx.to_le_bytes()); - - let block = hasher.finalize(); - let take = remaining.min(32); - output.extend_from_slice(&block[..take]); - remaining -= take; - block_idx += 1; - } - output -} - -/// Write ULEB128(bytes.len()) ++ bytes into the hasher. -fn update_with_bcs_bytes(hasher: &mut Sha3_256, bytes: &[u8]) { - let mut len_buf = Vec::new(); - write_uleb128(&mut len_buf, bytes.len() as u64); - hasher.update(&len_buf); - hasher.update(bytes); -} - -fn write_uleb128(out: &mut Vec, mut v: u64) { - loop { - let byte = (v & 0x7f) as u8; - v >>= 7; - if v == 0 { - out.push(byte); - break; - } - out.push(byte | 0x80); - } -} - -// ── HMAC-SHA3-256 ───────────────────────────────────────────────────────────── - -/// HMAC-SHA3-256 matching `ts-sdk/src/utils.ts::hmac_sha3_256`. -/// -/// 32-byte key is padded to 64 bytes by appending 32 zero bytes. -/// Standard HMAC construction with ipad=0x36 and opad=0x5c. -pub fn hmac_sha3_256(key: &[u8; 32], msg: &[u8]) -> [u8; 32] { - let mut padded = [0u8; 64]; - padded[..32].copy_from_slice(key); - // padded[32..64] = 0x00 (already zero) - - let ipad = [0x36u8; 64]; - let opad = [0x5cu8; 64]; - - let inner_key: Vec = padded.iter().zip(ipad.iter()).map(|(a, b)| a ^ b).collect(); - let outer_key: Vec = padded.iter().zip(opad.iter()).map(|(a, b)| a ^ b).collect(); - - let mut inner = Sha3_256::new(); - inner.update(&inner_key); - inner.update(msg); - let inner_hash = inner.finalize(); - - let mut outer = Sha3_256::new(); - outer.update(&outer_key); - outer.update(inner_hash); - outer.finalize().into() -} - -// ── PKE encrypt ─────────────────────────────────────────────────────────────── - -/// Encrypt `plaintext` under `key`, matching `ts-sdk/src/pke/elgamal_otp_ristretto255.ts::encrypt`. -pub fn pke_encrypt(key: &pke::EncryptionKey, plaintext: &[u8]) -> pke::Ciphertext { - match key { - pke::EncryptionKey::ElGamalOtpRistretto255(inner) => { - let enc_base_arr: [u8; 32] = inner - .enc_base - .as_slice() - .try_into() - .expect("enc_base must be 32 bytes"); - let public_point_arr: [u8; 32] = inner - .public_point - .as_slice() - .try_into() - .expect("public_point must be 32 bytes"); - - let enc_base_pt = CompressedRistretto(enc_base_arr) - .decompress() - .expect("enc_base is not a valid Ristretto point"); - let public_point_pt = CompressedRistretto(public_point_arr) - .decompress() - .expect("public_point is not a valid Ristretto point"); - - // elgamalPtxt = random Ristretto point (the "message" in ElGamal) - let ephemeral_pt = RistrettoPoint::random(&mut OsRng); - // elgamalRand = random scalar (ElGamal randomness) - let r = Scalar::random(&mut OsRng); - - // c0 = r * enc_base_pt - let c0 = (r * enc_base_pt).compress().to_bytes(); - // c1 = ephemeral_pt + r * public_point_pt - let c1 = (ephemeral_pt + r * public_point_pt).compress().to_bytes(); - - // seed = element.toBytes() = serializeBytes(compressed_point) = [ULEB128(32)][32B] - let ephemeral_compressed = ephemeral_pt.compress().to_bytes(); - let mut seed = Vec::with_capacity(33); - seed.push(0x20u8); // ULEB128(32) = 0x20 - seed.extend_from_slice(&ephemeral_compressed); - // seed is now 33 bytes, matching TypeScript's `elgamalPtxt.toBytes()` - - let otp = kdf(&seed, b"OTP/ELGAMAL_OTP_RISTRETTO255", plaintext.len()); - let sym_ciph: Vec = otp - .iter() - .zip(plaintext.iter()) - .map(|(a, b)| a ^ b) - .collect(); - - let hmac_key_vec = kdf(&seed, b"HMAC/ELGAMAL_OTP_RISTRETTO255", 32); - let hmac_key: [u8; 32] = hmac_key_vec.try_into().expect("kdf produced 32 bytes"); - let mac = hmac_sha3_256(&hmac_key, &sym_ciph); - - pke::Ciphertext::ElGamalOtpRistretto255(pke::ElGamalOtpRistretto255Ciphertext { - c0: c0.to_vec(), - c1: c1.to_vec(), - sym_ciph, - mac: mac.to_vec(), - }) - } - pke::EncryptionKey::HpkeX25519ChaCha20Poly1305(ek) => { - let ct = crate::pke_hpke_x25519_chacha20poly1305::encrypt(ek, plaintext, b"") - .expect("HPKE encrypt: invalid public key"); - pke::Ciphertext::HpkeX25519ChaCha20Poly1305(ct) - } - } -} +pub use ace_pke::{hmac_sha3_256, kdf, pke_encrypt}; // ── BLS12-381 Fr helpers ────────────────────────────────────────────────────── diff --git a/worker-components/vss-common/src/lib.rs b/worker-components/vss-common/src/lib.rs index 13b6d664..0bd0f829 100644 --- a/worker-components/vss-common/src/lib.rs +++ b/worker-components/vss-common/src/lib.rs @@ -6,13 +6,12 @@ pub mod aptos; pub mod crypto; pub mod group; -pub mod pke; -pub mod pke_hpke_x25519_chacha20poly1305; pub mod session; pub mod share_reconstruction; pub mod sigma_dlog_linear; pub mod vss_types; +pub use ace_pke as pke; pub use aptos::{AptosRpc, TxnArg}; pub use session::Session; pub use share_reconstruction::reconstruct_share; diff --git a/worker-components/vss-common/src/pke.rs b/worker-components/vss-common/src/pke.rs deleted file mode 100644 index 454819fb..00000000 --- a/worker-components/vss-common/src/pke.rs +++ /dev/null @@ -1,135 +0,0 @@ -// Copyright (c) Aptos Labs -// SPDX-License-Identifier: Apache-2.0 - -//! PKE types mirroring `ts-sdk/src/pke/index.ts` and `contracts/pke/sources/pke.move`. -//! -//! `EncryptionKey` and `Ciphertext` are serde-derived enums. BCS encodes an enum as -//! `[ULEB128 variant tag][variant fields...]`; for tags 0/1 the ULEB128 is a single -//! byte, so the on-wire format matches `[scheme byte][inner]` exactly. Adding a new -//! scheme is one new variant + one match arm — no per-scheme size table, no manual -//! byte-walking. - -use anyhow::{anyhow, Result}; -use curve25519_dalek::{ristretto::CompressedRistretto, Scalar}; -use serde::{Deserialize, Serialize}; - -use crate::pke_hpke_x25519_chacha20poly1305 as hpke_scheme; - -pub const SCHEME_ELGAMAL_OTP_RISTRETTO255: u8 = 0; -pub const SCHEME_HPKE_X25519_HKDF_SHA256_CHACHA20POLY1305: u8 = 1; - -// ── EncryptionKey ───────────────────────────────────────────────────────────── - -#[derive(Clone, Debug, Serialize, Deserialize)] -pub enum EncryptionKey { - ElGamalOtpRistretto255(ElGamalOtpRistretto255EncKey), - HpkeX25519ChaCha20Poly1305(hpke_scheme::EncryptionKey), -} - -#[derive(Clone, Debug, Serialize, Deserialize)] -pub struct ElGamalOtpRistretto255EncKey { - pub enc_base: Vec, // 32 bytes - pub public_point: Vec, // 32 bytes -} - -// ── Ciphertext ──────────────────────────────────────────────────────────────── - -#[derive(Clone, Debug, Serialize, Deserialize)] -pub enum Ciphertext { - ElGamalOtpRistretto255(ElGamalOtpRistretto255Ciphertext), - HpkeX25519ChaCha20Poly1305(hpke_scheme::Ciphertext), -} - -#[derive(Clone, Debug, Serialize, Deserialize)] -pub struct ElGamalOtpRistretto255Ciphertext { - pub c0: Vec, // 32 bytes - pub c1: Vec, // 32 bytes - pub sym_ciph: Vec, - pub mac: Vec, // 32 bytes -} - -// ── PKE decrypt ─────────────────────────────────────────────────────────────── - -/// Decrypt a `Ciphertext` using the given decryption key. `dk_bytes` is the wire-format -/// decryption key (`[scheme byte][inner BCS]`); the leading scheme byte must match `ct`. -pub fn pke_decrypt(dk_bytes: &[u8], ct: &Ciphertext) -> Result> { - if dk_bytes.is_empty() { - return Err(anyhow!("empty decryption-key bytes")); - } - match ct { - Ciphertext::ElGamalOtpRistretto255(inner) => { - if dk_bytes[0] != SCHEME_ELGAMAL_OTP_RISTRETTO255 { - return Err(anyhow!( - "PKE scheme mismatch: dk={}, ct=ElGamalOtpRistretto255", - dk_bytes[0] - )); - } - elgamal_otp_ristretto255_decrypt(dk_bytes, inner) - } - Ciphertext::HpkeX25519ChaCha20Poly1305(inner) => { - if dk_bytes[0] != SCHEME_HPKE_X25519_HKDF_SHA256_CHACHA20POLY1305 { - return Err(anyhow!( - "PKE scheme mismatch: dk={}, ct=HpkeX25519ChaCha20Poly1305", - dk_bytes[0] - )); - } - let dk = hpke_scheme::DecryptionKey::from_bytes(&dk_bytes[1..])?; - hpke_scheme::decrypt(&dk, inner, b"") - } - } -} - -/// Decrypt a ciphertext from its on-wire BCS bytes. -pub fn pke_decrypt_bytes(dk_bytes: &[u8], ct_bytes: &[u8]) -> Result> { - let ct: Ciphertext = bcs::from_bytes(ct_bytes) - .map_err(|e| anyhow!("pke_decrypt_bytes: BCS parse: {}", e))?; - pke_decrypt(dk_bytes, &ct) -} - -/// Decrypt the ElGamal-OTP-Ristretto255 variant. -/// -/// `dk_bytes` format: `[0x00 scheme][0x20 ULEB128(32)][32B encBase][0x20 ULEB128(32)][32B privateScalar]` — 67 bytes -/// (matches `pke.DecryptionKey.toBytes()` from the TypeScript SDK). -/// Decryption only uses `privateScalar` (bytes [35..67]). -fn elgamal_otp_ristretto255_decrypt( - dk_bytes: &[u8], - ct: &ElGamalOtpRistretto255Ciphertext, -) -> Result> { - if dk_bytes.len() < 67 || dk_bytes[0] != SCHEME_ELGAMAL_OTP_RISTRETTO255 { - return Err(anyhow!("invalid dk format (expected 67 bytes with scheme 0x00)")); - } - // privateScalar is at bytes[35..67]; bytes[2..34] is encBase (not needed for decryption) - let dk = Scalar::from_canonical_bytes( - dk_bytes[35..67].try_into().map_err(|_| anyhow!("dk scalar slice wrong length"))?, - ) - .into_option() - .ok_or_else(|| anyhow!("invalid dk scalar"))?; - - let c0_arr: [u8; 32] = ct.c0.as_slice().try_into().map_err(|_| anyhow!("c0 must be 32 bytes"))?; - let c1_arr: [u8; 32] = ct.c1.as_slice().try_into().map_err(|_| anyhow!("c1 must be 32 bytes"))?; - let mac_arr: [u8; 32] = ct.mac.as_slice().try_into().map_err(|_| anyhow!("mac must be 32 bytes"))?; - - let c0_pt = CompressedRistretto(c0_arr) - .decompress() - .ok_or_else(|| anyhow!("c0 not a valid Ristretto point"))?; - let c1_pt = CompressedRistretto(c1_arr) - .decompress() - .ok_or_else(|| anyhow!("c1 not a valid Ristretto point"))?; - - // ephemeral = c1 - dk * c0 - let ephemeral = c1_pt - dk * c0_pt; - let mut seed = vec![0x20u8]; // ULEB128(32) - seed.extend_from_slice(&ephemeral.compress().to_bytes()); - - // HMAC verify - let hmac_key_vec = crate::crypto::kdf(&seed, b"HMAC/ELGAMAL_OTP_RISTRETTO255", 32); - let hmac_key: [u8; 32] = hmac_key_vec.try_into().unwrap(); - let expected_mac = crate::crypto::hmac_sha3_256(&hmac_key, &ct.sym_ciph); - if expected_mac != mac_arr { - return Err(anyhow!("PKE decryption failed: HMAC mismatch")); - } - - // Decrypt - let otp = crate::crypto::kdf(&seed, b"OTP/ELGAMAL_OTP_RISTRETTO255", ct.sym_ciph.len()); - Ok(otp.iter().zip(ct.sym_ciph.iter()).map(|(a, b)| a ^ b).collect()) -}