diff --git a/crates/bin/pcli/src/terminal.rs b/crates/bin/pcli/src/terminal.rs index a13f03d65b..95bf433242 100644 --- a/crates/bin/pcli/src/terminal.rs +++ b/crates/bin/pcli/src/terminal.rs @@ -11,7 +11,7 @@ use penumbra_keys::{ }; use penumbra_proof_params::GROTH16_PROOF_LENGTH_BYTES; use penumbra_sct::Nullifier; -use penumbra_shielded_pool::{Note, NoteView}; +use penumbra_shielded_pool::{EncryptedBackref, Note, NoteView}; use penumbra_tct::structure::Hash; use penumbra_transaction::{view, ActionPlan, ActionView, TransactionPlan, TransactionView}; use termion::{color, input::TermRead}; @@ -82,6 +82,8 @@ fn pretty_print_transaction_plan( balance_commitment: dummy_commitment(), nullifier: Nullifier(Fq::default()), rk: dummy_pk(), + encrypted_backref: EncryptedBackref::try_from([0u8; 0]) + .expect("can create dummy encrypted backref"), }, auth_sig: dummy_sig(), proof: dummy_proof_spend(), diff --git a/crates/core/component/shielded-pool/src/backref.rs b/crates/core/component/shielded-pool/src/backref.rs new file mode 100644 index 0000000000..7eef7f4d96 --- /dev/null +++ b/crates/core/component/shielded-pool/src/backref.rs @@ -0,0 +1,217 @@ +use anyhow::Result; +use chacha20poly1305::{ + aead::{Aead, NewAead}, + ChaCha20Poly1305, Nonce, +}; + +use penumbra_keys::BackreferenceKey; +use penumbra_sct::Nullifier; +use penumbra_tct as tct; + +pub const ENCRYPTED_BACKREF_LEN: usize = 48; + +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct Backref { + note_commitment: tct::StateCommitment, +} + +#[derive(Clone, Debug)] +pub struct EncryptedBackref { + /// The inner bytes can either have 0 or `ENCRYPTED_BACKREF_LEN` bytes. + bytes: Vec, +} + +impl Backref { + pub fn new(note_commitment: tct::StateCommitment) -> Self { + Self { note_commitment } + } + + pub fn encrypt(&self, brk: &BackreferenceKey, nullifier: &Nullifier) -> EncryptedBackref { + let cipher = ChaCha20Poly1305::new(&brk.0); + + // Nonce is the first 12 bytes of the nullifier + let nonce_bytes = &nullifier.to_bytes()[..12]; + let nonce = Nonce::from_slice(&nonce_bytes); + + let plaintext = self.note_commitment.0.to_bytes(); + + let ciphertext = cipher + .encrypt(nonce, plaintext.as_ref()) + .expect("encryption should succeed "); + + EncryptedBackref { bytes: ciphertext } + } +} + +impl EncryptedBackref { + pub fn is_empty(&self) -> bool { + self.bytes.is_empty() + } + + pub fn len(&self) -> usize { + self.bytes.len() + } + + pub fn dummy() -> Self { + Self { bytes: vec![] } + } + + /// Decrypts the encrypted backref, returning a backref if the decryption is successful, + /// or `None` if the encrypted backref is zero-length. + pub fn decrypt( + &self, + brk: &BackreferenceKey, + nullifier: &Nullifier, + ) -> Result> { + // We might have a 0-length encrypted backref, which + // is treated as a valid value and means that the note has no backref. + if self.is_empty() { + return Ok(None); + } + + let cipher = ChaCha20Poly1305::new(&brk.0); + + let nonce_bytes = &nullifier.to_bytes()[..12]; + let nonce = Nonce::from_slice(&nonce_bytes); + + let plaintext = cipher + .decrypt(nonce, self.bytes.as_ref()) + .map_err(|_| anyhow::anyhow!("decryption error"))?; + + let note_commitment_bytes: [u8; 32] = plaintext + .try_into() + .map_err(|_| anyhow::anyhow!("decryption error"))?; + + let backref = Backref::try_from(note_commitment_bytes) + .map_err(|_| anyhow::anyhow!("decryption error"))?; + + Ok(Some(backref)) + } +} + +impl TryFrom<[u8; 32]> for Backref { + type Error = anyhow::Error; + + fn try_from(bytes: [u8; 32]) -> Result { + Ok(Self { + note_commitment: tct::StateCommitment::try_from(bytes) + .map_err(|_| anyhow::anyhow!("invalid note commitment"))?, + }) + } +} + +// EncryptedBackrefs can either have 0 or ENCRYPTED_BACKREF_LEN bytes. + +impl TryFrom<[u8; ENCRYPTED_BACKREF_LEN]> for EncryptedBackref { + type Error = anyhow::Error; + + fn try_from(bytes: [u8; ENCRYPTED_BACKREF_LEN]) -> Result { + Ok(Self { + bytes: bytes.to_vec(), + }) + } +} + +impl TryFrom<[u8; 0]> for EncryptedBackref { + type Error = anyhow::Error; + + fn try_from(bytes: [u8; 0]) -> Result { + Ok(Self { + bytes: bytes.to_vec(), + }) + } +} + +impl From for Vec { + fn from(encrypted_backref: EncryptedBackref) -> Vec { + encrypted_backref.bytes + } +} + +#[cfg(test)] +mod tests { + use super::*; + use proptest::prelude::*; + + use penumbra_asset::{asset, Value}; + use penumbra_keys::keys::{Bip44Path, SeedPhrase, SpendKey}; + + use crate::{Note, Rseed}; + + proptest! { + #[test] + fn encrypted_backref_zero_length(seed_phrase_randomness in any::<[u8; 32]>(), amount_to_send in any::(), rseed_randomness in any::<[u8; 32]>()) { + let seed_phrase = SeedPhrase::from_randomness(&seed_phrase_randomness); + let sk = SpendKey::from_seed_phrase_bip44(seed_phrase, &Bip44Path::new(0)); + let fvk = sk.full_viewing_key(); + let brk = fvk.backref_key(); + + let ivk = fvk.incoming(); + let (sender, _dtk_d) = ivk.payment_address(0u32.into()); + + let value_to_send = Value { + amount: amount_to_send.into(), + asset_id: asset::Cache::with_known_assets() + .get_unit("upenumbra") + .unwrap() + .id(), + }; + let rseed = Rseed(rseed_randomness); + + let note = Note::from_parts(sender, value_to_send, rseed).expect("valid note"); + let note_commitment: penumbra_tct::StateCommitment = note.commit(); + let nk = *sk.nullifier_key(); + let mut sct = tct::Tree::new(); + + sct.insert(tct::Witness::Keep, note_commitment).unwrap(); + let state_commitment_proof = sct.witness(note_commitment).unwrap(); + let nullifier = Nullifier::derive(&nk, state_commitment_proof.position(), ¬e_commitment); + + let encrypted_backref = EncryptedBackref::dummy(); + assert!(encrypted_backref.is_empty()); + assert_eq!(encrypted_backref.len(), 0); + + // Decrypting a zero-length encrypted backref should return `None`. + let decrypted_backref = encrypted_backref.decrypt(&brk, &nullifier).unwrap(); + assert_eq!(decrypted_backref, None); + } + } + + proptest! { + #[test] + fn encrypted_backref_round_trip(seed_phrase_randomness in any::<[u8; 32]>(), amount_to_send in any::(), rseed_randomness in any::<[u8; 32]>()) { + let seed_phrase = SeedPhrase::from_randomness(&seed_phrase_randomness); + let sk = SpendKey::from_seed_phrase_bip44(seed_phrase, &Bip44Path::new(0)); + let fvk = sk.full_viewing_key(); + let brk = fvk.backref_key(); + + let ivk = fvk.incoming(); + let (sender, _dtk_d) = ivk.payment_address(0u32.into()); + + let value_to_send = Value { + amount: amount_to_send.into(), + asset_id: asset::Cache::with_known_assets() + .get_unit("upenumbra") + .unwrap() + .id(), + }; + let rseed = Rseed(rseed_randomness); + + let note = Note::from_parts(sender, value_to_send, rseed).expect("valid note"); + let note_commitment: penumbra_tct::StateCommitment = note.commit(); + let nk = *sk.nullifier_key(); + let mut sct = tct::Tree::new(); + + sct.insert(tct::Witness::Keep, note_commitment).unwrap(); + let state_commitment_proof = sct.witness(note_commitment).unwrap(); + let nullifier = Nullifier::derive(&nk, state_commitment_proof.position(), ¬e_commitment); + + let backref = Backref::new(note_commitment); + let encrypted_backref = backref.encrypt(&brk, &nullifier); + + let decrypted_backref = encrypted_backref.decrypt(&brk, &nullifier).unwrap(); + + assert_eq!(Some(backref), decrypted_backref); + } + } +} diff --git a/crates/core/component/shielded-pool/src/lib.rs b/crates/core/component/shielded-pool/src/lib.rs index 84d112ed52..04109d614e 100644 --- a/crates/core/component/shielded-pool/src/lib.rs +++ b/crates/core/component/shielded-pool/src/lib.rs @@ -25,6 +25,9 @@ pub mod nullifier_derivation; pub mod output; pub mod spend; +pub mod backref; +pub use backref::{Backref, EncryptedBackref}; + pub use convert::{ConvertCircuit, ConvertProof, ConvertProofPrivate, ConvertProofPublic}; pub use nullifier_derivation::{ NullifierDerivationCircuit, NullifierDerivationProof, NullifierDerivationProofPrivate, diff --git a/crates/core/component/shielded-pool/src/spend/action.rs b/crates/core/component/shielded-pool/src/spend/action.rs index 2a548a592a..83517d6748 100644 --- a/crates/core/component/shielded-pool/src/spend/action.rs +++ b/crates/core/component/shielded-pool/src/spend/action.rs @@ -9,6 +9,7 @@ use penumbra_txhash::{EffectHash, EffectingData}; use serde::{Deserialize, Serialize}; use crate::SpendProof; +use crate::{backref::ENCRYPTED_BACKREF_LEN, EncryptedBackref}; #[derive(Clone, Debug)] pub struct Spend { @@ -23,6 +24,7 @@ pub struct Body { pub balance_commitment: balance::Commitment, pub nullifier: Nullifier, pub rk: VerificationKey, + pub encrypted_backref: EncryptedBackref, } impl EffectingData for Body { @@ -91,6 +93,7 @@ impl From for pb::SpendBody { balance_commitment: Some(msg.balance_commitment.into()), nullifier: Some(msg.nullifier.into()), rk: Some(msg.rk.into()), + encrypted_backref: msg.encrypted_backref.into(), } } } @@ -117,10 +120,26 @@ impl TryFrom for Body { .try_into() .context("malformed rk")?; + // `EncryptedBackref` must have 0 or `ENCRYPTED_BACKREF_LEN` bytes. + let encrypted_backref: EncryptedBackref; + if proto.encrypted_backref.len() == ENCRYPTED_BACKREF_LEN { + let bytes: [u8; ENCRYPTED_BACKREF_LEN] = proto + .encrypted_backref + .try_into() + .map_err(|_| anyhow::anyhow!("invalid encrypted backref"))?; + encrypted_backref = EncryptedBackref::try_from(bytes) + .map_err(|_| anyhow::anyhow!("invalid encrypted backref"))?; + } else if proto.encrypted_backref.len() == 0 { + encrypted_backref = EncryptedBackref::dummy(); + } else { + return Err(anyhow::anyhow!("invalid encrypted backref length")); + } + Ok(Body { balance_commitment, nullifier, rk, + encrypted_backref, }) } } diff --git a/crates/core/component/shielded-pool/src/spend/plan.rs b/crates/core/component/shielded-pool/src/spend/plan.rs index a79fd41d99..1602badd0b 100644 --- a/crates/core/component/shielded-pool/src/spend/plan.rs +++ b/crates/core/component/shielded-pool/src/spend/plan.rs @@ -9,7 +9,7 @@ use rand_core::{CryptoRng, RngCore}; use serde::{Deserialize, Serialize}; use super::{Body, Spend, SpendProof}; -use crate::{Note, Rseed, SpendProofPrivate, SpendProofPublic}; +use crate::{Backref, Note, Rseed, SpendProofPrivate, SpendProofPublic}; /// A planned [`Spend`](Spend). #[derive(Clone, Debug, Deserialize, Serialize)] @@ -75,10 +75,14 @@ impl SpendPlan { /// Construct the [`spend::Body`] described by this [`SpendPlan`]. pub fn spend_body(&self, fvk: &FullViewingKey) -> Body { + // Construct the backreference for this spend. + let backref = Backref::new(self.note.commit()); + let encrypted_backref = backref.encrypt(&fvk.backref_key(), &self.nullifier(fvk)); Body { balance_commitment: self.balance().commit(self.value_blinding), nullifier: self.nullifier(fvk), rk: self.rk(fvk), + encrypted_backref, } } diff --git a/crates/core/keys/src/keys/fvk.rs b/crates/core/keys/src/keys/fvk.rs index e9a029578f..44239700bf 100644 --- a/crates/core/keys/src/keys/fvk.rs +++ b/crates/core/keys/src/keys/fvk.rs @@ -12,7 +12,7 @@ use crate::keys::wallet_id::WalletId; use crate::{ fmd, ka, prf, rdsa::{SpendAuth, VerificationKey}, - Address, AddressView, + Address, AddressView, BackreferenceKey, }; use super::{AddressIndex, DiversifierKey, IncomingViewingKey, NullifierKey, OutgoingViewingKey}; @@ -120,6 +120,11 @@ impl FullViewingKey { &self.ak } + /// Construct the backreference key for this full viewing key. + pub fn backref_key(&self) -> BackreferenceKey { + BackreferenceKey::derive(self.outgoing()).clone() + } + /// Hashes the full viewing key into an [`WalletId`]. pub fn wallet_id(&self) -> WalletId { let hash_result = hash_2( diff --git a/crates/core/keys/src/lib.rs b/crates/core/keys/src/lib.rs index 81c8c8be7f..a880e51832 100644 --- a/crates/core/keys/src/lib.rs +++ b/crates/core/keys/src/lib.rs @@ -14,7 +14,7 @@ pub mod test_keys; pub use address::{Address, AddressVar, AddressView}; pub use keys::FullViewingKey; -pub use symmetric::PayloadKey; +pub use symmetric::{BackreferenceKey, PayloadKey}; fn fmt_hex>(data: T, f: &mut std::fmt::Formatter) -> std::fmt::Result { write!(f, "{}", hex::encode(data)) diff --git a/crates/core/keys/src/symmetric.rs b/crates/core/keys/src/symmetric.rs index 4e383d15c3..cc04585ed4 100644 --- a/crates/core/keys/src/symmetric.rs +++ b/crates/core/keys/src/symmetric.rs @@ -344,3 +344,20 @@ impl TryFrom<&[u8]> for WrappedMemoKey { Ok(Self(bytes)) } } + +/// Represents a symmetric `ChaCha20Poly1305` key used for Spend backreferences. +#[derive(Debug, Copy, Clone, PartialEq, Eq)] +pub struct BackreferenceKey(pub Key); + +impl BackreferenceKey { + pub fn derive(ovk: &OutgoingViewingKey) -> Self { + let mut kdf_params = blake2b_simd::Params::new(); + kdf_params.personal(b"Penumbra_Backref"); + kdf_params.hash_length(32); + let mut kdf = kdf_params.to_state(); + kdf.update(&ovk.to_bytes()); + + let key = kdf.finalize(); + Self(*Key::from_slice(key.as_bytes())) + } +} diff --git a/crates/proto/src/gen/penumbra.core.component.shielded_pool.v1.rs b/crates/proto/src/gen/penumbra.core.component.shielded_pool.v1.rs index 1362033de8..e287e3e5e6 100644 --- a/crates/proto/src/gen/penumbra.core.component.shielded_pool.v1.rs +++ b/crates/proto/src/gen/penumbra.core.component.shielded_pool.v1.rs @@ -420,6 +420,9 @@ pub struct SpendBody { pub rk: ::core::option::Option< super::super::super::super::crypto::decaf377_rdsa::v1::SpendVerificationKey, >, + /// An encryption of the commitment of the input note to the sender's OVK. + #[prost(bytes = "vec", tag = "7")] + pub encrypted_backref: ::prost::alloc::vec::Vec, } impl ::prost::Name for SpendBody { const NAME: &'static str = "SpendBody"; diff --git a/crates/proto/src/gen/penumbra.core.component.shielded_pool.v1.serde.rs b/crates/proto/src/gen/penumbra.core.component.shielded_pool.v1.serde.rs index 85e3edf22e..c77243e1d0 100644 --- a/crates/proto/src/gen/penumbra.core.component.shielded_pool.v1.serde.rs +++ b/crates/proto/src/gen/penumbra.core.component.shielded_pool.v1.serde.rs @@ -3789,6 +3789,9 @@ impl serde::Serialize for SpendBody { if self.rk.is_some() { len += 1; } + if !self.encrypted_backref.is_empty() { + len += 1; + } let mut struct_ser = serializer.serialize_struct("penumbra.core.component.shielded_pool.v1.SpendBody", len)?; if let Some(v) = self.balance_commitment.as_ref() { struct_ser.serialize_field("balanceCommitment", v)?; @@ -3799,6 +3802,10 @@ impl serde::Serialize for SpendBody { if let Some(v) = self.rk.as_ref() { struct_ser.serialize_field("rk", v)?; } + if !self.encrypted_backref.is_empty() { + #[allow(clippy::needless_borrow)] + struct_ser.serialize_field("encryptedBackref", pbjson::private::base64::encode(&self.encrypted_backref).as_str())?; + } struct_ser.end() } } @@ -3813,6 +3820,8 @@ impl<'de> serde::Deserialize<'de> for SpendBody { "balanceCommitment", "nullifier", "rk", + "encrypted_backref", + "encryptedBackref", ]; #[allow(clippy::enum_variant_names)] @@ -3820,6 +3829,7 @@ impl<'de> serde::Deserialize<'de> for SpendBody { BalanceCommitment, Nullifier, Rk, + EncryptedBackref, __SkipField__, } impl<'de> serde::Deserialize<'de> for GeneratedField { @@ -3845,6 +3855,7 @@ impl<'de> serde::Deserialize<'de> for SpendBody { "balanceCommitment" | "balance_commitment" => Ok(GeneratedField::BalanceCommitment), "nullifier" => Ok(GeneratedField::Nullifier), "rk" => Ok(GeneratedField::Rk), + "encryptedBackref" | "encrypted_backref" => Ok(GeneratedField::EncryptedBackref), _ => Ok(GeneratedField::__SkipField__), } } @@ -3867,6 +3878,7 @@ impl<'de> serde::Deserialize<'de> for SpendBody { let mut balance_commitment__ = None; let mut nullifier__ = None; let mut rk__ = None; + let mut encrypted_backref__ = None; while let Some(k) = map_.next_key()? { match k { GeneratedField::BalanceCommitment => { @@ -3887,6 +3899,14 @@ impl<'de> serde::Deserialize<'de> for SpendBody { } rk__ = map_.next_value()?; } + GeneratedField::EncryptedBackref => { + if encrypted_backref__.is_some() { + return Err(serde::de::Error::duplicate_field("encryptedBackref")); + } + encrypted_backref__ = + Some(map_.next_value::<::pbjson::private::BytesDeserialize<_>>()?.0) + ; + } GeneratedField::__SkipField__ => { let _ = map_.next_value::()?; } @@ -3896,6 +3916,7 @@ impl<'de> serde::Deserialize<'de> for SpendBody { balance_commitment: balance_commitment__, nullifier: nullifier__, rk: rk__, + encrypted_backref: encrypted_backref__.unwrap_or_default(), }) } } diff --git a/crates/proto/src/gen/proto_descriptor.bin.no_lfs b/crates/proto/src/gen/proto_descriptor.bin.no_lfs index f2452b1c4b..70880248d1 100644 Binary files a/crates/proto/src/gen/proto_descriptor.bin.no_lfs and b/crates/proto/src/gen/proto_descriptor.bin.no_lfs differ diff --git a/proto/penumbra/penumbra/core/component/shielded_pool/v1/shielded_pool.proto b/proto/penumbra/penumbra/core/component/shielded_pool/v1/shielded_pool.proto index f93427e8e8..d2c9cb55f7 100644 --- a/proto/penumbra/penumbra/core/component/shielded_pool/v1/shielded_pool.proto +++ b/proto/penumbra/penumbra/core/component/shielded_pool/v1/shielded_pool.proto @@ -42,7 +42,7 @@ message FmdMetaParameters { } // How much time users have to transition to new parameters. - uint64 fmd_grace_period_blocks = 1; + uint64 fmd_grace_period_blocks = 1; // The algorithm governing how the parameters change. oneof algorithm { uint32 fixed_precision_bits = 2; @@ -154,6 +154,8 @@ message SpendBody { sct.v1.Nullifier nullifier = 6; // The randomized validating key for the spend authorization signature. crypto.decaf377_rdsa.v1.SpendVerificationKey rk = 4; + // An encryption of the commitment of the input note to the sender's OVK. + bytes encrypted_backref = 7; } message SpendView { message Visible {