git.lucas.co / cce-secrets
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.)