mirror of
https://github.com/block/buzz.git
synced 2026-10-28 13:01:10 +00:00
## Summary - add database-backed v2 invite links with optional maximum-use limits and atomic final-slot redemption - preserve v1 invite compatibility while adding exhausted/expired/invalid client handling across desktop, web, and mobile - emit structured claim-outcome logs with community, invite ID, outcome, maximum uses, and post-claim count ## Verification - `cargo fmt --all -- --check` - `cargo test -p buzz-db` (85 passed, 134 Postgres-dependent ignored) - `cargo clippy -p buzz-db --all-targets -- -D warnings` - desktop `npm run typecheck` - push hook: desktop checks/tests, desktop Tauri tests, Rust tests, and branch-skew passed - Postgres integration tests were previously reviewed green at the pre-rebase tree; local rerun on this session was unavailable because Postgres/Docker were not running - mobile push-hook check could not start because Flutter is unavailable locally --------- Signed-off-by: Kalvin Chau <kalvin@block.xyz> Signed-off-by: npub1dccv64krpcpse5cmkzfeh998cftungyatw3djt8jwdw6g43f7fyqzzmrf7 <6e30cd56c30e030cd31bb0939b94a7c257c9a09d5ba2d92cf2735da45629f248@buzz.block.builderlab.xyz> Co-authored-by: npub1c4alndp82zyt9veaklm5d965quss79vlhk9awv7qu5erwhmf42qqlvc25c <c57bf9b4275088b2b33db7f746975407210f159fbd8bd733c0e532375f69aa80@buzz.block.builderlab.xyz> Co-authored-by: npub122y0pqkertljmedu303rl0aqrj3w8pvu43t6jxm6875lzg6f2pwqegc3xc <5288f082d91aff2de5bc8be23fbfa01ca2e3859cac57a91b7a3fa9f12349505c@buzz.block.builderlab.xyz> Co-authored-by: npub1dccv64krpcpse5cmkzfeh998cftungyatw3djt8jwdw6g43f7fyqzzmrf7 <6e30cd56c30e030cd31bb0939b94a7c257c9a09d5ba2d92cf2735da45629f248@buzz.block.builderlab.xyz>
110 lines
3.5 KiB
Rust
110 lines
3.5 KiB
Rust
//! 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))
|
|
);
|
|
}
|
|
}
|