From f556f5d8c97bdf5851f8f21b431c5d642935795b Mon Sep 17 00:00:00 2001 From: redshiftzero Date: Fri, 22 Nov 2024 11:21:21 -0500 Subject: [PATCH] UIP-4 Spend Backreferences (#4922) ## Describe your changes This implements UIP-4 as described in https://github.com/penumbra-zone/UIPs/pull/2 ## Issue ticket number and link https://github.com/penumbra-zone/UIPs/pull/2 ## Checklist before requesting a review - [ ] 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: --- crates/bin/pcli/src/terminal.rs | 4 +- .../component/shielded-pool/src/backref.rs | 217 ++++++++++++++++++ .../core/component/shielded-pool/src/lib.rs | 3 + .../shielded-pool/src/spend/action.rs | 19 ++ .../component/shielded-pool/src/spend/plan.rs | 6 +- crates/core/keys/src/keys/fvk.rs | 7 +- crates/core/keys/src/lib.rs | 2 +- crates/core/keys/src/symmetric.rs | 17 ++ ...enumbra.core.component.shielded_pool.v1.rs | 3 + ...a.core.component.shielded_pool.v1.serde.rs | 21 ++ .../proto/src/gen/proto_descriptor.bin.no_lfs | Bin 627367 -> 627545 bytes .../shielded_pool/v1/shielded_pool.proto | 4 +- 12 files changed, 298 insertions(+), 5 deletions(-) create mode 100644 crates/core/component/shielded-pool/src/backref.rs 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 f2452b1c4b474c5738ad69f0c7d1b1e5f47508b9..70880248d1fa01386236afd3ab38f2d09499eaa5 100644 GIT binary patch delta 3815 zcmYjUZ%kI_6@Sm+a=BhmcrUy!@WP8Pv}zSE40LL>?X+v0)^;XMYnLtEvRS${jc6=e zvg`v^XAE1Kur&7R2ZvCLh>b-Qf)M{imX1)M(jZd*qBhnUW86}+FU(}W^W6LJ_QU(( zcb@b6o%5XYoWrG~ec|Q93wQO)tBLO3_m}ujI`hJRjKmUOr4+^W1%JxLICiBr?XTyNC)@$v!K)Z$U5Z zh*C@6hySD~ds|$~%Hs^r`a5x1Vnb?Oz3Q&!{Kl=@w{3g7VOw2&Lu&h5sfORxrQWXJ z@%x5U{q}~sRKs>{dAF|q&AOe7-c4=ZvZ=~-=en69m8rF|3?zY0v4J{LY0}Mc*@}Ah zL5h4zF zf+}fwz-$BIdxUcG4TSU&iWi1d9Cpg}rE^i1%LD30t`)40v!mu1+)lZ;w9fPFV-y(( zTj#;^n6z#=GV&y&F16{j1?LC^Tz#7;<& z1iEQXkbuVoO1C5sL}AZ5sWy)w%1Pqjxp~Z>oRr$gl>k9GNwK*5f{p^zt7C6Vw8y;!4E{cY`Kcp4CU3OsrPXi8I~tUgbi8~R9X&r@E^Kv2$8QL#Hn z2wvdXLlpT+#lNKUxkFOUSD;iN!H3niU(&1oF!5YXg%L`_G%sNwl!hr;=Ke%ea5$oV zdx19kBgCblj|3l~WVL~yj8N?|_i0T*W5?9>3$&(c%+#ttH+Ia_3J{brsnvU=Ie_D8 z{W$Gu98Ytkr{m-lhGC?q;!86BM}>KHwNUC*+>8dt$PBE)stoP8=|fFPhZ@ zD213bdqZQ44h*iF+lXbPEuc&ihDR2OHX!`?p-EJkhbbzoNV`B|iWV#kZ;JJGh4|X+O(o_Pv%X>(dVO6@ z3kK^82HaHND_CDw(-&fct@7^@2V)r53EU;GEL4yL z?oxS$9^^QqclXrz1P1w@d9g4;=pH%2XaRz9kMg2=eZ>Xcm-V&2_I{cokNmz_T<{Sm zNlf-m9E;j|Ad72_EG}Pdo}`#wTrdWU3oa0NU?vL?0uN-eUep3uTn|<0B)wGoFinxg z^)OA5#r04q>s+?c?(Zn_Kcd7k<-e9uqIxH(eA9R26iCBuJv)zxw@6h4LFy6NdPW7J zERU$@2j&#Yk7&)S?gVQtoM-yVFVSoC+zi=z*MdYK%}{Y|NRo#zL(l%$KxoX+3%_)q zLE{TOdlyIcuo~ZNugEWi-Y#}h?n>OXP#V8mJ)WYcH|}N*wg!yw+08{MQ4=4i?dB(* z)X%8Mv-fi3FC2Wp6v1;ZJLT?P*-1qr*Q6T1rhT^DgR zLT$e!JtluFN<8}jN7~eXuFz=i0d^|2Qi&+FsL`vm&TrxHPdbcHYT-G-Q~^S%h4TyC zY6LHl*WapEU!$6rThkPo<5mv80^kEmD~EGj>e+`l@@aULQg|L>XNmhP)Ji3RcIH%~ zTJ_(eq?)`&NgsybPaclp-Olk6T_Ql}wsYBh*VkkmcBuK+soL*g4yL|TPjm+tCPRv( z(!r^6_a#liE9_K%x=ufB>@?Yz>WS_&*#m;o8DuXh0v%-z{tg16hky%0d712^yl{zu z+S#wtJxt>JizELCFP?%~hI*H4Gp=x)u{;)se&C%@r*2SX#R;sESw9)L6O7NQ=R!Z=9+f#wYg0XJ<<0uZ zlIxN7E=GZp67`g-o2E+tlxYKuh;hoa0T8!5C2d%uDL5QZXQpYXKVaIB)NL3rZ2$yi zK-#cYQxNmCir=K?{nMrmN!^CirVW6goR&5$)fAw!YVS>|sydsdNcqq5oZy2gsTb>6 z#{UjYGKERmtb^*-P5SGGLDMoA!5K6y1BCD(hb=4f?9UnhiUrG}44$8}vsl0UGAYfF z>bXTV{*WmRjPMyUr2&N6kd#I~>&j$UhLv}lR{FyxX&A+7k_H52Sdy;P6qIH}{oywG zKOZsGEYqbK;lla)nE`?_!WB=*wnh}7QS(B9@InED7Yc}IqvpDR;=J$iZNiW7%fQdV RFB3l-zpTE;x1FP1{|CP(5gY&j delta 3713 zcmZu!T}&L;72b1p7>40@2iRp77Go0QU_4M`V(d6+LmQ=Wn?y~NHfmJ0RUfLVeW?1{ zheG03q_RpBVd+{C!Ep%S)Uh#kVUc5GQil+N!FB-4*brKh-dc_AEBsQ=;jEwCbuN4lBCMMY?Z*+0fBZ? zCL*BpWy?eWTl58Gq7e=n(NBETY~e_LKRKRTAbpYz2FR)ix}@a+^9@Aq02P!P2;~8a zC)|RcX)aa=<@mb8EQbel4b-doF$2LJl!MFUJkLHxk+E=c9x{)~Fg&(E9-+!Acb_KbcveO| zJ4@eh%w#D!pk%U?98fYs*^VxkdiMJinNU-+)KL6BIYn+Wgi58s|gU?Y(xq+aZql%>acf#gno;^vCi$tl@ zq@J6g2h~45qJyPn;7m$~pNDRllrp6bU8EQMDdIvcgdlRJs3>J1bf+j??S5BNusE%f zmuR0qOi-?$H#?dPco=iS{DHO zKbL5K`&^bI)14zH5pE;XougExzSHHNeVHPw;hio==4CmjxKX*d(Wk^$!bb><-3 zvT-y(6u+z;O~xc=Tanu24FG|iu@~_ejJ%MW%@fH z8<&dK)c8E@^w)?>v^s*wS)*9686ecw2u`s}Q?Phjr59+of15aXlyP0aZStx^1u5V* zZLZbNI?kBg9relrwKU%`Hx@w%-61DQ$g3QD`&1xvKRcV5yauUI6o*85ZiR|=6byiAhzk^?Y^!$<=L-uU;wcgD zRy(g#TT{0w8bRcAo1y`s)-6TP%HNnu&whg=hgIKox={QEI}KW?Qj~hsAC{@j@8R%s z06{4Aa9;5I0HM^wrDbjtHm{Ut(yRWtOgo6wz-c%tx5{$W3EXxso&h9wCY@;w2wgWmlDg!?&El+ZV@1K`?z|O>uWL=`_<(Y zYV!M;1AC~_PrsiN>5w9&^mEN-_i0VRZ5&koXY_3QpsBt}Km9>dJs>E9LG@B1&{5{# z4<-<12)HGbm)Cxj>$VxFll?|L!!*9#IPyVw@H8@yvGb62|FpFQZ}UCo(;R$ur!6_^ zdz@S7ZpCLeptRxSSl=?6ayp!tJtJF|?685r@ClBu=*HM+YK@I+>83TgYW7LcR5T_iKF>KQmERLz) z-k|ONm>EM_k73M=0T7fi8N=h6f<4cuk8jYo{WE3^X+4HBW(a6