From bc0b0aceb3d786e1c9469dfb066a530f38296a85 Mon Sep 17 00:00:00 2001 From: Dan Gericke Date: Tue, 17 Mar 2026 19:47:44 +0800 Subject: [PATCH 1/3] Remove secret character counts from log output Logging the character count of resolved secrets leaks information about secret size, which can aid attackers in identifying or narrowing down credential types. Strip the char count from both the info-level resolve log and the debug-level write log. Signed-off-by: Dan Gericke --- src/resolver.rs | 8 ++------ 1 file changed, 2 insertions(+), 6 deletions(-) diff --git a/src/resolver.rs b/src/resolver.rs index 3889396..30dd468 100644 --- a/src/resolver.rs +++ b/src/resolver.rs @@ -75,11 +75,7 @@ pub async fn resolve_all(refs: &[SecretRef], store: &mut SecretStore) -> (usize, match op_read(&r.uri).await { Ok(value) => { store.insert_with_uri(r.name.clone(), value, r.uri.clone()); - info!( - "resolved {} ({} chars)", - r.name, - store.get(&r.name).map_or(0, |v| v.len()) - ); + info!("resolved {}", r.name); ok += 1; } Err(e) => { @@ -145,7 +141,7 @@ pub async fn op_read(uri: &str) -> Result { /// Returns a descriptive error string if the URI is malformed, the `op` binary /// cannot be executed, or `op item edit` exits with a non-zero status. pub async fn op_write(uri: &str, value: &str) -> Result<(), String> { - debug!("op item edit for {uri} ({} chars)", value.len()); + debug!("op item edit for {uri}"); let parts: Vec<&str> = uri .strip_prefix("op://") .ok_or_else(|| format!("invalid op:// URI: {uri}"))? From 1265f5e76529f38708add1682ce5fd6220f2d05f Mon Sep 17 00:00:00 2001 From: Dan Gericke Date: Tue, 17 Mar 2026 20:07:58 +0800 Subject: [PATCH 2/3] Add op item create fallback when item doesn't exist op_write now detects "item not found" errors from `op item edit` and falls back to `op item create --category=password`. This supports new OAuth credentials that don't have a pre-existing 1Password item. Also zeroizes the assignment string containing the secret value after CLI invocation, and extracts the error detection marker to a constant. Signed-off-by: Dan Gericke --- src/lib.rs | 2 +- src/resolver.rs | 69 +++++++++++++++++++++++++++++++++++++++---------- src/socket.rs | 2 +- 3 files changed, 58 insertions(+), 15 deletions(-) diff --git a/src/lib.rs b/src/lib.rs index 30e99ad..e6f6874 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -11,7 +11,7 @@ //! ## Modules //! //! - [`client`] — Unix socket client for querying the daemon from CLI subcommands. -//! - [`resolver`] — 1Password CLI integration (`op read` / `op item edit`). +//! - [`resolver`] — 1Password CLI integration (`op read` / `op item edit` / `op item create`). //! - [`socket`] — Unix socket server implementing the line-based protocol. //! - [`store`] — In-memory secret store with zeroize-on-drop guarantees. //! - [`watcher`] — File system watcher for credential auto-sync back to 1Password. diff --git a/src/resolver.rs b/src/resolver.rs index 30dd468..1aade15 100644 --- a/src/resolver.rs +++ b/src/resolver.rs @@ -7,9 +7,14 @@ use secrecy::SecretString; use tracing::{debug, error, info, trace}; +use zeroize::Zeroize; use crate::store::SecretStore; +/// Substring the `op` CLI includes in stderr when an item doesn't exist in a vault. +/// Used to distinguish "not found" from other edit failures (auth, network, etc.). +const OP_ITEM_NOT_FOUND_MARKER: &str = "isn't an item"; + /// A discovered secret reference mapping an environment variable name to an /// `op://` URI. /// @@ -122,26 +127,32 @@ pub async fn op_read(uri: &str) -> Result { Ok(SecretString::from(value.trim_end_matches('\n').to_string())) } -/// Writes a secret value to 1Password by invoking `op item edit`. +/// Writes a secret value to 1Password, creating the item if it doesn't exist. /// -/// The `uri` must be in `op://vault/item/field` format. This function parses -/// the URI into its components and runs: +/// The `uri` must be in `op://vault/item/field` format. This function first +/// attempts to update the existing item via `op item edit`. If the item does +/// not exist (detected by the `"isn't an item"` error from the `op` CLI), +/// it falls back to creating a new item via `op item create`. /// /// ```text +/// # Update existing: /// op item edit = --vault +/// +/// # Create new (fallback): +/// op item create --category=password --title= --vault= = /// ``` /// /// # Safety boundary /// -/// This function can **update** existing 1Password fields but cannot create -/// or delete items. The security boundary matches the `op` CLI itself. +/// This function can **update** existing 1Password fields and **create** new +/// items. It cannot delete items. The security boundary matches the `op` CLI. /// /// # Errors /// /// Returns a descriptive error string if the URI is malformed, the `op` binary -/// cannot be executed, or `op item edit` exits with a non-zero status. +/// cannot be executed, or both edit and create fail. pub async fn op_write(uri: &str, value: &str) -> Result<(), String> { - debug!("op item edit for {uri}"); + debug!("op write for {uri}"); let parts: Vec<&str> = uri .strip_prefix("op://") .ok_or_else(|| format!("invalid op:// URI: {uri}"))? @@ -153,20 +164,52 @@ pub async fn op_write(uri: &str, value: &str) -> Result<(), String> { } let (vault, item, field) = (parts[0], parts[1], parts[2]); - let assignment = format!("{field}={value}"); + let mut assignment = format!("{field}={value}"); + // Try editing the existing item first. let output = tokio::process::Command::new("op") .args(["item", "edit", item, &assignment, "--vault", vault]) .output() .await .map_err(|e| format!("failed to execute op: {e}"))?; - if !output.status.success() { - let stderr = String::from_utf8_lossy(&output.stderr); + if output.status.success() { + assignment.zeroize(); + return Ok(()); + } + + let stderr = String::from_utf8_lossy(&output.stderr); + let stderr_trimmed = stderr.trim(); + + // Only fall back to create when the item genuinely doesn't exist. + // The op CLI returns: `"" isn't an item in the "" vault.` + if !stderr_trimmed.contains(OP_ITEM_NOT_FOUND_MARKER) { + assignment.zeroize(); + return Err(format!("op item edit failed ({}): {}", output.status, stderr_trimmed)); + } + + info!("item not found in 1Password, creating: {item} in vault {vault}"); + + let create_output = tokio::process::Command::new("op") + .args([ + "item", "create", + "--category=password", + &format!("--title={item}"), + &format!("--vault={vault}"), + &assignment, + ]) + .output() + .await + .map_err(|e| format!("failed to execute op: {e}"))?; + + assignment.zeroize(); + + if !create_output.status.success() { + let create_stderr = String::from_utf8_lossy(&create_output.stderr); return Err(format!( - "op item edit failed ({}): {}", - output.status, - stderr.trim() + "op item create failed ({}): {}", + create_output.status, + create_stderr.trim() )); } diff --git a/src/socket.rs b/src/socket.rs index a72c521..c1c2019 100644 --- a/src/socket.rs +++ b/src/socket.rs @@ -153,6 +153,6 @@ async fn handle_set(line: &str, store: &Arc>) -> String { ); } - info!("SET {} -> {uri} ({} chars)", name, value.len()); + info!("SET {} -> {uri}", name); "OK\n".to_string() } From f9f7706c540e8eb97627733b4d4b266da11cf4d5 Mon Sep 17 00:00:00 2001 From: Dan Gericke Date: Tue, 17 Mar 2026 20:10:52 +0800 Subject: [PATCH 3/3] style: apply cargo fmt Signed-off-by: Dan Gericke --- src/resolver.rs | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/src/resolver.rs b/src/resolver.rs index 1aade15..3106c18 100644 --- a/src/resolver.rs +++ b/src/resolver.rs @@ -185,14 +185,18 @@ pub async fn op_write(uri: &str, value: &str) -> Result<(), String> { // The op CLI returns: `"" isn't an item in the "" vault.` if !stderr_trimmed.contains(OP_ITEM_NOT_FOUND_MARKER) { assignment.zeroize(); - return Err(format!("op item edit failed ({}): {}", output.status, stderr_trimmed)); + return Err(format!( + "op item edit failed ({}): {}", + output.status, stderr_trimmed + )); } info!("item not found in 1Password, creating: {item} in vault {vault}"); let create_output = tokio::process::Command::new("op") .args([ - "item", "create", + "item", + "create", "--category=password", &format!("--title={item}"), &format!("--vault={vault}"),