diff --git a/crates/buzz-push-gateway/src/authority.rs b/crates/buzz-push-gateway/src/authority.rs index 36c220885c..f334ab7529 100644 --- a/crates/buzz-push-gateway/src/authority.rs +++ b/crates/buzz-push-gateway/src/authority.rs @@ -133,11 +133,13 @@ pub trait AuthorityStore: Send + Sync { token_ciphertext: Vec, token_fingerprint: [u8; 32], ) -> Result<(), AuthorityError>; + /// Revoke an active delegation only when `expected_generation` is current, + /// retaining that generation as the replacement watermark. async fn revoke_delegation( &self, installation_id: Uuid, relay_pubkey: &str, - new_generation: i64, + expected_generation: i64, ) -> Result<(), AuthorityError>; async fn revoke_installation( &self, @@ -348,7 +350,7 @@ impl AuthorityStore for MemoryAuthorityStore { &self, id: Uuid, relay: &str, - generation: i64, + expected_generation: i64, ) -> Result<(), AuthorityError> { let mut s = self.0.lock().map_err(|_| AuthorityError::Unavailable)?; let key = (id, relay.to_owned()); @@ -356,10 +358,9 @@ impl AuthorityStore for MemoryAuthorityStore { .delegations .get_mut(&key) .ok_or(AuthorityError::Rejected)?; - if generation <= old.generation { + if old.revoked || expected_generation != old.generation { return Err(AuthorityError::Rejected); } - old.generation = generation; old.revoked = true; Ok(()) } @@ -573,4 +574,69 @@ mod tests { .unwrap(); assert!(admitted(&store, &"33".repeat(32), request).await.is_err()); } + + #[tokio::test] + async fn delegation_revocation_requires_the_current_generation() { + let store = store().await; + + assert_eq!( + store + .revoke_delegation(Uuid::from_u128(1), &"11".repeat(32), 0) + .await, + Err(AuthorityError::Rejected) + ); + assert_eq!( + store + .revoke_delegation(Uuid::from_u128(1), &"11".repeat(32), 2) + .await, + Err(AuthorityError::Rejected) + ); + admitted(&store, &"44".repeat(32), Uuid::new_v4()) + .await + .expect("rejected revocations must leave generation 1 active"); + + store + .revoke_delegation(Uuid::from_u128(1), &"11".repeat(32), 1) + .await + .expect("the current generation can be revoked"); + assert!(admitted(&store, &"55".repeat(32), Uuid::new_v4()) + .await + .is_err()); + + let replacement = |id, generation| Delegation { + id, + installation_id: Uuid::from_u128(1), + relay_pubkey: "11".repeat(32), + endpoint_epoch: 1, + generation, + not_before: 900, + expires_at: 1_500, + revoked: false, + }; + assert_eq!( + store + .upsert_delegation(replacement(Uuid::from_u128(3), 1)) + .await, + Err(AuthorityError::Rejected) + ); + store + .upsert_delegation(replacement(Uuid::from_u128(4), 2)) + .await + .expect("only a strictly newer generation can reactivate the delegation"); + store + .authorize_delivery( + Uuid::from_u128(4), + &"11".repeat(32), + 1, + 2, + &"66".repeat(32), + Uuid::new_v4(), + 1_100, + 60, + 10, + 1_000, + ) + .await + .expect("generation 2 authority is active"); + } } diff --git a/crates/buzz-push-gateway/src/postgres.rs b/crates/buzz-push-gateway/src/postgres.rs index bd69ec2564..7761be584e 100644 --- a/crates/buzz-push-gateway/src/postgres.rs +++ b/crates/buzz-push-gateway/src/postgres.rs @@ -226,10 +226,10 @@ impl AuthorityStore for PostgresAuthorityStore { &self, id: Uuid, relay: &str, - generation: i64, + expected_generation: i64, ) -> Result<(), AuthorityError> { let relay = hex::decode(relay).map_err(|_| AuthorityError::Rejected)?; - let result=sqlx::query("UPDATE push_gateway_delegations SET generation=$3,revoked_at=now(),updated_at=now() WHERE installation_id=$1 AND relay_pubkey=$2 AND generation<$3").bind(id).bind(relay).bind(generation).execute(&self.pool).await.map_err(db)?; + let result=sqlx::query("UPDATE push_gateway_delegations SET revoked_at=now(),updated_at=now() WHERE installation_id=$1 AND relay_pubkey=$2 AND generation=$3 AND revoked_at IS NULL").bind(id).bind(relay).bind(expected_generation).execute(&self.pool).await.map_err(db)?; if result.rows_affected() != 1 { return Err(AuthorityError::Rejected); } diff --git a/docs/nips/NIP-PL.md b/docs/nips/NIP-PL.md index 08fa6ca34d..fa22363ef9 100644 --- a/docs/nips/NIP-PL.md +++ b/docs/nips/NIP-PL.md @@ -368,7 +368,7 @@ Transcript domain `buzz.push.revoke-delegation.v1`; ordered object: {"v":1,"audience":"https://push.buzz.xyz/v1/delegations/revoke","challenge_id":"","challenge":"","installation_handle":"","relay_pubkey":"","generation":} ``` -The generation identifies the current delegation generation. Success is `200 {"status":"revoked"}`. +The supplied generation MUST equal the current delegation generation. The gateway atomically compares it against the locked delegation row, marks that delegation revoked, and retains the same generation as its watermark. A stale or future generation is rejected without changing the delegation. Any later delegation for the same `(installation_handle, relay_pubkey)` MUST use a strictly greater generation. Success is `200 {"status":"revoked"}`. `POST /v1/installations/revoke` request: