fix(auth): root state validity at active lead

The v3 role-tag work filtered invited and ended listed maintainers, but every announcement author still seeded a repository view. A removed forwarding maintainer or an author with an ended self-role could therefore keep publishing authoritative kind 30618 state through their own coordinate. Parity-only role parsing could also treat malformed histories as active.

Validate numeric role boundaries and the final-end-only defer sentinel, resolve each selected coordinate through one valid active M path to a terminal self-M, and only then seed the reciprocal membership fixpoint. Legacy and explicitly leadless views continue to root at the selected author while that author remains active; incomplete, ambiguous, and cyclic explicit paths fail closed.

This changes current state and maintainer-scoped authorization only. Historical event authorization and membership mutation workflows remain outside the relay-side patch.

Validated with cargo test --lib (902 tests), cargo test --test state_authorization (53 tests), and cargo clippy --tests -- -D warnings.
This commit is contained in:
DanConwayDev
2026-08-29 20:18:22 +00:00
parent 3b8b74c22f
commit 650cfb57ee
6 changed files with 382 additions and 54 deletions
+12
View File
@@ -7,6 +7,18 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
## [Unreleased]
### Fixed
- Correct kind `30618` repository-state authority for a view explicitly
selected through a former maintainer's coordinate. Previously, only that
selected view could continue accepting state signed by the former
maintainer after the lead stopped assigning them; this did not restore the
former maintainer's authority over views selected through the lead or any
other maintainer. Selected views now resolve authority through valid active
`M` records to the terminal maintainer. Ended selected authors, malformed
role histories, missing or ambiguous lead targets, and lead cycles fail
closed.
## [3.0.0] - 2026-08-20
### Summary
+18 -13
View File
@@ -296,16 +296,18 @@ pub struct RepositoryState { ... }
Announcements carry maintainer listings in two formats:
- **Indexed role tags** (`M` lead, `m` co-maintainer). A tag may record
history as alternating start/end timestamps; it is currently active when
it has fewer than four elements or an odd number of elements. Ended
entries are ignored entirely - the history is only used to conclude a
role has *ended*, never to grant authority for a past period. The lead /
co-maintainer distinction carries no meaning for this service. A pubkey
may appear in multiple role tags: one of each letter records a role
transition, and out-of-spec duplicates under the same letter are
tolerated. Histories are consolidated - the pubkey is a maintainer while
any `M`/`m` entry is active. An author who appears in no role tag is
implicitly a maintainer for the repository's entire history.
history as alternating numeric start/end timestamps; `defer` is valid only
as the final end boundary. It is currently active when it has no boundaries
or its final valid boundary is a start. Ended, deferred, and malformed
entries grant no authority - the history is only used to conclude a role
has *ended*, never to grant authority for a past period. Both `M` and `m`
grant maintainer authority, while active `M` records also form the lead
path that roots the selected repository view. A pubkey may appear in
multiple role tags: one of each letter records a role transition, and
out-of-spec duplicates under the same letter are tolerated. Histories are
consolidated - the pubkey is a maintainer while any valid `M`/`m` entry is
active. An author who appears in no role tag is implicitly a maintainer for
the repository's entire history.
- **Deprecated `maintainers` tag** (fallback). Ignored when `M`/`m` tags
are present. Without any listing tags the author is the sole maintainer.
A `u` (subordinate fork) tag has no effect on maintainership.
@@ -331,9 +333,12 @@ Authorization follows a reciprocal membership rule, computed per owner by
author: an ended `M`/`m` self-entry means the pubkey has left, which
takes precedence over assignments in other announcements. An author who
appears in no role tag implicitly asserts maintainership.
- The owner is always a confirmed maintainer of their own repository:
announcing a repository in their namespace is what creates it on this
service.
- A valid active `M` path is followed to a terminal self-`M` before the
reciprocal fixpoint is seeded. Without an active `M`, the selected author
roots a legacy or deliberately leadless view while their own role is active.
Missing, ambiguous, and cyclic explicit paths grant no state authority, and
a former maintainer's forwarding coordinate does not restore that signer to
the confirmed set.
#### [`policy/state.rs`](src/nostr/policy/state.rs) - State Event Authorization
+15 -13
View File
@@ -215,9 +215,11 @@ NIP-34 maintainers model refined in nips commit `781590b`).
until its own announcement for the same identifier lists back an existing
confirmed maintainer.
- Only confirmed maintainers publish authoritative repository state.
- The owner is always a confirmed maintainer of their own repository:
announcing a repository in their namespace is what creates it on this
service.
- Authority is rooted at the terminal lead reached through valid active `M`
records. Without an active `M`, the selected author roots a legacy or
deliberately leadless view only while their own role is active. Once an
explicit path is followed, a missing target, multiple active targets, or a
cycle grants no repository-state authority.
### Implementation choices
@@ -240,15 +242,16 @@ NIP-34 maintainers model refined in nips commit `781590b`).
- `M` (lead) and `m` (co-maintainer) tags are the primary maintainer
listing (per the model clarified in nips commit `986edd1`); when present
the deprecated `maintainers` tag is ignored per NIP-34 (and parsed as
empty). The role distinction carries no meaning for this service: both
collapse into one maintainer set.
- Role tags may record history as alternating start/end timestamps; a tag
is currently active when it has fewer than four elements or an odd number
of elements. Ended entries are ignored entirely: role history is only
used to conclude that a pubkey is *no longer* a maintainer, never to
grant time-scoped retroactive authority over historic events. The
NIP's owner-first precedence for conflicting past-role records is
therefore unused.
empty). Both grant equal maintainer authority, while active `M` records
additionally define the lead path used to root that authority.
- Role tags may record history as alternating numeric start/end timestamps;
`defer` is valid only as the final end boundary. A valid tag is currently
active when it has no boundaries or its final boundary is a start. Ended,
deferred, and malformed entries grant no authority: role history is only
used to conclude that a pubkey is *no longer* a maintainer, never to grant
time-scoped retroactive authority over historic events. The NIP's
owner-first precedence for conflicting past-role records is therefore
unused.
- A pubkey may appear in multiple role tags: one of each letter records a
role transition per the NIP, and out-of-spec duplicates under the same
letter are consolidated rather than rejected - rejecting them would drop
@@ -284,4 +287,3 @@ the `M`/`m` assignments they might carry (the NIP says role combinations
beyond self-plus-lead SHOULD be avoided unless the author is `M`), and
moderator membership gets no reciprocal-confirmation treatment. Both only
matter if status-event authority is ever enforced.
+160 -7
View File
@@ -316,7 +316,8 @@ pub fn pubkey_authorised_for_repo_owners(
#[derive(Debug, Default)]
pub struct RepoMembership {
/// Pubkeys whose repository state events are authoritative for this
/// owner's repository: the owner plus every confirmed maintainer.
/// owner's selected repository view: the resolved lead or selected
/// leadless/legacy root plus every confirmed maintainer.
pub state_maintainers: HashSet<String>,
/// Pubkeys currently listed as maintainers whose own announcement does
/// not yet list back a confirmed maintainer. Their announcements must
@@ -337,9 +338,12 @@ pub struct RepoMembership {
/// assignments in other announcements. An author who appears in no role tag
/// implicitly asserts maintainership for the repository's entire history.
///
/// The owner is always a confirmed maintainer of their own repository:
/// announcing a repository in their namespace is what creates it on this
/// service.
/// An explicit active `M` path is resolved before the fixpoint is seeded. A
/// self-`M` terminates at the lead; an announcement without an active `M`
/// seeds its author only when resolution has not already followed an explicit
/// path. Missing, ambiguous, or cyclic explicit paths grant no authority.
/// This prevents a removed maintainer's forwarding coordinate from restoring
/// that signer to the confirmed set.
pub fn compute_membership(
announcements: &[RepositoryAnnouncement],
owner: &str,
@@ -350,11 +354,35 @@ pub fn compute_membership(
.iter()
.find(|a| a.event.pubkey.to_hex() == pubkey && a.identifier == identifier)
};
if find(owner).is_none() {
return RepoMembership::default();
// Resolve the selected coordinate's explicit lead path before seeding
// reciprocal membership. With no active M on the selected announcement,
// this is a legacy or deliberately leadless view rooted at the selected
// author. Once an explicit path is followed, only an active self-M may
// terminate it; incomplete, ambiguous, and cyclic paths fail closed.
let mut root = owner.to_string();
let mut followed_explicit_lead = false;
let mut visited = HashSet::new();
loop {
if !visited.insert(root.clone()) {
return RepoMembership::default();
}
let Some(announcement) = find(&root) else {
return RepoMembership::default();
};
let active_leads = announcement.active_leads();
match active_leads.as_slice() {
[] if !followed_explicit_lead && announcement.author_role_active() => break,
[] => return RepoMembership::default(),
[lead] if lead == &root && announcement.author_role_active() => break,
[lead] if lead != &root => {
root = lead.clone();
followed_explicit_lead = true;
}
_ => return RepoMembership::default(),
}
}
let mut confirmed: HashSet<String> = HashSet::from([owner.to_string()]);
let mut confirmed: HashSet<String> = HashSet::from([root]);
// Fixpoint: keep confirming listed pubkeys whose own announcement lists
// back an already-confirmed maintainer. The confirmed set only grows, so
// the loop terminates.
@@ -1456,6 +1484,131 @@ mod tests {
let membership = compute_membership(&announcements, &hex(&alice), identifier);
assert!(membership.state_maintainers.contains(&hex(&bob)));
let selected_from_bob = compute_membership(&announcements, &hex(&bob), identifier);
assert_eq!(
selected_from_bob.state_maintainers,
membership.state_maintainers
);
}
#[test]
fn test_ended_selected_author_has_no_state_authority() {
let alice = create_test_keys();
let identifier = "test-repo";
let announcements = vec![parse(create_role_announcement(
&alice,
identifier,
&[("m", vec![hex(&alice), "100".to_string(), "200".to_string()])],
))];
let membership = compute_membership(&announcements, &hex(&alice), identifier);
assert!(membership.state_maintainers.is_empty());
assert!(membership.invited.is_empty());
}
#[test]
fn test_removed_selected_maintainer_resolves_lead_without_regaining_authority() {
let alice = create_test_keys();
let bob = create_test_keys();
let identifier = "test-repo";
// Alice no longer assigns Bob. Bob's still-active acceptance and M
// redirect keep the coordinate attached to Alice, but do not seed Bob
// as a maintainer of a separate same-identifier repository.
let announcements = vec![
parse(create_role_announcement(
&alice,
identifier,
&[("M", vec![hex(&alice)])],
)),
parse(create_role_announcement(
&bob,
identifier,
&[("M", vec![hex(&alice)]), ("m", vec![hex(&bob)])],
)),
];
let membership = compute_membership(&announcements, &hex(&bob), identifier);
assert_eq!(membership.state_maintainers, HashSet::from([hex(&alice)]));
let repo_data = RepositoryData {
announcements,
states: Vec::new(),
};
assert!(pubkey_authorised_for_repo_owners(&bob.public_key(), &repo_data).is_empty());
assert_eq!(
pubkey_authorised_for_repo_owners(&alice.public_key(), &repo_data)
.into_iter()
.collect::<HashSet<_>>(),
HashSet::from([hex(&alice), hex(&bob)])
);
}
#[test]
fn test_incomplete_ambiguous_or_cyclic_lead_path_grants_no_state_authority() {
let alice = create_test_keys();
let bob = create_test_keys();
let identifier = "test-repo";
let bob_announcement = parse(create_role_announcement(
&bob,
identifier,
&[("M", vec![hex(&alice)]), ("m", vec![hex(&bob)])],
));
let missing_lead = compute_membership(
std::slice::from_ref(&bob_announcement),
&hex(&bob),
identifier,
);
assert!(missing_lead.state_maintainers.is_empty());
let ambiguous = parse(create_role_announcement(
&alice,
identifier,
&[("M", vec![hex(&alice)]), ("M", vec![hex(&bob)])],
));
let ambiguous_lead = compute_membership(&[ambiguous], &hex(&alice), identifier);
assert!(ambiguous_lead.state_maintainers.is_empty());
let alice_announcement = parse(create_role_announcement(
&alice,
identifier,
&[("M", vec![hex(&bob)]), ("m", vec![hex(&alice)])],
));
let cycle = compute_membership(
&[alice_announcement, bob_announcement],
&hex(&alice),
identifier,
);
assert!(cycle.state_maintainers.is_empty());
}
#[test]
fn test_explicit_leadless_roles_still_root_selected_authority() {
let alice = create_test_keys();
let bob = create_test_keys();
let identifier = "test-repo";
let announcements = vec![
parse(create_role_announcement(
&alice,
identifier,
&[("m", vec![hex(&alice)]), ("m", vec![hex(&bob)])],
)),
parse(create_role_announcement(
&bob,
identifier,
&[("m", vec![hex(&bob)]), ("m", vec![hex(&alice)])],
)),
];
let membership = compute_membership(&announcements, &hex(&alice), identifier);
assert_eq!(
membership.state_maintainers,
HashSet::from([hex(&alice), hex(&bob)])
);
}
#[test]
+102 -21
View File
@@ -7,7 +7,32 @@
/// - NIP-34: https://nips.nostr.com/34
/// - GRASP-01: https://gitworkshop.dev/danconwaydev.com/grasp/01.md
use anyhow::{anyhow, Result};
use nostr_sdk::prelude::{Event, Kind, ToBech32};
use nostr_sdk::prelude::{Event, Kind, PublicKey, ToBech32};
/// Whether a NIP-34 indexed role record is syntactically valid and active.
///
/// Values after the pubkey alternate between numeric start and end
/// boundaries. `defer` is valid only as the final end boundary and is never
/// active. Malformed history must not grant repository authority.
fn role_entry_is_active(slice: &[String]) -> bool {
if slice.len() < 2 {
return false;
}
let boundaries = &slice[2..];
for (index, value) in boundaries.iter().enumerate() {
if value == "defer" {
let is_final_end = index % 2 == 1 && index + 1 == boundaries.len();
if !is_final_end {
return false;
}
} else if value.parse::<u64>().is_err() {
return false;
}
}
boundaries.is_empty() || boundaries.len() % 2 == 1
}
// NOTE: Using rust-nostr Kind variants instead of hardcoded constants:
// - KIND_REPOSITORY_ANNOUNCEMENT -> Kind::GitRepoAnnouncement (30617)
@@ -34,11 +59,11 @@ pub struct RepositoryAnnouncement {
/// `None` when the announcement contains no role tags. Moderator (`o`)
/// entries never contribute: moderators are not maintainers.
///
/// The lead (`M`) / co-maintainer (`m`) distinction carries no meaning
/// for this service, so both collapse into one maintainer set. Entries
/// whose role history shows the role has ended are excluded entirely: an
/// ended role means the pubkey is no longer a maintainer, and historic
/// listing never grants authority for a past period.
/// Both `M` and `m` grant maintainer authority, so they collapse into one
/// maintainer set. Active `M` entries additionally drive lead resolution
/// through [`Self::active_leads`]. Entries whose role history is malformed
/// or ended are excluded entirely: historic listing never grants
/// authority for a past period.
pub role_maintainers: Option<Vec<String>>,
/// Whether any role tag names the author, active or ended. Per NIP-34 an
/// author without a self-entry is implicitly a maintainer for the
@@ -127,13 +152,14 @@ impl RepositoryAnnouncement {
// Extract maintainer pubkeys from NIP-34 `M`/`m`/`o` role tags
// Format: ["M"|"m"|"o", "<pubkey-hex>", "<start>", "<end>", ...]
// A role tag lists a pubkey followed by optional alternating start/end
// history timestamps; it is currently active when it has fewer than
// four elements or an odd number of elements. Ended entries are
// ignored entirely. A pubkey may appear in multiple role tags: one
// tag of each letter records transitions between roles, and
// out-of-spec duplicates under the same letter are tolerated. The
// histories are consolidated - the pubkey is a maintainer while any
// of its `M`/`m` entries is active.
// history timestamps; it is currently active when it has no history
// or its final valid boundary is a start. `defer` is accepted only as
// the final end boundary. Ended and malformed entries are ignored for
// authority. A pubkey may appear in multiple role tags: one tag of
// each letter records transitions between roles, and out-of-spec
// duplicates under the same letter are tolerated. The histories are
// consolidated - the pubkey is a maintainer while any of its valid
// `M`/`m` entries is active.
//
// The moderator role (`o`) grants no maintainership: moderators are
// only empowered to have status events (kinds 1630-1633) treated as
@@ -144,7 +170,6 @@ impl RepositoryAnnouncement {
// counts as a role tag: its presence suppresses the deprecated
// `maintainers` fallback, and a self-`o` entry is a self-role, so
// the author is not implicitly a maintainer.
let author_hex = event.pubkey.to_hex();
let mut role_tags_present = false;
let mut author_has_role_entry = false;
let mut role_active: Vec<String> = Vec::new();
@@ -158,15 +183,21 @@ impl RepositoryAnnouncement {
let Some(pubkey) = slice.get(1).filter(|value| !value.is_empty()) else {
continue;
};
if *pubkey == author_hex {
let parsed_pubkey = PublicKey::from_hex(pubkey).ok();
if parsed_pubkey.as_ref() == Some(&event.pubkey) {
author_has_role_entry = true;
}
if kind == "o" {
continue;
}
let active = slice.len() < 4 || slice.len() % 2 == 1;
if active && !role_active.contains(pubkey) {
role_active.push(pubkey.clone());
if role_entry_is_active(slice) {
let Some(parsed_pubkey) = parsed_pubkey else {
continue;
};
let pubkey = parsed_pubkey.to_hex();
if !role_active.contains(&pubkey) {
role_active.push(pubkey);
}
}
}
let role_maintainers = role_tags_present.then_some(role_active);
@@ -226,6 +257,32 @@ impl RepositoryAnnouncement {
listed
}
/// Distinct targets of syntactically valid, currently-active `M` records.
///
/// An active `M` is both a maintainer edge and an explicit lead pointer.
/// Duplicate records for one target are consolidated; distinct targets
/// remain visible so authority resolution can fail closed on ambiguity.
pub fn active_leads(&self) -> Vec<String> {
let mut leads = Vec::new();
for tag in self.event.tags.iter() {
let slice = tag.as_slice();
if slice.first().map(String::as_str) != Some("M") || !role_entry_is_active(slice) {
continue;
}
let Some(pubkey) = slice
.get(1)
.and_then(|value| PublicKey::from_hex(value).ok())
else {
continue;
};
let pubkey = pubkey.to_hex();
if !leads.contains(&pubkey) {
leads.push(pubkey);
}
}
leads
}
/// Whether this announcement asserts an active maintainer role for its
/// own author.
///
@@ -928,20 +985,22 @@ mod tests {
}
#[test]
fn test_role_tag_activeness_by_element_count() {
fn test_role_tag_activeness_requires_valid_history() {
let keys = create_test_keys();
let author = keys.public_key().to_hex();
let other = create_test_keys().public_key().to_hex();
// (history values after the pubkey, expected active)
// Tag element counts: 2 -> active, 3 -> active, 4 -> ended,
// 5 -> active, 6 -> ended.
let cases: Vec<(Vec<&str>, bool)> = vec![
(vec![], true),
(vec!["100"], true),
(vec!["100", "200"], false),
(vec!["100", "200", "300"], true),
(vec!["100", "200", "300", "400"], false),
(vec!["100", "defer"], false),
(vec!["not-a-timestamp"], false),
(vec!["defer"], false),
(vec!["100", "defer", "300"], false),
];
for (history, expected_active) in cases {
@@ -957,6 +1016,28 @@ mod tests {
}
}
#[test]
fn test_active_leads_include_only_valid_distinct_targets() {
let keys = create_test_keys();
let first = create_test_keys().public_key().to_hex();
let second = create_test_keys().public_key().to_hex();
let ended = create_test_keys().public_key().to_hex();
let malformed = create_test_keys().public_key().to_hex();
let announcement = role_announcement(
&keys,
vec![
("M", vec![first.clone()]),
("M", vec![first.clone(), "100".to_string()]),
("M", vec![second.clone()]),
("M", vec![ended, "100".to_string(), "200".to_string()]),
("M", vec![malformed, "not-a-timestamp".to_string()]),
],
);
assert_eq!(announcement.active_leads(), vec![first, second]);
}
#[test]
fn test_lead_and_co_maintainer_roles_are_equivalent() {
let keys = create_test_keys();
+75
View File
@@ -297,6 +297,81 @@ async fn state_event_rejected_as_unauthorized(client: &Client, keys: &Keys) -> b
}
}
#[tokio::test]
async fn test_reject_state_from_announcement_author_with_ended_self_role() {
let relay = TestRelay::start().await;
let owner_keys = Keys::generate();
let announcement = EventBuilder::new(Kind::GitRepoAnnouncement, "")
.tags([
Tag::custom("d", ["test-repo"]),
Tag::custom("clone", [format!("https://{}/test.git", relay.domain())]),
Tag::custom("relays", [relay.url()]),
Tag::custom(
"m",
[
owner_keys.public_key().to_hex(),
"0".to_string(),
"1700000000".to_string(),
],
),
])
.finalize(&owner_keys)
.unwrap();
let client = Client::default();
client.add_relay(relay.url()).await.unwrap();
client.connect().await;
client.send_event(&announcement).await.unwrap();
assert!(
state_event_rejected_as_unauthorized(&client, &owner_keys).await,
"an ended selected author's state event must be rejected as unauthorized"
);
relay.stop().await;
}
#[tokio::test]
async fn test_reject_state_from_unassigned_forwarding_author() {
let relay = TestRelay::start().await;
let lead_keys = Keys::generate();
let former_keys = Keys::generate();
let lead_announcement = EventBuilder::new(Kind::GitRepoAnnouncement, "")
.tags([
Tag::custom("d", ["test-repo"]),
Tag::custom("clone", [format!("https://{}/test.git", relay.domain())]),
Tag::custom("relays", [relay.url()]),
Tag::custom("M", [lead_keys.public_key().to_hex()]),
])
.finalize(&lead_keys)
.unwrap();
let former_announcement = EventBuilder::new(Kind::GitRepoAnnouncement, "")
.tags([
Tag::custom("d", ["test-repo"]),
Tag::custom("clone", [format!("https://{}/test.git", relay.domain())]),
Tag::custom("relays", [relay.url()]),
Tag::custom("M", [lead_keys.public_key().to_hex()]),
Tag::custom("m", [former_keys.public_key().to_hex()]),
])
.finalize(&former_keys)
.unwrap();
let client = Client::default();
client.add_relay(relay.url()).await.unwrap();
client.connect().await;
client.send_event(&lead_announcement).await.unwrap();
client.send_event(&former_announcement).await.unwrap();
assert!(
state_event_rejected_as_unauthorized(&client, &former_keys).await,
"a forwarding signer not assigned by the lead must not regain state authority"
);
relay.stop().await;
}
#[tokio::test]
async fn test_reject_state_from_invited_maintainer() {
let relay = TestRelay::start().await;