Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 3 additions & 2 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -340,6 +340,7 @@ jobs:
cargo build --profile ci -p buzz-relay -p git-credential-nostr
cargo nextest archive \
--cargo-profile ci \
-p buzz-db \
-p buzz-relay \
-p buzz-test-client \
--lib \
Expand Down Expand Up @@ -671,11 +672,11 @@ jobs:
done
cat /tmp/buzz-relay.log
exit 1
- name: Invite claim security tests
- name: Invite security tests
run: |
cargo nextest run \
--archive-file target/ci/backend-integration-tests.tar.zst \
-E 'package(buzz-relay) and test(claim_)' \
-E '(package(buzz-db) and test(/relay_invite::tests/)) or (package(buzz-relay) and test(/api::invites::tests/))' \
--run-ignored ignored-only
env:
DATABASE_URL: postgres://buzz:${{ env.BUZZ_TEST_POSTGRES_PASSWORD }}@localhost:5432/buzz
Expand Down
2 changes: 2 additions & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -96,6 +96,7 @@ reqwest = { version = "0.13", features = ["json", "rustls"], default-features =
sha2 = "0.11"
hex = "0.4"
hmac = "0.13"
base64 = "0.22"

# Randomness
rand = "0.10"
Expand Down
1 change: 1 addition & 0 deletions crates/buzz-core/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ description = "Core types, event verification, and filter matching for Buzz"
test-utils = []

[dependencies]
base64 = { workspace = true }
nostr = { workspace = true }
serde = { workspace = true }
serde_json = { workspace = true }
Expand Down
109 changes: 109 additions & 0 deletions crates/buzz-core/src/invite.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,109 @@
//! Shared pure contracts for relay invite links.
//!
//! The relay transport and database persistence layers both depend on
//! `buzz-core`. Protocol constants and deterministic v2 code operations live
//! here so neither layer becomes the accidental source of truth.

use base64::engine::general_purpose::URL_SAFE_NO_PAD;
use base64::Engine as _;
use sha2::{Digest, Sha256};

/// Minimum invite lifetime accepted by the mint API: 60 seconds.
pub const MIN_INVITE_TTL_SECS: u64 = 60;

/// Default invite lifetime when the mint request omits `ttl_secs`: 72 hours.
pub const DEFAULT_INVITE_TTL_SECS: u64 = 72 * 60 * 60;

/// Maximum invite lifetime accepted by the mint API: 30 days.
pub const MAX_INVITE_TTL_SECS: u64 = 30 * 24 * 60 * 60;

/// Maximum supported `max_uses` value. Matches the database constraint.
pub const MAX_INVITE_USES: i32 = 10_000;

/// Prefix that distinguishes v2 opaque database-backed codes from v1 tokens.
pub const V2_PREFIX: &str = "v2.";

/// Number of random bytes encoded in a v2 invite code.
pub const V2_SECRET_LEN: usize = 32;

/// A malformed v2 opaque invite code.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct InvalidV2InviteCode;

/// Build a canonical v2 code from its random secret.
pub fn encode_v2_code(secret: &[u8; V2_SECRET_LEN]) -> String {
format!("{V2_PREFIX}{}", URL_SAFE_NO_PAD.encode(secret))
}

/// Validate the canonical v2 opaque-code shape without consulting storage.
///
/// A valid code is exactly `v2.` followed by the unpadded base64url encoding
/// of a 32-byte secret. The decode/re-encode comparison rejects aliases such
/// as padded or otherwise non-canonical encodings.
pub fn validate_v2_code(code: &str) -> Result<(), InvalidV2InviteCode> {
let encoded = code.strip_prefix(V2_PREFIX).ok_or(InvalidV2InviteCode)?;
let secret = URL_SAFE_NO_PAD
.decode(encoded)
.map_err(|_| InvalidV2InviteCode)?;
if secret.len() != V2_SECRET_LEN || URL_SAFE_NO_PAD.encode(&secret) != encoded {
return Err(InvalidV2InviteCode);
}
Ok(())
}

/// Hash the complete v2 code to the digest persisted by the database.
pub fn hash_v2_code(code: &str) -> [u8; 32] {
Sha256::digest(code.as_bytes()).into()
}

#[cfg(test)]
mod tests {
use super::*;

#[test]
fn v2_code_round_trip_is_canonical() {
let secret = [7_u8; V2_SECRET_LEN];
let code = encode_v2_code(&secret);

assert_eq!(validate_v2_code(&code), Ok(()));
assert_eq!(
code,
format!("{V2_PREFIX}{}", URL_SAFE_NO_PAD.encode(secret))
);
}

#[test]
fn v2_validation_rejects_malformed_and_noncanonical_codes() {
let valid = encode_v2_code(&[7_u8; V2_SECRET_LEN]);
let short = format!(
"{V2_PREFIX}{}",
URL_SAFE_NO_PAD.encode([7_u8; V2_SECRET_LEN - 1])
);

for malformed in [
"v2.",
"v2.not-base64!",
short.as_str(),
&format!("{valid}="),
] {
assert_eq!(
validate_v2_code(malformed),
Err(InvalidV2InviteCode),
"accepted malformed v2 code: {malformed}"
);
}
}

#[test]
fn v2_hash_covers_the_complete_code() {
let code = encode_v2_code(&[7_u8; V2_SECRET_LEN]);

let expected: [u8; 32] = Sha256::digest(code.as_bytes()).into();

assert_eq!(hash_v2_code(&code), expected);
assert_ne!(
hash_v2_code(&code),
hash_v2_code(code.trim_start_matches(V2_PREFIX))
);
}
}
2 changes: 2 additions & 0 deletions crates/buzz-core/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,8 @@ pub mod event;
pub mod filter;
/// Git permission types — ref patterns, protection rules, policy evaluation.
pub mod git_perms;
/// Shared invite-link contract constants.
pub mod invite;
/// Buzz kind number registry — custom event type constants.
pub mod kind;
/// Network utilities — SSRF-safe IP classification.
Expand Down
1 change: 1 addition & 0 deletions crates/buzz-db/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ sha2 = { workspace = true }
tracing = { workspace = true }
thiserror = { workspace = true }
nostr = { workspace = true }
rand = { workspace = true }

[dev-dependencies]
tokio = { workspace = true }
47 changes: 47 additions & 0 deletions crates/buzz-db/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,8 @@ pub mod product_feedback;
pub mod push;
/// Reaction persistence.
pub mod reaction;
/// Use-limited relay invite persistence (v2 opaque tokens).
pub mod relay_invite;
/// Relay-level membership persistence (NIP-43).
pub mod relay_members;
/// Replica freshness fence for keyset-cursor read routing.
Expand Down Expand Up @@ -3045,6 +3047,51 @@ impl Db {
relay_members::backfill_from_allowlist(&self.pool, community).await
}

/// Mints a v2 use-limited relay invite. The plaintext code is returned
/// exactly once; only its SHA-256 hash is persisted.
///
/// `max_uses` is `None` for unlimited or `Some(1..=10000)`.
/// `ttl_secs` must be in the shared invite lifetime range.
pub async fn mint_relay_invite(
&self,
community: CommunityId,
created_by: &str,
ttl_secs: u64,
max_uses: Option<i32>,
) -> Result<relay_invite::MintedInvite> {
relay_invite::mint_relay_invite(&self.pool, community, created_by, ttl_secs, max_uses).await
}

/// Delete one bounded batch of invites expired before `cutoff`.
pub async fn reap_expired_relay_invites(
&self,
cutoff: chrono::DateTime<chrono::Utc>,
) -> Result<u64> {
relay_invite::reap_expired_relay_invites(&self.pool, cutoff).await
}

/// Atomically claims a v2 relay invite. The full redemption (membership
/// insert, policy evidence, use_count increment) runs in one PostgreSQL
/// transaction with `FOR UPDATE` on the invite row.
///
/// `token_hash` is the SHA-256 of the presented v2 code (32 bytes).
pub async fn claim_relay_invite(
&self,
community: CommunityId,
token_hash: &[u8; 32],
claimer_pubkey: &str,
policy_version: Option<&str>,
) -> Result<relay_invite::ClaimOutcome> {
relay_invite::claim_relay_invite(
&self.pool,
community,
token_hash,
claimer_pubkey,
policy_version,
)
.await
}

/// Sidecar an accepted product-feedback event, idempotent by event id.
pub async fn insert_product_feedback(
&self,
Expand Down
29 changes: 27 additions & 2 deletions crates/buzz-db/src/migration.rs
Original file line number Diff line number Diff line change
Expand Up @@ -560,7 +560,7 @@ mod tests {
let mut migrations: Vec<_> = MIGRATOR.iter().collect();
migrations.sort_by_key(|migration| migration.version);

assert_eq!(migrations.len(), 24);
assert_eq!(migrations.len(), 25);
assert_eq!(migrations[0].version, 1);
assert_eq!(&*migrations[0].description, "initial schema");
assert!(migrations[0]
Expand Down Expand Up @@ -879,6 +879,31 @@ mod tests {
.to_lowercase()
.contains("for update"));
assert!(ttl_shared.contains("NEW.kind <> 9007"));

// Use-limited invite links: durable relay_invites table stores only
// the SHA-256 of an opaque v2 code, scoped by community_id. Never
// listed in _operator_global_tables — it is community-scoped.
assert_eq!(migrations[24].version, 25);
let relay_invites = migrations[24].sql.as_str();
assert!(relay_invites.contains("CREATE TABLE relay_invites"));
assert!(relay_invites
.contains("token_hash BYTEA NOT NULL CHECK (length(token_hash) = 32)"));
assert!(relay_invites.contains("PRIMARY KEY (community_id, id)"));
assert!(relay_invites.contains("UNIQUE (community_id, token_hash)"));
assert!(
relay_invites.contains("max_uses INTEGER CHECK (max_uses BETWEEN 1 AND 10000)")
);
assert!(relay_invites.contains("CHECK (max_uses IS NULL OR use_count <= max_uses)"));
assert!(relay_invites.contains("role = 'member'"));
assert!(relay_invites
.contains("CREATE INDEX relay_invites_expires_at_idx ON relay_invites (expires_at)"));
assert!(!relay_invites.contains("_operator_global_tables"));

let desired_schema = include_str!("../../../schema/schema.sql");
assert!(
desired_schema.contains("CREATE TABLE join_policy_acceptances"),
"desired-state schema must include join-policy evidence used by invite claims",
);
}

#[test]
Expand Down Expand Up @@ -1121,7 +1146,7 @@ mod tests {
run_migrations(&pool)
.await
.expect("retry succeeds after operator repair");
assert_eq!(applied_versions(&pool).await.last().copied(), Some(24));
assert_eq!(applied_versions(&pool).await.last().copied(), Some(25));
}

#[tokio::test]
Expand Down
Loading
Loading