git-ents.gitmain
⌘K
foforge
commit 866d95c
feat: revoke a key faster than expiry via a deny overlay

Routine compromise self-heals through expiry; revocation is the "faster than expiry" override. A new refs/meta/revoked ref lists denied fingerprints, and the verifier subtracts them from the trust set before checking a push, so a compromised key is refused the moment it is listed — without waiting for its window and without editing the member refs. It is one ref precisely so a forge can fan it out to every hosted repo as a single push.

Revoking the bootstrap stays keyed on member refs existing, so revoking every member’s keys fails closed rather than reopening the open window. Gated end-to-end: a push by an otherwise-valid in-window member whose fingerprint is revoked is rejected. git ents members revoke warns before denying your own signing key, which would otherwise lock you out of even lifting it.

This is the self-contained half of the plan’s Phase 4. The two cross-boundary halves are deferred as separate work: vendoring an upstream account’s CA into refs/meta/member/<user> (the sibling git-vendor is mid-refactor and exposes no library API for meta-refs, so it is not usable as-is), and the server-side fan-out that pushes this deny list to every hosted repo.

feat: add git_ents::revocations — the refs/meta/revoked deny list feat: subtract revoked fingerprints from the trust set in the verifier feat: add git ents members revoke/unrevoke and flag revoked keys in list 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-server/src/verify.rs @@ -5,12 +5,16 @@ //! its open bootstrap window and every push is allowed, so the first member can //! be pushed in. Once any member is listed, a push must carry a signed-push //! certificate (`git push --signed`) whose anti-replay nonce git accepted and -//! whose signature verifies against one of those members' in-window keys. +//! whose signature verifies against one of those members' in-window keys — +//! minus any fingerprint on the `refs/meta/revoked` deny list, which is +//! subtracted from the trust set before the check so a revoked key fails the +//! moment it is listed, faster than its window would expire. use std::io::Write; use std::path::Path; use std::process::{Command, Stdio}; +use git_ents::revocations; use git_ents::signers::{self, Member}; /// Verify the push git is about to apply, returning `Ok(())` to accept it or @@ -18,12 +22,18 @@ /// environment git populates for the hook. pub fn pre_receive() -> Result<(), String> { let repo = std::env::current_dir().map_err(|e| format!("cannot resolve repository: {e}"))?; - let authorized = + let members = signers::load_all(&repo).map_err(|e| format!("could not read authorized signers: {e}"))?; - if authorized.is_empty() { + if members.is_empty() { // No trust list pushed yet: stay open so the first signer can be added. + // Revocation is keyed on member refs existing, so revoking every member's + // keys leaves the set empty and fails closed rather than reopening this + // bootstrap window. return Ok(()); } + let revoked = + revocations::fingerprints(&repo).map_err(|e| format!("could not read revocations: {e}"))?; + let authorized = signers::without_revoked(members, &revoked); let cert_oid = env("GIT_PUSH_CERT") .filter(|oid| !oid.is_empty())
crates/git-ents-server/tests/pre_receive.rs @@ -151,6 +151,17 @@ repo } +/// Deny `fingerprint` on the server's `refs/meta/revoked` ref, in the real +/// on-disk `revoked/<fingerprint>` blob layout. The members helper records each +/// member's key under the fingerprint `key`. +fn revoke(repo: &Path, fingerprint: &str) { + let reason_blob = hash_object(repo, b"compromised"); + let revoked_tree = mktree(repo, &format!("100644 blob {reason_blob}\t{fingerprint}\n")); + let root_tree = mktree(repo, &format!("040000 tree {revoked_tree}\trevoked\n")); + let commit = ok(repo, "git", &["commit-tree", &root_tree, "-m", "revoke"]); + ok(repo, "git", &["update-ref", "refs/meta/revoked", &commit]); +} + fn hash_object(repo: &Path, bytes: &[u8]) -> String { pipe(repo, &["hash-object", "-w", "--stdin"], bytes) } @@ -315,6 +326,24 @@ std::fs::remove_dir_all(&base).ok(); } +#[test] +fn rejects_a_push_signed_by_a_revoked_key() { + // The key is a valid, in-window member, but its fingerprint is on the + // `refs/meta/revoked` deny list, so the verifier subtracts it and the push + // is refused — revocation faster than expiry. + let base = unique_dir("revoked"); + let pubkey = keygen(&base, "id"); + let server = server_repo(&base, &[&pubkey]); + revoke(&server, "key"); + let work = work_repo(&base, Some(&pubkey)); + + assert!( + !push(&work, &server, true), + "push signed by a revoked key was accepted" + ); + std::fs::remove_dir_all(&base).ok(); +} + #[test] fn accepts_any_push_before_signers_are_configured() { let base = unique_dir("bootstrap");
crates/git-ents/src/lib.rs @@ -4,6 +4,7 @@ pub mod checks; pub mod config; pub mod issues; +pub mod revocations; pub mod signers; #[cfg(test)] mod testutil;
crates/git-ents/src/main.rs @@ -15,6 +15,7 @@ use clap::{Parser, Subcommand}; use git_ents::account::{self, Account}; use git_ents::checks::{self, CHECKS_REF, Check}; +use git_ents::revocations::{self, REVOKED_REF, Revocation}; use git_ents::signers::{self, MEMBER_NS, Member, Trust, member_ref}; #[derive(Parser)] @@ -85,14 +86,35 @@ #[arg(long, value_name = "TIMESTAMP")] valid_before: Option<String>, }, - /// Revoke a member, deleting its ref on a remote and pushing the update. + /// Remove a member, deleting its ref on a remote and pushing the update. Remove { - /// Member (username) to revoke — its `refs/meta/member/<username>` ref. + /// Member (username) to remove — its `refs/meta/member/<username>` ref. username: String, /// Remote whose member ref to delete. #[arg(default_value = "origin")] remote: String, }, + /// Revoke a key fast: add its fingerprint to the `refs/meta/revoked` deny + /// list so it is refused before its window expires, and push the update. + Revoke { + /// Fingerprint of the key to deny (as shown by `members list`). + fingerprint: String, + /// Remote whose `refs/meta/revoked` to update. + #[arg(default_value = "origin")] + remote: String, + /// Free-text reason recorded alongside the revocation. + #[arg(long, default_value = "")] + reason: String, + }, + /// Lift a revocation, removing a fingerprint from the `refs/meta/revoked` + /// deny list and pushing the update. + Unrevoke { + /// Fingerprint to stop denying. + fingerprint: String, + /// Remote whose `refs/meta/revoked` to update. + #[arg(default_value = "origin")] + remote: String, + }, /// Report whether a key is a member and the client is configured. Check { /// Remote to read the `refs/meta/member/*` refs from. @@ -187,6 +209,15 @@ valid_before, ), Action::Remove { username, remote } => members_remove(&username, &remote), + Action::Revoke { + fingerprint, + remote, + reason, + } => members_revoke(&fingerprint, &remote, reason), + Action::Unrevoke { + fingerprint, + remote, + } => members_unrevoke(&fingerprint, &remote), Action::Check { remote, key } => check(&remote, key.as_deref()), } } @@ -486,15 +517,18 @@ /// List every member on `remote` — one line per authorized key, or one /// `cert-authority` line per pinned-CA member — as -/// `<username>[/<fingerprint>] <label><window>`. +/// `<username>[/<fingerprint>] <label><window>`, flagging keys on the +/// `refs/meta/revoked` deny list as `[revoked]`. fn members_list(remote: &str) -> Result<(), String> { let repo = repo()?; sync_namespace(remote, MEMBER_NS)?; + sync(remote, REVOKED_REF)?; let members = signers::load_all(&repo).map_err(|error| error.to_string())?; if members.is_empty() { println!("no members on {remote} (open bootstrap window)"); return Ok(()); } + let revoked = revocations::fingerprints(&repo).map_err(|error| error.to_string())?; for member in members { let suffix = window_suffix(&member); if let Some(ca) = member.ca() { @@ -505,8 +539,13 @@ ); } else { for (fingerprint, key) in member.keys() { + let flag = if revoked.contains(fingerprint) { + " [revoked]" + } else { + "" + }; println!( - "{}/{fingerprint} {}{suffix}", + "{}/{fingerprint} {}{suffix}{flag}", member.principal, key_comment(key) ); @@ -516,6 +555,57 @@ Ok(()) } +/// Add `fingerprint` to `remote`'s `refs/meta/revoked` deny list and push the +/// update, so the key is refused before its window would expire. +fn members_revoke(fingerprint: &str, remote: &str, reason: String) -> Result<(), String> { + let repo = repo()?; + // Revoking your own key fails closed against you too: if it is the last key + // that authorizes your pushes, you cannot even push the un-revoke. Warn + // before locking yourself out. + if own_fingerprint().is_some_and(|own| own == fingerprint) + && !confirm(&format!( + "{fingerprint} is your own signing key; \ + revoking it may lock you out of {remote}. Continue?" + ))? + { + return Err("revocation cancelled".to_owned()); + } + let expected = sync(remote, REVOKED_REF)?; + let mut revocations = revocations::load(&repo).map_err(|error| error.to_string())?; + if let Some(existing) = revocations + .iter_mut() + .find(|revocation| revocation.fingerprint == fingerprint) + { + existing.reason = reason; + } else { + revocations.push(Revocation { + fingerprint: fingerprint.to_owned(), + reason, + }); + } + revocations::store(&repo, &revocations).map_err(|error| error.to_string())?; + push_signed(remote, REVOKED_REF, expected.as_deref())?; + println!("revoked {fingerprint}"); + Ok(()) +} + +/// Remove `fingerprint` from `remote`'s `refs/meta/revoked` deny list and push +/// the update. +fn members_unrevoke(fingerprint: &str, remote: &str) -> Result<(), String> { + let repo = repo()?; + let expected = sync(remote, REVOKED_REF)?; + let mut revocations = revocations::load(&repo).map_err(|error| error.to_string())?; + let before = revocations.len(); + revocations.retain(|revocation| revocation.fingerprint != fingerprint); + if revocations.len() == before { + return Err(format!("{fingerprint} is not revoked on {remote}")); + } + revocations::store(&repo, &revocations).map_err(|error| error.to_string())?; + push_signed(remote, REVOKED_REF, expected.as_deref())?; + println!("lifted revocation of {fingerprint}"); + Ok(()) +} + /// 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( @@ -616,6 +706,13 @@ Ok(()) } +/// This client's own signing-key fingerprint, best-effort — `None` when no key +/// is configured or it cannot be read. +fn own_fingerprint() -> Option<String> { + let public_key = public_key(None).ok()?; + fingerprint(&public_key).ok() +} + /// The current time as seconds since the Unix epoch. fn now_seconds() -> u64 { std::time::SystemTime::now()
crates/git-ents/src/signers.rs @@ -17,7 +17,7 @@ //! verifiable forever, since `ssh-keygen -Y verify -Overify-time` can pin the //! check to the time the push was made. -use std::collections::BTreeMap; +use std::collections::{BTreeMap, BTreeSet}; use std::path::Path; use facet::Facet; @@ -140,6 +140,27 @@ Ok(()) } +/// Drop every `revoked` fingerprint from `members`, returning the trust set the +/// verifier should actually honor. +/// +/// A leaf key whose fingerprint is revoked is removed; a member left with no keys +/// drops out entirely, so a push it would have authorized fails closed. A member +/// resting on a CA is untouched — a compromised CA is revoked by removing its +/// member ref, since a CA is named by a ref rather than listed by fingerprint. +#[must_use] +pub fn without_revoked(members: Vec<Member>, revoked: &BTreeSet<String>) -> Vec<Member> { + members + .into_iter() + .filter_map(|mut member| match &mut member.trust { + Trust::Keys(keys) => { + keys.retain(|fingerprint, _key| !revoked.contains(fingerprint)); + if keys.is_empty() { None } else { Some(member) } + } + Trust::CertAuthority(_ca) => Some(member), + }) + .collect() +} + /// Render `members` as an OpenSSH `allowed_signers` file that authorizes any /// pusher identity (`*`) signing in git's namespace. /// @@ -324,6 +345,34 @@ let _ = std::fs::remove_dir_all(&repo); } + #[test] + fn without_revoked_drops_revoked_keys_and_emptied_members() { + let alice = Member::with_keys( + "alice".to_owned(), + keys(&[("aa:bb", KEY_A), ("cc:dd", KEY_B)]), + ); + let bob = Member::with_keys("bob".to_owned(), keys(&[("ee:ff", KEY_A)])); + let revoked = BTreeSet::from(["cc:dd".to_owned(), "ee:ff".to_owned()]); + + // bob's only key was revoked, so bob drops out entirely; alice keeps her + // un-revoked key. + let alice_kept = Member::with_keys("alice".to_owned(), keys(&[("aa:bb", KEY_A)])); + assert_eq!( + without_revoked(vec![alice, bob], &revoked), + vec![alice_kept] + ); + } + + #[test] + fn without_revoked_leaves_ca_members_untouched() { + let member = Member::with_ca("alice".to_owned(), KEY_A.to_owned()); + let revoked = BTreeSet::from(["aa:bb".to_owned()]); + assert_eq!( + without_revoked(vec![member.clone()], &revoked), + vec![member] + ); + } + #[test] fn renders_a_pinned_ca_as_a_cert_authority_line() { let mut member = Member::with_ca("alice".to_owned(), KEY_A.to_owned());
crates/git-ents/src/revocations.rs @@ -1,0 +1,169 @@ +//! Fast revocation, sourced from the `refs/meta/revoked` ref. +//! +//! Routine compromise self-heals through expiry: an un-refreshed member key +//! stops authorizing pushes once its window lapses. Revocation is the "faster +//! than expiry" override — a deny list of fingerprints the verifier subtracts +//! from the trust set *before* checking a push, so a compromised key is refused +//! the moment it is listed, without waiting for its window and without editing +//! the member refs that may be governed elsewhere. +//! +//! The list is one ref — `refs/meta/revoked` — precisely so a forge can fan it +//! out to every repository it hosts as a single push (that fan-out is a +//! server-side concern layered on top of this primitive). It denies leaf-key +//! fingerprints; a compromised certificate authority is revoked by removing the +//! CA member itself, since a CA is named by a whole ref rather than listed by +//! fingerprint. + +use std::collections::{BTreeMap, BTreeSet}; +use std::path::Path; + +use facet::Facet; + +/// The ref whose tree holds the revocation list — the deny overlay on the trust +/// set. +pub const REVOKED_REF: &str = "refs/meta/revoked"; + +/// The revocation document stored at [`REVOKED_REF`]: its `revoked/` subtree maps +/// each revoked fingerprint to a free-text reason (`""` when none was given). +#[derive(Debug, Clone, PartialEq, Eq, Facet)] +struct Revocations { + revoked: BTreeMap<String, String>, +} + +impl git_store::MapDoc for Revocations { + fn from_entries(entries: BTreeMap<String, String>) -> Self { + Self { revoked: entries } + } + + fn into_entries(self) -> BTreeMap<String, String> { + self.revoked + } +} + +/// One revoked key recorded in [`REVOKED_REF`]: its fingerprint and why it was +/// revoked. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct Revocation { + /// The revoked key's fingerprint — the `members/<fingerprint>` it denies. + pub fingerprint: String, + /// A free-text reason, or `""` when none was given. + pub reason: String, +} + +impl git_store::Row for Revocation { + fn from_pair(fingerprint: String, reason: String) -> Self { + Self { + fingerprint, + reason, + } + } + + fn into_pair(self) -> (String, String) { + (self.fingerprint, self.reason) + } +} + +/// Load the revocations recorded at [`REVOKED_REF`] in `repo`. An absent ref +/// yields an empty list — nothing is revoked. +pub fn load(repo: &Path) -> Result<Vec<Revocation>, git_store::Error> { + git_store::Store::open(repo)?.load_rows::<Revocations, Revocation>(REVOKED_REF) +} + +/// Write `revocations` to [`REVOKED_REF`], replacing any existing list, as a new +/// commit. +pub fn store(repo: &Path, revocations: &[Revocation]) -> Result<(), git_store::Error> { + git_store::Store::open(repo)?.store_rows::<Revocations, _>( + REVOKED_REF, + revocations.iter().cloned(), + "Update revocations", + ) +} + +/// The set of revoked fingerprints recorded at [`REVOKED_REF`] in `repo`, for the +/// verifier to subtract from the trust set. +pub fn fingerprints(repo: &Path) -> Result<BTreeSet<String>, git_store::Error> { + Ok(load(repo)? + .into_iter() + .map(|revocation| revocation.fingerprint) + .collect()) +} + +#[cfg(test)] +mod tests { + #![allow( + clippy::unwrap_used, + clippy::let_underscore_must_use, + reason = "unit test" + )] + + use super::*; + use crate::testutil::{unique_repo as new_repo, write_meta_doc}; + + fn unique_repo() -> std::path::PathBuf { + new_repo("revocations") + } + + fn revocation(fingerprint: &str, reason: &str) -> Revocation { + Revocation { + fingerprint: fingerprint.to_owned(), + reason: reason.to_owned(), + } + } + + #[test] + fn store_then_load_round_trips_the_revocations() { + let repo = unique_repo(); + let written = vec![ + revocation("aa:bb", "laptop stolen"), + revocation("cc:dd", ""), + ]; + store(&repo, &written).unwrap(); + let mut loaded = load(&repo).unwrap(); + loaded.sort_by(|a, b| a.fingerprint.cmp(&b.fingerprint)); + assert_eq!(loaded, written); + let _ = std::fs::remove_dir_all(&repo); + } + + #[test] + fn empty_when_the_revoked_ref_is_absent() { + let repo = unique_repo(); + assert!(load(&repo).unwrap().is_empty()); + assert!(fingerprints(&repo).unwrap().is_empty()); + let _ = std::fs::remove_dir_all(&repo); + } + + #[test] + fn fingerprints_collects_the_revoked_keys() { + let repo = unique_repo(); + store(&repo, &[revocation("aa:bb", "x"), revocation("cc:dd", "")]).unwrap(); + assert_eq!( + fingerprints(&repo).unwrap(), + BTreeSet::from(["aa:bb".to_owned(), "cc:dd".to_owned()]) + ); + let _ = std::fs::remove_dir_all(&repo); + } + + #[test] + fn loads_the_on_disk_revoked_format() { + // A fixture written as the real `revoked/<fingerprint>` blob layout must + // keep loading, guarding the document's shape against an incompatible + // change to data already on a ref. + let repo = unique_repo(); + write_meta_doc( + &repo, + REVOKED_REF, + "revoked", + &[("aa:bb", "laptop stolen"), ("cc:dd", "")], + ); + let mut loaded = load(&repo).unwrap(); + loaded.sort_by(|a, b| a.fingerprint.cmp(&b.fingerprint)); + assert_eq!( + loaded, + vec![ + revocation("aa:bb", "laptop stolen"), + revocation("cc:dd", "") + ] + ); + let _ = std::fs::remove_dir_all(&repo); + } +}