git-ents.gitmain
⌘K
foforge
commit 2a92da4
feat: decompose members into per-username refs and add accounts

The single aggregated refs/meta/members blob becomes one refs/meta/member/<username> ref per person, each a Member document with a validity window and a Trust of leaf keys. The verifier globs refs/meta/member/* and unions their in-window keys, so a member can be added, refreshed, or revoked as an independent, separately-history’d ref. A new refs/meta/account ref carries the repository’s account identity, and a repo is an account by the presence of that ref — no central user table.

The member key validity window moves from per-key (Phase 1, on the aggregated ref) to per-member, matching the decomposed model; the allowed_signers principal stays a wildcard, since trust is still membership of the key set rather than a binding to a signing identity.

This is a hard break with no fallback: refs/meta/members is no longer read, consistent with the auth to members rename precedent.

feat: add the Member document and Trust::Keys enum on refs/meta/member/* feat: union every refs/meta/member/* in the push verifier feat: add git_ents::accountAccount on refs/meta/account feat: add git ents account create <username> feat: target per-username refs from git ents members add/remove/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 @@ -1,17 +1,17 @@ //! The `pre-receive` verifier: a git hook that gates pushes on a signature from //! a member. //! -//! When the trust list at `refs/meta/members` is empty the server is still in +//! When no member is listed under `refs/meta/member/*` the server is still in //! 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 keys. +//! whose signature verifies against one of those members' in-window keys. use std::io::Write; use std::path::Path; use std::process::{Command, Stdio}; -use git_ents::signers::{self, Signer}; +use git_ents::signers::{self, Member}; /// Verify the push git is about to apply, returning `Ok(())` to accept it or /// `Err(reason)` to reject it. The push certificate is read from the @@ -19,7 +19,7 @@ pub fn pre_receive() -> Result<(), String> { let repo = std::env::current_dir().map_err(|e| format!("cannot resolve repository: {e}"))?; let authorized = - signers::load(&repo).map_err(|e| format!("could not read authorized signers: {e}"))?; + signers::load_all(&repo).map_err(|e| format!("could not read authorized signers: {e}"))?; if authorized.is_empty() { // No trust list pushed yet: stay open so the first signer can be added. return Ok(()); @@ -41,7 +41,7 @@ /// Split the certificate into its signed payload and SSH signature, then accept /// it only when `ssh-keygen -Y verify` trusts the signature against one of the /// authorized keys. -fn verify_certificate(authorized: &[Signer], certificate: &str) -> Result<(), String> { +fn verify_certificate(authorized: &[Member], certificate: &str) -> Result<(), String> { const MARKER: &str = "-----BEGIN SSH SIGNATURE-----"; let split = certificate .find(MARKER)
crates/git-ents-server/tests/pre_receive.rs @@ -87,10 +87,10 @@ valid_before: Option<&'a str>, } -/// Create a bare server repo wired to the `pre-receive` verifier whose -/// `refs/meta/members` lists `members` in the real on-disk layout — a -/// `members/<key-n>/` subtree per member holding a `key` blob and an -/// `valid_after`/`valid_before` `Option` subtree each. +/// Create a bare server repo wired to the `pre-receive` verifier with one +/// `refs/meta/member/member-<n>` ref per member in the real on-disk layout — a +/// `principal` blob, `valid_after`/`valid_before` `Option` subtrees, and a +/// `trust/Keys/key` blob. fn server_repo_with(base: &Path, members: &[Member]) -> PathBuf { let repo = base.join("srv.git"); ok( @@ -112,34 +112,41 @@ std::fs::set_permissions(&hook, std::fs::Permissions::from_mode(0o755)).unwrap(); } - if !members.is_empty() { - let option_tree = |bound: Option<&str>| match bound { - None => mktree(&repo, ""), - Some(value) => { - let blob = hash_object(&repo, value.as_bytes()); - mktree(&repo, &format!("100644 blob {blob}\tsome\n")) - } - }; - let mut tree_entries = String::new(); - for (index, member) in members.iter().enumerate() { - let key = std::fs::read_to_string(member.pubkey).unwrap(); - let key_blob = hash_object(&repo, key.as_bytes()); - let after_tree = option_tree(member.valid_after); - let before_tree = option_tree(member.valid_before); - let member_tree = mktree( - &repo, - &format!( - "100644 blob {key_blob}\tkey\n\ - 040000 tree {after_tree}\tvalid_after\n\ - 040000 tree {before_tree}\tvalid_before\n" - ), - ); - tree_entries.push_str(&format!("040000 tree {member_tree}\tkey-{index}\n")); + let option_tree = |bound: Option<&str>| match bound { + None => mktree(&repo, ""), + Some(value) => { + let blob = hash_object(&repo, value.as_bytes()); + mktree(&repo, &format!("100644 blob {blob}\tsome\n")) } - let members_tree = mktree(&repo, &tree_entries); - let root_tree = mktree(&repo, &format!("040000 tree {members_tree}\tmembers\n")); - let commit = ok(&repo, "git", &["commit-tree", &root_tree, "-m", "members"]); - ok(&repo, "git", &["update-ref", "refs/meta/members", &commit]); + }; + for (index, member) in members.iter().enumerate() { + let username = format!("member-{index}"); + let principal_blob = hash_object(&repo, username.as_bytes()); + let key = std::fs::read_to_string(member.pubkey).unwrap(); + let key_blob = hash_object(&repo, key.as_bytes()); + let keys_tree = mktree(&repo, &format!("100644 blob {key_blob}\tkey\n")); + let trust_tree = mktree(&repo, &format!("040000 tree {keys_tree}\tKeys\n")); + let after_tree = option_tree(member.valid_after); + let before_tree = option_tree(member.valid_before); + let root_tree = mktree( + &repo, + &format!( + "100644 blob {principal_blob}\tprincipal\n\ + 040000 tree {after_tree}\tvalid_after\n\ + 040000 tree {before_tree}\tvalid_before\n\ + 040000 tree {trust_tree}\ttrust\n" + ), + ); + let commit = ok(&repo, "git", &["commit-tree", &root_tree, "-m", "member"]); + ok( + &repo, + "git", + &[ + "update-ref", + &format!("refs/meta/member/{username}"), + &commit, + ], + ); } repo }
crates/git-ents/src/lib.rs @@ -1,5 +1,6 @@ //! Git Ents — helpful guardians of your git trees. +pub mod account; pub mod checks; pub mod config; pub mod issues;
crates/git-ents/src/main.rs @@ -1,17 +1,21 @@ //! `git ents` — the git-ents command-line porcelain. //! -//! Today it carries a single command, `git ents members`, for managing the -//! repository members recorded at `refs/meta/members` and for configuring this -//! client to produce the signed pushes the server requires. The member commands -//! read and write a remote's set by fetching `refs/meta/members` into the local -//! repository, editing it through [`git_ents::signers`], and pushing it back. +//! It carries `git ents members` for managing the repository members recorded +//! one-ref-per-person at `refs/meta/member/<username>`, `git ents account` for +//! the account identity at `refs/meta/account`, `git ents checks` for the check +//! set, and the client setup that produces the signed pushes the server +//! requires. The member commands read and write a remote's set by fetching the +//! `refs/meta/member/*` refs into the local repository, editing them through +//! [`git_ents::signers`], and pushing them back. +use std::collections::BTreeMap; use std::path::{Path, PathBuf}; use std::process::{Command, ExitCode, Stdio}; use clap::{Parser, Subcommand}; +use git_ents::account::{self, Account}; use git_ents::checks::{self, CHECKS_REF, Check}; -use git_ents::signers::{self, MEMBERS_REF, Signer}; +use git_ents::signers::{self, MEMBER_NS, Member, Trust, member_ref}; #[derive(Parser)] #[command(name = "git-ents", about = "Helpful guardians of your git trees.")] @@ -22,11 +26,16 @@ #[derive(Subcommand)] enum Top { - /// Manage the repository members at `refs/meta/members`. + /// Manage the repository members at `refs/meta/member/<username>`. Members { #[command(subcommand)] action: Action, }, + /// Manage this repository's account identity at `refs/meta/account`. + Account { + #[command(subcommand)] + action: AccountAction, + }, /// Manage the configured checks at `refs/meta/checks`. Checks { #[command(subcommand)] @@ -48,38 +57,41 @@ }, /// List the members on a remote. List { - /// Remote to read `refs/meta/members` from. + /// Remote to read the `refs/meta/member/*` refs from. #[arg(default_value = "origin")] remote: String, }, - /// Add a member to a remote's set and push the update. + /// Authorize a key for a member on a remote and push the update. Add { - /// Remote whose `refs/meta/members` to update. + /// Member (username) to authorize the key under — its + /// `refs/meta/member/<username>` ref. + username: String, + /// Remote whose member refs to update. #[arg(default_value = "origin")] remote: String, /// Key to authorize; defaults to `user.signingkey`. #[arg(long)] key: Option<PathBuf>, - /// Trust the key only at or after this OpenSSH timestamp + /// 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")] valid_after: Option<String>, - /// Stop trusting the key after this OpenSSH timestamp; omit for trust + /// Stop trusting the member after this OpenSSH timestamp; omit for trust /// that never lapses on its own. #[arg(long, value_name = "TIMESTAMP")] valid_before: Option<String>, }, - /// Remove a member from a remote's set and push the update. + /// Revoke a member, deleting its ref on a remote and pushing the update. Remove { - /// Fingerprint (`members/<name>`) to drop. - fingerprint: String, - /// Remote whose `refs/meta/members` to update. + /// Member (username) to revoke — its `refs/meta/member/<username>` ref. + username: String, + /// Remote whose member ref to delete. #[arg(default_value = "origin")] remote: String, }, /// Report whether a key is a member and the client is configured. Check { - /// Remote to read `refs/meta/members` from. + /// Remote to read the `refs/meta/member/*` refs from. #[arg(default_value = "origin")] remote: String, /// Key to look for; defaults to `user.signingkey`. @@ -88,6 +100,25 @@ }, } +#[derive(Subcommand)] +enum AccountAction { + /// Create or update this repository's account identity and push it. The + /// presence of `refs/meta/account` is what marks the repo as an account. + Create { + /// The account username — by convention the `user/<username>` repo name. + username: String, + /// Remote whose `refs/meta/account` to update. + #[arg(default_value = "origin")] + remote: String, + /// Human-facing display name; defaults to the username. + #[arg(long)] + display_name: Option<String>, + /// Short free-text bio. + #[arg(long, default_value = "")] + bio: String, + }, +} + #[derive(Subcommand)] enum ChecksAction { /// List the checks configured on a remote. @@ -120,6 +151,7 @@ let cli = Cli::parse(); let result = match cli.command { Top::Members { action } => run_members(action), + Top::Account { action } => run_account(action), Top::Checks { action } => run_checks(action), }; match result { @@ -134,21 +166,36 @@ fn run_members(action: Action) -> Result<(), String> { match action { Action::Setup { key, local } => setup(key.as_deref(), local), - Action::List { remote } => list::<Signers>(&remote), + Action::List { remote } => members_list(&remote), Action::Add { + username, remote, key, valid_after, valid_before, - } => add(&remote, key.as_deref(), valid_after, valid_before), - Action::Remove { - fingerprint, - remote, - } => remove::<Signers>(&fingerprint, &remote), + } => members_add( + &username, + &remote, + key.as_deref(), + valid_after, + valid_before, + ), + Action::Remove { username, remote } => members_remove(&username, &remote), Action::Check { remote, key } => check(&remote, key.as_deref()), } } +fn run_account(action: AccountAction) -> Result<(), String> { + match action { + AccountAction::Create { + username, + remote, + display_name, + bio, + } => account_create(&username, &remote, display_name, bio), + } +} + fn run_checks(action: ChecksAction) -> Result<(), String> { match action { ChecksAction::List { remote } => list::<Checks>(&remote), @@ -163,10 +210,10 @@ /// A `refs/meta/*` set the porcelain manages uniformly: a named ref synced from /// and pushed to a remote, holding entries the CLI lists and removes from. The -/// two sets — authorized signers and configured checks — share that flow and -/// differ only in their item type, what the messages call an entry, and how a -/// row presents; the thin [`load`](Set::load)/[`store`](Set::store) keep each on -/// its own typed module. +/// check set runs through this; the member set is decomposed across +/// `refs/meta/member/*` and handled on its own. A set differs only in its item +/// type, what the messages call an entry, and how a row presents; the thin +/// [`load`](Set::load)/[`store`](Set::store) keep each on its own typed module. trait Set { /// The set's item type. type Item; @@ -187,35 +234,6 @@ fn value(item: &Self::Item) -> String; } -/// The repository member set at `refs/meta/members`. -struct Signers; - -impl Set for Signers { - type Item = Signer; - const REF: &'static str = MEMBERS_REF; - const NOUN: &'static str = "member"; - - fn load(repo: &Path) -> Result<Vec<Signer>, String> { - signers::load(repo).map_err(|error| error.to_string()) - } - - fn store(repo: &Path, items: &[Signer]) -> Result<(), String> { - signers::store(repo, items).map_err(|error| error.to_string()) - } - - fn empty_listing(remote: &str) -> String { - format!("no members on {remote} (open bootstrap window)") - } - - fn key(item: &Signer) -> String { - item.fingerprint.clone() - } - - fn value(item: &Signer) -> String { - signer_label(item) - } -} - /// The configured check set at `refs/meta/checks`. struct Checks; @@ -245,25 +263,22 @@ } } -/// The list column for a member: its key comment and validity window, so an -/// expiry that has been set is visible at a glance rather than hidden in the -/// stored `allowed_signers` options. -fn signer_label(signer: &Signer) -> String { - let mut label = key_comment(&signer.key); +/// The trailing ` (after …, before …)` annotation for a member's validity +/// window, or `""` when unbounded — so an expiry that has been set is visible at +/// a glance rather than hidden in the stored `allowed_signers` options. +fn window_suffix(member: &Member) -> String { let mut window = Vec::new(); - if let Some(after) = &signer.valid_after { + if let Some(after) = &member.valid_after { window.push(format!("after {after}")); } - if let Some(before) = &signer.valid_before { + if let Some(before) = &member.valid_before { window.push(format!("before {before}")); } - if !window.is_empty() { - if !label.is_empty() { - label.push(' '); - } - label.push_str(&format!("({})", window.join(", "))); + if window.is_empty() { + String::new() + } else { + format!(" ({})", window.join(", ")) } - label } /// Print each entry of the set `S` on `remote` as `<key> <value>`. @@ -463,9 +478,33 @@ Ok(Path::new(&home).join(".ssh").join("id_ed25519")) } -/// Authorize `key` on `remote`, trusting it within the given validity window, -/// and push the updated set. -fn add( +/// List every member on `remote` — one line per authorized key — as +/// `<username>/<fingerprint> <key label><window>`. +fn members_list(remote: &str) -> Result<(), String> { + let repo = repo()?; + sync_namespace(remote, MEMBER_NS)?; + 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(()); + } + for member in members { + let suffix = window_suffix(&member); + 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. +fn members_add( + username: &str, remote: &str, key: Option<&Path>, valid_after: Option<String>, @@ -480,27 +519,74 @@ let repo = repo()?; let public_key = public_key(key)?; let fingerprint = fingerprint(&public_key)?; - let expected = sync(remote, MEMBERS_REF)?; - let mut signers = signers::load(&repo).map_err(|error| error.to_string())?; - if signers - .iter() - .any(|signer| same_key(&signer.key, &public_key)) + let refname = member_ref(username); + let expected = sync(remote, &refname)?; + let mut member = signers::load(&repo, username) + .map_err(|error| error.to_string())? + .unwrap_or_else(|| Member::with_keys(username.to_owned(), BTreeMap::new())); + if valid_after.is_some() { + member.valid_after = valid_after; + } + if valid_before.is_some() { + member.valid_before = valid_before; + } + let Trust::Keys(keys) = &mut member.trust; + if keys + .values() + .any(|existing| same_key(existing, &public_key)) { - println!("{fingerprint} is already a member"); + println!("{fingerprint} is already authorized for {username}"); return Ok(()); } - signers.push(Signer { - fingerprint: fingerprint.clone(), - key: public_key, - valid_after, - valid_before, - }); - signers::store(&repo, &signers).map_err(|error| error.to_string())?; - push_signed(remote, MEMBERS_REF, expected.as_deref())?; - println!("authorized {fingerprint}"); + keys.insert(fingerprint.clone(), public_key); + signers::store(&repo, &member).map_err(|error| error.to_string())?; + push_signed(remote, &refname, expected.as_deref())?; + println!("authorized {fingerprint} for {username}"); Ok(()) } +/// Revoke the member `username` on `remote`, deleting its ref and pushing the +/// deletion. Removal here is a plain signed delete; quorum-gated removal is a +/// later server-side policy. +fn members_remove(username: &str, remote: &str) -> Result<(), String> { + let refname = member_ref(username); + let expected = + sync(remote, &refname)?.ok_or_else(|| format!("no member named {username} on {remote}"))?; + push_delete(remote, &refname, &expected)?; + println!("revoked {username}"); + Ok(()) +} + +/// Create or update this repository's account identity on `remote` and push it. +fn account_create( + username: &str, + remote: &str, + display_name: Option<String>, + bio: String, +) -> Result<(), String> { + let repo = repo()?; + let expected = sync(remote, account::ACCOUNT_REF)?; + let existing = account::load(&repo).map_err(|error| error.to_string())?; + let account = Account { + username: username.to_owned(), + display_name: display_name.unwrap_or_else(|| username.to_owned()), + bio, + // Preserve the original creation time when updating an existing account. + created_at: existing.map_or_else(now_seconds, |account| account.created_at), + }; + account::store(&repo, &account).map_err(|error| error.to_string())?; + push_signed(remote, account::ACCOUNT_REF, expected.as_deref())?; + println!("created account {username}"); + Ok(()) +} + +/// The current time as seconds since the Unix epoch. +fn now_seconds() -> u64 { + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map_or(0, |elapsed| elapsed.as_secs()) +} + /// Check that `value` is an OpenSSH `allowed_signers` timestamp: `YYYYMMDD`, /// `YYYYMMDDHHMM`, or `YYYYMMDDHHMMSS`, each optionally suffixed `Z` for UTC. /// Without `Z` the verifying server reads it in its own local time zone. @@ -517,20 +603,23 @@ } } -/// Report whether `key` is in `remote`'s set and how this client is configured. +/// Report whether `key` is a member on `remote` and how this client is +/// configured. fn check(remote: &str, key: Option<&Path>) -> Result<(), String> { let repo = repo()?; let public_key = public_key(key)?; let fingerprint = fingerprint(&public_key)?; - sync(remote, MEMBERS_REF)?; - let signers = signers::load(&repo).map_err(|error| error.to_string())?; - if signers.is_empty() { + sync_namespace(remote, MEMBER_NS)?; + let members = signers::load_all(&repo).map_err(|error| error.to_string())?; + if members.is_empty() { println!("{remote}: open bootstrap window (no members yet)"); - } else if signers - .iter() - .any(|signer| same_key(&signer.key, &public_key)) - { - println!("{remote}: {fingerprint} is a member"); + } else if let Some(member) = members.iter().find(|member| { + member + .keys() + .iter() + .any(|(_fp, k)| same_key(k, &public_key)) + }) { + println!("{remote}: {fingerprint} is a member ({})", member.principal); } else { println!("{remote}: {fingerprint} is NOT a member"); } @@ -566,6 +655,14 @@ Ok(oid) } +/// Mirror every ref under `remote`'s `namespace` (e.g. `refs/meta/member`) into +/// the local repository, pruning local refs the remote no longer has, so the +/// glob helpers see the remote's current set. +fn sync_namespace(remote: &str, namespace: &str) -> Result<(), String> { + let refspec = format!("+{namespace}/*:{namespace}/*"); + git_run(&["fetch", "--quiet", "--prune", remote, &refspec]) +} + /// Push the local `refname` to `remote`, signed per the client's config. /// /// `expected` is the remote tip observed at sync time (`None` when the ref did @@ -580,6 +677,15 @@ git_run(&["push", "--force-if-includes", &lease, remote, refname]) } +/// Delete `refname` on `remote`, signed per the client's config and pinned with +/// `--force-with-lease` to the `expected` tip so a member changed since the +/// fetch is not clobbered. +fn push_delete(remote: &str, refname: &str, expected: &str) -> Result<(), String> { + let lease = format!("--force-with-lease={refname}:{expected}"); + let refspec = format!(":{refname}"); + git_run(&["push", "--force-if-includes", &lease, remote, &refspec]) +} + /// Resolve the OpenSSH public key to operate on, defaulting to the key behind /// `user.signingkey`. fn public_key(key: Option<&Path>) -> Result<String, String> {
crates/git-ents/src/signers.rs @@ -1,18 +1,19 @@ -//! The repository's members, sourced from the `refs/meta/members` ref. +//! The repository's members, sourced from the `refs/meta/member/*` refs. //! -//! Push authentication trusts exactly one place: the `refs/meta/members` ref. -//! Its tree is a [`Members`] document mapping each fingerprint to the OpenSSH -//! public key held there and the validity window it is trusted within. A member -//! *is* one or more keys whose signed pushes are accepted while in window. The -//! document is read and written through [`git_store`], so the trust list is a -//! typed value that lives in git — versioned, auditable, and itself pushable. +//! Push authentication trusts exactly one place: the `refs/meta/member/<username>` +//! refs. Each is a [`Member`] document — one person, named by the ref's last +//! segment — recording the keys whose signed pushes are accepted and the window +//! that trust holds within. The set is decomposed, one ref per person, rather +//! than a single aggregated blob, so a member can be added, refreshed, or revoked +//! as an independent, separately-history'd ref. The verifier unions every +//! `refs/meta/member/*` into the trust list. //! //! # Expiry //! -//! Each key carries an optional `valid-after`/`valid-before` window rendered -//! into the `allowed_signers` file git verifies pushes against. The window is -//! the security primitive: an un-refreshed key stops authorizing *new* pushes -//! once it lapses, so stale trust fails closed. A previously-valid push stays +//! Each member carries an optional `valid-after`/`valid-before` window rendered +//! into the `allowed_signers` file git verifies pushes against. The window is the +//! security primitive: an un-refreshed member stops authorizing *new* pushes once +//! it lapses, so stale trust fails closed. A previously-valid push stays //! verifiable forever, since `ssh-keygen -Y verify -Overify-time` can pin the //! check to the time the push was made. @@ -21,118 +22,135 @@ use facet::Facet; -/// The ref whose tree holds the member set — the push trust root. -pub const MEMBERS_REF: &str = "refs/meta/members"; +/// The namespace whose refs hold the member set — the push trust root. One +/// `refs/meta/member/<username>` ref per person. +pub const MEMBER_NS: &str = "refs/meta/member"; -/// The membership document stored at [`MEMBERS_REF`]: its `members/` subtree -/// maps each fingerprint to the [`Authorization`] held under it. -#[derive(Debug, Clone, PartialEq, Eq, Facet)] -struct Members { - members: BTreeMap<String, Authorization>, +/// The ref holding the member named `username`. +#[must_use] +pub fn member_ref(username: &str) -> String { + format!("{MEMBER_NS}/{username}") } -/// One fingerprint's stored authorization: the OpenSSH public key it names and -/// the window that key is trusted within. The fingerprint is the map key, so it -/// is not repeated here. +/// One member: a person named by their `refs/meta/member/<principal>` ref, the +/// window their trust holds within, and the keys (or, later, CA) it rests on. #[derive(Debug, Clone, PartialEq, Eq, Facet)] -struct Authorization { - /// The OpenSSH public key the member signs with (`<type> <base64> [comment]`). - key: String, - /// The key is trusted at or after this OpenSSH timestamp; `None` is no lower - /// bound. - valid_after: Option<String>, - /// The key is trusted at or before this OpenSSH timestamp; `None` is no upper - /// bound — trust that never lapses on its own. - valid_before: Option<String>, -} - -/// One member's authorized signing key recorded in [`MEMBERS_REF`], with the -/// validity window it is trusted within. -#[derive(Debug, Clone, PartialEq, Eq, Facet)] -pub struct Signer { - /// The key it is stored under — its fingerprint. - pub fingerprint: String, - /// The OpenSSH public key the blob holds (`<type> <base64> [comment]`). - pub key: String, +pub struct Member { + /// The member's username — the ref's last segment, and the `allowed_signers` + /// principal once identities are bound to signing keys. + pub principal: String, /// The OpenSSH timestamp (`YYYYMMDD[Z]` or `YYYYMMDDHHMM[SS][Z]`) at or after - /// which the key is trusted, or `None` for no lower bound. + /// which the member is trusted, or `None` for no lower bound. pub valid_after: Option<String>, - /// The OpenSSH timestamp at or before which the key is trusted, or `None` for - /// trust that never lapses on its own. + /// The OpenSSH timestamp at or before which the member is trusted, or `None` + /// for trust that never lapses on its own. pub valid_before: Option<String>, + /// What the member's trust rests on. + pub trust: Trust, } -/// Load the members recorded at [`MEMBERS_REF`] in `repo`. +/// 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. +#[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. + Keys(BTreeMap<String, String>), +} + +impl Member { + /// A member trusting `keys` with no validity window. + #[must_use] + pub fn with_keys(principal: String, keys: BTreeMap<String, String>) -> Self { + Self { + principal, + valid_after: None, + valid_before: None, + trust: Trust::Keys(keys), + } + } + + /// 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. + #[must_use] + pub fn keys(&self) -> Vec<(&String, &String)> { + match &self.trust { + Trust::Keys(keys) => keys.iter().collect(), + } + } +} + +/// Load the member named `username` in `repo`, or `None` when the ref is absent. +pub fn load(repo: &Path, username: &str) -> Result<Option<Member>, git_store::Error> { + git_store::Store::open(repo)?.load::<Member>(&member_ref(username)) +} + +/// Load every member recorded under [`MEMBER_NS`] in `repo`, newest ref first. /// -/// An absent ref yields an empty set, as on a fresh server whose trust list has -/// not been pushed yet. A present but unreadable ref is an error so callers can -/// fail closed rather than mistake corruption for "no members". -pub fn load(repo: &Path) -> Result<Vec<Signer>, git_store::Error> { - let Some(doc) = git_store::Store::open(repo)?.load::<Members>(MEMBERS_REF)? else { - return Ok(Vec::new()); - }; - Ok(doc - .members - .into_iter() - .map(|(fingerprint, held)| Signer { - fingerprint, - key: held.key, - valid_after: held.valid_after, - valid_before: held.valid_before, - }) - .collect()) +/// An empty result is a fresh server whose trust list has not been pushed yet. A +/// present but unreadable member ref is an error so callers can fail closed +/// rather than mistake corruption for "no members". +pub fn load_all(repo: &Path) -> Result<Vec<Member>, git_store::Error> { + let store = git_store::Store::open(repo)?; + let mut members = Vec::new(); + for refname in store.list(&format!("{MEMBER_NS}/"))? { + if let Some(member) = store.load::<Member>(&refname)? { + members.push(member); + } + } + Ok(members) } -/// Write `signers` to [`MEMBERS_REF`], replacing any existing set, as a new -/// commit. -pub fn store(repo: &Path, signers: &[Signer]) -> Result<(), git_store::Error> { - let members = Members { - members: signers - .iter() - .map(|signer| { - ( - signer.fingerprint.clone(), - Authorization { - key: signer.key.clone(), - valid_after: signer.valid_after.clone(), - valid_before: signer.valid_before.clone(), - }, - ) - }) - .collect(), - }; - git_store::Store::open(repo)?.store(MEMBERS_REF, &members, "Update members")?; +/// Write `member` to its `refs/meta/member/<principal>` ref, replacing any prior +/// value, as a new commit. +pub fn store(repo: &Path, member: &Member) -> Result<(), git_store::Error> { + git_store::Store::open(repo)?.store(&member_ref(&member.principal), member, "Update member")?; Ok(()) } -/// Render `signers` as an OpenSSH `allowed_signers` file that authorizes any +/// Render `members` as an OpenSSH `allowed_signers` file that authorizes any /// pusher identity (`*`) signing in git's namespace. /// /// The principal is a wildcard because authentication here is membership of the /// key set, not a binding between a key and a particular identity: `ssh-keygen /// -Y verify` accepts the push certificate as long as the signing key is one of -/// these, whatever name the pusher signed under. +/// these, whatever name the pusher signed under. The member's username lives in +/// the ref and the `principal` field; binding it to the signing principal is a +/// later identity concern. /// -/// Each key's `valid-after`/`valid-before` window is rendered as `allowed_signers` -/// options so git enforces expiry: out-of-window keys are not accepted. Options -/// are comma-joined, the syntax OpenSSH requires for more than one. +/// Each member's `valid-after`/`valid-before` window is rendered as +/// `allowed_signers` options so git enforces expiry: out-of-window keys are not +/// accepted. Options are comma-joined, the syntax OpenSSH requires for more than +/// one. #[must_use] -pub fn allowed_signers(signers: &[Signer]) -> String { - signers.iter().map(allowed_signers_line).collect() +pub fn allowed_signers(members: &[Member]) -> String { + members.iter().flat_map(member_lines).collect::<String>() } -/// One `allowed_signers` line for `signer`: the wildcard principal, its validity +/// The `allowed_signers` lines for one member: one per leaf key, each carrying +/// the member's validity window. +fn member_lines(member: &Member) -> Vec<String> { + member + .keys() + .into_iter() + .map(|(_fingerprint, key)| allowed_signers_line(member, key)) + .collect() +} + +/// One `allowed_signers` line: the wildcard principal, the member's validity /// window, the git namespace, and the key. -fn allowed_signers_line(signer: &Signer) -> String { +fn allowed_signers_line(member: &Member, key: &str) -> String { let mut options = Vec::new(); - if let Some(after) = &signer.valid_after { + if let Some(after) = &member.valid_after { options.push(format!("valid-after=\"{after}\"")); } - if let Some(before) = &signer.valid_before { + if let Some(before) = &member.valid_before { options.push(format!("valid-before=\"{before}\"")); } options.push("namespaces=\"git\"".to_owned()); - format!("* {} {}\n", options.join(","), signer.key) + format!("* {} {}\n", options.join(","), key) } #[cfg(test)] @@ -144,7 +162,7 @@ )] use super::*; - use crate::testutil::{unique_repo as new_repo, write_members_doc}; + use crate::testutil::{unique_repo as new_repo, write_member_doc}; const KEY_A: &str = "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaA alice"; @@ -155,83 +173,100 @@ new_repo("signers") } - fn signer(fingerprint: &str, key: &str) -> Signer { - Signer { - fingerprint: fingerprint.to_owned(), - key: key.to_owned(), - valid_after: None, - valid_before: None, - } + fn keys(pairs: &[(&str, &str)]) -> BTreeMap<String, String> { + pairs + .iter() + .map(|(fp, key)| ((*fp).to_owned(), (*key).to_owned())) + .collect() } #[test] - fn store_then_load_round_trips_the_signer_set() { + fn store_then_load_round_trips_a_member() { let repo = unique_repo(); - let mut bounded = signer("SHA256-bbb", KEY_B); - bounded.valid_after = Some("20260101".to_owned()); - bounded.valid_before = Some("20270101".to_owned()); - let written = vec![signer("SHA256-aaa", KEY_A), bounded]; - 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 store_replaces_the_previous_set() { - let repo = unique_repo(); - store(&repo, &[signer("SHA256-aaa", KEY_A)]).unwrap(); - store(&repo, &[signer("SHA256-bbb", KEY_B)]).unwrap(); - assert_eq!(load(&repo).unwrap(), vec![signer("SHA256-bbb", KEY_B)]); - let _ = std::fs::remove_dir_all(&repo); - } - - #[test] - fn empty_when_the_members_ref_is_absent() { - let repo = unique_repo(); - assert!(load(&repo).unwrap().is_empty()); - let _ = std::fs::remove_dir_all(&repo); - } - - #[test] - fn loads_the_on_disk_members_format() { - // A fixture written as the real `members/<fingerprint>/{key,valid_after, - // valid_before}` layout must keep loading; this fails if the Members - // document's shape changes incompatibly with data already on a ref. - let repo = unique_repo(); - write_members_doc( - &repo, - MEMBERS_REF, - &[ - ("aa:bb:cc", KEY_A, None, None), - ("dd:ee:ff", KEY_B, None, Some("20270101")), - ], + let mut member = Member::with_keys( + "alice".to_owned(), + keys(&[("aa:bb", KEY_A), ("cc:dd", KEY_B)]), + ); + member.valid_after = Some("20260101".to_owned()); + member.valid_before = Some("20270101".to_owned()); + store(&repo, &member).unwrap(); + + assert_eq!(load(&repo, "alice").unwrap(), Some(member)); + let _ = std::fs::remove_dir_all(&repo); + } + + #[test] + fn load_all_unions_every_member_ref() { + let repo = unique_repo(); + store( + &repo, + &Member::with_keys("alice".to_owned(), keys(&[("aa:bb", KEY_A)])), + ) + .unwrap(); + store( + &repo, + &Member::with_keys("bob".to_owned(), keys(&[("cc:dd", KEY_B)])), + ) + .unwrap(); + + let mut principals: Vec<String> = load_all(&repo) + .unwrap() + .into_iter() + .map(|member| member.principal) + .collect(); + principals.sort(); + assert_eq!(principals, vec!["alice".to_owned(), "bob".to_owned()]); + let _ = std::fs::remove_dir_all(&repo); + } + + #[test] + fn empty_when_no_member_refs_exist() { + let repo = unique_repo(); + assert!(load_all(&repo).unwrap().is_empty()); + assert_eq!(load(&repo, "nobody").unwrap(), None); + let _ = std::fs::remove_dir_all(&repo); + } + + #[test] + fn loads_the_on_disk_member_format() { + // A fixture written as the real `member/<username>` layout — a `principal` + // blob, `valid_after`/`valid_before` Option subtrees, and a + // `trust/Keys/<fingerprint>` subtree — must keep loading; this fails if + // the Member document's shape changes incompatibly with data on a ref. + let repo = unique_repo(); + write_member_doc( + &repo, + "alice", + None, + Some("20270101"), + &[("aa:bb:cc", KEY_A)], + ); + let member = load(&repo, "alice").unwrap().unwrap(); + assert_eq!(member.principal, "alice"); + assert_eq!(member.valid_before, Some("20270101".to_owned())); + assert_eq!( + member.keys(), + vec![(&"aa:bb:cc".to_owned(), &KEY_A.to_owned())] ); - let mut loaded = load(&repo).unwrap(); - loaded.sort_by(|a, b| a.fingerprint.cmp(&b.fingerprint)); - let mut expected_b = signer("dd:ee:ff", KEY_B); - expected_b.valid_before = Some("20270101".to_owned()); - assert_eq!(loaded, vec![signer("aa:bb:cc", KEY_A), expected_b]); let _ = std::fs::remove_dir_all(&repo); } #[test] fn renders_a_wildcard_allowed_signers_file() { + let member = Member::with_keys("alice".to_owned(), keys(&[("aa:bb", KEY_A)])); assert_eq!( - allowed_signers(&[signer("SHA256-aaa", KEY_A)]), + allowed_signers(&[member]), format!("* namespaces=\"git\" {KEY_A}\n") ); } #[test] fn renders_the_validity_window_as_comma_joined_options() { - let mut bounded = signer("SHA256-aaa", KEY_A); - bounded.valid_after = Some("20260101".to_owned()); - bounded.valid_before = Some("20270101".to_owned()); + let mut member = Member::with_keys("alice".to_owned(), keys(&[("aa:bb", KEY_A)])); + member.valid_after = Some("20260101".to_owned()); + member.valid_before = Some("20270101".to_owned()); assert_eq!( - allowed_signers(&[bounded]), + allowed_signers(&[member]), format!( "* valid-after=\"20260101\",valid-before=\"20270101\",namespaces=\"git\" {KEY_A}\n" )
crates/git-ents/src/testutil.rs @@ -70,15 +70,18 @@ assert!(status.success()); } -/// Lay a `Members` document out at `refname` as the real on-disk format: a -/// `members/<fingerprint>/` subtree per member holding a `key` blob and an -/// `valid_after`/`valid_before` `Option` subtree each (empty tree for `None`, a -/// single `some` blob for a bound). Asserts the loader still reads the format -/// independent of the writer. -pub(crate) fn write_members_doc( +/// Lay a `Member` document out at `refs/meta/member/<username>` as the real +/// on-disk format: a `principal` blob, `valid_after`/`valid_before` `Option` +/// subtrees (empty tree for `None`, a single `some` blob for a bound), and a +/// `trust/Keys/<fingerprint>` blob per key (the `Trust::Keys` newtype enum +/// variant resolving directly to its map). Asserts the loader still reads the +/// format independent of the writer. +pub(crate) fn write_member_doc( repo: &Path, - refname: &str, - members: &[(&str, &str, Option<&str>, Option<&str>)], + username: &str, + valid_after: Option<&str>, + valid_before: Option<&str>, + keys: &[(&str, &str)], ) { let option_tree = |bound: Option<&str>| match bound { None => git_with_stdin(repo, &["mktree"], ""), @@ -87,33 +90,72 @@ git_with_stdin(repo, &["mktree"], &format!("100644 blob {blob}\tsome\n")) } }; - let mut member_entries = String::new(); - for (fingerprint, key, valid_after, valid_before) in members { + let principal_blob = git_with_stdin(repo, &["hash-object", "-w", "--stdin"], username); + let after_tree = option_tree(valid_after); + let before_tree = option_tree(valid_before); + let mut key_entries = String::new(); + for (fingerprint, key) in keys { let key_blob = git_with_stdin(repo, &["hash-object", "-w", "--stdin"], key); - let after_tree = option_tree(*valid_after); - let before_tree = option_tree(*valid_before); - let member_tree = git_with_stdin( - repo, - &["mktree"], - &format!( - "100644 blob {key_blob}\tkey\n\ - 040000 tree {after_tree}\tvalid_after\n\ - 040000 tree {before_tree}\tvalid_before\n" - ), - ); - member_entries.push_str(&format!("040000 tree {member_tree}\t{fingerprint}\n")); + key_entries.push_str(&format!("100644 blob {key_blob}\t{fingerprint}\n")); } - let members_tree = git_with_stdin(repo, &["mktree"], &member_entries); + let keys_tree = git_with_stdin(repo, &["mktree"], &key_entries); + let trust_tree = git_with_stdin( + repo, + &["mktree"], + &format!("040000 tree {keys_tree}\tKeys\n"), + ); let root = git_with_stdin( repo, &["mktree"], - &format!("040000 tree {members_tree}\tmembers\n"), + &format!( + "100644 blob {principal_blob}\tprincipal\n\ + 040000 tree {after_tree}\tvalid_after\n\ + 040000 tree {before_tree}\tvalid_before\n\ + 040000 tree {trust_tree}\ttrust\n" + ), + ); + let commit = git_with_stdin(repo, &["commit-tree", &root, "-m", "fixture"], ""); + let refname = format!("refs/meta/member/{username}"); + let status = Command::new("git") + .arg("-C") + .arg(repo) + .args(["update-ref", &refname, &commit]) + .status() + .unwrap(); + assert!(status.success()); +} + +/// Lay an `Account` document out at `refs/meta/account` as the real on-disk +/// format: `username`, `display_name`, `bio`, and `created_at` blobs (the +/// integer in its decimal `Display` form). Asserts the loader still reads the +/// format independent of the writer. +pub(crate) fn write_account_doc( + repo: &Path, + username: &str, + display_name: &str, + bio: &str, + created_at: u64, +) { + let blob = |value: &str| git_with_stdin(repo, &["hash-object", "-w", "--stdin"], value); + let username_blob = blob(username); + let display_blob = blob(display_name); + let bio_blob = blob(bio); + let created_blob = blob(&created_at.to_string()); + let root = git_with_stdin( + repo, + &["mktree"], + &format!( + "100644 blob {created_blob}\tcreated_at\n\ + 100644 blob {display_blob}\tdisplay_name\n\ + 100644 blob {bio_blob}\tbio\n\ + 100644 blob {username_blob}\tusername\n" + ), ); let commit = git_with_stdin(repo, &["commit-tree", &root, "-m", "fixture"], ""); let status = Command::new("git") .arg("-C") .arg(repo) - .args(["update-ref", refname, &commit]) + .args(["update-ref", "refs/meta/account", &commit]) .status() .unwrap(); assert!(status.success());
crates/git-ents-server/src/web/pages.rs @@ -866,7 +866,7 @@ @if let Ok(signers) = &signers { span.count { (signers.len()) } } } p.shell-note { - "Keys on " code { "refs/meta/members" } " whose signed pushes are accepted " + "People on " code { "refs/meta/member/*" } " whose signed pushes are accepted " "(" code { "git ents members list" } ")." } @match &signers { @@ -877,8 +877,8 @@ } } Ok(signers) => { - @for signer in signers { - (signer.render()) + @for member in signers { + (member.render()) } } } @@ -910,11 +910,11 @@ ) } -/// Load the authorized signer set off the async runtime, since `signers::load` -/// shells out to git and reads the object database synchronously. -async fn load_signers(repo: &Path) -> Result<Vec<git_ents::signers::Signer>, String> { +/// Load the member set off the async runtime, since `signers::load_all` shells +/// out to git and reads the object database synchronously. +async fn load_signers(repo: &Path) -> Result<Vec<git_ents::signers::Member>, String> { let repo = repo.to_owned(); - tokio::task::spawn_blocking(move || git_ents::signers::load(&repo)) + tokio::task::spawn_blocking(move || git_ents::signers::load_all(&repo)) .await .map_err(|err| err.to_string())? .map_err(|err| err.to_string())
crates/git-ents-server/src/web/render.rs @@ -14,7 +14,7 @@ use git_ents::checks::{Check, Run}; use git_ents::config::Config; use git_ents::issues::Issue; -use git_ents::signers::Signer; +use git_ents::signers::Member; /// HTML rendering for a meta-ref value. The default walks the value's [`Facet`] /// shape structurally; a type overrides [`render`](Render::render) when its @@ -47,11 +47,16 @@ } } -/// A signer's stored key is too long for a row, so show a short label beside the -/// fingerprint instead of the raw key the structural walk would print. -impl Render for Signer { +/// 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. +impl Render for Member { fn render(&self) -> Markup { - row(&self.fingerprint, &signer_label(&self.key)) + html! { + @for (_fingerprint, key) in self.keys() { + (row(&self.principal, &signer_label(key))) + } + } } }
crates/git-ents/src/account.rs @@ -1,0 +1,109 @@ +//! The repository's account identity, sourced from the `refs/meta/account` ref. +//! +//! An *account* is just a repository that carries a `refs/meta/account` ref: +//! identity is a repo, not a row in a central table. By convention an account +//! repo is named `user/<username>`, but the trust never rests on that path — +//! move the repo, keep the identity. The presence of the ref is what marks a +//! repository as an account; its [`Account`] document carries the profile. This +//! is the did:web-shaped identity the member refs will eventually `@`-mention. + +use std::path::Path; + +use facet::Facet; + +/// The ref whose tree holds the account profile, and whose mere presence marks a +/// repository as an account repo. +pub const ACCOUNT_REF: &str = "refs/meta/account"; + +/// A repository's account profile, stored at [`ACCOUNT_REF`]. +#[derive(Debug, Clone, Default, PartialEq, Eq, Facet)] +pub struct Account { + /// The account's username — by convention the `user/<username>` repo name, + /// but authoritative here rather than in the path. + pub username: String, + /// The human-facing display name; defaults to the username. + pub display_name: String, + /// A short free-text bio; `""` when unset. + pub bio: String, + /// When the account was created, as seconds since the Unix epoch. + pub created_at: u64, +} + +/// Load the account profile at [`ACCOUNT_REF`] in `repo`, or `None` when the ref +/// is absent — i.e. when `repo` is not an account repo. +pub fn load(repo: &Path) -> Result<Option<Account>, git_store::Error> { + git_store::Store::open(repo)?.load::<Account>(ACCOUNT_REF) +} + +/// Write `account` to [`ACCOUNT_REF`], replacing any existing value, as a new +/// commit. +pub fn store(repo: &Path, account: &Account) -> Result<(), git_store::Error> { + git_store::Store::open(repo)?.store(ACCOUNT_REF, account, "Update account")?; + Ok(()) +} + +/// Whether `repo` is an account repo — whether it carries [`ACCOUNT_REF`]. +pub fn is_account_repo(repo: &Path) -> Result<bool, git_store::Error> { + Ok(load(repo)?.is_some()) +} + +#[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_account_doc}; + + fn unique_repo() -> std::path::PathBuf { + new_repo("account") + } + + fn account() -> Account { + Account { + username: "alice".to_owned(), + display_name: "Alice".to_owned(), + bio: "builder of trees".to_owned(), + created_at: 1_700_000_000, + } + } + + #[test] + fn store_then_load_round_trips_the_account() { + let repo = unique_repo(); + store(&repo, &account()).unwrap(); + assert_eq!(load(&repo).unwrap(), Some(account())); + let _ = std::fs::remove_dir_all(&repo); + } + + #[test] + fn absent_account_ref_is_not_an_account_repo() { + let repo = unique_repo(); + assert_eq!(load(&repo).unwrap(), None); + assert!(!is_account_repo(&repo).unwrap()); + let _ = std::fs::remove_dir_all(&repo); + } + + #[test] + fn the_account_ref_marks_an_account_repo() { + let repo = unique_repo(); + store(&repo, &account()).unwrap(); + assert!(is_account_repo(&repo).unwrap()); + let _ = std::fs::remove_dir_all(&repo); + } + + #[test] + fn loads_the_on_disk_account_format() { + // A fixture written as the real on-disk layout — `username`, + // `display_name`, `bio`, and `created_at` blobs — must keep loading, + // guarding the Account document's shape against an incompatible change to + // data already on a ref. + let repo = unique_repo(); + write_account_doc(&repo, "alice", "Alice", "builder of trees", 1_700_000_000); + assert_eq!(load(&repo).unwrap(), Some(account())); + let _ = std::fs::remove_dir_all(&repo); + } +}