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