secrets manager
git clone https://git.lucas.co/cce-secrets.git
cce-keyring-sync: the resident daemon and the 1Password merge (phase 2)
sync.rs runs the three-way table against the Interchange trait, keyed by
item id: list once, fetch only entries whose updated_at moved, write the
keyring in place (label/attributes/secret) or create, update remotely via
templates, archive for keyring deletes, recreate-and-restamp when a local
edit meets a remote archive; the first remote failure stops the apply
with the base kept for everything not yet done. daemon.rs keeps one
parent alive for op (phase 0's rule): a 5-minute tick, SIGUSR1 for now,
15/30/60-minute back-off after an unanswered Authorize dialog. The unit
becomes a resident service; the timer goes. cce-secrets' Sync button and
its saves poke the daemon, falling back to the one-shot.
Two op facts the end-to-end run turned up: list prints UTC timestamps,
get/edit print local-offset ones (parsed to one canonical form now), and
the app's push after an edit is asynchronous. scripts/e2e-1password.sh
exercises the whole table in an isolated keyring against a throwaway
vault: 19 checks pass.
Co-Authored-By: Claude Fable 5.1 <[email protected]>
KEYRING-SYNC.md | 62 ++++-
cce-keyring-sync.service | 25 +-
cce-keyring-sync.timer | 10 -
scripts/e2e-1password.sh | 188 +++++++++++++
src/bin/cce-keyring-sync/adopt.rs | 13 +-
src/bin/cce-keyring-sync/daemon.rs | 68 +++++
src/bin/cce-keyring-sync/main.rs | 31 ++-
src/bin/cce-keyring-sync/op.rs | 119 +++++---
src/bin/cce-keyring-sync/sync.rs | 552 +++++++++++++++++++++++++++++++++++++
src/main.rs | 90 ++++--
10 files changed, 1075 insertions(+), 83 deletions(-)
diff --git a/KEYRING-SYNC.md b/KEYRING-SYNC.md
index eaefaba..e049286 100644
--- a/KEYRING-SYNC.md
+++ b/KEYRING-SYNC.md
@@ -173,10 +173,10 @@ that is irrelevant, and there is no resident unlock to keep warm.
# Scoping: 1Password as the interchange (option 1, 2026-09-21)
-Status: **phase 1 shipped (2026-09-21).** `adopt` paired 168 of 171 keyring
-logins with 1Password and the base is keyed by item id; the kdbx timer is
-stopped and `sync` refuses until phase 2's daemon. See "Phase 0 results"
-and "Phase 1 results" at the end.
+Status: **phase 2 shipped (2026-09-21).** The resident daemon is the
+`cce-keyring-sync.service` unit, ticking every five minutes against the
+Personal vault; the kdbx timer is gone. Phase 3 (retire the kdbx code) is
+what remains. Results per phase at the end of this file.
## Goal
@@ -395,11 +395,9 @@ unacceptable however native the window is. Measure it before anything else
**Done 2026-09-21** (fab49c8, 28c7389); results below. The kdbx backend
did *not* move behind the trait yet — an unexercised impl is dead code;
it joins when phase 2 rewires `sync`.
-2. **`sync` on the new backend.** Exercise the full table in an isolated
- `dbus-run-session` keyring against a throwaway vault, as phase 2 above
- did against a fixture kdbx: edit both ways, delete both ways, conflict,
- new-entry adoption both ways, mid-apply `op` failure. Then flip the timer
- (cadence per phase 0) and stop the kdbx timer.
+2. ~~**`sync` on the new backend.**~~ **Done 2026-09-21**; results below.
+ The table is exercised by `scripts/e2e-1password.sh` (isolated keyring,
+ throwaway vault, every pass through one daemon), 19 checks.
3. **Retire the kdbx path** after a month clean: delete the backend, the
`doctor` subcommand, the `keepass` dependency, and the master-password
keyring item. Update cce-secrets' "synced Nm ago" hint (it already reads
@@ -567,3 +565,49 @@ Things learned that phase 2 has to carry:
re-enable the timer; the stamps are harmless to the kdbx path).
- The cce-secrets Sync button now runs a `sync` that refuses; its status
line shows the refusal text until the daemon lands.
+
+## Phase 2 results (2026-09-21)
+
+Shipped: `sync.rs` (the merge against the `Interchange` trait, keyed by
+item id), `daemon.rs` (the resident loop), the `cce-keyring-sync.service`
+unit replacing the timer, and cce-secrets' Sync button and saves poking
+the daemon with `SIGUSR1` (falling back to the one-shot when no daemon
+runs). The kdbx `sync` in main.rs is untouched and unreachable under the
+1Password backend; it and the `keepass` dependency go in phase 3.
+
+The first live pass mirrored the four 1Password-only entries into the
+keyring (the account item excluded), created the three keyring-only ones
+in 1Password (the cce-mail credentials, kept by decision), and took
+1Password's notes on the one drifted pair. 174 entries in the base.
+
+`scripts/e2e-1password.sh` runs the table end to end: mirror, idempotent
+quiet pass (one `op item list`, zero fetches), edit each way, keyring-born
+create with stamp, remote-born mirror, keyring delete → Archive, Archive →
+keyring delete, both conflict directions, modification-beats-deletion
+resurrecting as a new item with a restamp, a simulated mid-apply `op`
+failure that leaves the rest for the next run, and a value-free state
+file. 19 of 19.
+
+What it found, and what was changed for it:
+
+- **`op` prints two timestamp forms.** `item list` gives UTC to the
+ second (`…T16:27:42Z`); `item get` and `item edit` give local time with
+ an offset and nanoseconds (`…T12:27:42.39627885-04:00`). The parser
+ takes both and the base stores the canonical UTC form, or a written
+ entry would never match the list again and be fetched every tick. The
+ base timestamp is also refreshed whenever an in-sync entry's list value
+ differs — the server may stamp a write a second after the reply.
+- **A remote edit needs a moment.** Listing right after `op item edit`
+ can still show the previous `updated_at` (the app pushes
+ asynchronously); in a conflict that flips the winner. Real edits arrive
+ from other devices minutes old; the test waits two seconds.
+- **Two keyring items with one stamp** (a tool that re-creates instead of
+ editing — `secret-tool store` does exactly that, adding its own
+ `xdg:schema` attribute so it never replaces) are resolved to the most
+ recently modified one.
+- **`op item get` resolves archived ids**, so "still live" is a question
+ for the list, not `get`.
+
+Open question 4 (a locked app) is still open; the daemon's back-off is
+what happens in the meantime. Question 5 (the "top level process" the app
+looks for) is unneeded now.
diff --git a/cce-keyring-sync.service b/cce-keyring-sync.service
index 6a536cc..a2c14d2 100644
--- a/cce-keyring-sync.service
+++ b/cce-keyring-sync.service
@@ -1,11 +1,20 @@
-# Runs one bidirectional pass of cce-keyring-sync (see KEYRING-SYNC.md).
-# Driven by cce-keyring-sync.timer; harmless to start by hand.
+# The resident cce-keyring-sync daemon (see KEYRING-SYNC.md, "Phase 0
+# results"): keeps gnome-keyring and 1Password in step, ticking every five
+# minutes. Resident because the 1Password CLI's authorization is keyed to
+# the calling process's parent and lapses after ~10 idle minutes — a oneshot
+# timer would raise an Authorize dialog on every tick; this process raises
+# one per login. SIGUSR1 syncs now (cce-secrets sends it from its Sync
+# button and after a save).
[Unit]
-Description=Sync gnome-keyring with the Dropbox kdbx
+Description=Sync gnome-keyring with 1Password (resident)
+After=graphical-session.target
+PartOf=graphical-session.target
[Service]
-Type=oneshot
-ExecStart=%h/.local/bin/cce-keyring-sync sync
-# Refusals (conflicted copies, Dropbox settling, another run holding the
-# lock) exit nonzero on purpose; the timer just tries again next tick.
-SuccessExitStatus=0 1
+Type=simple
+ExecStart=%h/.local/bin/cce-keyring-sync daemon
+Restart=on-failure
+RestartSec=30
+
+[Install]
+WantedBy=graphical-session.target
diff --git a/cce-keyring-sync.timer b/cce-keyring-sync.timer
deleted file mode 100644
index 6cabda0..0000000
--- a/cce-keyring-sync.timer
+++ /dev/null
@@ -1,10 +0,0 @@
-[Unit]
-Description=Sync gnome-keyring with the Dropbox kdbx every 15 minutes
-
-[Timer]
-OnBootSec=3min
-OnUnitActiveSec=15min
-RandomizedDelaySec=90
-
-[Install]
-WantedBy=timers.target
diff --git a/scripts/e2e-1password.sh b/scripts/e2e-1password.sh
new file mode 100755
index 0000000..af069ea
--- /dev/null
+++ b/scripts/e2e-1password.sh
@@ -0,0 +1,188 @@
+#!/bin/bash
+# End-to-end exercise of cce-keyring-sync's 1Password merge table
+# (KEYRING-SYNC.md): an isolated gnome-keyring (own session bus, own data
+# and state dirs; the live keyring is never touched) against a throwaway
+# vault in the signed-in 1Password account, created at the start and
+# deleted at the end. Every pass runs through one resident daemon, the
+# shape the systemd unit uses.
+#
+# scripts/e2e-1password.sh # B=<binary> VAULT=<name> to override
+# DAEMON_LOG_COPY=/tmp/d.log … # keep the daemon's output
+#
+# Needs: the 1Password app unlocked with the CLI integration on, op,
+# gnome-keyring-daemon, dbus-launch, gdbus, secret-tool, python3. Raises
+# three Authorize dialogs (this script's own op calls, adopt, the daemon)
+# — click each; an unanswered one costs a 60 s retry. Lessons kept as
+# comments below: `op item get` resolves archived ids, so liveness is
+# checked on the list; `secret-tool store` never edits in place (it makes a
+# second item with the same stamp), so an "edit" deletes the old item by
+# object path first; and a remote edit needs a moment to reach the server
+# before the list reflects it.
+set -u
+B=${B:-$HOME/projects/cce/target/release/cce-keyring-sync}
+VAULT=${VAULT:-cce-sync-test}
+T=$(mktemp -d /tmp/cce-e2e.XXXX)
+# XDG_CONFIG_HOME stays: op finds the app integration through it.
+export XDG_DATA_HOME=$T/data XDG_STATE_HOME=$T/state
+mkdir -p "$XDG_DATA_HOME/keyrings" "$XDG_STATE_HOME"
+pass=0; fail=0
+ok(){ echo " PASS $1"; pass=$((pass+1)); }
+bad(){ echo " FAIL $1"; fail=$((fail+1)); }
+step(){ echo; echo "== $1"; }
+
+# --- isolated keyring on a private session bus ---
+eval "$(dbus-launch --sh-syntax)"
+export DBUS_SESSION_BUS_ADDRESS
+mkdir -p "$T/run"; chmod 700 "$T/run"
+KR_ENV=$(printf 'x\n' | XDG_RUNTIME_DIR=$T/run gnome-keyring-daemon --unlock --components=secrets --daemonize 2>&1)
+sleep 1
+KRPID=$(for p in $(pgrep -f gnome-keyring-daemon); do tr "\0" "\n" < /proc/$p/environ 2>/dev/null | grep -q "cce-e2e" && echo $p; done | head -1)
+echo "isolated gnome-keyring pid $KRPID"
+if ! printf "%s" "p" | secret-tool store --label="probe" probe 1 2>/dev/null; then echo "isolated keyring not usable"; exit 2; fi
+secret-tool clear probe 1
+echo "keyring ok at $DBUS_SESSION_BUS_ADDRESS (data in $T)"
+
+# --- throwaway vault ---
+op vault get "$VAULT" --format json >/dev/null 2>&1 || op vault create "$VAULT" --format json >/dev/null || { echo "vault create failed"; exit 2; }
+# start empty
+for id in $(op item list --vault "$VAULT" --format json | python3 -c 'import json,sys;print(" ".join(i["id"] for i in json.load(sys.stdin)))'); do op item delete "$id" --vault "$VAULT"; done
+mk(){ # title user pass url notes -> id
+ op item template get Login | python3 -c '
+import json,sys
+t=json.load(sys.stdin); a=sys.argv[1:]
+t["title"]=a[0]
+for f in t["fields"]:
+ f["value"]={"USERNAME":a[1],"PASSWORD":a[2],"NOTES":a[4]}[f["purpose"]]
+if a[3]: t["urls"]=[{"label":"website","primary":True,"href":a[3]}]
+print(json.dumps(t))' "$@" | op item create --vault "$VAULT" --format json - | python3 -c 'import json,sys;print(json.load(sys.stdin)["id"])'
+}
+get(){ op item get "$1" --format json | python3 -c '
+import json,sys;d=json.load(sys.stdin)
+f={x["purpose"]:x.get("value","") for x in d["fields"] if "purpose" in x}
+u=[x["href"] for x in d.get("urls",[]) if x.get("primary")] or [x["href"] for x in d.get("urls",[])] or [""]
+print("|".join([d["title"],f.get("USERNAME",""),f.get("PASSWORD",""),u[0],f.get("NOTES","")]))'; }
+# `secret-tool clear` skips items that lack its xdg:schema attribute (the
+# ones the sync creates), so "delete every item with this stamp" goes
+# through D-Bus by object path.
+kr_clear(){ for p in $(secret-tool search --all op-item "$1" 2>/dev/null | sed -n 's/^\[\(\/[0-9]*\)\]$/\/org\/freedesktop\/secrets\/collection\/login\1/p'); do gdbus call --session --dest org.freedesktop.secrets --object-path "$p" --method org.freedesktop.Secret.Item.Delete >/dev/null; done; }
+kr_path(){ secret-tool search --all op-item "$1" 2>/dev/null | sed -n 's/^\[\(\/[0-9]*\)\]$/\/org\/freedesktop\/secrets\/collection\/login\1/p' | head -1; }
+kr_get(){ # by op-item id → title|user|secret|url|notes
+ local path; path=$(kr_path "$1"); [ -z "$path" ] && { echo "<none>||||"; return; }
+ local attrs; attrs=$(gdbus call --session --dest org.freedesktop.secrets --object-path "$path" --method org.freedesktop.DBus.Properties.Get org.freedesktop.Secret.Item Attributes 2>/dev/null)
+ local label; label=$(gdbus call --session --dest org.freedesktop.secrets --object-path "$path" --method org.freedesktop.DBus.Properties.Get org.freedesktop.Secret.Item Label 2>/dev/null | sed -n "s/^(<'\(.*\)'>,)$/\1/p")
+ local secret; secret=$(secret-tool lookup op-item "$1" 2>/dev/null)
+ python3 - "$label" "$secret" "$attrs" <<'P'
+import sys,re
+label,secret,attrs=sys.argv[1:4]
+def a(k):
+ m=re.search(r"'"+re.escape(k)+r"': '((?:[^'\\]|\\.)*)'",attrs); return m.group(1) if m else ""
+print("|".join([label,a("UserName"),secret,a("URL"),a("Notes")]))
+P
+}
+kr_count(){ secret-tool search --all op-vault "$VAULT" 2>/dev/null | grep -c '^label' ; }
+mkdir -p $T/bin; cat > $T/bin/op <<'W'
+#!/bin/bash
+# test hook: fail `item edit` while the toggle file exists; pass through otherwise
+if [ "$1" = item ] && [ "$2" = edit ] && [ -e "$FAIL_EDIT_TOGGLE" ]; then echo "[ERROR] 2026/01/01 00:00:00 simulated failure" >&2; exit 1; fi
+exec /usr/sbin/op "$@"
+W
+chmod +x $T/bin/op
+export FAIL_EDIT_TOGGLE=$T/fail-edit
+STATE=$XDG_STATE_HOME/cce/keyring-sync/state.json
+last_run(){ python3 -c 'import json,sys;print(json.load(open(sys.argv[1])).get("last_run",0))' "$STATE" 2>/dev/null || echo 0; }
+# One resident daemon for the whole run: its op children share one parent,
+# so one Authorize dialog covers every pass. SIGUSR1 = pass now.
+PATH=$T/bin:$PATH "$B" daemon > $T/daemon.log 2>&1 &
+DPID=$!
+sleep 1
+dump_kr(){ echo " -- keyring items:"; secret-tool search --all op-vault "$VAULT" 2>/dev/null | grep -E "^\[|^label|attribute.op-item|^modified" | paste - - - - | sed "s/^/ /"; }
+sync(){
+ local before; before=$(last_run); local n=0
+ local mark; mark=$(wc -l < $T/daemon.log)
+ kill -USR1 $DPID
+ while [ $n -lt 200 ]; do sleep 0.5; n=$((n+1)); [ "$(last_run)" -gt "$before" ] && break; done
+ [ $n -ge 200 ] && echo " (daemon did not finish a pass in 100s)"
+ tail -n +$((mark+1)) $T/daemon.log | grep -vE '^$' | tail -4
+}
+
+step "0. adopt on an empty keyring seeds the base (vault has 2 items)"
+A=$(mk "Alpha" "alice" "pw-a1" "https://alpha.example" "note a")
+C=$(mk "Gamma" "carol" "pw-c1" "" "")
+"$B" adopt --vault "$VAULT" 2>&1 | tail -4
+"$B" status
+step "1. first sync mirrors 1Password -> keyring"
+sync
+[ "$(kr_count)" = 2 ] && ok "2 items landed in the keyring" || bad "keyring has $(kr_count) items"
+[ "$(kr_get "$A")" = "Alpha|alice|pw-a1|https://alpha.example|note a" ] && ok "fields intact" || bad "fields: $(kr_get "$A")"
+step "2. idempotent: a second pass fetches nothing"
+out=$(sync); echo "$out" | grep -q 'in sync (0 fetched)' && ok "in sync, 0 fetched" || bad "$out"
+step "3. edit in 1Password -> keyring"
+sleep 1; op item edit "$A" password=pw-a2 >/dev/null; sleep 2
+sync
+[ "$(kr_get "$A" | cut -d'|' -f3)" = "pw-a2" ] && ok "password followed" || bad "keyring pw: $(kr_get "$A")"
+step "4. edit in keyring -> 1Password (secret + url)"
+sleep 1
+kr_clear "$A"; printf "%s" "pw-a3" | secret-tool store --label="Alpha" op-item "$A" op-vault "$VAULT" UserName alice URL "https://alpha2.example" Notes "note a"
+sync
+[ "$(get "$A")" = "Alpha|alice|pw-a3|https://alpha2.example|note a" ] && ok "1Password followed" || bad "remote: $(get "$A")"
+step "5. keyring-born entry is created in 1Password and stamped"
+printf "%s" "pw-d1" | secret-tool store --label="Delta" UserName dave URL "https://delta.example" Notes ""
+sync
+D=$(op item list --vault "$VAULT" --format json | python3 -c 'import json,sys;print(next(i["id"] for i in json.load(sys.stdin) if i["title"]=="Delta"))')
+[ -n "$D" ] && [ "$(get "$D")" = "Delta|dave|pw-d1|https://delta.example|" ] && ok "created remotely" || bad "remote Delta: $(get "$D")"
+[ "$(kr_get "$D" | cut -d'|' -f1)" = "Delta" ] && ok "stamped with its id" || bad "stamp missing"
+step "6. new item in 1Password is mirrored"
+E=$(mk "Epsilon" "erin" "pw-e1" "https://eps.example" "n"); sleep 2
+sync
+[ "$(kr_get "$E" | cut -d'|' -f3)" = "pw-e1" ] && ok "mirrored" || bad "not mirrored"
+step "7. delete in keyring -> archived in 1Password"
+kr_clear "$C"
+sync
+op item list --vault "$VAULT" --format json | grep -q "\"$C\"" && bad "Gamma still live" || ok "Gamma gone from the live list"
+op item get "$C" --include-archive --format json >/dev/null 2>&1 && ok "Gamma is in the Archive, not deleted" || bad "Gamma not in archive"
+step "8. archive in 1Password -> deleted from keyring"
+op item delete "$E" --archive; sleep 2
+sync
+[ -z "$(secret-tool search --all op-item "$E" 2>/dev/null)" ] && ok "Epsilon removed from keyring" || bad "Epsilon still in keyring"
+step "9. conflict: both edited, newer wins (keyring, edited last)"
+op item edit "$A" password=pw-a4 >/dev/null; sleep 4
+kr_clear "$A"; printf "%s" "pw-a5" | secret-tool store --label="Alpha" op-item "$A" op-vault "$VAULT" UserName alice URL "https://alpha2.example" Notes "note a"
+sync
+[ "$(get "$A" | cut -d'|' -f3)" = "pw-a5" ] && [ "$(kr_get "$A" | cut -d'|' -f3)" = "pw-a5" ] && ok "keyring won, both sides pw-a5" || bad "remote=$(get "$A" | cut -d'|' -f3) keyring=$(kr_get "$A" | cut -d'|' -f3)"
+step "10. conflict the other way: 1Password edited last"
+kr_clear "$A"; printf "%s" "pw-a6" | secret-tool store --label="Alpha" op-item "$A" op-vault "$VAULT" UserName alice URL "https://alpha2.example" Notes "note a"; sleep 4
+op item edit "$A" password=pw-a7 >/dev/null; sleep 2
+sync
+[ "$(get "$A" | cut -d'|' -f3)" = "pw-a7" ] && [ "$(kr_get "$A" | cut -d'|' -f3)" = "pw-a7" ] && ok "1Password won, both sides pw-a7" || bad "remote=$(get "$A" | cut -d'|' -f3) keyring=$(kr_get "$A" | cut -d'|' -f3)"
+step "11. modification beats deletion: archived remotely, edited locally -> recreated"
+op item delete "$D" --archive; sleep 2
+kr_clear "$D"; printf "%s" "pw-d2" | secret-tool store --label="Delta" op-item "$D" op-vault "$VAULT" UserName dave URL "https://delta.example" Notes "edited"
+sync
+D2=$(op item list --vault "$VAULT" --format json | python3 -c 'import json,sys;print(next((i["id"] for i in json.load(sys.stdin) if i["title"]=="Delta"),""))')
+[ -n "$D2" ] && [ "$D2" != "$D" ] && [ "$(get "$D2" | cut -d'|' -f3)" = "pw-d2" ] && ok "resurrected as a new item" || bad "D2=$D2"
+[ "$(kr_get "$D2" | cut -d'|' -f1)" = "Delta" ] && ok "restamped" || bad "restamp missing"
+dump_kr; echo " -- state ids: $(python3 -c 'import json,sys;print(list(json.load(open(sys.argv[1]))["entries"]))' "$STATE")"; echo " -- remote live: $(op item list --vault "$VAULT" --format json | python3 -c 'import json,sys;print([(i["title"],i["id"]) for i in json.load(sys.stdin)])')"
+step "12. mid-apply op failure stops the loop and keeps the base for the rest"
+touch "$FAIL_EDIT_TOGGLE"
+kr_clear "$A"; printf "%s" "pw-a8" | secret-tool store --label="Alpha" op-item "$A" op-vault "$VAULT" UserName alice URL "https://alpha2.example" Notes "note a"
+out=$(sync); echo "$out" | tail -2
+echo "$out" | grep -q 'failed: op item: simulated failure' && ok "reported the failure" || bad "no failure report: $out"
+[ "$(get "$A" | cut -d'|' -f3)" = "pw-a7" ] && ok "remote untouched" || bad "remote changed"
+rm -f "$FAIL_EDIT_TOGGLE"
+sync
+[ "$(get "$A" | cut -d'|' -f3)" = "pw-a8" ] && ok "retried on the next run" || bad "retry did not land"
+dump_kr; echo " -- state ids: $(python3 -c 'import json,sys;print(list(json.load(open(sys.argv[1]))["entries"]))' "$STATE")"; echo " -- remote live: $(op item list --vault "$VAULT" --format json | python3 -c 'import json,sys;print([(i["title"],i["id"]) for i in json.load(sys.stdin)])')"
+step "13. quiet again"
+out=$(sync); echo "$out" | grep -q 'in sync (0 fetched)' && ok "in sync, 0 fetched" || bad "$out"
+
+echo; echo "== $pass passed, $fail failed"
+echo "state: $XDG_STATE_HOME/cce/keyring-sync/state.json"
+grep -c '"h"' "$XDG_STATE_HOME/cce/keyring-sync/state.json" | sed 's/^/base entries: /'
+grep -qE 'pw-a|pw-d|pw-e' "$XDG_STATE_HOME/cce/keyring-sync/state.json" && echo "!! a VALUE leaked into the state file" || echo "no values in the state file"
+# --- teardown ---
+kill -TERM $DPID; wait $DPID 2>/dev/null; cp $T/daemon.log "${DAEMON_LOG_COPY:-/dev/null}"; echo "daemon log: $(wc -l < $T/daemon.log) lines"
+op vault delete "$VAULT" >/dev/null && echo "vault $VAULT deleted"
+[ -n "$KRPID" ] && kill "$KRPID" 2>/dev/null && echo "isolated keyring stopped"
+kill "$DBUS_SESSION_BUS_PID" 2>/dev/null
+rm -rf "$T"
+exit $fail
diff --git a/src/bin/cce-keyring-sync/adopt.rs b/src/bin/cce-keyring-sync/adopt.rs
index b927413..070a85e 100644
--- a/src/bin/cce-keyring-sync/adopt.rs
+++ b/src/bin/cce-keyring-sync/adopt.rs
@@ -160,11 +160,20 @@ pub async fn adopt(state_path: &std::path::Path, mut state: State, vault: &str,
std::process::exit(1);
}
};
+ // The state-file hash key: minted once, kept in the keyring so the
+ // state file alone leaks nothing (a fresh keyring has none yet).
let hash_key: [u8; 32] = match keyring_get(&ss, "state-hash-key").await {
Ok(Some(b)) if b.len() == 32 => b.try_into().unwrap(),
_ => {
- eprintln!("no state hash key — run `cce-keyring-sync import` once (kdbx) first");
- std::process::exit(1);
+ let mut k = [0u8; 32];
+ getrandom::getrandom(&mut k).expect("entropy");
+ if !dry_run {
+ if let Err(e) = crate::keyring_put(&ss, "state-hash-key", "cce-keyring-sync: state hash key", &k).await {
+ eprintln!("could not store the hash key: {e}");
+ std::process::exit(1);
+ }
+ }
+ k
}
};
let col = match ss.get_default_collection().await {
diff --git a/src/bin/cce-keyring-sync/daemon.rs b/src/bin/cce-keyring-sync/daemon.rs
new file mode 100644
index 0000000..dee59ce
--- /dev/null
+++ b/src/bin/cce-keyring-sync/daemon.rs
@@ -0,0 +1,68 @@
+//! `daemon` — the resident parent that keeps the `op` authorization alive.
+//!
+//! Phase 0 (KEYRING-SYNC.md) measured the rule this loop lives by: the
+//! app's authorization is keyed to the calling process's parent and lapses
+//! after ~10 idle minutes, but use extends it indefinitely. So this process
+//! stays up for the session, ticks every [`TICK`], and every tick is one
+//! `op item list` under its own pid. One Authorize dialog per login, then
+//! none, as long as nothing (suspend, the app locking) opens a gap.
+//!
+//! A dialog nobody answers costs a 60-second hang and comes back as
+//! `authorization prompt dismissed`; re-offering one every five minutes to an
+//! empty chair is the annoyance the timer design was rejected for, so after a
+//! dismissal the tick backs off (15 → 30 → 60 minutes) until something asks:
+//! `SIGUSR1`, which cce-secrets sends from its Sync button and after a save.
+
+use std::time::Duration;
+
+use tokio::signal::unix::{signal, SignalKind};
+
+use crate::op::{is_dismissed, OnePassword};
+use crate::sync::sync_remote;
+use crate::{now_unix, State};
+
+/// Inside the ~10-minute idle window with margin.
+pub const TICK: Duration = Duration::from_secs(5 * 60);
+const BACKOFF: [Duration; 3] = [Duration::from_secs(15 * 60), Duration::from_secs(30 * 60), Duration::from_secs(60 * 60)];
+
+pub async fn daemon(state_path: &std::path::Path) {
+ let mut usr1 = signal(SignalKind::user_defined1()).expect("SIGUSR1 handler");
+ let mut term = signal(SignalKind::terminate()).expect("SIGTERM handler");
+ let mut dismissed = 0usize;
+ println!("cce-keyring-sync daemon: tick every {}s, SIGUSR1 syncs now", TICK.as_secs());
+
+ loop {
+ // Re-read every tick: adopt or a manual sync may have moved the base.
+ let mut state: State = std::fs::read_to_string(state_path)
+ .ok()
+ .and_then(|s| serde_json::from_str(&s).ok())
+ .unwrap_or_default();
+ let wait = if state.backend != "onepassword" {
+ eprintln!("{}: base is not 1Password's; idling until `adopt` runs", now_unix());
+ TICK
+ } else {
+ let mut remote = OnePassword::new(&state.vault);
+ match sync_remote(&mut remote, state_path, &mut state, false).await {
+ Ok(_) => {
+ dismissed = 0;
+ TICK
+ }
+ Err(e) if is_dismissed(&e) => {
+ let w = BACKOFF[dismissed.min(BACKOFF.len() - 1)];
+ dismissed += 1;
+ eprintln!("authorization dialog unanswered; next try in {}m (or SIGUSR1)", w.as_secs() / 60);
+ w
+ }
+ Err(e) => {
+ eprintln!("sync: {e}");
+ TICK
+ }
+ }
+ };
+ tokio::select! {
+ _ = tokio::time::sleep(wait) => {}
+ _ = usr1.recv() => { dismissed = 0; }
+ _ = term.recv() => { println!("cce-keyring-sync daemon: stopping"); return; }
+ }
+ }
+}
diff --git a/src/bin/cce-keyring-sync/main.rs b/src/bin/cce-keyring-sync/main.rs
index 6af163e..be3c5d5 100644
--- a/src/bin/cce-keyring-sync/main.rs
+++ b/src/bin/cce-keyring-sync/main.rs
@@ -17,7 +17,9 @@
//! bridge: label=Title, UserName, URL, Notes, plus kdbx-uuid / kdbx-group.
mod adopt;
+mod daemon;
mod op;
+mod sync;
use std::collections::HashMap;
use std::io::Cursor;
@@ -57,6 +59,10 @@ pub(crate) struct State {
/// 1Password only: the vault new entries are created in.
#[serde(default)]
pub vault: String,
+ /// The last run's one-line outcome ("in sync", "synced: …", "failed: …"),
+ /// for cce-secrets' status line — the daemon has no stdout anyone reads.
+ #[serde(default)]
+ pub last_result: String,
/// Per entry id: the last-synced snapshot the merge runs against.
pub entries: HashMap<String, EntryState>,
}
@@ -199,7 +205,7 @@ pub(crate) async fn keyring_get(
}
}
-async fn keyring_put(
+pub(crate) async fn keyring_put(
ss: &SecretService<'_>,
purpose: &str,
label: &str,
@@ -256,8 +262,10 @@ async fn main() {
Some("doctor") => "doctor",
Some("status") => "status",
Some("adopt") => "adopt",
+ Some("daemon") => "daemon",
_ => {
eprintln!("usage: cce-keyring-sync sync [--dry-run] [--kdbx <path>]");
+ eprintln!(" cce-keyring-sync daemon (resident; 1Password backend)");
eprintln!(" cce-keyring-sync import [--dry-run] [--kdbx <path>]");
eprintln!(" cce-keyring-sync doctor [--kdbx <path>]");
eprintln!(" cce-keyring-sync adopt [--dry-run] [--vault <name>] (pair the keyring with 1Password)");
@@ -286,6 +294,9 @@ async fn main() {
println!("kdbx: {}", kdbx.display());
}
println!("state: {} entries, last run {}", state.entries.len(), state.last_run);
+ if !state.last_result.is_empty() {
+ println!("last: {}", state.last_result);
+ }
if !onepassword {
for c in conflicted_copies(&kdbx) {
println!("CONFLICT: {}", c.display());
@@ -297,11 +308,25 @@ async fn main() {
adopt::adopt(&state_path, state, vault_flag.as_deref().unwrap_or(""), dry_run).await;
return;
}
+ if cmd == "daemon" {
+ daemon::daemon(&state_path).await;
+ return;
+ }
if onepassword {
+ if cmd == "sync" {
+ // A one-shot pass (its own Authorize dialog); the daemon is the
+ // usual caller, and the flock keeps the two apart.
+ let mut remote = op::OnePassword::new(&state.vault);
+ let mut state = state;
+ if let Err(e) = sync::sync_remote(&mut remote, &state_path, &mut state, dry_run).await {
+ eprintln!("{e}");
+ std::process::exit(1);
+ }
+ return;
+ }
// The kdbx paths key their base by kdbx UUID; running one against a
// 1Password base would re-plan every entry from nothing.
eprintln!("the sync base belongs to the 1Password backend; `{cmd}` is kdbx-only");
- eprintln!("(phase 2's daemon is not built yet — see KEYRING-SYNC.md)");
std::process::exit(1);
}
@@ -566,7 +591,7 @@ const SKEW_TOLERANCE_SECS: i64 = 3;
/// A crude cross-process lock: sync and import must not interleave with a
/// timer run. Advisory flock on a file in the state dir (local, never in
/// Dropbox — Dropbox syncing lock files is its own disaster).
-fn take_lock() -> Option<std::fs::File> {
+pub(crate) fn take_lock() -> Option<std::fs::File> {
let _ = std::fs::create_dir_all(state_dir());
let f = std::fs::OpenOptions::new()
.create(true)
diff --git a/src/bin/cce-keyring-sync/op.rs b/src/bin/cce-keyring-sync/op.rs
index f9b23e8..3c3ac82 100644
--- a/src/bin/cce-keyring-sync/op.rs
+++ b/src/bin/cce-keyring-sync/op.rs
@@ -35,8 +35,11 @@ pub struct RemoteEntry {
pub notes: String,
/// Server-side modification time, unix seconds (0 when unparseable).
pub updated: i64,
- /// The interchange's own timestamp text, verbatim, so a base snapshot
- /// compares without re-parsing (1Password: RFC 3339 `updated_at`).
+ /// `updated` as canonical RFC 3339 UTC text (`2026-09-21T16:27:42Z`),
+ /// the form the base snapshot stores. Canonical because `op` itself is
+ /// not consistent: `item list` prints UTC to the second, `item get` and
+ /// `item edit` print local time with an offset and nanoseconds, and a
+ /// base written from one must still match a list read from the other.
pub updated_raw: String,
}
@@ -54,24 +57,23 @@ pub struct RemoteSummary {
}
/// The cross-machine store the keyring is mirrored against.
-#[allow(dead_code)] // create/update/recycle are phase 2's callers
pub trait Interchange {
- fn name(&self) -> &'static str;
/// Every login the store holds — no secrets.
async fn list(&mut self) -> Result<Vec<RemoteSummary>, String>;
/// One entry in full.
async fn fetch(&mut self, id: &str) -> Result<RemoteEntry, String>;
- /// Store a new entry; returns its id. `e.id` is ignored.
- async fn create(&mut self, e: &RemoteEntry) -> Result<String, String>;
- /// Overwrite an existing entry's synced fields, leaving the rest alone.
- async fn update(&mut self, e: &RemoteEntry) -> Result<(), String>;
+ /// Store a new entry; returns its id and its timestamp text. `e.id` is ignored.
+ async fn create(&mut self, e: &RemoteEntry) -> Result<(String, String), String>;
+ /// Overwrite an existing entry's synced fields, leaving the rest alone;
+ /// returns the entry's new timestamp text, so the base can record it
+ /// without another fetch.
+ async fn update(&mut self, e: &RemoteEntry) -> Result<String, String>;
/// Soft-delete: 1Password's Archive, the kdbx's Recycle Bin.
async fn recycle(&mut self, id: &str) -> Result<(), String>;
}
/// True when the error text is the app's dialog timing out — a refusal to
-/// back off from, not a fault to log as one. Phase 2's daemon is the caller.
-#[allow(dead_code)]
+/// back off from, not a fault to log as one.
pub fn is_dismissed(err: &str) -> bool {
err.contains(DISMISSED)
}
@@ -134,10 +136,6 @@ impl OnePassword {
}
impl Interchange for OnePassword {
- fn name(&self) -> &'static str {
- "onepassword"
- }
-
async fn list(&mut self) -> Result<Vec<RemoteSummary>, String> {
let mut args = vec!["item", "list", "--categories", "Login"];
if !self.vault.is_empty() {
@@ -153,7 +151,7 @@ impl Interchange for OnePassword {
Ok(entry_from_json(&v))
}
- async fn create(&mut self, e: &RemoteEntry) -> Result<String, String> {
+ async fn create(&mut self, e: &RemoteEntry) -> Result<(String, String), String> {
if self.vault.is_empty() {
return Err("no vault configured for new entries (adopt --vault <name>)".into());
}
@@ -161,19 +159,18 @@ impl Interchange for OnePassword {
let v = self
.run_json(&["item", "create", "--vault", self.vault.as_str(), "-"], Some(template))
.await?;
- v.get("id")
- .and_then(Value::as_str)
- .map(str::to_string)
- .ok_or_else(|| "op item create: no id in reply".to_string())
+ let id = v.get("id").and_then(Value::as_str).ok_or("op item create: no id in reply")?;
+ Ok((id.to_string(), updated_of(&v).1))
}
- async fn update(&mut self, e: &RemoteEntry) -> Result<(), String> {
+ async fn update(&mut self, e: &RemoteEntry) -> Result<String, String> {
// Round-trip the whole item so sections, custom fields and tags
// survive; only the synced fields are rewritten.
let mut v = self.run_json(&["item", "get", &e.id], None).await?;
apply_entry(&mut v, e);
let body = serde_json::to_vec(&v).unwrap();
- self.run_json(&["item", "edit", &e.id], Some(body)).await.map(|_| ())
+ let reply = self.run_json(&["item", "edit", &e.id], Some(body)).await?;
+ Ok(updated_of(&reply).1)
}
async fn recycle(&mut self, id: &str) -> Result<(), String> {
@@ -210,15 +207,23 @@ fn field_by_purpose<'a>(v: &'a Value, purpose: &str) -> Option<&'a Value> {
.find(|f| f.get("purpose").and_then(Value::as_str) == Some(purpose))
}
+/// The (unix, canonical text) pair for an item's `updated_at`.
+pub fn updated_of(v: &Value) -> (i64, String) {
+ match parse_rfc3339(&s(v, "updated_at")) {
+ Some(t) => (t, format_rfc3339(t)),
+ None => (0, String::new()),
+ }
+}
+
pub fn summary_from_json(v: &Value) -> RemoteSummary {
- let updated_raw = s(v, "updated_at");
+ let (updated, updated_raw) = updated_of(v);
RemoteSummary {
id: s(v, "id"),
vault: v.get("vault").map(|x| s(x, "name")).unwrap_or_default(),
title: s(v, "title"),
username: s(v, "additional_information"),
url: primary_url(v),
- updated: parse_rfc3339(&updated_raw).unwrap_or(0),
+ updated,
updated_raw,
}
}
@@ -297,25 +302,57 @@ pub fn apply_entry(v: &mut Value, e: &RemoteEntry) {
set(v, "NOTES", "notesPlain", "STRING", &e.notes);
}
-/// `2026-09-21T15:41:14Z` (optionally with fraction) → unix seconds. Only
-/// the UTC form 1Password emits; anything else is None, and the caller
-/// treats 0 as "unknown", which the skew tolerance already absorbs.
+/// RFC 3339 → unix seconds: `2026-09-21T15:41:14Z`, or with a fraction,
+/// or with a `±HH:MM` offset (what `op item get`/`edit` print). Fractions
+/// are dropped: the list side only has seconds, and the two must agree.
pub fn parse_rfc3339(t: &str) -> Option<i64> {
- let t = t.strip_suffix('Z')?;
- let (date, time) = t.split_once('T')?;
+ let (date, rest) = t.split_once('T')?;
+ let (time, offset_secs) = if let Some(r) = rest.strip_suffix('Z') {
+ (r, 0)
+ } else {
+ let i = rest.rfind(['+', '-'])?;
+ let (r, off) = rest.split_at(i);
+ let sign = if off.starts_with('-') { -1 } else { 1 };
+ let (oh, om) = off[1..].split_once(':')?;
+ (r, sign * (oh.parse::<i64>().ok()? * 3600 + om.parse::<i64>().ok()? * 60))
+ };
let mut d = date.split('-').map(|p| p.parse::<i64>().ok());
let (y, m, day) = (d.next()??, d.next()??, d.next()??);
let time = time.split('.').next()?;
let mut c = time.split(':').map(|p| p.parse::<i64>().ok());
let (h, mi, sec) = (c.next()??, c.next()??, c.next()??);
- // Howard Hinnant's days-from-civil.
+ Some(days_from_civil(y, m, day) * 86400 + h * 3600 + mi * 60 + sec - offset_secs)
+}
+
+/// unix seconds → `2026-09-21T16:27:42Z`, the canonical base form.
+pub fn format_rfc3339(t: i64) -> String {
+ let days = t.div_euclid(86400);
+ let rem = t.rem_euclid(86400);
+ let (y, m, d) = civil_from_days(days);
+ format!("{y:04}-{m:02}-{d:02}T{:02}:{:02}:{:02}Z", rem / 3600, (rem % 3600) / 60, rem % 60)
+}
+
+// Howard Hinnant's civil-date algorithms.
+fn days_from_civil(y: i64, m: i64, d: i64) -> i64 {
let (y, m) = if m <= 2 { (y - 1, m + 9) } else { (y, m - 3) };
let era = y.div_euclid(400);
let yoe = y - era * 400;
- let doy = (153 * m + 2) / 5 + day - 1;
+ let doy = (153 * m + 2) / 5 + d - 1;
let doe = yoe * 365 + yoe / 4 - yoe / 100 + doy;
- let days = era * 146097 + doe - 719468;
- Some(days * 86400 + h * 3600 + mi * 60 + sec)
+ era * 146097 + doe - 719468
+}
+
+fn civil_from_days(z: i64) -> (i64, i64, i64) {
+ let z = z + 719468;
+ let era = z.div_euclid(146097);
+ let doe = z - era * 146097;
+ let yoe = (doe - doe / 1460 + doe / 36524 - doe / 146096) / 365;
+ let y = yoe + era * 400;
+ let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
+ let mp = (5 * doy + 2) / 153;
+ let d = doy - (153 * mp + 2) / 5 + 1;
+ let m = if mp < 10 { mp + 3 } else { mp - 9 };
+ (if m <= 2 { y + 1 } else { y }, m, d)
}
#[cfg(test)]
@@ -413,12 +450,26 @@ mod tests {
}
#[test]
- fn rfc3339_parses_1password_timestamps_only() {
+ fn rfc3339_parses_both_forms_op_prints_to_the_same_second() {
assert_eq!(parse_rfc3339("1970-01-01T00:00:00Z"), Some(0));
assert_eq!(parse_rfc3339("2026-09-21T15:41:14Z"), Some(1790005274));
assert_eq!(parse_rfc3339("2026-09-21T15:41:14.5Z"), Some(1790005274));
- assert_eq!(parse_rfc3339("2026-09-21T15:41:14+02:00"), None);
+ // `op item edit` printed this for the item `op item list` showed as 2026-09-21T16:27:42Z.
+ assert_eq!(parse_rfc3339("2026-09-21T12:27:42.39627885-04:00"), parse_rfc3339("2026-09-21T16:27:42Z"));
+ assert_eq!(parse_rfc3339("2026-09-21T18:27:42+02:00"), parse_rfc3339("2026-09-21T16:27:42Z"));
assert_eq!(parse_rfc3339(""), None);
+ assert_eq!(parse_rfc3339("nope"), None);
+ }
+
+ #[test]
+ fn the_canonical_form_round_trips() {
+ for t in [0i64, 951782400, 1790005274, 1790008062, 4102444799] {
+ assert_eq!(parse_rfc3339(&format_rfc3339(t)), Some(t), "{t}");
+ }
+ assert_eq!(format_rfc3339(1790005274), "2026-09-21T15:41:14Z");
+ assert_eq!(format_rfc3339(951782400), "2000-02-29T00:00:00Z");
+ let v: Value = json!({"updated_at": "2026-09-21T12:27:42.39627885-04:00"});
+ assert_eq!(updated_of(&v).1, "2026-09-21T16:27:42Z", "a get/edit reply stores as the list form");
}
#[test]
diff --git a/src/bin/cce-keyring-sync/sync.rs b/src/bin/cce-keyring-sync/sync.rs
new file mode 100644
index 0000000..2161720
--- /dev/null
+++ b/src/bin/cce-keyring-sync/sync.rs
@@ -0,0 +1,552 @@
+//! The three-way merge against an `Interchange` — the 1Password path.
+//!
+//! Same table as KEYRING-SYNC.md's, keyed by the interchange's item id
+//! (`op-item` on the keyring side). What differs from the kdbx `sync` in
+//! main.rs: there is no file, so nothing is batched — every remote write is
+//! one `op` call, and the first remote failure stops the apply loop with the
+//! base snapshot kept for everything not yet applied, so the next run
+//! re-plans from the same place. Conflict losers need no History push:
+//! 1Password records item history on every edit.
+//!
+//! Change detection on the remote side is by `updated_at`: an entry whose
+//! timestamp still equals the base's is unchanged and never fetched, so a
+//! quiet tick is one `op item list` and no secrets.
+
+use std::collections::HashMap;
+
+use secret_service::{EncryptionType, SecretService};
+
+use crate::adopt::{OP_ITEM_ATTR, OP_VAULT_ATTR};
+use crate::op::{Interchange, RemoteEntry};
+use crate::{journal_append, keyring_get, now_unix, take_lock, write_state, EntryState, KrEntry, State, APP};
+
+/// Allowed clock skew before "newer" means anything (the keyring's
+/// `Modified` is local time; the remote's is the server's).
+pub const SKEW_TOLERANCE_SECS: i64 = 3;
+
+/// Items the remote lists that must never reach the keyring: 1Password's
+/// own account item carries the Secret Key and account password.
+pub fn excluded_title(title: &str) -> bool {
+ title.starts_with("1Password Account")
+}
+
+/// What one entry needs done.
+#[derive(Debug, Clone, Copy, PartialEq)]
+pub enum Plan {
+ ToKeyring,
+ ToRemote,
+ /// Born in the keyring (or resurrected there): create remotely, stamp.
+ CreateRemote,
+ /// Both changed: newer wins, tie to the remote.
+ ConflictRemoteWins,
+ ConflictKeyringWins,
+ DeleteKeyring,
+ RecycleRemote,
+ InSync,
+ /// Gone on both sides: drop the base.
+ Forget,
+}
+
+/// One side's view of an entry for planning: its field hash and mtime.
+#[derive(Debug, Clone, PartialEq)]
+pub struct Side {
+ pub hash: String,
+ pub time: i64,
+}
+
+/// The merge table, pure. `base` is the last-synced hash, if any.
+pub fn plan(base: Option<&str>, remote: Option<&Side>, keyring: Option<&Side>) -> Plan {
+ let newer_remote = |r: &Side, k: &Side| (r.time - k.time).abs() <= SKEW_TOLERANCE_SECS || r.time >= k.time;
+ match (base, remote, keyring) {
+ (None, Some(_), None) => Plan::ToKeyring,
+ (None, None, Some(_)) => Plan::CreateRemote,
+ (None, Some(r), Some(k)) => {
+ // Stamped but no base (a run died before writing state).
+ if r.hash == k.hash {
+ Plan::InSync
+ } else if newer_remote(r, k) {
+ Plan::ConflictRemoteWins
+ } else {
+ Plan::ConflictKeyringWins
+ }
+ }
+ (Some(b), Some(r), Some(k)) => match (r.hash != b, k.hash != b) {
+ (false, false) => Plan::InSync,
+ (true, false) => Plan::ToKeyring,
+ (false, true) => Plan::ToRemote,
+ (true, true) => {
+ if newer_remote(r, k) {
+ Plan::ConflictRemoteWins
+ } else {
+ Plan::ConflictKeyringWins
+ }
+ }
+ },
+ // Deleted on one side; modification on the other beats deletion.
+ (Some(b), None, Some(k)) => {
+ if k.hash != b {
+ Plan::CreateRemote
+ } else {
+ Plan::DeleteKeyring
+ }
+ }
+ (Some(b), Some(r), None) => {
+ if r.hash != b {
+ Plan::ToKeyring
+ } else {
+ Plan::RecycleRemote
+ }
+ }
+ (Some(_), None, None) | (None, None, None) => Plan::Forget,
+ }
+}
+
+fn remote_to_kr(e: &RemoteEntry, modified: u64) -> KrEntry {
+ KrEntry {
+ title: e.title.clone(),
+ username: e.username.clone(),
+ password: e.password.clone(),
+ url: e.url.clone(),
+ notes: e.notes.clone(),
+ group: e.vault.clone(),
+ modified,
+ }
+}
+
+fn kr_to_remote(k: &KrEntry, id: &str, vault: &str) -> RemoteEntry {
+ RemoteEntry {
+ id: id.to_string(),
+ vault: vault.to_string(),
+ title: k.title.clone(),
+ username: k.username.clone(),
+ password: k.password.clone(),
+ url: k.url.clone(),
+ notes: k.notes.clone(),
+ updated: 0,
+ updated_raw: String::new(),
+ }
+}
+
+fn keyring_attrs<'a>(k: &'a KrEntry, id: &'a str, extra: &'a HashMap<String, String>) -> HashMap<&'a str, &'a str> {
+ // Keep whatever else the item carried (kdbx-uuid, xdg:schema, …).
+ let mut a: HashMap<&str, &str> = extra.iter().map(|(x, y)| (x.as_str(), y.as_str())).collect();
+ a.insert(OP_ITEM_ATTR, id);
+ a.insert(OP_VAULT_ATTR, k.group.as_str());
+ a.insert("UserName", k.username.as_str());
+ a.insert("URL", k.url.as_str());
+ a.insert("Notes", k.notes.as_str());
+ a
+}
+
+struct Local<'a> {
+ item: secret_service::Item<'a>,
+ attrs: HashMap<String, String>,
+ entry: KrEntry,
+}
+
+/// 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.
+pub async fn sync_remote<I: Interchange>(
+ remote: &mut I,
+ state_path: &std::path::Path,
+ state: &mut State,
+ dry_run: bool,
+) -> Result<String, String> {
+ let Some(_lock) = take_lock() else {
+ return Err("another cce-keyring-sync is running".into());
+ };
+ if state.backend != "onepassword" {
+ return Err("the sync base is not 1Password's — run `cce-keyring-sync adopt` first".into());
+ }
+ let vault = state.vault.clone();
+
+ let ss = SecretService::connect(EncryptionType::Dh)
+ .await
+ .map_err(|e| format!("Secret Service unavailable: {e}"))?;
+ let hash_key: [u8; 32] = match keyring_get(&ss, "state-hash-key").await {
+ Ok(Some(b)) if b.len() == 32 => b.try_into().unwrap(),
+ _ => return Err("no state hash key — run `cce-keyring-sync adopt` first".into()),
+ };
+ let col = ss.get_default_collection().await.map_err(|e| format!("no default collection: {e}"))?;
+ if col.is_locked().await.unwrap_or(false) && col.unlock().await.is_err() {
+ return Err("collection locked".into());
+ }
+
+ // ---- keyring snapshot ----
+ let mut kr: HashMap<String, Local<'_>> = HashMap::new();
+ let mut born: Vec<Local<'_>> = Vec::new();
+ for item in col.get_all_items().await.map_err(|e| format!("listing collection failed: {e}"))? {
+ let Ok(attrs) = item.get_attributes().await else { continue };
+ if attrs.get("application").map(String::as_str) == Some(APP) {
+ continue;
+ }
+ let id = attrs.get(OP_ITEM_ATTR).cloned();
+ if id.is_none() && !attrs.contains_key("UserName") && !attrs.contains_key("kdbx-uuid") {
+ continue; // some other app's item — never ours to sync
+ }
+ let entry = KrEntry {
+ title: item.get_label().await.unwrap_or_default(),
+ username: attrs.get("UserName").cloned().unwrap_or_default(),
+ password: String::from_utf8_lossy(&item.get_secret().await.unwrap_or_default()).into_owned(),
+ url: attrs.get("URL").cloned().unwrap_or_default(),
+ notes: attrs.get("Notes").cloned().unwrap_or_default(),
+ group: attrs.get(OP_VAULT_ATTR).cloned().unwrap_or_else(|| vault.clone()),
+ modified: item.get_modified().await.unwrap_or(0),
+ };
+ let local = Local { item, attrs, entry };
+ match id {
+ Some(id) => {
+ // Two items with one stamp (a tool that re-created rather than
+ // edited): the newer one is the person's latest word.
+ let newer = kr.get(&id).is_none_or(|old| local.entry.modified >= old.entry.modified);
+ if newer {
+ kr.insert(id, local);
+ }
+ }
+ None => born.push(local),
+ }
+ }
+
+ // ---- remote snapshot: the list, then fetches only where needed ----
+ let summaries = remote.list().await?;
+ let mut rs: HashMap<String, crate::op::RemoteSummary> = HashMap::new();
+ for s in summaries {
+ if excluded_title(&s.title) {
+ continue;
+ }
+ rs.insert(s.id.clone(), s);
+ }
+ let mut fetched: HashMap<String, RemoteEntry> = HashMap::new();
+ let mut fetches = 0usize;
+
+ let mut ids: Vec<String> = state.entries.keys().chain(rs.keys()).chain(kr.keys()).cloned().collect();
+ ids.sort();
+ ids.dedup();
+
+ // ---- plan ----
+ let mut plans: Vec<(String, Plan)> = Vec::new();
+ for id in &ids {
+ let base = state.entries.get(id);
+ let k_side = kr.get(id).map(|l| Side { hash: l.entry.hash(&hash_key), time: l.entry.modified as i64 });
+ let r_side = match rs.get(id) {
+ None => None,
+ Some(s) => {
+ let unchanged = base.is_some_and(|b| !b.op_updated_at.is_empty() && b.op_updated_at == s.updated_raw);
+ if unchanged {
+ Some(Side { hash: base.unwrap().h.clone(), time: s.updated })
+ } else {
+ let e = remote.fetch(id).await?;
+ fetches += 1;
+ let h = remote_to_kr(&e, 0).hash(&hash_key);
+ fetched.insert(id.clone(), e);
+ Some(Side { hash: h, time: s.updated })
+ }
+ }
+ };
+ plans.push((id.clone(), plan(base.map(|b| b.h.as_str()), r_side.as_ref(), k_side.as_ref())));
+ }
+
+ // ---- report ----
+ let title_of = |id: &str| -> String {
+ rs.get(id)
+ .map(|s| s.title.clone())
+ .or_else(|| kr.get(id).map(|l| l.entry.title.clone()))
+ .unwrap_or_else(|| id.to_string())
+ };
+ let mut journal = String::new();
+ let mut counts: HashMap<&'static str, usize> = HashMap::new();
+ for (id, p) in &plans {
+ let verb = match p {
+ Plan::ToKeyring => "1Password -> keyring",
+ Plan::ToRemote => "keyring -> 1Password",
+ Plan::CreateRemote => "create in 1Password",
+ Plan::ConflictRemoteWins => "CONFLICT: 1Password wins (loser in item history)",
+ Plan::ConflictKeyringWins => "CONFLICT: keyring wins (loser in item history)",
+ Plan::DeleteKeyring => "delete from keyring",
+ Plan::RecycleRemote => "archive in 1Password",
+ Plan::InSync | Plan::Forget => continue,
+ };
+ *counts
+ .entry(match p {
+ Plan::ToKeyring | Plan::ConflictRemoteWins => "to-keyring",
+ Plan::ToRemote | Plan::ConflictKeyringWins => "to-remote",
+ Plan::CreateRemote => "created",
+ Plan::DeleteKeyring => "deleted",
+ Plan::RecycleRemote => "archived",
+ _ => unreachable!(),
+ })
+ .or_default() += 1;
+ println!(" {verb}: {}", title_of(id));
+ journal.push_str(&format!("{} sync {verb}: {}\n", now_unix(), title_of(id)));
+ }
+ for l in &born {
+ println!(" create in 1Password: {}", l.entry.title);
+ journal.push_str(&format!("{} sync create in 1Password: {}\n", now_unix(), l.entry.title));
+ *counts.entry("created").or_default() += 1;
+ }
+ let c = |k: &str| counts.get(k).copied().unwrap_or(0);
+ let quiet = plans.iter().all(|(_, p)| matches!(p, Plan::InSync | Plan::Forget)) && born.is_empty();
+ let summary = if quiet {
+ "in sync".to_string()
+ } else {
+ format!(
+ "synced: {} -> keyring, {} -> 1Password, {} created, {} deleted, {} archived",
+ c("to-keyring"),
+ c("to-remote"),
+ c("created"),
+ c("deleted"),
+ c("archived")
+ )
+ };
+ if dry_run {
+ println!("{summary} (dry run — nothing changed; {fetches} fetched)");
+ return Ok(summary);
+ }
+
+ // ---- apply ----
+ // `next` starts as the old base and is rewritten entry by entry, so a
+ // remote failure mid-way leaves untouched entries with their old base.
+ let mut next: HashMap<String, EntryState> = state.entries.drain().collect();
+ let mut failure: Option<String> = None;
+ let mut applied = 0usize;
+
+ let snapshot = |k: &KrEntry, updated_raw: String, keyring_modified: u64| EntryState {
+ h: k.hash(&hash_key),
+ kdbx_mtime: 0,
+ keyring_modified,
+ op_updated_at: updated_raw,
+ };
+ 'apply: for (id, p) in &plans {
+ match p {
+ Plan::InSync => {
+ // Keep the base timestamp on the list's value: it was unknown
+ // after adopt (the drift marker), and the server may stamp a
+ // write a second later than the reply we recorded. Either way
+ // the entry would be fetched every tick until this catches up.
+ if let (Some(s), Some(b)) = (rs.get(id), next.get_mut(id)) {
+ if b.op_updated_at != s.updated_raw {
+ b.op_updated_at = s.updated_raw.clone();
+ }
+ }
+ }
+ Plan::Forget => {
+ next.remove(id);
+ }
+ Plan::ToKeyring | Plan::ConflictRemoteWins => {
+ let e = match fetched.get(id) {
+ Some(e) => e.clone(),
+ None => match remote.fetch(id).await {
+ Ok(e) => {
+ fetches += 1;
+ e
+ }
+ Err(err) => {
+ failure = Some(err);
+ break 'apply;
+ }
+ },
+ };
+ let k = remote_to_kr(&e, 0);
+ let empty = HashMap::new();
+ let modified = match kr.get(id) {
+ Some(l) => {
+ let attrs = keyring_attrs(&k, id, &l.attrs);
+ let r = async {
+ l.item.set_label(&k.title).await?;
+ l.item.set_attributes(attrs).await?;
+ l.item.set_secret(k.password.as_bytes(), "text/plain").await?;
+ l.item.get_modified().await
+ }
+ .await;
+ match r {
+ Ok(m) => m,
+ Err(err) => {
+ eprintln!(" keyring write failed for {}: {err}", k.title);
+ continue;
+ }
+ }
+ }
+ None => {
+ let attrs = keyring_attrs(&k, id, &empty);
+ match col.create_item(&k.title, attrs, k.password.as_bytes(), true, "text/plain").await {
+ Ok(item) => item.get_modified().await.unwrap_or(now_unix() as u64),
+ Err(err) => {
+ eprintln!(" keyring create failed for {}: {err}", k.title);
+ continue;
+ }
+ }
+ }
+ };
+ next.insert(id.clone(), snapshot(&k, e.updated_raw.clone(), modified));
+ applied += 1;
+ }
+ Plan::ToRemote | Plan::ConflictKeyringWins => {
+ let l = &kr[id];
+ let e = kr_to_remote(&l.entry, id, &l.entry.group);
+ match remote.update(&e).await {
+ Ok(updated_raw) => {
+ next.insert(id.clone(), snapshot(&l.entry, updated_raw, l.entry.modified));
+ applied += 1;
+ }
+ Err(err) => {
+ failure = Some(err);
+ break 'apply;
+ }
+ }
+ }
+ Plan::CreateRemote => {
+ // A keyring entry whose stamp points at nothing any more
+ // (archived remotely, edited locally): create afresh, restamp.
+ let l = &kr[id];
+ let mut e = kr_to_remote(&l.entry, "", &vault);
+ e.vault = vault.clone();
+ match remote.create(&e).await {
+ Ok((new_id, updated_raw)) => {
+ let mut k = l.entry.clone();
+ k.group = vault.clone();
+ let attrs = keyring_attrs(&k, &new_id, &l.attrs);
+ let modified = match async {
+ l.item.set_attributes(attrs).await?;
+ l.item.get_modified().await
+ }
+ .await
+ {
+ Ok(m) => m,
+ Err(err) => {
+ eprintln!(" could not restamp {}: {err}", k.title);
+ l.entry.modified
+ }
+ };
+ next.remove(id);
+ next.insert(new_id, snapshot(&k, updated_raw, modified));
+ applied += 1;
+ }
+ Err(err) => {
+ failure = Some(err);
+ break 'apply;
+ }
+ }
+ }
+ Plan::DeleteKeyring => {
+ let l = &kr[id];
+ match l.item.delete().await {
+ Ok(()) => {
+ next.remove(id);
+ applied += 1;
+ }
+ Err(err) => eprintln!(" keyring delete failed for {}: {err}", l.entry.title),
+ }
+ }
+ Plan::RecycleRemote => match remote.recycle(id).await {
+ Ok(()) => {
+ next.remove(id);
+ applied += 1;
+ }
+ Err(err) => {
+ failure = Some(err);
+ break 'apply;
+ }
+ },
+ }
+ }
+ if failure.is_none() {
+ for l in &born {
+ let mut k = l.entry.clone();
+ k.group = vault.clone();
+ let e = kr_to_remote(&k, "", &vault);
+ match remote.create(&e).await {
+ Ok((new_id, updated_raw)) => {
+ let attrs = keyring_attrs(&k, &new_id, &l.attrs);
+ let modified = match async {
+ l.item.set_attributes(attrs).await?;
+ l.item.get_modified().await
+ }
+ .await
+ {
+ Ok(m) => m,
+ Err(err) => {
+ eprintln!(" could not stamp {}: {err}", k.title);
+ l.entry.modified
+ }
+ };
+ next.insert(new_id, snapshot(&k, updated_raw, modified));
+ applied += 1;
+ }
+ Err(err) => {
+ failure = Some(err);
+ break;
+ }
+ }
+ }
+ }
+
+ state.entries = next;
+ state.last_run = now_unix();
+ let result = match failure {
+ None => Ok(summary.clone()),
+ Some(err) => {
+ let changes = plans.iter().filter(|(_, p)| !matches!(p, Plan::InSync | Plan::Forget)).count() + born.len();
+ Err(format!("{err} (after {applied} of {changes} changes; the rest retry next run)"))
+ }
+ };
+ state.last_result = match &result {
+ Ok(s) => s.clone(),
+ Err(e) => format!("failed: {e}"),
+ };
+ write_state(state_path, state);
+ if !journal.is_empty() {
+ journal_append(&journal);
+ }
+ if let Err(e) = &result {
+ journal_append(&format!("{} sync FAILED: {e}\n", now_unix()));
+ }
+ println!("{} ({fetches} fetched)", state.last_result);
+ result
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ fn side(h: &str, t: i64) -> Side {
+ Side { hash: h.into(), time: t }
+ }
+
+ #[test]
+ fn the_merge_table() {
+ let b = Some("B");
+ assert_eq!(plan(None, Some(&side("R", 0)), None), Plan::ToKeyring);
+ assert_eq!(plan(None, None, Some(&side("K", 0))), Plan::CreateRemote);
+ assert_eq!(plan(b, Some(&side("B", 0)), Some(&side("B", 0))), Plan::InSync);
+ assert_eq!(plan(b, Some(&side("R", 0)), Some(&side("B", 0))), Plan::ToKeyring);
+ assert_eq!(plan(b, Some(&side("B", 0)), Some(&side("K", 0))), Plan::ToRemote);
+ assert_eq!(plan(b, None, Some(&side("B", 0))), Plan::DeleteKeyring);
+ assert_eq!(plan(b, None, Some(&side("K", 0))), Plan::CreateRemote, "modification beats deletion");
+ assert_eq!(plan(b, Some(&side("B", 0)), None), Plan::RecycleRemote);
+ assert_eq!(plan(b, Some(&side("R", 0)), None), Plan::ToKeyring, "modification beats deletion");
+ assert_eq!(plan(b, None, None), Plan::Forget);
+ assert_eq!(plan(None, None, None), Plan::Forget);
+ }
+
+ #[test]
+ fn conflicts_go_to_the_newer_side_and_ties_to_the_remote() {
+ let b = Some("B");
+ assert_eq!(plan(b, Some(&side("R", 100)), Some(&side("K", 50))), Plan::ConflictRemoteWins);
+ assert_eq!(plan(b, Some(&side("R", 50)), Some(&side("K", 100))), Plan::ConflictKeyringWins);
+ assert_eq!(plan(b, Some(&side("R", 98)), Some(&side("K", 100))), Plan::ConflictRemoteWins, "inside the skew tolerance is a tie");
+ assert_eq!(plan(b, Some(&side("R", 100)), Some(&side("K", 100))), Plan::ConflictRemoteWins);
+ }
+
+ #[test]
+ fn a_stamped_entry_without_a_base_is_reconciled_by_hash() {
+ assert_eq!(plan(None, Some(&side("X", 0)), Some(&side("X", 0))), Plan::InSync);
+ assert_eq!(plan(None, Some(&side("R", 10)), Some(&side("K", 0))), Plan::ConflictRemoteWins);
+ }
+
+ #[test]
+ fn the_account_item_is_excluded() {
+ assert!(excluded_title("1Password Account (lsgalante)"));
+ assert!(!excluded_title("Account at 1Password"));
+ }
+}
diff --git a/src/main.rs b/src/main.rs
index 3b5a359..367978b 100644
--- a/src/main.rs
+++ b/src/main.rs
@@ -92,11 +92,71 @@ enum Mode {
Edit { path: Option<String> },
}
-/// One pass of the external sync tool. Success reports its own summary line
-/// ("synced: … " or "in sync"); refusals — conflicted copies, Dropbox
-/// settling, the flock — arrive as the error text, which is exactly the
-/// guidance the user needs ("run doctor").
+/// cce-keyring-sync's state file: `last_run` (unix seconds) and the last
+/// run's one-line outcome. The daemon's only channel back to this UI.
+fn sync_state_path() -> std::path::PathBuf {
+ std::env::var("XDG_STATE_HOME")
+ .ok()
+ .filter(|s| !s.is_empty())
+ .map(std::path::PathBuf::from)
+ .unwrap_or_else(|| {
+ std::path::PathBuf::from(std::env::var("HOME").unwrap_or_default()).join(".local/state")
+ })
+ .join("cce/keyring-sync/state.json")
+}
+
+fn read_sync_state() -> Option<(i64, String)> {
+ let v: serde_json::Value = serde_json::from_str(&std::fs::read_to_string(sync_state_path()).ok()?).ok()?;
+ let last_run = v.get("last_run")?.as_i64()?;
+ let last_result = v.get("last_result").and_then(|r| r.as_str()).unwrap_or("").to_string();
+ Some((last_run, last_result))
+}
+
+/// Ask the resident daemon for a pass now. Its `op` authorization is the
+/// live one (KEYRING-SYNC.md, phase 0), so this raises no dialog; a
+/// one-shot `cce-keyring-sync sync` from here would. Fire-and-forget: a
+/// save does not wait for the mirror. False when no daemon is running.
+async fn poke_sync_daemon() -> bool {
+ let active = tokio::process::Command::new("systemctl")
+ .args(["--user", "is-active", "--quiet", "cce-keyring-sync.service"])
+ .status()
+ .await
+ .map(|s| s.success())
+ .unwrap_or(false);
+ if !active {
+ return false;
+ }
+ tokio::process::Command::new("systemctl")
+ .args(["--user", "kill", "-s", "SIGUSR1", "cce-keyring-sync.service"])
+ .status()
+ .await
+ .map(|s| s.success())
+ .unwrap_or(false)
+}
+
+/// The Sync button. With the daemon up: poke it and wait for its state
+/// file to record a new run, then show that run's outcome. Without it: the
+/// one-shot binary, whose summary line ("synced: …", "in sync") or
+/// refusal text is the status.
async fn run_sync(tx: &calloop::channel::Sender<AppMessage>) {
+ let before = read_sync_state().map(|(t, _)| t).unwrap_or(0);
+ if poke_sync_daemon().await {
+ // A pass is one `op item list` plus writes; a dialog nobody
+ // answers holds it 60 s. Wait a little past that.
+ for _ in 0..180 {
+ tokio::time::sleep(std::time::Duration::from_millis(500)).await;
+ if let Some((t, result)) = read_sync_state() {
+ if t > before {
+ let is_error = result.starts_with("failed");
+ let msg = if result.is_empty() { "synced".to_string() } else { result };
+ let _ = tx.send(AppMessage::Status(msg, is_error));
+ return;
+ }
+ }
+ }
+ let _ = tx.send(AppMessage::Status("sync daemon did not report within 90s".to_string(), true));
+ return;
+ }
let out = tokio::process::Command::new("cce-keyring-sync")
.arg("sync")
.output()
@@ -163,7 +223,13 @@ fn spawn_worker(rx: std::sync::mpsc::Receiver<Cmd>, tx: calloop::channel::Sender
load_entries(&ss, &tx).await;
}
op => {
+ let edits = matches!(op, Cmd::CreateItem { .. } | Cmd::UpdateItem { .. } | Cmd::DeleteItem { .. });
let Err(first) = run_secret_op(&ss, &tx, op.clone()).await else {
+ if edits {
+ // A saved entry reaches 1Password on the
+ // daemon's next pass; ask for it now.
+ poke_sync_daemon().await;
+ }
continue;
};
// The daemon may have restarted underneath us
@@ -572,24 +638,14 @@ impl SecretsApp {
}
/// "synced 4m ago" from cce-keyring-sync's state file, refreshed at most
- /// every 5s — the timer runs every 15 minutes, so staleness is invisible.
+ /// every 5s — the daemon ticks every 5 minutes, so staleness is invisible.
fn refresh_sync_hint(&mut self) {
if self.sync_hint_at.is_some_and(|t| t.elapsed().as_secs() < 5) {
return;
}
self.sync_hint_at = Some(std::time::Instant::now());
- let path = std::env::var("XDG_STATE_HOME")
- .ok()
- .filter(|s| !s.is_empty())
- .map(std::path::PathBuf::from)
- .unwrap_or_else(|| {
- std::path::PathBuf::from(std::env::var("HOME").unwrap_or_default()).join(".local/state")
- })
- .join("cce/keyring-sync/state.json");
- self.sync_hint = std::fs::read_to_string(path)
- .ok()
- .and_then(|s| serde_json::from_str::<serde_json::Value>(&s).ok())
- .and_then(|v| v.get("last_run").and_then(|n| n.as_i64()))
+ self.sync_hint = read_sync_state()
+ .map(|(t, _)| t)
.filter(|&t| t > 0)
.map(|t| {
let ago = (std::time::SystemTime::now()