parley-core 0.1.1

Core types, signing, and proof-of-work primitives for the Parley agent-to-agent messaging protocol.
Documentation
//! Hashcash-style proof-of-work for identity registration.
//! Spec: `spec/v0.5.md` §3.

use base64::engine::general_purpose::URL_SAFE_NO_PAD;
use base64::Engine as _;
use sha2::{Digest as _, Sha256};

use crate::ids::{AgentPubkey, NetworkId};

/// Wire-format prefix: every challenge starts with these 11 ASCII bytes.
pub const POW_PREFIX: &[u8] = b"parley-pow:";

/// Current challenge serialization version.
pub const POW_VERSION: u8 = 1;

/// Operator-recommended cap on nonce length. Anything past ~32 bytes is
/// suspicious (and irrelevant — a satisfying nonce of any length works).
pub const DEFAULT_MAX_NONCE_BYTES: usize = 64;

/// Build the canonical challenge bytes per spec §3.2.
///
/// Layout (no JSON, no canonicalization):
/// ```text
/// "parley-pow:" | version_u8 | network_len_u8 | network_bytes | pubkey(32) | difficulty_u8
/// ```
#[must_use]
pub fn challenge_bytes(
    version: u8,
    network: &NetworkId,
    pubkey: &AgentPubkey,
    difficulty: u8,
) -> Vec<u8> {
    let n = network.as_str().as_bytes();
    let n_len = u8::try_from(n.len()).unwrap_or(u8::MAX);
    let mut out = Vec::with_capacity(POW_PREFIX.len() + 1 + 1 + n.len() + 32 + 1);
    out.extend_from_slice(POW_PREFIX);
    out.push(version);
    out.push(n_len);
    out.extend_from_slice(n);
    out.extend_from_slice(pubkey.as_bytes());
    out.push(difficulty);
    out
}

/// Count leading zero bits of the SHA-256 digest of `challenge || nonce`.
#[must_use]
pub fn leading_zero_bits_of_hash(challenge: &[u8], nonce: &[u8]) -> u32 {
    let mut h = Sha256::new();
    h.update(challenge);
    h.update(nonce);
    let digest = h.finalize();
    let mut bits = 0u32;
    for byte in digest.iter() {
        if *byte == 0 {
            bits += 8;
        } else {
            bits += byte.leading_zeros();
            break;
        }
    }
    bits
}

#[derive(Debug, thiserror::Error)]
pub enum PowVerifyError {
    #[error("nonce too long: {actual} > {max}")]
    NonceTooLong { actual: usize, max: usize },
    #[error("difficulty mismatch: submitted {submitted}, server {server}")]
    DifficultyMismatch { submitted: u8, server: u8 },
    #[error("version unsupported: {0}")]
    VersionUnsupported(u8),
    #[error("hash does not satisfy difficulty: got {got} leading zero bits, need {need}")]
    Insufficient { got: u32, need: u32 },
}

/// Server-side verification, matching spec §3.4.
///
/// Caller passes the server's *current* version + difficulty + nonce cap;
/// this function does ALL the spec-required checks.
pub fn verify(
    server_version: u8,
    server_difficulty: u8,
    max_nonce_bytes: usize,
    submitted_version: u8,
    submitted_difficulty: u8,
    network: &NetworkId,
    pubkey: &AgentPubkey,
    nonce: &[u8],
) -> Result<(), PowVerifyError> {
    if submitted_version != server_version {
        return Err(PowVerifyError::VersionUnsupported(submitted_version));
    }
    if submitted_difficulty != server_difficulty {
        return Err(PowVerifyError::DifficultyMismatch {
            submitted: submitted_difficulty,
            server: server_difficulty,
        });
    }
    if nonce.len() > max_nonce_bytes {
        return Err(PowVerifyError::NonceTooLong {
            actual: nonce.len(),
            max: max_nonce_bytes,
        });
    }
    let challenge = challenge_bytes(server_version, network, pubkey, server_difficulty);
    let bits = leading_zero_bits_of_hash(&challenge, nonce);
    let need = u32::from(server_difficulty);
    if bits < need {
        return Err(PowVerifyError::Insufficient { got: bits, need });
    }
    Ok(())
}

