Skip to main content

p47h_engine/
client.rs

1use crate::types::AuthDecision;
2use crate::utils::parse_resource;
3use core_identity::Identity;
4use core_policy::{Action, Policy};
5use secrecy::ExposeSecret;
6use serde::Deserialize;
7use wasm_bindgen::prelude::*;
8
9/// WASM-compatible client managing Identity lifecycle and local policy evaluation.
10///
11/// Handles key generation, secure wrapped export (ChaCha20Poly1305), and signing
12/// operations. Private keys remain within WASM linear memory unless explicitly exported.
13#[wasm_bindgen]
14pub struct P47hClient {
15    /// Cryptographic identity (Ed25519 keypair)
16    /// Private - never exposed directly to JavaScript
17    identity: Identity,
18}
19
20#[wasm_bindgen]
21impl P47hClient {
22    // ========================================
23    // AUTHENTICATION (Identity Management)
24    // ========================================
25
26    /// Generates a new cryptographic identity using browser's secure random source
27    #[wasm_bindgen(constructor)]
28    pub fn generate_new() -> Result<P47hClient, JsValue> {
29        let mut rng = rand::thread_rng();
30        let identity = Identity::generate(&mut rng)
31            .map_err(|e| JsValue::from_str(&format!("Failed to generate identity: {}", e)))?;
32
33        Ok(P47hClient { identity })
34    }
35
36    /// Reconstructs identity from previously exported secret bytes
37    #[wasm_bindgen]
38    pub fn from_secret(secret_bytes: &[u8]) -> Result<P47hClient, JsValue> {
39        if secret_bytes.len() != 32 {
40            return Err(JsValue::from_str("Secret must be exactly 32 bytes"));
41        }
42
43        let mut key_bytes = [0u8; 32];
44        key_bytes.copy_from_slice(secret_bytes);
45
46        let identity = Identity::from_bytes(&key_bytes)
47            .map_err(|e| JsValue::from_str(&format!("Failed to import identity: {}", e)))?;
48
49        Ok(P47hClient { identity })
50    }
51
52    /// Exports the private key encrypted with ChaCha20Poly1305
53    ///
54    /// # Security
55    ///
56    /// This method encrypts the private key using ChaCha20Poly1305 AEAD cipher
57    /// before exporting it. The caller must provide a 32-byte session key.
58    ///
59    /// # Arguments
60    ///
61    /// * `session_key` - A 32-byte key derived from user password or other secure source
62    ///
63    /// # Returns
64    ///
65    /// Returns a byte array containing: [nonce(12 bytes) || ciphertext || tag(16 bytes)]
66    ///
67    /// # Example
68    ///
69    /// ```javascript
70    /// // Derive session key from password using PBKDF2 or similar
71    /// const sessionKey = await deriveKey(password);
72    /// const wrapped = client.export_wrapped_secret(sessionKey);
73    /// // Store wrapped securely in IndexedDB
74    /// ```
75    #[wasm_bindgen]
76    pub fn export_wrapped_secret(&self, session_key: &[u8]) -> Result<Vec<u8>, JsValue> {
77        use chacha20poly1305::{
78            aead::{Aead, KeyInit},
79            ChaCha20Poly1305, Nonce,
80        };
81
82        if session_key.len() != 32 {
83            return Err(JsValue::from_str("Session key must be exactly 32 bytes"));
84        }
85
86        let cipher = ChaCha20Poly1305::new_from_slice(session_key)
87            .map_err(|e| JsValue::from_str(&format!("Invalid session key: {}", e)))?;
88
89        // Generate random nonce
90        let mut nonce_bytes = [0u8; 12];
91        use rand::RngCore;
92        rand::thread_rng().fill_bytes(&mut nonce_bytes);
93        let nonce = Nonce::from_slice(&nonce_bytes);
94
95        // Encrypt the private key
96        let signing_key = self.identity.signing_key_bytes();
97        let secret = signing_key.expose_secret();
98        let ciphertext = cipher
99            .encrypt(nonce, secret.as_ref())
100            .map_err(|e| JsValue::from_str(&format!("Encryption failed: {}", e)))?;
101
102        // Return [nonce || ciphertext]
103        let mut result = Vec::with_capacity(12 + ciphertext.len());
104        result.extend_from_slice(&nonce_bytes);
105        result.extend_from_slice(&ciphertext);
106
107        Ok(result)
108    }
109
110    /// Imports identity from encrypted secret
111    ///
112    /// # Arguments
113    ///
114    /// * `wrapped` - The encrypted secret from `export_wrapped_secret`
115    /// * `session_key` - The same 32-byte key used for encryption
116    ///
117    /// # Returns
118    ///
119    /// Returns a new `P47hClient` instance with the decrypted identity
120    ///
121    /// # Example
122    ///
123    /// ```javascript
124    /// const sessionKey = await deriveKey(password);
125    /// const client = P47hClient.from_wrapped_secret(wrapped, sessionKey);
126    /// ```
127    #[wasm_bindgen]
128    pub fn from_wrapped_secret(wrapped: &[u8], session_key: &[u8]) -> Result<P47hClient, JsValue> {
129        use chacha20poly1305::{
130            aead::{Aead, KeyInit},
131            ChaCha20Poly1305, Nonce,
132        };
133
134        if wrapped.len() < 12 + 32 + 16 {
135            return Err(JsValue::from_str(
136                "Invalid wrapped secret: too short (expected at least 60 bytes)",
137            ));
138        }
139
140        if session_key.len() != 32 {
141            return Err(JsValue::from_str("Session key must be exactly 32 bytes"));
142        }
143
144        let cipher = ChaCha20Poly1305::new_from_slice(session_key)
145            .map_err(|e| JsValue::from_str(&format!("Invalid session key: {}", e)))?;
146
147        let nonce = Nonce::from_slice(&wrapped[..12]);
148        let ciphertext = &wrapped[12..];
149
150        let plaintext = cipher.decrypt(nonce, ciphertext).map_err(|_| {
151            JsValue::from_str("Decryption failed: wrong password or corrupted data")
152        })?;
153
154        Self::from_secret(&plaintext)
155    }
156
157    /// Returns the Decentralized Identifier (DID) for this identity
158    #[wasm_bindgen]
159    pub fn get_did(&self) -> String {
160        let verifying_key = self.identity.verifying_key();
161        let pub_key_bytes = verifying_key.as_bytes();
162        format!("did:p47h:{}", hex::encode(pub_key_bytes))
163    }
164
165    /// Returns the raw public key bytes (for advanced use cases)
166    #[wasm_bindgen]
167    pub fn get_public_key(&self) -> Vec<u8> {
168        self.identity.verifying_key().as_bytes().to_vec()
169    }
170
171    /// Signs a challenge for authentication with the server
172    #[wasm_bindgen]
173    pub fn sign_challenge(&self, challenge: &[u8]) -> Vec<u8> {
174        self.identity.sign(challenge).to_bytes().to_vec()
175    }
176
177    /// Signs arbitrary data (for advanced use cases)
178    #[wasm_bindgen]
179    pub fn sign_data(&self, data: &[u8]) -> Vec<u8> {
180        self.identity.sign(data).to_bytes().to_vec()
181    }
182
183    // ========================================
184    // AUTHORIZATION (Policy Evaluation)
185    // ========================================
186
187    /// Evaluates a policy request locally without server round-trip
188    #[wasm_bindgen]
189    pub fn evaluate_request(
190        &self,
191        policy_toml: &str,
192        resource: &str,
193        action: &str,
194    ) -> Result<JsValue, JsValue> {
195        let start = web_sys::window()
196            .and_then(|w| w.performance())
197            .map(|p| p.now())
198            .unwrap_or(0.0);
199
200        // Parse policy from TOML
201        let policy: Policy = toml::from_str(policy_toml)
202            .map_err(|e| JsValue::from_str(&format!("Invalid policy TOML: {}", e)))?;
203
204        // Get our DID as the principal
205        let did = self.get_did();
206
207        // Convert string action to Action enum
208        let action_enum = match action.to_lowercase().as_str() {
209            "read" => Action::Read,
210            "write" => Action::Write,
211            "execute" => Action::Execute,
212            "delete" => Action::Delete,
213            "all" | "*" => Action::All,
214            custom => Action::Custom(custom.to_string()),
215        };
216
217        // Convert string resource to Resource enum
218        let resource_enum = parse_resource(resource);
219
220        // Evaluate policy
221        let allowed = policy.is_allowed(&did, &action_enum, &resource_enum);
222
223        let end = web_sys::window()
224            .and_then(|w| w.performance())
225            .map(|p| p.now())
226            .unwrap_or(0.0);
227
228        // Convert milliseconds to microseconds (f64 -> u64)
229        // performance.now() returns milliseconds, multiply by 1000 for microseconds
230        let evaluation_time_us = ((end - start) * 1000.0) as u64;
231
232        // Build detailed reasons
233        let reasons = if allowed {
234            vec![format!(
235                "ALLOWED: {} access to {} granted by policy",
236                action, resource
237            )]
238        } else {
239            vec![format!(
240                "DENIED: {} access to {} denied by policy",
241                action, resource
242            )]
243        };
244
245        let decision = AuthDecision {
246            allowed,
247            reasons,
248            evaluation_time_us,
249        };
250
251        serde_wasm_bindgen::to_value(&decision)
252            .map_err(|e| JsValue::from_str(&format!("Serialization error: {}", e)))
253    }
254
255    /// Batch evaluation of multiple requests (more efficient)
256    #[wasm_bindgen]
257    pub fn evaluate_batch(
258        &self,
259        policy_toml: &str,
260        requests_json: &str,
261    ) -> Result<JsValue, JsValue> {
262        #[derive(Deserialize)]
263        struct BatchRequest {
264            resource: String,
265            action: String,
266        }
267
268        let start = web_sys::window()
269            .and_then(|w| w.performance())
270            .map(|p| p.now())
271            .unwrap_or(0.0);
272
273        // Parse policy once
274        let policy: Policy = toml::from_str(policy_toml)
275            .map_err(|e| JsValue::from_str(&format!("Invalid policy TOML: {}", e)))?;
276
277        // Parse requests
278        let requests: Vec<BatchRequest> = serde_json::from_str(requests_json)
279            .map_err(|e| JsValue::from_str(&format!("Invalid requests JSON: {}", e)))?;
280
281        let did = self.get_did();
282        let mut decisions = Vec::with_capacity(requests.len());
283
284        // Evaluate each request
285        for req in requests {
286            let action_enum = match req.action.to_lowercase().as_str() {
287                "read" => Action::Read,
288                "write" => Action::Write,
289                "execute" => Action::Execute,
290                "delete" => Action::Delete,
291                "all" | "*" => Action::All,
292                custom => Action::Custom(custom.to_string()),
293            };
294
295            let resource_enum = parse_resource(&req.resource);
296            let allowed = policy.is_allowed(&did, &action_enum, &resource_enum);
297
298            let reasons = if allowed {
299                vec![format!(
300                    "ALLOWED: {} access to {} granted",
301                    req.action, req.resource
302                )]
303            } else {
304                vec![format!(
305                    "DENIED: {} access to {} denied",
306                    req.action, req.resource
307                )]
308            };
309
310            decisions.push(AuthDecision {
311                allowed,
312                reasons,
313                evaluation_time_us: 0, // Individual timing not tracked in batch
314            });
315        }
316
317        let end = web_sys::window()
318            .and_then(|w| w.performance())
319            .map(|p| p.now())
320            .unwrap_or(0.0);
321
322        // Convert milliseconds to microseconds (f64 -> u64)
323        let total_time_us = ((end - start) * 1000.0) as u64;
324
325        // Add total time to last decision
326        if let Some(last) = decisions.last_mut() {
327            last.evaluation_time_us = total_time_us;
328        }
329
330        serde_wasm_bindgen::to_value(&decisions)
331            .map_err(|e| JsValue::from_str(&format!("Serialization error: {}", e)))
332    }
333}