fix(invites): reject unknown claim codes before the admission lock

A v2 claim took the community-wide admission lock before looking up its
token, so forged `v2.` codes from fresh keys could serialize a
community's claims, bans and timeouts and hold shared writer-pool
connections while queued. Check that the code exists with an indexed
read first; the locked `FOR UPDATE` read stays authoritative. A
restricted claimant presenting an unknown code now gets
`invite_invalid`, which reveals nothing.

Also from review: mint's owner-link 503 reuses `OWNER_LINK_ERROR` like
the NIP-FI path; comments explain the admission wait behind membership
snapshots, the defensive claim-owner revocation, and why issuer
restrictions map to `invite_invalid`; TESTING.md notes that a rollback
past 0074 needs `BUZZ_AUTO_MIGRATE` off (docs/database.md Rule 5).

Co-authored-by: Elrond <28d6302a099e5225b02c4155ac4236e4912603df2ab08dbfc2f4fef08ce598c8@buzz.block.builderlab.xyz>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Luke Tornquist <tornquist@squareup.com>
This commit is contained in:
Luke Tornquist
2026-10-28 14:40:01 -04:00
parent 12f462cc3f
commit 989412f30e
6 changed files with 48 additions and 3 deletions

View File

@@ -219,6 +219,9 @@ keep it closed until it is active and repaired, then recheck zero before admitti
traffic. After repair, timeouts/mutes only block temporarily; active bans still
revoke permanently. A rollback to code that ignores `revoked_at` requires
invitation admission paused, using the same traffic/writer controls if needed.
It also requires `BUZZ_AUTO_MIGRATE` off: with auto-migration on (the Helm
default), the old binary fails at startup with SQLx `VersionMissing(74)`. See
[docs/database.md](docs/database.md) Rule 5.
Legacy owner attribution uses the agent's currently recorded
`agent_owner_pubkey`; deleting an owner user row out of band clears that link
through its foreign key, so a later repair cannot recover the former

View File

@@ -199,6 +199,9 @@ pub async fn mint_relay_invite(
/// Lock invitation admission against community restriction and owner writes.
/// Call before reading restrictions or locking an invite row, and hold to commit.
/// A joined claim holds it through its NIP-43 membership snapshot, so mint, ban,
/// timeout and first owner link wait for that snapshot, which grows with the
/// member count. Claims reject unknown codes before taking it.
pub(crate) async fn lock_admission(
tx: &mut sqlx::PgConnection,
community: CommunityId,
@@ -420,6 +423,21 @@ pub async fn claim_relay_invite_with_owner(
verified_owner: Option<&str>,
relay_keypair: &nostr::Keys,
) -> Result<MembershipWrite<InviteClaimResult>> {
// Unknown codes are free to forge, so reject them before taking the
// community-wide admission lock; the locked read below stays authoritative.
let known: bool = sqlx::query_scalar(
"SELECT EXISTS (SELECT 1 FROM relay_invites \
WHERE community_id = $1 AND token_hash = $2)",
)
.bind(community.as_uuid())
.bind(token_hash)
.fetch_one(pool)
.await?;
if !known {
log_claim_outcome(community, None, "invalid", None, None);
return Ok(MembershipWrite::unchanged(ClaimOutcome::Invalid.into()));
}
let mut tx = crate::begin_community_write_transaction(
pool,
community,

View File

@@ -82,7 +82,9 @@ pub(super) async fn persist_claim_owner(
}
let link_created = sqlx::query("UPDATE users SET agent_owner_pubkey=$3 WHERE community_id=$1 AND pubkey=$2 AND agent_owner_pubkey IS NULL")
.bind(community.as_uuid()).bind(&claimant_bytes).bind(&owner_bytes).execute(&mut *tx).await?.rows_affected() > 0;
// Defensive parity with other owner writers; admission already rejects banned owners.
// Defense in depth: `claimant_restricted` already refused a banned owner
// under the same admission lock, so this finds nothing today. It keeps
// parity with every other owner writer if that check ever moves.
if link_created {
super::revoke_restricted_agent_invites(tx, community, &claimant_bytes, &owner_bytes)
.await?;

View File

@@ -357,3 +357,22 @@ async fn linked_authorization_owner_and_missing_agent_do_not_wait_on_admission()
));
claim.rollback().await.unwrap();
}
#[tokio::test]
#[ignore = "requires Postgres"]
async fn unknown_claim_code_is_rejected_without_waiting_on_admission() {
let (pool, community, _, claimant) = fixture().await;
let mut held = pool.begin().await.unwrap();
lock_admission(&mut held, community).await.unwrap();
// A forged code must not queue behind admission; if the claim takes the
// lock before looking the code up, this bounded call cannot finish.
let outcome = tokio::time::timeout(
std::time::Duration::from_secs(5),
claim_relay_invite_outcome(&pool, community, &[0xAB; 32], &claimant, None),
)
.await
.expect("unknown code waited on the admission lock")
.unwrap();
assert_eq!(outcome, ClaimOutcome::Invalid);
held.rollback().await.unwrap();
}

View File

@@ -330,7 +330,7 @@ async fn mint_invite_checked(
super::relay_members::OwnerLink::Unavailable => {
return api_error(
StatusCode::SERVICE_UNAVAILABLE,
"restriction lookup unavailable",
crate::handlers::auth::OWNER_LINK_ERROR,
)
.into_response();
}
@@ -517,6 +517,9 @@ pub async fn claim_invite(
buzz_db::relay_invite::ClaimOutcome::Exhausted => {
Err(api_error(StatusCode::FORBIDDEN, "invite_exhausted"))
}
// Deliberately indistinguishable, even for an issuer timeout whose
// link works again later: a claim never reveals the issuer's
// moderation state.
buzz_db::relay_invite::ClaimOutcome::Revoked
| buzz_db::relay_invite::ClaimOutcome::IssuerRestricted => {
Err(api_error(StatusCode::FORBIDDEN, "invite_invalid"))

View File

@@ -351,7 +351,7 @@ pub(super) mod postgres_tests {
assert_eq!(response.status(), StatusCode::SERVICE_UNAVAILABLE);
assert_eq!(
read_json(response).await["error"],
"restriction lookup unavailable"
crate::handlers::auth::OWNER_LINK_ERROR
);
let after: i64 = sqlx::query_scalar("SELECT count(*) FROM relay_invites")
.fetch_one(state.db.pool())