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