cuenv-1password 0.40.6

1Password integration for the cuenv ecosystem
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
//! 1Password WASM SDK `SharedCore` wrapper
//!
//! This module provides a thread-safe wrapper around the 1Password WASM SDK,
//! following the same pattern as the official Go SDK.

// WASM host functions and SDK initialization involve complex setup
#![allow(clippy::too_many_lines)]

use super::wasm;
use cuenv_secrets::SecretError;
use extism::{CurrentPlugin, Function, Manifest, Plugin, UserData, Val, ValType, Wasm};
use std::sync::{LazyLock, Mutex};

/// Global `SharedCore` instance, lazily initialized
static SHARED_CORE: LazyLock<Mutex<Option<SharedCore>>> = LazyLock::new(|| Mutex::new(None));

/// Create host functions required by the 1Password WASM SDK.
///
/// These match the imports expected by the 1Password core WASM module:
/// - `random_fill_imported` (op-extism-core): Generates cryptographically secure random bytes
/// - `unix_time_milliseconds_imported` (op-now): Returns current Unix time in milliseconds
/// - `unix_time_milliseconds_imported` (zxcvbn): Same as above, for password strength checking
/// - `utc_offset_seconds` (op-time): Returns local timezone offset in seconds
#[must_use]
pub fn create_host_functions() -> Vec<Function> {
    use std::time::{SystemTime, UNIX_EPOCH};

    // random_fill_imported: Generate random bytes and return pointer to them in WASM memory
    // Input: i32 (length of bytes to generate)
    // Output: i64 (pointer to the generated bytes in WASM memory)
    let random_fill = Function::new(
        "random_fill_imported",
        [ValType::I32],
        [ValType::I64],
        UserData::new(()),
        |plugin: &mut CurrentPlugin, inputs: &[Val], outputs: &mut [Val], _: UserData<()>| {
            let length = usize::try_from(inputs[0].unwrap_i32()).unwrap_or(0);

            // Generate cryptographically secure random bytes using getrandom (same as Go's crypto/rand)
            let mut bytes = vec![0u8; length];
            getrandom::fill(&mut bytes)
                .map_err(|e| extism::Error::msg(format!("Failed to generate random bytes: {e}")))?;

            // Write bytes to WASM memory using memory_new (equivalent to Go's WriteBytes)
            let handle = plugin
                .memory_new(&bytes)
                .map_err(|e| extism::Error::msg(format!("Failed to write bytes: {e}")))?;

            // WASM memory offsets are always < i64::MAX
            #[expect(clippy::cast_possible_wrap)]
            let offset = handle.offset() as i64;
            outputs[0] = Val::I64(offset);
            Ok(())
        },
    )
    .with_namespace("op-extism-core");

    // unix_time_milliseconds_imported for "op-now" namespace
    // Input: none
    // Output: i64 (current Unix time in milliseconds)
    let time_op_now = Function::new(
        "unix_time_milliseconds_imported",
        [],
        [ValType::I64],
        UserData::new(()),
        |_plugin: &mut CurrentPlugin, _inputs: &[Val], outputs: &mut [Val], _: UserData<()>| {
            // Milliseconds since Unix epoch fits in i64 for foreseeable future
            #[expect(clippy::cast_possible_truncation)]
            let now = SystemTime::now()
                .duration_since(UNIX_EPOCH)
                .unwrap_or_default()
                .as_millis() as i64;
            outputs[0] = Val::I64(now);
            Ok(())
        },
    )
    .with_namespace("op-now");

    // unix_time_milliseconds_imported for "zxcvbn" namespace (password strength)
    let time_zxcvbn = Function::new(
        "unix_time_milliseconds_imported",
        [],
        [ValType::I64],
        UserData::new(()),
        |_plugin: &mut CurrentPlugin, _inputs: &[Val], outputs: &mut [Val], _: UserData<()>| {
            // Milliseconds since Unix epoch fits in i64 for foreseeable future
            #[expect(clippy::cast_possible_truncation)]
            let now = SystemTime::now()
                .duration_since(UNIX_EPOCH)
                .unwrap_or_default()
                .as_millis() as i64;
            outputs[0] = Val::I64(now);
            Ok(())
        },
    )
    .with_namespace("zxcvbn");

    // utc_offset_seconds: Return local timezone offset from UTC in seconds
    // Input: none
    // Output: i64 (offset in seconds)
    let utc_offset = Function::new(
        "utc_offset_seconds",
        [],
        [ValType::I64],
        UserData::new(()),
        |_plugin: &mut CurrentPlugin, _inputs: &[Val], outputs: &mut [Val], _: UserData<()>| {
            // Get local timezone offset using chrono
            let offset_seconds = i64::from(chrono::Local::now().offset().local_minus_utc());
            outputs[0] = Val::I64(offset_seconds);
            Ok(())
        },
    )
    .with_namespace("op-time");

    vec![random_fill, time_op_now, time_zxcvbn, utc_offset]
}

