Identity & Authentication

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.

1. Clubs — Identity and Permission Units 2. Credentials 3. The Lock Hierarchy 4. KeyMaster — Authority Tracking 5. The Authentication Flow 6. Rate Limiting & Brute-Force Protection 7. CSRF Protection 8. OAuth2: GitHub & Google Login 9. Email Verification 10. Session Lifecycle 11. The Security Audit Chain 12. SecurityMonitor & Threat Detection 13. Security Configuration Reference 14. Provenance & Attribution 15. Udanax-Gold Comparison 16. Source Code Map

1. Clubs — Identity and Permission Units

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.

Club structure

FieldTypePurpose
be_idBeIdUnique identifier
nameOption<String>Human-readable name ("alice", "editors")
membersHashSet<BeId>Member clubs (for group clubs)
credentialOption<Credential>Password or public key for authentication
is_personalboolTrue for individual user clubs
encrypted_signing_keyOption<EncryptedSigningKey>Per-club Ed25519 signing key, encrypted with the club's password
signature_clubOption<BeId>The owning club (usually the user's personal club)
sponsored_worksHashSet<BeId>Works vouched for by this club
default_read_clubOption<BeId>Default read-access club for new works
default_edit_clubOption<BeId>Default edit-access club for new works
emailOption<String>Email for verification (personal clubs only)
verifiedboolWhether email has been verified

Club types

Personal Club

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.

Group Club

A named collection of member clubs. Used for team permissions: "editors", "reviewers", "public". Members inherit the group's access rights.

Public Club

Special club for anonymous/guest access. Users who haven't signed in operate under the public club. Their edits are attributed to "anonymous".

Signature Club

Internal club that holds signing keys. Every personal club has a signature club that owns its Ed25519 keypair.

Udanax-Gold origin: Clubs are directly from the original C++ codebase. Gold had a Club class with recursive membership (clubs containing clubs). Xudanu currently implements flat membership (no nesting) but retains the sponsorship concept for work vouching.

System clubs

Every server starts with four system clubs created at initialization:

ClubPurpose
public_clubAnonymous/guest access. All unauthenticated sessions operate under this club.
admin_clubServer administration. Members can accept/reject connections, run preflight, manage other clubs.
access_clubUsed for general access control internally.
empty_clubThe empty set — no authority. Used as a sentinel.

2. Credentials

A club can have one of two credential types:

Password

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.

Public Key

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
    }
}
Key point: The signing key is encrypted at rest with AES-256-GCM, using an Argon2id-derived key from the password. The server only holds the decrypted signing key during the session (in-memory). When the password changes, the signing key is re-encrypted under the new password.

3. The Lock Hierarchy

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:

trait Lock try_open() → KeyMaster BooLock Open access WallLock Permanently locked ChallengeLock Password challenge MatchLock Credential match MultiLock { locks: Vec<Box<dyn Lock>> }

The Lock hierarchy. Each lock type implements the Lock trait. MultiLock composes multiple locks (OR semantics).

Lock types

LockBehaviorUse case
BooLockAlways opens with LockCredential::BooOpen/public access, anonymous login
WallLockNever opens — always returns LockFailedPermanently locked accounts, disabled users, clubs with no credential
ChallengeLockRequires correct password responseStandard password authentication
MatchLockMatches stored credential (password hash or public key)Username/password login, key-based auth
MultiLockComposes multiple locks; opens if ANY child opensMulti-factor: accept either password OR key

4. KeyMaster — Authority Tracking

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.

Two kinds of authority

Authority typeSourceUsed for
login_authorityDirect lock opening (password/key match)Acting as this club, editing, managing
actual_authorityLogin authority + membership-derived authorityAll 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.

Signature authority

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.

Udanax-Gold origin: The original C++ KeyMaster had incorporate() (merge authority) and removeLogins() (revoke). Xudanu implements make(), merge(), and incorporate_authority() but not full revocation.

5. The Authentication Flow

Authentication is a two-step challenge-response protocol. The client first requests a lock (step 1), then submits credentials to open it (step 2):

Client Server 1. login(club_id) "I want to authenticate as this club" 2. Lock (challenge) MatchLock or ChallengeLock stored as pending 3. authenticate_with_pending(credential) "Here's my password / signature" Rate limit check MAX_CLUB_LOGIN_ATTEMPTS = 10 window = 5 minutes Per-club, not per-IP 4a. KeyMaster (success) + decrypted signing key in session 4b. Error (failure) attempt_count++, logged as SECURITY:login_failed

The two-step authentication flow. The server stores the lock as "pending" on the session between steps 1 and 2.

What happens on success

  1. The KeyMaster is created with authority for the club
  2. update_authority() expands to include group memberships
  3. If login was via password, the club's encrypted signing key is decrypted and stored in the session (in-memory only)
  4. The rate-limit counter is cleared
  5. The event is logged as SECURITY:login_succeeded

