git-ents.gitmain
⌘K
foforge
commit 8aad2f0
feat: validate a member's timestamps and window before every write

The CLI checked a --valid-after/--valid-before flag was a well-formed OpenSSH timestamp before syncing, but nothing checked the two bounds were not inverted, and the check lived only in the CLI, so any other future caller of members::store (a web admin action, say) would silently accept an inverted window that could never authorize a push. Member::validate now checks both the timestamp shape and the ordering, called from members::store itself so it holds regardless of the caller; the CLI keeps only its fail-fast format check ahead of the network sync, now delegating to the shared members::valid_timestamp instead of duplicating the rule.

feat: add Member::validate (timestamp shape plus valid-after/valid-before ordering) refactor: call Member::validate from members::store refactor: have the CLI’s validate_timestamp reuse members::valid_timestamp Assisted-by: Claude:claude-sonnet-4-6

Joseph D. Carpinelli · 1 month ago

Reviews

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

Start a review

verdict

crates/git-ents/src/main.rs @@ -726,14 +726,12 @@ .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. +/// Fail-fast check, ahead of any network sync, that `value` is a well-formed +/// OpenSSH `allowed_signers` timestamp — the same rule [`Member::validate`] +/// (via [`members::store`]) checks again before the write actually lands, and +/// which also checks the two bounds are not inverted. fn validate_timestamp(value: &str) -> Result<(), String> { - let digits = value.strip_suffix('Z').unwrap_or(value); - let well_formed = - matches!(digits.len(), 8 | 12 | 14) && digits.bytes().all(|b| b.is_ascii_digit()); - if well_formed { + if members::valid_timestamp(value) { Ok(()) } else { Err(format!(
crates/git-ents/src/members.rs @@ -196,6 +196,56 @@ Trust::Keys(_) | Trust::WebAuthn(_) => None, } } + + /// Check invariants the type system does not enforce: a set validity + /// bound must be a well-formed OpenSSH timestamp, and when both bounds + /// are set, `valid_after` must not be after `valid_before` — an inverted + /// window would authorize nothing, silently locking every one of the + /// member's keys out rather than the admin's intended restriction. + /// [`store`] checks this before every write, so it holds regardless of + /// which caller builds the member (the CLI today, an admin web action + /// later). + pub fn validate(&self) -> Result<(), String> { + for bound in [&self.valid_after, &self.valid_before] { + if let Some(value) = bound + && !valid_timestamp(value) + { + return Err(format!( + "{value:?} is not a valid OpenSSH timestamp \ + (expected YYYYMMDD[Z] or YYYYMMDDHHMM[SS][Z])" + )); + } + } + if let (Some(after), Some(before)) = (&self.valid_after, &self.valid_before) + && timestamp_key(after) > timestamp_key(before) + { + return Err(format!( + "valid-after {after:?} is after valid-before {before:?}: \ + this window would never authorize a push" + )); + } + Ok(()) + } +} + +/// Whether `value` is a well-formed 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. +#[must_use] +pub fn valid_timestamp(value: &str) -> bool { + let digits = value.strip_suffix('Z').unwrap_or(value); + matches!(digits.len(), 8 | 12 | 14) && digits.bytes().all(|b| b.is_ascii_digit()) +} + +/// `value`'s digits, right-padded to 14 (`YYYYMMDDHHMMSS`), so two timestamps +/// of different precision compare correctly as calendar time. Ignores any `Z` +/// suffix — comparing a UTC bound against a local one is inherently +/// approximate; exact time-zone arithmetic is out of scope for this ordering +/// check. +fn timestamp_key(value: &str) -> String { + let digits = value.strip_suffix('Z').unwrap_or(value); + format!("{digits:0<14}") } /// Load the member named `username` from an already-open `store`. @@ -248,8 +298,10 @@ } /// Write `member` to its `refs/meta/member/<principal>` ref in `repo`, -/// replacing any prior value, as a new commit. +/// replacing any prior value, as a new commit. Rejects a member whose +/// validity window is malformed or inverted — see [`Member::validate`]. pub fn store(repo: &Path, member: &Member) -> Result<(), git_store::Error> { + member.validate().map_err(git_store::Error::Invalid)?; git_store::Store::open(repo)?.store_keyed(MEMBER_NS, member, "Update member") } @@ -555,4 +607,39 @@ format!("* cert-authority,valid-before=\"20270101\",namespaces=\"git\" {KEY_A}\n") ); } + + #[test] + fn validate_rejects_a_malformed_timestamp() { + let mut member = Member::with_keys("alice".to_owned(), keys(&[("aa:bb", KEY_A)])); + member.valid_before = Some("not-a-timestamp".to_owned()); + assert!(member.validate().is_err()); + } + + #[test] + fn validate_rejects_an_inverted_window() { + let mut member = Member::with_keys("alice".to_owned(), keys(&[("aa:bb", KEY_A)])); + member.valid_after = Some("20270101".to_owned()); + member.valid_before = Some("20260101".to_owned()); + assert!(member.validate().is_err()); + } + + #[test] + fn validate_accepts_an_ordered_window_of_mixed_precision() { + 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("20270101120000Z".to_owned()); + member.validate().unwrap(); + } + + #[test] + fn store_rejects_a_member_with_an_inverted_window() { + let repo = unique_repo(); + let mut member = Member::with_keys("alice".to_owned(), keys(&[("aa:bb", KEY_A)])); + member.valid_after = Some("20270101".to_owned()); + member.valid_before = Some("20260101".to_owned()); + let result = store(&repo, &member); + assert!(matches!(result, Err(git_store::Error::Invalid(_)))); + assert_eq!(load(&repo, "alice").unwrap(), None); + let _ = std::fs::remove_dir_all(&repo); + } }