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
No reviews of this commit yet — record a verdict below.
Start a review
.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);