Public login (anonymous)

For 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)
}

6. Rate Limiting & Brute-Force Protection

Xudanu implements per-club rate limiting for login attempts. This is deliberately per-club (not per-IP) because:

ParameterDefaultCode constant
Max attempts per club10MAX_CLUB_LOGIN_ATTEMPTS
Window duration5 minutes (300s)CLUB_LOGIN_ATTEMPT_WINDOW
TrackingPer-club ClubAttemptTrackerHashMap<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()
        ));
    }
}
Rate limit resets on success. A successful login clears the counter (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.

7. CSRF Protection

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:

ComponentImplementation
Token generation32 random bytes via OsRng, hex-encoded
StorageIn-memory HashSet<String>, capped at MAX_CSRF_TOKENS = 10,000
DeliveryClient fetches from GET /csrf-token before connecting
ValidationToken must be present in the ?csrf_token= query param on WS upgrade
Toggle--csrf-token CLI flag; can be disabled for development
Production guidance: CSRF protection should be enabled in any deployment where the server is accessible from a browser. The allowed_origins list in AppState provides an additional layer, checking the Origin header on WS upgrades.

8. OAuth2: GitHub & Google Login

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.

Browser Xudanu GitHub/Google click "Sign in" 1. redirect + state token 2. "Authorize Xudanu?" redirect back 3. ?code=...&state=... 4. validate_state(token) exchange code for access token fetch user profile 5. find_link or create club + session 6. session cookie

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.

OAuth state management

StructurePurposeTTL
pending_statesMaps state tokens to timestamps; prevents replay5 minutes (300s)
linksMaps (provider, provider_user_id) to club_id; persists across sessionsUntil server restart
sessionsOAuth session tokens with club_id and display name30 days

Account linking

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.

CLI flags

--github-client-id <id>       --github-client-secret <secret>
--google-client-id <id>       --google-client-secret <secret>
--oauth-redirect-base <url>

9. Email Verification

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.

The flow

  1. Signup: Client creates a personal club and sets an email via club_set_email()
  2. Issue: 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 token
  3. Deliver: The EmailProvider sends a verification URL (https://server/verify?token=...) to the email address
  4. Redeem: The user clicks the link; redeem(raw_token) hashes it, looks up the pending entry, checks expiry, atomically removes it (single-use), and returns (club_id, email)
  5. Mark verified: mark_club_verified(club_id) sets the club's verified flag
Token security: The raw token is returned exactly once (at issue time) and never persisted. The store keeps only the BLAKE3 hash. This means tokens can't be stolen from the server's memory or disk — only the user who receives the email link can redeem them. Expired tokens are cleaned up by sweep_expired().

Email providers

ProviderBehavior
DevProvider (default)Logs the verification URL to the server console — fully testable without SMTP infrastructure
Future: SMTP providerThe EmailProvider trait (fn send_verification(&self, to_email, url)) supports plugging in a real SMTP transport

Token parameters

ParameterValue
TTL24 hours (TOKEN_TTL_SECS = 86400)
Hash algorithmBLAKE3
Token entropy256 bits (32 bytes from OsRng)
Single-useYes — token is removed on successful redemption
PersistenceIn-memory (tokens issued before a restart are lost; users re-request)

10. Session Lifecycle

  1. Connect: WebSocket connection established. A Session is created with a unique SessionId. Default timeout: 1 hour (DEFAULT_SESSION_TIMEOUT = 3600s).
  2. Authenticate: Client calls login(club_id) then authenticate_with_pending(credential). On success, the session gains KeyMaster authority and (if password login) the decrypted Ed25519 signing key.
  3. Author identity: For CRDT editing, the session registers an OtreeAuthorIdentity (public key, display name, club ID) for attribution.
  4. Operate: All subsequent operations are performed under the session's authority. The SecurityMonitor tracks request rate, violations, and permission denials.
  5. Disconnect / timeout: Session is cleaned up. Awareness state is removed from CRDT. Active edits are preserved (auto-saved via CRDT materialization). The signing key is dropped from memory.

Session fields

FieldTypePurpose
key_masterOption<KeyMaster>Login + actual authority
club_signing_keyOption<SigningKey>Decrypted Ed25519 key (in-memory only)
pending_lockOption<Box<dyn Lock>>Lock awaiting credential response
login_timeOption<Instant>When authentication succeeded
last_activityInstantFor timeout enforcement

11. The Security Audit Chain

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).

security.log.seed AuthSuccess session=42 ip=10.0.0.1 chain=hash(s+e0) AuthFailure session=43 ip=10.0.0.2 chain=hash(c1+e1) TAMPERED: session=99 MISMATCH! Changing any field in Entry 3 breaks the chain at that point

The chained hash makes tampering detectable. Modifying any line invalidates all subsequent chain hashes.

The ChainedLogWriter

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())
    }
}

