secrets manager
git clone https://git.lucas.co/cce-secrets.git
KEYRING-SYNC.md (39.5K)
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 The keyring side matches it by `Modified`, which gnome-keyring bumps on a
284 label, attribute or secret edit and leaves alone on a read (measured
285 2026-10-06): an item still at the base's time is read for its attributes
286 and time only, never its label or secret. Until then every pass read and
287 decrypted all of them — 1,523 Secret Service calls and 380 `GetSecret`s
288 for 379 items, measured in an isolated keyring; now 765 calls and one
289 `GetSecret` (the state hash key), and gnome-keyring's share of a pass
290 halves.
291
292 ## Pairing and field mapping
293
294 Same table as the kdbx, with the id attribute renamed:
295
296 | 1Password | keyring |
297 | --- | --- |
298 | item `id` | `op-item` attribute (replaces `kdbx-uuid`) |
299 | `vault.name` | `op-vault` attribute (replaces `kdbx-group`; cce-secrets can filter on it later) |
300 | `title` | label |
301 | field purpose USERNAME | `UserName` |
302 | field purpose PASSWORD | the secret |
303 | first `urls[]` with `primary: true`, else the first | `URL` |
304 | field purpose NOTES | `Notes` |
305
306 Entries born in cce-secrets (no `op-item`) get created in 1Password at the
307 next sync and stamped, as kdbx UUIDs are minted today. Which vault new
308 entries land in is a state-file setting (`vault "Personal"` by default);
309 several vaults can be mirrored, each entry keeping its `op-vault`.
310
311 `adopt` is the migration step the kdbx never needed: the keyring already
312 holds the 168 entries, and after the CSV import so does 1Password, so the
313 first run must **pair, not copy**. Match on (title, username), exact and
314 case-sensitive, with the url as the tiebreaker when that alone is
315 ambiguous; stamp `op-item`/`op-vault` on the match (the `kdbx-*`
316 attributes stay until the kdbx backend retires, so an accidental kdbx run
317 still pairs by uuid instead of re-creating everything); print
318 every entry unmatched on either side and stop there. Duplicated
319 (title, username) pairs on either side are refused, listed, and left to the
320 person — a wrong pairing here silently cross-links two accounts, which is
321 the one mistake the merge cannot recover from later. Writes a fresh state
322 snapshot at the end, so the next `sync` has a base.
323
324 ## The merge, what changes
325
326 The table is the same. Three rows get cheaper:
327
328 - **Conflict, loser to History:** 1Password records item history on every
329 edit server-side, so the loser is already preserved by the winning write.
330 No separate history push; the journal still names the loser.
331 - **Deleted in keyring:** `--archive`, not delete. Recoverable in the app.
332 - **Deleted in 1Password** (archived or trashed): the item leaves `list`,
333 which reads as "deleted since base" exactly as a kdbx removal does today.
334 Same "modification beats deletion" resurrection rule.
335
336 Timestamps: `updated_at` (RFC 3339, server clock) vs the keyring's
337 `Modified`. The server clock is better than a Dropbox mtime — no per-machine
338 skew — but `SKEW_TOLERANCE_SECS` stays, because the keyring side is still
339 local time.
340
341 State file: `entries` keyed by 1Password item id; `EntryState` gains
342 `op_updated_at` beside the keyed hash. Values still never land in it.
343
344 ## What the Dropbox discipline becomes
345
346 Most of it evaporates — there is no file, so no quiescence check, no
347 atomic rename, no conflicted copies, and `doctor` has no job. What replaces
348 it:
349
350 - **`op` failures are refusals, not errors.** A non-zero exit or unparseable
351 JSON (network down, app locked, prompt dismissed) ends the run with the
352 keyring untouched, exit 1, and the timer retries next tick. Never
353 half-apply a plan: list, plan, then apply, and any `op` failure mid-apply
354 stops the loop and leaves the state snapshot for the entries already
355 applied.
356 - The flock stays; a click on Sync and a timer tick still serialize.
357 - `op` needs `DBUS_SESSION_BUS_ADDRESS`/`XDG_RUNTIME_DIR` to reach polkit and
358 the app's socket; the user unit already has both (it reaches gnome-keyring
359 the same way).
360
361 ## The open problem: authorization from a timer (superseded — see phase 0 results below)
362
363 This is the one thing that decides whether the timer survives, and it is
364 **not documented**: how often `op` re-prompts under desktop-app integration.
365 The SDK's desktop auth expires after ten minutes idle; if the CLI behaves
366 the same, a 15-minute timer means a polkit prompt every tick, which is
367 unacceptable however native the window is. Measure it before anything else
368 (phase 0). Then, depending on the answer:
369
370 - **Prompts once per app unlock** → keep the timer as is.
371 - **Prompts per idle window** → drop the timer to hourly, and make
372 cce-secrets fire a sync after each of its own saves (open question 3
373 above, now answered yes), so keyring-side edits still reach 1Password
374 promptly and the interactive prompt lands while the person is already
375 in the app. A tick that would prompt while the seat is idle is the
376 wrong moment; check `loginctl show-session -p IdleHint` and skip.
377 - A **service account** would run silently, but service accounts cannot
378 see the Private/Personal vault, so it would mean moving everything into
379 a shared vault. Available, not preferred.
380
381 ## Risks, ranked
382
383 1. **A bad `adopt` pairing cross-links two accounts.** Exact-match only,
384 duplicates refused, `--dry-run` first, and the plan is printed in full
385 before anything is stamped.
386 2. **Secret exposure via the subprocess.** Read paths are stdout of a pipe;
387 write paths are stdin templates; argv never carries a value; the journal
388 holds titles. `op` itself may log — check `~/.config/op` after the first
389 write for anything it persisted.
390 3. **The `op` prompt does not reach `cce-authenticator`.** Then every timer
391 run blocks until polkit times it out. Phase 0 catches it.
392 4. **`updated_at` moves without a field change** (1Password re-saving an
393 item on its own, e.g. after a client upgrade). Harmless: the entry is
394 fetched, hashes equal, `InSync`. Costs one `get`.
395 5. **Two machines editing the same item** is now 1Password's problem, not
396 Dropbox's — the server has one copy and item history. The residual
397 conflict is keyring-vs-1Password, which the merge table already owns.
398
399 ## Phases
400
401 0. **Measure.** ~~Install, integrate, run `op item list` from a terminal and
402 from a `systemd-run --user` unit; time how long the authorization lasts
403 and whether the prompt appears in `cce-authenticator`.~~ **Done 2026-09-21,
404 results below.** The timer is dead; the daemon replaces it.
405 1. ~~**`Interchange` trait + `OnePassword` backend + `adopt --dry-run`.**~~
406 **Done 2026-09-21** (fab49c8, 28c7389); results below. The kdbx backend
407 did *not* move behind the trait yet — an unexercised impl is dead code;
408 it joins when phase 2 rewires `sync`.
409 2. ~~**`sync` on the new backend.**~~ **Done 2026-09-21**; results below.
410 The table is exercised by `scripts/e2e-1password.sh` (isolated keyring,
411 throwaway vault, every pass through one daemon), 19 checks.
412 3. ~~**Retire the kdbx path** after a month clean~~ **Done 2026-09-21**,
413 the same day, on the person's call: the isolated run covered the table
414 and the daemon's live passes were quiet. Deleted the backend, `import`,
415 `doctor`, the `keepass` and `rpassword` dependencies, the `--kdbx`
416 flag, `State.kdbx_path` / `EntryState.kdbx_mtime` (unknown fields are
417 ignored on read, so the live base needed no migration), and the
418 master-password keyring item. main.rs went from 1198 lines to 242.
419
420 ## Open questions
421
422 1. Which vault(s) to mirror — Personal only, or everything `op item list`
423 can read?
424 2. ~~`adopt` match key: (title, username) exact, or also fall back to URL
425 host + username for retitled entries?~~ Answered: url as the tiebreaker
426 for duplicate (title, username) only — two Microsoft tenants needed it.
427 3. Does the CSV round trip carry TOTP seeds, and should
428 `cce-authenticator` then read them from 1Password directly (its own
429 decision, as before)?
430
431 ## Phase 0 results (2026-09-21)
432
433 Measured with `op` 2.39.0 against 1Password for Linux 8.12.36, CLI
434 integration on, system authentication on, app unlocked throughout.
435 Everything below comes from timed calls plus the app's own log
436 (`~/.config/1Password/logs/1Password_r00000.log`, UTC).
437
438 **The integration works, and the JSON is as scoped.** `op item list --format
439 json` carries `id`, `title`, `updated_at`, `urls[{href,primary}]`, `vault`
440 and `additional_information` (the username), and no secrets. `op vault
441 list` shows the one vault, `Personal`.
442
443 **Authorization is the app's own "Authorize" dialog, not polkit.** Across
444 some twenty authorizations neither polkitd nor `cce-authenticator` logged
445 a line. The "system authentication" setting only governs unlocking the
446 app; risk 3 above is void. An unanswered dialog times out after **60 s**
447 and `op` exits 1 with `authorization prompt dismissed, please try again`.
448
449 **Authorization is keyed to the caller's parent process, nothing else.**
450 The app log says it on every request: `no top level process found,
451 falling back to the caller process`. The consequences, each measured:
452
453 | shape | result |
454 | --- | --- |
455 | second `op` call under the same parent, 5–20 s later | passes, no dialog |
456 | new parent 30 s later (a second oneshot unit) | new dialog |
457 | `setsid` under the same shell | refused (new parent) |
458 | scrubbed environment, same parent | passes — the environment is irrelevant |
459 | `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 |
460 | `op` under a shell inside the unit | works like anywhere else |
461 | private `--config` dir | still routes through the app; no escape into the non-integrated mode |
462
463 So **a oneshot timer run is one dialog per tick**, full stop, and the
464 timer design above cannot ship.
465
466 **The authorization is idle-limited and use extends it.** Under one
467 long-lived parent: calls 3 minutes apart passed for 12 minutes with no
468 dialog; a 12-minute gap then produced a fresh dialog. A second run with
469 calls 5 minutes apart passed for 25 minutes after the click, to the end of the run. That is the
470 SDK's documented rule (ten minutes of inactivity) applied to the CLI, and
471 it is the whole design: **a resident parent that calls `op` at least every
472 few minutes holds its authorization for as long as it lives.**
473
474 ### What changes in the shape
475
476 `cce-keyring-sync` becomes resident under the 1Password backend, as
477 `cce-keyring-sync daemon`, a systemd user service (`WantedBy=
478 graphical-session.target`, like the polkit agent), replacing the timer.
479 The objection to a resident process above was a daemon holding the
480 Dropbox kdbx open all day; there is no file any more, so it lapses.
481
482 - **Tick every 5 minutes.** That is inside the idle window with margin, so
483 the tick doubles as the keepalive; a quiet tick is one `op item list`,
484 no secrets, a few hundred bytes.
485 - **One dialog per login session** at the first tick, plus one after any
486 gap the tick could not cover — suspend, the app locking (autolock is 60
487 minutes here; an `op` call against a locked app is **untested**, open
488 question 4), or the app restarting.
489 - **Back off after a dismissed dialog.** A dialog nobody answers costs a
490 60-second hang, and re-offering one every five minutes to an empty chair
491 is exactly the annoyance the timer design was rejected for. After
492 `authorization prompt dismissed`, the daemon waits for a trigger before
493 trying again: the cce-secrets Sync button, a save in cce-secrets, or the
494 seat coming back from idle (`loginctl show-session -p IdleHint`). Every
495 `op` call runs under a 75-second timeout so a wedged app cannot hang a
496 tick.
497 - **Spawn `op` as a direct child, always**, never via `setsid`,
498 `systemd-run`, or a double fork: the authorization is the daemon's pid.
499 - **cce-secrets pokes the daemon** instead of running the binary: the Sync
500 button sends `SIGUSR1` to the unit's main pid (or a line over the usual
501 `cce_ui::ipc` socket, if the status line wants a reply). The state file
502 and its "synced Nm ago" hint stay as they are. Running `cce-keyring-sync
503 sync` by hand still works and prompts its own dialog; the flock keeps
504 it from interleaving with the daemon.
505
506 The "open problem" section above is answered: the timer cadence question
507 does not arise, and the seat-idle check moves from "when to prompt" to
508 "when to retry after a dismissed prompt". The service-account fallback
509 stays on the shelf.
510
511 ### Fallback, parked
512
513 `op` without the app integration signs in with the account password and
514 Secret Key, prints a 30-minute session token, and can take the password
515 on stdin — the same trust posture as the kdbx master password stored in
516 the keyring today, and no dialogs ever. It needs the integration toggle
517 off (a private `--config` dir does not escape it, measured) and a manual
518 `op account add`, which needs the Secret Key and password typed by the
519 person. Worth knowing if the resident design misbehaves; not preferred,
520 because it moves the account password into the keyring and gives up the
521 app's approval entirely.
522
523 ### Open questions, continued
524
525 4. *(Answered 2026-10-01 — see "Question 4 answered" at the end.)*
526 What does an `op` call do against a *locked* app — a system-auth
527 (polkit → cce-authenticator) unlock prompt, the Authorize dialog, or a
528 plain refusal? Decides what the daemon sees after autolock.
529 5. What does the app count as a "top level process"? If the daemon could
530 present as one, the authorization might be remembered across restarts
531 the way it is for a terminal window. Not needed for the design; nice
532 if cheap.
533
534 ## Phase 1 results (2026-09-21)
535
536 `cce-keyring-sync adopt --vault Personal` against the live keyring and the
537 freshly imported vault:
538
539 | | |
540 | --- | --- |
541 | keyring logins | 171 |
542 | 1Password logins after cleanup | 172 |
543 | paired and stamped | **168** |
544 | 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 |
545 | keyring only | 3: two `…@cce-mail:default` items cce-mail writes with a `UserName` attribute, and one test entry |
546 | field drift on a pair | 1 (notes) — base timestamp left unknown so the first sync takes 1Password's value |
547
548 The 168 all pair on exact (title, username); four needed the url tiebreaker
549 (two Microsoft tenants, two xferrecords accounts). The first dry run
550 refused on 23 import artefacts — Recycle Bin entries the CSV export had
551 resurrected, eight copies of one test login among them — which were
552 archived in 1Password by hand; the per-copy report (url, timestamp, id)
553 is what made that a five-minute job.
554
555 Things learned that phase 2 has to carry:
556
557 - **The Authorize dialog can be hidden.** It is a 400×370 floating window
558 the app raises via xdg-activation at the same origin as its main window;
559 when the main window was opened later it sat on top, and three dialogs
560 in a row timed out unseen while the app log showed each one shown. Focus
561 went to the dialog; the compositor did not raise it. Filed against
562 cce-compositor. Until fixed: keep the main 1Password window closed or
563 moved when a sync is expected to prompt.
564 - **`1Password Account (…)` must never be mirrored.** It is the item
565 1Password creates for the account itself — Secret Key and account
566 password inside. The sync's "1Password only → keyring" rule would copy
567 it into gnome-keyring. Phase 2 skips it (title prefix `1Password
568 Account`; better, its `category` if the list ever distinguishes it).
569 - **cce-mail's keyring items look like logins** (they carry `UserName`),
570 so the sync will create them in 1Password at the first run, as the kdbx
571 sync adopted them before (that is where the `…@cce-email:default`
572 debris in the CSV came from). Either accept that — mail passwords in
573 1Password are not wrong — or have phase 2 exclude items whose label
574 matches `*@cce-mail:*`. Decide before the first real sync.
575 - **The old binary must never see the new state.** The version-2 base is
576 keyed by item id; the kdbx `sync` reading it would treat every kdbx
577 entry as never seen and re-create all of them in the keyring. Hence the
578 install-before-adopt order, the `backend` guard in every kdbx path, and
579 the backup `state.json.kdbx-<ts>` for a rollback (restore it and
580 re-enable the timer; the stamps are harmless to the kdbx path).
581 - The cce-secrets Sync button now runs a `sync` that refuses; its status
582 line shows the refusal text until the daemon lands.
583
584 ## Phase 2 results (2026-09-21)
585
586 Shipped: `sync.rs` (the merge against the `Interchange` trait, keyed by
587 item id), `daemon.rs` (the resident loop), the `cce-keyring-sync.service`
588 unit replacing the timer, and cce-secrets' Sync button and saves poking
589 the daemon with `SIGUSR1` (falling back to the one-shot when no daemon
590 runs). The kdbx `sync` in main.rs is untouched and unreachable under the
591 1Password backend; it and the `keepass` dependency go in phase 3.
592
593 The first live pass mirrored the four 1Password-only entries into the
594 keyring (the account item excluded), created the three keyring-only ones
595 in 1Password (the cce-mail credentials, kept by decision), and took
596 1Password's notes on the one drifted pair. 174 entries in the base.
597
598 `scripts/e2e-1password.sh` runs the table end to end: mirror, idempotent
599 quiet pass (one `op item list`, zero fetches), edit each way, keyring-born
600 create with stamp, remote-born mirror, keyring delete → Archive, Archive →
601 keyring delete, both conflict directions, modification-beats-deletion
602 resurrecting as a new item with a restamp, a simulated mid-apply `op`
603 failure that leaves the rest for the next run, and a value-free state
604 file. 19 of 19.
605
606 What it found, and what was changed for it:
607
608 - **`op` prints two timestamp forms.** `item list` gives UTC to the
609 second (`…T16:27:42Z`); `item get` and `item edit` give local time with
610 an offset and nanoseconds (`…T12:27:42.39627885-04:00`). The parser
611 takes both and the base stores the canonical UTC form, or a written
612 entry would never match the list again and be fetched every tick. The
613 base timestamp is also refreshed whenever an in-sync entry's list value
614 differs — the server may stamp a write a second after the reply.
615 - **A remote edit needs a moment.** Listing right after `op item edit`
616 can still show the previous `updated_at` (the app pushes
617 asynchronously); in a conflict that flips the winner. Real edits arrive
618 from other devices minutes old; the test waits two seconds.
619 - **Two keyring items with one stamp** (a tool that re-creates instead of
620 editing — `secret-tool store` does exactly that, adding its own
621 `xdg:schema` attribute so it never replaces) are resolved to the most
622 recently modified one.
623 - **`op item get` resolves archived ids**, so "still live" is a question
624 for the list, not `get`.
625
626 Open question 4 (a locked app) is still open; the daemon's back-off is
627 what happens in the meantime. Question 5 (the "top level process" the app
628 looks for) is unneeded now.
629
630 ## Phase 3 results (2026-09-21)
631
632 `cce-keyring-sync` is now `adopt`, `sync`, `daemon`, `status` — 1,830
633 lines across main.rs, op.rs, sync.rs, adopt.rs, daemon.rs, with 19 unit
634 tests and `scripts/e2e-1password.sh` for the table. The kdbx file in
635 Dropbox is untouched and still serves other machines and phones; nothing
636 here reads or writes it any more. The `kdbx-uuid` / `kdbx-group`
637 attributes remain on the 168 adopted keyring items as inert history. The
638 kdbx-era base is at `state.json.kdbx-1790007276` beside the live state
639 file, should anyone want the old pairing back.
640
641 Rolling back to the kdbx design would mean checking out 0bac2e9^ and
642 re-importing the master password; the design above documents how that
643 worked. Nothing in cce-secrets or cce-browser changed shape in any phase:
644 both still front gnome-keyring over the Secret Service, which was the
645 point of choosing a mirror.
646
647 Still open: question 4, what an `op` call sees against a locked app. The
648 daemon's back-off covers the gap until it is measured. (Answered
649 2026-10-01, below.)
650
651 # 1Password's window out of the loop (option A, 2026-10-01)
652
653 The question was whether cce programs could replace 1Password's graphical
654 frontend. Two shapes were weighed:
655
656 - **A — the app stays, headless.** It keeps running (`cce-1password.service`
657 starts it with `--silent`, so no main window opens; its own XDG autostart
658 entry never fires under cce) as what `op` authorizes
659 against and what syncs with 1Password's servers; cce-secrets is the only
660 window anyone browses in. **Chosen.**
661 - **B — the app goes.** `op` without the integration signs in on its own
662 (the "Fallback, parked" above): no dialogs ever, but the account password
663 and Secret Key would have to live in the keyring, whose TPM seal has no
664 PCR policy on a disk with no encryption — a stolen laptop would yield
665 the whole account, not just the mirrored logins. It would also drop the
666 Chrome extension's app unlock, the MCP integration and Quick Access.
667 Rejected. Service accounts cannot read personal vaults, so they are no
668 way around it.
669
670 What remains of 1Password's own UI under A, and why it cannot be replaced:
671 its unlock screen and its Authorize dialog are the app's trust boundary,
672 drawn by the app on purpose and not scriptable. The compositor already
673 floats, centres and raises the Authorize dialog (cce-compositor bebebc0e,
674 bbd73499), so the phase 1 "dialog hidden under the main window" problem is
675 gone, and with the main window never opened it could not arise anyway.
676
677 ## Question 4 answered: an `op` call against a locked app
678
679 From the app's log for the 2026-10-01 18:12 login
680 (`~/.config/1Password/logs/1Password_rCURRENT.log`, UTC):
681
682 - The app **starts locked**, and system unlock is not available until the
683 account password has been typed once after the app starts (`Sys auth
684 status NotReady`; after the password, `Adding system unlock key`). So
685 every login costs one account-password unlock in 1Password's own window.
686 That is 1Password's rule, not something cce can route around.
687 - The daemon starts before the app: its first call ends in `cannot connect
688 to 1Password app`. The next, against the locked app, waited and ended in
689 `authorization timeout`; the calls after that ended as `authorization
690 prompt dismissed`. With the old back-off that held the first sync of the
691 session until about an hour after login, half an hour after the app had
692 been unlocked.
693 - The app's idle/screen-lock hook fails here (`op-auto-lock: Could not
694 connect to the X server`), so only the 60-minute auto-lock timer locks
695 it.
696
697 What changed in the daemon for it: `authorization timeout` counts as an
698 unanswered prompt (back off, like a dismissed dialog), and `cannot connect
699 to 1Password app` retries every 30 s, up to ten times, before falling back
700 to the 5-minute tick, so a login that unlocks the app promptly syncs
701 promptly. The cce-secrets Sync button still cuts any back-off short.
702
703 ## One-time codes in cce-secrets
704
705 The keyring mirror carries logins only, so one-time codes were the one
706 thing a person still opened 1Password's window for. cce-secrets now shows
707 the selected entry's current code with a countdown, plus a **Copy code**
708 button (the clipboard clears after 30 s, like a copied password).
709
710 The code comes from the **daemon**, not from cce-secrets running `op`
711 itself: the daemon already holds the session's authorization, while
712 cce-secrets would raise its own Authorize dialog each launch. The daemon
713 listens on `$XDG_RUNTIME_DIR/cce/keyring-sync.sock` (0600), one request per
714 connection:
715
716 ```
717 otp <item-id>\n → otp <code> <seconds left>\n | none\n | err <text>\n
718 ```
719
720 - `op item get <id> --otp` computes the code app-side, so **the seed never
721 enters either process**; only the six digits cross the socket.
722 - Only ids in the base snapshot are served (the mirror's own items), and an
723 id must be bare alphanumerics before it reaches `op`'s argv.
724 - The trade: any same-user process can now get a current code without a
725 dialog. Such a process could already read every mirrored password from
726 the unlocked keyring, so this extends the mirror's posture to the second
727 factor's codes. It does not extend it to the seeds.
728 - Seconds left assume TOTP's usual 30 s period. A code with another period
729 is still right; only its countdown would be off.
730 - cce-secrets asks when the selection lands on an entry carrying an
731 `op-item` stamp, again when the code runs out, and never again for an
732 entry the daemon said has none (until the window reopens). Each answer
733 costs one `op` call, about a second.
734
735 **No mirrored login carries a code yet.** A scan of all 377 base ids
736 through the socket on 2026-10-01 answered `none` for every one, with no
737 errors: this account's codes live in a separate authenticator. The feature
738 waits for the first item that gets an OTP field in 1Password. Its UI was
739 verified in a shadow against a stand-in daemon instead, since a shadow
740 shares the live runtime dir and must not bind the real socket:
741 `CCE_KEYRING_SYNC_SOCK=<path>` points cce-secrets elsewhere, and a
742 twelve-line Python server answering `otp 482913 <30 - now % 30>` stands in.
743 Seen: the code row under the secret, the countdown tracking the wall clock,
744 exactly one re-ask per expiry, and Copy code ending the first button row.
745 (cce-secrets is a Secret Service *client* and owns no bus name, so it is
746 safe in a shadow, contrary to `cce-shadow`'s help text. It lists the live
747 keyring there, so browse only.)