Skip to content

Commit

Permalink
UIP-4 Spend Backreferences (#4922)
Browse files Browse the repository at this point in the history
This implements UIP-4 as described in
penumbra-zone/UIPs#2

penumbra-zone/UIPs#2

- [ ] I have added guiding text to explain how a reviewer should test
these changes.

- [x] If this code contains consensus-breaking changes, I have added the
"consensus-breaking" label. Otherwise, I declare my belief that there
are not consensus-breaking changes, for the following reason:

(cherry picked from commit 067708b)
  • Loading branch information
redshiftzero authored and conorsch committed Dec 20, 2024
1 parent 0d0b319 commit a07b18d
Show file tree
Hide file tree
Showing 12 changed files with 298 additions and 5 deletions.
4 changes: 3 additions & 1 deletion crates/bin/pcli/src/terminal.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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};
Expand Down Expand Up @@ -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(),
Expand Down
217 changes: 217 additions & 0 deletions crates/core/component/shielded-pool/src/backref.rs
Original file line number Diff line number Diff line change
@@ -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<u8>,
}

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<Option<Backref>> {
// 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<Self> {
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<Self> {
Ok(Self {
bytes: bytes.to_vec(),
})
}
}

impl TryFrom<[u8; 0]> for EncryptedBackref {
type Error = anyhow::Error;

fn try_from(bytes: [u8; 0]) -> Result<Self> {
Ok(Self {
bytes: bytes.to_vec(),
})
}
}

impl From<EncryptedBackref> for Vec<u8> {
fn from(encrypted_backref: EncryptedBackref) -> Vec<u8> {
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::<u64>(), 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(), &note_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::<u64>(), 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(), &note_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);
}
}
}
3 changes: 3 additions & 0 deletions crates/core/component/shielded-pool/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
19 changes: 19 additions & 0 deletions crates/core/component/shielded-pool/src/spend/action.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -23,6 +24,7 @@ pub struct Body {
pub balance_commitment: balance::Commitment,
pub nullifier: Nullifier,
pub rk: VerificationKey<SpendAuth>,
pub encrypted_backref: EncryptedBackref,
}

impl EffectingData for Body {
Expand Down Expand Up @@ -91,6 +93,7 @@ impl From<Body> 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(),
}
}
}
Expand All @@ -117,10 +120,26 @@ impl TryFrom<pb::SpendBody> 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,
})
}
}
6 changes: 5 additions & 1 deletion crates/core/component/shielded-pool/src/spend/plan.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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)]
Expand Down Expand Up @@ -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,
}
}

Expand Down
7 changes: 6 additions & 1 deletion crates/core/keys/src/keys/fvk.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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};
Expand Down Expand Up @@ -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(
Expand Down
2 changes: 1 addition & 1 deletion crates/core/keys/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<T: AsRef<[u8]>>(data: T, f: &mut std::fmt::Formatter) -> std::fmt::Result {
write!(f, "{}", hex::encode(data))
Expand Down
17 changes: 17 additions & 0 deletions crates/core/keys/src/symmetric.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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()))
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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<u8>,
}
impl ::prost::Name for SpendBody {
const NAME: &'static str = "SpendBody";
Expand Down
Loading

0 comments on commit a07b18d

Please sign in to comment.