Skip to content

Security hardening

This page is a hardening guide for administrators running a Bor server and agents. It describes the cryptography Bor uses out of the box, the protections already built into the packages, and the steps you should take after installation: protecting secrets, replacing the initial admin password, enforcing MFA, securing LDAP, limiting network exposure, forwarding the audit log, configuring GDPR-related features, and handling agent certificates and enrollment tokens.

Post-installation checklist

  • [ ] Log in, change the initial admin password, then remove admin_password from /etc/bor/server.yaml.
  • [ ] Move secrets from server.yaml to /etc/bor/server.env (mode 0640).
  • [ ] Set a dedicated BOR_MFA_SECRET before anyone enrolls an authenticator.
  • [ ] Turn on Require MFA for all users.
  • [ ] Use LDAPS or StartTLS with certificate verification if LDAP is enabled.
  • [ ] Use port 443 as the web port and restrict it to administrators and endpoints; restrict port 8444 to endpoints; keep the metrics listener on loopback or protect it.
  • [ ] Forward the audit log to your SIEM and review the retention period.
  • [ ] Enable FIPS 140-3 runtime enforcement if your environment requires it.
  • [ ] Back up the CA, the configuration and the escrow KEK (Backup and restore).

Cryptography and TLS

Bor runs two TLS listeners with different settings. The values below come directly from the server code and are not configurable.

Web port, 443 or 8443 (UI, REST API, enrollment) Port 8444 (agent policy stream)
Minimum TLS version TLS 1.2 TLS 1.3
Client certificate Verified if presented, not required Required and verified against the Bor CA
TLS 1.2 cipher suites ECDHE-ECDSA-AES256-GCM-SHA384, ECDHE-RSA-AES256-GCM-SHA384, ECDHE-ECDSA-AES128-GCM-SHA256, ECDHE-RSA-AES128-GCM-SHA256 not applicable
TLS 1.3 cipher suites Go defaults (all AEAD) Go defaults (all AEAD)
Key exchange groups X25519, P-256, P-384 Go defaults
Application protocols HTTP/2, HTTP/1.1 HTTP/2 (gRPC) only
Certificate Internal-CA-signed, your own, or ACME Always the internal-CA-signed server certificate

Only ECDHE key exchange with AEAD ciphers is offered on TLS 1.2: no static RSA, no CBC, no RC4 or 3DES. The agent always connects with TLS 1.3 as its minimum, both for enrollment and for the policy stream.

Keys and certificates generated by Bor:

Item Algorithm Lifetime
Internal CA (/var/lib/bor/pki/ca) ECDSA P-384 10 years
Server certificate (/var/lib/bor/pki/ui) ECDSA P-256, signed by the CA 1 year, regenerated at startup when expired or when host names change
Agent key and certificate ECDSA P-256 generated on the endpoint, signed by the CA 90 days

The server rejects certificate signing requests with RSA keys shorter than 3072 bits or ECDSA curves weaker than P-256. Other primitives:

  • Local passwords: PBKDF2-HMAC-SHA256 with 600,000 iterations; new passwords must be at least 12 characters.
  • Web sessions: HS256-signed JWTs with jwt_secret; access tokens last 1 hour (security.jwt_lifetime), refresh tokens 24 hours (security.refresh_lifetime).
  • TOTP secrets: AES-256-GCM at rest, key derived from BOR_MFA_SECRET (or jwt_secret if unset).
  • Escrowed LUKS recovery keys: envelope-encrypted under a KEK kept outside the database.

For certificate options (your own certificate, ACME, extra host names) see TLS and certificates.

FIPS 140-3

All release packages of bor-server and bor-agent are built with GOFIPS140=v1.0.0, which embeds the Go Cryptographic Module v1.0.0 (the Go FIPS 140-3 module) into the binaries. By default the binaries use that module but do not restrict algorithm choice. To enforce FIPS mode at runtime, set GODEBUG=fips140=on with a systemd drop-in.

On the server:

sudo systemctl edit bor-server

Add:

[Service]
Environment=GODEBUG=fips140=on

Then restart:

sudo systemctl restart bor-server

Do the same for the agent on each endpoint with sudo systemctl edit bor-agent and sudo systemctl restart bor-agent.

With FIPS mode on, the Go runtime removes non-approved algorithms such as X25519 from TLS negotiation, so connections use P-256 or P-384. Everything Bor generates (P-384 CA, P-256 server and agent keys, AES-GCM, SHA-256) is within the approved set. If you supply your own certificates, LDAP server or syslog receiver, make sure they also use approved algorithms, or connections to them may fail in FIPS mode.

