From d5bed747cf70211d79fd1abb89fce0b3bb36f874 Mon Sep 17 00:00:00 2001 From: Michael Snoyman Date: Tue, 25 Nov 2025 17:16:36 +0200 Subject: [PATCH 1/3] feat: encryption/decryption using PublicKey/SecretKey (Codex-generated) --- Cargo.lock | 28 +++++ Cargo.toml | 2 + packages/shared/Cargo.toml | 15 ++- packages/shared/src/cryptography/real.rs | 142 +++++++++++++++++++++++ 4 files changed, 185 insertions(+), 2 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index fd1735a7..0658cd33 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -48,6 +48,20 @@ dependencies = [ "cpufeatures", ] +[[package]] +name = "aes-gcm" +version = "0.10.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "831010a0f742e1209b3bcea8fab6a8e149051ba6099432c8cb2cc117dec3ead1" +dependencies = [ + "aead", + "aes", + "cipher", + "ctr", + "ghash", + "subtle", +] + [[package]] name = "aes-gcm-siv" version = "0.11.1" @@ -2311,6 +2325,7 @@ dependencies = [ "ff", "generic-array", "group", + "hkdf", "pkcs8", "rand_core 0.6.4", "sec1", @@ -2972,6 +2987,16 @@ dependencies = [ "wasm-bindgen", ] +[[package]] +name = "ghash" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0d8a4362ccb29cb0b265253fb0a2728f592895ee6854fd9bc13f2ffda266ff1" +dependencies = [ + "opaque-debug", + "polyval", +] + [[package]] name = "gif" version = "0.12.0" @@ -6430,13 +6455,16 @@ dependencies = [ name = "shared" version = "0.1.0" dependencies = [ + "aes-gcm", "borsh 1.5.7", "cosmwasm-std", "cw-storage-plus", "hex", + "hkdf", "k256", "rand 0.8.5", "serde", + "sha2 0.10.9", "thiserror 2.0.12", ] diff --git a/Cargo.toml b/Cargo.toml index 9fb95b4a..6df4515c 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -14,6 +14,7 @@ members = [ ] [workspace.dependencies] +aes-gcm = "0.10.3" anyhow = "1.0.97" axum = "0.8.3" backon = "1.4.1" @@ -32,6 +33,7 @@ fjall = "2.9.0" futures = "0.3.31" futures-util = "0.3.31" hex = "0.4.3" +hkdf = "0.12.4" jiff = { version = "0.2.3", features = ["serde"] } k256 = "0.13.4" kademlia-discovery = { path = "packages/examples/kademlia-discovery" } diff --git a/packages/shared/Cargo.toml b/packages/shared/Cargo.toml index 23cdcea1..cb0a74b9 100644 --- a/packages/shared/Cargo.toml +++ b/packages/shared/Cargo.toml @@ -6,19 +6,30 @@ edition = "2021" license = "MIT" [dependencies] +aes-gcm = { workspace = true, optional = true } borsh = { workspace = true, features = ["derive"], optional = true } cosmwasm-std = { workspace = true, optional = true } cw-storage-plus = { workspace = true, optional = true } +hkdf = { workspace = true, optional = true } hex = { workspace = true, optional = true } -k256 = { workspace = true, features = ["ecdsa", "serde", "sha256"], optional = true } +k256 = { workspace = true, features = ["ecdsa", "ecdh", "serde", "sha256"], optional = true } rand = { workspace = true, optional = true } serde = { workspace = true } +sha2 = { workspace = true, optional = true } thiserror = { workspace = true } [features] chaincryptography = [] cosmwasm = ["dep:cosmwasm-std", "dep:cw-storage-plus"] -realcryptography = ["borsh?/std", "dep:hex", "dep:k256", "dep:rand"] +realcryptography = [ + "borsh?/std", + "dep:aes-gcm", + "dep:hex", + "dep:hkdf", + "dep:k256", + "dep:rand", + "dep:sha2", +] solana = ["dep:borsh", "dep:hex"] [lints] diff --git a/packages/shared/src/cryptography/real.rs b/packages/shared/src/cryptography/real.rs index dfeec5ff..42af9c76 100644 --- a/packages/shared/src/cryptography/real.rs +++ b/packages/shared/src/cryptography/real.rs @@ -1,7 +1,17 @@ pub use rand::rngs::ThreadRng; +use aes_gcm::{ + aead::{Aead, KeyInit}, + Aes256Gcm, Key, Nonce, +}; +use k256::ecdh::{diffie_hellman, EphemeralSecret, SharedSecret}; +use rand::{CryptoRng, RngCore}; +use sha2::Sha256; use std::{fmt::Display, str::FromStr}; +const KEY_DERIVATION_SALT: &[u8] = b"kolme-ecdh-salt-v1"; +const KEY_DERIVATION_INFO_PREFIX: &[u8] = b"kolme-ecdh-aes256-gcm-v1"; + /// Newtype wrapper around [k256::PublicKey] to provide consistent serialization. #[derive(Clone, Copy, PartialEq, Eq, PartialOrd, Ord)] pub struct PublicKey(k256::PublicKey); @@ -55,6 +65,39 @@ impl PublicKey { bytes: bytes.to_owned(), }) } + + /// Encrypt data so it can be decrypted by the corresponding [SecretKey]. + pub fn encrypt( + &self, + plaintext: impl AsRef<[u8]>, + ) -> Result { + self.encrypt_with(&mut rand::thread_rng(), plaintext) + } + + pub fn encrypt_with( + &self, + rng: &mut (impl CryptoRng + RngCore), + plaintext: impl AsRef<[u8]>, + ) -> Result { + let ephemeral_secret = EphemeralSecret::random(rng); + let ephemeral_public_key: PublicKey = k256::PublicKey::from(&ephemeral_secret).into(); + let shared_secret = ephemeral_secret.diffie_hellman(&self.0); + let key = derive_encryption_key(&shared_secret, &ephemeral_public_key, self)?; + + let mut nonce_bytes = [0u8; 12]; + rng.fill_bytes(&mut nonce_bytes); + + let cipher = Aes256Gcm::new(&key); + let ciphertext = cipher + .encrypt(Nonce::from_slice(&nonce_bytes), plaintext.as_ref()) + .map_err(|_| EncryptionError::EncryptionFailed)?; + + Ok(EncryptedMessage { + ephemeral_public_key, + nonce: nonce_bytes, + ciphertext, + }) + } } impl From for PublicKey { @@ -157,6 +200,23 @@ pub enum SecretKeyError { InvalidBytes { source: k256::elliptic_curve::Error }, } +#[derive(thiserror::Error, Debug)] +pub enum EncryptionError { + #[error("Key derivation failed")] + KeyDerivationFailed, + #[error("Encryption failed")] + EncryptionFailed, + #[error("Decryption failed")] + DecryptionFailed, +} + +#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)] +pub struct EncryptedMessage { + pub ephemeral_public_key: PublicKey, + pub nonce: [u8; 12], + pub ciphertext: Vec, +} + #[derive(Clone, PartialEq, Eq)] pub struct SecretKey(k256::SecretKey); @@ -215,6 +275,26 @@ impl SecretKey { pub fn reveal_as_hex(&self) -> String { hex::encode(self.0.to_bytes()) } + + pub fn decrypt(&self, message: &EncryptedMessage) -> Result, EncryptionError> { + let shared_secret = diffie_hellman( + self.0.to_nonzero_scalar(), + message.ephemeral_public_key.0.as_affine(), + ); + let key = derive_encryption_key( + &shared_secret, + &message.ephemeral_public_key, + &self.public_key(), + )?; + + let cipher = Aes256Gcm::new(&key); + cipher + .decrypt( + Nonce::from_slice(&message.nonce), + message.ciphertext.as_ref(), + ) + .map_err(|_| EncryptionError::DecryptionFailed) + } } impl FromStr for SecretKey { @@ -231,6 +311,29 @@ impl std::fmt::Debug for SecretKey { } } +fn derive_encryption_key( + shared_secret: &SharedSecret, + ephemeral_public_key: &PublicKey, + recipient_public_key: &PublicKey, +) -> Result, EncryptionError> { + let hkdf = shared_secret.extract::(Some(KEY_DERIVATION_SALT)); + let ephemeral_bytes = ephemeral_public_key.as_bytes(); + let recipient_bytes = recipient_public_key.as_bytes(); + + let mut info = Vec::with_capacity( + KEY_DERIVATION_INFO_PREFIX.len() + ephemeral_bytes.len() + recipient_bytes.len(), + ); + info.extend_from_slice(KEY_DERIVATION_INFO_PREFIX); + info.extend_from_slice(&ephemeral_bytes); + info.extend_from_slice(&recipient_bytes); + + let mut okm = [0u8; 32]; + hkdf.expand(&info, &mut okm) + .map_err(|_| EncryptionError::KeyDerivationFailed)?; + + Ok(Key::::clone_from_slice(&okm)) +} + mod sigerr { #[derive(thiserror::Error, Debug)] pub enum SignatureError { @@ -416,3 +519,42 @@ impl SignatureWithRecovery { PublicKey::recover_from_msg(msg, &self.sig, self.recid) } } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn encrypt_decrypt_roundtrip() { + let receiver = SecretKey::random(); + let plaintext = b"hello world"; + + let encrypted = receiver.public_key().encrypt(plaintext).unwrap(); + let decrypted = receiver.decrypt(&encrypted).unwrap(); + + assert_eq!(decrypted, plaintext); + } + + #[test] + fn encrypt_is_randomized() { + let receiver = SecretKey::random(); + let plaintext = b"stable text"; + + let first = receiver.public_key().encrypt(plaintext).unwrap(); + let second = receiver.public_key().encrypt(plaintext).unwrap(); + + assert_ne!(first.nonce, second.nonce); + assert_ne!(first.ciphertext, second.ciphertext); + assert_ne!(first.ephemeral_public_key, second.ephemeral_public_key); + } + + #[test] + fn tampering_fails() { + let receiver = SecretKey::random(); + let mut encrypted = receiver.public_key().encrypt(b"super secret").unwrap(); + + encrypted.ciphertext[0] ^= 0b0000_0001; + + assert!(receiver.decrypt(&encrypted).is_err()); + } +} From 4fd1578917bd657321db527125ac1109e4e82f8e Mon Sep 17 00:00:00 2001 From: Michael Snoyman Date: Tue, 25 Nov 2025 18:04:51 +0200 Subject: [PATCH 2/3] feat: switch to ChaCha20-Poly1305 --- Cargo.lock | 62 +++++++++++++---------- Cargo.toml | 2 +- packages/shared/Cargo.toml | 4 +- packages/shared/src/cryptography/real.rs | 63 +++++++++--------------- 4 files changed, 64 insertions(+), 67 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 0658cd33..0e648412 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -48,20 +48,6 @@ dependencies = [ "cpufeatures", ] -[[package]] -name = "aes-gcm" -version = "0.10.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "831010a0f742e1209b3bcea8fab6a8e149051ba6099432c8cb2cc117dec3ead1" -dependencies = [ - "aead", - "aes", - "cipher", - "ctr", - "ghash", - "subtle", -] - [[package]] name = "aes-gcm-siv" version = "0.11.1" @@ -1226,6 +1212,30 @@ dependencies = [ "syn 2.0.101", ] +[[package]] +name = "chacha20" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3613f74bd2eac03dad61bd53dbe620703d4371614fe0bc3b9f04dd36fe4e818" +dependencies = [ + "cfg-if", + "cipher", + "cpufeatures", +] + +[[package]] +name = "chacha20poly1305" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "10cd79432192d1c0f4e1a0fef9527696cc039165d729fb41b3f4f4f354c2dc35" +dependencies = [ + "aead", + "chacha20", + "cipher", + "poly1305", + "zeroize", +] + [[package]] name = "chrono" version = "0.4.41" @@ -1285,6 +1295,7 @@ checksum = "773f3b9af64447d2ce9850330c473515014aa235e6a783b02db81ff39e4a3dad" dependencies = [ "crypto-common", "inout", + "zeroize", ] [[package]] @@ -2987,16 +2998,6 @@ dependencies = [ "wasm-bindgen", ] -[[package]] -name = "ghash" -version = "0.5.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f0d8a4362ccb29cb0b265253fb0a2728f592895ee6854fd9bc13f2ffda266ff1" -dependencies = [ - "opaque-debug", - "polyval", -] - [[package]] name = "gif" version = "0.12.0" @@ -5107,6 +5108,17 @@ dependencies = [ "miniz_oxide", ] +[[package]] +name = "poly1305" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8159bd90725d2df49889a078b54f4f79e87f1f8a8444194cdca81d38f5393abf" +dependencies = [ + "cpufeatures", + "opaque-debug", + "universal-hash", +] + [[package]] name = "polyval" version = "0.6.2" @@ -6455,8 +6467,8 @@ dependencies = [ name = "shared" version = "0.1.0" dependencies = [ - "aes-gcm", "borsh 1.5.7", + "chacha20poly1305", "cosmwasm-std", "cw-storage-plus", "hex", diff --git a/Cargo.toml b/Cargo.toml index 6df4515c..4363dd71 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -14,7 +14,6 @@ members = [ ] [workspace.dependencies] -aes-gcm = "0.10.3" anyhow = "1.0.97" axum = "0.8.3" backon = "1.4.1" @@ -33,6 +32,7 @@ fjall = "2.9.0" futures = "0.3.31" futures-util = "0.3.31" hex = "0.4.3" +chacha20poly1305 = "0.10.1" hkdf = "0.12.4" jiff = { version = "0.2.3", features = ["serde"] } k256 = "0.13.4" diff --git a/packages/shared/Cargo.toml b/packages/shared/Cargo.toml index cb0a74b9..8b10d2e8 100644 --- a/packages/shared/Cargo.toml +++ b/packages/shared/Cargo.toml @@ -6,7 +6,7 @@ edition = "2021" license = "MIT" [dependencies] -aes-gcm = { workspace = true, optional = true } +chacha20poly1305 = { workspace = true, optional = true } borsh = { workspace = true, features = ["derive"], optional = true } cosmwasm-std = { workspace = true, optional = true } cw-storage-plus = { workspace = true, optional = true } @@ -23,7 +23,7 @@ chaincryptography = [] cosmwasm = ["dep:cosmwasm-std", "dep:cw-storage-plus"] realcryptography = [ "borsh?/std", - "dep:aes-gcm", + "dep:chacha20poly1305", "dep:hex", "dep:hkdf", "dep:k256", diff --git a/packages/shared/src/cryptography/real.rs b/packages/shared/src/cryptography/real.rs index 42af9c76..128a2c31 100644 --- a/packages/shared/src/cryptography/real.rs +++ b/packages/shared/src/cryptography/real.rs @@ -1,16 +1,15 @@ pub use rand::rngs::ThreadRng; -use aes_gcm::{ - aead::{Aead, KeyInit}, - Aes256Gcm, Key, Nonce, +use chacha20poly1305::{ + aead::{AeadInPlace, KeyInit}, + ChaCha20Poly1305, Key, Nonce, Tag, }; use k256::ecdh::{diffie_hellman, EphemeralSecret, SharedSecret}; use rand::{CryptoRng, RngCore}; use sha2::Sha256; use std::{fmt::Display, str::FromStr}; -const KEY_DERIVATION_SALT: &[u8] = b"kolme-ecdh-salt-v1"; -const KEY_DERIVATION_INFO_PREFIX: &[u8] = b"kolme-ecdh-aes256-gcm-v1"; +const KEY_DERIVATION_INFO: &[u8] = b"kolme/chacha20poly1305-v1"; /// Newtype wrapper around [k256::PublicKey] to provide consistent serialization. #[derive(Clone, Copy, PartialEq, Eq, PartialOrd, Ord)] @@ -82,20 +81,22 @@ impl PublicKey { let ephemeral_secret = EphemeralSecret::random(rng); let ephemeral_public_key: PublicKey = k256::PublicKey::from(&ephemeral_secret).into(); let shared_secret = ephemeral_secret.diffie_hellman(&self.0); - let key = derive_encryption_key(&shared_secret, &ephemeral_public_key, self)?; + let key = derive_encryption_key(&shared_secret)?; let mut nonce_bytes = [0u8; 12]; rng.fill_bytes(&mut nonce_bytes); - let cipher = Aes256Gcm::new(&key); - let ciphertext = cipher - .encrypt(Nonce::from_slice(&nonce_bytes), plaintext.as_ref()) + let cipher = ChaCha20Poly1305::new(&key); + let mut buffer = plaintext.as_ref().to_vec(); + let tag = cipher + .encrypt_in_place_detached(Nonce::from_slice(&nonce_bytes), &[], &mut buffer) .map_err(|_| EncryptionError::EncryptionFailed)?; Ok(EncryptedMessage { ephemeral_public_key, nonce: nonce_bytes, - ciphertext, + ciphertext: buffer, + tag: tag.into(), }) } } @@ -215,6 +216,7 @@ pub struct EncryptedMessage { pub ephemeral_public_key: PublicKey, pub nonce: [u8; 12], pub ciphertext: Vec, + pub tag: [u8; 16], } #[derive(Clone, PartialEq, Eq)] @@ -281,19 +283,16 @@ impl SecretKey { self.0.to_nonzero_scalar(), message.ephemeral_public_key.0.as_affine(), ); - let key = derive_encryption_key( - &shared_secret, - &message.ephemeral_public_key, - &self.public_key(), - )?; + let key = derive_encryption_key(&shared_secret)?; - let cipher = Aes256Gcm::new(&key); + let cipher = ChaCha20Poly1305::new(&key); + let mut buffer = message.ciphertext.clone(); + let tag = Tag::from_slice(&message.tag); cipher - .decrypt( - Nonce::from_slice(&message.nonce), - message.ciphertext.as_ref(), - ) - .map_err(|_| EncryptionError::DecryptionFailed) + .decrypt_in_place_detached(Nonce::from_slice(&message.nonce), &[], &mut buffer, tag) + .map_err(|_| EncryptionError::DecryptionFailed)?; + + Ok(buffer) } } @@ -311,27 +310,13 @@ impl std::fmt::Debug for SecretKey { } } -fn derive_encryption_key( - shared_secret: &SharedSecret, - ephemeral_public_key: &PublicKey, - recipient_public_key: &PublicKey, -) -> Result, EncryptionError> { - let hkdf = shared_secret.extract::(Some(KEY_DERIVATION_SALT)); - let ephemeral_bytes = ephemeral_public_key.as_bytes(); - let recipient_bytes = recipient_public_key.as_bytes(); - - let mut info = Vec::with_capacity( - KEY_DERIVATION_INFO_PREFIX.len() + ephemeral_bytes.len() + recipient_bytes.len(), - ); - info.extend_from_slice(KEY_DERIVATION_INFO_PREFIX); - info.extend_from_slice(&ephemeral_bytes); - info.extend_from_slice(&recipient_bytes); - +fn derive_encryption_key(shared_secret: &SharedSecret) -> Result { + let hkdf = shared_secret.extract::(None); let mut okm = [0u8; 32]; - hkdf.expand(&info, &mut okm) + hkdf.expand(KEY_DERIVATION_INFO, &mut okm) .map_err(|_| EncryptionError::KeyDerivationFailed)?; - Ok(Key::::clone_from_slice(&okm)) + Ok(Key::clone_from_slice(&okm)) } mod sigerr { From e68b00bc2bff1b92985473e657c64d27ef87079f Mon Sep 17 00:00:00 2001 From: Michael Snoyman Date: Tue, 25 Nov 2025 18:09:59 +0200 Subject: [PATCH 3/3] chore: regenerate Solana lock file --- solana/Cargo.lock | 49 +++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 49 insertions(+) diff --git a/solana/Cargo.lock b/solana/Cargo.lock index 81f65ff8..f6f6d503 100644 --- a/solana/Cargo.lock +++ b/solana/Cargo.lock @@ -545,6 +545,30 @@ version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724" +[[package]] +name = "chacha20" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3613f74bd2eac03dad61bd53dbe620703d4371614fe0bc3b9f04dd36fe4e818" +dependencies = [ + "cfg-if", + "cipher", + "cpufeatures", +] + +[[package]] +name = "chacha20poly1305" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "10cd79432192d1c0f4e1a0fef9527696cc039165d729fb41b3f4f4f354c2dc35" +dependencies = [ + "aead", + "chacha20", + "cipher", + "poly1305", + "zeroize", +] + [[package]] name = "chrono" version = "0.4.40" @@ -568,6 +592,7 @@ checksum = "773f3b9af64447d2ce9850330c473515014aa235e6a783b02db81ff39e4a3dad" dependencies = [ "crypto-common", "inout", + "zeroize", ] [[package]] @@ -875,6 +900,7 @@ dependencies = [ "ff", "generic-array", "group", + "hkdf", "pkcs8", "rand_core 0.6.4", "sec1", @@ -1176,6 +1202,15 @@ version = "0.4.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70" +[[package]] +name = "hkdf" +version = "0.12.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7b5f8eb2ad728638ea2c7d47a21db23b7b58a72ed6a38256b8a1849f15fbbdf7" +dependencies = [ + "hmac 0.12.1", +] + [[package]] name = "hmac" version = "0.8.1" @@ -2098,6 +2133,17 @@ version = "0.3.32" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7edddbd0b52d732b21ad9a5fab5c704c14cd949e5e9a1ec5929a24fded1b904c" +[[package]] +name = "poly1305" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8159bd90725d2df49889a078b54f4f79e87f1f8a8444194cdca81d38f5393abf" +dependencies = [ + "cpufeatures", + "opaque-debug", + "universal-hash", +] + [[package]] name = "polyval" version = "0.6.2" @@ -2563,10 +2609,13 @@ name = "shared" version = "0.1.0" dependencies = [ "borsh 1.5.7", + "chacha20poly1305", "hex", + "hkdf", "k256", "rand 0.8.5", "serde", + "sha2 0.10.8", "thiserror 2.0.12", ]