gate: judge signatures against the member in force at acceptance
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
No reviews of this commit yet — record a verdict below.
Start a review
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
///