Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion frame/shielded-pool/Cargo.toml
Original file line number Diff line number Diff line change
@@ -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"
Expand Down
1 change: 0 additions & 1 deletion frame/shielded-pool/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
22 changes: 19 additions & 3 deletions frame/shielded-pool/src/types.rs
Original file line number Diff line number Diff line change
Expand Up @@ -289,8 +289,8 @@ pub type DefaultMerklePath = MerklePath<DEFAULT_TREE_DEPTH>;
// 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(
Expand Down Expand Up @@ -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,
Expand All @@ -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 {
&[]
}
}
}

// ════════════════════════════════════════════════════════════════════════════
Expand Down Expand Up @@ -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]
Expand Down
2 changes: 1 addition & 1 deletion primitives/encrypted-memo/Cargo.toml
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
[package]
name = "orbinum-encrypted-memo"
version = "0.3.0"
version = "0.4.0"
authors = ["Orbinum Network <contact@orbinum.net>"]
edition = "2021"
license = "Apache-2.0 OR GPL-3.0-or-later"
Expand Down
52 changes: 41 additions & 11 deletions primitives/encrypted-memo/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand Down Expand Up @@ -101,31 +109,53 @@ 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

| 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

Expand Down
36 changes: 28 additions & 8 deletions primitives/encrypted-memo/src/keys.rs
Original file line number Diff line number Diff line change
@@ -1,18 +1,33 @@
//! 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
//! ├── viewing_key SHA256(sk || "orbinum-viewing-key-v1")
//! ├── 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};

Expand Down Expand Up @@ -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()
Expand Down
9 changes: 8 additions & 1 deletion primitives/encrypted-memo/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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)]
Expand All @@ -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")]
Expand Down
54 changes: 36 additions & 18 deletions primitives/encrypted-memo/src/memo.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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;
Expand Down Expand Up @@ -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<Vec<u8>, 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)
}

Expand All @@ -220,32 +228,41 @@ pub fn encrypt_memo(
pub fn encrypt_memo_random(
memo: &MemoData,
commitment: &[u8; 32],
recipient_viewing_key: &[u8; 32],
shared_secret: &[u8; 32],
) -> Result<Vec<u8>, 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<MemoData, MemoError> {
if encrypted.len() < MIN_ENCRYPTED_MEMO_SIZE {
return Err(MemoError::DataTooShort);
}
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)
Expand All @@ -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<MemoData> {
decrypt_memo(encrypted, commitment, viewing_key).ok()
decrypt_memo(encrypted, commitment, shared_secret).ok()
}

// ─── Tests ────────────────────────────────────────────────────────────────────
Expand All @@ -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);
}

Expand Down
Loading