git-ents.gitmain
⌘K
foforge
commit 4e58f2d
gate: judge signatures against the member in force at acceptance

Design decision: admission consults the member entity at the current tip of the member’s ref, in the same verification snapshot — never the entity at the commit’s claimed committer timestamp. A revoked key’s new pushes are refused regardless of any backdated timestamp (closes the security review’s backdated-forgery finding); refs accepted before a revocation stay valid because acceptance is never re-judged, and the acceptance moment’s durable witness is the deployment op log, explicitly out of scope for every crate here.

Deletes the member-history walk (policy::member_at) in favor of member_current, and the commit committer-timestamp decoding that existed only to feed it. Tests reworked: the before/after-revocation pair becomes the backdated-forgery regression plus a never-rejudged-acceptance test (a second admin continues a ref whose tip was placed by a now-revoked member).

deprecates: nothing external; ents-gate has no dependents yet Assisted-by: Claude:claude-fable-5

Joseph D. Carpinelli · 1 month ago

Reviews

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

Start a review

verdict

crates/ents-gate/src/lib.rs @@ -69,13 +69,17 @@ //! are a later, additive narrowing: they arrive with a Config entity in //! `ents-model`, not a new gate. //! -//! Signature-time semantics: a signature is judged against the member -//! entity in force *at the commit's own timestamp*, recovered by walking -//! the member ref's history (`model.member-revocation`). This keeps -//! verdicts reproducible after the fact in any clone — re-running the -//! gate years later returns the same answer — at the documented cost -//! that commit timestamps are author-controlled; the fast-forward -//! requirement still forces any replay to descend from the live tip. +//! Acceptance-time semantics: a signature is judged against the member +//! entity *currently in force* — the member ref's tip in the same +//! snapshot the gate reads (`model.member-revocation`). No +//! commit-supplied timestamp participates, so a revoked key's new +//! pushes are refused even with a backdated committer date; refs +//! accepted before the revocation stay valid because acceptance is +//! never re-judged. A verdict is therefore a pure function of the +//! proposed update and current repository state: any clone reproduces +//! it against the same snapshot, and reconstructing what a *past* +//! acceptance saw is an audit function over the deployment's op log — +//! explicitly out of scope for this crate and every other crate here. //! //! # Examples //!
crates/ents-gate/src/object.rs @@ -17,9 +17,6 @@ pub tree: ObjectId, /// Parents, in order. pub parents: Vec<ObjectId>, - /// Committer timestamp, seconds since the Unix epoch — the time a - /// signature is judged against (`model.member-revocation`). - pub committer_seconds: i64, /// The full commit message, for trailer parsing. pub message: Vec<u8>, } @@ -37,37 +34,23 @@ return Ok(None); } let raw = data.data.to_vec(); - let (tree, parents, committer_seconds, message) = decode_commit(&raw, oid)?; + let (tree, parents, message) = decode_commit(&raw, oid)?; Ok(Some(CommitData { tree, parents, - committer_seconds, message, raw, })) } -fn decode_commit(raw: &[u8], oid: ObjectId) -> Result<(ObjectId, Vec<ObjectId>, i64, Vec<u8>)> { +fn decode_commit(raw: &[u8], oid: ObjectId) -> Result<(ObjectId, Vec<ObjectId>, Vec<u8>)> { let commit = CommitRef::from_bytes(raw, oid.kind()).map_err(|e| Error::Decode { oid, detail: e.to_string(), })?; - let committer_seconds = commit - .committer() - .map_err(|e| Error::Decode { - oid, - detail: format!("committer: {e}"), - })? - .time() - .map_err(|e| Error::Decode { - oid, - detail: format!("committer time: {e}"), - })? - .seconds; Ok(( commit.tree(), commit.parents().collect(), - committer_seconds, commit.message.to_vec(), )) }
crates/ents-gate/src/policy.rs @@ -1,8 +1,8 @@ //! Policy loading: the member set, read from `refs/meta/member/*` //! through the read half of the ref store (`gate.policy-as-state`). //! -//! The gate consults no state outside `refs/meta/*`: members, their -//! revocation timelines, and the epoch (`crate::config`) are all +//! The gate consults no state outside `refs/meta/*`: the member set, +//! each member's current state, and the epoch (`crate::config`) are all //! repository state, so any frontend with a clone evaluates the actual //! policy offline, staleness bounded only by the age of its last fetch. @@ -42,32 +42,21 @@ Ok(out) } -/// The member entity in force at `at_seconds`, found by walking the -/// member ref's own commit history (first-parent) from `tip` back to the -/// newest mutation at or before that time, and deserializing *that* -/// commit's tree. +/// The member entity currently in force: the typed tree behind `tip`, +/// the member ref's tip as read in the same verification snapshot. /// -/// This is how revocation gets its before/after boundary with no -/// validity-window field on the entity (`model.member-revocation`): the -/// ref's commit chain is the audit trail (`meta-ref.namespace`), so the -/// state, provenance, and key that judge a signature are the ones the -/// chain records for the signature's own timestamp. `Ok(None)` means the -/// member had not been enrolled yet at `at_seconds`. +/// Admission consults only this current entity +/// (`model.member-revocation`): a revoked key's new pushes are refused +/// from the moment the revocation lands, regardless of any committer +/// timestamp the pushed commit claims — a backdated commit changes +/// nothing, because no commit-supplied time participates in the +/// judgment. Refs accepted before a revocation stay valid because +/// acceptance is never re-judged; reconstructing what a past acceptance +/// saw is an audit function over the deployment's out-of-scope op log, +/// not a gate path. // @relation(model.member-revocation, gate.policy-as-state, scope=function) -pub(crate) fn member_at( - objects: &dyn Find, - tip: ObjectId, - at_seconds: i64, -) -> Result<Option<Member>> { - let mut cursor = Some(tip); - while let Some(oid) = cursor { - let commit = expect_commit(objects, oid)?; - if commit.committer_seconds <= at_seconds { - let member: Member = facet_git_tree::deserialize(&commit.tree, objects) - .map_err(|source| Error::Entity { oid, source })?; - return Ok(Some(member)); - } - cursor = commit.parents.first().copied(); - } - Ok(None) +pub(crate) fn member_current(objects: &dyn Find, tip: ObjectId) -> Result<Member> { + let commit = expect_commit(objects, tip)?; + facet_git_tree::deserialize(&commit.tree, objects) + .map_err(|source| Error::Entity { oid: tip, source }) }
crates/ents-gate/src/verify.rs @@ -58,9 +58,10 @@ /// in force (`gate.epoch`): /// /// 1. `gate.tip-signed` — the new tip carries a `gpgsig` SSHSIG that -/// verifies against the key of an enrolled member whose entity, *as -/// recorded at the signature's own timestamp* in the member ref's -/// history, is active (`model.member-revocation`) and whose +/// verifies against the key of an enrolled member whose entity, +/// *currently in force* at the member ref's tip in this same +/// snapshot, is active (`model.member-revocation`: acceptance-time +/// semantics — no commit-supplied timestamp participates) and whose /// provenance authorizes this refname (`model.member-provenance`, /// `effect.admin-only`). /// 2. `gate.refname-binding` — the commit's `Ents-Ref:` trailer names @@ -174,16 +175,15 @@ return bootstrap(objects, update, new, &commit, &payload, &sig, old, &cas); } - // Identify the signer: the member whose entity *at the signature's - // own timestamp* carries the verifying key. Walking the member ref's - // history for that entity is what gives revocation its - // before/after boundary (`model.member-revocation`). - let at = commit.committer_seconds; + // Identify the signer: the member whose entity *currently in + // force* — the member ref's tip in this same snapshot — carries the + // verifying key (`model.member-revocation`). No commit-supplied + // timestamp participates: a backdated committer date cannot reach + // back past a revocation. let mut signer: Option<(MemberId, Member)> = None; for enrolled in &members { - if let Some(member) = policy::member_at(objects, enrolled.tip, at)? - && signature::verifies(&member.key, &payload, &sig) - { + let member = policy::member_current(objects, enrolled.tip)?; + if signature::verifies(&member.key, &payload, &sig) { signer = Some((enrolled.id.clone(), member)); break; } @@ -196,22 +196,23 @@ let Some((id, member)) = signer else { return refuse( Requirement::TipSigned, - "the tip's signature does not verify against any member key enrolled and in force \ - at the commit's timestamp" - .into(), + "the tip's signature does not verify against any member key currently enrolled".into(), false, ); }; - // A revoked key is explicitly rejected for signatures made after - // revocation (`model.member-revocation`); because the entity above - // was resolved at the signature's timestamp, a signature made while - // the key was valid stays verifiable even if the member is revoked - // by the time the gate runs. + // A revoked key authorizes no new pushes, full stop + // (`model.member-revocation`): the judgment uses the member entity + // currently in force, so a claimed pre-revocation committer + // timestamp changes nothing. Refs the key placed before the + // revocation landed stay valid — acceptance is never re-judged. if member.state == MemberState::Revoked { return refuse( Requirement::TipSigned, - format!("member {id}'s key was revoked at the signature's timestamp"), + format!( + "member {id}'s key is revoked; new pushes are refused regardless of the \ + commit's claimed timestamp" + ), false, ); }
crates/ents-gate/tests/gate.rs @@ -173,13 +173,19 @@ ); } +/// Revoke `id`'s key in the fixture, as an admin-signed mutation of the +/// member's ref. +fn revoke(f: &Forge, id: &str, key: &Keypair, provenance: Provenance, seconds: i64) { + let mut revoked = Member::new(key.public_openssh(), provenance); + revoked.revoke(); + write_member(&f.refs, &f.objects, id, &revoked, Some(&f.admin), seconds); +} + #[rstest] // @relation(gate.tip-signed, model.member-revocation, scope=function, role=Verifies) -fn signature_made_after_revocation_is_refused() { +fn a_revoked_members_new_push_is_refused() { let f = forge(); - let mut revoked = Member::new(f.admin.public_openssh(), Provenance::AdminRegistered); - revoked.revoke(); - write_member(&f.refs, &f.objects, "admin", &revoked, Some(&f.admin), 400); + revoke(&f, "admin", &f.admin, Provenance::AdminRegistered, 400); let new = proposal(&f, vec![], Some("refs/meta/issues/1"), Some(&f.admin), 500); let verdict = run(&f, "refs/meta/issues/1", Some(new)); @@ -192,20 +198,62 @@ #[rstest] // @relation(model.member-revocation, gate.tip-signed, scope=function, role=Verifies) -fn signature_made_before_revocation_stays_verifiable() { - // The boundary is found by walking the member ref's history and - // deserializing the tree in force at the signature's timestamp — - // there is no validity-window field, by design. +fn a_backdated_commit_cannot_reach_past_a_revocation() { + // The security-review regression: admission consults the member + // entity currently in force, so a revoked key authoring a NEW + // commit with a committer timestamp claimed from before the + // revocation — descending cleanly from the live tip — is still + // refused. No commit-supplied time participates in the judgment. let f = forge(); - let new = proposal(&f, vec![], Some("refs/meta/issues/1"), Some(&f.admin), 300); + let refname = "refs/meta/issues/1"; + let tip = write_meta_entity( + &f.refs, + &f.objects, + name(refname), + &ents_model::Status::Pass, + Some(&f.admin), + 300, + ); + revoke(&f, "admin", &f.admin, Provenance::AdminRegistered, 400); - let mut revoked = Member::new(f.admin.public_openssh(), Provenance::AdminRegistered); - revoked.revoke(); - write_member(&f.refs, &f.objects, "admin", &revoked, Some(&f.admin), 400); + // Authored "at 300", pushed after the revocation at 400. + let backdated = proposal(&f, vec![tip], Some(refname), Some(&f.admin), 300); + expect_fail(&run(&f, refname, Some(backdated)), Requirement::TipSigned); +} - // The gate runs *after* the revocation, on a commit signed before it. +#[rstest] +// @relation(model.member-revocation, gate.fast-forward, scope=function, role=Verifies) +fn refs_accepted_before_a_revocation_are_never_rejudged() { + // A second admin keeps working on a ref whose current tip was + // placed by a member revoked afterwards: the accepted tip stays + // valid history — the gate judges only the proposed update, and + // fast-forwarding over ancestry signed by a now-revoked key is + // ordinary descent, not a re-judgment of past acceptance. + let f = forge(); + let second = Keypair::from_seed(OUTSIDER_SEED); + enroll_member( + &f.refs, + &f.objects, + "second", + &second, + Provenance::AdminRegistered, + 310, + ); + + let refname = "refs/meta/issues/1"; + let tip = write_meta_entity( + &f.refs, + &f.objects, + name(refname), + &ents_model::Status::Pass, + Some(&f.admin), + 320, + ); + revoke(&f, "admin", &f.admin, Provenance::AdminRegistered, 400); + + let continued = proposal(&f, vec![tip], Some(refname), Some(&second), 500); expect_pass( - &run(&f, "refs/meta/issues/1", Some(new)), + &run(&f, refname, Some(continued)), AdmissionKind::TipInvariant, ); }
crates/ents-testutil/src/commit.rs @@ -8,7 +8,9 @@ /// The inputs for one fixture commit; see [`write_commit`]. /// /// Author and committer are fixed fixture identities — only the timestamp -/// varies, because timestamps are what revocation-boundary logic keys on. +/// varies, which keeps fixture object ids deterministic and lets tests +/// stage commits that *claim* earlier authorship (the gate must ignore +/// such claims: admission is judged at acceptance time). /// /// # Examples ///