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    /// The 32-byte `nonce` is a self-authenticating cookie:
59    /// `[timestamp (8 bytes, LE) | MAC(timestamp ‖ client_id) (24 bytes)]`,
60    /// where the MAC is a keyed BLAKE3 hash under the server's `secret`
61    /// (truncated to 24 bytes). Because the MAC binds the timestamp and the
62    /// client id, the server need keep no per-challenge state — `verify`
63    /// recomputes the MAC to confirm authenticity and freshness.
64    pub fn new_stateless(difficulty: u8, client_id: &[u8], secret: &[u8; 32]) -> Self {
65        let timestamp = std::time::SystemTime::now()
66            .duration_since(std::time::UNIX_EPOCH)
67            .unwrap_or_default()
68            .as_secs();
69
70        let mut nonce = [0u8; 32];
71        nonce[0..8].copy_from_slice(&timestamp.to_le_bytes());
72
73        // Keyed-BLAKE3 MAC binding over `timestamp ‖ client_id`.
74        let mut hasher = Hasher::new_keyed(secret);
75        hasher.update(&timestamp.to_le_bytes());
76        hasher.update(client_id);
77        let mac = hasher.finalize();
78
79        nonce[8..32].copy_from_slice(&mac.as_bytes()[0..24]);
80
81        Self { nonce, difficulty }
82    }
83
84    /// Verify a solution and the validity of the challenge (stateless check)
85    pub fn verify(&self, solution: &PoWSolution, client_id: &[u8], secret: &[u8; 32]) -> bool {
86        // 1. Verify nonce matches
87        if self.nonce != solution.nonce {
88            return false;
89        }
90
91        // 2. Verify challenge integrity (Stateless Cookie)
92        let timestamp_bytes: [u8; 8] = self.nonce[0..8].try_into().unwrap_or_default();
93        let timestamp = u64::from_le_bytes(timestamp_bytes);
94
95        // Verify MAC
96        let mut hasher = Hasher::new_keyed(secret);
97        hasher.update(&timestamp_bytes);
98        hasher.update(client_id);
99        let mac = hasher.finalize();
100
101        // CRYPTO-2/HS-04: constant-time compare of the server-keyed challenge
102        // MAC, matching the cookie/path-validation paths — a short-circuiting
103        // `!=` would leak how many leading MAC bytes an attacker guessed.
104        if !bool::from(self.nonce[8..32].ct_eq(&mac.as_bytes()[0..24])) {
105            return false;
106        }
107
108        // 3. Verify expiration: the challenge is valid for 120 seconds from
109        //    its embedded timestamp (and a future timestamp is rejected).
110        let now = std::time::SystemTime::now()
111            .duration_since(std::time::UNIX_EPOCH)
112            .unwrap_or_default()
113            .as_secs();
114
115        if now < timestamp || now > timestamp + 120 {
116            return false; // Expired or future timestamp
117        }
118
119        // 4. Verify PoW solution
120        // Calculate hash: Blake3(nonce || solution)
121        let hash = compute_blake3_hash(&self.nonce, solution.solution);
122
123        // Check leading zeros
124        check_leading_zeros(&hash, self.difficulty)
125    }
126
127    /// Solve at the challenge's difficulty, bounded by [`MAX_SOLVE_ITERATIONS`]
128    /// (H3) so an infeasible difficulty fails closed instead of looping forever.
129    pub fn solve(&self) -> Result<PoWSolution, PowError> {
130        self.solve_with_bound(MAX_SOLVE_ITERATIONS)
131    }
132
133    /// Like [`solve`](Self::solve) but rejects a difficulty above
134    /// `max_difficulty` BEFORE doing any work (H3). The client passes
135    /// [`MAX_CLIENT_POW_DIFFICULTY`] so an injected high-difficulty
136    /// `HelloRetryRequest` cannot pin a CPU core.
137    pub fn solve_capped(&self, max_difficulty: u8) -> Result<PoWSolution, PowError> {
138        if self.difficulty > max_difficulty {
139            return Err(PowError::DifficultyTooHigh {
140                demanded: self.difficulty,
141                cap: max_difficulty,
142            });
143        }
144        self.solve()
145    }
146
147    /// Iteration-bounded solve core: returns `Exhausted` if no solution is found
148    /// within `max_iters`. `solve` delegates here with [`MAX_SOLVE_ITERATIONS`];
149    /// tests pass a small bound to exercise the fail-closed path without running
150    /// billions of hashes. (`difficulty == 0` succeeds at `solution == 0`,
151    /// preserving the no-PoW-demanded semantics.)
152    fn solve_with_bound(&self, max_iters: u64) -> Result<PoWSolution, PowError> {
153        for solution in 0..max_iters {
154            let hash = compute_blake3_hash(&self.nonce, solution);
155            if check_leading_zeros(&hash, self.difficulty) {
156                return Ok(PoWSolution {
157                    nonce: self.nonce,
158                    solution,
159                });
160            }
161        }
162        Err(PowError::Exhausted)
163    }
164}
165fn compute_blake3_hash(nonce: &[u8; 32], solution: u64) -> [u8; 32] {
166    let mut hasher = Hasher::new();
167    hasher.update(nonce);
168    hasher.update(&solution.to_le_bytes());
169    *hasher.finalize().as_bytes()
170}
171
172fn check_leading_zeros(hash: &[u8], difficulty: u8) -> bool {
173    let mut zeros = 0;
174    for &byte in hash {
175        if byte == 0 {
176            zeros += 8;
177        } else {
178            zeros += byte.leading_zeros() as u8;
179            break;
180        }
181    }
182    zeros >= difficulty
183}
184
185#[cfg(test)]
186mod tests {
187    use super::*;
188
189    #[test]
190    fn test_pow_stateless_verify() {
191        let secret = [42u8; 32];
192        let client_id = b"127.0.0.1";
193
194        let challenge = PoWChallenge::new_stateless(8, client_id, &secret);
195        let solution = challenge.solve().expect("difficulty 8 is solvable");
196
197        assert!(challenge.verify(&solution, client_id, &secret));
198    }
199
200    #[test]
201    fn test_pow_invalid_mac() {
202        let secret = [42u8; 32];
203        let client_id = b"127.0.0.1";
204
205        let mut challenge = PoWChallenge::new_stateless(8, client_id, &secret);
206        challenge.nonce[10] ^= 0xFF; // Corrupt MAC
207
208        let solution = challenge.solve().expect("difficulty 8 is solvable");
209        assert!(!challenge.verify(&solution, client_id, &secret));
210    }
211
212    #[test]
213    fn test_pow_invalid_client() {
214        let secret = [42u8; 32];
215        let client_id = b"127.0.0.1";
216        let other_client = b"192.168.1.1";
217
218        let challenge = PoWChallenge::new_stateless(8, client_id, &secret);
219        let solution = challenge.solve().expect("difficulty 8 is solvable");
220
221        assert!(!challenge.verify(&solution, other_client, &secret));
222    }
223
224    /// **H3.** The client must refuse to brute-force a server-chosen difficulty
225    /// above its cap (an injected `HelloRetryRequest` with difficulty 255 would
226    /// otherwise pin a CPU core forever) — it returns an error instead.
227    #[test]
228    fn solve_capped_rejects_oversized_difficulty() {
229        let challenge = PoWChallenge {
230            nonce: [7u8; 32],
231            difficulty: 255,
232        };
233        match challenge.solve_capped(MAX_CLIENT_POW_DIFFICULTY) {
234            Err(PowError::DifficultyTooHigh { demanded, cap }) => {
235                assert_eq!(demanded, 255);
236                assert_eq!(cap, MAX_CLIENT_POW_DIFFICULTY);
237            }
238            other => panic!("expected DifficultyTooHigh, got {:?}", other),
239        }
240    }
241
242    /// A realistic difficulty within the cap still solves and verifies.
243    #[test]
244    fn solve_capped_accepts_within_cap() {
245        let secret = [9u8; 32];
246        let client_id = b"127.0.0.1";
247        let challenge = PoWChallenge::new_stateless(8, client_id, &secret);
248        assert!(challenge.difficulty <= MAX_CLIENT_POW_DIFFICULTY);
249        let solution = challenge
250            .solve_capped(MAX_CLIENT_POW_DIFFICULTY)
251            .expect("difficulty 8 is solvable");
252        assert!(challenge.verify(&solution, client_id, &secret));
253    }
254
255    /// **H3.** `solve` is iteration-bounded: an infeasible difficulty terminates
256    /// with `Exhausted` instead of looping forever.
257    #[test]
258    fn solve_is_bounded_and_fails_closed() {
259        let challenge = PoWChallenge {
260            nonce: [3u8; 32],
261            difficulty: 250,
262        };
263        assert!(matches!(
264            challenge.solve_with_bound(1_000),
265            Err(PowError::Exhausted)
266        ));
267    }
268
269    /// The client cap must admit the server's maximum legitimate difficulty
270    /// (server tier max 16, `ReputationTracker::MAX_DIFFICULTY` 20, and the
271    /// frozen `difficulty: 20` wire vector) — otherwise an honest server's PoW
272    /// would be self-rejected.
273    #[test]
274    fn max_client_pow_difficulty_admits_the_server_max() {
275        assert!(MAX_CLIENT_POW_DIFFICULTY >= 20);
276    }
277}