secrets manager
git clone https://git.lucas.co/cce-secrets.git
keyring sync: refuse a pass that would remove a tenth of the vault
Nothing bounded what one pass could remove. The vault is named, not
pinned by id, and op uses its default account. If a second account's
"Personal" vault became the default, the listing would hold entirely
different items, every synced entry would read as deleted in 1Password,
and the pass would hard-delete all ~378 of them from the keyring.
A pass whose deletions plus archives exceed a tenth of the synced
entries (never fewer than 5) now applies nothing and returns an error
naming the count. The daemon logs it and retries. A deliberate large
removal goes through once with `cce-keyring-sync sync
--allow-mass-delete`, after reviewing it with --dry-run.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
src/bin/cce-keyring-sync/daemon.rs | 2 +-
src/bin/cce-keyring-sync/main.rs | 4 +++-
src/bin/cce-keyring-sync/sync.rs | 47 ++++++++++++++++++++++++++++++++++++++
3 files changed, 51 insertions(+), 2 deletions(-)
diff --git a/src/bin/cce-keyring-sync/daemon.rs b/src/bin/cce-keyring-sync/daemon.rs
index db075e6..6b33244 100644
--- a/src/bin/cce-keyring-sync/daemon.rs
+++ b/src/bin/cce-keyring-sync/daemon.rs
@@ -57,7 +57,7 @@ pub async fn daemon(state_path: &std::path::Path) {
TICK
} else {
let mut remote = OnePassword::new(&state.vault);
- match sync_remote(&mut remote, state_path, &mut state, false).await {
+ match sync_remote(&mut remote, state_path, &mut state, false, false).await {
Ok(_) => {
dismissed = 0;
app_down = 0;
diff --git a/src/bin/cce-keyring-sync/main.rs b/src/bin/cce-keyring-sync/main.rs
index 4bf40a1..bc4f02a 100644
--- a/src/bin/cce-keyring-sync/main.rs
+++ b/src/bin/cce-keyring-sync/main.rs
@@ -178,6 +178,7 @@ pub(crate) fn write_state(state_path: &Path, state: &State) {
async fn main() {
let args: Vec<String> = std::env::args().skip(1).collect();
let dry_run = args.iter().any(|a| a == "--dry-run");
+ let allow_mass_delete = args.iter().any(|a| a == "--allow-mass-delete");
let vault_flag = args
.iter()
.position(|a| a == "--vault")
@@ -204,6 +205,7 @@ async fn main() {
Some(c @ ("sync" | "status" | "adopt" | "daemon")) => c.to_string(),
_ => {
eprintln!("usage: cce-keyring-sync sync [--dry-run] (one merge pass; raises its own Authorize dialog)");
+ eprintln!(" cce-keyring-sync sync --allow-mass-delete (let one pass remove more than a tenth of the synced items)");
eprintln!(" cce-keyring-sync daemon (resident; what cce-keyring-sync.service runs)");
eprintln!(" cce-keyring-sync adopt [--dry-run] [--vault <name>] (pair the keyring with 1Password, seed the base)");
eprintln!(" cce-keyring-sync status");
@@ -235,7 +237,7 @@ async fn main() {
// A one-shot pass; the daemon is the usual caller, and the flock
// keeps the two apart.
let mut remote = op::OnePassword::new(&state.vault);
- if let Err(e) = sync::sync_remote(&mut remote, &state_path, &mut state, dry_run).await {
+ if let Err(e) = sync::sync_remote(&mut remote, &state_path, &mut state, dry_run, allow_mass_delete).await {
eprintln!("{e}");
std::process::exit(1);
}
diff --git a/src/bin/cce-keyring-sync/sync.rs b/src/bin/cce-keyring-sync/sync.rs
index 945bd0c..fbee678 100644
--- a/src/bin/cce-keyring-sync/sync.rs
+++ b/src/bin/cce-keyring-sync/sync.rs
@@ -147,11 +147,30 @@ struct Local<'a> {
/// One merge pass. Always writes the state file on a real run (with
/// `last_result` set to the outcome, success or not) unless it could not
/// even start. Returns the one-line summary, or the error.
+/// Removals one pass may make before it is refused: deletions from the
+/// keyring plus archives in 1Password, at most 10% of the synced entries
+/// and never fewer than this.
+const MASS_REMOVAL_FLOOR: usize = 5;
+
+/// Whether a pass removing `removals` items, with `synced` entries in the
+/// base, looks like a mistake rather than an edit.
+///
+/// Nothing else bounded it. The vault is named, not pinned by id, and `op`
+/// uses its default account, so a second account with its own "Personal"
+/// vault becoming the default would list entirely different items: every
+/// synced entry would read as deleted in 1Password, and the pass would
+/// hard-delete all of them from the keyring. People delete a few items at a
+/// time; a pass that would remove a tenth of everything stops and says so.
+pub fn is_mass_removal(removals: usize, synced: usize) -> bool {
+ removals > MASS_REMOVAL_FLOOR.max(synced / 10)
+}
+
pub async fn sync_remote<I: Interchange>(
remote: &mut I,
state_path: &std::path::Path,
state: &mut State,
dry_run: bool,
+ allow_mass_removal: bool,
) -> Result<String, String> {
let Some(_lock) = take_lock() else {
return Err("another cce-keyring-sync is running".into());
@@ -323,6 +342,16 @@ pub async fn sync_remote<I: Interchange>(
println!("{summary} (dry run — nothing changed; {fetches} fetched)");
return Ok(summary);
}
+ let removals = c("deleted") + c("archived");
+ if !allow_mass_removal && is_mass_removal(removals, state.entries.len()) {
+ return Err(format!(
+ "refusing a pass that would remove {removals} of {} synced items ({}) — nothing changed. \
+ If 1Password's default account or vault changed, fix that; if the removals are meant, \
+ run `cce-keyring-sync sync --dry-run` to review them, then `cce-keyring-sync sync --allow-mass-delete`",
+ state.entries.len(),
+ summary
+ ));
+ }
// ---- apply ----
// `next` starts as the old base and is rewritten entry by entry, so a
@@ -524,6 +553,24 @@ pub async fn sync_remote<I: Interchange>(
result
}
+#[cfg(test)]
+mod mass_removal_tests {
+ use super::is_mass_removal;
+
+ #[test]
+ fn a_pass_removing_a_tenth_of_the_vault_is_refused() {
+ // The live vault is ~378 entries: 37 removals pass, 38 stop.
+ assert!(!is_mass_removal(37, 378));
+ assert!(is_mass_removal(38, 378));
+ // Every entry reading as gone — the wrong-account case.
+ assert!(is_mass_removal(378, 378));
+ // Small vaults still allow a handful.
+ assert!(!is_mass_removal(5, 12));
+ assert!(is_mass_removal(6, 12));
+ assert!(!is_mass_removal(0, 0));
+ }
+}
+
#[cfg(test)]
mod tests {
use super::*;