/// Client-side solver: find a nonce satisfying the challenge.
///
/// Single-threaded, counter-based. For default difficulty 20 this is
/// ~1 second on modern CPUs. Calls `progress(attempts)` periodically
/// so the CLI can show feedback during longer solves.
///
/// Returns the satisfying nonce.
pub fn solve(
    version: u8,
    network: &NetworkId,
    pubkey: &AgentPubkey,
    difficulty: u8,
    mut progress: impl FnMut(u64),
) -> Vec<u8> {
    let challenge = challenge_bytes(version, network, pubkey, difficulty);
    let need = u32::from(difficulty);
    let mut nonce: u64 = 0;
    loop {
        let nonce_bytes = nonce.to_be_bytes();
        let bits = leading_zero_bits_of_hash(&challenge, &nonce_bytes);
        if bits >= need {
            return nonce_bytes.to_vec();
        }
        nonce = nonce.wrapping_add(1);
        if nonce.is_multiple_of(50_000) {
            progress(nonce);
        }
    }
}

/// Convenience: encode a nonce for the wire (base64url-no-pad).
#[must_use]
pub fn encode_nonce(nonce: &[u8]) -> String {
    URL_SAFE_NO_PAD.encode(nonce)
}

/// Convenience: decode a wire nonce. Returns the raw bytes.
pub fn decode_nonce(nonce_b64: &str) -> Result<Vec<u8>, base64::DecodeError> {
    URL_SAFE_NO_PAD.decode(nonce_b64)
}

#[cfg(test)]
#[allow(clippy::unwrap_used, clippy::expect_used)]
mod tests {
    use super::*;

    fn pk() -> AgentPubkey {
        AgentPubkey::from_bytes([7u8; 32])
    }

    fn net() -> NetworkId {
        NetworkId::new("parley-test").unwrap()
    }

    #[test]
    fn challenge_layout_is_deterministic() {
        let a = challenge_bytes(1, &net(), &pk(), 8);
        let b = challenge_bytes(1, &net(), &pk(), 8);
        assert_eq!(a, b);
        assert!(a.starts_with(POW_PREFIX));
        assert_eq!(a[POW_PREFIX.len()], 1);
        assert_eq!(a[POW_PREFIX.len() + 1] as usize, b"parley-test".len());
        // last byte is difficulty
        assert_eq!(*a.last().unwrap(), 8);
    }

    #[test]
    fn solve_then_verify_roundtrip_low_difficulty() {
        let nonce = solve(1, &net(), &pk(), 8, |_| {});
        verify(1, 8, 64, 1, 8, &net(), &pk(), &nonce).expect("should verify");
    }

    #[test]
    fn verify_rejects_wrong_version() {
        let nonce = solve(1, &net(), &pk(), 8, |_| {});
        let err = verify(1, 8, 64, 2, 8, &net(), &pk(), &nonce).unwrap_err();
        assert!(matches!(err, PowVerifyError::VersionUnsupported(2)));
    }

    #[test]
    fn verify_rejects_difficulty_mismatch() {
        let nonce = solve(1, &net(), &pk(), 8, |_| {});
        let err = verify(1, 12, 64, 1, 8, &net(), &pk(), &nonce).unwrap_err();
        assert!(matches!(err, PowVerifyError::DifficultyMismatch { .. }));
    }

    #[test]
    fn verify_rejects_overlong_nonce() {
        let big_nonce = vec![0u8; 100];
        let err = verify(1, 8, 64, 1, 8, &net(), &pk(), &big_nonce).unwrap_err();
        assert!(matches!(err, PowVerifyError::NonceTooLong { .. }));
    }

    #[test]
    fn verify_rejects_garbage_nonce() {
        let err = verify(1, 8, 64, 1, 8, &net(), &pk(), &[0u8; 8]).unwrap_err();
        assert!(matches!(err, PowVerifyError::Insufficient { .. }));
    }

    #[test]
    fn nonce_round_trips_through_b64() {
        let n = vec![0xab, 0xcd, 0xef];
        let s = encode_nonce(&n);
        let back = decode_nonce(&s).unwrap();
        assert_eq!(back, n);
    }
}