/// `SharedCore` wraps the 1Password WASM plugin for thread-safe access.
///
/// The WASM runtime is single-threaded, so we use a mutex to serialize access.
/// This follows the same pattern as the official 1Password Go SDK.
pub struct SharedCore {
    plugin: Plugin,
}

impl SharedCore {
    /// Get or initialize the shared core.
    ///
    /// On first call, loads the WASM from disk and initializes the plugin.
    /// Subsequent calls return the cached instance.
    ///
    /// # Errors
    ///
    /// Returns an error if:
    /// - The shared core lock cannot be acquired
    /// - The WASM file cannot be loaded
    /// - The Extism plugin fails to initialize
    pub fn get_or_init() -> Result<&'static Mutex<Option<Self>>, SecretError> {
        let mut guard = SHARED_CORE
            .lock()
            .map_err(|_| SecretError::ResolutionFailed {
                name: "onepassword".to_string(),
                message: "Failed to acquire shared core lock".to_string(),
            })?;

        if guard.is_none() {
            let wasm_bytes = wasm::load_onepassword_wasm()?;

            let manifest = Manifest::new([Wasm::data(wasm_bytes)]).with_allowed_hosts(
                ["*.1password.com", "*.1password.ca", "*.1password.eu"]
                    .into_iter()
                    .map(String::from),
            );

            let host_functions = create_host_functions();
            let plugin = Plugin::new(&manifest, host_functions, true).map_err(|e| {
                SecretError::ResolutionFailed {
                    name: "onepassword".to_string(),
                    message: format!("Failed to initialize WASM plugin: {e}"),
                }
            })?;

            *guard = Some(Self { plugin });
            tracing::debug!("1Password WASM plugin initialized");
        }

