diff --git a/frame/shielded-pool/Cargo.toml b/frame/shielded-pool/Cargo.toml index 69ec815d..49d84717 100644 --- a/frame/shielded-pool/Cargo.toml +++ b/frame/shielded-pool/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "pallet-shielded-pool" -version = "0.6.0" +version = "0.6.1" description = "Shielded pool pallet for private transactions using ZK proofs" authors = ["Orbinum Team"] license = "GPL-3.0-or-later" diff --git a/frame/shielded-pool/README.md b/frame/shielded-pool/README.md index e6084e58..2b5298d2 100644 --- a/frame/shielded-pool/README.md +++ b/frame/shielded-pool/README.md @@ -81,7 +81,6 @@ These are design properties of the current MVP. No formal security audit has bee - `pallet-zk-verifier`: proof verification via `ZkVerifierPort`. - `orbinum-zk-core`: Poseidon hash, commitment and nullifier types. -- `orbinum-encrypted-memo`: encrypted memo types. - FRAME: `frame-support`, `frame-system`, `sp-runtime`. ## Testing diff --git a/frame/shielded-pool/src/types.rs b/frame/shielded-pool/src/types.rs index 764d0280..b07aa5ea 100644 --- a/frame/shielded-pool/src/types.rs +++ b/frame/shielded-pool/src/types.rs @@ -289,8 +289,8 @@ pub type DefaultMerklePath = MerklePath; // EncryptedMemo (concrete, FRAME-compatible — used in storage & extrinsics) // ════════════════════════════════════════════════════════════════════════════ -/// Max encrypted memo size: `nonce(12) + note_data(108) + MAC(16) = 136`. -pub const MAX_ENCRYPTED_MEMO_SIZE: u32 = 136; +/// Max encrypted memo size: `nonce(12) + ciphertext(108) + MAC(16) + ephPk(32) = 168`. +pub const MAX_ENCRYPTED_MEMO_SIZE: u32 = 168; /// Encrypted memo attached to a commitment (ChaCha20-Poly1305). #[derive( @@ -363,7 +363,7 @@ impl EncryptedMemo { } } pub fn tag(&self) -> &[u8] { - // Invariant: see nonce(). tag (MAC) occupies bytes 120..136. + // Layout: nonce(0..12) | ciphertext(12..120) | tag/MAC(120..136) | ephPk(136..168) debug_assert_eq!( self.0.len(), MAX_ENCRYPTED_MEMO_SIZE as usize, @@ -377,6 +377,21 @@ impl EncryptedMemo { &[] } } + pub fn eph_pk(&self) -> &[u8] { + // Ephemeral BabyJubJub public key (packed, LE) occupies bytes 136..168. + debug_assert_eq!( + self.0.len(), + MAX_ENCRYPTED_MEMO_SIZE as usize, + "EncryptedMemo invariant violated: expected {} bytes, got {}", + MAX_ENCRYPTED_MEMO_SIZE, + self.0.len() + ); + if self.0.len() >= 168 { + &self.0[136..168] + } else { + &[] + } + } } // ════════════════════════════════════════════════════════════════════════════ @@ -755,6 +770,7 @@ mod tests { assert_eq!(memo.nonce().len(), 12); assert_eq!(memo.ciphertext().len(), 108); assert_eq!(memo.tag().len(), 16); + assert_eq!(memo.eph_pk().len(), 32); } #[test] diff --git a/primitives/encrypted-memo/Cargo.toml b/primitives/encrypted-memo/Cargo.toml index 0bda121a..102eb34a 100644 --- a/primitives/encrypted-memo/Cargo.toml +++ b/primitives/encrypted-memo/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "orbinum-encrypted-memo" -version = "0.3.0" +version = "0.4.0" authors = ["Orbinum Network "] edition = "2021" license = "Apache-2.0 OR GPL-3.0-or-later" diff --git a/primitives/encrypted-memo/README.md b/primitives/encrypted-memo/README.md index e7337c21..f2f47e99 100644 --- a/primitives/encrypted-memo/README.md +++ b/primitives/encrypted-memo/README.md @@ -17,13 +17,13 @@ Encrypted memo primitives for private transaction metadata in Orbinum Network. ```toml [dependencies] -orbinum-encrypted-memo = "1.0" +orbinum-encrypted-memo = "0.4" # Enable random nonce generation (requires std/rand) -orbinum-encrypted-memo = { version = "1.0", features = ["encrypt"] } +orbinum-encrypted-memo = { version = "0.4", features = ["encrypt"] } # Enable SCALE codec + TypeInfo (Substrate runtime) -orbinum-encrypted-memo = { version = "1.0", features = ["parity-scale-codec", "scale-info"] } +orbinum-encrypted-memo = { version = "0.4", features = ["parity-scale-codec", "scale-info"] } ``` ## Usage @@ -36,15 +36,23 @@ use orbinum_encrypted_memo::{MemoData, KeySet, encrypt_memo, decrypt_memo}; // Derive keys from master spending key let keys = KeySet::from_spending_key(spending_key); -// Create memo -let memo = MemoData::new(1000, owner_pubkey, blinding, 0); +// Create memo with counterparty (private transfer) +let memo = MemoData::new(1000, owner_pubkey, blinding, 0, counterparty_pk); + +// Create memo without counterparty (shield / unshield) +let memo = MemoData::new_without_counterparty(1000, owner_pubkey, blinding, 0); // Encrypt (nonce must be unique per note) let encrypted = encrypt_memo(&memo, &commitment, keys.viewing_key.as_bytes(), &nonce)?; -// Decrypt +// Decrypt (symmetric mode — viewing_key as shared_secret) let decrypted = decrypt_memo(&encrypted, &commitment, keys.viewing_key.as_bytes())?; assert_eq!(decrypted.value, 1000); + +// Decrypt (ECDH mode — derive shared_secret from ephPk appended to encrypted[136..168]) +// let eph_pk = &encrypted[136..168]; +// let shared_secret = bjj_ecdh(ivsk_scalar, eph_pk); +// let decrypted = decrypt_memo(&encrypted, &commitment, &shared_secret)?; ``` ### Key Derivation from Spending Key @@ -101,19 +109,38 @@ let proof = DisclosureProof::from_bytes(&bytes)?; ChaCha20Poly1305 AEAD with per-note key derivation: ```text -encryption_key = SHA256(viewing_key || commitment || "orbinum-note-encryption-v1") -ciphertext = ChaCha20Poly1305(plaintext=76B, key=encryption_key, nonce=12B) -encrypted_memo = nonce(12) || ciphertext(76) || mac(16) → 104 bytes total +Plaintext (MemoData): value(8) | owner_pk(32) | blinding(32) | asset_id(4) | counterparty_pk(32) = 108 bytes + +encryption_key = SHA256(shared_secret || commitment || "orbinum-note-encryption-v1") +ciphertext = ChaCha20Poly1305(plaintext=108B, key=encryption_key, nonce=12B) +encrypted_memo = nonce(12) | ciphertext(108) | MAC(16) | ephPk(32) → 168 bytes total ``` +`shared_secret` is either the `viewing_key` (symmetric mode) or the ECDH x-coordinate (wallet mode). See **Key Derivation Hierarchy** below. + ## Key Derivation Hierarchy +### Symmetric mode (server-side / legacy) + ```text spending_key (32 bytes, never shared) │ ├── viewing_key = SHA256(spending_key || "orbinum-viewing-key-v1") ├── nullifier_key = SHA256(spending_key || "orbinum-nullifier-key-v1") └── eddsa_key = SHA256(spending_key || "orbinum-eddsa-key-v1") + +encryption_key(commitment) = SHA256(viewing_key || commitment || "orbinum-note-encryption-v1") +``` + +### ECDH mode (wallet / TypeScript client) + +```text +ivsk = HKDF-SHA256(spendingKey_bytes, info="orbinum-ivk-v1") ← secret +ivk_point = BJJ_mul(Base8, ivsk_scalar) ← public, in address +ephSk = random BJJ scalar +ephPk = BJJ_mul(Base8, ephSk) ← appended to encrypted[136..168] +shared_sec = BJJ_mul(ivk_point, ephSk)[0] (x-coordinate, LE) +enc_key = SHA256(shared_sec || commitment || "orbinum-note-encryption-v1") ``` ## Memo Structure @@ -121,11 +148,14 @@ spending_key (32 bytes, never shared) | Field | Type | Size | Description | |-------|------|------|-------------| | `value` | `u64` | 8 bytes | Note amount | -| `owner_pk` | `[u8; 32]` | 32 bytes | Owner public key | +| `owner_pk` | `[u8; 32]` | 32 bytes | Owner BabyJubJub public key (Ax, LE) | | `blinding` | `[u8; 32]` | 32 bytes | Blinding factor | | `asset_id` | `u32` | 4 bytes | Asset identifier | +| `counterparty_pk` | `[u8; 32]` | 32 bytes | Other party's Ax (LE); `[0u8;32]` for shield/unshield | + +**Plaintext**: 108 bytes — **Encrypted wire format**: 168 bytes (`nonce(12) | ciphertext(108) | MAC(16) | ephPk(32)`) -**Plaintext**: 76 bytes — **Encrypted memo**: 104 bytes (nonce + ciphertext + MAC) +Use `MemoData::new_without_counterparty(value, owner_pk, blinding, asset_id)` for shield and unshield notes. ## Selective Disclosure Masks diff --git a/primitives/encrypted-memo/src/keys.rs b/primitives/encrypted-memo/src/keys.rs index 695761ee..5ea7e2c1 100644 --- a/primitives/encrypted-memo/src/keys.rs +++ b/primitives/encrypted-memo/src/keys.rs @@ -1,9 +1,6 @@ //! Key types and derivation for Orbinum shielded transactions. //! -//! All sub-keys are derived from a single 32-byte spending key via SHA-256 -//! with domain separation. The spending key itself is never exposed over the wire. -//! -//! # Key hierarchy +//! # Symmetric key hierarchy (server-side / legacy) //! //! ```text //! spending_key @@ -11,8 +8,26 @@ //! ├── nullifier_key SHA256(sk || "orbinum-nullifier-key-v1") //! └── eddsa_key SHA256(sk || "orbinum-eddsa-key-v1") //! -//! encryption_key(commitment) SHA256(viewing_key || commitment || "orbinum-note-encryption-v1") +//! encryption_key(commitment) SHA256(shared_secret || commitment || "orbinum-note-encryption-v1") //! ``` +//! +//! # ECDH mode (wallet / TypeScript client) +//! +//! In the ECDH scheme the wallet never embeds `viewing_key` in addresses. +//! Instead it derives an ephemeral BabyJubJub keypair and performs ECDH: +//! +//! ```text +//! ivsk = HKDF-SHA256(spendingKey_bytes, info="orbinum-ivk-v1") ← secret +//! ivk_point = BJJ_mul(Base8, ivsk_scalar) ← public, in address +//! ephSk = random BJJ scalar +//! ephPk = BJJ_mul(Base8, ephSk) ← appended to memo +//! shared_sec = BJJ_mul(ivk_point, ephSk)[0] (x-coordinate, LE) ← encrypt key input +//! enc_key = SHA256(shared_sec || commitment || "orbinum-note-encryption-v1") +//! ``` +//! +//! The `derive_encryption_key` function below works for both schemes; callers +//! must pass the correct 32-byte `shared_secret` (viewing_key in symmetric mode, +//! ECDH x-coordinate in ECDH mode). use sha2::{Digest, Sha256}; @@ -164,10 +179,15 @@ pub fn derive_eddsa_key_from_spending(spending_key: &[u8; 32]) -> EdDSAKey { EdDSAKey(h.finalize().into()) } -/// Derives the per-note encryption key: `SHA256(viewing_key || commitment || KEY_DOMAIN)` -pub(crate) fn derive_encryption_key(viewing_key: &[u8; 32], commitment: &[u8; 32]) -> [u8; 32] { +/// Derives the per-note encryption key from shared secret material and commitment. +/// +/// `SHA256(shared_secret || commitment || "orbinum-note-encryption-v1")` +/// +/// In symmetric mode `shared_secret` is the `viewing_key` bytes. +/// In ECDH mode `shared_secret` is the x-coordinate of the BabyJubJub shared point (LE). +pub(crate) fn derive_encryption_key(shared_secret: &[u8; 32], commitment: &[u8; 32]) -> [u8; 32] { let mut h = Sha256::new(); - h.update(viewing_key); + h.update(shared_secret); h.update(commitment); h.update(KEY_DOMAIN); h.finalize().into() diff --git a/primitives/encrypted-memo/src/lib.rs b/primitives/encrypted-memo/src/lib.rs index 0fe69eed..3d8a55ed 100644 --- a/primitives/encrypted-memo/src/lib.rs +++ b/primitives/encrypted-memo/src/lib.rs @@ -11,10 +11,16 @@ //! ```rust,ignore //! use orbinum_encrypted_memo::{MemoData, KeySet, encrypt_memo, decrypt_memo}; //! +//! // Symmetric mode: use viewing_key as shared_secret //! let keys = KeySet::from_spending_key(spending_key); //! let memo = MemoData::new(1000, owner_pk, blinding, 0, [0u8; 32]); //! let encrypted = encrypt_memo(&memo, &commitment, keys.viewing_key.as_bytes(), &nonce)?; //! let decrypted = decrypt_memo(&encrypted, &commitment, keys.viewing_key.as_bytes())?; +//! +//! // ECDH mode: derive shared_secret externally via BabyJubJub before calling decrypt_memo +//! // let eph_pk = &encrypted[136..168]; +//! // let shared_secret = bjj_ecdh(ivsk_scalar, eph_pk); +//! // let decrypted = decrypt_memo(&encrypted, &commitment, &shared_secret)?; //! ``` #![cfg_attr(not(feature = "std"), no_std)] @@ -34,7 +40,8 @@ pub use keys::{ // Memo pub use memo::{ decrypt_memo, encrypt_memo, is_valid_encrypted_memo, try_decrypt_memo, MemoData, MemoError, - MAC_SIZE, MAX_ENCRYPTED_MEMO_SIZE, MEMO_DATA_SIZE, MIN_ENCRYPTED_MEMO_SIZE, NONCE_SIZE, + EPH_PK_SIZE, MAC_SIZE, MAX_ENCRYPTED_MEMO_SIZE, MEMO_DATA_SIZE, MIN_ENCRYPTED_MEMO_SIZE, + NONCE_SIZE, }; #[cfg(feature = "encrypt")] diff --git a/primitives/encrypted-memo/src/memo.rs b/primitives/encrypted-memo/src/memo.rs index 64459d55..87c703bd 100644 --- a/primitives/encrypted-memo/src/memo.rs +++ b/primitives/encrypted-memo/src/memo.rs @@ -4,7 +4,7 @@ //! //! ```text //! Plaintext (MemoData): value(8) | owner_pk(32) | blinding(32) | asset_id(4) | counterparty_pk(32) = 108 bytes -//! Encrypted wire format: nonce(12) | ciphertext(108) | MAC(16) = 136 bytes +//! Encrypted wire format: nonce(12) | ciphertext(108) | MAC(16) | ephPk(32) = 168 bytes //! ``` use alloc::vec::Vec; @@ -26,8 +26,11 @@ pub const NONCE_SIZE: usize = 12; /// ChaCha20-Poly1305 MAC (authentication tag) size. pub const MAC_SIZE: usize = 16; -/// Maximum encrypted memo size: `nonce(12) + plaintext(108) + MAC(16)` -pub const MAX_ENCRYPTED_MEMO_SIZE: usize = NONCE_SIZE + MEMO_DATA_SIZE + MAC_SIZE; +/// Ephemeral BabyJubJub public key (packed LE) appended to the memo for ECDH recipient decryption. +pub const EPH_PK_SIZE: usize = 32; + +/// Maximum encrypted memo size: `nonce(12) + ciphertext(108) + MAC(16) + ephPk(32)` +pub const MAX_ENCRYPTED_MEMO_SIZE: usize = NONCE_SIZE + MEMO_DATA_SIZE + MAC_SIZE + EPH_PK_SIZE; /// Minimum encrypted memo size: `nonce(12) + MAC(16)` (zero-length plaintext) pub const MIN_ENCRYPTED_MEMO_SIZE: usize = NONCE_SIZE + MAC_SIZE; @@ -191,25 +194,30 @@ pub fn is_valid_encrypted_memo(data: &[u8]) -> bool { /// Encrypts memo data using ChaCha20-Poly1305. /// -/// Returns `nonce(12) || ciphertext(76) || MAC(16)` = 104 bytes. +/// Returns `nonce(12) || ciphertext(108) || MAC(16) || ephPk(32)` = 168 bytes. +/// The ephemeral public key is set to all-zeros (symmetric / public-note mode). +/// For full ECDH, compute the shared secret externally with BabyJubJub and pass it as +/// `shared_secret`. The caller is responsible for appending the real ephPk after the call +/// if needed for ECDH recipients (replace the trailing 32 zero bytes). /// /// **WARNING**: `nonce` MUST be unique per (key, message) pair and never reused. pub fn encrypt_memo( memo: &MemoData, commitment: &[u8; 32], - recipient_viewing_key: &[u8; 32], + shared_secret: &[u8; 32], nonce: &[u8; 12], ) -> Result, MemoError> { - let key = derive_encryption_key(recipient_viewing_key, commitment); + let key = derive_encryption_key(shared_secret, commitment); let cipher = ChaCha20Poly1305::new((&key).into()); let nonce_obj = Nonce::from_slice(nonce); let ciphertext = cipher .encrypt(nonce_obj, memo.to_bytes().as_ref()) .map_err(|_| MemoError::EncryptionFailed)?; - let mut result = Vec::with_capacity(NONCE_SIZE + ciphertext.len()); + let mut result = Vec::with_capacity(NONCE_SIZE + ciphertext.len() + EPH_PK_SIZE); result.extend_from_slice(nonce); result.extend_from_slice(&ciphertext); + result.extend_from_slice(&[0u8; EPH_PK_SIZE]); // zero ephPk = symmetric / public-note mode Ok(result) } @@ -220,21 +228,28 @@ pub fn encrypt_memo( pub fn encrypt_memo_random( memo: &MemoData, commitment: &[u8; 32], - recipient_viewing_key: &[u8; 32], + shared_secret: &[u8; 32], ) -> Result, MemoError> { use rand::{rngs::OsRng, RngCore}; let mut nonce = [0u8; 12]; OsRng.fill_bytes(&mut nonce); - encrypt_memo(memo, commitment, recipient_viewing_key, &nonce) + encrypt_memo(memo, commitment, shared_secret, &nonce) } -/// Decrypts an encrypted memo using a viewing key. +/// Decrypts an encrypted memo. +/// +/// Expects `nonce(12) || ciphertext + MAC(124) || ephPk(32)` (168 bytes). +/// The trailing `ephPk` bytes are stripped; the first 136 bytes are fed to ChaCha20-Poly1305. +/// +/// **ECDH callers**: extract `ephPk = encrypted[136..168]`, derive the BabyJubJub ECDH +/// shared secret externally (x-coordinate of `BJJ_mul(ephPk_point, ivsk_scalar)`), then +/// pass that 32-byte value as `shared_secret`. /// -/// Expects `nonce(12) || ciphertext + MAC` as produced by [`encrypt_memo`]. +/// **Symmetric / public-note callers**: pass the viewing key directly as `shared_secret`. pub fn decrypt_memo( encrypted: &[u8], commitment: &[u8; 32], - viewing_key: &[u8; 32], + shared_secret: &[u8; 32], ) -> Result { if encrypted.len() < MIN_ENCRYPTED_MEMO_SIZE { return Err(MemoError::DataTooShort); @@ -242,10 +257,12 @@ pub fn decrypt_memo( if encrypted.len() > MAX_ENCRYPTED_MEMO_SIZE { return Err(MemoError::DataTooLong); } + // Strip trailing ephemeral public key bytes (v2 ECDH layout). + let cipher_data = &encrypted[..encrypted.len().min(MAX_ENCRYPTED_MEMO_SIZE - EPH_PK_SIZE)]; - let (nonce_bytes, ciphertext) = encrypted.split_at(NONCE_SIZE); + let (nonce_bytes, ciphertext) = cipher_data.split_at(NONCE_SIZE); let nonce = Nonce::from_slice(nonce_bytes); - let key = derive_encryption_key(viewing_key, commitment); + let key = derive_encryption_key(shared_secret, commitment); let cipher = ChaCha20Poly1305::new((&key).into()); let plaintext = cipher .decrypt(nonce, ciphertext) @@ -260,9 +277,9 @@ pub fn decrypt_memo( pub fn try_decrypt_memo( encrypted: &[u8], commitment: &[u8; 32], - viewing_key: &[u8; 32], + shared_secret: &[u8; 32], ) -> Option { - decrypt_memo(encrypted, commitment, viewing_key).ok() + decrypt_memo(encrypted, commitment, shared_secret).ok() } // ─── Tests ──────────────────────────────────────────────────────────────────── @@ -286,12 +303,13 @@ mod tests { assert_eq!(MEMO_DATA_SIZE, 108); assert_eq!(NONCE_SIZE, 12); assert_eq!(MAC_SIZE, 16); + assert_eq!(EPH_PK_SIZE, 32); assert_eq!( MAX_ENCRYPTED_MEMO_SIZE, - NONCE_SIZE + MEMO_DATA_SIZE + MAC_SIZE + NONCE_SIZE + MEMO_DATA_SIZE + MAC_SIZE + EPH_PK_SIZE ); assert_eq!(MIN_ENCRYPTED_MEMO_SIZE, NONCE_SIZE + MAC_SIZE); - assert_eq!(MAX_ENCRYPTED_MEMO_SIZE, 136); + assert_eq!(MAX_ENCRYPTED_MEMO_SIZE, 168); assert_eq!(MIN_ENCRYPTED_MEMO_SIZE, 28); }