git-ents.gitmain
⌘K
foforge
commit 38ce5c9
feat: add Trust::WebAuthn and member provenance

Appending a variant is backward-compatible (variants are name-keyed on disk), so Trust::WebAuthn — a set of passkey credentials in COSE form — slots in alongside Keys/CertAuthority with no migration. It authorizes browser sign-in only: Member::keys and member_lines emit nothing for it, so a WebAuthn member cannot push at all.

provenance records whether a member was admin-registered or self-attested via web onboarding, defaulting to AdminRegistered so a member ref written before this field existed still loads. This default is load-bearing: without #[facet(default)] and Provenance: Default, an old member ref would fail to deserialize, load_all would error, and pre_receive would refuse every push. pre_receive itself is unaffected (purely key-based, and a WebAuthn member has no push key to gate anyway) — the web layer is where a self-attested member’s limited trust gets enforced, once that gate lands.

feat: add Trust::WebAuthn, WebAuthnKey, and Member::with_webauthn feat: add Member::provenance and the Provenance enum, defaulting to AdminRegistered test: add a hand-built WebAuthn fixture and an explicit no-provenance-entry load test docs: extend members.trust and add members.provenance/web.auth.webauthn-onboarding chore: teach the typos hook that COSE is a real WebAuthn term Assisted-by: Claude:claude-sonnet-5

Joseph D. Carpinelli · 1 month ago

Reviews

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

Start a review

verdict

