From 3374cadf8e2c960df6ee7e0b392e794e8e18bd1f Mon Sep 17 00:00:00 2001 From: Tommi Niemi Date: Fri, 13 Mar 2026 22:17:52 +0700 Subject: [PATCH 1/3] Add ECIES encryption support to subxt-signer Adds encrypt/decrypt methods to sr25519::Keypair using schnorrkel's new ECIES module. Gated behind the `ecies` feature flag. Depends on: paritytech/schnorrkel#116 --- Cargo.lock | 44 ++++++++++++++++++++++++++++++++++------ Cargo.toml | 2 +- signer/Cargo.toml | 1 + signer/examples/ecies.rs | 38 ++++++++++++++++++++++++++++++++++ signer/src/sr25519.rs | 43 +++++++++++++++++++++++++++++++++++++++ 5 files changed, 121 insertions(+), 7 deletions(-) create mode 100644 signer/examples/ecies.rs diff --git a/Cargo.lock b/Cargo.lock index b7ed4711f27..6d29e13e2ee 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -942,6 +942,19 @@ dependencies = [ "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 = "ciborium" version = "0.2.2" @@ -4591,13 +4604,32 @@ dependencies = [ [[package]] name = "schnorrkel" -version = "0.11.4" +version = "0.11.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8de18f6d8ba0aad7045f5feae07ec29899c1112584a38509a84ad7b04451eaa0" +checksum = "6e9fcb6c2e176e86ec703e22560d99d65a5ee9056ae45a08e13e84ebf796296f" +dependencies = [ + "aead", + "arrayref", + "arrayvec 0.7.6", + "curve25519-dalek", + "getrandom_or_panic", + "merlin", + "rand_core", + "serde_bytes", + "sha2 0.10.9", + "subtle", + "zeroize", +] + +[[package]] +name = "schnorrkel" +version = "0.11.5" +source = "git+https://github.com/hitchhooker/schnorrkel?branch=ecies#78d9d86cc291f66299db0a576c25129d865658a3" dependencies = [ "aead", "arrayref", "arrayvec 0.7.6", + "chacha20poly1305", "curve25519-dalek", "getrandom_or_panic", "merlin", @@ -5016,7 +5048,7 @@ dependencies = [ "rand", "rand_chacha", "ruzstd", - "schnorrkel", + "schnorrkel 0.11.5 (registry+https://github.com/rust-lang/crates.io-index)", "serde", "serde_json", "sha2 0.10.9", @@ -5189,7 +5221,7 @@ dependencies = [ "primitive-types", "rand", "scale-info", - "schnorrkel", + "schnorrkel 0.11.5 (registry+https://github.com/rust-lang/crates.io-index)", "secp256k1 0.28.2", "secrecy 0.8.0", "serde", @@ -5621,7 +5653,7 @@ checksum = "ca58ffd742f693dc13d69bdbb2e642ae239e0053f6aab3b104252892f856700a" dependencies = [ "hmac 0.12.1", "pbkdf2", - "schnorrkel", + "schnorrkel 0.11.5 (registry+https://github.com/rust-lang/crates.io-index)", "sha2 0.10.9", "zeroize", ] @@ -5847,7 +5879,7 @@ dependencies = [ "pbkdf2", "proptest", "regex", - "schnorrkel", + "schnorrkel 0.11.5 (git+https://github.com/hitchhooker/schnorrkel?branch=ecies)", "scrypt", "secp256k1 0.30.0", "secrecy 0.10.3", diff --git a/Cargo.toml b/Cargo.toml index 6e89d444d58..52e36f56dac 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -173,7 +173,7 @@ bip39 = { version = "2.1.0", default-features = false } bip32 = { version = "0.5.2", default-features = false } hmac = { version = "0.12.1", default-features = false } pbkdf2 = { version = "0.12.2", default-features = false } -schnorrkel = { version = "0.11.4", default-features = false } +schnorrkel = { version = "0.11.5", default-features = false, git = "https://github.com/hitchhooker/schnorrkel", branch = "ecies" } secp256k1 = { version = "0.30.0", default-features = false } keccak-hash = { version = "0.11.0", default-features = false } secrecy = "0.10.3" diff --git a/signer/Cargo.toml b/signer/Cargo.toml index d281f70958e..851ff5af050 100644 --- a/signer/Cargo.toml +++ b/signer/Cargo.toml @@ -36,6 +36,7 @@ std = [ # ecdsa compiling to WASM on my mac; following this comment helped: # https://github.com/rust-bitcoin/rust-bitcoin/issues/930#issuecomment-1215538699 sr25519 = ["schnorrkel"] +ecies = ["sr25519", "schnorrkel/ecies"] ecdsa = ["secp256k1"] unstable-eth = ["keccak-hash", "ecdsa", "secp256k1", "bip32"] diff --git a/signer/examples/ecies.rs b/signer/examples/ecies.rs new file mode 100644 index 00000000000..73f462d2678 --- /dev/null +++ b/signer/examples/ecies.rs @@ -0,0 +1,38 @@ +// Copyright 2019-2026 Parity Technologies (UK) Ltd. +// This file is dual-licensed as Apache-2.0 or GPL-3.0. +// see LICENSE for license details. + +//! ECIES encryption/decryption example using sr25519 dev accounts. + +use subxt_signer::sr25519; + +fn main() { + let alice = sr25519::dev::alice(); + let bob = sr25519::dev::bob(); + + let plaintext = b"The quick brown fox jumps over the lazy dog"; + let ctx = b"example-ecies-v1"; + + // Alice encrypts a message for Bob + let encrypted = alice + .encrypt(plaintext, &bob.public_key(), ctx) + .expect("encryption failed"); + + println!("Plaintext: {} bytes", plaintext.len()); + println!("Ciphertext: {} bytes (overhead: {} bytes)", encrypted.len(), encrypted.len() - plaintext.len()); + + // Bob decrypts + let decrypted = bob.decrypt(&encrypted, ctx).expect("decryption failed"); + assert_eq!(&decrypted[..], plaintext); + println!("Bob decrypted successfully: {:?}", core::str::from_utf8(&decrypted).unwrap()); + + // Alice cannot decrypt (wrong key) + let result = alice.decrypt(&encrypted, ctx); + assert!(result.is_err()); + println!("Alice cannot decrypt Bob's message: {:?}", result.unwrap_err()); + + // Wrong context fails + let result = bob.decrypt(&encrypted, b"wrong-context"); + assert!(result.is_err()); + println!("Wrong context fails: {:?}", result.unwrap_err()); +} diff --git a/signer/src/sr25519.rs b/signer/src/sr25519.rs index 56aeeeb03f5..eede67724cf 100644 --- a/signer/src/sr25519.rs +++ b/signer/src/sr25519.rs @@ -182,6 +182,49 @@ impl Keypair { let signature = self.0.sign(context.bytes(message)); Signature(signature.to_bytes()) } + + /// Encrypt `plaintext` for `recipient` using ECIES over sr25519. + /// + /// The `ctx` parameter provides domain separation — use a unique + /// application-specific byte string (e.g. `b"my-app-v1"`). + /// + /// # Example + /// + /// ```rust,standalone_crate + /// use subxt_signer::sr25519; + /// + /// let alice = sr25519::dev::alice(); + /// let bob = sr25519::dev::bob(); + /// + /// let encrypted = alice.encrypt(b"secret message", &bob.public_key(), b"example") + /// .expect("encryption works"); + /// let decrypted = bob.decrypt(&encrypted, b"example") + /// .expect("decryption works"); + /// assert_eq!(decrypted, b"secret message"); + /// ``` + #[cfg(feature = "ecies")] + pub fn encrypt( + &self, + plaintext: &[u8], + recipient: &PublicKey, + ctx: &[u8], + ) -> Result, schnorrkel::ecies::EciesError> { + let recipient_pk = schnorrkel::PublicKey::from_bytes(&recipient.0) + .map_err(|_| schnorrkel::ecies::EciesError::InvalidEphemeralKey)?; + schnorrkel::ecies::encrypt(plaintext, &recipient_pk, ctx) + } + + /// Decrypt an ECIES ciphertext using this keypair's secret key. + /// + /// The `ctx` must match the context used during encryption. + #[cfg(feature = "ecies")] + pub fn decrypt( + &self, + ciphertext: &[u8], + ctx: &[u8], + ) -> Result, schnorrkel::ecies::EciesError> { + schnorrkel::ecies::decrypt(ciphertext, &self.0.secret, ctx) + } } /// Verify that some signature for a message was created by the owner of the [`PublicKey`]. From 6e78ce904f7ce01ce44cb836ee328941be044205 Mon Sep 17 00:00:00 2001 From: Tommi Niemi Date: Sat, 14 Mar 2026 01:30:50 +0700 Subject: [PATCH 2/3] Add WASM ECIES test for subxt-signer --- signer/tests/wasm/Cargo.lock | 40 +++++++++++++++++++++++++++++---- signer/tests/wasm/Cargo.toml | 1 + signer/tests/wasm/tests/wasm.rs | 21 +++++++++++++++++ 3 files changed, 58 insertions(+), 4 deletions(-) diff --git a/signer/tests/wasm/Cargo.lock b/signer/tests/wasm/Cargo.lock index 86e85b12da6..d5ccb235087 100644 --- a/signer/tests/wasm/Cargo.lock +++ b/signer/tests/wasm/Cargo.lock @@ -307,6 +307,19 @@ dependencies = [ "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 = "cipher" version = "0.4.4" @@ -789,9 +802,9 @@ dependencies = [ [[package]] name = "frame-decode" -version = "0.16.1" +version = "0.17.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e63257bb5f8d7a707d626aa1b4e464c3b9720edd168b73cee98f48f0dd6386e4" +checksum = "88cda60c640572c970c544ba5879375a18ecfb2c47c617be8265830b63df193d" dependencies = [ "frame-metadata", "parity-scale-codec", @@ -2105,6 +2118,25 @@ dependencies = [ "zeroize", ] +[[package]] +name = "schnorrkel" +version = "0.11.5" +source = "git+https://github.com/hitchhooker/schnorrkel?branch=ecies#78d9d86cc291f66299db0a576c25129d865658a3" +dependencies = [ + "aead", + "arrayref", + "arrayvec 0.7.6", + "chacha20poly1305", + "curve25519-dalek", + "getrandom_or_panic", + "merlin", + "rand_core", + "serde_bytes", + "sha2 0.10.9", + "subtle", + "zeroize", +] + [[package]] name = "scrypt" version = "0.11.0" @@ -2397,7 +2429,7 @@ dependencies = [ "rand", "rand_chacha", "ruzstd", - "schnorrkel", + "schnorrkel 0.11.5 (registry+https://github.com/rust-lang/crates.io-index)", "serde", "serde_json", "sha2 0.10.9", @@ -2660,7 +2692,7 @@ dependencies = [ "parity-scale-codec", "pbkdf2", "regex", - "schnorrkel", + "schnorrkel 0.11.5 (git+https://github.com/hitchhooker/schnorrkel?branch=ecies)", "scrypt", "secp256k1 0.30.0", "secrecy", diff --git a/signer/tests/wasm/Cargo.toml b/signer/tests/wasm/Cargo.toml index 3ee73f727bd..ec4cdb1c24d 100644 --- a/signer/tests/wasm/Cargo.toml +++ b/signer/tests/wasm/Cargo.toml @@ -17,6 +17,7 @@ subxt-signer = { path = "../../", default-features = false, features = [ "web", "sr25519", "ecdsa", + "ecies", "unstable-eth", "std", ] } diff --git a/signer/tests/wasm/tests/wasm.rs b/signer/tests/wasm/tests/wasm.rs index 1051877225c..a3f9148c80c 100644 --- a/signer/tests/wasm/tests/wasm.rs +++ b/signer/tests/wasm/tests/wasm.rs @@ -29,6 +29,27 @@ async fn wasm_sr25519_signing_works() { )); } +#[wasm_bindgen_test] +async fn wasm_sr25519_ecies_works() { + let alice = sr25519::dev::alice(); + let bob = sr25519::dev::bob(); + + let plaintext = b"hello from wasm"; + let ctx = b"wasm-ecies-test"; + + let encrypted = alice.encrypt(plaintext, &bob.public_key(), ctx) + .expect("encryption failed"); + let decrypted = bob.decrypt(&encrypted, ctx) + .expect("decryption failed"); + + assert_eq!(&decrypted[..], plaintext); + + // Wrong key should fail + assert!(alice.decrypt(&encrypted, ctx).is_err()); + // Wrong context should fail + assert!(bob.decrypt(&encrypted, b"wrong-ctx").is_err()); +} + #[wasm_bindgen_test] async fn wasm_ecdsa_signing_works() { let alice = ecdsa::dev::alice(); From 11415a9a43186bb84027232b56dc94373ae9f0f5 Mon Sep 17 00:00:00 2001 From: Tommi Niemi Date: Sat, 14 Mar 2026 12:59:25 +0700 Subject: [PATCH 3/3] update subxt-signer ecies for full viewing key api - add Keypair::viewing_key() to derive FullViewingKey - encrypt now takes recipient ivk public + sender ovk - decrypt unchanged (secret key path still works) - example uses viewing key hierarchy (ivk/ovk) --- Cargo.lock | 2 +- signer/examples/ecies.rs | 24 ++++++++++++++---------- signer/src/sr25519.rs | 31 ++++++++++++++++++++++--------- 3 files changed, 37 insertions(+), 20 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 6d29e13e2ee..15fbbb6a429 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4624,7 +4624,7 @@ dependencies = [ [[package]] name = "schnorrkel" version = "0.11.5" -source = "git+https://github.com/hitchhooker/schnorrkel?branch=ecies#78d9d86cc291f66299db0a576c25129d865658a3" +source = "git+https://github.com/hitchhooker/schnorrkel?branch=ecies#f931ad4320461ca5fcc1fbe5074c789ca069cd52" dependencies = [ "aead", "arrayref", diff --git a/signer/examples/ecies.rs b/signer/examples/ecies.rs index 73f462d2678..f04be15041e 100644 --- a/signer/examples/ecies.rs +++ b/signer/examples/ecies.rs @@ -9,30 +9,34 @@ use subxt_signer::sr25519; fn main() { let alice = sr25519::dev::alice(); let bob = sr25519::dev::bob(); + let alice_vk = alice.viewing_key(); + let bob_vk = bob.viewing_key(); let plaintext = b"The quick brown fox jumps over the lazy dog"; - let ctx = b"example-ecies-v1"; + let ctx = b"example-ecies"; // Alice encrypts a message for Bob let encrypted = alice - .encrypt(plaintext, &bob.public_key(), ctx) + .encrypt(plaintext, bob_vk.ivk_public(), ctx, alice_vk.ovk()) .expect("encryption failed"); println!("Plaintext: {} bytes", plaintext.len()); println!("Ciphertext: {} bytes (overhead: {} bytes)", encrypted.len(), encrypted.len() - plaintext.len()); - // Bob decrypts - let decrypted = bob.decrypt(&encrypted, ctx).expect("decryption failed"); + // Bob decrypts with his viewing key + let decrypted = bob_vk.decrypt_incoming(&encrypted, ctx).expect("decryption failed"); assert_eq!(&decrypted[..], plaintext); - println!("Bob decrypted successfully: {:?}", core::str::from_utf8(&decrypted).unwrap()); + println!("Bob decrypted: {:?}", core::str::from_utf8(&decrypted).unwrap()); - // Alice cannot decrypt (wrong key) - let result = alice.decrypt(&encrypted, ctx); - assert!(result.is_err()); - println!("Alice cannot decrypt Bob's message: {:?}", result.unwrap_err()); + // Alice re-reads her outgoing message + let outgoing = alice_vk + .decrypt_outgoing(&encrypted, bob_vk.ivk_public(), ctx) + .expect("outgoing decryption failed"); + assert_eq!(&outgoing[..], plaintext); + println!("Alice re-read outgoing: {:?}", core::str::from_utf8(&outgoing).unwrap()); // Wrong context fails - let result = bob.decrypt(&encrypted, b"wrong-context"); + let result = bob_vk.decrypt_incoming(&encrypted, b"wrong-context"); assert!(result.is_err()); println!("Wrong context fails: {:?}", result.unwrap_err()); } diff --git a/signer/src/sr25519.rs b/signer/src/sr25519.rs index eede67724cf..d373c47428c 100644 --- a/signer/src/sr25519.rs +++ b/signer/src/sr25519.rs @@ -183,10 +183,20 @@ impl Keypair { Signature(signature.to_bytes()) } + /// Derive the full viewing key for this keypair. + /// + /// The viewing key allows decrypting incoming and outgoing ECIES + /// messages without signing authority. + #[cfg(feature = "ecies")] + pub fn viewing_key(&self) -> schnorrkel::viewing_key::FullViewingKey { + schnorrkel::viewing_key::FullViewingKey::from_keypair(&self.0) + } + /// Encrypt `plaintext` for `recipient` using ECIES over sr25519. /// - /// The `ctx` parameter provides domain separation — use a unique - /// application-specific byte string (e.g. `b"my-app-v1"`). + /// `recipient` should be an IVK public key (from the recipient's + /// viewing key), and `sender_ovk` is this keypair's outgoing + /// viewing key so the sender can later re-read the message. /// /// # Example /// @@ -195,10 +205,14 @@ impl Keypair { /// /// let alice = sr25519::dev::alice(); /// let bob = sr25519::dev::bob(); + /// let alice_vk = alice.viewing_key(); + /// let bob_vk = bob.viewing_key(); + /// + /// let encrypted = alice.encrypt( + /// b"secret message", bob_vk.ivk_public(), b"example", alice_vk.ovk(), + /// ).expect("encryption works"); /// - /// let encrypted = alice.encrypt(b"secret message", &bob.public_key(), b"example") - /// .expect("encryption works"); - /// let decrypted = bob.decrypt(&encrypted, b"example") + /// let decrypted = bob_vk.decrypt_incoming(&encrypted, b"example") /// .expect("decryption works"); /// assert_eq!(decrypted, b"secret message"); /// ``` @@ -206,12 +220,11 @@ impl Keypair { pub fn encrypt( &self, plaintext: &[u8], - recipient: &PublicKey, + recipient: &schnorrkel::PublicKey, ctx: &[u8], + sender_ovk: &schnorrkel::viewing_key::OutgoingViewingKey, ) -> Result, schnorrkel::ecies::EciesError> { - let recipient_pk = schnorrkel::PublicKey::from_bytes(&recipient.0) - .map_err(|_| schnorrkel::ecies::EciesError::InvalidEphemeralKey)?; - schnorrkel::ecies::encrypt(plaintext, &recipient_pk, ctx) + schnorrkel::ecies::encrypt(plaintext, recipient, ctx, sender_ovk) } /// Decrypt an ECIES ciphertext using this keypair's secret key.