Skip to content

Security: linagora/open-bastion

Security

SECURITY.md

Security Policy

Reporting a Vulnerability

We take security vulnerabilities seriously. If you discover a security issue, please report it responsibly.

Contact

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
Loading
  1. User provides a one-time token generated by the LLNG portal
  2. PAM module verifies token via /pam/verify endpoint
  3. Token is consumed (single-use) and cannot be replayed
  4. Server returns user attributes and authorization status

Transport Security

TLS Configuration

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=

Request Signing (Optional)

When request_signing_secret is configured, requests include:

  • X-Timestamp: Unix timestamp (server should reject if too old)
  • X-Nonce: Unique timestamp_ms-uuid format (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.

Server Authentication

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.

OAuth2 Client Authentication

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_secret is never transmitted over the network
  • Each request includes a unique JWT signed with HMAC-SHA256
  • JWT contains: iss, sub, aud, exp, iat, and unique jti (UUID v4)
  • JWT validity is 5 minutes to prevent replay attacks

Automatic Token Rotation

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.

Bastion-to-Backend Authentication (Certificate Vouching)

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

Architecture

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
Loading

Flow

  1. User SSHes to the bastion using their SSO-issued certificate. pam_openbastion calls POST /pam/authorize; LLNG mints a voucher bound to (bastion_id, user) and returns it. The voucher is exported via pam_putenv("LLNG_BASTION_VOUCHER=...") — bastion-local, so the SendEnv/pam_getenv trap does not apply.
  2. ob-ssh [user@]backend generates an ephemeral ed25519 keypair in tmpfs (private key never leaves the bastion). It connects (as the logged-in user, via the unprivileged ob-cert-request client) to ob-cert-daemon, a socket-activated service running as root. The daemon derives the certificate's user from the connection's SO_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 to POST /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.
  3. LLNG verifies the voucher against the stored (bastion_id, user) record, then signs a certificate (~120 s validity) with principal=user, key-id encoding bastion=<bastion_id>;user=<user>;target=<host>, and a source-address critical option pinned to the bastion's IP.
  4. ob-ssh connects to the backend: ssh -i <ephkey> -o CertificateFile=<cert> -o IdentitiesOnly=yes. Temp files are wiped afterwards.
  5. Backend sshd validates the certificate natively (CA signature, validity window, principal, source-address). AuthorizedPrincipalsCommand ob-ssh-principals checks the key-id field against /etc/open-bastion/allowed_bastions. pam_openbastion runs the normal /pam/authorize user-authorization call unchanged.

Security Benefits

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

Certificate Key-ID Fields

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.

Configuration (Backend)

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_bastions

This 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 when pamAccessServerGroups is empty — the configuration recommended for multi-group projects — it takes server_group straight 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-setup asks for the list when run interactively, and pressing Enter is not an answer: it re-asks, and takes the empty list only on an explicit y to "Accept a hop from ANY bastion?" (or --allow-any-bastion, which states the same answer up front). ob-ssh-principals then logs an authpriv.warning on 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_bastions absent — 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 an authpriv.warning on every accepted connection; the fix is to re-run ob-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 from ob-bastion-id; a lab is the one place where --allow-any-bastion is 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 nobody

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

Voucher Lifecycle

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

Authorization Cache Security

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.

Encryption at Rest

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_id when /etc/machine-id is 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.

Fail-Closed Key Derivation

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.

Cache Isolation

  • 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/auth by 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

TTL and Invalidation

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.

Brute-Force Protection

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.

Rate Limiting

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.

Auto-Create User Security

When create_user_enabled = true, users can be automatically created on first login.

Path Validation

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

UID Generation

  • 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

NSS Module Security

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_gid range (default [1000, 65533], the Debian/RHEL system-group vs. user-group boundary). gid 0 and nogroup are 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 to default_gid and 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

Direct /etc/passwd and /etc/shadow Manipulation

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 useradd which 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/shadow write fails after /etc/passwd succeeds, rollback is attempted via userdel
  • TOCTOU protection: user existence is re-checked after acquiring locks

Audit Logging

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

Webhook Notifications

For real-time security monitoring:

Setting Description
notify_enabled Enable webhooks
notify_url Webhook endpoint URL
notify_secret HMAC secret for webhook signatures

Configuration Security

Secrets Management

Secrets in openbastion.confclient_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.

File Permissions

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

Operational Security Considerations

Debug Logging Warning

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 = warn or log_level = error in production
  • If debug logging is temporarily needed, ensure syslog access is restricted
  • Rotate and purge logs containing debug output promptly

Machine-ID Stability Requirement

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:

  1. Document machine-id stability as a deployment requirement
  2. Before system migration: Backup enrollment tokens or plan for re-enrollment
  3. VM cloning: Always regenerate machine-id (systemd-machine-id-setup) and re-enroll
  4. 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-access

Service Accounts Security

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

Configuration File Security

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)

