git-ents.gitmain
⌘K
foforge
commit 94ce438
feat: pin a certificate authority as an opt-in member trust

A member’s Trust gains an additive CertAuthority case beside Keys: pinning a CA’s public key trusts any certificate it issues for the member’s principal, within the cert’s own validity window. This decouples the stable pin from ephemeral device keys, so rotation, expiry, and new devices cost zero downstream edits — the enterprise / many-repos option, while leaf keys stay the solo default.

The verifier needs no new logic: allowed_signers renders a cert-authority line and ssh-keygen -Y verify consumes it natively, enforcing the cert chain, the principal, and the member’s window. Gated end-to-end: a cert from the pinned CA verifies (signed through an ssh-agent that embeds it), a cert from an unpinned CA does not.

feat: add Trust::CertAuthority and Member::with_ca/Member::ca feat: render a pinned CA as a cert-authority allowed_signers line feat: add --cert-authority to git ents members add Assisted-by: Claude:claude-opus-4-8

Joseph D. Carpinelli · 1 month ago

Reviews

No reviews of this commit yet — record a verdict below.

Start a review

verdict

crates/git-ents/src/main.rs @@ -72,6 +72,10 @@ /// Key to authorize; defaults to `user.signingkey`. #[arg(long)] key: Option<PathBuf>, + /// Pin a certificate authority public key instead of leaf keys: trust + /// any certificate it issues for the member, within the cert's validity. + #[arg(long, value_name = "CA_PUBKEY", conflicts_with = "key")] + cert_authority: Option<PathBuf>, /// Trust the member only at or after this OpenSSH timestamp /// (`YYYYMMDD[Z]` or `YYYYMMDDHHMM[SS][Z]`; append `Z` for UTC). #[arg(long, value_name = "TIMESTAMP")] @@ -171,12 +175,14 @@ username, remote, key, + cert_authority, valid_after, valid_before, } => members_add( &username, &remote, key.as_deref(), + cert_authority.as_deref(), valid_after, valid_before, ), @@ -478,8 +484,9 @@ Ok(Path::new(&home).join(".ssh").join("id_ed25519")) } -/// List every member on `remote` — one line per authorized key — as -/// `<username>/<fingerprint> <key label><window>`. +/// List every member on `remote` — one line per authorized key, or one +/// `cert-authority` line per pinned-CA member — as +/// `<username>[/<fingerprint>] <label><window>`. fn members_list(remote: &str) -> Result<(), String> { let repo = repo()?; sync_namespace(remote, MEMBER_NS)?; @@ -490,23 +497,32 @@ } for member in members { let suffix = window_suffix(&member); - for (fingerprint, key) in member.keys() { + if let Some(ca) = member.ca() { println!( - "{}/{fingerprint} {}{suffix}", + "{} cert-authority {}{suffix}", member.principal, - key_comment(key) + key_comment(ca) ); + } else { + for (fingerprint, key) in member.keys() { + println!( + "{}/{fingerprint} {}{suffix}", + member.principal, + key_comment(key) + ); + } } } Ok(()) } -/// Authorize `key` for the member `username` on `remote`, trusting the member -/// within the given validity window, and push the updated member ref. +/// Authorize a key (or pin a CA) for the member `username` on `remote`, trusting +/// the member within the given validity window, and push the updated member ref. fn members_add( username: &str, remote: &str, key: Option<&Path>, + cert_authority: Option<&Path>, valid_after: Option<String>, valid_before: Option<String>, ) -> Result<(), String> { @@ -517,8 +533,6 @@ validate_timestamp(before)?; } let repo = repo()?; - let public_key = public_key(key)?; - let fingerprint = fingerprint(&public_key)?; let refname = member_ref(username); let expected = sync(remote, &refname)?; let mut member = signers::load(&repo, username) @@ -530,7 +544,29 @@ if valid_before.is_some() { member.valid_before = valid_before; } - let Trust::Keys(keys) = &mut member.trust; + + // Pinning a CA replaces the member's trust wholesale — a member is either + // leaf keys or a CA, never both. + if let Some(ca_path) = cert_authority { + let ca = read_public_key(ca_path)?; + member.trust = Trust::CertAuthority(ca); + signers::store(&repo, &member).map_err(|error| error.to_string())?; + push_signed(remote, &refname, expected.as_deref())?; + println!("pinned a certificate authority for {username}"); + return Ok(()); + } + + let public_key = public_key(key)?; + let fingerprint = fingerprint(&public_key)?; + let keys = match &mut member.trust { + Trust::Keys(keys) => keys, + Trust::CertAuthority(_ca) => { + return Err(format!( + "{username} is pinned to a certificate authority; \ + revoke and re-add to switch to leaf keys" + )); + } + }; if keys .values() .any(|existing| same_key(existing, &public_key))
crates/git-ents/src/signers.rs @@ -50,14 +50,21 @@ } /// What a member's trust rests on. A member is *either* a set of leaf keys *or* -/// (from Phase 3) a pinned certificate authority — additive cases, not a -/// migration of one another. +/// a pinned certificate authority — additive cases, not a migration of one +/// another. Pinning a CA decouples the stable pin from ephemeral device keys, so +/// rotation, expiry, and new devices cost zero downstream edits; it is a +/// security win only when the CA lives off the device (hardware token, offline, +/// or a remote issuer behind SSO). #[derive(Debug, Clone, PartialEq, Eq, Facet)] #[repr(u8)] pub enum Trust { /// A set of leaf signing keys, mapping each fingerprint to its OpenSSH public - /// key. + /// key. The solo/small-team default. Keys(BTreeMap<String, String>), + /// A pinned certificate authority's OpenSSH public key: any certificate it + /// issues for the member's principal, within the cert's own validity window, + /// is trusted. The enterprise / many-repos option. + CertAuthority(String), } impl Member { @@ -72,12 +79,35 @@ } } + /// A member trusting any certificate the CA `ca` issues for them, with no + /// validity window. + #[must_use] + pub fn with_ca(principal: String, ca: String) -> Self { + Self { + principal, + valid_after: None, + valid_before: None, + trust: Trust::CertAuthority(ca), + } + } + /// The member's leaf signing keys as `(fingerprint, key)` pairs. A member - /// resting on a CA (Phase 3) has no leaf keys and yields none. + /// resting on a CA has no leaf keys and yields none. #[must_use] pub fn keys(&self) -> Vec<(&String, &String)> { match &self.trust { Trust::Keys(keys) => keys.iter().collect(), + Trust::CertAuthority(_ca) => Vec::new(), + } + } + + /// The member's pinned certificate authority key, or `None` when the member + /// rests on leaf keys. + #[must_use] + pub fn ca(&self) -> Option<&str> { + match &self.trust { + Trust::CertAuthority(ca) => Some(ca), + Trust::Keys(_keys) => None, } } } @@ -129,20 +159,29 @@ members.iter().flat_map(member_lines).collect::<String>() } -/// The `allowed_signers` lines for one member: one per leaf key, each carrying -/// the member's validity window. +/// The `allowed_signers` lines for one member: one per leaf key, or a single +/// `cert-authority` line for a pinned CA. Each carries the member's validity +/// window. `ssh-keygen -Y verify` consumes the `cert-authority` flag natively — +/// it accepts a certificate the CA issued for the verified principal — so no +/// special verifier logic is needed beyond emitting the line. fn member_lines(member: &Member) -> Vec<String> { - member - .keys() - .into_iter() - .map(|(_fingerprint, key)| allowed_signers_line(member, key)) - .collect() + match &member.trust { + Trust::Keys(keys) => keys + .values() + .map(|key| allowed_signers_line(member, false, key)) + .collect(), + Trust::CertAuthority(ca) => vec![allowed_signers_line(member, true, ca)], + } } -/// One `allowed_signers` line: the wildcard principal, the member's validity -/// window, the git namespace, and the key. -fn allowed_signers_line(member: &Member, key: &str) -> String { +/// One `allowed_signers` line: the wildcard principal, the `cert-authority` flag +/// when `ca` is set, the member's validity window, the git namespace, and the +/// key. Options are comma-joined, the syntax OpenSSH requires for more than one. +fn allowed_signers_line(member: &Member, ca: bool, key: &str) -> String { let mut options = Vec::new(); + if ca { + options.push("cert-authority".to_owned()); + } if let Some(after) = &member.valid_after { options.push(format!("valid-after=\"{after}\"")); } @@ -272,4 +311,26 @@ ) ); } + + #[test] + fn store_then_load_round_trips_a_ca_member() { + let repo = unique_repo(); + let member = Member::with_ca("alice".to_owned(), KEY_A.to_owned()); + store(&repo, &member).unwrap(); + let loaded = load(&repo, "alice").unwrap().unwrap(); + assert_eq!(loaded, member); + assert_eq!(loaded.ca(), Some(KEY_A)); + assert!(loaded.keys().is_empty()); + let _ = std::fs::remove_dir_all(&repo); + } + + #[test] + fn renders_a_pinned_ca_as_a_cert_authority_line() { + let mut member = Member::with_ca("alice".to_owned(), KEY_A.to_owned()); + member.valid_before = Some("20270101".to_owned()); + assert_eq!( + allowed_signers(&[member]), + format!("* cert-authority,valid-before=\"20270101\",namespaces=\"git\" {KEY_A}\n") + ); + } }
crates/git-ents-server/src/web/render.rs @@ -48,10 +48,16 @@ } /// A member renders one row per authorized key — the username as the key column, -/// a short key label beside it — rather than the raw keys and trust enum the -/// structural walk would print. +/// a short key label beside it — or a single `cert-authority` row for a pinned +/// CA, rather than the raw keys and trust enum the structural walk would print. impl Render for Member { fn render(&self) -> Markup { + if let Some(ca) = self.ca() { + return row( + &self.principal, + &format!("cert-authority · {}", signer_label(ca)), + ); + } html! { @for (_fingerprint, key) in self.keys() { (row(&self.principal, &signer_label(key)))
crates/git-ents/tests/cert_authority.rs @@ -1,0 +1,228 @@ +#![allow( + missing_docs, + clippy::unwrap_used, + clippy::panic, + clippy::unused_result_ok, + reason = "integration test binary" +)] + +//! The Phase 3 CA-pin gate: a certificate the pinned CA issued verifies against +//! the `allowed_signers` file `git_ents::signers` renders, and a certificate +//! from an unpinned CA does not. The cert-embedded signature is produced the way +//! a real client would — through an `ssh-agent` holding the key and its +//! certificate — since `ssh-keygen -Y sign` only embeds a certificate when the +//! agent supplies it. + +use std::path::{Path, PathBuf}; +use std::process::{Command, Stdio}; +use std::sync::atomic::{AtomicUsize, Ordering}; + +use git_ents::signers::{Member, Trust, allowed_signers}; + +/// The principal the CA certifies and the verifier checks — the pusher identity. +const PRINCIPAL: &str = "tester@example.com"; + +#[test] +fn a_cert_from_the_pinned_ca_verifies_and_an_unpinned_one_does_not() { + let Some(agent) = Agent::start() else { + // No usable ssh-agent (unusual, but don't fail the suite over the + // environment); the unit tests still cover the rendered line. + eprintln!("skipping: could not start ssh-agent"); + return; + }; + let dir = unique_dir(); + + let ca = keygen(&dir, "ca"); + let other_ca = keygen(&dir, "other-ca"); + let user = keygen(&dir, "user"); + certify(&dir, &ca, &user, PRINCIPAL); + agent.add(&user); + + let message = dir.join("msg"); + std::fs::write(&message, "payload\n").unwrap(); + let signature = agent.sign(&user_cert(&user), &message); + + // The pinned CA's `allowed_signers` accepts the cert it issued. + let pinned = render_ca_allowed_signers(&dir, "pinned", &ca); + assert!( + verify(&pinned, PRINCIPAL, &message, &signature), + "a cert from the pinned CA was rejected" + ); + + // A different CA's `allowed_signers` rejects it. + let unpinned = render_ca_allowed_signers(&dir, "unpinned", &other_ca); + assert!( + !verify(&unpinned, PRINCIPAL, &message, &signature), + "a cert from an unpinned CA was accepted" + ); + + agent.stop(); + std::fs::remove_dir_all(&dir).ok(); +} + +/// Write the `allowed_signers` file `git_ents::signers` renders for a member +/// whose trust is the CA `ca`, and return its path. +fn render_ca_allowed_signers(dir: &Path, name: &str, ca: &Key) -> PathBuf { + let ca_pubkey = std::fs::read_to_string(&ca.public) + .unwrap() + .trim() + .to_owned(); + let member = Member { + principal: "anyone".to_owned(), + valid_after: None, + valid_before: None, + trust: Trust::CertAuthority(ca_pubkey), + }; + // Sanity: a CA member exposes no leaf keys. + assert!(member.keys().is_empty()); + let path = dir.join(name); + std::fs::write(&path, allowed_signers(&[member])).unwrap(); + path +} + +/// An ed25519 key pair: its private and public key paths. +struct Key { + private: PathBuf, + public: PathBuf, +} + +fn keygen(dir: &Path, name: &str) -> Key { + let private = dir.join(name); + let status = Command::new("ssh-keygen") + .args(["-q", "-t", "ed25519", "-N", "", "-C", name, "-f"]) + .arg(&private) + .status() + .unwrap(); + assert!(status.success(), "ssh-keygen failed"); + Key { + public: dir.join(format!("{name}.pub")), + private, + } +} + +/// The certificate path `ssh-keygen` writes beside a signed public key. +fn user_cert(user: &Key) -> PathBuf { + user.private.with_file_name(format!( + "{}-cert.pub", + user.private.file_name().unwrap().to_str().unwrap() + )) +} + +/// Have `ca` issue a user certificate for `user` valid for `principal`. +fn certify(dir: &Path, ca: &Key, user: &Key, principal: &str) { + let status = Command::new("ssh-keygen") + .arg("-q") + .arg("-s") + .arg(&ca.private) + .args(["-I", "test-id", "-n", principal, "-V", "-1d:+365d"]) + .arg(&user.public) + .current_dir(dir) + .status() + .unwrap(); + assert!( + status.success(), + "ssh-keygen could not issue the certificate" + ); +} + +/// Verify `signature` over `message` against `allowed`, returning whether +/// `ssh-keygen -Y verify` accepts it for `principal`. +fn verify(allowed: &Path, principal: &str, message: &Path, signature: &Path) -> bool { + use std::io::Write as _; + let payload = std::fs::read(message).unwrap(); + let mut child = Command::new("ssh-keygen") + .args(["-Y", "verify", "-n", "git", "-I", principal, "-f"]) + .arg(allowed) + .arg("-s") + .arg(signature) + .stdin(Stdio::piped()) + .stdout(Stdio::null()) + .stderr(Stdio::null()) + .spawn() + .unwrap(); + child.stdin.take().unwrap().write_all(&payload).unwrap(); + child.wait().unwrap().success() +} + +/// A running `ssh-agent` the test signs through. +struct Agent { + sock: String, + pid: String, +} + +impl Agent { + /// Start an `ssh-agent`, parsing its socket and pid from the shell snippet it + /// prints. Returns `None` when no agent could be started. + fn start() -> Option<Self> { + let output = Command::new("ssh-agent").arg("-s").output().ok()?; + if !output.status.success() { + return None; + } + let text = String::from_utf8_lossy(&output.stdout); + let field = |key: &str| { + text.split(';') + .find_map(|part| part.trim().strip_prefix(&format!("{key}="))) + .map(str::to_owned) + }; + Some(Self { + sock: field("SSH_AUTH_SOCK")?, + pid: field("SSH_AGENT_PID")?, + }) + } + + /// Load `key` (and the certificate beside it) into the agent. + fn add(&self, key: &Key) { + let status = Command::new("ssh-add") + .arg(&key.private) + .env("SSH_AUTH_SOCK", &self.sock) + .stdin(Stdio::null()) + .stdout(Stdio::null()) + .stderr(Stdio::null()) + .status() + .unwrap(); + assert!(status.success(), "ssh-add failed"); + } + + /// Sign `message` through the agent using the certificate `cert`, returning + /// the signature path. Pointing `-f` at the certificate makes the agent embed + /// it in the SSHSIG, which is what a `cert-authority` line verifies against. + fn sign(&self, cert: &Path, message: &Path) -> PathBuf { + let status = Command::new("ssh-keygen") + .args(["-Y", "sign", "-n", "git", "-f"]) + .arg(cert) + .arg(message) + .env("SSH_AUTH_SOCK", &self.sock) + .stdin(Stdio::null()) + .stdout(Stdio::null()) + .stderr(Stdio::null()) + .status() + .unwrap(); + assert!( + status.success(), + "ssh-keygen -Y sign through the agent failed" + ); + message.with_file_name(format!( + "{}.sig", + message.file_name().unwrap().to_str().unwrap() + )) + } + + fn stop(&self) { + Command::new("ssh-agent") + .args(["-k"]) + .env("SSH_AUTH_SOCK", &self.sock) + .env("SSH_AGENT_PID", &self.pid) + .stdout(Stdio::null()) + .stderr(Stdio::null()) + .status() + .ok(); + } +} + +fn unique_dir() -> PathBuf { + static COUNTER: AtomicUsize = AtomicUsize::new(0); + let n = COUNTER.fetch_add(1, Ordering::SeqCst); + let dir = std::env::temp_dir().join(format!("git-ents-ca-{}-{n}", std::process::id())); + std::fs::create_dir_all(&dir).unwrap(); + dir +}