Skip to main content

phantom_protocol/crypto/
pow.rs

1use blake3::Hasher;
2use borsh::{BorshDeserialize, BorshSerialize};
3use serde::{Deserialize as SerdeDeserialize, Serialize as SerdeSerialize};
4use subtle::ConstantTimeEq;
5
6/// Client-side cap on the PoW difficulty it will attempt to solve (H3). An
7/// unauthenticated `HelloRetryRequest` carries `difficulty` verbatim; without a
8/// cap an injected `difficulty = 255` would make the client spin ~2^255 hashes
9/// forever. 24 sits strictly above every difficulty an honest server issues
10/// (load-tier max 16, `ReputationTracker::MAX_DIFFICULTY` 20, the frozen
11/// `difficulty: 20` wire vector) yet is a sub-second solve (~2^24 hashes).
12pub const MAX_CLIENT_POW_DIFFICULTY: u8 = 24;
13
14/// Hard iteration bound for [`PoWChallenge::solve`] (H3) — `2^32`, ~2^8 expected
15/// attempts of headroom over the worst in-cap difficulty (`2^24`), so a
16/// legitimate solve effectively never spuriously fails while an infeasible one
17/// still terminates.
18pub const MAX_SOLVE_ITERATIONS: u64 = 1u64 << (MAX_CLIENT_POW_DIFFICULTY as u32 + 8);
19
20/// Error from the bounded / capped PoW solver (H3).
21#[derive(Debug, Clone, PartialEq, Eq)]
22pub enum PowError {
23    /// Difficulty above the client's [`MAX_CLIENT_POW_DIFFICULTY`].
24    DifficultyTooHigh { demanded: u8, cap: u8 },
25    /// `solve` exhausted [`MAX_SOLVE_ITERATIONS`] without a solution.
26    Exhausted,
27}
28
29impl core::fmt::Display for PowError {
30    fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
31        match self {
32            PowError::DifficultyTooHigh { demanded, cap } => write!(
33                f,
34                "server demanded PoW difficulty {demanded} exceeding client cap {cap}"
35            ),
36            PowError::Exhausted => write!(f, "PoW solve exhausted the iteration bound"),
37        }
38    }
39}
40
41/// Proof-of-Work Challenge
42#[derive(BorshSerialize, BorshDeserialize, SerdeSerialize, SerdeDeserialize, Debug, Clone)]
43pub struct PoWChallenge {
44    pub nonce: [u8; 32], // Increased to 32 bytes for stateless cookie
45    pub difficulty: u8,  // Number of leading zero bits required
46}
47
48/// Proof-of-Work Solution
49#[derive(BorshSerialize, BorshDeserialize, SerdeSerialize, SerdeDeserialize, Debug, Clone)]
50pub struct PoWSolution {
51    pub nonce: [u8; 32],
52    pub solution: u64,
53}
54
55impl PoWChallenge {
56    /// Generate a new stateless challenge
57    ///
58    /// Nonce format: [Timestamp (8 bytes) | HMAC(Timestamp + ClientID, Secret) (24 bytes)]
59    pub fn new_stateless(difficulty: u8, client_id: &[u8], secret: &[u8; 32]) -> Self {
60        let timestamp = std::time::SystemTime::now()
61            .duration_since(std::time::UNIX_EPOCH)
62            .unwrap_or_default()
63            .as_secs();
64
65        let mut nonce = [0u8; 32];
66        nonce[0..8].copy_from_slice(&timestamp.to_le_bytes());
67
68        // HMAC binding
69        let mut hasher = Hasher::new_keyed(secret);
70        hasher.update(&timestamp.to_le_bytes());
71        hasher.update(client_id);
72        let mac = hasher.finalize();
73
74        nonce[8..32].copy_from_slice(&mac.as_bytes()[0..24]);
75
76        Self { nonce, difficulty }
77    }
78
79    /// Verify a solution and the validity of the challenge (stateless check)
80    pub fn verify(&self, solution: &PoWSolution, client_id: &[u8], secret: &[u8; 32]) -> bool {
81        // 1. Verify nonce matches
82        if self.nonce != solution.nonce {
83            return false;
84        }
85
86        // 2. Verify challenge integrity (Stateless Cookie)
87        let timestamp_bytes: [u8; 8] = self.nonce[0..8].try_into().unwrap_or_default();
88        let timestamp = u64::from_le_bytes(timestamp_bytes);
89
90        // Verify MAC
91        let mut hasher = Hasher::new_keyed(secret);
92        hasher.update(&timestamp_bytes);
93        hasher.update(client_id);
94        let mac = hasher.finalize();
95
96        // CRYPTO-2/HS-04: constant-time compare of the server-keyed challenge
97        // MAC, matching the cookie/path-validation paths — a short-circuiting
98        // `!=` would leak how many leading MAC bytes an attacker guessed.
99        if !bool::from(self.nonce[8..32].ct_eq(&mac.as_bytes()[0..24])) {
100            return false;
101        }
102
103        // 3. Verify expiration (e.g., 60 seconds validity)
104        let now = std::time::SystemTime::now()
105            .duration_since(std::time::UNIX_EPOCH)
106            .unwrap_or_default()
107            .as_secs();
108
109        if now < timestamp || now > timestamp + 120 {
110            return false; // Expired or future timestamp
111        }
112
113        // 4. Verify PoW solution
114        // Calculate hash: Blake3(nonce || solution)
115        let hash = compute_blake3_hash(&self.nonce, solution.solution);
116
117        // Check leading zeros
118        check_leading_zeros(&hash, self.difficulty)
119    }
120
121    /// Solve at the challenge's difficulty, bounded by [`MAX_SOLVE_ITERATIONS`]
122    /// (H3) so an infeasible difficulty fails closed instead of looping forever.
123    pub fn solve(&self) -> Result<PoWSolution, PowError> {
124        self.solve_with_bound(MAX_SOLVE_ITERATIONS)
125    }
126
127    /// Like [`solve`](Self::solve) but rejects a difficulty above
128    /// `max_difficulty` BEFORE doing any work (H3). The client passes
129    /// [`MAX_CLIENT_POW_DIFFICULTY`] so an injected high-difficulty
130    /// `HelloRetryRequest` cannot pin a CPU core.
131    pub fn solve_capped(&self, max_difficulty: u8) -> Result<PoWSolution, PowError> {
132        if self.difficulty > max_difficulty {
133            return Err(PowError::DifficultyTooHigh {
134                demanded: self.difficulty,
135                cap: max_difficulty,
136            });
137        }
138        self.solve()
139    }
140
141    /// Iteration-bounded solve core: returns `Exhausted` if no solution is found
142    /// within `max_iters`. `solve` delegates here with [`MAX_SOLVE_ITERATIONS`];
143    /// tests pass a small bound to exercise the fail-closed path without running
144    /// billions of hashes. (`difficulty == 0` succeeds at `solution == 0`,
145    /// preserving the no-PoW-demanded semantics.)
146    fn solve_with_bound(&self, max_iters: u64) -> Result<PoWSolution, PowError> {
147        for solution in 0..max_iters {
148            let hash = compute_blake3_hash(&self.nonce, solution);
149            if check_leading_zeros(&hash, self.difficulty) {
150                return Ok(PoWSolution {
151                    nonce: self.nonce,
152                    solution,
153                });
154            }
155        }
156        Err(PowError::Exhausted)
157    }
158}
159fn compute_blake3_hash(nonce: &[u8; 32], solution: u64) -> [u8; 32] {
160    let mut hasher = Hasher::new();
161    hasher.update(nonce);
162    hasher.update(&solution.to_le_bytes());
163    *hasher.finalize().as_bytes()
164}
165
166fn check_leading_zeros(hash: &[u8], difficulty: u8) -> bool {
167    let mut zeros = 0;
168    for &byte in hash {
169        if byte == 0 {
170            zeros += 8;
171        } else {
172            zeros += byte.leading_zeros() as u8;
173            break;
174        }
175    }
176    zeros >= difficulty
177}
178
179#[cfg(test)]
180mod tests {
181    use super::*;
182
183    #[test]
184    fn test_pow_stateless_verify() {
185        let secret = [42u8; 32];
186        let client_id = b"127.0.0.1";
187
188        let challenge = PoWChallenge::new_stateless(8, client_id, &secret);
189        let solution = challenge.solve().expect("difficulty 8 is solvable");
190
191        assert!(challenge.verify(&solution, client_id, &secret));
192    }
193
194    #[test]
195    fn test_pow_invalid_mac() {
196        let secret = [42u8; 32];
197        let client_id = b"127.0.0.1";
198
199        let mut challenge = PoWChallenge::new_stateless(8, client_id, &secret);
200        challenge.nonce[10] ^= 0xFF; // Corrupt MAC
201
202        let solution = challenge.solve().expect("difficulty 8 is solvable");
203        assert!(!challenge.verify(&solution, client_id, &secret));
204    }
205
206    #[test]
207    fn test_pow_invalid_client() {
208        let secret = [42u8; 32];
209        let client_id = b"127.0.0.1";
210        let other_client = b"192.168.1.1";
211
212        let challenge = PoWChallenge::new_stateless(8, client_id, &secret);
213        let solution = challenge.solve().expect("difficulty 8 is solvable");
214
215        assert!(!challenge.verify(&solution, other_client, &secret));
216    }
217
218    /// **H3.** The client must refuse to brute-force a server-chosen difficulty
219    /// above its cap (an injected `HelloRetryRequest` with difficulty 255 would
220    /// otherwise pin a CPU core forever) — it returns an error instead.
221    #[test]
222    fn solve_capped_rejects_oversized_difficulty() {
223        let challenge = PoWChallenge {
224            nonce: [7u8; 32],
225            difficulty: 255,
226        };
227        match challenge.solve_capped(MAX_CLIENT_POW_DIFFICULTY) {
228            Err(PowError::DifficultyTooHigh { demanded, cap }) => {
229                assert_eq!(demanded, 255);
230                assert_eq!(cap, MAX_CLIENT_POW_DIFFICULTY);
231            }
232            other => panic!("expected DifficultyTooHigh, got {:?}", other),
233        }
234    }
235
236    /// A realistic difficulty within the cap still solves and verifies.
237    #[test]
238    fn solve_capped_accepts_within_cap() {
239        let secret = [9u8; 32];
240        let client_id = b"127.0.0.1";
241        let challenge = PoWChallenge::new_stateless(8, client_id, &secret);
242        assert!(challenge.difficulty <= MAX_CLIENT_POW_DIFFICULTY);
243        let solution = challenge
244            .solve_capped(MAX_CLIENT_POW_DIFFICULTY)
245            .expect("difficulty 8 is solvable");
246        assert!(challenge.verify(&solution, client_id, &secret));
247    }
248
249    /// **H3.** `solve` is iteration-bounded: an infeasible difficulty terminates
250    /// with `Exhausted` instead of looping forever.
251    #[test]
252    fn solve_is_bounded_and_fails_closed() {
253        let challenge = PoWChallenge {
254            nonce: [3u8; 32],
255            difficulty: 250,
256        };
257        assert!(matches!(
258            challenge.solve_with_bound(1_000),
259            Err(PowError::Exhausted)
260        ));
261    }
262
263    /// The client cap must admit the server's maximum legitimate difficulty
264    /// (server tier max 16, `ReputationTracker::MAX_DIFFICULTY` 20, and the
265    /// frozen `difficulty: 20` wire vector) — otherwise an honest server's PoW
266    /// would be self-rejected.
267    #[test]
268    fn max_client_pow_difficulty_admits_the_server_max() {
269        assert!(MAX_CLIENT_POW_DIFFICULTY >= 20);
270    }
271}