secrets manager
git clone https://git.lucas.co/cce-secrets.git
src/bin/cce-keyring-sync/main.rs (8.5K)
1 //! cce-keyring-sync — keep gnome-keyring and 1Password in step.
2 //!
3 //! See KEYRING-SYNC.md for the design and its measurements. gnome-keyring
4 //! stays the live store (what cce-secrets and cce-browser front over the
5 //! Secret Service); 1Password is the cross-machine interchange, reached
6 //! through the `op` CLI as a child process (op.rs). `adopt` pairs what both
7 //! sides already hold and seeds the base; `sync` is one three-way merge
8 //! pass (sync.rs); `daemon` is the resident loop the systemd unit runs
9 //! (daemon.rs) — resident because the CLI's authorization is keyed to the
10 //! calling process's parent and lapses when idle. The daemon also serves
11 //! one-time codes to cce-secrets over a socket (serve.rs).
12 //!
13 //! Discipline kept from the kdbx era this replaced (2026-09-21):
14 //! - the state file stores keyed hashes of fields, never values; the hash
15 //! key is itself a keyring item;
16 //! - attribute names match cce-secrets: label=Title, UserName, URL, Notes,
17 //! plus op-item / op-vault (kdbx-uuid / kdbx-group linger on old items
18 //! and are ignored);
19 //! - the merge never destroys a value: 1Password keeps item history on
20 //! every edit, keyring deletions become Archive entries, and modification
21 //! beats deletion.
22
23 mod adopt;
24 mod daemon;
25 mod op;
26 mod serve;
27 mod sync;
28
29 use std::collections::HashMap;
30 use std::path::{Path, PathBuf};
31
32 use secret_service::SecretService;
33 use serde::{Deserialize, Serialize};
34
35 pub(crate) const APP: &str = "cce-keyring-sync";
36
37 #[derive(Serialize, Deserialize, Default)]
38 pub(crate) struct State {
39 pub version: u32,
40 pub last_run: i64,
41 /// Which interchange the base snapshot belongs to: "onepassword" once
42 /// `adopt` has run. (The retired kdbx backend wrote "" and keyed
43 /// `entries` by kdbx UUID; such a file is refused, not misread.)
44 #[serde(default)]
45 pub backend: String,
46 /// 1Password only: the vault new entries are created in.
47 #[serde(default)]
48 pub vault: String,
49 /// The last run's one-line outcome ("in sync", "synced: …", "failed: …"),
50 /// for cce-secrets' status line — the daemon has no stdout anyone reads.
51 #[serde(default)]
52 pub last_result: String,
53 /// Per entry id: the last-synced snapshot the merge runs against.
54 pub entries: HashMap<String, EntryState>,
55 }
56
57 #[derive(Serialize, Deserialize)]
58 pub(crate) struct EntryState {
59 /// Keyed blake3 over the canonical field concatenation — never values.
60 pub h: String,
61 pub keyring_modified: u64,
62 /// 1Password's `updated_at` at the base, verbatim. Empty means unknown:
63 /// the next sync fetches the entry regardless of the list timestamp.
64 #[serde(default)]
65 pub op_updated_at: String,
66 }
67
68 pub(crate) fn state_dir() -> PathBuf {
69 cce_ui::config::cce_state_dir().join("keyring-sync")
70 }
71
72 pub(crate) fn now_unix() -> i64 {
73 std::time::SystemTime::now()
74 .duration_since(std::time::UNIX_EPOCH)
75 .map(|d| d.as_secs() as i64)
76 .unwrap_or(0)
77 }
78
79 /// A secret held as a keyring item under our own application attribute:
80 /// the state-file hash key lives this way, unlocked by PAM along with
81 /// everything else.
82 pub(crate) async fn keyring_get(
83 ss: &SecretService<'_>,
84 purpose: &str,
85 ) -> Result<Option<Vec<u8>>, secret_service::Error> {
86 let mut attrs = HashMap::new();
87 attrs.insert("application", APP);
88 attrs.insert("purpose", purpose);
89 let found = ss.search_items(attrs).await?;
90 match found.unlocked.first() {
91 Some(item) => Ok(Some(item.get_secret().await?)),
92 None => Ok(None),
93 }
94 }
95
96 pub(crate) async fn keyring_put(
97 ss: &SecretService<'_>,
98 purpose: &str,
99 label: &str,
100 secret: &[u8],
101 ) -> Result<(), secret_service::Error> {
102 let col = ss.get_default_collection().await?;
103 let mut attrs = HashMap::new();
104 attrs.insert("application", APP);
105 attrs.insert("purpose", purpose);
106 col.create_item(label, attrs, secret, true, "text/plain").await?;
107 Ok(())
108 }
109
110 /// A keyring item's synced fields, snapshotted once per run.
111 #[derive(Clone)]
112 pub(crate) struct KrEntry {
113 pub title: String,
114 pub username: String,
115 pub password: String,
116 pub url: String,
117 pub notes: String,
118 /// The vault name (`op-vault`).
119 pub group: String,
120 pub modified: u64,
121 }
122
123 impl KrEntry {
124 pub fn hash(&self, key: &[u8; 32]) -> String {
125 let mut h = blake3::Hasher::new_keyed(key);
126 for part in [&self.title, &self.username, &self.password, &self.url, &self.notes, &self.group] {
127 h.update(part.as_bytes());
128 h.update(&[0]);
129 }
130 h.finalize().to_hex().to_string()
131 }
132 }
133
134 /// A crude cross-process lock: a one-shot `sync` must not interleave with
135 /// the daemon's tick. Advisory flock on a file in the state dir.
136 pub(crate) fn take_lock() -> Option<std::fs::File> {
137 let _ = std::fs::create_dir_all(state_dir());
138 let f = std::fs::OpenOptions::new()
139 .create(true)
140 .write(true)
141 .open(state_dir().join("lock"))
142 .ok()?;
143 match rustix::fs::flock(&f, rustix::fs::FlockOperation::NonBlockingLockExclusive) {
144 Ok(()) => Some(f),
145 Err(_) => None,
146 }
147 }
148
149 pub(crate) fn journal_append(lines: &str) {
150 use std::io::Write;
151 if let Ok(mut f) = std::fs::OpenOptions::new()
152 .create(true)
153 .append(true)
154 .open(state_dir().join("journal.log"))
155 {
156 let _ = f.write_all(lines.as_bytes());
157 }
158 }
159
160 pub(crate) fn write_state(state_path: &Path, state: &State) {
161 let _ = std::fs::create_dir_all(state_dir());
162 let tmp = state_path.with_extension("json.tmp");
163 if std::fs::write(&tmp, serde_json::to_vec_pretty(state).unwrap()).is_ok() {
164 let _ = std::fs::rename(&tmp, state_path);
165 }
166 }
167
168 #[tokio::main(flavor = "current_thread")]
169 async fn main() {
170 let args: Vec<String> = std::env::args().skip(1).collect();
171 let dry_run = args.iter().any(|a| a == "--dry-run");
172 let allow_mass_delete = args.iter().any(|a| a == "--allow-mass-delete");
173 let vault_flag = args
174 .iter()
175 .position(|a| a == "--vault")
176 .and_then(|i| args.get(i + 1))
177 .cloned();
178 // The subcommand: the first word that is neither a flag nor a flag's value.
179 let mut skip_next = false;
180 let mut cmd = None;
181 for a in &args {
182 if skip_next {
183 skip_next = false;
184 continue;
185 }
186 if a == "--vault" {
187 skip_next = true;
188 continue;
189 }
190 if !a.starts_with("--") {
191 cmd = Some(a.clone());
192 break;
193 }
194 }
195 let cmd = match cmd.as_deref() {
196 Some(c @ ("sync" | "status" | "adopt" | "daemon")) => c.to_string(),
197 _ => {
198 eprintln!("usage: cce-keyring-sync sync [--dry-run] (one merge pass; raises its own Authorize dialog)");
199 eprintln!(" cce-keyring-sync sync --allow-mass-delete (let one pass remove more than a tenth of the synced items)");
200 eprintln!(" cce-keyring-sync daemon (resident; what cce-keyring-sync.service runs)");
201 eprintln!(" cce-keyring-sync adopt [--dry-run] [--vault <name>] (pair the keyring with 1Password, seed the base)");
202 eprintln!(" cce-keyring-sync status");
203 std::process::exit(2);
204 }
205 };
206
207 let state_path = state_dir().join("state.json");
208 let mut state: State = std::fs::read_to_string(&state_path)
209 .ok()
210 .and_then(|s| serde_json::from_str(&s).ok())
211 .unwrap_or_default();
212
213 match cmd.as_str() {
214 "status" => {
215 if state.backend == "onepassword" {
216 println!("backend: 1Password (vault {})", if state.vault.is_empty() { "*" } else { &state.vault });
217 } else {
218 println!("backend: none — run `cce-keyring-sync adopt --vault <name>`");
219 }
220 println!("state: {} entries, last run {}", state.entries.len(), state.last_run);
221 if !state.last_result.is_empty() {
222 println!("last: {}", state.last_result);
223 }
224 }
225 "adopt" => adopt::adopt(&state_path, state, vault_flag.as_deref().unwrap_or(""), dry_run).await,
226 "daemon" => daemon::daemon(&state_path).await,
227 _ => {
228 // A one-shot pass; the daemon is the usual caller, and the flock
229 // keeps the two apart.
230 let mut remote = op::OnePassword::new(&state.vault);
231 if let Err(e) = sync::sync_remote(&mut remote, &state_path, &mut state, dry_run, allow_mass_delete).await {
232 eprintln!("{e}");
233 std::process::exit(1);
234 }
235 }
236 }
237 }