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