Skip to main content

parley_core/
pow.rs

1//! Hashcash-style proof-of-work for identity registration.
2//! Spec: `spec/v0.5.md` §3.
3
4use base64::engine::general_purpose::URL_SAFE_NO_PAD;
5use base64::Engine as _;
6use sha2::{Digest as _, Sha256};
7
8use crate::ids::{AgentPubkey, NetworkId};
9
10/// Wire-format prefix: every challenge starts with these 11 ASCII bytes.
11pub const POW_PREFIX: &[u8] = b"parley-pow:";
12
13/// Current challenge serialization version.
14pub const POW_VERSION: u8 = 1;
15
16/// Operator-recommended cap on nonce length. Anything past ~32 bytes is
17/// suspicious (and irrelevant — a satisfying nonce of any length works).
18pub const DEFAULT_MAX_NONCE_BYTES: usize = 64;
19
20/// Build the canonical challenge bytes per spec §3.2.
21///
22/// Layout (no JSON, no canonicalization):
23/// ```text
24/// "parley-pow:" | version_u8 | network_len_u8 | network_bytes | pubkey(32) | difficulty_u8
25/// ```
26#[must_use]
27pub fn challenge_bytes(
28    version: u8,
29    network: &NetworkId,
30    pubkey: &AgentPubkey,
31    difficulty: u8,
32) -> Vec<u8> {
33    let n = network.as_str().as_bytes();
34    let n_len = u8::try_from(n.len()).unwrap_or(u8::MAX);
35    let mut out = Vec::with_capacity(POW_PREFIX.len() + 1 + 1 + n.len() + 32 + 1);
36    out.extend_from_slice(POW_PREFIX);
37    out.push(version);
38    out.push(n_len);
39    out.extend_from_slice(n);
40    out.extend_from_slice(pubkey.as_bytes());
41    out.push(difficulty);
42    out
43}
44
45/// Count leading zero bits of the SHA-256 digest of `challenge || nonce`.
46#[must_use]
47pub fn leading_zero_bits_of_hash(challenge: &[u8], nonce: &[u8]) -> u32 {
48    let mut h = Sha256::new();
49    h.update(challenge);
50    h.update(nonce);
51    let digest = h.finalize();
52    let mut bits = 0u32;
53    for byte in digest.iter() {
54        if *byte == 0 {
55            bits += 8;
56        } else {
57            bits += byte.leading_zeros();
58            break;
59        }
60    }
61    bits
62}
63
64#[derive(Debug, thiserror::Error)]
65pub enum PowVerifyError {
66    #[error("nonce too long: {actual} > {max}")]
67    NonceTooLong { actual: usize, max: usize },
68    #[error("difficulty mismatch: submitted {submitted}, server {server}")]
69    DifficultyMismatch { submitted: u8, server: u8 },
70    #[error("version unsupported: {0}")]
71    VersionUnsupported(u8),
72    #[error("hash does not satisfy difficulty: got {got} leading zero bits, need {need}")]
73    Insufficient { got: u32, need: u32 },
74}
75
76/// Server-side verification, matching spec §3.4.
77///
78/// Caller passes the server's *current* version + difficulty + nonce cap;
79/// this function does ALL the spec-required checks.
80#[allow(clippy::too_many_arguments)]
81pub fn verify(
82    server_version: u8,
83    server_difficulty: u8,
84    max_nonce_bytes: usize,
85    submitted_version: u8,
86    submitted_difficulty: u8,
87    network: &NetworkId,
88    pubkey: &AgentPubkey,
89    nonce: &[u8],
90) -> Result<(), PowVerifyError> {
91    if submitted_version != server_version {
92        return Err(PowVerifyError::VersionUnsupported(submitted_version));
93    }
94    if submitted_difficulty != server_difficulty {
95        return Err(PowVerifyError::DifficultyMismatch {
96            submitted: submitted_difficulty,
97            server: server_difficulty,
98        });
99    }
100    if nonce.len() > max_nonce_bytes {
101        return Err(PowVerifyError::NonceTooLong {
102            actual: nonce.len(),
103            max: max_nonce_bytes,
104        });
105    }
106    let challenge = challenge_bytes(server_version, network, pubkey, server_difficulty);
107    let bits = leading_zero_bits_of_hash(&challenge, nonce);
108    let need = u32::from(server_difficulty);
109    if bits < need {
110        return Err(PowVerifyError::Insufficient { got: bits, need });
111    }
112    Ok(())
113}
114
115/// Client-side solver: find a nonce satisfying the challenge.
116///
117/// Single-threaded, counter-based. For default difficulty 20 this is
118/// ~1 second on modern CPUs. Calls `progress(attempts)` periodically
119/// so the CLI can show feedback during longer solves.
120///
121/// Returns the satisfying nonce.
122pub fn solve(
123    version: u8,
124    network: &NetworkId,
125    pubkey: &AgentPubkey,
126    difficulty: u8,
127    mut progress: impl FnMut(u64),
128) -> Vec<u8> {
129    let challenge = challenge_bytes(version, network, pubkey, difficulty);
130    let need = u32::from(difficulty);
131    let mut nonce: u64 = 0;
132    loop {
133        let nonce_bytes = nonce.to_be_bytes();
134        let bits = leading_zero_bits_of_hash(&challenge, &nonce_bytes);
135        if bits >= need {
136            return nonce_bytes.to_vec();
137        }
138        nonce = nonce.wrapping_add(1);
139        if nonce.is_multiple_of(50_000) {
140            progress(nonce);
141        }
142    }
143}
144
145/// Convenience: encode a nonce for the wire (base64url-no-pad).
146#[must_use]
147pub fn encode_nonce(nonce: &[u8]) -> String {
148    URL_SAFE_NO_PAD.encode(nonce)
149}
150
151/// Convenience: decode a wire nonce. Returns the raw bytes.
152pub fn decode_nonce(nonce_b64: &str) -> Result<Vec<u8>, base64::DecodeError> {
153    URL_SAFE_NO_PAD.decode(nonce_b64)
154}
155
156#[cfg(test)]
157#[allow(clippy::unwrap_used, clippy::expect_used)]
158mod tests {
159    use super::*;
160
161    fn pk() -> AgentPubkey {
162        AgentPubkey::from_bytes([7u8; 32])
163    }
164
165    fn net() -> NetworkId {
166        NetworkId::new("parley-test").unwrap()
167    }
168
169    #[test]
170    fn challenge_layout_is_deterministic() {
171        let a = challenge_bytes(1, &net(), &pk(), 8);
172        let b = challenge_bytes(1, &net(), &pk(), 8);
173        assert_eq!(a, b);
174        assert!(a.starts_with(POW_PREFIX));
175        assert_eq!(a[POW_PREFIX.len()], 1);
176        assert_eq!(a[POW_PREFIX.len() + 1] as usize, b"parley-test".len());
177        // last byte is difficulty
178        assert_eq!(*a.last().unwrap(), 8);
179    }
180
181    #[test]
182    fn solve_then_verify_roundtrip_low_difficulty() {
183        let nonce = solve(1, &net(), &pk(), 8, |_| {});
184        verify(1, 8, 64, 1, 8, &net(), &pk(), &nonce).expect("should verify");
185    }
186
187    #[test]
188    fn verify_rejects_wrong_version() {
189        let nonce = solve(1, &net(), &pk(), 8, |_| {});
190        let err = verify(1, 8, 64, 2, 8, &net(), &pk(), &nonce).unwrap_err();
191        assert!(matches!(err, PowVerifyError::VersionUnsupported(2)));
192    }
193
194    #[test]
195    fn verify_rejects_difficulty_mismatch() {
196        let nonce = solve(1, &net(), &pk(), 8, |_| {});
197        let err = verify(1, 12, 64, 1, 8, &net(), &pk(), &nonce).unwrap_err();
198        assert!(matches!(err, PowVerifyError::DifficultyMismatch { .. }));
199    }
200
201    #[test]
202    fn verify_rejects_overlong_nonce() {
203        let big_nonce = vec![0u8; 100];
204        let err = verify(1, 8, 64, 1, 8, &net(), &pk(), &big_nonce).unwrap_err();
205        assert!(matches!(err, PowVerifyError::NonceTooLong { .. }));
206    }
207
208    #[test]
209    fn verify_rejects_garbage_nonce() {
210        let err = verify(1, 8, 64, 1, 8, &net(), &pk(), &[0u8; 8]).unwrap_err();
211        assert!(matches!(err, PowVerifyError::Insufficient { .. }));
212    }
213
214    #[test]
215    fn nonce_round_trips_through_b64() {
216        let n = vec![0xab, 0xcd, 0xef];
217        let s = encode_nonce(&n);
218        let back = decode_nonce(&s).unwrap();
219        assert_eq!(back, n);
220    }
221}