use base64::Engine as _;
use base64::engine::general_purpose::URL_SAFE_NO_PAD;
pub const POW_CONTEXT: &str = "galata-vault v1 proof-of-work";
pub const MAX_DIFFICULTY: u8 = 40;
pub const CHALLENGE_TTL_SECS: i64 = 600;
const PAYLOAD_LEN: usize = 16 + 8 + 1;
const TAG_LEN: usize = 16;
#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
#[non_exhaustive]
pub enum PowError {
#[error("the challenge is malformed")]
Malformed,
#[error("the challenge was not issued by this server")]
Tampered,
#[error("the challenge has expired")]
Expired,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Challenge {
pub id: [u8; 16],
pub expires_at: i64,
pub difficulty: u8,
}
impl Challenge {
fn payload(&self) -> [u8; PAYLOAD_LEN] {
let mut p = [0u8; PAYLOAD_LEN];
p[..16].copy_from_slice(&self.id);
p[16..24].copy_from_slice(&self.expires_at.to_be_bytes());
p[24] = self.difficulty;
p
}
pub fn issue(&self, server_key: &[u8; 32]) -> String {
let payload = self.payload();
let tag = blake3::keyed_hash(server_key, &payload);
let mut token = Vec::with_capacity(PAYLOAD_LEN + TAG_LEN);
token.extend_from_slice(&payload);
token.extend_from_slice(&tag.as_bytes()[..TAG_LEN]);
URL_SAFE_NO_PAD.encode(token)
}
pub fn open(token: &str, server_key: &[u8; 32], now: i64) -> Result<Challenge, PowError> {
let raw = URL_SAFE_NO_PAD
.decode(token.as_bytes())
.map_err(|_| PowError::Malformed)?;
if raw.len() != PAYLOAD_LEN + TAG_LEN {
return Err(PowError::Malformed);
}
let (payload, tag) = raw.split_at(PAYLOAD_LEN);
let expected = blake3::keyed_hash(server_key, payload);
let diff = expected.as_bytes()[..TAG_LEN]
.iter()
.zip(tag)
.fold(0u8, |acc, (a, b)| acc | (a ^ b));
if diff != 0 {
return Err(PowError::Tampered);
}
let Some((id, rest)) = payload.split_first_chunk::<16>() else {
return Err(PowError::Malformed);
};
let Some((expires_at, rest)) = rest.split_first_chunk::<8>() else {
return Err(PowError::Malformed);
};
let Some(&difficulty) = rest.first() else {
return Err(PowError::Malformed);
};
let (id, expires_at) = (*id, i64::from_be_bytes(*expires_at));
if difficulty > MAX_DIFFICULTY {
return Err(PowError::Malformed);
}
if now > expires_at {
return Err(PowError::Expired);
}
Ok(Challenge {
id,
expires_at,
difficulty,
})
}
}
pub fn pow_hash(token: &str, nonce: u64) -> [u8; 32] {
let mut h = blake3::Hasher::new_derive_key(POW_CONTEXT);
h.update(token.as_bytes());
h.update(&nonce.to_le_bytes());
*h.finalize().as_bytes()
}
pub fn leading_zero_bits(hash: &[u8; 32]) -> u32 {
let mut bits = 0;
for &b in hash {
if b == 0 {
bits += 8;
} else {
return bits + b.leading_zeros();
}
}
bits
}
pub fn verify(token: &str, difficulty: u8, nonce: u64) -> bool {
leading_zero_bits(&pow_hash(token, nonce)) >= u32::from(difficulty)
}
pub fn solve(token: &str, difficulty: u8) -> Result<u64, PowError> {
if difficulty > MAX_DIFFICULTY {
return Err(PowError::Malformed);
}
(0..=u64::MAX)
.find(|&nonce| verify(token, difficulty, nonce))
.ok_or(PowError::Malformed)
}
#[cfg(test)]
mod tests {
use super::*;
const KEY: [u8; 32] = [7; 32];
fn challenge(difficulty: u8) -> Challenge {
Challenge {
id: [3; 16],
expires_at: 1_000,
difficulty,
}
}
#[test]
fn issue_open_roundtrip() {
let c = challenge(12);
let token = c.issue(&KEY);
assert_eq!(Challenge::open(&token, &KEY, 999).unwrap(), c);
}
#[test]
fn expired_tampered_malformed() {
let token = challenge(12).issue(&KEY);
assert_eq!(Challenge::open(&token, &KEY, 1_001), Err(PowError::Expired));
assert_eq!(
Challenge::open(&token, &[8; 32], 0),
Err(PowError::Tampered)
);
let mut raw = URL_SAFE_NO_PAD.decode(&token).unwrap();
raw[24] = 1;
let forged = URL_SAFE_NO_PAD.encode(raw);
assert_eq!(Challenge::open(&forged, &KEY, 0), Err(PowError::Tampered));
assert_eq!(Challenge::open("!!", &KEY, 0), Err(PowError::Malformed));
let absurd = challenge(MAX_DIFFICULTY + 1).issue(&KEY);
assert_eq!(Challenge::open(&absurd, &KEY, 0), Err(PowError::Malformed));
}
#[test]
fn solve_then_verify() {
let token = challenge(10).issue(&KEY);
let nonce = solve(&token, 10).unwrap();
assert!(verify(&token, 10, nonce));
let other = challenge(10).issue(&[9; 32]);
let other_nonce = solve(&other, 10).unwrap();
assert!(verify(&other, 10, other_nonce));
assert!(verify(&token, 0, 12345), "difficulty 0 always verifies");
}
#[test]
fn leading_zero_count() {
let mut h = [0u8; 32];
assert_eq!(leading_zero_bits(&h), 256);
h[1] = 0b0001_0000;
assert_eq!(leading_zero_bits(&h), 11);
}
}