Verify the setting took effect:

systemctl show bor-server --property=Environment

Built-in service hardening

The bor-server.service unit shipped in the package already contains:

Directive Effect
User=bor, Group=bor The server runs as an unprivileged system user
NoNewPrivileges=yes The process and its children cannot gain privileges
PrivateTmp=yes Private /tmp and /var/tmp
ProtectSystem=strict The whole file system is read-only for the service ...
ReadWritePaths=/var/lib/bor/pki /var/lib/bor/acme ... except these two directories
CapabilityBoundingSet=CAP_NET_BIND_SERVICE, AmbientCapabilities=CAP_NET_BIND_SERVICE The only capability is binding ports below 1024 (ACME port 80, or 443 if you move the UI there)

The post-install script also sets cap_net_bind_service on /usr/bin/bor-server so the binary can bind low ports when started outside systemd.

Because of ProtectSystem=strict, if you move tls.autogen_dir, ca.autogen_dir or acme.cache_dir outside the two default directories, add the new paths with a drop-in (sudo systemctl edit bor-server):

[Service]
ReadWritePaths=/srv/bor/pki

Files the server only reads (escrow KEK, keytab, your certificates) can live anywhere the bor user can read, for example under /etc/bor.

The agent (bor-agent.service) runs as root. It must write system policy files (browser policies, dconf, polkit rules, firewalld zones, repository definitions and so on), so it is not sandboxed.

File permissions

Path Owner Mode Notes
/etc/bor/server.yaml root:bor 0640 Set by the package. Contains jwt_secret and possibly passwords.
/etc/bor/server.env root:bor 0640 Not created by the package; create it yourself.
/var/lib/bor/pki bor:bor 0750 ca/ is created 0700; generated keys are 0600.
/var/lib/bor/acme bor:bor 0750 ACME account key and certificates.
Escrow KEK file bor 0600 The server refuses to start if the file is group- or world-readable.
/etc/bor/config.yaml (agent) root 0600 Set by the agent package.
/var/lib/bor/agent (agent) root 0750 agent.key is written 0600.

Check them:

sudo stat -c '%U:%G %a %n' /etc/bor/server.yaml /etc/bor/server.env /var/lib/bor/pki /var/lib/bor/pki/ca/ca.key

Warning

If the bor user cannot read server.yaml, the server does not report an error: it silently falls back to built-in defaults (database localhost, password bor) and then fails to connect to the database. Keep the root:bor 0640 ownership when editing the file.

Keep secrets out of server.yaml

Every setting can be given as an environment variable, and environment variables take precedence over server.yaml. The systemd unit loads /etc/bor/server.env if it exists. Moving secrets there keeps them out of a file you are likely to copy, diff or show to colleagues.

  1. Create the file:

    sudo install -m 0640 -o root -g bor /dev/null /etc/bor/server.env
    sudoedit /etc/bor/server.env
    
  2. Add the secrets you use, for example:

    JWT_SECRET=<64 hex characters from: openssl rand -hex 32>
    BOR_MFA_SECRET=<64 hex characters from: openssl rand -hex 32>
    DB_PASSWORD=<database password, for TCP connections>
    LDAP_BIND_PASSWORD=<LDAP service account password>
    BOR_METRICS_TOKEN=<metrics bearer token>
    
  3. Remove the corresponding values from server.yaml (or leave jwt_secret there and do not set JWT_SECRET; do not keep two different values).

  4. Restart: sudo systemctl restart bor-server.

Notes:

  • jwt_secret must be at least 32 characters; a shorter value stops the server with JWT_SECRET is too short. If it is missing or still the placeholder change-me-in-production, the server generates a random one on each start and logs WARNING: JWT_SECRET not set; all sessions are then lost on every restart. The package generates a random secret on first install.
  • Changing jwt_secret signs everyone out.
  • BOR_MFA_SECRET is only read from the environment. It encrypts stored TOTP secrets; when it is not set, jwt_secret is used instead. Set it before users enroll authenticator apps and never change it afterwards (nor jwt_secret, if you rely on the fallback): existing TOTP enrollments would stop working and need to be reset.

Replace the initial admin password

