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