git.lucas.co / cce-secrets
secrets manager
git clone https://git.lucas.co/cce-secrets.git

KEYRING-SYNC.md (33.6K)

  1 # Scoping: cce-keyring-sync — passwords synced across machines, cce-native UI here
  2 
  3 Status: **phase 1 shipped** (2026-08-31) — `cce-keyring-sync import` with
  4 `--dry-run`, `status`, the state file, quiescence + conflicted-copy detection.
  5 Verified end to end in an isolated `dbus-run-session` keyring against a fixture
  6 kdbx: create, idempotent re-run, update, secret round-trip, and a state file
  7 holding only keyed hashes. Phase 2 shipped the same day: `sync`
  8 (the full three-way merge), `doctor`, and the 15-minute timer units. The whole
  9 merge table was exercised in an isolated keyring — kdbx→keyring edit, keyring
 10 deletion → kdbx Recycle Bin (verified in keepassxc-cli's own listing), new
 11 entries both directions, adoption of a keyring-born entry with UUID stamping,
 12 conflicted-copy refusal, and doctor merging a staged conflict whose entry then
 13 synced through. Files written by the `keepass` crate re-open in keepassxc-cli,
 14 and pre-KDBX4 databases are upgraded loudly on first write. Phase 3 shipped: a Sync button in
 15 cce-secrets (runs the same binary the timer runs, so the flock serializes a
 16 click against a timer tick; its summary or refusal text lands in the status
 17 line, and the list reloads after), plus a right-aligned "synced Nm ago" hint
 18 read from the sync state file. **Next: replacing the kdbx with 1Password as the
 19 interchange — scoped at the bottom of this file (2026-09-21).**
 20 
 21 ## Goal
 22 
 23 cce-secrets becomes the only password UI on this machine, without giving up
 24 cross-machine sync. Concretely:
 25 
 26 - **gnome-keyring stays the live store** on this machine — it already owns
 27   `org.freedesktop.secrets`, is PAM-unlocked at login, and is what cce-secrets
 28   fronts today.
 29 - **The Dropbox kdbx stays the interchange**: a `.kdbx` in the Dropbox folder,
 30   usable from other machines and phones with ordinary KeePass apps, unchanged.
 31 - **`cce-keyring-sync`** is a small non-resident tool that merges the two.
 32 - **KeePassXC retires on this machine only.** Elsewhere it keeps working against
 33   the same file; that is a feature of choosing kdbx as the interchange, not a
 34   compromise.
 35 
 36 Explicitly not the goal: replacing the Secret Service provider (route rejected —
 37 a hand-rolled secrets daemon is weeks of security-critical work for the same
 38 user-visible result), syncing kdbx attachments/history (they stay kdbx-side,
 39 untouched), or any resident process holding the Dropbox file open — a daemon
 40 with the kdbx open all day is precisely what produced the old sync grief.
 41 
 42 ## The evidence this design answers
 43 
 44 Next to the main kdbx sit **two Dropbox conflicted copies right now** — one
 45 labelled `archlinux… 2026-08-28`. The write-loss hazard is not hypothetical; it
 46 has already happened, and whatever diverged in those files may be entries nobody
 47 has missed yet. Handling this is in scope from day one (see `doctor`).
 48 
 49 ## Shape
 50 
 51 A second `[[bin]]` in this crate — it shares the `secret-service` dependency,
 52 and `ccebuild` derives binaries from cargo metadata, so it ships with
 53 `ccebuild install cce-secrets` automatically (never hand-listed anywhere).
 54 
 55 ```
 56 cce-keyring-sync              # bidirectional sync (the default)
 57 cce-keyring-sync --dry-run    # print the plan, change nothing
 58 cce-keyring-sync import       # first run: kdbx -> keyring (the 168 entries)
 59 cce-keyring-sync status       # last sync, pending differences, hazards
 60 cce-keyring-sync doctor       # reconcile Dropbox conflicted copies
 61 ```
 62 
 63 State lives in `~/.local/state/cce/keyring-sync/state.json`: a per-entry
 64 snapshot of the last synced state (UUID, timestamps, **keyed hashes of fields —
 65 never values**; the hash key is itself a keyring item, so the state file alone
 66 leaks nothing). The state file is what turns two-way comparison into a real
 67 three-way merge, and it is local — never in Dropbox.
 68 
 69 ## Pairing and field mapping
 70 
 71 - kdbx entries have UUIDs; keyring items get a `kdbx-uuid` attribute. Entries
 72   born in cce-secrets (no UUID yet) get one minted at first sync.
 73 - Everything lands in the **`login` collection** — the one PAM unlocks. Other
 74   collections would mean separate unlock prompts, defeating the point. The kdbx
 75   group path is recorded as a `kdbx-group` attribute (cce-secrets can filter on
 76   it later); it is not mapped to collections.
 77 - Fields, matching what KeePassXC's own Secret Service bridge used — the exact
 78   mapping cce-secrets' code comments already assume:
 79   Title ↔ label, Password ↔ the secret, UserName / URL / Notes ↔ attributes.
 80 - **TOTP seeds do not go to attributes** — Secret Service attributes are
 81   searchable metadata, not secret storage. v1 leaves `otp` fields kdbx-side
 82   untouched; if TOTP should move into the DE, that is `cce-authenticator`'s
 83   door to knock on, as its own decision.
 84 
 85 ## The merge, precisely
 86 
 87 Three-way, with the state file as base. Per entry:
 88 
 89 | kdbx since base | keyring since base | action |
 90 | --- | --- | --- |
 91 | changed | unchanged | → keyring |
 92 | unchanged | changed | → kdbx |
 93 | changed | changed | **newer modification time wins**; the losing value is written into the kdbx entry's native History, so nothing is destroyed |
 94 | deleted | unchanged | delete from keyring |
 95 | unchanged | deleted | move to the kdbx Recycle Bin group (not purged) |
 96 | deleted | changed (or vice versa) | **modification beats deletion** — the entry is resurrected; for passwords, the failure mode of a wrong resurrect is annoyance, of a wrong delete is lockout |
 97 
 98 Timestamps: kdbx `LastModificationTime` vs the service's own `Modified`
 99 (exposed by `secret-service 5.1.0` — verified). Compared with a skew tolerance;
100 a true tie is logged and the kdbx side wins, arbitrarily but documented.
101 
102 ## Dropbox discipline
103 
104 - **Open briefly, atomically**: read once → merge in memory → write to a temp
105   file in the same directory → fsync → rename. Rename is the pattern Dropbox
106   tolerates best.
107 - **Quiescence check**: refuse to start if the kdbx mtime moved in the last few
108   seconds; re-hash before the final rename and abort if the file changed under
109   us (retry next run).
110 - **Conflicted-copy detection**: any `*conflicted copy*.kdbx` sibling → normal
111   sync refuses and points at `doctor`.
112 - `doctor` merges conflicted copies into the main kdbx via **`keepassxc-cli
113   merge`** (battle-tested; present at `/usr/sbin/keepassxc-cli`) with a backup
114   of everything first, then archives the conflict files. Its first real job is
115   the two copies that already exist. (The `keepass` crate's own `_merge` feature
116   is underscore-experimental — not trusted with this.)
117 - **Never invoke `dropbox status`** — no CLI exists on this machine and the
118   binary spawns a second daemon (documented prior incident).
119 - Concurrency: an flock on the state file, so a timer run and a manual run
120   cannot interleave.
121 
122 ## Unlock and bootstrap
123 
124 The kdbx master password is stored as a keyring item; sync reads it at runtime,
125 so it runs unattended once the keyring is unlocked — which PAM does at login.
126 Entered once, on first run. If the database also uses a keyfile (the old
127 `cce-keyring-unlock-setup <user> <database.kdbx> [keyfile]` signature suggests
128 it may), its path is stored alongside — **open question below.**
129 
130 The kdbx KDF (argon2) costs real CPU per open; at a timer cadence of minutes
131 that is irrelevant, and there is no resident unlock to keep warm.
132 
133 ## Library facts (checked 2026-08-31)
134 
135 - `keepass` 0.13.25, updated 2026-08-30, 400k downloads; `save_kdbx4` is a
136   first-class feature. Read+write of the real database goes through it.
137 - `secret-service` 5.1.0 (already this crate's dependency) exposes
138   `get_modified()`. Sessions should use `EncryptionType::Dh` so secrets do not
139   cross the bus in the clear.
140 - Fallback and doctor's merge engine: `keepassxc-cli`.
141 
142 ## Risks, ranked
143 
144 1. **A merge bug eats a password.** The design never destroys a value: losers
145    go to kdbx History, deletions to the Recycle Bin, and Dropbox's own file
146    versioning backstops the file itself. Plus `--dry-run`, and a journal line
147    per action.
148 2. **A Dropbox race corrupts the kdbx.** Atomic rename + re-hash-before-commit
149    makes the window tiny; the residual case (offline edits on two machines) is
150    exactly a conflicted copy, which `doctor` owns.
151 3. **Secret exposure.** Secrets transit only the encrypted bus session and the
152    kdbx; the state file holds keyed hashes; logs hold labels, never values.
153 4. **Timestamp skew across machines** mis-picks a winner. Tolerance + history
154    preservation caps the damage at "restore from History".
155 
156 ## Phases
157 
158 1. **`import` + `--dry-run` + state file** — one-way kdbx → keyring. Ships
159    alone; cce-secrets immediately shows the real 168 entries.
160 2. **Bidirectional sync + `doctor` + systemd user timer** (15 min, jittered).
161    Reconcile the two existing conflicted copies as its acceptance test.
162 3. **cce-secrets UI**: a Sync action and status line; KeePassXC removed from
163    this machine's session.
164 
165 ## Open questions
166 
167 1. Does `Passwords.kdbx` use a keyfile in addition to the master password?
168 2. Merge policy sign-off: "newer wins, loser to History; modification beats
169    deletion" — acceptable?
170 3. Timer cadence, and whether sync should also fire on cce-secrets edits.
171 
172 ---
173 
174 # Scoping: 1Password as the interchange (option 1, 2026-09-21)
175 
176 Status: **complete (2026-09-21).** The resident daemon is the
177 `cce-keyring-sync.service` unit, ticking every five minutes against the
178 Personal vault; the kdbx backend, `import`, `doctor`, the `keepass`
179 dependency and the stored master password are gone. Everything above this
180 line up to "Scoping: 1Password as the interchange" describes the retired
181 kdbx design and is kept as history. Results per phase at the end.
182 
183 ## Goal
184 
185 Swap the kdbx for 1Password in the role the kdbx plays above: the
186 cross-machine interchange. Everything downstream stays exactly as it is —
187 gnome-keyring remains the live store, cce-secrets keeps fronting it, and
188 cce-browser's account autocomplete (`cce-browser/src/accounts.rs`, a second
189 Secret Service client) needs **zero changes**. The only code that learns
190 about 1Password is `cce-keyring-sync`, which grows a second backend.
191 
192 Why this door and not another (checked 2026-09-20):
193 
194 - **1Password for Linux is a Secret Service *client*, not a provider.** It
195   stores its own unlock material in gnome-keyring and never puts vault items
196   on `org.freedesktop.secrets`. So nothing either crate does today can see a
197   1Password item, and no setting changes that.
198 - **The browser extension route is closed.** 1Password's browser side is a
199   Chrome/Firefox/Safari extension over native messaging; WPE WebKit has no
200   extension support.
201 - **The official SDKs are Go, JavaScript and Python** (desktop auth over a
202   Unix socket; no Rust SDK, official or blessed). From Rust the honest path
203   is a subprocess around the `op` CLI, which has the same desktop-app
204   authentication.
205 - The alternative — a store trait in both cce-secrets and cce-browser with a
206   1Password implementation — keeps 1Password the sole store but makes every
207   reveal wait on a polkit prompt and touches both crates. Rejected for now;
208   the mirror is the drop-in for the design already shipped above.
209 
210 The trade being made, stated plainly: every password is mirrored into the
211 session-unlocked keyring, and 1Password's per-fetch approval is lost. That is
212 the posture this machine has had since phase 1 with the kdbx — nothing gets
213 worse, and the kdbx (plus its Dropbox conflicted-copy hazard) goes away.
214 
215 ## Prerequisites (manual, once)
216 
217 1. `1password` and `1password-cli` from the AUR. The app **requires** a
218    Secret Service provider to run at all; gnome-keyring already is one here.
219 2. In the app: Settings → Security → *Unlock using system authentication*,
220    then Settings → Developer → *Integrate with 1Password CLI*. On Linux this
221    authenticates through **polkit**, and `cce-authenticator` is this
222    session's polkit agent — so the prompt is a native cce window, not a GTK
223    dialog that cannot take focus. Verify once that the `op` prompt actually
224    reaches it (`pkexec true` does; `op` should look identical to polkit).
225 3. `op account add` once, interactively. If more than one account ends up
226    signed in, the sync passes `--account`.
227 4. Migrate the data: export the kdbx (`keepassxc-cli export --format csv`)
228    into 1Password's importer, then check the count against the 168. TOTP
229    seeds: 1Password's importer takes them if the CSV carries them; verify
230    on one entry. cce-keyring-sync never touches them either way.
231 
232 ## Shape
233 
234 `cce-keyring-sync` gets a backend switch. The kdbx code does not go away
235 until the 1Password path has run clean for a while; both live behind one
236 `Interchange` trait the merge loop calls:
237 
238 ```rust
239 trait Interchange {
240     fn list(&mut self) -> Result<Vec<RemoteEntry>, String>;  // no secrets
241     fn fetch(&mut self, id: &str) -> Result<RemoteEntry, String>; // full entry
242     fn create(&mut self, e: &KrEntry) -> Result<String, String>; // -> new id
243     fn update(&mut self, id: &str, e: &KrEntry) -> Result<(), String>;
244     fn recycle(&mut self, id: &str) -> Result<(), String>;
245 }
246 ```
247 
248 `sync` and `import` already work on `KdbxEntry`/`KrEntry` + `Plan`; the
249 trait is the seam that lets `sync()` stop knowing which side is remote. The
250 backend is chosen by a `backend "onepassword"` key in the state file (set by
251 the new `adopt` subcommand, below), not by a flag, so the timer unit does
252 not change.
253 
254 Commands become:
255 
256 ```
257 cce-keyring-sync adopt            # one-time: pair keyring items to 1Password items
258 cce-keyring-sync adopt --dry-run  # the pairing plan; unmatched on both sides
259 cce-keyring-sync sync             # unchanged: the three-way merge
260 cce-keyring-sync status           # unchanged, plus "backend: onepassword"
261 cce-keyring-sync doctor           # kdbx-only; refuses under the 1Password backend
262 ```
263 
264 ## `op` invocations, exactly
265 
266 All read paths are JSON over stdout of a child process; **no secret ever
267 goes on argv** (argv is readable by every same-user process via
268 `/proc/*/cmdline`; stdout of a pipe is not).
269 
270 | need | invocation | note |
271 | --- | --- | --- |
272 | list | `op item list --categories Login --vault <v> --format json` | ids, titles, `updated_at`, `urls[]`, `additional_information` (= username); **no secrets** |
273 | full entry | `op item get <id> --format json` | fields with `purpose` USERNAME / PASSWORD / NOTES; `--reveal` is for human output, JSON is plain |
274 | create | `op item create --format json` with the item JSON **on stdin** | returns the new id |
275 | update | `op item edit <id>` with the patched item JSON **on stdin** | the template form, never `password=…` assignments on argv |
276 | recycle | `op item delete <id> --archive` | the Recycle Bin analogue; archived items drop out of `list` |
277 
278 `list` is one call per run; `get` is called only for items whose
279 `updated_at` moved since the base snapshot or that have no snapshot yet, so
280 a quiet run is one `op` process and a few hundred bytes of JSON. That also
281 keeps the timer's steady state from touching a single secret.
282 
283 ## Pairing and field mapping
284 
285 Same table as the kdbx, with the id attribute renamed:
286 
287 | 1Password | keyring |
288 | --- | --- |
289 | item `id` | `op-item` attribute (replaces `kdbx-uuid`) |
290 | `vault.name` | `op-vault` attribute (replaces `kdbx-group`; cce-secrets can filter on it later) |
291 | `title` | label |
292 | field purpose USERNAME | `UserName` |
293 | field purpose PASSWORD | the secret |
294 | first `urls[]` with `primary: true`, else the first | `URL` |
295 | field purpose NOTES | `Notes` |
296 
297 Entries born in cce-secrets (no `op-item`) get created in 1Password at the
298 next sync and stamped, as kdbx UUIDs are minted today. Which vault new
299 entries land in is a state-file setting (`vault "Personal"` by default);
300 several vaults can be mirrored, each entry keeping its `op-vault`.
301 
302 `adopt` is the migration step the kdbx never needed: the keyring already
303 holds the 168 entries, and after the CSV import so does 1Password, so the
304 first run must **pair, not copy**. Match on (title, username), exact and
305 case-sensitive, with the url as the tiebreaker when that alone is
306 ambiguous; stamp `op-item`/`op-vault` on the match (the `kdbx-*`
307 attributes stay until the kdbx backend retires, so an accidental kdbx run
308 still pairs by uuid instead of re-creating everything); print
309 every entry unmatched on either side and stop there. Duplicated
310 (title, username) pairs on either side are refused, listed, and left to the
311 person — a wrong pairing here silently cross-links two accounts, which is
312 the one mistake the merge cannot recover from later. Writes a fresh state
313 snapshot at the end, so the next `sync` has a base.
314 
315 ## The merge, what changes
316 
317 The table is the same. Three rows get cheaper:
318 
319 - **Conflict, loser to History:** 1Password records item history on every
320   edit server-side, so the loser is already preserved by the winning write.
321   No separate history push; the journal still names the loser.
322 - **Deleted in keyring:** `--archive`, not delete. Recoverable in the app.
323 - **Deleted in 1Password** (archived or trashed): the item leaves `list`,
324   which reads as "deleted since base" exactly as a kdbx removal does today.
325   Same "modification beats deletion" resurrection rule.
326 
327 Timestamps: `updated_at` (RFC 3339, server clock) vs the keyring's
328 `Modified`. The server clock is better than a Dropbox mtime — no per-machine
329 skew — but `SKEW_TOLERANCE_SECS` stays, because the keyring side is still
330 local time.
331 
332 State file: `entries` keyed by 1Password item id; `EntryState` gains
333 `op_updated_at` beside the keyed hash. Values still never land in it.
334 
335 ## What the Dropbox discipline becomes
336 
337 Most of it evaporates — there is no file, so no quiescence check, no
338 atomic rename, no conflicted copies, and `doctor` has no job. What replaces
339 it:
340 
341 - **`op` failures are refusals, not errors.** A non-zero exit or unparseable
342   JSON (network down, app locked, prompt dismissed) ends the run with the
343   keyring untouched, exit 1, and the timer retries next tick. Never
344   half-apply a plan: list, plan, then apply, and any `op` failure mid-apply
345   stops the loop and leaves the state snapshot for the entries already
346   applied.
347 - The flock stays; a click on Sync and a timer tick still serialize.
348 - `op` needs `DBUS_SESSION_BUS_ADDRESS`/`XDG_RUNTIME_DIR` to reach polkit and
349   the app's socket; the user unit already has both (it reaches gnome-keyring
350   the same way).
351 
352 ## The open problem: authorization from a timer (superseded — see phase 0 results below)
353 
354 This is the one thing that decides whether the timer survives, and it is
355 **not documented**: how often `op` re-prompts under desktop-app integration.
356 The SDK's desktop auth expires after ten minutes idle; if the CLI behaves
357 the same, a 15-minute timer means a polkit prompt every tick, which is
358 unacceptable however native the window is. Measure it before anything else
359 (phase 0). Then, depending on the answer:
360 
361 - **Prompts once per app unlock** → keep the timer as is.
362 - **Prompts per idle window** → drop the timer to hourly, and make
363   cce-secrets fire a sync after each of its own saves (open question 3
364   above, now answered yes), so keyring-side edits still reach 1Password
365   promptly and the interactive prompt lands while the person is already
366   in the app. A tick that would prompt while the seat is idle is the
367   wrong moment; check `loginctl show-session -p IdleHint` and skip.
368 - A **service account** would run silently, but service accounts cannot
369   see the Private/Personal vault, so it would mean moving everything into
370   a shared vault. Available, not preferred.
371 
372 ## Risks, ranked
373 
374 1. **A bad `adopt` pairing cross-links two accounts.** Exact-match only,
375    duplicates refused, `--dry-run` first, and the plan is printed in full
376    before anything is stamped.
377 2. **Secret exposure via the subprocess.** Read paths are stdout of a pipe;
378    write paths are stdin templates; argv never carries a value; the journal
379    holds titles. `op` itself may log — check `~/.config/op` after the first
380    write for anything it persisted.
381 3. **The `op` prompt does not reach `cce-authenticator`.** Then every timer
382    run blocks until polkit times it out. Phase 0 catches it.
383 4. **`updated_at` moves without a field change** (1Password re-saving an
384    item on its own, e.g. after a client upgrade). Harmless: the entry is
385    fetched, hashes equal, `InSync`. Costs one `get`.
386 5. **Two machines editing the same item** is now 1Password's problem, not
387    Dropbox's — the server has one copy and item history. The residual
388    conflict is keyring-vs-1Password, which the merge table already owns.
389 
390 ## Phases
391 
392 0. **Measure.** ~~Install, integrate, run `op item list` from a terminal and
393    from a `systemd-run --user` unit; time how long the authorization lasts
394    and whether the prompt appears in `cce-authenticator`.~~ **Done 2026-09-21,
395    results below.** The timer is dead; the daemon replaces it.
396 1. ~~**`Interchange` trait + `OnePassword` backend + `adopt --dry-run`.**~~
397    **Done 2026-09-21** (fab49c8, 28c7389); results below. The kdbx backend
398    did *not* move behind the trait yet — an unexercised impl is dead code;
399    it joins when phase 2 rewires `sync`.
400 2. ~~**`sync` on the new backend.**~~ **Done 2026-09-21**; results below.
401    The table is exercised by `scripts/e2e-1password.sh` (isolated keyring,
402    throwaway vault, every pass through one daemon), 19 checks.
403 3. ~~**Retire the kdbx path** after a month clean~~ **Done 2026-09-21**,
404    the same day, on the person's call: the isolated run covered the table
405    and the daemon's live passes were quiet. Deleted the backend, `import`,
406    `doctor`, the `keepass` and `rpassword` dependencies, the `--kdbx`
407    flag, `State.kdbx_path` / `EntryState.kdbx_mtime` (unknown fields are
408    ignored on read, so the live base needed no migration), and the
409    master-password keyring item. main.rs went from 1198 lines to 242.
410 
411 ## Open questions
412 
413 1. Which vault(s) to mirror — Personal only, or everything `op item list`
414    can read?
415 2. ~~`adopt` match key: (title, username) exact, or also fall back to URL
416    host + username for retitled entries?~~ Answered: url as the tiebreaker
417    for duplicate (title, username) only — two Microsoft tenants needed it.
418 3. Does the CSV round trip carry TOTP seeds, and should
419    `cce-authenticator` then read them from 1Password directly (its own
420    decision, as before)?
421 
422 ## Phase 0 results (2026-09-21)
423 
424 Measured with `op` 2.39.0 against 1Password for Linux 8.12.36, CLI
425 integration on, system authentication on, app unlocked throughout.
426 Everything below comes from timed calls plus the app's own log
427 (`~/.config/1Password/logs/1Password_r00000.log`, UTC).
428 
429 **The integration works, and the JSON is as scoped.** `op item list --format
430 json` carries `id`, `title`, `updated_at`, `urls[{href,primary}]`, `vault`
431 and `additional_information` (the username), and no secrets. `op vault
432 list` shows the one vault, `Personal`.
433 
434 **Authorization is the app's own "Authorize" dialog, not polkit.** Across
435 some twenty authorizations neither polkitd nor `cce-authenticator` logged
436 a line. The "system authentication" setting only governs unlocking the
437 app; risk 3 above is void. An unanswered dialog times out after **60 s**
438 and `op` exits 1 with `authorization prompt dismissed, please try again`.
439 
440 **Authorization is keyed to the caller's parent process, nothing else.**
441 The app log says it on every request: `no top level process found,
442 falling back to the caller process`. The consequences, each measured:
443 
444 | shape | result |
445 | --- | --- |
446 | second `op` call under the same parent, 5–20 s later | passes, no dialog |
447 | new parent 30 s later (a second oneshot unit) | new dialog |
448 | `setsid` under the same shell | refused (new parent) |
449 | scrubbed environment, same parent | passes — the environment is irrelevant |
450 | `op` as a unit's main process (parent = `systemd --user`) | app aborts with `executable path is missing for caller process`; `op` hangs the full 60 s |
451 | `op` under a shell inside the unit | works like anywhere else |
452 | private `--config` dir | still routes through the app; no escape into the non-integrated mode |
453 
454 So **a oneshot timer run is one dialog per tick**, full stop, and the
455 timer design above cannot ship.
456 
457 **The authorization is idle-limited and use extends it.** Under one
458 long-lived parent: calls 3 minutes apart passed for 12 minutes with no
459 dialog; a 12-minute gap then produced a fresh dialog. A second run with
460 calls 5 minutes apart passed for 25 minutes after the click, to the end of the run. That is the
461 SDK's documented rule (ten minutes of inactivity) applied to the CLI, and
462 it is the whole design: **a resident parent that calls `op` at least every
463 few minutes holds its authorization for as long as it lives.**
464 
465 ### What changes in the shape
466 
467 `cce-keyring-sync` becomes resident under the 1Password backend, as
468 `cce-keyring-sync daemon`, a systemd user service (`WantedBy=
469 graphical-session.target`, like the polkit agent), replacing the timer.
470 The objection to a resident process above was a daemon holding the
471 Dropbox kdbx open all day; there is no file any more, so it lapses.
472 
473 - **Tick every 5 minutes.** That is inside the idle window with margin, so
474   the tick doubles as the keepalive; a quiet tick is one `op item list`,
475   no secrets, a few hundred bytes.
476 - **One dialog per login session** at the first tick, plus one after any
477   gap the tick could not cover — suspend, the app locking (autolock is 60
478   minutes here; an `op` call against a locked app is **untested**, open
479   question 4), or the app restarting.
480 - **Back off after a dismissed dialog.** A dialog nobody answers costs a
481   60-second hang, and re-offering one every five minutes to an empty chair
482   is exactly the annoyance the timer design was rejected for. After
483   `authorization prompt dismissed`, the daemon waits for a trigger before
484   trying again: the cce-secrets Sync button, a save in cce-secrets, or the
485   seat coming back from idle (`loginctl show-session -p IdleHint`). Every
486   `op` call runs under a 75-second timeout so a wedged app cannot hang a
487   tick.
488 - **Spawn `op` as a direct child, always**, never via `setsid`,
489   `systemd-run`, or a double fork: the authorization is the daemon's pid.
490 - **cce-secrets pokes the daemon** instead of running the binary: the Sync
491   button sends `SIGUSR1` to the unit's main pid (or a line over the usual
492   `cce_ui::ipc` socket, if the status line wants a reply). The state file
493   and its "synced Nm ago" hint stay as they are. Running `cce-keyring-sync
494   sync` by hand still works and prompts its own dialog; the flock keeps
495   it from interleaving with the daemon.
496 
497 The "open problem" section above is answered: the timer cadence question
498 does not arise, and the seat-idle check moves from "when to prompt" to
499 "when to retry after a dismissed prompt". The service-account fallback
500 stays on the shelf.
501 
502 ### Fallback, parked
503 
504 `op` without the app integration signs in with the account password and
505 Secret Key, prints a 30-minute session token, and can take the password
506 on stdin — the same trust posture as the kdbx master password stored in
507 the keyring today, and no dialogs ever. It needs the integration toggle
508 off (a private `--config` dir does not escape it, measured) and a manual
509 `op account add`, which needs the Secret Key and password typed by the
510 person. Worth knowing if the resident design misbehaves; not preferred,
511 because it moves the account password into the keyring and gives up the
512 app's approval entirely.
513 
514 ### Open questions, continued
515 
516 4. What does an `op` call do against a *locked* app — a system-auth
517    (polkit → cce-authenticator) unlock prompt, the Authorize dialog, or a
518    plain refusal? Decides what the daemon sees after autolock.
519 5. What does the app count as a "top level process"? If the daemon could
520    present as one, the authorization might be remembered across restarts
521    the way it is for a terminal window. Not needed for the design; nice
522    if cheap.
523 
524 ## Phase 1 results (2026-09-21)
525 
526 `cce-keyring-sync adopt --vault Personal` against the live keyring and the
527 freshly imported vault:
528 
529 | | |
530 | --- | --- |
531 | keyring logins | 171 |
532 | 1Password logins after cleanup | 172 |
533 | paired and stamped | **168** |
534 | 1Password only | 4: 1Password's own account item, one login that was in the kdbx Recycle Bin (the CSV export carries the bin), and two test entries |
535 | keyring only | 3: two `…@cce-mail:default` items cce-mail writes with a `UserName` attribute, and one test entry |
536 | field drift on a pair | 1 (notes) — base timestamp left unknown so the first sync takes 1Password's value |
537 
538 The 168 all pair on exact (title, username); four needed the url tiebreaker
539 (two Microsoft tenants, two xferrecords accounts). The first dry run
540 refused on 23 import artefacts — Recycle Bin entries the CSV export had
541 resurrected, eight copies of one test login among them — which were
542 archived in 1Password by hand; the per-copy report (url, timestamp, id)
543 is what made that a five-minute job.
544 
545 Things learned that phase 2 has to carry:
546 
547 - **The Authorize dialog can be hidden.** It is a 400×370 floating window
548   the app raises via xdg-activation at the same origin as its main window;
549   when the main window was opened later it sat on top, and three dialogs
550   in a row timed out unseen while the app log showed each one shown. Focus
551   went to the dialog; the compositor did not raise it. Filed against
552   cce-compositor. Until fixed: keep the main 1Password window closed or
553   moved when a sync is expected to prompt.
554 - **`1Password Account (…)` must never be mirrored.** It is the item
555   1Password creates for the account itself — Secret Key and account
556   password inside. The sync's "1Password only → keyring" rule would copy
557   it into gnome-keyring. Phase 2 skips it (title prefix `1Password
558   Account`; better, its `category` if the list ever distinguishes it).
559 - **cce-mail's keyring items look like logins** (they carry `UserName`),
560   so the sync will create them in 1Password at the first run, as the kdbx
561   sync adopted them before (that is where the `…@cce-email:default`
562   debris in the CSV came from). Either accept that — mail passwords in
563   1Password are not wrong — or have phase 2 exclude items whose label
564   matches `*@cce-mail:*`. Decide before the first real sync.
565 - **The old binary must never see the new state.** The version-2 base is
566   keyed by item id; the kdbx `sync` reading it would treat every kdbx
567   entry as never seen and re-create all of them in the keyring. Hence the
568   install-before-adopt order, the `backend` guard in every kdbx path, and
569   the backup `state.json.kdbx-<ts>` for a rollback (restore it and
570   re-enable the timer; the stamps are harmless to the kdbx path).
571 - The cce-secrets Sync button now runs a `sync` that refuses; its status
572   line shows the refusal text until the daemon lands.
573 
574 ## Phase 2 results (2026-09-21)
575 
576 Shipped: `sync.rs` (the merge against the `Interchange` trait, keyed by
577 item id), `daemon.rs` (the resident loop), the `cce-keyring-sync.service`
578 unit replacing the timer, and cce-secrets' Sync button and saves poking
579 the daemon with `SIGUSR1` (falling back to the one-shot when no daemon
580 runs). The kdbx `sync` in main.rs is untouched and unreachable under the
581 1Password backend; it and the `keepass` dependency go in phase 3.
582 
583 The first live pass mirrored the four 1Password-only entries into the
584 keyring (the account item excluded), created the three keyring-only ones
585 in 1Password (the cce-mail credentials, kept by decision), and took
586 1Password's notes on the one drifted pair. 174 entries in the base.
587 
588 `scripts/e2e-1password.sh` runs the table end to end: mirror, idempotent
589 quiet pass (one `op item list`, zero fetches), edit each way, keyring-born
590 create with stamp, remote-born mirror, keyring delete → Archive, Archive →
591 keyring delete, both conflict directions, modification-beats-deletion
592 resurrecting as a new item with a restamp, a simulated mid-apply `op`
593 failure that leaves the rest for the next run, and a value-free state
594 file. 19 of 19.
595 
596 What it found, and what was changed for it:
597 
598 - **`op` prints two timestamp forms.** `item list` gives UTC to the
599   second (`…T16:27:42Z`); `item get` and `item edit` give local time with
600   an offset and nanoseconds (`…T12:27:42.39627885-04:00`). The parser
601   takes both and the base stores the canonical UTC form, or a written
602   entry would never match the list again and be fetched every tick. The
603   base timestamp is also refreshed whenever an in-sync entry's list value
604   differs — the server may stamp a write a second after the reply.
605 - **A remote edit needs a moment.** Listing right after `op item edit`
606   can still show the previous `updated_at` (the app pushes
607   asynchronously); in a conflict that flips the winner. Real edits arrive
608   from other devices minutes old; the test waits two seconds.
609 - **Two keyring items with one stamp** (a tool that re-creates instead of
610   editing — `secret-tool store` does exactly that, adding its own
611   `xdg:schema` attribute so it never replaces) are resolved to the most
612   recently modified one.
613 - **`op item get` resolves archived ids**, so "still live" is a question
614   for the list, not `get`.
615 
616 Open question 4 (a locked app) is still open; the daemon's back-off is
617 what happens in the meantime. Question 5 (the "top level process" the app
618 looks for) is unneeded now.
619 
620 ## Phase 3 results (2026-09-21)
621 
622 `cce-keyring-sync` is now `adopt`, `sync`, `daemon`, `status` — 1,830
623 lines across main.rs, op.rs, sync.rs, adopt.rs, daemon.rs, with 19 unit
624 tests and `scripts/e2e-1password.sh` for the table. The kdbx file in
625 Dropbox is untouched and still serves other machines and phones; nothing
626 here reads or writes it any more. The `kdbx-uuid` / `kdbx-group`
627 attributes remain on the 168 adopted keyring items as inert history. The
628 kdbx-era base is at `state.json.kdbx-1790007276` beside the live state
629 file, should anyone want the old pairing back.
630 
631 Rolling back to the kdbx design would mean checking out 0bac2e9^ and
632 re-importing the master password; the design above documents how that
633 worked. Nothing in cce-secrets or cce-browser changed shape in any phase:
634 both still front gnome-keyring over the Secret Service, which was the
635 point of choosing a mirror.
636 
637 Still open: question 4, what an `op` call sees against a locked app. The
638 daemon's back-off covers the gap until it is measured.