Splintr Security Architecture
Comprehensive documentation of every security layer implemented in the Splintr codebase, verified against the source.
Overview
Splintr is a self-hosted federated social network. Its threat model assumes that each server is independently administered, that servers communicate over the public internet, and that users on one server visit profiles and read content on others. Security is layered: network controls limit who can talk to the server, authentication proves identity, authorization decides what a proven identity can see, cryptographic signing proves content hasn't been tampered with, and encryption protects data at rest. Every layer is designed to degrade gracefully — a missing DNS record falls back to TOFU, a missing signing key still shows the post (unsigned), a federation failure still serves local content.
The codebase enforces these layers across 13 core PHP files, with the primary security surface split between core/federation.php (inter-server trust), core/privacy.php (access control), core/encryption.php (cryptographic operations), core/auth.php (login and sessions), core/csrf.php (cross-site request forgery), and core/plugins.php (third-party sandboxing).
Network & Transport Security
TLS enforcement — Session cookies are set with secure: !empty($_SERVER['HTTPS']), so cookies are HTTPS-only when TLS is available. SSL verification on outbound federation requests is enforced in production; bypass is gated behind SPLINTR_DEV_MODE. Protocol enforcement is intentionally configurable rather than hardcoded, because the network should be able to run over HTTP on private/mesh networks if main infrastructure is disrupted.
Federation CORS (core/federation-cors.php) — Cross-origin requests from browsers are handled with dynamic single-origin echo. The handler reads the request's Origin header, checks whether that hostname exists in remote_servers or remote_users, and echoes back that specific origin in Access-Control-Allow-Origin if it's a known peer. Unknown origins get no CORS headers at all — the browser blocks the response. Wildcard * is never used. OPTIONS preflight requests are handled in PHP, not by Apache rewrite rules.
Federation rate limiting (core/federation.php, checkRateLimit()) — Each remote node is limited to 100 requests per hour. Every inbound federation request is logged to the federation_requests table with the remote node hostname and request type. Requests exceeding the limit are rejected with an exception.
Cookie security (core/session-config.php) — Session cookies are configured with httponly: true (no JavaScript access), samesite: Lax (prevents cross-site POST attacks while allowing top-level navigations for federation handoffs), and lifetime: 0 (browser-session only, no persistent cookies).
Static User-Agent — Outbound federation requests use a fixed Splintr-Federation/1.0 user-agent string. User IP addresses and browser fingerprints are never forwarded to remote servers.
Authentication
Login rate limiting (core/auth.php, checkRateLimit()) — Configurable max attempts and time window (default: 5 attempts per 15 minutes per identifier). Called before every login attempt across all login endpoints.
Password requirements — Minimum 8 characters, at least one letter and one number. Common password rejection list.
CSRF protection (core/csrf.php) — Per-session token stored in $_SESSION['csrf_token'], generated with bin2hex(random_bytes(32)). Emitted as a <meta name="csrf-token"> tag in the page head. JavaScript reads it and sends it as X-CSRF-Token on every API request. Server-side validation via csrf_validate() uses hash_equals() for timing-safe comparison. Origin checking via csrf_check_origin() validates Origin and Referer headers against the server URL. Full gate via csrf_check_request() on all authenticated API endpoints — unauthenticated requests skip the check (no session to protect).
Session fixation prevention — session_regenerate_id(true) is called on successful login, destroying the old session ID and issuing a new one. This prevents session fixation attacks where an attacker sets a known session ID before the victim authenticates.
Error response hygiene — Login failures return generic messages ("Invalid credentials") and never leak whether the username exists, what the password policy violation was, or internal details like exception messages, class names, or file paths.
Federation Security
Standardized signed envelopes (core/federation.php) — All inter-server messages use a standardized envelope: {version, type, requester:{node, server}, timestamp, payload, signature}. The sender signs the envelope (excluding the signature field) with its RSA-2048 private key using openssl_sign() with OPENSSL_ALGO_SHA256. The receiver strips the signature, sorts all keys deterministically (including nested arrays via array_walk_recursive_ksort()), serializes to JSON, and verifies with openssl_verify() against the sender's cached public key.
Timestamp validation — verifyFederationEnvelope() enforces a 5-minute window with 60-second future tolerance. Requests outside this range are rejected, preventing replay attacks where a valid signed message is re-sent later.
Public key caching — Remote server public keys are fetched from /api/federation/public-key on first contact and cached in the remote_servers table with a 24-hour TTL. This is a Trust-On-First-Use (TOFU) model — the first key seen for a server is trusted, and subsequent connections verify against it.
DNS identity verification verifyDnsIdentity() — After verifying the RSA signature, the receiver looks up a _splintr.<domain> TXT record in DNS. Record format: v=splintr1; alg=rsa-sha256; p=<base64-encoded-public-key>. If a DNS record exists and the public key doesn't match the one from the handshake/cache, the request is hard-rejected as a possible impersonation. If no DNS record exists, the system falls back to the TOFU model. This catches scenarios where an attacker presents a valid signature from a different key than the one the real server published.
Single-use federation handoff tokens (core/federation-token.php) — When a user navigates to a remote server, the home server issues a token scoped to the target server's hostname. The remote server verifies it via a server-to-server callback. On successful verification, the token is immediately consumed (invalidateToken()) — it cannot be reused. Tokens also have an expiry timestamp and are rejected if expired. Unscoped tokens (from warmup flows) are rejected if a remote server tries to verify them.
Audience-scoped JWTs (core/federation-jwt.php) — For client-direct federation (browser talks directly to remote servers), the home server issues RS256 JWTs with a 15-minute lifetime. Each token is scoped to a single target server via the aud (audience) claim. The receiving server verifies the signature against the issuing server's public key (already cached for federation) and checks that the audience matches itself. This prevents token replay to other servers.
Transport — All server-to-server requests use HTTPS with certificate verification (disabled only in SPLINTR_DEV_MODE). Modern TLS (1.3, or 1.2 with ECDHE ciphers) negotiates a fresh key for every connection, so a stolen server key can't decrypt recorded traffic. Splintr's RSA server keys only sign requests; they never encrypt anything. Anything that terminates TLS between two servers — such as a CDN in proxy mode — can read the traffic passing through it, including message bodies before the server encrypts them; see "Using Cloudflare" in the installation guide.
Authorization & Privacy
Privacy level hierarchy (core/permissions.php, core/privacy.php) — Six privacy levels ordered from most open to most restrictive: 0 = Public, 1 = Followers (reserved), 2 = Friends of Friends, 3 = Friends Only, 4 = Friend Groups, 5 = Selected Users (no UI yet), 6 = Private. Constants are defined in both core/permissions.php (global) and as class constants in SplintrPrivacy.
Access control gating (SplintrPrivacy::checkAccess()) — Central gatekeeper that maps the viewer's relationship to the content owner against the content's privacy level and returns an access boolean. Called by canViewProfile(), canViewPosts(), canViewFriendsList(), and canViewBookmarks(). Each takes $viewerUserId, $targetUserId, and optionally $viewerRemoteNode for federated viewers.
Friendship validation (areFriends()) — Checks the friendships table for an accepted friendship. Supports both local user IDs and federated remote IDs by joining the remote_users table. The $viewerRemoteNode parameter enables cross-server friendship lookups.
Friends-of-friends federation (FederationPrivacyMesh) — For FoF access checks involving federated users, SplintrPrivacy delegates to FederationPrivacyMesh::checkFederatedFoF() in core/federation-privacy.php. This involves cross-server queries to validate indirect relationships. Results are cached in federation_fof_cache for use in SQL queries where HTTP calls can't run.
Block propagation — blockUser() and unblockUser() don't just update the local database. They call pushBlockNotification(), which delegates to FederationPrivacyMesh to propagate the block across the federation. This ensures a block on one server is respected network-wide.
Per-post privacy — Each post has its own privacy_level column. The user_privacy_settings.posts_privacy value controls only the default for the post editor's privacy selector, not a page-level access gate. Feed queries in getUserPosts() and getUserFeed() filter posts per-row based on the viewer's relationship to each post's author.
Friend group visibility — Posts with privacy_level = 4 use the post_visibility_groups table. Feed queries JOIN through post_visibility_groups and friend_group_memberships to check whether the viewer belongs to any of the post's designated groups.
Batch post security — Federation batch-posts endpoints return friends-only posts only when the requesting server has verified friendships. Public data is returned without friendship verification.
Content Integrity
Per-post Ed25519 signing (core/content.php, core/encryption.php) — When a user creates a post, createPost() retrieves the author's cached Ed25519 signing key from the session via getCachedSigningKey(), then calls signContentWithKey() to produce a detached signature over the raw post content using sodium_crypto_sign_detached(). The base64-encoded signature is stored in the content_signature column and the key record ID in signature_key_id.
Signature verification on read — Every read path calls verifyPostSignature(), which retrieves the author's public key by signature_key_id via getPublicKeyById() and verifies the signature with sodium_crypto_sign_verify_detached(). The result is attached to the post as signature_verified: true (valid), false (tampered), or null (no signature present, or key revoked/deleted).
Key revocation handling — If a signing key has been revoked or deleted, getPublicKeyById() returns null and signature_verified is set to null rather than false. This distinguishes "key unavailable" from "content tampered" — unsigned legacy posts and posts whose author rotated keys are not falsely flagged as tampered.
Content stored as raw text — Post content is stored as raw markdown/plaintext, never pre-rendered HTML. Rendering happens at read time via parseMarkdown(). This means the content that was signed is the content that's verified — there's no ambiguity about whether the signature covers the source or the rendered output.
Encryption
Profile field encryption — Sensitive profile fields (email, real name, phone, date of birth) are encrypted with AES-256-CBC using a key derived from the user's password and a per-user salt combined with the server salt. The encryptData() / decryptData() methods generate a random 16-byte IV per encryption. Email is stored as a salted SHA-256 hash (email_hash) for lookup and as an encrypted blob (encrypted_email) for retrieval — there is no plaintext email column.
At-rest content encryption (core/encryption.php) — A server-side symmetric key stored at keys/content_encryption.key (32-byte hex) is used with AES-256-GCM via sodium_crypto_aead_aes256gcm_encrypt(). The encryptWithContentKey() method generates a random nonce per operation and stores base64(nonce + ciphertext). Currently used for encrypting sensitive values like PFS session keys in the database.
User asymmetric keypairs — Each user has two keypairs: an X25519 (Curve25519) keypair for encryption and an Ed25519 keypair for signing. Private keys are never stored in plaintext. They are wrapped with a key derived from the user's password via Argon2id (sodium_crypto_pwhash() with interactive limits), encrypted using sodium_crypto_secretbox(), and stored as base64(salt + nonce + ciphertext) in the user_keys table.
Messaging encryption — Message bodies are encrypted with a random AES-256-GCM session key via encryptMessageBody(). The session key is then encrypted per-recipient using their X25519 public key. This means only conversation participants can decrypt the message body — the server stores only ciphertext.
Key rotation on password change (rotateUserKeypairWrapping()) — When a user changes their password (knowing the old one), private keys are decrypted with the old password-derived key and re-encrypted with a new one. The keypairs themselves don't change, so existing signatures and encrypted messages remain valid.
Key destruction on password reset (resetUserKeypairs()) — When a user resets their password (forgot old one), private keys are unrecoverable. All existing keys are revoked (is_active = FALSE, revoked_at = NOW()) and new keypairs are generated. Old encrypted messages become permanently unreadable. This is an intentional security property — there is no backdoor recovery mechanism.
Output Encoding & XSS Prevention
Escape-first rendering (core/content.php, parseMarkdown()) — The first operation in parseMarkdown() is htmlspecialchars($value, ENT_QUOTES, 'UTF-8'). Markdown formatting (bold, italic, headings, links) is applied after escaping, so user-supplied HTML is never interpreted. The function produces a limited set of output tags: h1, h2, strong, em, a, br, p, b, i, u.
Client-side sanitization (sanitizeHtml()) — An allowlist-based sanitizer that strips all tags except those produced by parseMarkdown(). This is the final gate for both locally-rendered and federated content. Federated posts arrive as raw markdown and go through a client-side parseMarkdown() (same escape-first logic) before sanitizeHtml().
Display name escaping (escapeHtml()) — All user-controlled text displayed outside of post content (display names, node URLs, media titles) uses escapeHtml() to prevent injection.
Selector injection prevention — CSS.escape() is applied to all post-ID selectors in JavaScript. This prevents injection via malicious node URLs that could contain CSS selector metacharacters (e.g., a node URL with a # or . in it breaking querySelector() calls).
DOM methods over innerHTML — The codebase uses createElement(), appendChild(), and textContent for constructing dynamic UI elements rather than innerHTML. This prevents accidental HTML injection and avoids Windows Defender false positives triggered by innerHTML patterns that resemble phishing overlay construction.
Rendering pipeline for federation — Local posts are rendered server-side by parseMarkdown() in getUserFeed(). Federated posts are rendered client-side using a ported version of the same escape-first logic, conditioned on post.source_type === 'federated'. Remote servers never send pre-rendered HTML — only raw markdown.
Plugin Sandboxing
Iframe isolation (templates/content/plugins.php) — Plugins run inside an <iframe> with sandbox="allow-scripts allow-top-navigation-by-user-activation". This means plugin code can execute JavaScript and navigate the top frame only on user gesture, but cannot access the parent page's DOM, cookies, localStorage, or same-origin resources. The plugin is effectively cross-origin isolated even though it's served from the same domain.
PostMessage bridge — Plugins communicate with the host page exclusively through postMessage. The bridge handler in plugins.php accepts only messages of type splintr_plugin_request from the plugin iframe's contentWindow (source-checked via event.source !== iframe.contentWindow). Each request includes a requestId, action, and data. The bridge forwards valid requests to /api/plugins with the CSRF token and returns results back to the iframe.
Client-side permission check — Before forwarding a request to the server, the bridge checks the action against a permission map. Each action maps to a required manifest permission (e.g., score.submit requires leaderboard, friends.list requires friends_list). Unknown actions are rejected immediately. Actions where scope is determined server-side (like data.save) and always-allowed actions (theme.get) pass through.
Server-side permission enforcement (api/plugins.php) — Every action handler calls $plugins->hasPermission($plugin, $requiredPerm) before processing. This is the authoritative check — the client-side check is a fast-fail optimization, not a trust boundary.
Manifest validation (core/plugins.php, validateManifest()) — Plugin manifests must declare an id (lowercase alphanumeric + hyphens, 3–50 characters), name, version, entry file, and a permissions array. Permissions are validated against an allowlist: user_info, data_user, data_shared, leaderboard, events, friends_list, matchmaking. Unknown permissions are rejected.
File integrity verification (verifyChecksums()) — Plugin manifests include a checksums object mapping file paths to sha256:<hex> hashes. On install, every file listed is verified against its declared hash. Missing files and hash mismatches are reported as errors.
Data storage quotas — Plugin data storage is capped per scope: user scope allows 10 keys and 256 KB total, shared scope allows 100 keys and 1 MB total. Keys are validated with a strict regex (/^[a-zA-Z0-9._:-]+$/, max 200 characters). Values must be JSON-serializable. Size and key count are checked on every write, rejecting operations that would exceed limits.
Federated player identity — Federated visitors in plugins are identified by their remote_users.id, resolved via resolvePlayer(). If no remote_users row exists for the visitor, the resolver returns remote_user_id: null and the API layer rejects operations that require an identity. Plugin tables use the same dual-FK pattern as friendships: user_id for local users, remote_user_id for federated users, never both.
Theme injection — For theme-aware plugins, the host page injects CSS variables via postMessage (type splintr_theme_inject). The plugin receives pre-mapped --splintr-* variables. No host-page DOM access or style computation happens inside the sandbox.
App Gateway
Bearer token authentication (api/app-gateway.php) — External API access requires both an identity header (X-App-Username or X-App-Node) and a Bearer token in the Authorization header. The token is SHA-256 hashed and looked up in the api_tokens table. Critically, the identity claim must match the token owner — a valid token presented with the wrong username is rejected as a mismatch. This prevents token theft from granting access under a different identity.
Access mode gating — Server-wide API access is controlled by the api_access_mode setting in server_config: disabled (no API access), bot_only (default — only is_bot_account users), approved (bots + users with can_use_api = TRUE), Bot accounts always have access unless the gateway is fully disabled.
Per-endpoint permission checks — Each gateway endpoint checks a granular permission key against the app_gateway_permissions table. Disabled permissions return 403 with a security log entry naming the denied key and the requesting user.
Usage logging — Every gateway request is logged to app_gateway_usage with user ID, action, endpoint, response time, success/failure, error message, and client IP. This provides a full audit trail for API usage.
Security event logging — Authentication failures (missing token, invalid token, identity mismatch) are logged via splintr_security_log() with the claimed identity and client IP.
Account Delegation
Delegation table (account_delegates) — Many-to-many relationship between accounts and their delegates. Each row links an account_id (the account being controlled) to a delegate_user_id (the person granted access), with a role determining their permissions. Same-server only.
Role-based access — Three broad permission buckets: owner (full control: post, delete, manage settings, manage other delegates, delete account — assigned automatically to the account creator, multiple owners allowed), admin (can post, delete posts, accept friend requests, manage settings — cannot manage delegates or delete the account), contributor (can create posts optionally requiring approval, can comment and react — cannot delete others' posts, change settings, or manage friends).
Content approval workflow — Per-delegation requires_approval flag. When enabled, a contributor's posts enter a pending state until an owner or admin approves them. Approval/rejection notifications go to all owners and admins via the user_notifications table and display in the right sidebar. When a post is approved or rejected, the pending notifications are removed from other owners/admins who haven't acted on it.
Account switching — $_SESSION['splintr_user_id'] tracks the authenticated person (never changes), $_SESSION['splintr_acting_as'] tracks which account they're currently operating as (null = their own). The switcher lives in the user section at the bottom of the left sidebar, showing the current account's avatar and a dropdown of delegated accounts. Switching updates acting_as and reloads — no re-authentication. Every action checks the delegation table to verify the real user has a role permitting that action on the acting-as account.
Federation restriction — An account owner or admin can disable federation for the account regardless of account type.
Memory Safety & Key Hygiene
sodium\_memzero after use (core/encryption.php) — Every method that handles raw private key material calls sodium_memzero() on the key bytes after use. This applies to: generateUserKeypairs() (zeroes wrapping key, both private keys, and both raw keypairs), decryptPrivateKey() (zeroes wrapping key), rotateUserKeypairWrapping() (zeroes private keys and new wrapping key), encryptWithContentKey() / decryptWithContentKey() (zeroes content key in both success and failure paths), and PFS ephemeral key handling in core/federation.php. The caller of signContentWithKey() in createPost() also zeroes the signing key after use.
Session-encrypted key caching — Decrypted private keys are cached in $_SESSION for the duration of the login session, but not as plaintext. A random session-specific key is generated, the private key is encrypted with it using sodium_crypto_secretbox(), and both the encrypted blob and the session key are stored. This pattern applies to both the encryption key (cachePrivateKeyInSession()) and the signing key (cacheSigningKeyInSession()). On logout, clearCachedPrivateKey() and clearCachedSigningKey() remove all session entries.
No plaintext key storage — Private keys are never stored in plaintext anywhere: not in the database (wrapped with Argon2id-derived key), not in the session (re-encrypted with session-specific key), not in memory longer than necessary (zeroed after use). The content encryption key is stored on the filesystem in keys/content_encryption.key (hex-encoded, protected by filesystem permissions, excluded from deploys).
Key versioning — The user_keys table tracks key_version per user and key type, with is_active and revoked_at fields. Old keys are kept (inactive) so that historical signatures can still be verified against the correct public key via getPublicKeyById().
Audit & Logging
Security event log (core/error-logger.php, splintr_security_log()) — Dedicated security logging function used for authentication failures, CSRF violations, gateway permission denials, and identity mismatches. Writes to a separate security log file distinct from the general application log.
CSRF violation logging (core/csrf.php) — Both origin mismatches and token validation failures are logged with context: the request path, the mismatched origin, or the token failure detail (missing vs. invalid). These logs surface attempted cross-site attacks.
Admin action audit trail (core/adminlog.php) — Administrative actions (user management, plugin install/uninstall, configuration changes) are logged via SplintrAdminLog::logAction() with the admin user ID, action name, data payload, and optionally the target user ID. Viewable in the admin panel.
Federation request tracking — Every inbound federation request is logged to the federation_requests table as part of rate limiting. This provides a historical record of which remote nodes have communicated with the server and at what frequency.
App Gateway usage tracking — Every API request through the App Gateway is logged to app_gateway_usage with action, endpoint, response time, success/failure, error message, and client IP. This enables both auditing and performance monitoring of external API consumers.
General application logging (splintr_log()) — Configurable log levels (DEBUG, INFO, WARNING, ERROR). Debug output (including display_errors and debug fields in error responses) is gated behind SPLINTR_DEV_MODE. Production error responses never include exception messages, class names, or file paths.