cuenv_1password/secrets/
core.rs

1//! 1Password WASM SDK `SharedCore` wrapper
2//!
3//! This module provides a thread-safe wrapper around the 1Password WASM SDK,
4//! following the same pattern as the official Go SDK.
5
6// WASM host functions and SDK initialization involve complex setup
7#![allow(clippy::too_many_lines)]
8
9use super::wasm;
10use cuenv_secrets::SecretError;
11use extism::{CurrentPlugin, Function, Manifest, Plugin, UserData, Val, ValType, Wasm};
12use std::sync::{LazyLock, Mutex};
13
14/// Global `SharedCore` instance, lazily initialized
15static SHARED_CORE: LazyLock<Mutex<Option<SharedCore>>> = LazyLock::new(|| Mutex::new(None));
16
17/// Create host functions required by the 1Password WASM SDK.
18///
19/// These match the imports expected by the 1Password core WASM module:
20/// - `random_fill_imported` (op-extism-core): Generates cryptographically secure random bytes
21/// - `unix_time_milliseconds_imported` (op-now): Returns current Unix time in milliseconds
22/// - `unix_time_milliseconds_imported` (zxcvbn): Same as above, for password strength checking
23/// - `utc_offset_seconds` (op-time): Returns local timezone offset in seconds
24#[must_use]
25pub fn create_host_functions() -> Vec<Function> {
26    use std::time::{SystemTime, UNIX_EPOCH};
27
28    // random_fill_imported: Generate random bytes and return pointer to them in WASM memory
29    // Input: i32 (length of bytes to generate)
30    // Output: i64 (pointer to the generated bytes in WASM memory)
31    let random_fill = Function::new(
32        "random_fill_imported",
33        [ValType::I32],
34        [ValType::I64],
35        UserData::new(()),
36        |plugin: &mut CurrentPlugin, inputs: &[Val], outputs: &mut [Val], _: UserData<()>| {
37            let length = usize::try_from(inputs[0].unwrap_i32()).unwrap_or(0);
38
39            // Generate cryptographically secure random bytes using getrandom (same as Go's crypto/rand)
40            let mut bytes = vec![0u8; length];
41            getrandom::fill(&mut bytes)
42                .map_err(|e| extism::Error::msg(format!("Failed to generate random bytes: {e}")))?;
43
44            // Write bytes to WASM memory using memory_new (equivalent to Go's WriteBytes)
45            let handle = plugin
46                .memory_new(&bytes)
47                .map_err(|e| extism::Error::msg(format!("Failed to write bytes: {e}")))?;
48
49            // WASM memory offsets are always < i64::MAX
50            #[expect(clippy::cast_possible_wrap)]
51            let offset = handle.offset() as i64;
52            outputs[0] = Val::I64(offset);
53            Ok(())
54        },
55    )
56    .with_namespace("op-extism-core");
57
58    // unix_time_milliseconds_imported for "op-now" namespace
59    // Input: none
60    // Output: i64 (current Unix time in milliseconds)
61    let time_op_now = Function::new(
62        "unix_time_milliseconds_imported",
63        [],
64        [ValType::I64],
65        UserData::new(()),
66        |_plugin: &mut CurrentPlugin, _inputs: &[Val], outputs: &mut [Val], _: UserData<()>| {
67            // Milliseconds since Unix epoch fits in i64 for foreseeable future
68            #[expect(clippy::cast_possible_truncation)]
69            let now = SystemTime::now()
70                .duration_since(UNIX_EPOCH)
71                .unwrap_or_default()
72                .as_millis() as i64;
73            outputs[0] = Val::I64(now);
74            Ok(())
75        },
76    )
77    .with_namespace("op-now");
78
79    // unix_time_milliseconds_imported for "zxcvbn" namespace (password strength)
80    let time_zxcvbn = Function::new(
81        "unix_time_milliseconds_imported",
82        [],
83        [ValType::I64],
84        UserData::new(()),
85        |_plugin: &mut CurrentPlugin, _inputs: &[Val], outputs: &mut [Val], _: UserData<()>| {
86            // Milliseconds since Unix epoch fits in i64 for foreseeable future
87            #[expect(clippy::cast_possible_truncation)]
88            let now = SystemTime::now()
89                .duration_since(UNIX_EPOCH)
90                .unwrap_or_default()
91                .as_millis() as i64;
92            outputs[0] = Val::I64(now);
93            Ok(())
94        },
95    )
96    .with_namespace("zxcvbn");
97
98    // utc_offset_seconds: Return local timezone offset from UTC in seconds
99    // Input: none
100    // Output: i64 (offset in seconds)
101    let utc_offset = Function::new(
102        "utc_offset_seconds",
103        [],
104        [ValType::I64],
105        UserData::new(()),
106        |_plugin: &mut CurrentPlugin, _inputs: &[Val], outputs: &mut [Val], _: UserData<()>| {
107            // Get local timezone offset using chrono
108            let offset_seconds = i64::from(chrono::Local::now().offset().local_minus_utc());
109            outputs[0] = Val::I64(offset_seconds);
110            Ok(())
111        },
112    )
113    .with_namespace("op-time");
114
115    vec![random_fill, time_op_now, time_zxcvbn, utc_offset]
116}
117
118/// `SharedCore` wraps the 1Password WASM plugin for thread-safe access.
119///
120/// The WASM runtime is single-threaded, so we use a mutex to serialize access.
121/// This follows the same pattern as the official 1Password Go SDK.
122pub struct SharedCore {
123    plugin: Plugin,
124}
125
126impl SharedCore {
127    /// Get or initialize the shared core.
128    ///
129    /// On first call, loads the WASM from disk and initializes the plugin.
130    /// Subsequent calls return the cached instance.
131    ///
132    /// # Errors
133    ///
134    /// Returns an error if:
135    /// - The shared core lock cannot be acquired
136    /// - The WASM file cannot be loaded
137    /// - The Extism plugin fails to initialize
138    pub fn get_or_init() -> Result<&'static Mutex<Option<Self>>, SecretError> {
139        let mut guard = SHARED_CORE
140            .lock()
141            .map_err(|_| SecretError::ResolutionFailed {
142                name: "onepassword".to_string(),
143                message: "Failed to acquire shared core lock".to_string(),
144            })?;
145
146        if guard.is_none() {
147            let wasm_bytes = wasm::load_onepassword_wasm()?;
148
149            let manifest = Manifest::new([Wasm::data(wasm_bytes)]).with_allowed_hosts(
150                ["*.1password.com", "*.1password.ca", "*.1password.eu"]
151                    .into_iter()
152                    .map(String::from),
153            );
154
155            let host_functions = create_host_functions();
156            let plugin = Plugin::new(&manifest, host_functions, true).map_err(|e| {
157                SecretError::ResolutionFailed {
158                    name: "onepassword".to_string(),
159                    message: format!("Failed to initialize WASM plugin: {e}"),
160                }
161            })?;
162
163            *guard = Some(Self { plugin });
164            tracing::debug!("1Password WASM plugin initialized");
165        }
166
167        // Drop guard before returning static reference
168        drop(guard);
169        Ok(&SHARED_CORE)
170    }
171
172    /// Initialize a new 1Password client.
173    ///
174    /// Returns a client ID that can be used for subsequent `invoke` calls.
175    ///
176    /// # Errors
177    ///
178    /// Returns an error if:
179    /// - The client configuration cannot be serialized
180    /// - The WASM `init_client` call fails
181    /// - The response cannot be parsed
182    /// - 1Password returns an authentication error
183    pub fn init_client(&mut self, token: &str) -> Result<u64, SecretError> {
184        // Map Rust OS/arch names to Go equivalents (what 1Password SDK expects)
185        let os = match std::env::consts::OS {
186            "macos" => "darwin",
187            other => other,
188        };
189        let arch = match std::env::consts::ARCH {
190            "aarch64" => "arm64",
191            "x86_64" => "amd64",
192            other => other,
193        };
194
195        // Note: Go SDK uses "0030101" from version-build file
196        let config = serde_json::json!({
197            "serviceAccountToken": token,
198            "programmingLanguage": "Go",  // WASM was compiled from Go SDK
199            "sdkVersion": "0030101",  // Must match WASM SDK version file exactly
200            "integrationName": "cuenv",
201            "integrationVersion": env!("CARGO_PKG_VERSION"),
202            "requestLibraryName": "net/http",
203            "requestLibraryVersion": "go1.23.0",
204            "os": os,
205            "osVersion": "0.0.0",
206            "architecture": arch,
207        });
208
209        let config_bytes =
210            serde_json::to_vec(&config).map_err(|e| SecretError::ResolutionFailed {
211                name: "onepassword".to_string(),
212                message: format!("Failed to serialize config: {e}"),
213            })?;
214
215        let result = self
216            .plugin
217            .call::<_, String>("init_client", config_bytes)
218            .map_err(|e| SecretError::ResolutionFailed {
219                name: "onepassword".to_string(),
220                message: format!("Failed to initialize client: {e}"),
221            })?;
222
223        // Parse the response - Go SDK expects either:
224        // - On success: a JSON number (uint64 client ID)
225        // - On error: a JSON object like {"name": "Auth", "message": "..."}
226        let response: serde_json::Value =
227            serde_json::from_str(&result).map_err(|e| SecretError::ResolutionFailed {
228                name: "onepassword".to_string(),
229                message: format!("Failed to parse init_client response: {e}"),
230            })?;
231
232        // Check if response is an error object
233        if let Some(error_name) = response.get("name") {
234            let message = response
235                .get("message")
236                .and_then(|m| m.as_str())
237                .unwrap_or("unknown error");
238            return Err(SecretError::ResolutionFailed {
239                name: "onepassword".to_string(),
240                message: format!("1Password error ({error_name}): {message}"),
241            });
242        }
243
244        // On success, response is just the client ID as a number
245        let client_id = response
246            .as_u64()
247            .ok_or_else(|| SecretError::ResolutionFailed {
248                name: "onepassword".to_string(),
249                message: format!("Expected client ID number, got: {result}"),
250            })?;
251
252        tracing::debug!(client_id, "1Password client initialized");
253        Ok(client_id)
254    }
255
256    /// Invoke a method on the 1Password client.
257    ///
258    /// The method name and parameters depend on the specific operation.
259    /// For resolving secrets, use method `SecretsResolve` with the secret reference.
260    ///
261    /// The `context` parameter is used for error messages to identify which secret failed.
262    ///
263    /// # Errors
264    ///
265    /// Returns an error if:
266    /// - The invoke request cannot be serialized
267    /// - The WASM invoke call fails
268    /// - The response cannot be parsed
269    /// - 1Password returns an error for the operation
270    pub fn invoke(
271        &mut self,
272        client_id: u64,
273        method: &str,
274        params: &serde_json::Map<String, serde_json::Value>,
275        context: &str,
276    ) -> Result<String, SecretError> {
277        // Structure matches Go SDK's InvokeConfig exactly:
278        // InvokeConfig { Invocation { ClientID, Parameters { MethodName, SerializedParams } } }
279        let request = serde_json::json!({
280            "invocation": {
281                "clientId": client_id,
282                "parameters": {
283                    "name": method,
284                    "parameters": params
285                }
286            }
287        });
288
289        let request_bytes =
290            serde_json::to_vec(&request).map_err(|e| SecretError::ResolutionFailed {
291                name: context.to_string(),
292                message: format!("Failed to serialize invoke request: {e}"),
293            })?;
294
295        let result = self
296            .plugin
297            .call::<_, String>("invoke", request_bytes)
298            .map_err(|e| SecretError::ResolutionFailed {
299                name: context.to_string(),
300                message: format!("1Password invoke failed: {e}"),
301            })?;
302
303        // Parse response to check for errors
304        let response: serde_json::Value =
305            serde_json::from_str(&result).map_err(|e| SecretError::ResolutionFailed {
306                name: context.to_string(),
307                message: format!("Failed to parse invoke response: {e}"),
308            })?;
309
310        // Check if response is an error object
311        if let Some(error_name) = response.get("name") {
312            let message = response
313                .get("message")
314                .and_then(|m| m.as_str())
315                .unwrap_or("unknown error");
316            return Err(SecretError::ResolutionFailed {
317                name: context.to_string(),
318                message: format!("1Password error ({error_name}): {message}"),
319            });
320        }
321
322        Ok(result)
323    }
324
325    /// Release a 1Password client.
326    ///
327    /// This should be called when the client is no longer needed.
328    pub fn release_client(&mut self, client_id: u64) {
329        // Go SDK marshals the client ID to JSON (produces a number like "0")
330        if let Ok(client_id_bytes) = serde_json::to_vec(&client_id) {
331            let _ = self
332                .plugin
333                .call::<_, String>("release_client", client_id_bytes);
334        }
335    }
336}
337
338#[cfg(test)]
339mod tests {
340    use super::*;
341
342    #[test]
343    fn test_shared_core_lazy_init() {
344        // This test just verifies the lazy static compiles
345        // Actual WASM loading requires the file to exist
346        let _ = &SHARED_CORE;
347    }
348
349    #[test]
350    fn test_create_host_functions_returns_four_functions() {
351        let functions = create_host_functions();
352        assert_eq!(functions.len(), 4, "Should create 4 host functions");
353    }
354
355    #[test]
356    fn test_host_functions_are_valid() {
357        // Creating host functions should not panic
358        let functions = create_host_functions();
359
360        // We should have exactly 4 functions:
361        // 1. random_fill_imported (op-extism-core)
362        // 2. unix_time_milliseconds_imported (op-now)
363        // 3. unix_time_milliseconds_imported (zxcvbn)
364        // 4. utc_offset_seconds (op-time)
365        assert_eq!(functions.len(), 4, "Should create exactly 4 host functions");
366    }
367
368    #[test]
369    fn test_create_host_functions_can_be_created_multiple_times() {
370        // Creating host functions should be idempotent
371        let first = create_host_functions();
372        let second = create_host_functions();
373        assert_eq!(first.len(), second.len());
374    }
375
376    #[test]
377    fn test_shared_core_static_is_mutex() {
378        // Verify the static is a mutex (compile-time check)
379        let guard = SHARED_CORE.lock();
380        assert!(guard.is_ok(), "Should be able to lock the mutex");
381        // Guard should be None initially (before get_or_init is called with WASM)
382    }
383
384    #[test]
385    fn test_os_mapping() {
386        // Test the OS mapping logic used in init_client
387        let os = match std::env::consts::OS {
388            "macos" => "darwin",
389            other => other,
390        };
391
392        // Should map macos to darwin
393        if std::env::consts::OS == "macos" {
394            assert_eq!(os, "darwin");
395        }
396    }
397
398    #[test]
399    fn test_arch_mapping() {
400        // Test the arch mapping logic used in init_client
401        let arch = match std::env::consts::ARCH {
402            "aarch64" => "arm64",
403            "x86_64" => "amd64",
404            other => other,
405        };
406
407        // Should map aarch64 to arm64
408        if std::env::consts::ARCH == "aarch64" {
409            assert_eq!(arch, "arm64");
410        }
411        // Should map x86_64 to amd64
412        if std::env::consts::ARCH == "x86_64" {
413            assert_eq!(arch, "amd64");
414        }
415    }
416
417    #[test]
418    fn test_os_mapping_linux() {
419        // Test that linux stays as linux
420        let os = match "linux" {
421            "macos" => "darwin",
422            other => other,
423        };
424        assert_eq!(os, "linux");
425    }
426
427    #[test]
428    fn test_os_mapping_windows() {
429        // Test that windows stays as windows
430        let os = match "windows" {
431            "macos" => "darwin",
432            other => other,
433        };
434        assert_eq!(os, "windows");
435    }
436
437    #[test]
438    fn test_arch_mapping_arm() {
439        // Test other arch mappings
440        let arch = match "arm" {
441            "aarch64" => "arm64",
442            "x86_64" => "amd64",
443            other => other,
444        };
445        assert_eq!(arch, "arm");
446    }
447
448    #[test]
449    fn test_arch_mapping_riscv() {
450        let arch = match "riscv64" {
451            "aarch64" => "arm64",
452            "x86_64" => "amd64",
453            other => other,
454        };
455        assert_eq!(arch, "riscv64");
456    }
457
458    #[test]
459    fn test_host_functions_creates_random_fill() {
460        // Verify host functions are created successfully
461        let functions = create_host_functions();
462        // The functions list should contain 4 entries
463        assert_eq!(functions.len(), 4);
464    }
465
466    #[test]
467    fn test_create_host_functions_no_side_effects() {
468        // Creating host functions should not have side effects
469        let _functions1 = create_host_functions();
470        let _functions2 = create_host_functions();
471        // Both should succeed without issues
472    }
473
474    #[test]
475    fn test_shared_core_mutex_initial_state() {
476        // The shared core mutex should be lockable
477        let guard = SHARED_CORE.lock();
478        assert!(guard.is_ok());
479        let inner = guard.unwrap();
480        // Initially None until get_or_init is called with valid WASM
481        // We just verify it's accessible
482        let _ = inner.is_none() || inner.is_some();
483    }
484
485    #[test]
486    fn test_get_or_init_without_wasm_returns_error() {
487        // If WASM is not available and ONEPASSWORD_WASM_PATH is not set,
488        // get_or_init should fail. But we can't guarantee state here,
489        // so we just verify it doesn't panic
490        let result = SharedCore::get_or_init();
491        // Result is either Ok (WASM available) or Err (not available)
492        let _ = result.is_ok() || result.is_err();
493    }
494}