Clubs, credentials, the lock hierarchy, KeyMaster authority, OAuth2 login, email verification, the authentication flow with rate limiting, CSRF protection, and the tamper-evident security audit chain — the complete identity and access control system.
In Udanax-Gold, a Club is the fundamental identity unit. Every user, group, and permission set is a Club. Clubs own works, control access, and sign content. There is no separate "user" concept — everything is a Club.
| Field | Type | Purpose |
|---|---|---|
be_id | BeId | Unique identifier |
name | Option<String> | Human-readable name ("alice", "editors") |
members | HashSet<BeId> | Member clubs (for group clubs) |
credential | Option<Credential> | Password or public key for authentication |
is_personal | bool | True for individual user clubs |
encrypted_signing_key | Option<EncryptedSigningKey> | Per-club Ed25519 signing key, encrypted with the club's password |
signature_club | Option<BeId> | The owning club (usually the user's personal club) |
sponsored_works | HashSet<BeId> | Works vouched for by this club |
default_read_club | Option<BeId> | Default read-access club for new works |
default_edit_club | Option<BeId> | Default edit-access club for new works |
email | Option<String> | Email for verification (personal clubs only) |
verified | bool | Whether email has been verified |
One per user. Has credentials (password or public key). Owns the user's works and edits. Has an encrypted Ed25519 signing key for attribution. Can have an email address for verification.
A named collection of member clubs. Used for team permissions: "editors", "reviewers", "public". Members inherit the group's access rights.
Special club for anonymous/guest access. Users who haven't signed in operate under the public club. Their edits are attributed to "anonymous".
Internal club that holds signing keys. Every personal club has a signature club that owns its Ed25519 keypair.
Club class with recursive membership (clubs containing clubs). Xudanu currently implements flat membership (no nesting) but retains the sponsorship concept for work vouching.
Every server starts with four system clubs created at initialization:
| Club | Purpose |
|---|---|
public_club | Anonymous/guest access. All unauthenticated sessions operate under this club. |
admin_club | Server administration. Members can accept/reject connections, run preflight, manage other clubs. |
access_club | Used for general access control internally. |
empty_club | The empty set — no authority. Used as a sentinel. |
A club can have one of two credential types:
Credential::Password { phc_hash }
Stored as a PHC-format Argon2id hash. The PHC string encodes the algorithm, parameters, salt, and hash, making it self-describing and future-proof. Passwords must be 10–256 bytes.
Credential::PublicKey { verifying_key }
A 32-byte Ed25519 verifying key. The client proves identity by signing a challenge with the corresponding private key.
When a password is set, the server also generates (or re-encrypts) the club's Ed25519 signing key, encrypted with a key derived from the password via Argon2id:
// From identity.rs club_set_password()
if is_personal {
if let Some(existing_sk) = existing_signing_key {
// Re-encrypt the signing key with the new password
let encrypted = encrypt_signing_key(&sk, password)?;
club.set_encrypted_signing_key(Some(encrypted));
} else if club.encrypted_signing_key().is_none() {
// Generate a new Ed25519 keypair for this club
let (encrypted, signing_key) = generate_club_keypair(password)?;
club.set_encrypted_signing_key(Some(encrypted));
session.set_club_signing_key(Some(signing_key)); // available in-session
}
}
Locks control access to clubs. A club may have a lock that must be opened before the club's authority can be used. The lock system implements the Strategy pattern via the Lock trait:
The Lock hierarchy. Each lock type implements the Lock trait. MultiLock composes multiple locks (OR semantics).
| Lock | Behavior | Use case |
|---|---|---|
BooLock | Always opens with LockCredential::Boo | Open/public access, anonymous login |
WallLock | Never opens — always returns LockFailed | Permanently locked accounts, disabled users, clubs with no credential |
ChallengeLock | Requires correct password response | Standard password authentication |
MatchLock | Matches stored credential (password hash or public key) | Username/password login, key-based auth |
MultiLock | Composes multiple locks; opens if ANY child opens | Multi-factor: accept either password OR key |
The KeyMaster tracks which clubs a session has proven identity for. When a lock is opened, the resulting KeyMaster contains the set of authorized club IDs.
| Authority type | Source | Used for |
|---|---|---|
login_authority | Direct lock opening (password/key match) | Acting as this club, editing, managing |
actual_authority | Login authority + membership-derived authority | All operations the session can perform, including group-club access |
After successful login, update_authority() walks the club membership graph to expand actual_authority — if you're a member of "editors" and "editors" is a member of "staff", you get authority over all three.
For provenance signing, the KeyMaster also tracks signature authority — the right to use a club's signing key. This is checked via has_signature_authority(club_id, &clubs), which walks the signature_club chain to verify the session controls the key.
KeyMaster had incorporate() (merge authority) and removeLogins() (revoke). Xudanu implements make(), merge(), and incorporate_authority() but not full revocation.
Authentication is a two-step challenge-response protocol. The client first requests a lock (step 1), then submits credentials to open it (step 2):
The two-step authentication flow. The server stores the lock as "pending" on the session between steps 1 and 2.
KeyMaster is created with authority for the clubupdate_authority() expands to include group membershipsSECURITY:login_succeededFor anonymous access, the server uses a BooLock that always opens:
pub fn login_public(&mut self, session_id: SessionId) -> Result<KeyMaster, ServerError> {
let lock = BooLock::new(self.system_clubs.public_club);
self.authenticate(session_id, &lock, &LockCredential::Boo)
}
Xudanu implements per-club rate limiting for login attempts. This is deliberately per-club (not per-IP) because:
| Parameter | Default | Code constant |
|---|---|---|
| Max attempts per club | 10 | MAX_CLUB_LOGIN_ATTEMPTS |
| Window duration | 5 minutes (300s) | CLUB_LOGIN_ATTEMPT_WINDOW |
| Tracking | Per-club ClubAttemptTracker | HashMap<BeId, ClubAttemptTracker> |
The rate limit is checked in authenticate_with_pending before attempting to open the lock:
if let Some(tracker) = self.login_attempts.get_mut(&club_id) {
if now.duration_since(tracker.window_start) > CLUB_LOGIN_ATTEMPT_WINDOW {
tracker.count = 0; // window reset
tracker.window_start = now;
}
if tracker.count >= MAX_CLUB_LOGIN_ATTEMPTS {
security_warn!(
club_id = ?club_id,
event = "SECURITY:login_rate_limited",
"login rate limited"
);
return Err(ServerError::Unauthorized(
"too many login attempts for this account, try again later".into()
));
}
}
self.login_attempts.remove(&club_id)). This means a user who mistypes their password a few times but eventually gets it right doesn't carry a partial counter forward.
The WebSocket connection is protected by CSRF (Cross-Site Request Forgery) tokens. The server generates a random token that the client must include in the WebSocket upgrade request:
| Component | Implementation |
|---|---|
| Token generation | 32 random bytes via OsRng, hex-encoded |
| Storage | In-memory HashSet<String>, capped at MAX_CSRF_TOKENS = 10,000 |
| Delivery | Client fetches from GET /csrf-token before connecting |
| Validation | Token must be present in the ?csrf_token= query param on WS upgrade |
| Toggle | --csrf-token CLI flag; can be disabled for development |
allowed_origins list in AppState provides an additional layer, checking the Origin header on WS upgrades.
Xudanu supports OAuth2 login through GitHub and Google, allowing users to authenticate without creating a password. The flow follows the standard OAuth2 authorization code pattern.
The OAuth2 flow. The state token prevents CSRF attacks on the callback. After fetching the user profile, the server finds or creates a club and establishes a session.
| Structure | Purpose | TTL |
|---|---|---|
pending_states | Maps state tokens to timestamps; prevents replay | 5 minutes (300s) |
links | Maps (provider, provider_user_id) to club_id; persists across sessions | Until server restart |
sessions | OAuth session tokens with club_id and display name | 30 days |
When a user signs in via OAuth for the first time, the server creates a personal club with no password (OAuth is the credential). On subsequent logins, the find_link() method looks up the existing club by (provider, provider_user_id). A single club can be linked to both GitHub and Google simultaneously.
--github-client-id <id> --github-client-secret <secret> --google-client-id <id> --google-client-secret <secret> --oauth-redirect-base <url>
Personal clubs can have an email address that must be verified before the account is considered trusted. The verification system uses single-use BLAKE3-hashed tokens with a 24-hour TTL.
club_set_email()VerificationTokenStore::issue(club_id, email) generates a 32-byte random token, stores its BLAKE3 hash (the raw token is never persisted), and returns the raw tokenEmailProvider sends a verification URL (https://server/verify?token=...) to the email addressredeem(raw_token) hashes it, looks up the pending entry, checks expiry, atomically removes it (single-use), and returns (club_id, email)mark_club_verified(club_id) sets the club's verified flagsweep_expired().
| Provider | Behavior |
|---|---|
DevProvider (default) | Logs the verification URL to the server console — fully testable without SMTP infrastructure |
| Future: SMTP provider | The EmailProvider trait (fn send_verification(&self, to_email, url)) supports plugging in a real SMTP transport |
| Parameter | Value |
|---|---|
| TTL | 24 hours (TOKEN_TTL_SECS = 86400) |
| Hash algorithm | BLAKE3 |
| Token entropy | 256 bits (32 bytes from OsRng) |
| Single-use | Yes — token is removed on successful redemption |
| Persistence | In-memory (tokens issued before a restart are lost; users re-request) |
Session is created with a unique SessionId. Default timeout: 1 hour (DEFAULT_SESSION_TIMEOUT = 3600s).login(club_id) then authenticate_with_pending(credential). On success, the session gains KeyMaster authority and (if password login) the decrypted Ed25519 signing key.OtreeAuthorIdentity (public key, display name, club ID) for attribution.| Field | Type | Purpose |
|---|---|---|
key_master | Option<KeyMaster> | Login + actual authority |
club_signing_key | Option<SigningKey> | Decrypted Ed25519 key (in-memory only) |
pending_lock | Option<Box<dyn Lock>> | Lock awaiting credential response |
login_time | Option<Instant> | When authentication succeeded |
last_activity | Instant | For timeout enforcement |
Every security-relevant event is written to a tamper-evident chained log at data/security.log.*. The log uses SHA-256 chaining: each line's hash incorporates the previous line's hash, forming an unbreakable chain from a random seed file (security.log.seed).
The chained hash makes tampering detectable. Modifying any line invalidates all subsequent chain hashes.
The ChainedLogWriter<W: Write> is a generic writer that transparently adds chain hashes. It wraps any Write target (file, stdout):
impl<W: Write> Write for ChainedLogWriter<W> {
fn write(&mut self, buf: &[u8]) -> io::Result<usize> {
let line = String::from_utf8_lossy(buf);
let chain_input = format!("{}{}", self.prev_hash, line.trim());
let chain_hash = sha256_hex(chain_input.as_bytes());
self.prev_hash = chain_hash.clone();
let chained_line = format!("{} chain={}\n", line.trim(), chain_hash);
self.inner.write_all(chained_line.as_bytes())?;
Ok(buf.len())
}
}
The verify-security-log CLI subcommand walks the entire log from the seed and checks every chain hash:
cargo run --release --features server --bin xudanu-server -- verify-security-log data
The same chain technique is used by the attribution log for tamper-evident provenance recording.
security.log.2026-05-15, security.log.2026-05-16). The chain continues across files: the final hash of one file becomes the seed for the next. The test suite verifies this in multi_file_chain_verification.
The SecurityMonitor (in transport/audit.rs) is the real-time defense layer. It tracks counters per-session and per-IP, escalating threat levels when patterns are detected.
| Event | Severity | Trigger |
|---|---|---|
AuthSuccess | Info | Successful login |
AuthFailure | Warn | Failed login attempt |
PermissionDenied | Warn | Unauthorized operation attempted |
ProtocolViolation | Warn | Malformed message, invalid opcode |
ResourceExhaustion | Error | Server resources depleted |
SuspiciousPattern | Warn | Unusual access pattern detected |
RateLimit | Warn | Rate limit exceeded |
SessionOpened | Info | New WebSocket connection |
SessionClosed | Info | Session ended |
GrabConflict | Warn | Concurrent edit conflict on a work |
StateCorruption | Error | Internal state inconsistency detected |
Each session and IP accumulates a ThreatLevel based on sliding-window counters:
| Level | Meaning | Triggers |
|---|---|---|
Normal | No threats | Baseline |
Elevated | Suspicious activity | A few auth failures or protocol violations |
High | Active attack likely | Repeated violations, rate-limit hits |
Critical | Severe threat | Sustained abuse; session recommended for disconnection |
The monitor uses SlidingCounter — a fixed-window counter that resets after the window expires:
struct SlidingCounter {
count: u32,
window_start: Instant,
window_secs: u64,
}
impl SlidingCounter {
fn bump(&mut self) -> u32 {
if self.window_start.elapsed().as_secs() >= self.window_secs {
self.count = 0; // window expired, reset
self.window_start = Instant::now();
}
self.count += 1;
self.count
}
}
Counters are tracked both per-session and per-IP, giving both granular (this session is malicious) and aggregate (this IP is running a botnet) detection.
The SecurityConfig struct controls all rate-limiting thresholds:
| Parameter | Default | Scope |
|---|---|---|
max_auth_failures_per_minute | 10 | Per-session and per-IP |
max_protocol_violations_per_minute | 20 | Per-session and per-IP |
max_requests_per_second | 100 | Per-session |
max_sessions_per_ip | 50 | Per-IP |
max_permission_denials_per_minute | 30 | Per-session and per-IP |
max_grab_conflicts_per_minute | 15 | Per-session |
| Parameter | Default | Scope |
|---|---|---|
MAX_CLUB_LOGIN_ATTEMPTS | 10 | Per-club |
CLUB_LOGIN_ATTEMPT_WINDOW | 300s (5 min) | Fixed window |
| Backend | Behavior |
|---|---|
TracingAuditLog (production) | Events go to tracing which feeds the ChainedLogWriter writing to security.log |
CollectorAuditLog (testing) | Events stored in-memory Vec<AuditEvent> for test assertions |
Once authenticated, every edit a session makes is cryptographically signed with the club's Ed25519 key. This creates per-span provenance that answers "who wrote this?" at the character level. The full provenance system — signing, verification, the tamper-evident attribution log, transclusion attribution, historical attestation, attestation reports, federation cluster consensus, and W3C PROV-JSON export — is documented in the dedicated guide:
| Aspect | Udanax-Gold | Xudanu |
|---|---|---|
| Identity | Clubs with recursive membership | Clubs with flat membership (nesting planned) |
| Authentication | Password-only | Password (Argon2id) + Ed25519 public key + OAuth2 (GitHub/Google) |
| Email verification | None | BLAKE3-hashed single-use tokens, 24h TTL, pluggable email provider |
| Signing | No cryptographic signing | Ed25519 per-club signing keys (encrypted at rest with Argon2id-derived KEK) |
| Provenance | Club ownership of entire works | Per-span Ed25519-signed attribution (full doc) |
| Locks | Boo, Wall, Challenge, Match, Multi | Same hierarchy, enhanced with modern hash verification |
| Key delegation | Full incorporate/removeLogins | make/merge/incorporate (remove planned) |
| Rate limiting | None | Per-club login limit + per-session/per-IP sliding window monitors |
| CSRF | N/A (no web) | Token-based CSRF on WebSocket upgrade, configurable |
| Audit | No security monitoring | SecurityMonitor (11 event types, 4 threat levels) + tamper-evident SHA-256 chained log |
| Key storage | Plaintext keys in memory | Encrypted signing keys (AES-256-GCM + Argon2id-derived KEK), decrypted only during authenticated sessions |
| File | Path | Key Types / Functions |
|---|---|---|
| Identity model | ||
club.rs | src/server/club.rs | Club, Credential (Password/PublicKey) |
session.rs | src/server/session.rs | SessionId, Session (key_master, signing_key, pending_lock, timeout) |
keymaster.rs | src/server/keymaster.rs | KeyMaster (login_authority, actual_authority) |
lock.rs | src/server/lock.rs | Lock trait, BooLock, WallLock, ChallengeLock, MatchLock, MultiLock |
| Authentication | ||
identity.rs | src/server/identity.rs | login, authenticate_with_pending, login_public, create_personal_club, ClubAttemptTracker, rate limiting, email verification, club rosters, OAuth session creation |
oauth.rs | src/server/transport/oauth.rs | OAuthConfig, OAuthState, OAuthLink, OAuthSession, github_redirect_handler, github_callback_handler, google_redirect_handler, google_callback_handler |
verification/ | src/server/verification/ | VerificationState, VerificationTokenStore (BLAKE3-hashed, single-use, 24h TTL), EmailProvider trait, DevProvider |
| Security audit | ||
audit.rs | src/server/transport/audit.rs | AuditEvent, AuditEventKind (11 variants), AuditLog trait, TracingAuditLog, CollectorAuditLog, ThreatLevel, SecurityConfig, SecurityMonitor, SlidingCounter |
chained_log.rs | src/server/transport/chained_log.rs | ChainedLogWriter<W>, verify_log(), ChainVerifyError |
attribution_log.rs | src/server/transport/attribution_log.rs | AttributionLog, AttributionEntry, verify_attribution_log() (same chain technique, for provenance — see Provenance doc) |
| Crypto | ||
password.rs | src/crypto/password.rs | hash_password (Argon2id PHC), verify_password |
club_keys.rs | src/crypto/club_keys.rs | generate_club_keypair, encrypt_signing_key, decrypt_signing_key |
sign.rs | src/crypto/sign.rs | sign_bytes (Ed25519), verify_signature, generate_signing_key |
keys.rs | src/crypto/keys.rs | ServerIdentity, ServerKeyPair, KeyHistory, hex_encode/decode |