We take security vulnerabilities seriously. If you discover a security issue, please report it responsibly.
Email: [email protected]
sequenceDiagram
participant User
participant PAM as PAM Module
participant LLNG as LLNG Portal
User->>PAM: One-time token
PAM->>LLNG: POST /pam/verify
LLNG-->>PAM: User attributes + authorization
Note over LLNG: Token consumed (single-use)
PAM-->>User: Session established
- User provides a one-time token generated by the LLNG portal
- PAM module verifies token via
/pam/verifyendpoint - Token is consumed (single-use) and cannot be replayed
- Server returns user attributes and authorization status
| Setting | Default | Description |
|---|---|---|
min_tls_version |
13 (TLS 1.3) | Minimum TLS version (12=1.2, 13=1.3) |
verify_ssl |
true | Verify server certificate |
ca_cert |
system | Custom CA certificate path |
cert_pin |
none | Certificate pin (sha256//base64 format) |
Certificate Pinning: When configured, the module validates the server's public key against the pinned value, preventing MITM attacks even with compromised CAs.
# Example configuration
min_tls_version = 13
cert_pin = sha256//AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=When request_signing_secret is configured, requests include:
X-Timestamp: Unix timestamp (server should reject if too old)X-Nonce: Uniquetimestamp_ms-uuidformat (server should reject duplicates)X-Signature-256: HMAC-SHA256 signature of the request
This provides defense-in-depth against request tampering, even if TLS is somehow compromised.
The PAM module authenticates to the LLNG server using:
| Setting | Description |
|---|---|
server_token_file |
Path to file containing server bearer token |
server_group |
Server group name (default: "default") |
token_rotate_refresh |
Automatically rotate refresh tokens (default: true) |
The server token should be stored in a file with restricted permissions (0600) owned by root.
For OAuth2 token introspection and refresh operations, the module uses JWT Client Assertion (RFC 7523) instead of HTTP Basic Authentication. This provides enhanced security:
- The
client_secretis never transmitted over the network - Each request includes a unique JWT signed with HMAC-SHA256
- JWT contains:
iss,sub,aud,exp,iat, and uniquejti(UUID v4) - JWT validity is 5 minutes to prevent replay attacks
When token_rotate_refresh = true (default), the module automatically rotates the refresh token after each successful token refresh. This limits the window of opportunity if a token is compromised, as stolen tokens become invalid after the next legitimate use.
In bastion/backend architectures, the PAM module supports cryptographic verification that SSH connections to backends originate from authorized bastion servers.
flowchart LR
subgraph Bastion["Bastion Server"]
proxy["llng-ssh-proxy"]
end
subgraph LLNG["LLNG Portal"]
bastion_token["/pam/bastion-token"]
jwks["/.well-known/jwks.json"]
end
subgraph Backend["Backend Server"]
pam["pam_llng.so"]
cache["JWKS Cache"]
end
proxy -->|1. Request JWT| bastion_token
bastion_token -->|2. Signed JWT| proxy
proxy -->|3. SSH + JWT| pam
pam -->|4. Get public keys| jwks
jwks -->|5. Cache keys| cache
pam -->|6. Verify signature| cache
| Threat | Without Bastion JWT | With Bastion JWT |
|---|---|---|
| Direct backend access | Possible if network accessible | Blocked (no valid JWT) |
| VPN bypass to backend | Possible | Blocked |
| Firewall misconfiguration | Exposes backends | Backends still protected |
| Compromised bastion keys | Access to backends | Each hop still verified |
# /etc/security/pam_llng.conf
bastion_jwt_required = true
bastion_jwt_issuer = https://auth.example.com
bastion_jwt_jwks_url = https://auth.example.com/.well-known/jwks.json
bastion_jwt_jwks_cache = /var/cache/pam_llng/jwks.json
bastion_jwt_cache_ttl = 3600
bastion_jwt_clock_skew = 60
# Optional: restrict to specific bastions
bastion_jwt_allowed_bastions = bastion-01,bastion-02# /etc/ssh/sshd_config
AcceptEnv LLNG_BASTION_JWT| Claim | Description |
|---|---|
iss |
LLNG portal URL (must match bastion_jwt_issuer) |
sub |
Username being proxied |
aud |
pam:bastion-backend |
exp |
Expiration timestamp (short-lived) |
bastion_id |
Identifier of the bastion server |
bastion_group |
Server group of the bastion |
target_host |
Target backend hostname |
user_groups |
User's LLNG groups |
The JWKS cache enables JWT verification without network access to LLNG:
- First connection fetches JWKS from LLNG portal
- Public keys cached locally with configurable TTL
- Subsequent verifications use cached keys
- Cache refreshed when TTL expires or unknown key ID encountered
This provides resilience against LLNG outages while maintaining security.
When cache_encrypted = true (default), cached tokens are encrypted using:
- Algorithm: AES-256-GCM (authenticated encryption)
- Key Derivation: PBKDF2-SHA256 with 100,000 iterations
- Key Source: Machine ID (
/etc/machine-id) + cache username as salt - Authentication: GCM tag prevents tampering
File format:
[Plaintext: "expires_at\n"][Magic: LLNGCACHE02][IV: 12 bytes][Tag: 16 bytes][Ciphertext]
The plaintext timestamp header allows quick expiration checks without decryption (performance optimization). However, the timestamp is duplicated inside the encrypted payload for integrity verification. If an attacker modifies the plaintext header to extend cache validity, the mismatch with the encrypted timestamp causes immediate rejection and cache file deletion.
- Each user's cache is stored in a separate file
- File permissions: 0600 (owner read/write only)
- Directory permissions: 0700
When cache_invalidate_on_logout = true (default):
- User's cache is cleared when their PAM session closes
- Prevents stale tokens from being reused
| Service Type | Default TTL |
|---|---|
| Normal services | 300 seconds |
| High-risk services | 60 seconds |
Configure high-risk services via high_risk_services (comma-separated).
Protection against brute-force attacks:
| Setting | Default | Description |
|---|---|---|
rate_limit_enabled |
true | Enable rate limiting |
rate_limit_max_attempts |
5 | Failures before lockout |
rate_limit_initial_lockout |
30s | Initial lockout duration |
rate_limit_max_lockout |
3600s | Maximum lockout duration |
rate_limit_backoff_mult |
2.0 | Exponential backoff multiplier |
Lockout state is stored per-user in rate_limit_state_dir.
When create_user_enabled = true, users can be automatically created on first login.
All paths are validated before use:
Shell Validation (approved_shells):
- Must be in approved list (default: common shells like /bin/bash, /bin/zsh)
- Must be absolute path
- No path traversal sequences (.., //)
- No shell metacharacters
Home Directory Validation (approved_home_prefixes):
- Must start with approved prefix (default: /home, /var/home)
- Same safety checks as shell
Skeleton Directory Validation:
- Must be absolute path
- Must be owned by root
- No symlinks in path components
- No dangerous patterns
- UIDs are generated deterministically from username hash
- Range: 10000-60000 (configurable)
- Collision handling: If UID exists, operation fails safely (returns 0)
- No fallback to random UIDs that could cause unpredictable behavior
The NSS module (libnss_llng.so) provides user resolution:
- Buffer overflow protection: All string copies use bounds-checked
safe_strcpy() - Server input validation: Shell and home paths from server are validated against approved lists
- UID range enforcement: Server-provided UIDs must be within configured min_uid/max_uid range
- Fail-safe: Returns appropriate error codes on any failure; invalid paths fall back to defaults
User accounts are created by directly writing to /etc/passwd and /etc/shadow rather than using
external tools like useradd. This design choice was made for:
Advantages:
- Portability: No dependency on
useraddwhich may not exist or have different options across distributions - Atomicity: Single-process control over file locking ensures consistent state
- Predictability: No external tool behavior variations or unexpected prompts
Trade-offs:
- PAM account creation hooks are not triggered (this module IS the PAM hook)
- SELinux contexts must be handled separately if required
- System audit logs only see file modifications, not semantic "user created" events
Mitigations:
- The module emits its own structured audit events when
audit_enabled = true - File operations use exclusive locks (
flock) to prevent race conditions - If
/etc/shadowwrite fails after/etc/passwdsucceeds, rollback is attempted viauserdel - TOCTOU protection: user existence is re-checked after acquiring locks
When audit_enabled = true:
| Setting | Default | Description |
|---|---|---|
audit_log_file |
none | JSON audit log file path |
audit_to_syslog |
true | Also emit to syslog |
audit_level |
1 | 0=critical, 1=auth events, 2=all |
Audit events include:
- Authentication attempts (success/failure)
- Authorization decisions
- Rate limit triggers
- User creation events
For real-time security monitoring:
| Setting | Description |
|---|---|
notify_enabled |
Enable webhooks |
notify_url |
Webhook endpoint URL |
notify_secret |
HMAC secret for webhook signatures |
| Setting | Default | Description |
|---|---|---|
secrets_encrypted |
true | Encrypt secrets at rest |
secrets_use_keyring |
true | Use kernel keyring |
secrets_keyring_name |
"pam_llng" | Keyring identifier |
Recommended permissions:
| File | Permissions | Owner |
|---|---|---|
/etc/pam_llng.conf |
0600 | root |
| Server token file | 0600 | root |
| Cache directory | 0700 | root |
| Rate limit state dir | 0700 | root |
CRITICAL: Never enable debug logging in production environments.
When log_level = debug, the module may log sensitive information to syslog:
- SSH certificate metadata (key_id, serial, principals)
- Token validation details
- Authorization request parameters
Risk: If debug logs are captured by a log aggregator or accessed by unauthorized users, this information could be used to:
- Identify infrastructure topology
- Track user movements across systems
- Correlate sessions for targeting
Recommendation:
- Use
log_level = warnorlog_level = errorin production - If debug logging is temporarily needed, ensure syslog access is restricted
- Rotate and purge logs containing debug output promptly
The encryption key for cached tokens and secrets is derived from /etc/machine-id.
Impact of machine-id change:
- All cached tokens become unreadable (automatic re-authentication required)
- Encrypted secrets in the secret store become permanently unrecoverable
- Server enrollment tokens must be re-issued
Scenarios causing machine-id change:
- VM cloning without regenerating machine-id
- System reinstallation
- Container image reuse across hosts
- Some cloud provider instance recreation
Recommendations:
- Document machine-id stability as a deployment requirement
- Before system migration: Backup enrollment tokens or plan for re-enrollment
- VM cloning: Always regenerate machine-id (
systemd-machine-id-setup) and re-enroll - Monitoring: Alert on machine-id changes via configuration management
Re-enrollment procedure after machine-id change:
# 1. The old token file is now unusable - remove it
rm /etc/security/pam_llng.token
# 2. Re-run enrollment
llng-pam-enroll --portal https://auth.example.com --client-id pam-accessService accounts (ansible, backup, deploy, etc.) are local accounts that authenticate via SSH key only, bypassing OIDC authentication. They are defined in a local configuration file.
| Requirement | Description |
|---|---|
| Ownership | Must be owned by root (uid 0) |
| Permissions | Must be 0600 (owner read/write only) |
| Symlinks | File must not be a symlink (O_NOFOLLOW) |
| Location | /etc/open-bastion/service-accounts.conf (configurable) |
Service accounts are validated against the same security rules as regular users:
| Field | Validation |
|---|---|
name |
Lowercase letters, digits, underscore, hyphen; max 32 chars |
key_fingerprint |
Must start with SHA256: or MD5:, valid base64 chars only |
shell |
Must be in approved_shells list |
home |
Must match approved_home_prefixes |
uid/gid |
Must be in valid range (0-65534) |
Important: The SSH server must have ExposeAuthInfo yes in /etc/ssh/sshd_config:
# /etc/ssh/sshd_config
ExposeAuthInfo yesThis setting allows the PAM module to access the SSH key fingerprint via the SSH_USER_AUTH
environment variable, which is required for fingerprint validation.
sequenceDiagram
participant SA as Service Account
participant SSH as SSH Server
participant PAM as PAM Module
SA->>SSH: SSH key authentication
SSH->>PAM: pam_sm_authenticate
Note over SSH: ExposeAuthInfo provides<br/>SSH_USER_AUTH with fingerprint
PAM->>PAM: Extract fingerprint from SSH_USER_AUTH
PAM->>PAM: Check service_accounts.conf
PAM->>PAM: Validate fingerprint matches config
Note over PAM: Fingerprint OK = authorized
PAM-->>SSH: PAM_SUCCESS
SSH-->>SA: Session established
- Service account connects via SSH with its configured key
- SSH server exposes key fingerprint via
SSH_USER_AUTH(requiresExposeAuthInfo yes) - PAM module extracts fingerprint and checks if user is in
service_accounts.conf - PAM module validates that the SSH key fingerprint matches the configured value
- If fingerprint matches, account is authorized locally (no LLNG call needed)
- sudo permissions are checked from the same configuration file
| Feature | Benefit |
|---|---|
| Local configuration | No network dependency for service accounts |
| Per-server control | Each server explicitly lists allowed service accounts |
| SSH key binding | Fingerprint validation prevents key substitution |
| Audit logging | All service account access is logged |
| sudo control | Fine-grained sudo permissions per account |
| Limitation | Mitigation |
|---|---|
| No centralized management | Use configuration management (Ansible, Puppet) |
| Manual key rotation | Implement key rotation procedures |
| Local file dependency | Monitor file integrity with AIDE/Tripwire |
[ansible]
key_fingerprint = SHA256:abc123def456
sudo_allowed = true
sudo_nopasswd = true
gecos = Ansible Automation
shell = /bin/bash
home = /var/lib/ansibleThe offline cache enables Desktop SSO authentication when the LLNG server is unreachable. This section describes the security architecture and considerations.
Password Hashing (Argon2id):
| Parameter | Value | Rationale |
|---|---|---|
| Memory cost | 64 MiB | Prevents GPU/ASIC attacks |
| Iterations | 3 | Balance of security and latency |
| Parallelism | 4 | Utilizes multi-core CPUs |
| Hash length | 32 bytes | 256-bit output |
| Salt length | 16 bytes | Unique per user, random |
These parameters follow OWASP guidelines for high-security password storage.
Data Encryption (AES-256-GCM):
File format:
[Magic: OBCRED01 (8 bytes)][AES-256-GCM encrypted JSON]
| Component | Description |
|---|---|
| Algorithm | AES-256-GCM authenticated encryption |
| Key source | Root-only key file (/etc/open-bastion/cache.key), fallback to machine-id derivation |
| Key derivation | PBKDF2-SHA256 (100,000 iterations) with per-cache-directory salt |
| IV | 12 bytes, random per encryption |
| Auth tag | 16 bytes, prevents tampering |
GCM authentication ensures any tampering (bit flips, truncation) is detected and the entry is rejected.
The encryption key is derived from a root-only key file or /etc/machine-id, ensuring:
- Portability prevention: Cache files are useless on other machines
- Cloning detection: VM clones with same machine-id must re-enroll
- Hardware binding: Physical theft of disk provides no access without key file
Impact of machine-id/key change:
- All cached credentials become permanently unreadable
- Users must authenticate online to re-cache credentials
- No security risk (encrypted data remains encrypted)
stateDiagram-v2
[*] --> Stored: Successful online auth
Stored --> Verified: Correct password (offline)
Stored --> Failed: Wrong password
Failed --> Failed: Increment failures
Failed --> Locked: Max attempts reached
Locked --> Stored: Lockout expires
Stored --> Expired: TTL exceeded
Expired --> [*]: Entry removed
Verified --> [*]: User authenticated
| Protection | Description |
|---|---|
| Per-user lockout | 5 failed attempts triggers lockout |
| Lockout duration | 5 minutes |
| Failure persistence | Stored encrypted in cache file (failed_attempts, locked_until) |
| Timing attack prevention | Constant-time hash comparison |
Note: The lockout thresholds (5 attempts, 5 minutes) are compile-time constants defined in
offline_cache.h(OFFLINE_CACHE_MAX_FAILED_ATTEMPTSandOFFLINE_CACHE_LOCKOUT_DURATION). For environments requiring stricter lockout, recompile with lower thresholds (minimum recommended: 3 attempts, 15 minutes).
Lockout state is stored within the encrypted cache entry, ensuring it cannot be reset by file manipulation.
The greeter and PAM module communicate via structured error codes
(must match include/offline_cache.h):
| Code | Constant | Meaning |
|---|---|---|
| 0 | OFFLINE_CACHE_OK | Success |
| -1 | OFFLINE_CACHE_ERR_NOMEM | Out of memory |
| -2 | OFFLINE_CACHE_ERR_IO | File system error |
| -3 | OFFLINE_CACHE_ERR_CRYPTO | Decryption failed |
| -4 | OFFLINE_CACHE_ERR_NOTFOUND | User not in cache |
| -5 | OFFLINE_CACHE_ERR_EXPIRED | Cache entry expired |
| -6 | OFFLINE_CACHE_ERR_LOCKED | Account locked out |
| -7 | OFFLINE_CACHE_ERR_INVALID | Invalid cache data |
| -8 | OFFLINE_CACHE_ERR_PASSWORD | Password mismatch |
The PAM module sends structured messages (OFFLINE_ERROR:code[:locktime]) via
PAM conversation so the greeter can display appropriate feedback.
| Requirement | Implementation |
|---|---|
| Directory permissions | 0700 (owner only) |
| File permissions | 0600 (owner read/write) |
| Symlink protection | O_NOFOLLOW on all opens |
| Race condition | Atomic file operations (rename) |
| Filename | SHA-256("cred:username") to prevent enumeration |
| Secure deletion | shred used by admin tool |
| Threat Vector | Protection |
|---|---|
| Cache file theft | AES-256-GCM + machine/key binding |
| Memory analysis | Secure memory clearing (explicit_bzero/sodium_memzero) |
| Timing attacks | Constant-time comparison for hashes |
| Symlink attacks | O_NOFOLLOW on all file operations |
| Race conditions | Atomic file operations (rename) |
| Privilege escalation | Cache directory is 0700 root-owned |
| Lockout bypass | Lockout state encrypted in cache |
| User enumeration | SHA-256 hashed filenames |
When to enable offline mode:
- Corporate workstations with network reliability concerns
- Laptops used in areas with poor connectivity
- Business continuity during LLNG maintenance
When NOT to enable offline mode:
- High-security environments requiring real-time authorization
- Shared/public workstations
- Systems requiring immediate access revocation
Emergency procedures:
# Immediate user revocation
ob-cache-admin invalidate username
# Force online-only authentication
touch /etc/open-bastion/force_online
# Complete cache flush
ob-cache-admin invalidate-allThe ob-cache-admin tool provides secure cache management:
# List cached credentials (metadata only, no secrets)
ob-cache-admin list
# Show details for a specific user
ob-cache-admin show username
# Invalidate specific user's cache (secure deletion)
ob-cache-admin invalidate username
# Invalidate all cached credentials
ob-cache-admin invalidate-all
# Unlock a locked account
ob-cache-admin unlock username
# Remove invalid/orphaned files
ob-cache-admin cleanupSecurity notes:
- Requires root privileges (cache files owned by root)
- Never displays passwords or hashes
- Uses
shredfor secure file deletion - Unlock cannot modify encrypted lockout state directly; offers invalidation instead
Offline authentication events are logged to:
- Syslog (via PAM)
- Structured audit log (
/var/log/open-bastion/audit.json)
Look for:
offline_auth_success: User authenticated via cacheoffline_auth_failure: Failed offline authentication attemptoffline_cache_locked: User locked out due to failed attempts
-
Generate a key file: Use
ob-desktop-setup --offlineor create manually (dd if=/dev/urandom of=/etc/open-bastion/cache.key bs=32 count=1) -
Set appropriate TTL: Default 7 days; reduce for high-security environments
-
Enable disk encryption: Use LUKS or similar for the system drive
-
Monitor lockouts: Check
ob-cache-admin statsfor unusual patterns -
Regular cleanup: Run
ob-cache-admin cleanupperiodically via cron -
Disable when not needed: Set
auth_cache_enabled = falseto eliminate the attack surface entirely
When a user authenticates offline and the network returns, the system revalidates the session via three mechanisms:
| Mechanism | Trigger | Action |
|---|---|---|
| Screen unlock (PAM) | User enters password | Online LLNG auth attempted |
| Token refresh (Greeter) | Screen unlock | Refresh token exchanged for new access token |
| ob-session-monitor | Periodic (60s) | Check user validity via /pam/userinfo |
Anti-firewall-bypass protection: If the SSO portal is unreachable despite
network being available (possible local firewall manipulation), all offline
sessions are terminated after offline_max_sso_unreachable seconds (default: 1h).
| Threat | Mitigation |
|---|---|
| Token replay | Single-use tokens, cache invalidation |
| MITM attacks | TLS 1.3, certificate pinning |
| Brute force | Rate limiting with exponential backoff |
| Cache tampering | AES-256-GCM authenticated encryption |
| Path injection | Strict path validation, approved lists |
| Buffer overflow | Bounds-checked string operations, snprintf with null-termination |
| UID collision | Fail-safe collision detection |
| Request tampering | Optional HMAC request signing with nonces |
| Memory exhaustion DoS | Response size limits (256KB), group limits (256 max) |
| Integer overflow | Input validation in base64 encoding, backoff calculations |
| Malformed JSON | Type validation for critical response fields |
| Client secret exposure | JWT Client Assertion (RFC 7523) - secret never transmitted |
| Bastion bypass | Bastion JWT verification on backends (RS256 signed) |
| Direct backend access | JWT required + JWKS-based offline verification |
| Offline cache theft | AES-256-GCM encryption + machine-id binding |
| Offline brute force | Argon2id + per-user lockout after 5 attempts |
| Stale offline credentials | Configurable TTL (default 7 days) |
To report security vulnerabilities, please email [email protected] with:
- Description of the vulnerability
- Steps to reproduce
- Potential impact
- Any suggested fixes (optional)
| Stage | Timeline |
|---|---|
| Initial response | Within 48 hours |
| Vulnerability assessment | Within 7 days |
| Fix development | Depends on severity |
| Public disclosure | After fix is released |
- We will acknowledge your report within 48 hours
- We will keep you informed of our progress
- We will credit you in the security advisory (unless you prefer anonymity)
- We will not take legal action against researchers who follow responsible disclosure
| Version | Supported |
|---|---|
| 1.x | Yes |
| < 1.0 | No |
Only the latest minor version receives security updates. We recommend always running the latest release.
- Security issues are fixed in private and released as part of a new version
- Security advisories are published after the fix is available
- Critical vulnerabilities may receive expedited patches
For detailed information about the security architecture and implementation:
- Security Architecture - Transport security, authentication, encryption
- Enrollment Security - Server enrollment security analysis
- SSH Connection Security - SSH authentication and authorization
- Offboarding Procedures - Revocation and deprovisioning
- Future Improvements - Planned security enhancements
When deploying Open Bastion:
- Use TLS 1.3 - Set
min_tls_version = 13in configuration - Enable audit logging - Set
audit_enabled = truefor security monitoring - Enable rate limiting - Enabled by default, protects against brute-force
- Restrict file permissions - Configuration files should be
0600owned by root - Use certificate pinning - For high-security environments, pin the LLNG server certificate
- Never enable debug logging in production - Debug logs may contain sensitive information