Skip to main content

curvy_core/
cipher.rs

1//! Note-data cipher - a faithful port of `balanceCipher.ts`.
2//!
3//! The encrypted amount/token are two PUBLIC field signals (each `< r`), so there
4//! is no room for an AEAD tag/IV. We use AES-256-CTR as a keystream and add it into
5//! the value **in the field** (an additive field-OTP):
6//!
7//! ```text
8//! enc = (value + keystream_field) mod r        dec = (enc − keystream_field) mod r
9//! ```
10//!
11//! Integrity comes from the on-chain `noteId = Poseidon([ownerHash, amount, token])`,
12//! not the cipher (the recipient recomputes it and rejects on mismatch).
13//!
14//! - key   = HKDF-SHA256(ikm = BE32(sharedSecret), salt, info) → 32-byte AES key
15//! - nonce = SHA-256(BE32(ephX) ‖ BE32(ephY))[0..16], used as a **64-bit** CTR
16//!   counter block (`Ctr64BE`, matching WebCrypto `AES-CTR` with `length: 64`)
17//! - keystream = `AES-256-CTR.encrypt(zeros[64])`; `ks[0..32]` → amount, `ks[32..64]` → token
18
19use aes::Aes256;
20use ctr::Ctr64BE;
21use ctr::cipher::{KeyIvInit, StreamCipher};
22use hkdf::Hkdf;
23use num_bigint::BigUint;
24use sha2::{Digest, Sha256};
25
26use crate::encoding::biguint_to_be_32;
27use crate::field::{Fr, fr_from_be_bytes_mod};
28
29const NOTE_KEY_SALT: &[u8] = b"curvy/agg-note/v1";
30const NOTE_KEY_INFO: &[u8] = b"curvy/agg-note/v1:amount+token";
31const KEYSTREAM_BYTES: usize = 64;
32
33type Aes256Ctr64BE = Ctr64BE<Aes256>;
34
35// The `sharedSecret` and `ephemeralKey` coordinates are used purely as key material
36// and are packed as RAW 256-bit big-endian integers (no field reduction), matching
37// the TS `bigIntToBytes(value, 32)`. In production they are always BabyJubjub field
38// coordinates (`< r`), but typing them as `BigUint` keeps the cipher byte-identical
39// to the TS for the whole `[0, 2^256)` input domain.
40fn derive_note_key(shared_secret: &BigUint) -> [u8; 32] {
41    let hk = Hkdf::<Sha256>::new(Some(NOTE_KEY_SALT), &biguint_to_be_32(shared_secret));
42    let mut okm = [0u8; 32];
43    hk.expand(NOTE_KEY_INFO, &mut okm)
44        .expect("hkdf expand to 32 bytes");
45    okm
46}
47
48fn derive_counter_block(ephemeral_key: (&BigUint, &BigUint)) -> [u8; 16] {
49    let mut h = Sha256::new();
50    h.update(biguint_to_be_32(ephemeral_key.0));
51    h.update(biguint_to_be_32(ephemeral_key.1));
52    let digest = h.finalize();
53    let mut counter = [0u8; 16];
54    counter.copy_from_slice(&digest[0..16]);
55    counter
56}
57
58/// The two field-element keystream pads `(ksAmount, ksToken)`.
59fn ctr_keystream_fields(shared_secret: &BigUint, ephemeral_key: (&BigUint, &BigUint)) -> (Fr, Fr) {
60    let key = derive_note_key(shared_secret);
61    let counter = derive_counter_block(ephemeral_key);
62
63    let mut ks = [0u8; KEYSTREAM_BYTES];
64    let mut cipher =
65        Aes256Ctr64BE::new_from_slices(&key, &counter).expect("valid AES-256 key + 16-byte IV");
66    cipher.apply_keystream(&mut ks);
67
68    (
69        fr_from_be_bytes_mod(&ks[0..32]),
70        fr_from_be_bytes_mod(&ks[32..64]),
71    )
72}
73
74/// The two encrypted `EncryptedNoteData` field slots.
75#[derive(Clone, Copy, Debug, PartialEq, Eq)]
76pub struct EncryptedAmountToken {
77    pub encrypted_amount: Fr,
78    pub encrypted_token: Fr,
79}
80
81/// Encrypt `(amount, token)` into the two field slots (`encryptAmountToken`).
82/// `amount`/`token` are field elements; `shared_secret`/`ephemeral_key` are raw
83/// 256-bit key material (see the module note).
84pub fn encrypt_amount_token(
85    amount: Fr,
86    token: Fr,
87    shared_secret: &BigUint,
88    ephemeral_key: (&BigUint, &BigUint),
89) -> EncryptedAmountToken {
90    let (ks_amount, ks_token) = ctr_keystream_fields(shared_secret, ephemeral_key);
91    EncryptedAmountToken {
92        encrypted_amount: amount + ks_amount,
93        encrypted_token: token + ks_token,
94    }
95}
96
97/// Inverse of [`encrypt_amount_token`] (`decryptAmountToken`). The caller MUST
98/// verify the recomputed `noteId`.
99pub fn decrypt_amount_token(
100    encrypted_amount: Fr,
101    encrypted_token: Fr,
102    shared_secret: &BigUint,
103    ephemeral_key: (&BigUint, &BigUint),
104) -> (Fr, Fr) {
105    let (ks_amount, ks_token) = ctr_keystream_fields(shared_secret, ephemeral_key);
106    (encrypted_amount - ks_amount, encrypted_token - ks_token)
107}