diff --git a/Cargo.toml b/Cargo.toml index 95681a5..d566c5e 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -34,6 +34,7 @@ snip721 = ["secret-toolkit-snip721", "utils"] storage = ["secret-toolkit-storage", "serialization"] utils = ["secret-toolkit-utils"] viewing-key = ["secret-toolkit-viewing-key"] +notification = ["secret-toolkit-notification"] [dependencies] secret-toolkit-crypto = { version = "0.10.1", path = "packages/crypto", optional = true } @@ -45,7 +46,7 @@ secret-toolkit-snip721 = { version = "0.10.1", path = "packages/snip721", option secret-toolkit-storage = { version = "0.10.1", path = "packages/storage", optional = true } secret-toolkit-utils = { version = "0.10.1", path = "packages/utils", optional = true } secret-toolkit-viewing-key = { version = "0.10.1", path = "packages/viewing_key", optional = true } - +secret-toolkit-notification = { version = "0.10.1", path = "packages/notification", optional = true } [workspace] members = ["packages/*"] diff --git a/packages/crypto/Cargo.toml b/packages/crypto/Cargo.toml index a0ab92e..6dfa584 100644 --- a/packages/crypto/Cargo.toml +++ b/packages/crypto/Cargo.toml @@ -18,6 +18,7 @@ default = ["hash", "ecc-secp256k1", "rand"] hash = ["sha2"] ecc-secp256k1 = ["secp256k1"] rand = ["hash", "rand_chacha", "rand_core"] +hkdf = ["sha2"] [dependencies] rand_core = { version = "0.6.4", default-features = false, optional = true } @@ -26,6 +27,7 @@ sha2 = { version = "0.10.6", default-features = false, optional = true } secp256k1 = { version = "0.27.0", default-features = false, features = [ "alloc", ], optional = true } +hkdf = "0.12.3" cosmwasm-std = { workspace = true } cc = { version = "=1.1.10" } diff --git a/packages/crypto/src/hkdf.rs b/packages/crypto/src/hkdf.rs new file mode 100644 index 0000000..583b217 --- /dev/null +++ b/packages/crypto/src/hkdf.rs @@ -0,0 +1,40 @@ +use cosmwasm_std::{StdError, StdResult}; +use hkdf::{hmac::Hmac, Hkdf}; +use sha2::{Sha256, Sha512}; + +// Create alias for HMAC-SHA256 +pub type HmacSha256 = Hmac; + +pub fn hkdf_sha_256( + salt: &Option>, + ikm: &[u8], + info: &[u8], + length: usize, +) -> StdResult> { + let hk: Hkdf = Hkdf::::new(salt.as_deref(), ikm); + let mut zero_bytes = vec![0u8; length]; + let okm = zero_bytes.as_mut_slice(); + match hk.expand(info, okm) { + Ok(_) => Ok(okm.to_vec()), + Err(e) => { + Err(StdError::generic_err(format!("{:?}", e))) + } + } +} + +pub fn hkdf_sha_512( + salt: &Option>, + ikm: &[u8], + info: &[u8], + length: usize, +) -> StdResult> { + let hk: Hkdf = Hkdf::::new(salt.as_deref(), ikm); + let mut zero_bytes = vec![0u8; length]; + let okm = zero_bytes.as_mut_slice(); + match hk.expand(info, okm) { + Ok(_) => Ok(okm.to_vec()), + Err(e) => { + Err(StdError::generic_err(format!("{:?}", e))) + } + } +} \ No newline at end of file diff --git a/packages/crypto/src/lib.rs b/packages/crypto/src/lib.rs index 5fe8308..cfbcb89 100644 --- a/packages/crypto/src/lib.rs +++ b/packages/crypto/src/lib.rs @@ -12,3 +12,8 @@ pub use hash::{sha_256, SHA256_HASH_SIZE}; #[cfg(feature = "rand")] pub use rng::ContractPrng; + +#[cfg(feature = "hkdf")] +pub mod hkdf; +#[cfg(feature = "hkdf")] +pub use crate::hkdf::*; diff --git a/packages/notification/Cargo.toml b/packages/notification/Cargo.toml new file mode 100644 index 0000000..99e8192 --- /dev/null +++ b/packages/notification/Cargo.toml @@ -0,0 +1,35 @@ +[package] +name = "secret-toolkit-notification" +version = "0.10.1" +edition = "2021" +authors = ["darwinzer0","blake-regalia"] +license-file = "../../LICENSE" +repository = "https://github.com/scrtlabs/secret-toolkit" +readme = "Readme.md" +description = "Helper tools for SNIP-52 notifications in Secret Contracts" +categories = ["cryptography::cryptocurrencies", "wasm"] +keywords = ["secret-network", "secret-contracts", "secret-toolkit"] + +[package.metadata.docs.rs] +all-features = true + +[dependencies] +cosmwasm-std = { workspace = true, version = "1.0.0" } +serde = { workspace = true } + +ripemd = { version = "0.1.3", default-features = false } +schemars = { workspace = true } + +# rand_core = { version = "0.6.4", default-features = false } +# rand_chacha = { version = "0.3.1", default-features = false } +sha2 = "0.10.6" +chacha20poly1305 = { version = "0.10.1", default-features = false, features = ["alloc", "rand_core"] } +generic-array = "0.14.7" +hkdf = "0.12.3" +primitive-types = { version = "0.12.2", default-features = false } +hex = "0.4.3" +minicbor = "0.25.1" + +secret-toolkit-crypto = { version = "0.10.1", path = "../crypto", features = [ + "hash", "hkdf" +] } \ No newline at end of file diff --git a/packages/notification/Readme.md b/packages/notification/Readme.md new file mode 100644 index 0000000..f3375d3 --- /dev/null +++ b/packages/notification/Readme.md @@ -0,0 +1,72 @@ +# Secret Contract Development Toolkit - SNIP52 (Private Push Notification) Interface + +⚠️ This package is a sub-package of the `secret-toolkit` package. Please see its crate page for more context. + +These functions are meant to help you easily create notification channels for private push notifications in secret contracts (see [SNIP-52 Private Push Notification](https://github.com/SolarRepublic/SNIPs/blob/feat/snip-52/SNIP-52.md)). + +### Implementing a `DirectChannel` struct + +Each notification channel will have a specified data format, which is defined by creating a struct that implements the `DirectChannel` trait, which has one method: `encode_cbor`. + +The following example illustrates how you might implement this for a channel called `my_channel` and notification data containing two fields: `sender` and `amount`. + +```rust +use cosmwasm_std::{Api, StdError, StdResult}; +use secret_toolkit::notification::{EncoderExt, CBL_ARRAY_SHORT, CBL_BIGNUM_U64, CBL_U8, Notification, DirectChannel, GroupChannel}; +use serde::{Deserialize, Serialize}; +use minicbor_ser as cbor; + +#[derive(Serialize, Debug, Deserialize, Clone)] +pub struct MyNotification { + pub sender: Addr, + pub amount: u128, +} + +impl DirectChannel for MyNotification { + const CHANNEL_ID: &'static str = "my_channel"; + const CDDL_SCHEMA: &'static str = "my_channel=[sender:bstr .size 20,amount:uint .size 8]"; + const ELEMENTS: u64 = 2; + const PAYLOAD_SIZE: usize = CBL_ARRAY_SHORT + CBL_BIGNUM_U64 + CBL_U8; + + fn encode_cbor(&self, api: &dyn Api, encoder: &mut Encoder<&mut [u8]>) -> StdResult<()> { + // amount:biguint (8-byte uint) + encoder.ext_u64_from_u128(self.amount)?; + + // sender:bstr (20-byte address) + let sender_raw = api.addr_canonicalize(sender.as_str())?; + encoder.ext_address(sender_raw)?; + + Ok(()) + } +} +``` + + +### Sending a TxHash notification + +To send a notification to a recipient you first create a new `Notification` struct passing in the address of the recipient along with the notification data you want to send. Then to turn it into a `TxHashNotification` execute the `to_txhash_notification` method on the `Notification` by passing in `deps.api`, `env`, and an internal `secret`, which is a randomly generated byte slice that has been stored previously in your contract during initialization. + +The following code snippet creates a notification for the above `my_channel` and adds it to the contract `Response` as a plaintext attribute. + +```rust +let notification = Notification::new( + recipient, + MyNotification { + sender, + 1000_u128, + } +) +.to_txhash_notification(deps.api, &env, secret)?; + +// ... other code + +// add notification to response +Ok(Response::new() + .set_data(to_binary(&ExecuteAnswer::MyMessage { status: Success } )?) + .add_attribute_plaintext( + notification.id_plaintext(), + notification.data_plaintext(), + ) +) +``` + diff --git a/packages/notification/src/cbor.rs b/packages/notification/src/cbor.rs new file mode 100644 index 0000000..b9a2213 --- /dev/null +++ b/packages/notification/src/cbor.rs @@ -0,0 +1,102 @@ +use cosmwasm_std::{CanonicalAddr, StdResult, StdError}; +use minicbor::{data as cbor_data, encode as cbor_encode, Encoder}; + +/// Length of encoding an arry header that holds less than 24 items +pub const CBL_ARRAY_SHORT: usize = 1; + +/// Length of encoding an arry header that holds between 24 and 255 items +pub const CBL_ARRAY_MEDIUM: usize = 2; + +/// Length of encoding an arry header that holds more than 255 items +pub const CBL_ARRAY_LARGE: usize = 3; + +/// Length of encoding a u8 value that is less than 24 +pub const CBL_U8_LESS_THAN_24: usize = 1; + +/// Length of encoding a u8 value that is greater than or equal to 24 +pub const CBL_U8: usize = 1 + 1; + +/// Length of encoding a u16 value +pub const CBL_U16: usize = 1 + 2; + +/// Length of encoding a u32 value +pub const CBL_U32: usize = 1 + 4; + +/// Length of encoding a u53 value (the maximum safe integer size for javascript) +pub const CBL_U53: usize = 1 + 8; + +/// Length of encoding a u64 value (with the bignum tag attached) +pub const CBL_BIGNUM_U64: usize = 1 + 1 + 8; + +// Length of encoding a timestamp +pub const CBL_TIMESTAMP: usize = 1 + 1 + 8; + +// Length of encoding a 20-byte canonical address +pub const CBL_ADDRESS: usize = 1 + 20; + +/// Wraps the CBOR error to CosmWasm StdError +pub fn cbor_to_std_error(e: cbor_encode::Error) -> StdError { + StdError::generic_err("CBOR encoding error") +} + +/// Extends the minicbor encoder with wrapper functions that handle CBOR errors +pub trait EncoderExt { + fn ext_tag(&mut self, tag: cbor_data::IanaTag) -> StdResult<&mut Self>; + + fn ext_u8(&mut self, value: u8) -> StdResult<&mut Self>; + fn ext_u32(&mut self, value: u32) -> StdResult<&mut Self>; + fn ext_u64_from_u128(&mut self, value: u128) -> StdResult<&mut Self>; + fn ext_address(&mut self, value: CanonicalAddr) -> StdResult<&mut Self>; + fn ext_bytes(&mut self, value: &[u8]) -> StdResult<&mut Self>; + fn ext_timestamp(&mut self, value: u64) -> StdResult<&mut Self>; +} + +impl EncoderExt for Encoder { + #[inline] + fn ext_tag(&mut self, tag: cbor_data::IanaTag) -> StdResult<&mut Self> { + self + .tag(cbor_data::Tag::from(tag)) + .map_err(cbor_to_std_error) + } + + #[inline] + fn ext_u8(&mut self, value: u8) -> StdResult<&mut Self> { + self + .u8(value) + .map_err(cbor_to_std_error) + } + + #[inline] + fn ext_u32(&mut self, value: u32) -> StdResult<&mut Self> { + self + .u32(value) + .map_err(cbor_to_std_error) + } + + #[inline] + fn ext_u64_from_u128(&mut self, value: u128) -> StdResult<&mut Self> { + self + .ext_tag(cbor_data::IanaTag::PosBignum)? + .ext_bytes(&value.to_be_bytes()[8..]) + } + + #[inline] + fn ext_address(&mut self, value: CanonicalAddr) -> StdResult<&mut Self> { + self.ext_bytes(&value.as_slice()) + } + + #[inline] + fn ext_bytes(&mut self, value: &[u8]) -> StdResult<&mut Self> { + self + .bytes(&value) + .map_err(cbor_to_std_error) + } + + #[inline] + fn ext_timestamp(&mut self, value: u64) -> StdResult<&mut Self> { + self + .ext_tag(cbor_data::IanaTag::Timestamp)? + .u64(value) + .map_err(cbor_to_std_error) + } +} \ No newline at end of file diff --git a/packages/notification/src/cipher.rs b/packages/notification/src/cipher.rs new file mode 100644 index 0000000..1d42dc8 --- /dev/null +++ b/packages/notification/src/cipher.rs @@ -0,0 +1,20 @@ +use chacha20poly1305::{ + aead::{AeadInPlace, KeyInit}, + ChaCha20Poly1305, +}; +use cosmwasm_std::{StdError, StdResult}; +use generic_array::GenericArray; + +pub fn cipher_data(key: &[u8], nonce: &[u8], plaintext: &[u8], aad: &[u8]) -> StdResult> { + let cipher = ChaCha20Poly1305::new_from_slice(key) + .map_err(|e| StdError::generic_err(format!("{:?}", e)))?; + let mut buffer: Vec = plaintext.to_vec(); + cipher + .encrypt_in_place(GenericArray::from_slice(nonce), aad, &mut buffer) + .map_err(|e| StdError::generic_err(format!("{:?}", e)))?; + Ok(buffer) +} + +pub fn xor_bytes(vec1: &[u8], vec2: &[u8]) -> Vec { + vec1.iter().zip(vec2.iter()).map(|(&a, &b)| a ^ b).collect() +} diff --git a/packages/notification/src/funcs.rs b/packages/notification/src/funcs.rs new file mode 100644 index 0000000..f6d9431 --- /dev/null +++ b/packages/notification/src/funcs.rs @@ -0,0 +1,99 @@ +use cosmwasm_std::{Binary, CanonicalAddr, StdResult}; +use secret_toolkit_crypto::{sha_256, hkdf_sha_256, HmacSha256}; +use hkdf::hmac::Mac; +use crate::cipher_data; + +pub const SEED_LEN: usize = 32; // 256 bits + +/// +/// fn notification_id +/// +/// Returns a notification id for the given address and channel id. +/// +pub fn notification_id(seed: &Binary, channel: &str, tx_hash: &String) -> StdResult { + // compute notification ID for this event + let material = [channel.as_bytes(), ":".as_bytes(), tx_hash.to_ascii_uppercase().as_bytes()].concat(); + + // create HMAC from seed + let mut mac: HmacSha256 = HmacSha256::new_from_slice(seed.0.as_slice()).unwrap(); + + // add material to input stream + mac.update(material.as_slice()); + + // finalize the digest and convert to CW Binary + Ok(Binary::from(mac.finalize().into_bytes().as_slice())) +} + +/// +/// fn encrypt_notification_data +/// +/// Returns encrypted bytes given plaintext bytes, address, and channel id. +/// Optionally, can set block size (default 36). +/// +pub fn encrypt_notification_data( + block_height: &u64, + tx_hash: &String, + seed: &Binary, + channel: &str, + plaintext: Vec, + block_size: Option, +) -> StdResult { + // pad the plaintext to the optionally given block size + let mut padded_plaintext = plaintext.clone(); + if let Some(size) = block_size { + zero_pad_right(&mut padded_plaintext, size); + } + + // take the last 12 bytes of the channel name's hash to create the channel ID + let channel_id_bytes = sha_256(channel.as_bytes())[..12].to_vec(); + + // take the last 12 bytes of the tx hash (after hex-decoding) to use for salt + let salt_bytes = hex::decode(tx_hash).unwrap()[..12].to_vec(); + + // generate nonce by XOR'ing channel ID with salt + let nonce: Vec = channel_id_bytes + .iter() + .zip(salt_bytes.iter()) + .map(|(&b1, &b2)| b1 ^ b2) + .collect(); + + // secure this message by attaching the block height and tx hash to the additional authenticated data + let aad = format!("{}:{}", block_height, tx_hash); + + // encrypt notification data for this event + let tag_ciphertext = cipher_data( + seed.0.as_slice(), + nonce.as_slice(), + padded_plaintext.as_slice(), + aad.as_bytes(), + )?; + + Ok(Binary::from(tag_ciphertext.clone())) +} + +/// get the seed for a secret and given address +pub fn get_seed(addr: &CanonicalAddr, secret: &[u8]) -> StdResult { + let seed = hkdf_sha_256( + &None, + secret, + addr.as_slice(), + SEED_LEN, + )?; + + Ok(Binary::from(seed)) +} + +/// take a Vec and pad it up to a multiple of `block_size`, using 0x00 at the end +fn zero_pad_right(message: &mut Vec, block_size: usize) -> &mut Vec { + let len = message.len(); + let surplus = len % block_size; + if surplus == 0 { + return message; + } + + let missing = block_size - surplus; + message.reserve(missing); + message.extend(std::iter::repeat(0x00).take(missing)); + message +} + diff --git a/packages/notification/src/lib.rs b/packages/notification/src/lib.rs new file mode 100644 index 0000000..2161554 --- /dev/null +++ b/packages/notification/src/lib.rs @@ -0,0 +1,10 @@ +#![doc = include_str!("../Readme.md")] + +pub mod structs; +pub mod funcs; +pub mod cipher; +pub mod cbor; +pub use structs::*; +pub use funcs::*; +pub use cipher::*; +pub use cbor::*; diff --git a/packages/notification/src/structs.rs b/packages/notification/src/structs.rs new file mode 100644 index 0000000..212238a --- /dev/null +++ b/packages/notification/src/structs.rs @@ -0,0 +1,193 @@ +use cosmwasm_std::{Addr, Api, Binary, Env, StdError, StdResult, Uint64}; +use schemars::JsonSchema; +use serde::{Deserialize, Serialize}; +use minicbor::{encode as cbor_encode, Encoder}; + +use crate::{encrypt_notification_data, get_seed, notification_id, cbor_to_std_error}; + +#[derive(Serialize, Debug, Deserialize, Clone)] +#[cfg_attr(test, derive(Eq, PartialEq))] +pub struct Notification { + /// Recipient address of the notification + pub notification_for: Addr, + /// Typed notification data + pub data: T, +} + +pub trait DirectChannel { + const CHANNEL_ID: &'static str; + const CDDL_SCHEMA: &'static str; + const ELEMENTS: u64; + const PAYLOAD_SIZE: usize; + + fn channel_id(&self) -> String { + Self::CHANNEL_ID.to_string() + } + + fn cddl_schema(&self) -> String { + Self::CDDL_SCHEMA.to_string() + } + + fn to_cbor(&self, api: &dyn Api) -> StdResult> { + // dynamically allocate output buffer + let mut buffer = vec![0u8; Self::PAYLOAD_SIZE]; + + // create CBOR encoder + let mut encoder = Encoder::new(&mut buffer[..]); + + // encode number of elements + encoder.array(Self::ELEMENTS).map_err(cbor_to_std_error)?; + + // encode CBOR data + self.encode_cbor(api, &mut encoder)?; + + // return buffer (already right-padded with zero bytes) + Ok(buffer) + } + + /// CBOR encodes notification data into the encoder + fn encode_cbor(&self, api: &dyn Api, encoder: &mut Encoder<&mut [u8]>) -> StdResult<()>; +} + + +impl Notification { + pub fn new(notification_for: Addr, data: T) -> Self { + Notification { + notification_for, + data, + } + } + + pub fn to_txhash_notification( + &self, + api: &dyn Api, + env: &Env, + secret: &[u8], + block_size: Option, + ) -> StdResult { + // extract and normalize tx hash + let tx_hash = env.transaction.clone() + .ok_or(StdError::generic_err("no tx hash found"))? + .hash.to_ascii_uppercase(); + + // canonicalize notification recipient address + let notification_for_raw = api.addr_canonicalize(self.notification_for.as_str())?; + + // derive recipient's notification seed + let seed = get_seed(¬ification_for_raw, secret)?; + + // derive notification id + let id = notification_id(&seed, self.data.channel_id().as_str(), &tx_hash)?; + + // use CBOR to encode the data + let cbor_data = self.data.to_cbor(api)?; + + // encrypt the receiver message + let encrypted_data = encrypt_notification_data( + &env.block.height, + &tx_hash, + &seed, + self.data.channel_id().as_str(), + cbor_data, + block_size, + )?; + + // enstruct + Ok(TxHashNotification { + id, + encrypted_data, + }) + } +} + + +#[derive(Serialize, Debug, Deserialize, Clone)] +#[cfg_attr(test, derive(Eq, PartialEq))] +pub struct TxHashNotification { + pub id: Binary, + pub encrypted_data: Binary, +} + +impl TxHashNotification { + pub fn id_plaintext(&self) -> String { + format!("snip52:{}", self.id.to_base64()) + } + + pub fn data_plaintext(&self) -> String { + self.encrypted_data.to_base64() + } +} + +// types for channel info response + +#[derive(Serialize, Deserialize, JsonSchema, Clone, Debug)] +pub struct ChannelInfoData { + /// same as query input + pub channel: String, + /// "counter", "txhash", "bloom" + pub mode: String, + + /// txhash / bloom fields only + /// if txhash argument was given, this will be its computed Notification ID + pub answer_id: Option, + + /// bloom fields only + /// bloom filter parameters + pub parameters: Option, + /// bloom filter data + pub data: Option, + + /// counter fields only + /// current counter value + pub counter: Option, + /// the next Notification ID + pub next_id: Option, + + /// counter / txhash field only + /// optional CDDL schema definition string for the CBOR-encoded notification data + pub cddl: Option, +} + +#[derive(Serialize, Deserialize, JsonSchema, Clone, Debug)] +pub struct BloomParameters { + pub m: u32, + pub k: u32, + pub h: String, +} + +#[derive(Serialize, Deserialize, JsonSchema, Clone, Debug)] +pub struct Descriptor { + pub r#type: String, + pub version: String, + pub packet_size: u32, + pub data: StructDescriptor, +} + +#[derive(Serialize, Deserialize, JsonSchema, Clone, Debug)] +pub struct StructDescriptor { + pub r#type: String, + pub label: String, + pub members: Vec, +} + +#[derive(Serialize, Deserialize, JsonSchema, Clone, Debug)] +pub struct FlatDescriptor { + pub r#type: String, + pub label: String, + pub description: Option, +} + +pub trait GroupChannel { + const CHANNEL_ID: &'static str; + const BLOOM_N: usize; + const BLOOM_M: u32; + const BLOOM_K: u32; + const PACKET_SIZE: usize; + + const BLOOM_M_LOG2: u32 = Self::BLOOM_M.ilog2(); + + fn build_packet(&self, api: &dyn Api, data: &D) -> StdResult>; + + fn notifications(&self) -> &Vec>; +} + diff --git a/src/lib.rs b/src/lib.rs index 66bf181..6e87575 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -18,3 +18,5 @@ pub use secret_toolkit_storage as storage; pub use secret_toolkit_utils as utils; #[cfg(feature = "viewing-key")] pub use secret_toolkit_viewing_key as viewing_key; +#[cfg(feature = "notification")] +pub use secret_toolkit_notification as notification;