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.