        // Drop guard before returning static reference
        drop(guard);
        Ok(&SHARED_CORE)
    }

    /// Initialize a new 1Password client.
    ///
    /// Returns a client ID that can be used for subsequent `invoke` calls.
    ///
    /// # Errors
    ///
    /// Returns an error if:
    /// - The client configuration cannot be serialized
    /// - The WASM `init_client` call fails
    /// - The response cannot be parsed
    /// - 1Password returns an authentication error
    pub fn init_client(&mut self, token: &str) -> Result<u64, SecretError> {
        // Map Rust OS/arch names to Go equivalents (what 1Password SDK expects)
        let os = match std::env::consts::OS {
            "macos" => "darwin",
            other => other,
        };
        let arch = match std::env::consts::ARCH {
            "aarch64" => "arm64",
            "x86_64" => "amd64",
            other => other,
        };

        // Note: Go SDK uses "0030101" from version-build file
        let config = serde_json::json!({
            "serviceAccountToken": token,
            "programmingLanguage": "Go",  // WASM was compiled from Go SDK
            "sdkVersion": "0030101",  // Must match WASM SDK version file exactly
            "integrationName": "cuenv",
            "integrationVersion": env!("CARGO_PKG_VERSION"),
            "requestLibraryName": "net/http",
            "requestLibraryVersion": "go1.23.0",
            "os": os,
            "osVersion": "0.0.0",
            "architecture": arch,
        });

        let config_bytes =
            serde_json::to_vec(&config).map_err(|e| SecretError::ResolutionFailed {
                name: "onepassword".to_string(),
                message: format!("Failed to serialize config: {e}"),
            })?;

        let result = self
            .plugin
            .call::<_, String>("init_client", config_bytes)
            .map_err(|e| SecretError::ResolutionFailed {
                name: "onepassword".to_string(),
                message: format!("Failed to initialize client: {e}"),
            })?;

        // Parse the response - Go SDK expects either:
        // - On success: a JSON number (uint64 client ID)
        // - On error: a JSON object like {"name": "Auth", "message": "..."}
        let response: serde_json::Value =
            serde_json::from_str(&result).map_err(|e| SecretError::ResolutionFailed {
                name: "onepassword".to_string(),
                message: format!("Failed to parse init_client response: {e}"),
            })?;

        // Check if response is an error object
        if let Some(error_name) = response.get("name") {
            let message = response
                .get("message")
                .and_then(|m| m.as_str())
                .unwrap_or("unknown error");
            return Err(SecretError::ResolutionFailed {
                name: "onepassword".to_string(),
                message: format!("1Password error ({error_name}): {message}"),
            });
        }

        // On success, response is just the client ID as a number
        let client_id = response
            .as_u64()
            .ok_or_else(|| SecretError::ResolutionFailed {
                name: "onepassword".to_string(),
                message: format!("Expected client ID number, got: {result}"),
            })?;

        tracing::debug!(client_id, "1Password client initialized");
        Ok(client_id)
    }

    /// Invoke a method on the 1Password client.
    ///
    /// The method name and parameters depend on the specific operation.
    /// For resolving secrets, use method `SecretsResolve` with the secret reference.
    ///
    /// The `context` parameter is used for error messages to identify which secret failed.
    ///
    /// # Errors
    ///
    /// Returns an error if:
    /// - The invoke request cannot be serialized
    /// - The WASM invoke call fails
    /// - The response cannot be parsed
    /// - 1Password returns an error for the operation
    pub fn invoke(
        &mut self,
        client_id: u64,
        method: &str,
        params: &serde_json::Map<String, serde_json::Value>,
        context: &str,
    ) -> Result<String, SecretError> {
        // Structure matches Go SDK's InvokeConfig exactly:
        // InvokeConfig { Invocation { ClientID, Parameters { MethodName, SerializedParams } } }
        let request = serde_json::json!({
            "invocation": {
                "clientId": client_id,
                "parameters": {
                    "name": method,
                    "parameters": params
                }
            }
        });

        let request_bytes =
            serde_json::to_vec(&request).map_err(|e| SecretError::ResolutionFailed {
                name: context.to_string(),
                message: format!("Failed to serialize invoke request: {e}"),
            })?;

        let result = self
            .plugin
            .call::<_, String>("invoke", request_bytes)
            .map_err(|e| SecretError::ResolutionFailed {
                name: context.to_string(),
                message: format!("1Password invoke failed: {e}"),
            })?;

        // Parse response to check for errors
        let response: serde_json::Value =
            serde_json::from_str(&result).map_err(|e| SecretError::ResolutionFailed {
                name: context.to_string(),
                message: format!("Failed to parse invoke response: {e}"),
            })?;

        // Check if response is an error object
        if let Some(error_name) = response.get("name") {
            let message = response
                .get("message")
                .and_then(|m| m.as_str())
                .unwrap_or("unknown error");
            return Err(SecretError::ResolutionFailed {
                name: context.to_string(),
                message: format!("1Password error ({error_name}): {message}"),
            });
        }

        Ok(result)
    }

    /// Release a 1Password client.
    ///
    /// This should be called when the client is no longer needed.
    pub fn release_client(&mut self, client_id: u64) {
        // Go SDK marshals the client ID to JSON (produces a number like "0")
        if let Ok(client_id_bytes) = serde_json::to_vec(&client_id) {
            let _ = self
                .plugin
                .call::<_, String>("release_client", client_id_bytes);
        }
    }
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn test_shared_core_lazy_init() {
        // This test just verifies the lazy static compiles
        // Actual WASM loading requires the file to exist
        let _ = &SHARED_CORE;
    }

    #[test]
    fn test_create_host_functions_returns_four_functions() {
        let functions = create_host_functions();
        assert_eq!(functions.len(), 4, "Should create 4 host functions");
    }

    #[test]
    fn test_host_functions_are_valid() {
        // Creating host functions should not panic
        let functions = create_host_functions();

        // We should have exactly 4 functions:
        // 1. random_fill_imported (op-extism-core)
        // 2. unix_time_milliseconds_imported (op-now)
        // 3. unix_time_milliseconds_imported (zxcvbn)
        // 4. utc_offset_seconds (op-time)
        assert_eq!(functions.len(), 4, "Should create exactly 4 host functions");
    }

    #[test]
    fn test_create_host_functions_can_be_created_multiple_times() {
        // Creating host functions should be idempotent
        let first = create_host_functions();
        let second = create_host_functions();
        assert_eq!(first.len(), second.len());
    }

    #[test]
    fn test_shared_core_static_is_mutex() {
        // Verify the static is a mutex (compile-time check)
        let guard = SHARED_CORE.lock();
        assert!(guard.is_ok(), "Should be able to lock the mutex");
        // Guard should be None initially (before get_or_init is called with WASM)
    }

    #[test]
    fn test_os_mapping() {
        // Test the OS mapping logic used in init_client
        let os = match std::env::consts::OS {
            "macos" => "darwin",
            other => other,
        };

        // Should map macos to darwin
        if std::env::consts::OS == "macos" {
            assert_eq!(os, "darwin");
        }
    }

    #[test]
    fn test_arch_mapping() {
        // Test the arch mapping logic used in init_client
        let arch = match std::env::consts::ARCH {
            "aarch64" => "arm64",
            "x86_64" => "amd64",
            other => other,
        };

        // Should map aarch64 to arm64
        if std::env::consts::ARCH == "aarch64" {
            assert_eq!(arch, "arm64");
        }
        // Should map x86_64 to amd64
        if std::env::consts::ARCH == "x86_64" {
            assert_eq!(arch, "amd64");
        }
    }

    #[test]
    fn test_os_mapping_linux() {
        // Test that linux stays as linux
        let os = match "linux" {
            "macos" => "darwin",
            other => other,
        };
        assert_eq!(os, "linux");
    }

    #[test]
    fn test_os_mapping_windows() {
        // Test that windows stays as windows
        let os = match "windows" {
            "macos" => "darwin",
            other => other,
        };
        assert_eq!(os, "windows");
    }

    #[test]
    fn test_arch_mapping_arm() {
        // Test other arch mappings
        let arch = match "arm" {
            "aarch64" => "arm64",
            "x86_64" => "amd64",
            other => other,
        };
        assert_eq!(arch, "arm");
    }

    #[test]
    fn test_arch_mapping_riscv() {
        let arch = match "riscv64" {
            "aarch64" => "arm64",
            "x86_64" => "amd64",
            other => other,
        };
        assert_eq!(arch, "riscv64");
    }

    #[test]
    fn test_host_functions_creates_random_fill() {
        // Verify host functions are created successfully
        let functions = create_host_functions();
        // The functions list should contain 4 entries
        assert_eq!(functions.len(), 4);
    }

    #[test]
    fn test_create_host_functions_no_side_effects() {
        // Creating host functions should not have side effects
        let _functions1 = create_host_functions();
        let _functions2 = create_host_functions();
        // Both should succeed without issues
    }

    #[test]
    fn test_shared_core_mutex_initial_state() {
        // The shared core mutex should be lockable
        let guard = SHARED_CORE.lock();
        assert!(guard.is_ok());
        let inner = guard.unwrap();
        // Initially None until get_or_init is called with valid WASM
        // We just verify it's accessible
        let _ = inner.is_none() || inner.is_some();
    }

    #[test]
    fn test_get_or_init_without_wasm_returns_error() {
        // If WASM is not available and ONEPASSWORD_WASM_PATH is not set,
        // get_or_init should fail. But we can't guarantee state here,
        // so we just verify it doesn't panic
        let result = SharedCore::get_or_init();
        // Result is either Ok (WASM available) or Err (not available)
        let _ = result.is_ok() || result.is_err();
    }
}