This document describes the security measures implemented in the LemonLDAP::NG PAM module.
The PAM module authenticates users against a LemonLDAP::NG portal using a secure token-based protocol. Multiple layers of defense protect against various attack vectors.
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
Audit events use differentiated codes for SIEM integration:
| Event Type | Description |
|---|---|
AUDIT_AUTH_SUCCESS |
Successful authentication |
AUDIT_AUTH_FAILURE |
Failed authentication |
AUDIT_AUTHZ_DENIED |
Authorization denied (valid user, no permission) |
AUDIT_SECURITY_ERROR |
Cryptographic/security failure (invalid signature, malformed JWT) |
AUDIT_RATE_LIMITED |
Rate limit triggered |
AUDIT_USER_CREATED |
Local user account created |
AUDIT_SERVER_ERROR |
Backend communication error |
This classification enables security teams to distinguish between:
- Authorization failures (user lacks permission) →
AUDIT_AUTHZ_DENIED - Security incidents (attack attempt) →
AUDIT_SECURITY_ERROR
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 |
The shell scripts (ob-ssh-proxy, ob-enroll, ob-ssh-cert) implement security measures:
Scripts use jq for JSON payload construction instead of string interpolation:
# Safe - uses jq argument passing
json_payload=$(jq -n --arg user "$user" --arg host "$host" '{user: $user, host: $host}')
# Unsafe - vulnerable to injection (NOT USED)
# json_payload="{\"user\": \"$user\"}"This prevents JSON injection attacks where malicious input could break out of string context.
Scripts verify configuration file security before sourcing:
# Check owner is root
# Check no group/world write permissions
# Refuse to source if checks failThis prevents privilege escalation via malicious configuration injection.
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 PAM module can enforce restrictions on which SSH key types are allowed and their minimum sizes. This prevents connections using weak or deprecated cryptographic algorithms.
| Setting | Default | Description |
|---|---|---|
ssh_key_policy_enabled |
false | Enable SSH key type restrictions |
ssh_key_allowed_types |
all | Comma-separated list of allowed key types |
ssh_key_min_rsa_bits |
2048 | Minimum RSA key size in bits |
ssh_key_min_ecdsa_bits |
256 | Minimum ECDSA key size in bits |
| Type | Algorithm | Recommendation |
|---|---|---|
ed25519 |
Ed25519 | Recommended - Modern, fast, secure |
sk-ed25519 |
Ed25519 with FIDO2 | Recommended - Hardware-bound |
sk-ecdsa |
ECDSA with FIDO2 | Recommended - Hardware-bound |
ecdsa |
ECDSA (P-256/P-384/P-521) | Acceptable |
rsa |
RSA | Acceptable with ≥3072 bits |
dsa |
DSA | Deprecated - Should be disabled |
- DSA keys: Should be disabled. DSA is considered deprecated and has fixed 1024-bit key size.
- RSA keys: Should require at least 2048 bits, preferably 3072 bits for long-term security.
- ECDSA keys: P-256 (256 bits) is the minimum recommended curve.
- Ed25519 keys: Always 256 bits, no size configuration needed.
- FIDO2/Security Keys:
sk-ed25519andsk-ecdsaprovide hardware-bound private keys.
ssh_key_policy_enabled = true
ssh_key_allowed_types = ed25519, sk-ed25519, sk-ecdsaThis configuration only allows Ed25519 keys and FIDO2 hardware security keys.
| 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 |
| Weak SSH keys | SSH key policy enforcement with type/size restrictions |
| Cache brute-force | Rate limiting for offline cache lookups with exponential backoff |