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
adminpassword, then removeadmin_passwordfrom/etc/bor/server.yaml. - [ ] Move secrets from
server.yamlto/etc/bor/server.env(mode0640). - [ ] Set a dedicated
BOR_MFA_SECRETbefore 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(orjwt_secretif 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:
Add:
Then restart:
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:
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):
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.
-
Create the file:
-
Add the secrets you use, for example:
-
Remove the corresponding values from
server.yaml(or leavejwt_secretthere and do not setJWT_SECRET; do not keep two different values). - Restart:
sudo systemctl restart bor-server.
Notes:
jwt_secretmust be at least 32 characters; a shorter value stops the server withJWT_SECRET is too short. If it is missing or still the placeholderchange-me-in-production, the server generates a random one on each start and logsWARNING: JWT_SECRET not set; all sessions are then lost on every restart. The package generates a random secret on first install.- Changing
jwt_secretsigns everyone out. BOR_MFA_SECRETis only read from the environment. It encrypts stored TOTP secrets; when it is not set,jwt_secretis used instead. Set it before users enroll authenticator apps and never change it afterwards (norjwt_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.
- Log in as
admin. - 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.
-
Remove the password from the configuration:
Delete the
admin_password:line or turn it back into a comment (#admin_password: ""). -
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):
- Go to Settings -> MFA Policy.
- Turn on Require MFA for all users.
- 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.
- Optionally pick the TOTP algorithm (
SHA256orSHA512). It applies to new enrollments. - 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:
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: trueon port 389). LDAPS wins if both are set. - Set
ldap.tls_ca_fileto 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 againstldap.host. - Do not use
ldap.tls_skip_verify. The server refuses to start with it unlessBOR_DEV_MODE=trueorBOR_ALLOW_INSECURE_LDAP=trueis also set. - Use a dedicated read-only service account for
bind_dnand put its password inLDAP_BIND_PASSWORDinserver.env. - Map directory groups to Bor roles with
ldap.group_role_mapand keep the mapping toSuper Adminas 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-agentstarts 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 onbor_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-fileor theBOR_ENROLLMENT_TOKENenvironment variable.--tokenon 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_tokenempty 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.