Verification

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.

Multi-file chains: Logs can span multiple files (e.g., 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.

12. SecurityMonitor & Threat Detection

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.

Audit event types

EventSeverityTrigger
AuthSuccessInfoSuccessful login
AuthFailureWarnFailed login attempt
PermissionDeniedWarnUnauthorized operation attempted
ProtocolViolationWarnMalformed message, invalid opcode
ResourceExhaustionErrorServer resources depleted
SuspiciousPatternWarnUnusual access pattern detected
RateLimitWarnRate limit exceeded
SessionOpenedInfoNew WebSocket connection
SessionClosedInfoSession ended
GrabConflictWarnConcurrent edit conflict on a work
StateCorruptionErrorInternal state inconsistency detected

Threat levels

Each session and IP accumulates a ThreatLevel based on sliding-window counters:

LevelMeaningTriggers
NormalNo threatsBaseline
ElevatedSuspicious activityA few auth failures or protocol violations
HighActive attack likelyRepeated violations, rate-limit hits
CriticalSevere threatSustained abuse; session recommended for disconnection

Sliding window counters

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.

13. Security Configuration Reference

The SecurityConfig struct controls all rate-limiting thresholds:

ParameterDefaultScope
max_auth_failures_per_minute10Per-session and per-IP
max_protocol_violations_per_minute20Per-session and per-IP
max_requests_per_second100Per-session
max_sessions_per_ip50Per-IP
max_permission_denials_per_minute30Per-session and per-IP
max_grab_conflicts_per_minute15Per-session

Club login rate limiting (separate)

ParameterDefaultScope
MAX_CLUB_LOGIN_ATTEMPTS10Per-club
CLUB_LOGIN_ATTEMPT_WINDOW300s (5 min)Fixed window

Audit log backends

BackendBehavior
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:

See: Provenance & Cryptographic Attribution — the complete deep dive (23 sections, 7 diagrams).

15. Udanax-Gold Comparison

AspectUdanax-GoldXudanu
IdentityClubs with recursive membershipClubs with flat membership (nesting planned)
AuthenticationPassword-onlyPassword (Argon2id) + Ed25519 public key + OAuth2 (GitHub/Google)
Email verificationNoneBLAKE3-hashed single-use tokens, 24h TTL, pluggable email provider
SigningNo cryptographic signingEd25519 per-club signing keys (encrypted at rest with Argon2id-derived KEK)
ProvenanceClub ownership of entire worksPer-span Ed25519-signed attribution (full doc)
LocksBoo, Wall, Challenge, Match, MultiSame hierarchy, enhanced with modern hash verification
Key delegationFull incorporate/removeLoginsmake/merge/incorporate (remove planned)
Rate limitingNonePer-club login limit + per-session/per-IP sliding window monitors
CSRFN/A (no web)Token-based CSRF on WebSocket upgrade, configurable
AuditNo security monitoringSecurityMonitor (11 event types, 4 threat levels) + tamper-evident SHA-256 chained log
Key storagePlaintext keys in memoryEncrypted signing keys (AES-256-GCM + Argon2id-derived KEK), decrypted only during authenticated sessions

16. Source Code Map

FilePathKey Types / Functions
Identity model
club.rssrc/server/club.rsClub, Credential (Password/PublicKey)
session.rssrc/server/session.rsSessionId, Session (key_master, signing_key, pending_lock, timeout)
keymaster.rssrc/server/keymaster.rsKeyMaster (login_authority, actual_authority)
lock.rssrc/server/lock.rsLock trait, BooLock, WallLock, ChallengeLock, MatchLock, MultiLock
Authentication
identity.rssrc/server/identity.rslogin, authenticate_with_pending, login_public, create_personal_club, ClubAttemptTracker, rate limiting, email verification, club rosters, OAuth session creation
oauth.rssrc/server/transport/oauth.rsOAuthConfig, 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.rssrc/server/transport/audit.rsAuditEvent, AuditEventKind (11 variants), AuditLog trait, TracingAuditLog, CollectorAuditLog, ThreatLevel, SecurityConfig, SecurityMonitor, SlidingCounter
chained_log.rssrc/server/transport/chained_log.rsChainedLogWriter<W>, verify_log(), ChainVerifyError
attribution_log.rssrc/server/transport/attribution_log.rsAttributionLog, AttributionEntry, verify_attribution_log() (same chain technique, for provenance — see Provenance doc)
Crypto
password.rssrc/crypto/password.rshash_password (Argon2id PHC), verify_password
club_keys.rssrc/crypto/club_keys.rsgenerate_club_keypair, encrypt_signing_key, decrypt_signing_key
sign.rssrc/crypto/sign.rssign_bytes (Ed25519), verify_signature, generate_signing_key
keys.rssrc/crypto/keys.rsServerIdentity, ServerKeyPair, KeyHistory, hex_encode/decode