diff --git a/component/src/ibc/component/client.rs b/component/src/ibc/component/client.rs index 093f5a9b66..b1ed57a67e 100644 --- a/component/src/ibc/component/client.rs +++ b/component/src/ibc/component/client.rs @@ -684,6 +684,7 @@ mod tests { chain_id: "".to_string(), fee: Default::default(), fmd_clues: vec![], + memo: None, }, anchor: tct::Tree::new().root(), binding_sig: [0u8; 64].into(), @@ -699,6 +700,7 @@ mod tests { chain_id: "".to_string(), fee: Default::default(), fmd_clues: vec![], + memo: None, }, binding_sig: [0u8; 64].into(), anchor: tct::Tree::new().root(), @@ -748,6 +750,7 @@ mod tests { chain_id: "".to_string(), fee: Default::default(), fmd_clues: vec![], + memo: None, }, anchor: tct::Tree::new().root(), binding_sig: [0u8; 64].into(), diff --git a/crypto/src/memo.rs b/crypto/src/memo.rs index 960a5161c9..ba396f1fc1 100644 --- a/crypto/src/memo.rs +++ b/crypto/src/memo.rs @@ -7,10 +7,10 @@ use anyhow::anyhow; use crate::{ ka, - keys::{IncomingViewingKey, OutgoingViewingKey}, + keys::OutgoingViewingKey, note, - symmetric::{OvkWrappedKey, PayloadKey, PayloadKind}, - value, Address, Note, + symmetric::{OvkWrappedKey, PayloadKey, PayloadKind, WrappedMemoKey}, + value, Note, }; pub const MEMO_CIPHERTEXT_LEN_BYTES: usize = 528; @@ -49,16 +49,13 @@ impl TryFrom<&[u8]> for MemoPlaintext { } } +#[derive(Clone, Debug)] +pub struct MemoCiphertext(pub [u8; MEMO_CIPHERTEXT_LEN_BYTES]); + impl MemoPlaintext { /// Encrypt a memo, returning its ciphertext. - pub fn encrypt(&self, esk: &ka::Secret, address: &Address) -> MemoCiphertext { - let epk = esk.diversified_public(address.diversified_generator()); - let shared_secret = esk - .key_agreement_with(address.transmission_key()) - .expect("key agreement succeeds"); - - let key = PayloadKey::derive(&shared_secret, &epk); - let encryption_result = key.encrypt(self.0.to_vec(), PayloadKind::Memo); + pub fn encrypt(&self, memo_key: PayloadKey) -> MemoCiphertext { + let encryption_result = memo_key.encrypt(self.0.to_vec(), PayloadKind::Memo); let ciphertext: [u8; MEMO_CIPHERTEXT_LEN_BYTES] = encryption_result .try_into() .expect("memo encryption result fits in ciphertext len"); @@ -69,19 +66,12 @@ impl MemoPlaintext { /// Decrypt a `MemoCiphertext` to generate a plaintext `Memo`. pub fn decrypt( ciphertext: MemoCiphertext, - ivk: &IncomingViewingKey, - epk: &ka::Public, + memo_key: &PayloadKey, ) -> Result { - let shared_secret = ivk - .key_agreement_with(epk) - .map_err(|_| anyhow!("could not perform key agreement"))?; - - let key = PayloadKey::derive(&shared_secret, epk); - let plaintext = key + let encryption_result = memo_key .decrypt(ciphertext.0.to_vec(), PayloadKind::Memo) .map_err(|_| anyhow!("decryption error"))?; - - let plaintext_bytes: [u8; MEMO_LEN_BYTES] = plaintext + let plaintext_bytes: [u8; MEMO_LEN_BYTES] = encryption_result .try_into() .map_err(|_| anyhow!("could not fit plaintext into memo size"))?; @@ -96,12 +86,17 @@ impl MemoPlaintext { cv: value::Commitment, ovk: &OutgoingViewingKey, epk: &ka::Public, + wrapped_memo_key: &WrappedMemoKey, ) -> Result { let shared_secret = Note::decrypt_key(wrapped_ovk, cm, cv, ovk, epk) .map_err(|_| anyhow!("key decryption error"))?; - let key = PayloadKey::derive(&shared_secret, epk); - let plaintext = key + let action_key = PayloadKey::derive(&shared_secret, epk); + let memo_key = wrapped_memo_key + .decrypt_outgoing(&action_key) + .map_err(|_| anyhow!("could not decrypt wrapped memo key"))?; + + let plaintext = memo_key .decrypt(ciphertext.0.to_vec(), PayloadKind::Memo) .map_err(|_| anyhow!("decryption error"))?; @@ -113,8 +108,21 @@ impl MemoPlaintext { } } -#[derive(Clone, Debug)] -pub struct MemoCiphertext(pub [u8; MEMO_CIPHERTEXT_LEN_BYTES]); +impl TryFrom<&[u8]> for MemoCiphertext { + type Error = anyhow::Error; + + fn try_from(input: &[u8]) -> Result { + if input.len() > MEMO_CIPHERTEXT_LEN_BYTES { + return Err(anyhow::anyhow!( + "provided memo ciphertext exceeds maximum memo size" + )); + } + let mut mc = [0u8; MEMO_CIPHERTEXT_LEN_BYTES]; + mc[..input.len()].copy_from_slice(input); + + Ok(MemoCiphertext(mc)) + } +} #[cfg(test)] mod tests { @@ -144,13 +152,27 @@ mod tests { let esk = ka::Secret::new(&mut rng); + // On the sender side, we have to encrypt the memo to put into the transaction-level, + // and also the memo key to put on the action-level (output). let memo = MemoPlaintext(memo_bytes); - - let ciphertext = memo.encrypt(&esk, &dest); - + let memo_key = PayloadKey::random_key(&mut OsRng); + let ciphertext = memo.encrypt(memo_key.clone()); + let wrapped_memo_key = WrappedMemoKey::encrypt( + &memo_key, + esk.clone(), + dest.transmission_key(), + dest.diversified_generator(), + ); + + // On the recipient side, we have to decrypt the wrapped memo key, and then the memo. let epk = esk.diversified_public(dest.diversified_generator()); - let plaintext = MemoPlaintext::decrypt(ciphertext, ivk, &epk).expect("can decrypt memo"); + let decrypted_memo_key = wrapped_memo_key + .decrypt(epk, ivk) + .expect("can decrypt memo key"); + let plaintext = + MemoPlaintext::decrypt(ciphertext, &decrypted_memo_key).expect("can decrypt memo"); + assert_eq!(memo_key, decrypted_memo_key); assert_eq!(plaintext, memo); } @@ -176,18 +198,34 @@ mod tests { }; let note = Note::generate(&mut rng, &dest, value); + // On the sender side, we have to encrypt the memo to put into the transaction-level, + // and also the memo key to put on the action-level (output). let memo = MemoPlaintext(memo_bytes); + let memo_key = PayloadKey::random_key(&mut OsRng); + let ciphertext = memo.encrypt(memo_key.clone()); + let wrapped_memo_key = WrappedMemoKey::encrypt( + &memo_key, + esk.clone(), + dest.transmission_key(), + dest.diversified_generator(), + ); let value_blinding = Fr::rand(&mut rng); let cv = note.value().commit(value_blinding); - let wrapped_ovk = note.encrypt_key(&esk, ovk, cv); - let ciphertext = memo.encrypt(&esk, &dest); + // Later, still on the sender side, we decrypt the memo by using the decrypt_outgoing method. let epk = esk.diversified_public(dest.diversified_generator()); - let plaintext = - MemoPlaintext::decrypt_outgoing(ciphertext, wrapped_ovk, note.commit(), cv, ovk, &epk) - .expect("can decrypt memo"); + let plaintext = MemoPlaintext::decrypt_outgoing( + ciphertext, + wrapped_ovk, + note.commit(), + cv, + ovk, + &epk, + &wrapped_memo_key, + ) + .expect("can decrypt memo"); assert_eq!(plaintext, memo); } diff --git a/crypto/src/symmetric.rs b/crypto/src/symmetric.rs index 87966674c0..f6108c5ef2 100644 --- a/crypto/src/symmetric.rs +++ b/crypto/src/symmetric.rs @@ -1,33 +1,43 @@ -use anyhow::Result; +use anyhow::{anyhow, Result}; use chacha20poly1305::{ aead::{Aead, NewAead}, ChaCha20Poly1305, Key, Nonce, }; +use rand::{CryptoRng, RngCore}; -use crate::{ka, keys::OutgoingViewingKey, note, value}; +use crate::{ + ka, + keys::{IncomingViewingKey, OutgoingViewingKey}, + note, value, +}; +pub const PAYLOAD_KEY_LEN_BYTES: usize = 32; pub const OVK_WRAPPED_LEN_BYTES: usize = 48; +pub const MEMOKEY_WRAPPED_LEN_BYTES: usize = 48; /// Represents the item to be encrypted/decrypted with the [`PayloadKey`]. pub enum PayloadKind { Note, - Memo, + MemoKey, Swap, + Memo, } impl PayloadKind { pub(crate) fn nonce(&self) -> [u8; 12] { match self { Self::Note => [0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0], - Self::Memo => [1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0], + Self::MemoKey => [1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0], Self::Swap => [2, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0], + Self::Memo => [3, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0], } } } /// Represents a symmetric `ChaCha20Poly1305` key. /// -/// Used for encrypting and decrypting notes, memos and swaps. +/// Used for encrypting and decrypting notes, memos, memo keys, and swaps. +#[derive(Debug, Clone, PartialEq, Eq)] pub struct PayloadKey(Key); impl PayloadKey { @@ -43,7 +53,18 @@ impl PayloadKey { Self(*Key::from_slice(key.as_bytes())) } - /// Encrypt a note, swap, or memo using the `PayloadKey`. + /// Derive a random `PayloadKey`. Used for memo key wrapping. + pub fn random_key(rng: &mut R) -> Self { + let mut key_bytes = [0u8; 32]; + rng.fill_bytes(&mut key_bytes); + Self(*Key::from_slice(&key_bytes[..])) + } + + pub fn to_vec(&self) -> Vec { + self.0.to_vec() + } + + /// Encrypt a note, swap, memo, or memo key using the `PayloadKey`. pub fn encrypt(&self, plaintext: Vec, kind: PayloadKind) -> Vec { let cipher = ChaCha20Poly1305::new(&self.0); let nonce_bytes = kind.nonce(); @@ -54,7 +75,7 @@ impl PayloadKey { .expect("encryption succeeded") } - /// Decrypt a note, swap, or memo using the `PayloadKey`. + /// Decrypt a note, swap, memo, or memo key using the `PayloadKey`. pub fn decrypt(&self, ciphertext: Vec, kind: PayloadKind) -> Result> { let cipher = ChaCha20Poly1305::new(&self.0); let nonce_bytes = kind.nonce(); @@ -66,6 +87,17 @@ impl PayloadKey { } } +impl TryFrom> for PayloadKey { + type Error = anyhow::Error; + + fn try_from(vector: Vec) -> Result { + let bytes: [u8; PAYLOAD_KEY_LEN_BYTES] = vector + .try_into() + .map_err(|_| anyhow::anyhow!("PayloadKey incorrect len"))?; + Ok(Self(*Key::from_slice(&bytes))) + } +} + /// Represents a symmetric `ChaCha20Poly1305` key. /// /// Used for encrypting and decrypting [`OvkWrappedKey`] material used to decrypt @@ -159,3 +191,82 @@ impl TryFrom<&[u8]> for OvkWrappedKey { Ok(Self(bytes)) } } + +/// Represents encrypted key material used to decrypt a `MemoCiphertext`. +#[derive(Clone, Debug)] +pub struct WrappedMemoKey(pub [u8; MEMOKEY_WRAPPED_LEN_BYTES]); + +impl WrappedMemoKey { + pub fn to_vec(&self) -> Vec { + self.0.to_vec() + } + + /// Encrypt a memo key using the action-specific `PayloadKey`. + pub fn encrypt( + memo_key: &PayloadKey, + esk: ka::Secret, + transmission_key: &ka::Public, + diversified_generator: &decaf377::Element, + ) -> Self { + // 1. Construct the per-action PayloadKey. + let epk = esk.diversified_public(diversified_generator); + let shared_secret = esk + .key_agreement_with(transmission_key) + .expect("key agreement succeeded"); + + let action_key = PayloadKey::derive(&shared_secret, &epk); + // 2. Now use the per-action key to encrypt the memo key. + let encrypted_memo_key = action_key.encrypt(memo_key.to_vec(), PayloadKind::MemoKey); + let wrapped_memo_key_bytes: [u8; MEMOKEY_WRAPPED_LEN_BYTES] = encrypted_memo_key + .try_into() + .expect("memo key must fit in wrapped memo key field"); + + WrappedMemoKey(wrapped_memo_key_bytes) + } + + /// Decrypt a wrapped memo key by first deriving the action-specific `PayloadKey`. + pub fn decrypt(&self, epk: ka::Public, ivk: &IncomingViewingKey) -> Result { + // 1. Construct the per-action PayloadKey. + let shared_secret = ivk + .key_agreement_with(&epk) + .expect("key agreement succeeded"); + + let action_key = PayloadKey::derive(&shared_secret, &epk); + // 2. Now use the per-action key to decrypt the memo key. + let decrypted_memo_key = action_key + .decrypt(self.to_vec(), PayloadKind::MemoKey) + .map_err(|_| anyhow!("decryption error"))?; + + decrypted_memo_key.try_into() + } + + /// Decrypt a wrapped memo key using the action-specific `PayloadKey`. + pub fn decrypt_outgoing(&self, action_key: &PayloadKey) -> Result { + let decrypted_memo_key = action_key + .decrypt(self.to_vec(), PayloadKind::MemoKey) + .map_err(|_| anyhow!("decryption error"))?; + decrypted_memo_key.try_into() + } +} + +impl TryFrom> for WrappedMemoKey { + type Error = anyhow::Error; + + fn try_from(vector: Vec) -> Result { + let bytes: [u8; MEMOKEY_WRAPPED_LEN_BYTES] = vector + .try_into() + .map_err(|_| anyhow::anyhow!("wrapped memo key malformed"))?; + Ok(Self(bytes)) + } +} + +impl TryFrom<&[u8]> for WrappedMemoKey { + type Error = anyhow::Error; + + fn try_from(arr: &[u8]) -> Result { + let bytes: [u8; MEMOKEY_WRAPPED_LEN_BYTES] = arr + .try_into() + .map_err(|_| anyhow::anyhow!("wrapped memo key malformed"))?; + Ok(Self(bytes)) + } +} diff --git a/docs/protocol/src/protocol/transaction_crypto.md b/docs/protocol/src/protocol/transaction_crypto.md index b5b2db32de..9b1b1cc26e 100644 --- a/docs/protocol/src/protocol/transaction_crypto.md +++ b/docs/protocol/src/protocol/transaction_crypto.md @@ -3,9 +3,9 @@ This section describes the transaction-level cryptography that is used in Penumbra actions to symmetrically encrypt and decrypt swaps, notes, and memos. -For our symmetric encryption, we use ChaCha20-Poly1305 ([RFC-8439]). +For the symmetric encryption described in this section, we use ChaCha20-Poly1305 ([RFC-8439]). It is security-critical that `(key, nonce)` pairs are not reused. We do this by -deriving keys from ephemeral shared secrets, and using each key at maximum once +deriving per-action keys from ephemeral shared secrets, and using each key at maximum once with a given nonce. We describe the nonces and keys below. ## Nonces @@ -15,21 +15,27 @@ We use a different nonce to encrypt each type of item using the following `[u8; * Notes: `[0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0]` * Memos: `[1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0]` * Swaps: `[2, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0]` +* Memo keys: `[3, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0]` This enables the same key to be used in the -case of e.g. a swap with an associated note, or a note with an associated memo. +case of e.g. a swap with an associated note. ## Keys -We have four primary keys involved in transaction-level symmetric cryptography: +We have several keys involved in transaction-level symmetric cryptography: * A *shared secret* derived between sender and recipient, -* A *payload key* used to encrypt a single swap, note, or memo (one of each type). -It is derived from the shared secret. +* A *per-action payload key* used to encrypt a single swap, note, or memo key (one of each type). +It is derived from the shared secret. It is used for a single action. +* A *random payload key* used to encrypt a memo. It is generated randomly and is shared between all +actions in a given transaction. * An *outgoing cipher key* used to encrypt key material (the shared secret) to enable the sender to later decrypt an outgoing swap, note, or memo. * An *OVK wrapped key* - this is the shared secret encrypted using the sender's outgoing cipher key. A sender can later use this field to recover the shared secret they used when encrypting the note, swap, or memo to a recipient. +* A *wrapped memo key* - this is the memo key encrypted using the per-action payload key. The memo +key used for a (per-transaction) memo is shared between all outputs in that transaction. The wrapped +key is encrypted to their per-action payload key. In the rest of this section, we describe how each key is derived. @@ -42,17 +48,24 @@ key exchange between: This allows both sender and recipient to generate the shared secret based on the keys they posess. -### Payload Key +### Per-action Payload Key -The symmetric payload key is a 32-byte key derived from the `shared_secret` and the $epk$: +The symmetric per-action payload key is a 32-byte key derived from the `shared_secret` and the $epk$: ``` -payload_key = BLAKE2b-512(shared_secret, epk) +action_payload_key = BLAKE2b-512(shared_secret, epk) ``` -This symmetric key is then used with the nonces specified above to encrypt a memo, +This symmetric key is then used with the nonces specified above to encrypt a memo key, note, or swap. It should not be used to encrypt two items of the same type. +### Random Payload Key + +The random payload key is a 32-byte key generated randomly. This +symmetric key is used with the nonces specified above to encrypt memos only. +This key is provided to all output actions in a given transaction, as all outputs +should be able to decrypt the per-transaction memo. + ### Outgoing Cipher Key The symmetric outgoing cipher key is a 32-byte key derived from the sender's outgoing viewing key @@ -70,7 +83,7 @@ they will need to later reconstruct any outgoing transaction details. ### OVK Wrapped Key -To decrypt outgoing notes, memos, or swaps, the sender needs to store the shared secret encrypted +To decrypt outgoing notes, memo keys, or swaps, the sender needs to store the shared secret encrypted using the outgoing cipher key described above. This encrypted data, 48-bytes in length (32 bytes plus 16 bytes for authentication) we call the OVK wrapped key and is saved on `OutputBody`. @@ -80,6 +93,11 @@ The sender later scanning the chain can: 1. Derive the outgoing cipher key as described above using their outgoing viewing key and public fields. 2. Decrypt the OVK wrapped key. -3. Derive the payload key and use it to decrypt the corresponding memo, note, or swap. +3. Derive the per-action payload key and use it to decrypt the corresponding memo, note, or swap. + +### Wrapped Memo Key + +The wrapped memo key is 48 bytes in length (32 bytes plus 16 bytes for authentication). +It is saved on the `OutputBody` and is encrypted using the per-action payload key. [RFC-8439]: https://datatracker.ietf.org/doc/rfc8439/ diff --git a/proto/build.rs b/proto/build.rs index a729b5c42c..35043dc8e8 100644 --- a/proto/build.rs +++ b/proto/build.rs @@ -182,6 +182,7 @@ static TYPE_ATTRIBUTES: &[(&str, &str)] = &[ (".penumbra.transaction.SwapPlan", SERIALIZE), (".penumbra.transaction.SwapClaimPlan", SERIALIZE), (".penumbra.transaction.CluePlan", SERIALIZE), + (".penumbra.transaction.MemoPlan", SERIALIZE), (".penumbra.transaction.Transaction", SERIALIZE), (".penumbra.transaction.TransactionBody", SERIALIZE), (".penumbra.transaction.Action", SERIALIZE), diff --git a/proto/proto/transaction.proto b/proto/proto/transaction.proto index 3f10299ec2..d7cb707537 100644 --- a/proto/proto/transaction.proto +++ b/proto/proto/transaction.proto @@ -38,6 +38,9 @@ message TransactionBody { crypto.Fee fee = 4; // A list of clues for use with Fuzzy Message Detection. repeated crypto.Clue fmd_clues = 5; + // An optional encrypted memo. It will only be populated if there are + // outputs in the actions of this transaction. 528 bytes. + optional bytes encrypted_memo = 6; } // A state change performed by a transaction. @@ -107,8 +110,8 @@ message OutputBody { crypto.NotePayload note_payload = 1; // A commitment to the value of the output note. 32 bytes. crypto.ValueCommitment value_commitment = 2; - // An encrypted memo. 528 bytes. - bytes encrypted_memo = 3; + // An encrypted key for decrypting the memo. + bytes wrapped_memo_key = 3; // The key material used for note encryption, wrapped in encryption to the // sender's outgoing viewing key. 80 bytes. bytes ovk_wrapped_key = 4; @@ -219,6 +222,7 @@ message TransactionPlan { string chain_id = 3; crypto.Fee fee = 4; repeated CluePlan clue_plans = 5; + MemoPlan memo_plan = 6; } // Describes a planned transaction action. @@ -265,6 +269,14 @@ message CluePlan { uint64 precision_bits = 3; } +// Describes a plan for forming a `Memo`. +message MemoPlan { + // The plaintext. + bytes plaintext = 1; + // The key to use to encrypt the memo. + bytes key = 2; +} + message SpendPlan { // The plaintext note we plan to spend. crypto.Note note = 1; @@ -281,14 +293,12 @@ message OutputPlan { crypto.Value value = 1; // The destination address to send it to. crypto.Address dest_address = 2; - // The memo describing the output. - bytes memo = 3; // The blinding factor to use for the new note. - bytes note_blinding = 4; + bytes note_blinding = 3; // The blinding factor to use for the value commitment. - bytes value_blinding = 5; + bytes value_blinding = 4; // The ephemeral secret key to use for the note encryption. - bytes esk = 6; + bytes esk = 5; } message SwapPlan { diff --git a/transaction/src/action/output.rs b/transaction/src/action/output.rs index 1182d2301c..be37d1e5f9 100644 --- a/transaction/src/action/output.rs +++ b/transaction/src/action/output.rs @@ -3,8 +3,9 @@ use std::convert::{TryFrom, TryInto}; use anyhow::Error; use bytes::Bytes; use penumbra_crypto::{ - memo::MemoCiphertext, proofs::transparent::OutputProof, symmetric::OvkWrappedKey, value, - NotePayload, + proofs::transparent::OutputProof, + symmetric::{OvkWrappedKey, WrappedMemoKey}, + value, NotePayload, }; use penumbra_proto::{transaction as pb, Protobuf}; @@ -18,8 +19,8 @@ pub struct Output { pub struct Body { pub note_payload: NotePayload, pub value_commitment: value::Commitment, - pub encrypted_memo: MemoCiphertext, pub ovk_wrapped_key: OvkWrappedKey, + pub wrapped_memo_key: WrappedMemoKey, } impl Protobuf for Output {} @@ -57,7 +58,7 @@ impl From for pb::OutputBody { pb::OutputBody { note_payload: Some(output.note_payload.into()), value_commitment: Some(output.value_commitment.into()), - encrypted_memo: Bytes::copy_from_slice(&output.encrypted_memo.0), + wrapped_memo_key: Bytes::copy_from_slice(&output.wrapped_memo_key.0), ovk_wrapped_key: Bytes::copy_from_slice(&output.ovk_wrapped_key.0), } } @@ -73,11 +74,9 @@ impl TryFrom for Body { .try_into() .map_err(|e: Error| e.context("output body malformed"))?; - let encrypted_memo = MemoCiphertext( - proto.encrypted_memo[..] - .try_into() - .map_err(|_| anyhow::anyhow!("output malformed"))?, - ); + let wrapped_memo_key = proto.wrapped_memo_key[..] + .try_into() + .map_err(|_| anyhow::anyhow!("output malformed"))?; let ovk_wrapped_key: OvkWrappedKey = proto.ovk_wrapped_key[..] .try_into() @@ -90,7 +89,7 @@ impl TryFrom for Body { Ok(Body { note_payload, - encrypted_memo, + wrapped_memo_key, ovk_wrapped_key, value_commitment, }) diff --git a/transaction/src/auth_hash.rs b/transaction/src/auth_hash.rs index f74820813b..e585ea0345 100644 --- a/transaction/src/auth_hash.rs +++ b/transaction/src/auth_hash.rs @@ -1,7 +1,7 @@ use blake2b_simd::{Hash, Params}; use decaf377::FieldExt; use decaf377_fmd::Clue; -use penumbra_crypto::FullViewingKey; +use penumbra_crypto::{FullViewingKey, PayloadKey}; use penumbra_proto::{ transaction::{self as pb}, Message, Protobuf, @@ -77,6 +77,10 @@ impl TransactionBody { state.update(chain_id_auth_hash(&self.chain_id).as_bytes()); state.update(&self.expiry_height.to_le_bytes()); state.update(self.fee.auth_hash().as_bytes()); + if self.memo.is_some() { + let memo = self.memo.clone(); + state.update(&memo.unwrap().0); + } // Hash the actions. let num_actions = self.actions.len() as u32; @@ -118,6 +122,14 @@ impl TransactionPlan { state.update(&self.expiry_height.to_le_bytes()); state.update(self.fee.auth_hash().as_bytes()); + // Hash the memo and save the memo key for use with outputs later. + let mut memo_key: Option = None; + if self.memo_plan.is_some() { + let memo_plan = self.memo_plan.clone().unwrap(); + state.update(&memo_plan.memo().0); + memo_key = Some(memo_plan.key); + } + let num_actions = self.actions.len() as u32; state.update(&num_actions.to_le_bytes()); @@ -127,7 +139,12 @@ impl TransactionPlan { state.update(spend.spend_body(fvk).auth_hash().as_bytes()); } for output in self.output_plans() { - state.update(output.output_body(fvk.outgoing()).auth_hash().as_bytes()); + state.update( + output + .output_body(fvk.outgoing(), &memo_key.clone().unwrap()) + .auth_hash() + .as_bytes(), + ); } for swap in self.swap_plans() { state.update(swap.swap_body(fvk).auth_hash().as_bytes()); @@ -225,7 +242,7 @@ impl AuthorizingData for output::Body { state.update(&self.note_payload.ephemeral_key.0); state.update(&self.note_payload.encrypted_note); state.update(&self.value_commitment.to_bytes()); - state.update(&self.encrypted_memo.0); + state.update(&self.wrapped_memo_key.0); state.update(&self.ovk_wrapped_key.0); state.finalize() @@ -521,7 +538,7 @@ mod tests { use rand_core::OsRng; use crate::{ - plan::{OutputPlan, SpendPlan, TransactionPlan}, + plan::{MemoPlan, OutputPlan, SpendPlan, TransactionPlan}, WitnessData, }; @@ -574,13 +591,13 @@ mod tests { asset_id: *STAKING_TOKEN_ASSET_ID, }, addr.clone(), - MemoPlaintext::default(), ) .into(), SpendPlan::new(&mut OsRng, note0, 0u64.into()).into(), SpendPlan::new(&mut OsRng, note1, 1u64.into()).into(), ], clue_plans: vec![], + memo_plan: Some(MemoPlan::new(&mut OsRng, MemoPlaintext::default())), }; println!("{}", serde_json::to_string_pretty(&plan).unwrap()); diff --git a/transaction/src/plan.rs b/transaction/src/plan.rs index 50331377a3..ffe7d7d2fe 100644 --- a/transaction/src/plan.rs +++ b/transaction/src/plan.rs @@ -13,12 +13,14 @@ mod action; mod auth; mod build; mod clue; +mod memo; pub use action::{ ActionPlan, DelegatorVotePlan, OutputPlan, ProposalWithdrawPlan, SpendPlan, SwapClaimPlan, SwapPlan, }; pub use clue::CluePlan; +pub use memo::MemoPlan; /// A declaration of a planned [`Transaction`](crate::Transaction), /// for use in transaction authorization and creation. @@ -31,6 +33,7 @@ pub struct TransactionPlan { pub chain_id: String, pub fee: Fee, pub clue_plans: Vec, + pub memo_plan: Option, } impl Default for TransactionPlan { @@ -41,6 +44,7 @@ impl Default for TransactionPlan { chain_id: String::new(), fee: Default::default(), clue_plans: vec![], + memo_plan: None, } } } @@ -212,6 +216,7 @@ impl From for pb::TransactionPlan { chain_id: msg.chain_id, fee: Some(msg.fee.into()), clue_plans: msg.clue_plans.into_iter().map(Into::into).collect(), + memo_plan: msg.memo_plan.map(Into::into), } } } @@ -219,6 +224,11 @@ impl From for pb::TransactionPlan { impl TryFrom for TransactionPlan { type Error = anyhow::Error; fn try_from(value: pb::TransactionPlan) -> Result { + let memo_plan = match value.memo_plan { + Some(plan) => Some(plan.try_into()?), + None => None, + }; + Ok(Self { actions: value .actions @@ -236,6 +246,7 @@ impl TryFrom for TransactionPlan { .into_iter() .map(TryInto::try_into) .collect::>()?, + memo_plan, }) } } diff --git a/transaction/src/plan/action/output.rs b/transaction/src/plan/action/output.rs index 4d115e3c62..c8054328f0 100644 --- a/transaction/src/plan/action/output.rs +++ b/transaction/src/plan/action/output.rs @@ -2,9 +2,9 @@ use ark_ff::UniformRand; use penumbra_crypto::{ ka, keys::{IncomingViewingKey, OutgoingViewingKey}, - memo::MemoPlaintext, proofs::transparent::OutputProof, - Address, FieldExt, Fq, Fr, Note, NotePayload, Value, + symmetric::WrappedMemoKey, + Address, FieldExt, Fq, Fr, Note, NotePayload, PayloadKey, Value, }; use penumbra_proto::{transaction as pb, Protobuf}; use rand_core::{CryptoRng, RngCore}; @@ -18,20 +18,17 @@ use crate::action::{output, Output}; pub struct OutputPlan { pub value: Value, pub dest_address: Address, - pub memo: MemoPlaintext, pub note_blinding: Fq, pub value_blinding: Fr, pub esk: ka::Secret, } impl OutputPlan { - /// Create a new [`OutputPlan`] that sends `value` to `dest_address` with - /// the provided `memo`. + /// Create a new [`OutputPlan`] that sends `value` to `dest_address`. pub fn new( rng: &mut R, value: Value, dest_address: Address, - memo: MemoPlaintext, ) -> OutputPlan { let note_blinding = Fq::rand(rng); let value_blinding = Fr::rand(rng); @@ -39,7 +36,6 @@ impl OutputPlan { Self { value, dest_address, - memo, note_blinding, value_blinding, esk, @@ -48,9 +44,9 @@ impl OutputPlan { /// Convenience method to construct the [`Output`] described by this /// [`OutputPlan`]. - pub fn output(&self, ovk: &OutgoingViewingKey) -> Output { + pub fn output(&self, ovk: &OutgoingViewingKey, memo_key: &PayloadKey) -> Output { Output { - body: self.output_body(ovk), + body: self.output_body(ovk, memo_key), proof: self.output_proof(), } } @@ -75,7 +71,7 @@ impl OutputPlan { } /// Construct the [`output::Body`] described by this plan. - pub fn output_body(&self, ovk: &OutgoingViewingKey) -> output::Body { + pub fn output_body(&self, ovk: &OutgoingViewingKey, memo_key: &PayloadKey) -> output::Body { // Prepare the output note and commitment. let note = self.output_note(); let note_commitment = note.commit(); @@ -88,10 +84,16 @@ impl OutputPlan { let diversified_generator = note.diversified_generator(); let ephemeral_key = self.esk.diversified_public(&diversified_generator); let encrypted_note = note.encrypt(&self.esk); - let encrypted_memo = self.memo.encrypt(&self.esk, &self.dest_address); // ... and wrap the encryption key to ourselves. let ovk_wrapped_key = note.encrypt_key(&self.esk, ovk, value_commitment); + let wrapped_memo_key = WrappedMemoKey::encrypt( + memo_key, + self.esk.clone(), + note.transmission_key(), + ¬e.diversified_generator(), + ); + output::Body { note_payload: NotePayload { note_commitment, @@ -99,8 +101,8 @@ impl OutputPlan { encrypted_note, }, value_commitment, - encrypted_memo, ovk_wrapped_key, + wrapped_memo_key, } } @@ -117,7 +119,6 @@ impl From for pb::OutputPlan { Self { value: Some(msg.value.into()), dest_address: Some(msg.dest_address.into()), - memo: msg.memo.0.to_vec().into(), note_blinding: msg.note_blinding.to_bytes().to_vec().into(), value_blinding: msg.value_blinding.to_bytes().to_vec().into(), esk: msg.esk.to_bytes().to_vec().into(), @@ -137,7 +138,6 @@ impl TryFrom for OutputPlan { .dest_address .ok_or_else(|| anyhow::anyhow!("missing address"))? .try_into()?, - memo: msg.memo.as_ref().try_into()?, note_blinding: Fq::from_bytes(msg.note_blinding.as_ref().try_into()?)?, value_blinding: Fr::from_bytes(msg.value_blinding.as_ref().try_into()?)?, esk: msg.esk.as_ref().try_into()?, diff --git a/transaction/src/plan/build.rs b/transaction/src/plan/build.rs index f382c6d680..237968c6ca 100644 --- a/transaction/src/plan/build.rs +++ b/transaction/src/plan/build.rs @@ -1,5 +1,7 @@ use anyhow::Result; -use penumbra_crypto::{rdsa, Fr, FullViewingKey, Zero}; +use penumbra_crypto::{ + memo::MemoCiphertext, rdsa, symmetric::PayloadKey, Fr, FullViewingKey, Zero, +}; use rand_core::{CryptoRng, RngCore}; use super::TransactionPlan; @@ -42,6 +44,15 @@ impl TransactionPlan { let mut fmd_clues = Vec::new(); let mut synthetic_blinding_factor = Fr::zero(); + // Add the memo. + let mut memo: Option = None; + let mut memo_key: Option = None; + if self.memo_plan.is_some() { + let memo_plan = self.memo_plan.clone().unwrap(); + memo = Some(memo_plan.memo()); + memo_key = Some(memo_plan.key); + } + // We build the actions sorted by type, with all spends first, then all // outputs, etc. This order has to align with the ordering in // TransactionPlan::auth_hash, which computes the auth hash of the @@ -62,7 +73,9 @@ impl TransactionPlan { for output_plan in self.output_plans() { // Outputs subtract from the transaction's value balance. synthetic_blinding_factor -= output_plan.value_blinding; - actions.push(Action::Output(output_plan.output(fvk.outgoing()))); + actions.push(Action::Output( + output_plan.output(fvk.outgoing(), &memo_key.clone().unwrap()), + )); } // Build the transaction's swaps. @@ -145,6 +158,7 @@ impl TransactionPlan { chain_id: self.chain_id, fee: self.fee, fmd_clues, + memo, }, anchor: witness_data.anchor, binding_sig, diff --git a/transaction/src/plan/memo.rs b/transaction/src/plan/memo.rs new file mode 100644 index 0000000000..c30e3c938a --- /dev/null +++ b/transaction/src/plan/memo.rs @@ -0,0 +1,50 @@ +use bytes::Bytes; +use penumbra_crypto::{ + memo::{MemoCiphertext, MemoPlaintext, MEMO_LEN_BYTES}, + symmetric::PayloadKey, +}; +use penumbra_proto::{transaction as pb, Protobuf}; + +use rand::{CryptoRng, RngCore}; + +#[derive(Clone, Debug)] +pub struct MemoPlan { + pub plaintext: MemoPlaintext, + pub key: PayloadKey, +} + +impl MemoPlan { + /// Create a new [`MemoPlan`]. + pub fn new(rng: &mut R, plaintext: MemoPlaintext) -> MemoPlan { + let key = PayloadKey::random_key(rng); + MemoPlan { plaintext, key } + } + + /// Create a [`MemoCiphertext`] from the [`MemoPlan`]. + pub fn memo(&self) -> MemoCiphertext { + self.plaintext.encrypt(self.key.clone()) + } +} + +impl Protobuf for MemoPlan {} + +impl From for pb::MemoPlan { + fn from(msg: MemoPlan) -> Self { + Self { + plaintext: Bytes::copy_from_slice(&msg.plaintext.0), + key: msg.key.to_vec().into(), + } + } +} + +impl TryFrom for MemoPlan { + type Error = anyhow::Error; + + fn try_from(msg: pb::MemoPlan) -> Result { + let plaintext_bytes: [u8; MEMO_LEN_BYTES] = msg.plaintext.as_ref().try_into()?; + Ok(Self { + plaintext: MemoPlaintext(plaintext_bytes), + key: PayloadKey::try_from(msg.key.to_vec())?, + }) + } +} diff --git a/transaction/src/transaction.rs b/transaction/src/transaction.rs index c52b5009bb..fbe56c303c 100644 --- a/transaction/src/transaction.rs +++ b/transaction/src/transaction.rs @@ -5,6 +5,7 @@ use ark_ff::Zero; use bytes::Bytes; use decaf377_fmd::Clue; use penumbra_crypto::{ + memo::MemoCiphertext, rdsa::{Binding, Signature, VerificationKey, VerificationKeyBytes}, transaction::Fee, Fr, NotePayload, Nullifier, @@ -25,6 +26,7 @@ pub struct TransactionBody { pub chain_id: String, pub fee: Fee, pub fmd_clues: Vec, + pub memo: Option, } #[derive(Clone, Debug, Serialize, Deserialize)] @@ -200,6 +202,7 @@ impl From for pbt::TransactionBody { chain_id: msg.chain_id, fee: Some(msg.fee.into()), fmd_clues: msg.fmd_clues.into_iter().map(|x| x.into()).collect(), + encrypted_memo: msg.memo.map(|x| bytes::Bytes::copy_from_slice(&x.0)), } } } @@ -236,12 +239,22 @@ impl TryFrom for TransactionBody { ); } + let memo = match proto.encrypted_memo { + Some(bytes) => Some( + bytes[..] + .try_into() + .map_err(|_| anyhow::anyhow!("memo malformed"))?, + ), + None => None, + }; + Ok(TransactionBody { actions, expiry_height, chain_id, fee, fmd_clues, + memo, }) } } diff --git a/wallet/src/plan.rs b/wallet/src/plan.rs index b9dc7f385c..61f898748d 100644 --- a/wallet/src/plan.rs +++ b/wallet/src/plan.rs @@ -11,7 +11,7 @@ use penumbra_crypto::{ use penumbra_proto::view::NotesRequest; use penumbra_transaction::{ action::{Proposal, ValidatorVote}, - plan::{OutputPlan, SpendPlan, SwapPlan, TransactionPlan}, + plan::{MemoPlan, OutputPlan, SpendPlan, SwapPlan, TransactionPlan}, }; use penumbra_view::{SpendableNoteRecord, ViewClient}; use rand_core::{CryptoRng, RngCore}; @@ -139,6 +139,8 @@ where let mut plan = TransactionPlan { chain_id: chain_params.chain_id, fee, + // SwapClaim will create outputs, so we add a memo. + memo_plan: Some(MemoPlan::new(&mut rng, MemoPlaintext::default())), ..Default::default() }; @@ -222,7 +224,6 @@ where asset_id: denom.id(), }, change_address, - MemoPlaintext::default(), ) .into(), ); @@ -258,6 +259,8 @@ where let mut plan = TransactionPlan { chain_id: chain_params.chain_id, fee: fee.clone(), + // Swap will create outputs, so we add a memo. + memo_plan: Some(MemoPlan::new(&mut rng, MemoPlaintext::default())), ..Default::default() }; @@ -368,7 +371,6 @@ where asset_id: denom.id(), }, change_address, - MemoPlaintext::default(), ) .into(), ); @@ -408,9 +410,10 @@ where let mut planner = Planner::new(rng); planner.fee(fee); for value in values.iter().cloned() { - planner.output(value, dest_address, memo.clone()); + planner.output(value, dest_address); } planner + .memo(memo) .plan(view, fvk, source_address.map(Into::into)) .await .context("can't build send transaction") diff --git a/wallet/src/plan/planner.rs b/wallet/src/plan/planner.rs index 6165eb3a46..dce707605e 100644 --- a/wallet/src/plan/planner.rs +++ b/wallet/src/plan/planner.rs @@ -15,7 +15,7 @@ use penumbra_proto::view::NotesRequest; use penumbra_tct as tct; use penumbra_transaction::{ action::{Proposal, ProposalSubmit, ProposalWithdrawBody, ValidatorVote}, - plan::{ActionPlan, OutputPlan, ProposalWithdrawPlan, SpendPlan, TransactionPlan}, + plan::{ActionPlan, MemoPlan, OutputPlan, ProposalWithdrawPlan, SpendPlan, TransactionPlan}, }; use penumbra_view::ViewClient; use rand::{CryptoRng, RngCore}; @@ -67,6 +67,13 @@ impl Planner { self } + /// Set a memo for this transaction plan. + #[instrument(skip(self))] + pub fn memo(&mut self, memo: MemoPlaintext) -> &mut Self { + self.plan.memo_plan = Some(MemoPlan::new(&mut self.rng, memo)); + self + } + /// Add a fee to the transaction plan. /// /// This function should be called once. @@ -92,9 +99,9 @@ impl Planner { /// /// Any unused output value will be redirected back to the originating address as change notes /// when the plan is [`finish`](Builder::finish)ed. - #[instrument(skip(self, memo))] - pub fn output(&mut self, value: Value, address: Address, memo: MemoPlaintext) -> &mut Self { - let output = OutputPlan::new(&mut self.rng, value, address, memo).into(); + #[instrument(skip(self))] + pub fn output(&mut self, value: Value, address: Address) -> &mut Self { + let output = OutputPlan::new(&mut self.rng, value, address).into(); self.action(output); self } @@ -293,7 +300,7 @@ impl Planner { .0; for value in self.balance.provided().collect::>() { - self.output(value, self_address, MemoPlaintext::default()); + self.output(value, self_address); } // TODO: add dummy change outputs in the staking token denomination (this means they'll pass