diff --git a/.claude/rules/code-style.md b/.claude/rules/code-style.md index c4283b98a..4e1615576 100644 --- a/.claude/rules/code-style.md +++ b/.claude/rules/code-style.md @@ -154,3 +154,10 @@ Apply these guidelines when writing or reviewing code in this codebase. - Place public functions at the top of the module, before their private helpers - Readers should encounter the high-level orchestration first and drill into details top-down - *Example:* `pub async fn sync_incoming_state(...)` at the top, followed by `process_channel(...)`, then `process_subchannel(...)` + +### Imports at scope top, not inline +- Place `use` statements at the top of the scope they serve — module-level for module-wide usage, `#[cfg(test)] mod tests` top for test-only usage +- Never put `use` inside function bodies; hoist to the enclosing module +- Use short imported names in signatures and bodies, not inline qualified paths like `super::types::Foo` or `crate::module::Bar` +- *Bad:* `fn f(key: &super::types::SecretFelt)` with no import; `use crate::Foo;` inside a function body +- *Good:* `use super::types::SecretFelt;` at module top, then `fn f(key: &SecretFelt)` diff --git a/.claude/skills/pr-splitter/SKILL.md b/.claude/skills/pr-splitter/SKILL.md index a974d5e9a..3e0366a9c 100644 --- a/.claude/skills/pr-splitter/SKILL.md +++ b/.claude/skills/pr-splitter/SKILL.md @@ -346,7 +346,7 @@ If anything fails, fix before reporting to user. #### 7f. Report to user -Brief: files applied, verification result, any warnings. Changes are staged. +Brief: files applied, verification result, any warnings. Suggest a commit message. #### 7g. Update plan and track progress diff --git a/Cargo.lock b/Cargo.lock index 8890a1d1d..6f3a0adaf 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -540,6 +540,7 @@ dependencies = [ "tracing", "tracing-subscriber", "url", + "zeroize", ] [[package]] diff --git a/crates/discovery-core/Cargo.toml b/crates/discovery-core/Cargo.toml index ca2765ac6..f5f280739 100644 --- a/crates/discovery-core/Cargo.toml +++ b/crates/discovery-core/Cargo.toml @@ -12,10 +12,11 @@ starknet-crypto = "0.8" starknet-types-core = { version = "0.2", features = ["curve", "serde"] } starknet-providers = "0.16" tracing = "0.1" +serde = { version = "1.0", features = ["derive"] } url = "2" +zeroize = "1" [dev-dependencies] -serde = { version = "1.0", features = ["derive"] } serde_json = "1.0" tokio = { version = "1", features = ["rt-multi-thread", "macros"] } tracing-subscriber = { version = "0.3", features = ["env-filter"] } diff --git a/crates/discovery-core/src/discovery/incoming_channels.rs b/crates/discovery-core/src/discovery/incoming_channels.rs index 5d42d2bd8..05786aefd 100644 --- a/crates/discovery-core/src/discovery/incoming_channels.rs +++ b/crates/discovery-core/src/discovery/incoming_channels.rs @@ -8,7 +8,7 @@ use starknet_types_core::felt::Felt; use super::{DiscoveryError, COST_CHANNEL_INFO, COST_NUM_CHANNELS}; use crate::io_budget::IoBudget; use crate::privacy_pool::decryption::decrypt_channel_info; -use crate::privacy_pool::types::ChannelInfo; +use crate::privacy_pool::types::{ChannelInfo, SecretFelt}; use crate::privacy_pool::views::IViews; /// A discovered and decrypted incoming channel. @@ -74,7 +74,7 @@ pub async fn get_incoming_channel_count( pub async fn discover_incoming_channels( privacy_pool: &PrivacyPool, recipient_addr: Felt, - private_key: &Felt, + private_key: &SecretFelt, start_index: u64, total_n_channels: u64, budget: &IoBudget, @@ -135,7 +135,7 @@ mod tests { async fn test_discover_no_channels() { let backend = MockBackend::empty(); let recipient = Felt::from_hex_unchecked("0x123"); - let key = Felt::from(1u64); + let key = SecretFelt::new(Felt::from(1u64)); let budget = IoBudget::new(100); // Test with 0 (start from beginning) @@ -171,7 +171,7 @@ mod tests { let result = discover_incoming_channels( &backend, fixture.constants.alice_address, - &fixture.constants.alice_viewing_key, + &SecretFelt::new(fixture.constants.alice_viewing_key), 0, count, &budget, @@ -205,7 +205,7 @@ mod tests { let result = discover_incoming_channels( &backend, fixture.constants.bob_address, - &fixture.constants.bob_viewing_key, + &SecretFelt::new(fixture.constants.bob_viewing_key), 0, count, &budget, @@ -240,7 +240,7 @@ mod tests { let result1 = discover_incoming_channels( &backend, fixture.constants.alice_address, - &fixture.constants.alice_viewing_key, + &SecretFelt::new(fixture.constants.alice_viewing_key), 0, count, &budget, @@ -257,7 +257,7 @@ mod tests { let result2 = discover_incoming_channels( &backend, fixture.constants.alice_address, - &fixture.constants.alice_viewing_key, + &SecretFelt::new(fixture.constants.alice_viewing_key), result1.last_index.unwrap() + 1, count, &budget, @@ -302,7 +302,7 @@ mod tests { let result = discover_incoming_channels( &backend, fixture.constants.alice_address, - &fixture.constants.alice_viewing_key, + &SecretFelt::new(fixture.constants.alice_viewing_key), 0, count, &budget, diff --git a/crates/discovery-core/src/privacy_pool/decryption.rs b/crates/discovery-core/src/privacy_pool/decryption.rs index e5dd44456..06f1ff916 100644 --- a/crates/discovery-core/src/privacy_pool/decryption.rs +++ b/crates/discovery-core/src/privacy_pool/decryption.rs @@ -7,10 +7,13 @@ use starknet_types_core::{curve::AffinePoint, felt::Felt}; use thiserror::Error; use super::hashes::{ - compute_enc_amount_hash, compute_enc_channel_key_hash, compute_enc_sender_addr_hash, - compute_enc_token_hash, + compute_enc_amount_hash, compute_enc_channel_key_hash, compute_enc_recipient_addr_hash, + compute_enc_sender_addr_hash, compute_enc_token_hash, +}; +use super::types::{ + felt_low_u128, ChannelInfo, EncChannelInfo, EncOutgoingChannelInfo, EncSubchannelInfo, + SecretFelt, }; -use super::types::{felt_low_u128, ChannelInfo, EncChannelInfo, EncSubchannelInfo}; /// Errors that can occur during decryption. #[derive(Debug, Error)] @@ -26,18 +29,14 @@ pub enum DecryptionError { /// 1. Recover the ephemeral public key point from its x-coordinate /// 2. Compute ECDH shared secret: `shared_point = ephemeral_pubkey * private_key` /// 3. Decrypt: `plaintext = ciphertext - hash(tag, shared_x)` -/// -/// # Security -/// -/// The caller should zero the `private_key` after use by calling `private_key.zeroize()`. pub fn decrypt_channel_info( enc: &EncChannelInfo, - private_key: &Felt, + private_key: &SecretFelt, ) -> Result { // Recover the ephemeral public key from x-coordinate.. let ephemeral_point = AffinePoint::new_from_x(&enc.ephemeral_pubkey, false) .ok_or(DecryptionError::InvalidEphemeralPubkey)?; - let shared_point = &ephemeral_point * *private_key; + let shared_point = &ephemeral_point * **private_key; let shared_x = shared_point.x(); // Decrypt: plaintext = ciphertext - hash(tag, shared_x) @@ -99,6 +98,19 @@ pub fn decrypt_note_amount( enc_amount.wrapping_sub(pad) } +/// Decrypts an outgoing channel's encrypted recipient address. +/// +/// `recipient_addr = enc_recipient_addr - hash(ENC_RECIPIENT_ADDR_TAG, sender_addr, private_key, index, 0, salt)` +pub fn decrypt_outgoing_recipient_addr( + enc: &EncOutgoingChannelInfo, + sender_addr: Felt, + private_key: &SecretFelt, + index: u64, +) -> Felt { + let mask = compute_enc_recipient_addr_hash(sender_addr, private_key, index, enc.salt); + enc.enc_recipient_addr - mask +} + #[cfg(test)] mod tests { use super::*; @@ -111,7 +123,7 @@ mod tests { enc_channel_key: Felt::ONE, enc_sender_addr: Felt::TWO, }; - let result = decrypt_channel_info(&enc, &Felt::from(12345u64)); + let result = decrypt_channel_info(&enc, &SecretFelt::new(Felt::from(12345u64))); assert!(matches!( result, Err(DecryptionError::InvalidEphemeralPubkey) @@ -128,8 +140,8 @@ mod tests { enc_sender_addr: f.outputs.enc_channel_sender_addr, }; - let result = decrypt_channel_info(&encrypted, &f.inputs.recipient_private_key) - .expect("decryption should succeed"); + let key = SecretFelt::new(f.inputs.recipient_private_key); + let result = decrypt_channel_info(&encrypted, &key).expect("decryption should succeed"); assert_eq!(result.channel_key, f.inputs.channel_key); assert_eq!(result.sender_addr, f.inputs.sender); @@ -167,4 +179,20 @@ mod tests { ); assert_eq!(amount, f.outputs.dec_note_amount as u128); } + + #[test] + fn test_decrypt_outgoing_recipient_addr_with_cairo_vectors() { + let f = load_cairo_ref_fixture(); + + let enc = EncOutgoingChannelInfo { + salt: f.outputs.enc_outgoing_salt, + enc_recipient_addr: f.outputs.enc_outgoing_recipient_addr, + }; + + let key = SecretFelt::new(f.inputs.sender_private_key); + let recipient = + decrypt_outgoing_recipient_addr(&enc, f.inputs.sender, &key, f.inputs.index); + + assert_eq!(recipient, f.inputs.recipient); + } } diff --git a/crates/discovery-core/src/privacy_pool/hashes.rs b/crates/discovery-core/src/privacy_pool/hashes.rs index f15d8904e..5e0fb852c 100644 --- a/crates/discovery-core/src/privacy_pool/hashes.rs +++ b/crates/discovery-core/src/privacy_pool/hashes.rs @@ -5,6 +5,8 @@ use std::sync::LazyLock; use starknet_crypto::{poseidon_hash_many, PoseidonHasher}; use starknet_types_core::felt::Felt; +use super::types::SecretFelt; + /// Domain separation tag for encrypted channel key. static ENC_CHANNEL_KEY_TAG: LazyLock = LazyLock::new(|| short_string_to_felt("ENC_CHANNEL_KEY_TAG:V1")); @@ -126,7 +128,7 @@ pub fn compute_nullifier( channel_key: Felt, token: Felt, index: u64, - decryption_key: &Felt, + private_key: &SecretFelt, ) -> Felt { hash(&[ *NULLIFIER_TAG, @@ -134,21 +136,21 @@ pub fn compute_nullifier( token, Felt::from(index), Felt::ZERO, - *decryption_key, + **private_key, ]) } /// Computes the channel key from sender credentials and recipient identity. pub fn compute_channel_key( sender_addr: Felt, - decryption_key: &Felt, + private_key: &SecretFelt, recipient_addr: Felt, recipient_public_key: Felt, ) -> Felt { hash(&[ *CHANNEL_KEY_TAG, sender_addr, - *decryption_key, + **private_key, recipient_addr, recipient_public_key, ]) @@ -187,11 +189,15 @@ pub fn compute_subchannel_marker( } /// Computes the outgoing channel id for storage lookup. -pub fn compute_outgoing_channel_id(sender_addr: Felt, decryption_key: &Felt, index: u64) -> Felt { +pub fn compute_outgoing_channel_id( + sender_addr: Felt, + private_key: &SecretFelt, + index: u64, +) -> Felt { hash(&[ *OUTGOING_CHANNEL_ID_TAG, sender_addr, - *decryption_key, + **private_key, Felt::from(index), Felt::ZERO, ]) @@ -200,14 +206,14 @@ pub fn compute_outgoing_channel_id(sender_addr: Felt, decryption_key: &Felt, ind /// Computes the encryption mask for the outgoing recipient address. pub fn compute_enc_recipient_addr_hash( sender_addr: Felt, - decryption_key: &Felt, + private_key: &SecretFelt, index: u64, salt: Felt, ) -> Felt { hash(&[ *ENC_RECIPIENT_ADDR_TAG, sender_addr, - *decryption_key, + **private_key, Felt::from(index), Felt::ZERO, salt, @@ -217,6 +223,7 @@ pub fn compute_enc_recipient_addr_hash( #[cfg(test)] mod tests { use super::*; + use crate::privacy_pool::types::SecretFelt; use crate::test_fixtures::load_cairo_ref_fixture; #[test] @@ -275,7 +282,7 @@ mod tests { f.inputs.channel_key, f.inputs.token, f.inputs.index, - &f.inputs.sender_private_key, + &SecretFelt::new(f.inputs.sender_private_key), ), f.outputs.nullifier ); @@ -296,7 +303,7 @@ mod tests { #[test] fn test_compute_channel_key() { let f = load_cairo_ref_fixture(); - let key = f.inputs.sender_private_key; + let key = SecretFelt::new(f.inputs.sender_private_key); assert_eq!( compute_channel_key( f.inputs.sender, @@ -311,7 +318,7 @@ mod tests { #[test] fn test_compute_outgoing_channel_id() { let f = load_cairo_ref_fixture(); - let key = f.inputs.sender_private_key; + let key = SecretFelt::new(f.inputs.sender_private_key); assert_eq!( compute_outgoing_channel_id(f.inputs.sender, &key, f.inputs.index), f.outputs.outgoing_channel_id @@ -321,7 +328,7 @@ mod tests { #[test] fn test_compute_enc_recipient_addr_hash() { let f = load_cairo_ref_fixture(); - let key = f.inputs.sender_private_key; + let key = SecretFelt::new(f.inputs.sender_private_key); assert_eq!( compute_enc_recipient_addr_hash(f.inputs.sender, &key, f.inputs.index, f.inputs.salt), f.outputs.enc_recipient_addr_hash diff --git a/crates/discovery-core/src/privacy_pool/types.rs b/crates/discovery-core/src/privacy_pool/types.rs index 2b6eec8e9..802728a23 100644 --- a/crates/discovery-core/src/privacy_pool/types.rs +++ b/crates/discovery-core/src/privacy_pool/types.rs @@ -3,7 +3,12 @@ //! This module contains the data structures used by the privacy pool contract, //! including encrypted ciphertext types and decrypted plaintext types. +use std::fmt; +use std::ops::Deref; + +use serde::{Deserialize, Serialize}; use starknet_types_core::felt::Felt; +use zeroize::Zeroize; /// Extracts low 128 bits from a Felt. pub fn felt_low_u128(felt: Felt) -> u128 { @@ -11,21 +16,45 @@ pub fn felt_low_u128(felt: Felt) -> u128 { d[0] as u128 | (d[1] as u128) << 64 } -/// Trait for securely zeroing sensitive data from memory. +/// A Felt that automatically zeroes its memory on drop. +/// +/// Implements `Deref` for transparent use where `&Felt` is expected. /// -/// This is a simplified version of the `zeroize` crate's `Zeroize` trait, -/// implemented specifically for `Felt` to avoid the orphan rule. -pub trait Zeroize { - /// Securely zeros the memory containing this value. - fn zeroize(&mut self); +/// Deliberately excludes `Copy` (silent copies of secrets are dangerous) +/// and `Serde` (keys should be wrapped at the system boundary, not +/// deserialized directly). `Debug` prints `[REDACTED]` to prevent +/// accidental logging of key material. +#[derive(Clone)] +pub struct SecretFelt(Felt); + +impl SecretFelt { + pub fn new(felt: Felt) -> Self { + Self(felt) + } } -impl Zeroize for Felt { +impl Deref for SecretFelt { + type Target = Felt; + fn deref(&self) -> &Felt { + &self.0 + } +} + +impl Zeroize for SecretFelt { fn zeroize(&mut self) { - // Overwrite with zero - *self = Felt::ZERO; - // Use a compiler fence to prevent optimization from removing the write - std::sync::atomic::compiler_fence(std::sync::atomic::Ordering::SeqCst); + self.0 = Felt::ZERO; + } +} + +impl Drop for SecretFelt { + fn drop(&mut self) { + self.zeroize(); + } +} + +impl fmt::Debug for SecretFelt { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.write_str("[REDACTED]") } } @@ -58,17 +87,8 @@ pub struct EncSubchannelInfo { pub enc_token: Felt, } -/// Encrypted outgoing channel information (2 storage slots). -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub struct EncOutgoingChannelInfo { - /// Salt generated by the sender for one-time key usage. - pub salt: Felt, - /// Encrypted recipient address. - pub enc_recipient_addr: Felt, -} - /// Decrypted channel information. -#[derive(Debug, Clone, Copy, PartialEq, Eq)] +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] pub struct ChannelInfo { /// The channel key. pub channel_key: Felt, @@ -76,14 +96,11 @@ pub struct ChannelInfo { pub sender_addr: Felt, } -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn test_zeroize_felt() { - let mut secret = Felt::from(42u64); - secret.zeroize(); - assert_eq!(secret, Felt::ZERO); - } +/// Encrypted outgoing channel info stored in the contract. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct EncOutgoingChannelInfo { + /// Salt generated by the sender for one-time key usage. + pub salt: Felt, + /// Encrypted recipient address. + pub enc_recipient_addr: Felt, } diff --git a/crates/discovery-core/src/test_fixtures.rs b/crates/discovery-core/src/test_fixtures.rs index 04e028f65..911149222 100644 --- a/crates/discovery-core/src/test_fixtures.rs +++ b/crates/discovery-core/src/test_fixtures.rs @@ -12,6 +12,9 @@ use std::collections::HashMap; use serde::Deserialize; use starknet_types_core::felt::Felt; +use crate::io_budget::IoBudget; +use crate::privacy_pool::types::SecretFelt; + /// Devnet fixture loaded from devnet-state.json. #[derive(Deserialize)] pub struct DevnetFixture { @@ -135,18 +138,18 @@ pub fn load_cairo_ref_fixture() -> CairoRefFixture { pub async fn get_channel_key( backend: &crate::storage_backend::MockBackend, recipient: starknet_types_core::felt::Felt, - viewing_key: &starknet_types_core::felt::Felt, + private_key: &starknet_types_core::felt::Felt, ) -> Option { use crate::discovery::incoming_channels::{ discover_incoming_channels, get_incoming_channel_count, }; - use crate::io_budget::IoBudget; let budget = IoBudget::new(100); let count = get_incoming_channel_count(backend, recipient, &budget) .await .ok()??; - let result = discover_incoming_channels(backend, recipient, viewing_key, 0, count, &budget) + let key = SecretFelt::new(*private_key); + let result = discover_incoming_channels(backend, recipient, &key, 0, count, &budget) .await .ok()?; @@ -159,7 +162,6 @@ pub async fn get_subchannel_token( channel_key: starknet_types_core::felt::Felt, ) -> Option { use crate::discovery::subchannels::discover_subchannels; - use crate::io_budget::IoBudget; let budget = IoBudget::new(100); let result = discover_subchannels(backend, channel_key, 0, &budget)