On first install the package generates a random password for the admin account, writes it to security.admin_password in server.yaml, and prints it on the console. The server uses this value only once, to create admin when the database has no users at all.

  1. Log in as admin.
  2. Open the user menu in the top right corner, choose Account security, open the Change Password tab, enter the current and a new password (at least 12 characters) and click Change password.
  3. Remove the password from the configuration:

    sudoedit /etc/bor/server.yaml
    

    Delete the admin_password: line or turn it back into a comment (#admin_password: "").

  4. Restart the server (sudo systemctl restart bor-server) so the value is no longer held in memory.

Removing it has no effect on the existing account. Consider creating named administrator accounts (or using LDAP) and disabling the shared admin account once they work.

If the server ever starts with an empty database and no admin_password, it generates a password and writes it to /var/lib/bor/initial_admin_password (mode 0600) instead of logging it. Read it, log in, change it, and delete the file.

Enforce multi-factor authentication

Bor supports TOTP authenticator apps and, when webauthn.rpid is configured, FIDO2/WebAuthn security keys. Both local and LDAP accounts can use them, because Bor verifies LDAP passwords itself. See Multi-factor authentication for setup.

To require MFA for everyone (needs the settings:manage permission):

  1. Go to Settings -> MFA Policy.
  2. Turn on Require MFA for all users.
  3. Decide on Set up MFA while signing in:
    • Allowed: a user without a second factor is guided through enrollment right after entering their password, and only then gets a session.
    • Not allowed: such sign-ins are refused with "Two-factor authentication is required, but your account has none. Contact your administrator to set it up." Use this if you want to hand out authenticators yourself.
  4. Optionally pick the TOTP algorithm (SHA256 or SHA512). It applies to new enrollments.
  5. Click Save.

Make sure at least one administrator has enrolled a second factor and stored the backup codes before you turn enforcement on.

If an administrator loses their authenticator and backup codes, reset MFA on the server host. The command reads server.yaml and connects to the database directly; it does not need the service to be running and its action is not written to the Bor audit log, so record it in your change log:

sudo -u bor bor-server --reset-mfa admin

Run it as the bor user so local peer authentication to PostgreSQL works. If your secrets are in server.env, export them into the environment first. See Troubleshooting.

Login endpoints are rate limited to 10 requests per minute per client IP address; additional attempts get HTTP 429 rate limit exceeded, try again later.

Secure LDAP

If you enable LDAP (LDAP configuration), never send bind passwords in clear text:

  • Use LDAPS (ldap.use_tls: true, usually port 636) or StartTLS (ldap.start_tls: true on port 389). LDAPS wins if both are set.
  • Set ldap.tls_ca_file to the CA that issued the directory server's certificate if it is not in the system trust store. Bor requires TLS 1.2 or newer and checks the server name against ldap.host.
  • Do not use ldap.tls_skip_verify. The server refuses to start with it unless BOR_DEV_MODE=true or BOR_ALLOW_INSECURE_LDAP=true is also set.
  • Use a dedicated read-only service account for bind_dn and put its password in LDAP_BIND_PASSWORD in server.env.
  • Map directory groups to Bor roles with ldap.group_role_map and keep the mapping to Super Admin as narrow as possible.

Restrict network exposure

Port Who needs it Recommendation
Web port: 443 recommended, default 8443 (server.enrollment_port) Administrators' browsers; endpoints during enrollment; endpoints installing or updating bor-agent from the server's /agent/ repository Allow from admin networks and endpoint networks only. Never expose to the Internet unless you must.
8444 Enrolled endpoints Allow from endpoint networks only. Must not pass through a TLS-terminating proxy: it needs end-to-end mutual TLS.
9090 (metrics) Prometheus Default bound to 127.0.0.1. If you expose it, add a bearer token, TLS and a firewall rule.
80 ACME CA (HTTP-01 challenge), only with acme.enabled Open only when you use ACME. For the TLS-ALPN-01 challenge instead, the ACME CA connects to the web port, which must then be 443.
5432 bor-server Do not expose PostgreSQL; use the local socket or a private network with sslmode: require.

server.address binds both the web port and 8444 to the same address (empty means all interfaces). Example with firewalld, admins in 192.0.2.0/26 and endpoints in 192.0.2.128/25, web port 443:

sudo firewall-cmd --permanent --new-zone=bor
sudo firewall-cmd --permanent --zone=bor --add-source=192.0.2.0/26
sudo firewall-cmd --permanent --zone=bor --add-source=192.0.2.128/25
sudo firewall-cmd --permanent --zone=bor --add-port=443/tcp
sudo firewall-cmd --permanent --zone=bor --add-port=8444/tcp
sudo firewall-cmd --reload

Make sure the ports are not also open in the default zone.

Audit logging and forwarding

Bor records authentication events, permission denials, policy, binding, node, user and role changes, tamper detections and recovery-key reveals in its audit log (Audit Logs page, see Audit log). Users with the audit_log:export permission can download it with Export CSV or Export JSON.

To ship events to a SIEM in real time, enable the syslog sink in server.yaml:

audit:
  syslog:
    enabled: true
    network: "tcp+tls"            # udp | tcp | tcp+tls
    addr: "siem.example.com:6514"
    format: "cef"                 # cef | ocsf
    facility: 16                  # local0
    tls_ca: "/etc/bor/siem-ca.crt"
Key Environment variable Default
audit.syslog.enabled BOR_AUDIT_SYSLOG_ENABLED false
audit.syslog.network BOR_AUDIT_SYSLOG_NETWORK udp
audit.syslog.addr BOR_AUDIT_SYSLOG_ADDR localhost:514
audit.syslog.format BOR_AUDIT_SYSLOG_FORMAT cef
audit.syslog.facility BOR_AUDIT_SYSLOG_FACILITY 16
audit.syslog.tls_ca BOR_AUDIT_SYSLOG_TLS_CA system trust store

Use tcp+tls across untrusted networks; it requires TLS 1.2 or newer. cef is the ArcSight Common Event Format; ocsf produces Open Cybersecurity Schema Framework JSON. After a restart the server logs Audit syslog sink enabled: ....

GDPR features

Feature Setting Default
Audit log retention audit.retention_days / BOR_AUDIT_RETENTION_DAYS 365. Entries older than this are deleted at startup and then daily. 0 disables deletion.
IP address minimization BOR_AUDIT_ANONYMIZE_IPS=true (environment variable only) Off. When on, client IP addresses are truncated to /24 (IPv4) or /48 (IPv6) before they are stored.
Privacy policy link ui.privacy_policy_url / BOR_PRIVACY_POLICY_URL Empty. When set, the UI shows a privacy policy link.
Data export for the signed-in user GET /api/v1/users/me/export Always available to authenticated users; returns their account data as JSON (no secrets).

Note

IP anonymization is read only from the BOR_AUDIT_ANONYMIZE_IPS environment variable. An anonymize_ips key in server.yaml is ignored. Put it in /etc/bor/server.env.

Choose a retention period that satisfies both your audit obligations and data minimization. Records forwarded to a SIEM are subject to the SIEM's own retention.

Agent certificates

Agents authenticate with client certificates. The server checks the certificate's serial number against its revocation list on every gRPC call and every new stream, so revocation takes effect when the agent next connects or calls the server.

  • Revoke a certificate: Nodes -> select the node -> Revoke Certificate. The node stays in the inventory but its agent cannot connect any more. To bring it back, decommission the node and re-enroll the agent.
  • Remove a node: Nodes -> select the node -> Decommission. This revokes the certificate and deletes the node record.
  • Renewal: when bor-agent starts and its certificate expires within 30 days, it requests a new one from port 8444, authenticating with its current certificate. Restart agents (or reboot endpoints) regularly, and alert on bor_node_certificate_expiry_seconds (see Monitoring). An expired certificate cannot be renewed; the node must be re-enrolled.
  • Each node name is the authoritative identity: the server writes it into the certificate's common name and rejects requests whose claimed client ID does not match.

Do not leave insecure_skip_verify: true in agent configurations as a habit. It only affects enrollment (after enrollment the agent always verifies the server against its stored CA), but during enrollment it means the agent will send its token to whatever answers on the address. Install the server CA into the endpoint's trust store instead, as the Deploy agent wizard does.

Enrollment token hygiene

  • Tokens are generated per node group (Node Groups -> Generate token), are valid for 5 minutes, and are consumed by the first enrollment attempt, successful or not. The server stores only a hash of each token.
  • Pass the token with --token-file or the BOR_ENROLLMENT_TOKEN environment variable. --token on the command line is deprecated because it is visible in process listings; the agent prints a warning when it is used.
  • Delete token files right after enrolling, as the wizard's script does.
  • Leave security.admin_token empty unless you automate token creation over gRPC. When it is empty, that RPC is disabled.
  • Consider Kerberos enrollment for domain-joined endpoints; it needs no token at all.