We take security vulnerabilities seriously. If you discover a security issue, please report it responsibly.
Email: security@linagora.com
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:sha256=<hex>, HMAC-SHA256 of the request
The signed message is, with . as separator and an empty string for a bodyless request:
<timestamp>.<nonce>.<method>.<path>.<body>
The nonce is part of the signed message: leaving it out would let an attacker
replay a captured request with a fresh X-Nonce while keeping a valid
signature, defeating the replay window.
This provides defense-in-depth against request tampering, even if TLS is somehow compromised.
Every caller signs: the PAM module (/pam/verify, /pam/authorize,
/pam/heartbeat), ob-cert-daemon (/pam/bastion-cert), and the shell
callers ob-heartbeat, ob-bastion-id, ob-enroll and ob-session-monitor.
The portal refuses an unsigned call on any of them once
pamAccessRequestSigningMode is required, so leaving one out is a fleet-wide
outage waiting on a token to expire, not a missing hardening measure.
The shell callers sign through ob-sign-request, never through
openssl dgst -sha256 -hmac "$secret". OpenSSL takes the HMAC key on the
command line and offers no form that reads it from a file or the environment;
/proc/<pid>/cmdline is world-readable, so on a bastion that one-liner would
hand the fleet-wide signing secret to every user with a shell, every few
minutes, forever. ob-sign-request reads the secret from the root-only
configuration file and takes the body on stdin — which matters too, since
ob-heartbeat signs a body carrying the host's refresh_token.
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. This is implemented via LLNG-signed ephemeral
SSH certificates — not JWTs — because the JWT/SendEnv approach was structurally broken (SendEnv/AcceptEnv
populate only the child-process environment, never the PAM environment that pam_getenv reads).
flowchart LR
subgraph User["User"]
client["SSH client"]
end
subgraph Bastion["Bastion Server"]
pam_b["pam_openbastion (authorize)"]
proxy["ob-ssh -> ob-cert-request\n(unprivileged client)"]
helper["ob-cert-daemon\n(root, socket-activated; user from SO_PEERCRED)"]
end
subgraph LLNG["LLNG Portal"]
authorize["/pam/authorize\n(mints voucher)"]
bastion_cert["/pam/bastion-cert\n(signs ephemeral cert)"]
end
subgraph Backend["Backend Server"]
sshd["sshd\n(TrustedUserCAKeys)"]
principals["ob-ssh-principals\n(AuthorizedPrincipalsCommand)"]
pam_bk["pam_openbastion (acct_mgmt)"]
end
client -->|1. SSH with SSO cert| pam_b
pam_b -->|2. POST /pam/authorize| authorize
authorize -->|3. voucher| pam_b
pam_b -->|4. pam_putenv LLNG_BASTION_VOUCHER| proxy
proxy -->|5. ephemeral pubkey + voucher over unix socket| helper
helper -->|6. POST /pam/bastion-cert + Bearer server token| bastion_cert
bastion_cert -->|7. signed ~120s cert| helper
helper -->|8. cert| proxy
proxy -->|9. SSH -i ephkey -o CertificateFile=cert| sshd
sshd -->|10. validate CA + source-address + principal| principals
principals -->|11. check key-id bastion= vs allowed_bastions| pam_bk
- User SSHes to the bastion using their SSO-issued certificate.
pam_openbastioncallsPOST /pam/authorize; LLNG mints a voucher bound to(bastion_id, user)and returns it. The voucher is exported viapam_putenv("LLNG_BASTION_VOUCHER=...")— bastion-local, so theSendEnv/pam_getenvtrap does not apply. ob-ssh [user@]backendgenerates an ephemeral ed25519 keypair in tmpfs (private key never leaves the bastion). It connects (as the logged-in user, via the unprivilegedob-cert-requestclient) toob-cert-daemon, a socket-activated service running as root. The daemon derives the certificate's user from the connection'sSO_PEERCRED(kernel-verified, never from the request) — so a caller can only mint a cert for itself — then POSTs the voucher and ephemeral public key toPOST /pam/bastion-cert, authenticating with the bastion's root-only server token as Bearer. No sudo, no setuid; the server token never leaves the daemon.- LLNG verifies the voucher against the stored
(bastion_id, user)record, then signs a certificate (~120 s validity) withprincipal=user,key-idencodingbastion=<bastion_id>;user=<user>;target=<host>, and asource-addresscritical option pinned to the bastion's IP. ob-sshconnects to the backend:ssh -i <ephkey> -o CertificateFile=<cert> -o IdentitiesOnly=yes. Temp files are wiped afterwards.- Backend
sshdvalidates the certificate natively (CA signature, validity window,principal,source-address).AuthorizedPrincipalsCommand ob-ssh-principalschecks thekey-idfield against/etc/open-bastion/allowed_bastions.pam_openbastionruns the normal/pam/authorizeuser-authorization call unchanged.
| Threat | Without Cert Vouching | With Cert Vouching |
|---|---|---|
| Direct backend access | Possible if network accessible | Blocked (no valid LLNG-signed cert) |
| VPN bypass to backend | Possible | Blocked (source-address critical option) |
| Firewall misconfiguration | Exposes backends | Backends still protected |
| Rogue bastion | Access if on network | Blocked (bastion not in allowed_bastions) |
| Voucher theft by user | N/A | Useless without root-only server token |
The key-id field of the ephemeral cert carries structured audit and enforcement data:
| Field | Format | Description |
|---|---|---|
bastion |
bastion=<bastion_id> |
Enrolling OIDC client_id of the bastion |
user |
user=<username> |
Username authorized on the bastion |
target |
target=<hostname> |
Target backend hostname |
Full key-id example: bastion=bastion-01;user=alice;target=db-server
The source-address critical option is set to the bastion's IP, so sshd refuses the cert
from any other origin at the protocol level.
Backend configuration is managed via ob-backend-setup:
# Configure allowed bastions (by OIDC client_id)
ob-backend-setup --allowed-bastions bastion-01,bastion-02
# Ansible variable: ob_bastion_allowed_bastionsThis writes /etc/open-bastion/allowed_bastions (0644 inside a 0711 directory) and wires
AuthorizedPrincipalsCommand. If the file is absent, legacy direct-user SSO certs are accepted.
If the file is present but empty, any vouched bastion is accepted. If the file is unreadable,
ob-ssh-principals fails closed (denies the connection).
Set this list. An empty allowlist accepts a hop voucher from any host enrolled in the project, and that allowlist is the residual defence behind a real gap on the SSO side: the pam-access plugin performs no RP/audience binding on
/pam/*tokens, and whenpamAccessServerGroupsis empty — the configuration recommended for multi-group projects — it takesserver_groupstraight from the request body. Any compromised enrolled host in the project can therefore declare itself a bastion and mint a 12-hour hop voucher for a user. The second defence,pamAccessBastionCertPinSourceAddress, is also off by default.
ob-backend-setupasks for the list when run interactively, and pressing Enter is not an answer: it re-asks, and takes the empty list only on an explicityto "Accept a hop from ANY bastion?" (or--allow-any-bastion, which states the same answer up front).ob-ssh-principalsthen logs anauthpriv.warningon every hop it accepts without checking which bastion it came from, so a running fleet shows the condition in its logs and not only in a setup transcript.The empty semantic itself is unchanged, and a non-interactive run (
--yes, i.e. Ansible) still defaults to it: inverting it, or making the list mandatory there, would deny every hop on an existing fleet the moment it upgrades. An unattended deployment that wants the protection has to pass--allowed-bastions(Ansible:ob_bastion_allowed_bastions).Legacy mode is not a quieter version of this. With
/etc/open-bastion/allowed_bastionsabsent — a backend set up by a version that predates vouching and never re-run — the helper skips vouching entirely and accepts a direct SSO certificate, which is broader than an empty list. That path also logs anauthpriv.warningon every accepted connection; the fix is to re-runob-backend-setup.A standalone host (its own bastion,
--node-role standalone) legitimately has nothing to list, so it will warn on every hop. Either accept the log volume or pass its own id fromob-bastion-id; a lab is the one place where--allow-any-bastionis the honest answer.
Required sshd settings (no AcceptEnv needed):
# /etc/ssh/sshd_config
TrustedUserCAKeys /etc/ssh/open-bastion_ca.pub
ExposeAuthInfo yes
AuthorizedPrincipalsCommand /usr/local/sbin/ob-ssh-principals %u %f %i
AuthorizedPrincipalsCommandUser nobodyob-ssh-principals is generated at setup time by ob-backend-setup (and by
ob-bastion-setup, in its two-token %u %f bastion form) into
/usr/local/sbin/. It is not a file shipped by the package, so it will not be
found under /usr/lib/open-bastion/ or /usr/sbin/.
Direct user SSO certs that do not carry a bastion= key-id field are denied before PAM runs
when an allowed_bastions file is present.
| Property | Value |
|---|---|
| Bound to | (bastion_id, user) — minted at /pam/authorize, keyed per pair |
| Validity | min(now + pamAccessBastionVoucherTtl, SSO cert expires_at), default 12 h |
| Reusable | Yes — multiplexed hops and scp host1: host2: all work with the same voucher |
| Expiry | Fail-closed: ob-ssh prints a clear error and exits non-zero |
| Renewal | User reconnects to the bastion; no silent re-vouching |
The authorization cache (auth_cache) is the only PAM-side cache in the
module. It stores the result of a successful /pam/authorize call so that an
already-authorized user can still log in while the LLNG portal is unreachable.
It never caches credentials, tokens, or password material — only an
authorization verdict and the account attributes that come with it.
Entries are written only by builds that include the Desktop SSO components
(-DINSTALL_DESKTOP=ON, which is what the .deb and .rpm packages use).
An SSH-only build reads the cache but never populates it.
Cache entries are always encrypted — there is no plaintext mode:
- Algorithm: AES-256-GCM (authenticated encryption)
- Key Derivation: PBKDF2-HMAC-SHA256, 100,000 iterations, 32-byte key
- Key Source: the machine ID (
/etc/machine-id), or a per-installation.instance_idwhen/etc/machine-idis unavailable (containers, chroots) - Salt: 16 random bytes from
RAND_bytes(), generated on first use and persisted next to the cache as.auth_salt. The salt is not derived from the machine id or the username: a random salt is what stops an attacker precomputing keys for a known machine id. - Authentication: GCM tag prevents tampering
Scope of the key. The derived key is per cache directory, not per user: every entry in one cache directory is encrypted under the same key. Earlier versions of this document described the salt as "the cache username", which wrongly implied per-user key separation. The salt is 16 random bytes from
RAND_bytes(), generated on first use and persisted next to the cache as.auth_salt; it is not derived from the machine id or the username, and its purpose is to stop an attacker precomputing keys for a known machine id.Encryption at rest protects a cache file lifted off the host (a backup, a stolen disk); it is not a separation boundary between users on a live host. That separation comes from the file permissions below.
File format:
["<expires_at> <HMAC-SHA256 hex>\n"][Magic: LLNGCACHE04][IV: 12 bytes][Ciphertext][GCM tag: 16 bytes]
The plaintext expiration header allows quick expiry checks and cleanup without decrypting the payload. It is authenticated with an HMAC-SHA256 over the timestamp, keyed with the same derived key, so an attacker cannot extend cache validity by editing it: a header that fails HMAC verification is ignored and the entry falls through to a full authenticated decrypt.
auth_cache_init() returns NULL if key derivation fails. There is no
unencrypted fallback: if the cache cannot be encrypted, it is not used at all
and authorization falls back to the online path.
- Entries are keyed on
(user, server_group, host)and stored one per file - File permissions: 0600 (owner read/write only)
- Directory permissions: 0700 (
/var/cache/open-bastion/authby default) - Files are written to a temporary path and renamed atomically
- Reads use
O_NOFOLLOW; a symlink in place of a cache file is deleted, not followed
The TTL is not a local setting — it comes from the server, in the offline.ttl
field of the /pam/authorize response, and the entry is only written when the
server explicitly enables offline mode for that user. Expired entries are
removed on access and by auth_cache_cleanup().
Set auth_cache_enabled = false to disable the cache entirely; touching the
auth_cache_force_online file (/etc/open-bastion/force_online by default)
forces every authorization online without a configuration change.
Because a cached verdict can be replayed offline, cache lookups are rate
limited independently of the online path (cache_rate_limit_*). Both hits and
misses count towards the lockout, so an attacker cannot probe for which
usernames have a cache entry without incurring the penalty.
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_openbastion.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
- GID policy enforcement: A server-provided primary GID must be within the
configured
min_gid/max_gidrange (default[1000, 65533], the Debian/RHEL system-group vs. user-group boundary).gid 0andnogroupare refused unconditionally, so a compromised or misconfigured portal cannot hand SSO users a root-equivalent primary group (root,sudo,wheel,shadow,docker). An out-of-policy GID falls back todefault_gidand is logged to syslog (falling back rather than failing the lookup: a bad GID must not turn into a host-wide NSS lockout) - 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 |
Secrets in openbastion.conf — client_secret, notify_secret,
crowdsec_password — are not encrypted at rest. They are protected by file
permissions alone: the module refuses to read the file unless it is a regular
file owned by root with no group or other access (0600). Keep it that way,
and prefer a deployment that never writes the secret to disk on the host at all
(client_secret_mode: prompt in an ob-builder bundle, or an
ansible-vault-held value).
The secrets_encrypted setting documented here until 0.6.2 never did anything:
it fed a secret_store module that had no callers. Both were removed.
Recommended permissions:
| File | Permissions | Owner |
|---|---|---|
/etc/open-bastion/openbastion.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 the authorization cache and the desktop offline
credential cache is derived from /etc/machine-id — combined with
/etc/open-bastion/cache.key, for the offline cache, when one is present.
Impact of machine-id change:
- All cached tokens become unreadable (automatic re-authentication required)
- 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 /var/lib/open-bastion/token
# 2. Re-run enrollment
ob-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.
On OpenSSH >= 9.8 ExposeAuthInfo is not sufficient on its own: sshd does not propagate
SSH_USER_AUTH to the PAM environment during pam_acct_mgmt. ob-bastion-setup /
ob-backend-setup therefore also install the ob-ssh-principals helper as
AuthorizedPrincipalsCommand ... %u %f %t %k, which spools the fingerprint, the key type
and the key blob under /run/open-bastion/ssh-fp/. That spool is what feeds both the
fingerprint binding and the optional SSH key policy (ssh_key_policy_enabled, see
doc/security.md); the key policy is enforced fail-closed and denies a
login whose key cannot be identified.
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 && chown root:root /etc/open-bastion/cache.key && chmod 600 /etc/open-bastion/cache.key). The key file must be a root-owned regular file with mode 0600: any other owner or permission bit makes it be ignored (the cache key then falls back to machine-id derivation, which is weaker). -
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 | LLNG-signed ephemeral cert; source-address critical option; allowed_bastions allowlist |
| Direct backend access | TrustedUserCAKeys + cert source-address + AuthorizedPrincipalsCommand enforcement |
| 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 security@linagora.com 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 |
|---|---|
| 0.6.x | Yes |
| < 0.6 | No |
Open Bastion has not reached 1.0 yet. 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