Account Validation

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)

SSH Server Requirement

Important: The SSH server must have ExposeAuthInfo yes in /etc/ssh/sshd_config:

# /etc/ssh/sshd_config
ExposeAuthInfo yes

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

Authentication Flow

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
Loading
  1. Service account connects via SSH with its configured key
  2. SSH server exposes key fingerprint via SSH_USER_AUTH (requires ExposeAuthInfo yes)
  3. PAM module extracts fingerprint and checks if user is in service_accounts.conf
  4. PAM module validates that the SSH key fingerprint matches the configured value
  5. If fingerprint matches, account is authorized locally (no LLNG call needed)
  6. sudo permissions are checked from the same configuration file

Security Benefits

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

Limitations

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

Example Configuration

[ansible]
key_fingerprint = SHA256:abc123def456
sudo_allowed = true
sudo_nopasswd = true
gecos = Ansible Automation
shell = /bin/bash
home = /var/lib/ansible

Offline Credential Cache Security

The offline cache enables Desktop SSO authentication when the LLNG server is unreachable. This section describes the security architecture and considerations.

Cryptographic Design

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.

Machine Binding

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)

Cache Entry Lifecycle

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
Loading

Brute Force Mitigation

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_ATTEMPTS and OFFLINE_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.

Error Codes

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.

File System Security

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

Security Boundaries

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

Operational Considerations

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-all

Administrative Controls

The 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 cleanup

Security notes:

  • Requires root privileges (cache files owned by root)
  • Never displays passwords or hashes
  • Uses shred for secure file deletion
  • Unlock cannot modify encrypted lockout state directly; offers invalidation instead

Audit and Monitoring

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 cache
  • offline_auth_failure: Failed offline authentication attempt
  • offline_cache_locked: User locked out due to failed attempts

Recommendations

  1. Generate a key file: Use ob-desktop-setup --offline or 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).

  2. Set appropriate TTL: Default 7 days; reduce for high-security environments

  3. Enable disk encryption: Use LUKS or similar for the system drive

  4. Monitor lockouts: Check ob-cache-admin stats for unusual patterns

  5. Regular cleanup: Run ob-cache-admin cleanup periodically via cron

  6. Disable when not needed: Set auth_cache_enabled = false to eliminate the attack surface entirely

Network Revalidation

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 Mitigations

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)

Security Reporting

To report security vulnerabilities, please email security@linagora.com with:

  1. Description of the vulnerability
  2. Steps to reproduce
  3. Potential impact
  4. Any suggested fixes (optional)

Response Timeline

Stage Timeline
Initial response Within 48 hours
Vulnerability assessment Within 7 days
Fix development Depends on severity
Public disclosure After fix is released

What to Expect

  • 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

Supported Versions

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 Disclosure Policy

  • 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

Security Documentation

For detailed information about the security architecture and implementation:

Security Best Practices

When deploying Open Bastion:

  1. Use TLS 1.3 - Set min_tls_version = 13 in configuration
  2. Enable audit logging - Set audit_enabled = true for security monitoring
  3. Enable rate limiting - Enabled by default, protects against brute-force
  4. Restrict file permissions - Configuration files should be 0600 owned by root
  5. Use certificate pinning - For high-security environments, pin the LLNG server certificate
  6. Never enable debug logging in production - Debug logs may contain sensitive information

There aren't any published security advisories