Skip to main content

beam/sea/
work.rs

1//! Proof of Work and content hashing — Gun.js `sea/work.js` equivalent.
2//!
3//! Provides two modes of cryptographic hashing:
4//!
5//! - **PBKDF2 mode** (default): Key derivation using PBKDF2-HMAC-SHA256
6//!   with 100,000 iterations and a random 9-byte salt (matching Gun.js).
7//!   Used for password hashing and key derivation.
8//!
9//! - **SHA-256 mode**: Direct SHA-256 hash of input data. Triggered when
10//!   `WorkOptions::name` starts with `"sha"` (case-insensitive).
11//!
12//! # Blocking
13//!
14//! PBKDF2 is CPU-intensive and runs via [`tokio::task::spawn_blocking`].
15//!
16//! # Example
17//!
18//! ```no_run
19//! use beam::sea::{work, WorkOptions};
20//!
21//! # tokio::runtime::Runtime::new().unwrap().block_on(async {
22//! let hash = work(b"password", None, WorkOptions::default()).await.unwrap();
23//! assert!(!hash.is_empty());
24//! # });
25//! ```
26
27use super::{SeaError, WorkOptions};
28use base64::prelude::*;
29use pbkdf2::pbkdf2_hmac;
30use rand::RngCore;
31use sha2::{Digest, Sha256};
32use std::sync::Arc;
33
34/// Compute proof-of-work or content hash.
35///
36/// # PBKDF2 Mode (default)
37///
38/// - Algorithm: PBKDF2-HMAC-SHA256
39/// - Iterations: 100,000 (configurable via `WorkOptions::iterations`)
40/// - Salt: random 9 bytes if not provided (matching Gun.js)
41/// - Output: base64-encoded derived key
42///
43/// # SHA-256 Mode
44///
45/// - Triggered when `WorkOptions::name` starts with `"sha"` (case-insensitive)
46/// - Direct SHA-256 hash of input data
47/// - Output: base64-encoded hash
48///
49/// # Arguments
50///
51/// * `data` — Input bytes to hash
52/// * `salt` — Optional salt (PBKDF2 mode only). If `None`, uses `WorkOptions::salt`
53///   or generates a random 9-byte salt.
54/// * `opts` — Configuration (see [`WorkOptions`])
55///
56/// # Errors
57///
58/// Returns [`SeaError::Crypto`] on task join failure or internal error.
59pub async fn work(data: &[u8], salt: Option<&[u8]>, opts: WorkOptions) -> Result<String, SeaError> {
60    let opts = Arc::new(opts);
61    let data = data.to_vec();
62
63    // Check if SHA-256 mode
64    let name_lower = opts
65        .name
66        .as_ref()
67        .map(|n| n.to_lowercase())
68        .unwrap_or_else(|| "pbkdf2".to_string());
69
70    if name_lower.starts_with("sha") {
71        // SHA-256 hashing mode
72        return tokio::task::spawn_blocking(move || {
73            let mut hasher = Sha256::new();
74            hasher.update(&data);
75            let hash = hasher.finalize();
76            let encoded = BASE64_URL_SAFE_NO_PAD.encode(&hash[..]);
77            Ok(encoded)
78        })
79        .await
80        .map_err(|e| SeaError::Crypto(format!("task join error: {}", e)))?;
81    }
82
83    // PBKDF2 key derivation mode (default)
84    let salt = if let Some(s) = salt {
85        s.to_vec()
86    } else if let Some(ref opt_salt) = opts.salt {
87        opt_salt.clone()
88    } else {
89        // Generate random 9-byte salt (matching Gun.js)
90        let mut salt_bytes = vec![0u8; 9];
91        rand::rng().fill_bytes(&mut salt_bytes);
92        salt_bytes
93    };
94
95    let iterations = opts.iterations.unwrap_or(100_000);
96    let length_bits = opts.length.unwrap_or(512);
97    let length_bytes = length_bits / 8;
98
99    // Perform PBKDF2 in blocking task (CPU-intensive)
100    let result = tokio::task::spawn_blocking(move || {
101        let mut output = vec![0u8; length_bytes];
102        pbkdf2_hmac::<Sha256>(&data, &salt, iterations, &mut output);
103        output
104    })
105    .await
106    .map_err(|e| SeaError::Crypto(format!("task join error: {}", e)))?;
107
108    // Encode result as base64
109    let encoded = BASE64_URL_SAFE_NO_PAD.encode(&result);
110    Ok(encoded)
111}
112
113#[cfg(test)]
114mod tests {
115    use super::*;
116
117    #[tokio::test]
118    async fn test_work_pbkdf2_default() {
119        let result = work(b"password", None, WorkOptions::default())
120            .await
121            .unwrap();
122        assert!(!result.is_empty());
123        // 512 bits = 64 bytes = 86 chars base64 no-pad
124        assert_eq!(result.len(), 86);
125    }
126
127    #[tokio::test]
128    async fn test_work_sha256_mode() {
129        let opts = WorkOptions {
130            name: Some("SHA-256".to_string()),
131            ..Default::default()
132        };
133        let result = work(b"data", None, opts).await.unwrap();
134        // SHA-256 = 32 bytes = 43 chars base64 no-pad
135        assert_eq!(result.len(), 43);
136    }
137
138    #[tokio::test]
139    async fn test_work_sha256_deterministic() {
140        let opts = WorkOptions {
141            name: Some("sha".to_string()),
142            ..Default::default()
143        };
144        let a = work(b"same input", None, opts.clone()).await.unwrap();
145        let b = work(b"same input", None, opts).await.unwrap();
146        assert_eq!(a, b, "SHA-256 should be deterministic for same input");
147    }
148
149    #[tokio::test]
150    async fn test_work_pbkdf2_different_salt_different_output() {
151        let opts = WorkOptions::default();
152        let a = work(b"password", Some(b"salt_a"), opts.clone())
153            .await
154            .unwrap();
155        let b = work(b"password", Some(b"salt_b"), opts).await.unwrap();
156        assert_ne!(a, b, "different salts should produce different outputs");
157    }
158
159    #[tokio::test]
160    async fn test_work_pbkdf2_same_salt_same_output() {
161        let opts = WorkOptions::default();
162        let a = work(b"password", Some(b"same_salt"), opts.clone())
163            .await
164            .unwrap();
165        let b = work(b"password", Some(b"same_salt"), opts).await.unwrap();
166        assert_eq!(a, b, "same salt should produce same output");
167    }
168
169    #[tokio::test]
170    async fn test_work_custom_iterations() {
171        let opts = WorkOptions {
172            iterations: Some(100),
173            ..Default::default()
174        };
175        let result = work(b"password", Some(b"salt"), opts).await.unwrap();
176        assert!(!result.is_empty());
177    }
178
179    #[tokio::test]
180    async fn test_work_empty_data() {
181        let result = work(b"", Some(b"salt"), WorkOptions::default())
182            .await
183            .unwrap();
184        assert!(!result.is_empty());
185    }
186
187    #[tokio::test]
188    async fn test_work_sha256_known_value() {
189        // SHA-256 of empty string = e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855
190        let opts = WorkOptions {
191            name: Some("sha".to_string()),
192            ..Default::default()
193        };
194        let result = work(b"", None, opts).await.unwrap();
195        // base64 no-pad of the known SHA-256 of empty string
196        let expected = "47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU";
197        assert_eq!(result, expected);
198    }
199}