.config/typos.toml @@ -39,3 +39,7 @@ extend-ignore-words-re = [] # Fly.io 6PN (private network) — a real term, not a typo extend-ignore-re = ["6PN"] + +[default.extend-words] +# COSE (CBOR Object Signing and Encryption) — a real WebAuthn term, not a typo +cose = "cose"
docs/specification.adoc @@ -137,7 +137,7 @@ [role="requirement", id="members.trust"] .Member Trust Modes -- -A member's trust MUST rest on exactly one of two mutually exclusive bases: +A member's trust MUST rest on exactly one of three mutually exclusive bases: Keys:: A set of leaf signing keys, mapping each fingerprint to its OpenSSH public @@ -150,6 +150,22 @@ certificate's own validity window, is trusted. The enterprise option: rotation, expiry, and new devices require no edit to the member ref. + +WebAuthn:: + A set of passkey credentials in CASE form, keyed by credential ID, each with + a human-readable label. WebAuthn credentials authorize browser sign-in only + and MUST NOT produce `allowed_signers` lines or authorize git push. +-- + +[role="requirement", id="members.provenance"] +.Member Provenance +-- +Every member MUST carry a `provenance` recording whether they were +admin-registered or self-attested via web onboarding. A member ref written +before this field existed MUST load as admin-registered. A self-attested +member MUST be granted limited trust: the web service MUST refuse their writes +outside an allowed set (such as issues and comments), and they MUST NOT be +trusted for signed git push, until an admin promotes them. -- [role="requirement", id="members.window"] @@ -490,6 +506,16 @@ tab MUST hide the edit controls rather than present a form that cannot succeed. -- +[role="requirement", id="web.auth.webauthn-onboarding"] +.Passkey Onboarding +-- +A new member MAY onboard without a CLI by proving control of a passkey in the +browser. The server MUST verify the attestation server-side and write the new +member ref with `provenance` set to self-attested, recording the attestation +evidence in the member ref's first commit as an audit record. The resulting +member gets limited trust until an admin promotes them. +-- + === Nonfunctional [role="requirement", id="nonfunctional.push-latency"]
crates/git-ents/src/main.rs @@ -656,6 +656,12 @@ revoke and re-add to switch to leaf keys" )); } + Trust::WebAuthn(_keys) => { + return Err(format!( + "{username} is a self-attested WebAuthn member; \ + an admin must promote them before adding leaf keys" + )); + } }; if keys .values()
crates/git-ents/src/members.rs @@ -20,6 +20,18 @@ //! 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. +//! +//! # Provenance and `Trust::WebAuthn` +//! +//! A member is ordinarily added by an admin (a CLI action), but can also +//! onboard through the browser by proving control of a passkey — no push key +//! required. `Trust::WebAuthn` records that credential set, but authorizes web +//! sign-in only: [`member_lines`] and [`Member::keys`] emit nothing for it, so +//! such a member cannot push at all. `provenance` separately records whether +//! the member was admin-registered or self-attested via the web, so the web +//! layer can grant a self-attested member only limited trust until an admin +//! promotes them (`pre_receive` stays purely key-based and is unaffected, +//! since a self-attested member has no push key to gate anyway). use std::collections::{BTreeMap, BTreeSet}; use std::path::Path; @@ -51,14 +63,48 @@ pub valid_before: Option<String>, /// What the member's trust rests on. pub trust: Trust, + /// Whether the member was admin-registered or self-attested via web + /// onboarding. + /// + /// `#[facet(default)]` is required (not just `Option`, which auto-defaults + /// on its own): without it, and without `Provenance: Default`, a member + /// ref written before this field existed would fail to load, `load_all` + /// would error, and `pre_receive` would refuse every push. See + /// `loads_a_member_ref_with_no_provenance_entry`. + #[facet(default)] + pub provenance: Provenance, } -/// What a member's trust rests on. A member is *either* a set of leaf keys *or* -/// 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). +/// Whether a member was admin-registered or self-attested via web onboarding. +/// Defaults to [`Provenance::AdminRegistered`] so a member ref written before +/// this field existed loads unchanged. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Facet)] +#[repr(u8)] +pub enum Provenance { + /// Added by an admin (a CLI action). Fully trusted. + #[default] + AdminRegistered, + /// Onboarded through the browser by proving a passkey, with no admin + /// action. Limited trust until an admin promotes them — enforced by the + /// web layer, not `pre_receive`. + SelfAttestedWeb, +} + +/// A WebAuthn passkey credential, in COSE form, with a human-readable label. +#[derive(Debug, Clone, PartialEq, Eq, Facet)] +pub struct WebAuthnKey { + /// The credential's public key, in COSE form. + pub cose_key: String, + /// A human-readable label (e.g. the authenticator's name). + pub label: String, +} + +/// What a member's trust rests on: a set of leaf keys, a pinned certificate +/// authority, or a set of WebAuthn passkeys — 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 { @@ -69,6 +115,10 @@ /// issues for the member's principal, within the cert's own validity window, /// is trusted. The enterprise / many-repos option. CertAuthority(String), + /// A set of passkey credentials, keyed by credential id. Authorizes browser + /// sign-in only: [`member_lines`] and [`Member::keys`] emit nothing for it, + /// so a `WebAuthn` member cannot push at all. + WebAuthn(BTreeMap<String, WebAuthnKey>), } impl git_store::HasId for Member { @@ -78,7 +128,7 @@ } impl Member { - /// A member trusting `keys` with no validity window. + /// A member trusting `keys` with no validity window, admin-registered. #[must_use] pub fn with_keys(principal: String, keys: BTreeMap<String, String>) -> Self { Self { @@ -86,11 +136,12 @@ valid_after: None, valid_before: None, trust: Trust::Keys(keys), + provenance: Provenance::AdminRegistered, } } /// A member trusting any certificate the CA `ca` issues for them, with no - /// validity window. + /// validity window, admin-registered. #[must_use] pub fn with_ca(principal: String, ca: String) -> Self { Self { @@ -98,26 +149,41 @@ valid_after: None, valid_before: None, trust: Trust::CertAuthority(ca), + provenance: Provenance::AdminRegistered, + } + } + + /// A member trusting `keys` (a WebAuthn credential set) with no validity + /// window, self-attested via web onboarding. + #[must_use] + pub fn with_webauthn(principal: String, keys: BTreeMap<String, WebAuthnKey>) -> Self { + Self { + principal, + valid_after: None, + valid_before: None, + trust: Trust::WebAuthn(keys), + provenance: Provenance::SelfAttestedWeb, } } /// The member's leaf signing keys as `(fingerprint, key)` pairs. A member - /// resting on a CA has no leaf keys and yields none. + /// resting on a CA or WebAuthn credentials has no leaf keys and yields + /// none — a `WebAuthn` member authorizes web sign-in only and cannot push. #[must_use] pub fn keys(&self) -> Vec<(&String, &String)> { match &self.trust { Trust::Keys(keys) => keys.iter().collect(), - Trust::CertAuthority(_ca) => Vec::new(), + Trust::CertAuthority(_) | Trust::WebAuthn(_) => Vec::new(), } } /// The member's pinned certificate authority key, or `None` when the member - /// rests on leaf keys. + /// rests on leaf keys or WebAuthn credentials. #[must_use] pub fn ca(&self) -> Option<&str> { match &self.trust { Trust::CertAuthority(ca) => Some(ca), - Trust::Keys(_keys) => None, + Trust::Keys(_) | Trust::WebAuthn(_) => None, } } } @@ -182,7 +248,7 @@ keys.retain(|fingerprint, _key| !revoked.contains(fingerprint)); if keys.is_empty() { None } else { Some(member) } } - Trust::CertAuthority(_ca) => Some(member), + Trust::CertAuthority(_) | Trust::WebAuthn(_) => Some(member), }) .collect() } @@ -206,11 +272,13 @@ members.iter().flat_map(member_lines).collect::<String>() } -/// 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. +/// The `allowed_signers` lines for one member: one per leaf key, a single +/// `cert-authority` line for a pinned CA, or none at all for `WebAuthn` +/// credentials, which authorize browser sign-in only. Each key/CA line 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> { match &member.trust { Trust::Keys(keys) => keys @@ -218,6 +286,7 @@ .map(|key| allowed_signers_line(member, false, key)) .collect(), Trust::CertAuthority(ca) => vec![allowed_signers_line(member, true, ca)], + Trust::WebAuthn(_) => Vec::new(), } } @@ -248,7 +317,7 @@ )] use super::*; - use crate::testutil::{unique_repo as new_repo, write_member_doc}; + use crate::testutil::{unique_repo as new_repo, write_member_doc, write_webauthn_member_doc}; const KEY_A: &str = "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaA alice"; @@ -314,11 +383,14 @@ } #[test] - fn loads_the_on_disk_member_format() { + fn loads_the_on_disk_member_format_with_no_provenance_entry_as_admin_registered() { // 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. + // `trust/Keys/<fingerprint>` subtree, with no `provenance` entry at all — + // must keep loading, and load as `AdminRegistered`. Without + // `#[facet(default)]` on `provenance` this fixture would fail to + // deserialize, `load_all` would error, and `pre_receive` would refuse + // every push: this is the regression guard for that. let repo = unique_repo(); write_member_doc( &repo, @@ -334,6 +406,60 @@ member.keys(), vec![(&"aa:bb:cc".to_owned(), &KEY_A.to_owned())] ); + assert_eq!(member.provenance, Provenance::AdminRegistered); + let _ = std::fs::remove_dir_all(&repo); + } + + #[test] + fn loads_a_hand_built_webauthn_fixture() { + // A hand-built `trust/WebAuthn/<credential_id>/{cose_key,label}` + // fixture with an explicit `provenance/SelfAttestedWeb` entry must + // round-trip; a `Keys`/`CertAuthority` fixture (the prior two tests) + // must keep loading unchanged alongside the new variant. + let repo = unique_repo(); + write_webauthn_member_doc( + &repo, + "alice", + "SelfAttestedWeb", + &[("cred-1", "cose-bytes", "YubiKey")], + ); + let member = load(&repo, "alice").unwrap().unwrap(); + assert_eq!(member.principal, "alice"); + assert_eq!(member.provenance, Provenance::SelfAttestedWeb); + assert!(member.keys().is_empty()); + assert_eq!(member.ca(), None); + assert_eq!( + member.trust, + Trust::WebAuthn(BTreeMap::from([( + "cred-1".to_owned(), + WebAuthnKey { + cose_key: "cose-bytes".to_owned(), + label: "YubiKey".to_owned(), + } + )])) + ); + let _ = std::fs::remove_dir_all(&repo); + } + + #[test] + fn store_then_load_round_trips_a_webauthn_member() { + let repo = unique_repo(); + let creds = BTreeMap::from([( + "cred-1".to_owned(), + WebAuthnKey { + cose_key: "cose-bytes".to_owned(), + label: "YubiKey".to_owned(), + }, + )]); + let member = Member::with_webauthn("alice".to_owned(), creds); + store(&repo, &member).unwrap(); + let loaded = load(&repo, "alice").unwrap().unwrap(); + assert_eq!(loaded, member); + assert_eq!(loaded.provenance, Provenance::SelfAttestedWeb); + // A WebAuthn member authorizes web sign-in only: no allowed_signers + // line, so it cannot push at all. + assert!(loaded.keys().is_empty()); + assert!(allowed_signers(&[loaded]).is_empty()); let _ = std::fs::remove_dir_all(&repo); }
crates/git-ents/src/testutil.rs @@ -100,6 +100,65 @@ assert!(status.success()); } +/// Lay a `Member` document out at `refs/meta/member/<username>` with +/// `Trust::WebAuthn` credentials and an explicit `provenance`, as the real +/// on-disk format: a `trust/WebAuthn/<credential_id>/{cose_key,label}` +/// subtree per credential (the `Trust::WebAuthn` newtype variant resolving +/// directly to its map, each `WebAuthnKey` a two-field subtree) and a +/// `provenance/<variant>` unit-variant tree. Asserts the loader still reads +/// the format independent of the writer. +pub(crate) fn write_webauthn_member_doc( + repo: &Path, + username: &str, + provenance: &str, + credentials: &[(&str, &str, &str)], +) { + let empty_tree = git_with_stdin(repo, &["mktree"], ""); + let principal_blob = git_with_stdin(repo, &["hash-object", "-w", "--stdin"], username); + let mut cred_entries = String::new(); + for (credential_id, cose_key, label) in credentials { + let case_blob = git_with_stdin(repo, &["hash-object", "-w", "--stdin"], cose_key); + let label_blob = git_with_stdin(repo, &["hash-object", "-w", "--stdin"], label); + let cred_tree = git_with_stdin( + repo, + &["mktree"], + &format!("100644 blob {case_blob}\tcose_key\n100644 blob {label_blob}\tlabel\n"), + ); + cred_entries.push_str(&format!("040000 tree {cred_tree}\t{credential_id}\n")); + } + let creds_tree = git_with_stdin(repo, &["mktree"], &cred_entries); + let trust_tree = git_with_stdin( + repo, + &["mktree"], + &format!("040000 tree {creds_tree}\tWebAuthn\n"), + ); + let provenance_tree = git_with_stdin( + repo, + &["mktree"], + &format!("040000 tree {empty_tree}\t{provenance}\n"), + ); + let root = git_with_stdin( + repo, + &["mktree"], + &format!( + "100644 blob {principal_blob}\tprincipal\n\ + 040000 tree {empty_tree}\tvalid_after\n\ + 040000 tree {empty_tree}\tvalid_before\n\ + 040000 tree {trust_tree}\ttrust\n\ + 040000 tree {provenance_tree}\tprovenance\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
crates/git-ents/tests/cert_authority.rs @@ -17,7 +17,7 @@ use std::process::{Command, Stdio}; use std::sync::atomic::{AtomicUsize, Ordering}; -use git_ents::members::{Member, Trust, allowed_signers}; +use git_ents::members::{Member, allowed_signers}; /// The principal the CA certifies and the verifier checks — the pusher identity. const PRINCIPAL: &str = "tester@example.com"; @@ -67,12 +67,7 @@ .unwrap() .trim() .to_owned(); - let member = Member { - principal: "anyone".to_owned(), - valid_after: None, - valid_before: None, - trust: Trust::CertAuthority(ca_pubkey), - }; + let member = Member::with_ca("anyone".to_owned(), ca_pubkey); // Sanity: a CA member exposes no leaf keys. assert!(member.keys().is_empty()); let path = dir.join(name);