From 0470cdd16a6895564272b9f9bba1255c81221f4b Mon Sep 17 00:00:00 2001 From: Blagovest Petrov Date: Thu, 12 Nov 2026 18:00:52 +0200 Subject: [PATCH] fix(security): close certificate, audit and enrollment gaps Agent certificates: - The agent checks its certificate every 12 hours, not only at start, and renews it 30 days before expiry. - The policy port closes agent connections after at most 24 hours, so a renewed certificate is taken into use and an expired one cannot keep an old connection. - Renewal keeps the presented certificate valid until the node first opens a policy stream with the new one, then revokes it (reason "superseded by renewal"). A renewal retried with the previous certificate revokes the unused one; any older certificate is refused. Renewal no longer deletes the node's revocation records. - Revoking or decommissioning a node revokes its current and previous certificates. - Open policy streams re-check revocation and expiry every minute. - New migration 000048 adds nodes.previous_cert_serial. Enrollment: - Enroll, KerberosEnroll and CreateEnrollmentToken are rate limited per client IP (BOR_ENROLL_RATE_LIMIT, default 60 per minute, 0 disables). The limiter moves to internal/ratelimit and is shared with the HTTP auth routes. - Kerberos enrollment refuses principals outside kerberos.realm (when set) and accepts only machine accounts (host/, $). The node name must belong to the principal. Audit: - Forwarding headers (X-Forwarded-For, X-Real-IP) are believed only from BOR_TRUSTED_PROXIES / audit.trusted_proxies; X-Forwarded-For is walked from the right, skipping trusted hops. - BOR_AUDIT_ANONYMIZE_IPS now also applies to sign-in and sign-out. - bor-server --reset-mfa writes auth.mfa.reset audit records and refuses to reset MFA when it cannot write them. Other: - CSRF and metrics bearer tokens are compared in constant time. - The agent resets its reconnect backoff after a stream that stayed up for at least a minute. BREAKING CHANGE: a Kerberos-enrolled agent whose agent.client_id does not match its machine principal is now refused. --- agent/cmd/agent/main.go | 27 +-- agent/cmd/agent/trust.go | 53 ++++++ doc/configuration/kerberos.md | 46 +++-- doc/configuration/server.md | 4 +- doc/configuration/tls.md | 46 +++-- doc/development/architecture.md | 18 +- doc/getting-started/concepts.md | 3 +- doc/installation/agent.md | 3 +- doc/operations/troubleshooting.md | 6 +- doc/security/compliance.md | 32 ++-- doc/security/hardening.md | 25 +-- doc/user-guide/audit-log.md | 13 +- doc/user-guide/nodes.md | 11 +- server/internal/api/audit_middleware.go | 101 +++++++++-- server/internal/api/audit_middleware_test.go | 49 ++++- server/internal/api/auth.go | 2 +- server/internal/api/middleware.go | 67 +------ server/internal/api/nodes.go | 10 +- server/internal/api/permission_gate.go | 2 +- server/internal/config/config.go | 68 ++++++- .../000048_node_previous_cert.down.sql | 4 + .../000048_node_previous_cert.up.sql | 9 + server/internal/database/revocations.go | 168 ++++++++++++++++-- server/internal/grpc/enrollment_server.go | 10 +- server/internal/grpc/interceptor.go | 15 ++ server/internal/grpc/ratelimit_interceptor.go | 56 ++++++ .../grpc/ratelimit_interceptor_test.go | 62 +++++++ server/internal/grpc/server.go | 59 +++++- server/internal/metrics/server.go | 7 +- server/internal/ratelimit/ratelimit.go | 86 +++++++++ server/internal/ratelimit/ratelimit_test.go | 51 ++++++ server/internal/services/enrollment.go | 48 +++-- server/internal/services/kerberos.go | 70 ++++++-- server/internal/services/kerberos_test.go | 45 +++++ server/pkg/app/app.go | 72 +++++++- 35 files changed, 1122 insertions(+), 226 deletions(-) create mode 100644 server/internal/database/migrations/000048_node_previous_cert.down.sql create mode 100644 server/internal/database/migrations/000048_node_previous_cert.up.sql create mode 100644 server/internal/grpc/ratelimit_interceptor.go create mode 100644 server/internal/grpc/ratelimit_interceptor_test.go create mode 100644 server/internal/ratelimit/ratelimit.go create mode 100644 server/internal/ratelimit/ratelimit_test.go create mode 100644 server/internal/services/kerberos_test.go diff --git a/agent/cmd/agent/main.go b/agent/cmd/agent/main.go index 62eaadfe..7fc4560b 100644 --- a/agent/cmd/agent/main.go +++ b/agent/cmd/agent/main.go @@ -466,19 +466,10 @@ To follow the agent logs: agentAddr := cfg.Server.PolicyAddr() // ─── Certificate renewal check ──────────────────────────────────── - // Renew the agent certificate if it expires within 30 days. - const renewThreshold = 30 * 24 * time.Hour - expiring, expiryErr := policyclient.CertExpiringSoon(paths.CertFile, renewThreshold) - if expiryErr != nil { - log.Printf("Warning: could not check cert expiry: %v", expiryErr) - } else if expiring { - log.Println("Certificate expires within 30 days, renewing...") - if renewErr := policyclient.RenewCertificate(agentAddr, paths.CACert, paths.CertFile, paths.KeyFile); renewErr != nil { - log.Printf("Warning: certificate renewal failed: %v - will retry next cycle", renewErr) - } - } - + // Renew the agent certificate if it expires within 30 days; a running + // agent checks again periodically (watchCertExpiry below). agentCreds = &agentCredentials{serverAddr: agentAddr, paths: paths} + agentCreds.renewIfExpiring() // ─── Connect with mTLS credentials ──────────────────────────────── client, err := policyclient.New( @@ -507,6 +498,8 @@ To follow the agent logs: cancel() }() + go agentCreds.watchCertExpiry(ctx) + // Start the file watcher to restore managed files if tampered externally. var watcherErr error fileWatcher, watcherErr = filewatcher.New(func(path string) { @@ -614,6 +607,10 @@ To follow the agent logs: log.Println("Bor Agent stopped") } +// stableStreamDuration is how long a policy stream must stay up for its end +// to reset the reconnect backoff. +const stableStreamDuration = time.Minute + // runStreamingLoop connects to the server's SubscribePolicyUpdates // stream and applies policies as they arrive. On stream failure it // reconnects with exponential backoff. The last known revision is @@ -766,6 +763,7 @@ func runStreamingLoop(ctx context.Context, client *policyclient.Client, cfg *con startUpdatesInventoryTicker(ctx, client, cfg) var postInitialSync bool + streamStart := time.Now() err := client.SubscribePolicyUpdates(ctx, lastRevision, func(updateType string, pi *policyclient.PolicyInfo, revision int64, snapshotComplete bool) { // Don't let targeted server-to-agent commands overwrite the @@ -781,6 +779,11 @@ func runStreamingLoop(ctx context.Context, client *policyclient.Client, cfg *con return // parent context cancelled - shutting down } + // A stream that stayed up for a while ended normally (the server + // closes connections daily); reconnect quickly. + if time.Since(streamStart) >= stableStreamDuration { + backoff = time.Second + } log.Printf("Policy stream disconnected: %v - reconnecting in %v", err, backoff) select { diff --git a/agent/cmd/agent/trust.go b/agent/cmd/agent/trust.go index 9f507800..c615aa4b 100644 --- a/agent/cmd/agent/trust.go +++ b/agent/cmd/agent/trust.go @@ -5,6 +5,7 @@ package main import ( + "context" "log" "sync" "time" @@ -17,6 +18,14 @@ import ( // renew in a loop. const renewRetryInterval = time.Hour +// certRenewThreshold is how long before expiry the agent renews its +// certificate, and certCheckInterval how often a running agent checks. +// The server issues certificates for 90 days. +const ( + certRenewThreshold = 30 * 24 * time.Hour + certCheckInterval = 12 * time.Hour +) + // agentCredentials is where the enrolled agent keeps its CA certificates and // its own certificate, and how it reacts when the server replaces its CA. type agentCredentials struct { @@ -74,3 +83,47 @@ func (c *agentCredentials) apply(res *policyclient.HeartbeatResult) (trustChange log.Println("Certificate renewed") return trustChanged } + +// renewIfExpiring renews the agent certificate when it expires within +// certRenewThreshold. The renewed certificate is used from the next +// connection on; the server closes connections daily. +func (c *agentCredentials) renewIfExpiring() { + if c == nil { + return + } + expiring, err := policyclient.CertExpiringSoon(c.paths.CertFile, certRenewThreshold) + if err != nil { + log.Printf("Warning: could not check certificate expiry: %v", err) + return + } + if !expiring { + return + } + c.mu.Lock() + defer c.mu.Unlock() + if !c.lastRenewal.IsZero() && time.Since(c.lastRenewal) < renewRetryInterval { + return + } + c.lastRenewal = time.Now() + log.Println("Certificate expires within 30 days, renewing...") + if err := policyclient.RenewCertificate(c.serverAddr, c.paths.CACert, c.paths.CertFile, c.paths.KeyFile); err != nil { + log.Printf("Warning: certificate renewal failed: %v - will retry in %s", err, certCheckInterval) + return + } + log.Println("Certificate renewed") +} + +// watchCertExpiry checks the agent certificate every certCheckInterval +// until ctx ends, so an agent that runs for months renews in time. +func (c *agentCredentials) watchCertExpiry(ctx context.Context) { + ticker := time.NewTicker(certCheckInterval) + defer ticker.Stop() + for { + select { + case <-ctx.Done(): + return + case <-ticker.C: + c.renewIfExpiring() + } + } +} diff --git a/doc/configuration/kerberos.md b/doc/configuration/kerberos.md index 61899e5b..77428577 100644 --- a/doc/configuration/kerberos.md +++ b/doc/configuration/kerberos.md @@ -22,22 +22,44 @@ directory accounts is a separate feature, see [LDAP](ldap.md). 4. The Bor server decrypts and validates the ticket with its own service keytab. It does not contact the KDC and does not need to be joined to the domain. -5. The server signs the certificate, creates the node in the configured - default node group and returns the certificate and CA certificate. +5. The server checks that the principal is a machine account of the + configured realm and that the requested node name belongs to it, signs + the certificate, creates the node in the configured default node group + and returns the certificate and CA certificate. Because the server only needs the keytab, the Bor server itself can stay outside the domain. The agent hosts must reach the KDC on port 88 (TCP and UDP). -!!! warning "Who can enroll" - The server accepts any client whose service ticket for the Bor principal - it can decrypt with its keytab. It does not restrict enrollment to - particular hosts, groups or realms. Every account that can obtain a - ticket for the Bor service principal from your KDC can therefore enroll - a node, and all of them land in the same default node group. Keep the - default node group free of sensitive policy assignments, or restrict - ticket issuance for the service principal in the directory. Review new - nodes in **Nodes** and decommission unexpected ones. +### Who can enroll + +The server accepts a ticket only when: + +- it can decrypt it with its keytab, +- the client principal is in `kerberos.realm` (when set; tickets from + trusted foreign realms are refused), and +- the client principal is a machine account: `host/` (FreeIPA, MIT + Kerberos) or `$` (Active Directory, Samba AD). User and service + principals cannot enroll a node. + +The node name is bound to that principal, ignoring case: + +| Principal | Accepted node names | Without a requested name | +|-----------|---------------------|--------------------------| +| `host/ws042.example.com@EXAMPLE.COM` | `ws042.example.com` or `ws042` | `ws042.example.com` | +| `WS042$@EXAMPLE.COM` | `ws042`, or any FQDN whose first label is `ws042` | `ws042` | + +Any other name (from `agent.client_id`, or the host name when that is +unset) is refused with `node name ... does not match principal ...`. One +machine's keytab therefore cannot enroll under another machine's name. + +!!! warning "Default node group" + Every machine account that can obtain a ticket for the Bor service + principal can still enroll itself, and all of them land in the same + default node group. Keep that group free of sensitive policy + assignments, or restrict ticket issuance for the service principal in + the directory. Review new nodes in **Nodes** and decommission + unexpected ones. ## Prerequisites @@ -153,7 +175,7 @@ kerberos: | YAML key | Environment variable | Description | |----------|----------------------|-------------| | `kerberos.enabled` | `BOR_KERBEROS_ENABLED` | Turn on the `KerberosEnroll` call. While it is off, agents get `Kerberos enrollment is not enabled on this server`. | -| `kerberos.realm` | `BOR_KERBEROS_REALM` | Realm, for reference in the startup log. Tickets are validated with the keytab. | +| `kerberos.realm` | `BOR_KERBEROS_REALM` | Realm whose machine accounts may enroll. Tickets for principals of any other realm are refused. Empty accepts any realm the keytab can validate. | | `kerberos.keytab_file` | `BOR_KERBEROS_KEYTAB` | Service keytab. Read at startup; if it cannot be loaded, the server stops with `Failed to initialize Kerberos service`. | | `kerberos.service_principal` | `BOR_KERBEROS_PRINCIPAL` | Service principal, shown in the startup log. Agents must use the same value. | | `kerberos.default_node_group_id` | `BOR_KERBEROS_DEFAULT_NODE_GROUP` | Group ID from step 2. Without it, every Kerberos enrollment is refused. | diff --git a/doc/configuration/server.md b/doc/configuration/server.md index 1a2da63b..7de1e160 100644 --- a/doc/configuration/server.md +++ b/doc/configuration/server.md @@ -178,6 +178,7 @@ Web UI sessions and bootstrap credentials. | `security.jwt_lifetime` | `BOR_JWT_LIFETIME` | `1h` | Lifetime of the access token. | | `security.refresh_lifetime` | `BOR_REFRESH_LIFETIME` | `24h` | Lifetime of the refresh token, kept in an HTTP-only cookie. A user must sign in again after this time. | | `security.admin_token` | `BOR_ADMIN_TOKEN` | `""` | Static token that authorizes the `CreateEnrollmentToken` gRPC call (header `x-admin-token`) on the enrollment port, for scripted token creation. Empty disables that call. Enrollment tokens created in the web UI do not need it. | +| `security.enroll_rate_limit` | `BOR_ENROLL_RATE_LIMIT` | `60` | Most `Enroll`, `KerberosEnroll` and `CreateEnrollmentToken` calls accepted per client IP address per minute on the enrollment port. Further calls fail with `ResourceExhausted` until the minute is over. Raise it when many machines enroll at once from behind one NAT address; `0` turns the limit off. | | `security.admin_password` | `BOR_ADMIN_PASSWORD` | `""` | Password for the initial `admin` account. Used only when the user table is empty, that is on the very first start. The package writes a random value here at install time. Once you have signed in and changed the password, remove the value from the file. | ```yaml @@ -322,7 +323,8 @@ log](../user-guide/audit-log.md). | YAML key | Environment variable | Default | Description | |----------|----------------------|---------|-------------| | `audit.retention_days` | `BOR_AUDIT_RETENTION_DAYS` | `365` | Audit records older than this are purged. `0` keeps them forever. | -| (none) | `BOR_AUDIT_ANONYMIZE_IPS` | `false` | Store client IP addresses truncated to /24 (IPv4) or /48 (IPv6). Environment variable only: an `anonymize_ips` key in `server.yaml` is ignored. | +| (none) | `BOR_AUDIT_ANONYMIZE_IPS` | `false` | Store client IP addresses truncated to /24 (IPv4) or /48 (IPv6), in every audit record including sign-in and sign-out. Environment variable only: an `anonymize_ips` key in `server.yaml` is ignored. | +| `audit.trusted_proxies` | `BOR_TRUSTED_PROXIES` | empty | Reverse proxies (IP addresses or CIDR ranges; comma-separated in the environment variable) whose `X-Forwarded-For` and `X-Real-IP` headers are believed when the audit log records a client IP. Requests from any other address are recorded with their TCP peer address, and the headers are ignored. Rate limiting always uses the peer address. | | `audit.syslog.enabled` | `BOR_AUDIT_SYSLOG_ENABLED` | `false` | Also send every audit event to a syslog receiver. | | `audit.syslog.network` | `BOR_AUDIT_SYSLOG_NETWORK` | `udp` | `udp`, `tcp` or `tcp+tls`. | | `audit.syslog.addr` | `BOR_AUDIT_SYSLOG_ADDR` | `localhost:514` | `host:port` of the receiver. | diff --git a/doc/configuration/tls.md b/doc/configuration/tls.md index 2e924356..4799c59a 100644 --- a/doc/configuration/tls.md +++ b/doc/configuration/tls.md @@ -496,27 +496,45 @@ The agent stores the key, certificate and CA certificate in ### Renewal -When the agent starts and its certificate expires within **30 days**, it -generates a new key pair, requests a new certificate over the existing -mutual TLS connection on port 8444, and replaces the files on disk. No -token and no administrator action are needed. If renewal fails, the agent -logs `certificate renewal failed` and tries again at its next start. - -Because the check runs at agent start, an endpoint whose agent runs -without interruption for more than 60 days only renews at its next restart -(a reboot or a package upgrade restarts it). If a certificate expires, the -agent can no longer connect and must be re-enrolled with a new token. +The agent checks its certificate when it starts and every 12 hours while +it runs. When the certificate expires within **30 days**, the agent +generates a new key pair, requests a new certificate over mutual TLS on +port 8444, and replaces the files on disk. No token and no administrator +action are needed. If renewal fails, the agent logs `certificate renewal +failed` and tries again at the next check. + +The agent uses the new certificate from its next connection on. The server +closes every agent connection after at most 24 hours (open streams get a +minute to finish), so a renewed certificate is in use within a day, and an +expired certificate cannot keep an old connection open. + +Until the agent connects with the new certificate, the previous one stays +valid, so an agent that lost the server's answer can renew again. The +first connection with the new certificate revokes the previous one +(reason `superseded by renewal`). A certificate that was already replaced +cannot be renewed. + +If a certificate expires anyway (for example the machine was switched off +for months), the agent can no longer connect and must be re-enrolled with a +new token. ### Revocation Bor keeps its own revocation list in the database instead of CRLs or OCSP. Every call on the policy port, and every authenticated call on the -enrollment port, checks it, so a revocation takes effect on the agent's -next connection. A certificate is revoked when: +enrollment port, checks it. An open policy stream checks it again every +minute and closes when its certificate is revoked or has expired. A +certificate is revoked when: - you click **Revoke Certificate** on a node in **Nodes**, or -- you **Decommission** (delete) a node: its certificate is revoked before - the record is removed. +- you **Decommission** (delete) a node: its certificates are revoked before + the record is removed, or +- the agent connects with a renewed certificate: the one it replaced is + revoked. + +Revoking or decommissioning a node revokes both its current certificate +and, shortly after a renewal, the previous one. Renewal never clears a +revocation. A revoked agent must be re-enrolled with a new token. See [Nodes](../user-guide/nodes.md). diff --git a/doc/development/architecture.md b/doc/development/architecture.md index 365f4d4c..231eb9f9 100644 --- a/doc/development/architecture.md +++ b/doc/development/architecture.md @@ -265,12 +265,18 @@ At start-up, after the enrollment check, the agent calls is close to expiry it generates a new key and CSR with the same CN and calls `RenewCertificate` on the policy port, authenticated with the current certificate. The server looks up the node by the verified CN, signs a new -90-day certificate and updates the node's certificate serial and expiry. -The agent then writes the new key and certificate and connects as usual. - -The check runs once per agent start, so a daemon that is never restarted -for longer than the certificate lifetime does not renew. Any restart of -the service (for example a reboot) runs the check again. +90-day certificate and records it as the node's current certificate; the +presented one becomes `previous_cert_serial`. The agent then writes the +new key and certificate. + +The check runs at agent start and every 12 hours. The new certificate is +used from the next connection on; the policy listener sets +`MaxConnectionAge` to 24 hours, so that happens within a day. When the +node opens a policy stream with its current certificate, the previous one +is revoked. A renewal authenticated with the previous certificate (the +agent lost the answer) revokes the unused current one instead; any other +certificate is refused. An open stream re-checks its certificate against +the revocation list and its expiry every minute. ### Compliance and tamper reports diff --git a/doc/getting-started/concepts.md b/doc/getting-started/concepts.md index 7c55b633..26056117 100644 --- a/doc/getting-started/concepts.md +++ b/doc/getting-started/concepts.md @@ -227,7 +227,8 @@ See [Users, groups and roles](../user-guide/users-and-roles.md) and the - Agents authenticate with a client certificate issued by the server's internal CA during enrollment. Certificates are valid for 90 days and the - agent renews its certificate at startup when it expires within 30 days. + agent renews its certificate when it expires within 30 days (checked at + startup and every 12 hours). - Enrollment tokens are single-use and expire after 5 minutes. - Deleting a node revokes its certificate; a revoked agent can no longer connect to the policy port. diff --git a/doc/installation/agent.md b/doc/installation/agent.md index 61ca20e1..3b7a30fd 100644 --- a/doc/installation/agent.md +++ b/doc/installation/agent.md @@ -377,7 +377,8 @@ sudo systemctl enable --now bor-agent sudo systemctl status bor-agent ``` -At start the agent renews its certificate if it expires within 30 days, +At start (and every 12 hours) the agent renews its certificate if it +expires within 30 days, connects to the policy port and applies every policy it receives. ## Verify the node diff --git a/doc/operations/troubleshooting.md b/doc/operations/troubleshooting.md index 8d73a894..955a3056 100644 --- a/doc/operations/troubleshooting.md +++ b/doc/operations/troubleshooting.md @@ -137,7 +137,9 @@ from `server.env` first if your database settings are there. The user can then sign in with their password. If MFA is required for all users, they are asked to enroll again (when self-enrollment is allowed). The -reset is not recorded in the Bor audit log; note it in your change log. +reset is recorded in the Bor audit log as `auth.mfa.reset`; if that record +cannot be written, the command fails with `write audit record (MFA not +reset)` and nothing changes. If **every** user's TOTP codes stopped working at the same time, the secret that encrypts TOTP data changed: `BOR_MFA_SECRET`, or `jwt_secret` when @@ -176,6 +178,8 @@ sudo bor-agent --token-file /etc/bor/enroll-token | `Enrollment failed: enrollment RPC failed: rpc error: code = Unauthenticated desc = enrollment failed: enrollment token expired` | The token is older than 5 minutes | Generate a new one (**Node Groups** -> **Generate token**) and use it right away | | `... desc = enrollment failed: invalid enrollment token` | Token mistyped, already used (tokens are single-use, even by a failed attempt), or from another server | Generate a new token | | `... code = Internal desc = enrolled but failed to create node record: a node named "" already exists; delete it before re-enrolling` | A node with this name (the host name, or `agent.client_id`) is already registered; the token is now used up | Decommission the old node on **Nodes**, or set a unique `agent.client_id`; then use a new token | +| `... code = ResourceExhausted desc = too many requests, retry in 1m0s` | More enrollment calls from this IP address in the last minute than `security.enroll_rate_limit` allows (default 60), common when many machines enroll from behind one NAT address | Wait a minute and retry, or raise `security.enroll_rate_limit` on the server | +| `... code = PermissionDenied desc = node name "" does not match principal ""` (Kerberos) | The node name (`agent.client_id` or the host name) does not belong to the machine principal | Leave `agent.client_id` unset, or set it to the principal's host name; see [Kerberos enrollment](../configuration/kerberos.md#who-can-enroll) | | `... x509: certificate signed by unknown authority` | The server uses the internal CA and the endpoint does not trust it | Install the CA into the system trust store (the **Deploy agent** wizard shows the commands), or set `server.insecure_skip_verify: true` in `/etc/bor/config.yaml` for enrollment only | | `... x509: certificate is valid for , not
` | `server.address` in the agent configuration is not in the server certificate | Use a listed name, or add the address to `server.hostnames` on the server and restart it | | `... x509: certificate has expired or is not yet valid` | The endpoint's clock is wrong (or the server certificate really expired) | Fix time synchronization (`timedatectl`), then retry | diff --git a/doc/security/compliance.md b/doc/security/compliance.md index 30d5a448..50739367 100644 --- a/doc/security/compliance.md +++ b/doc/security/compliance.md @@ -95,7 +95,7 @@ syslog) set a minimum of TLS 1.2 and otherwise use Go defaults. | Internal CA certified by an external authority (`bor-server ca csr` / `ca import`) | Key generated on the server or the token, ECDSA P-384 | Set by the external authority | Set by the external authority. Replaced in place when key and subject are kept; client certificates are verified against the Bor signing certificate only. | `pki` | | Internal CA in an HSM (PKCS#11, optional build) | ECDSA P-384 generated on the token | ECDSA with SHA-384 | 10 years by default (`ca.lifetime`) | `pki` (`pkcs11` build tag) | | Server certificate (`ui.crt` in `tls.autogen_dir`) | ECDSA P-256, `digitalSignature` key usage, `serverAuth` EKU | Signed by the CA (ECDSA with SHA-384) | 1 year. Regenerated at server start when it no longer verifies against the CA (including when expired) or when required host names are missing. | `pki` | -| Agent client certificate | ECDSA P-256 key generated on the endpoint, `digitalSignature` key usage, `clientAuth` EKU | Signed by the CA (ECDSA with SHA-384) | 90 days. The agent requests renewal (new key pair, new CSR over mTLS) when it starts and the certificate expires within 30 days. | `pki`, `policyclient` | +| Agent client certificate | ECDSA P-256 key generated on the endpoint, `digitalSignature` key usage, `clientAuth` EKU | Signed by the CA (ECDSA with SHA-384) | 90 days. The agent requests renewal (new key pair, new CSR over mTLS) when the certificate expires within 30 days, checked at start and every 12 hours. The replaced certificate is revoked once the new one is in use. | `pki`, `policyclient` | | Certificate serial numbers | 128-bit random from `crypto/rand` | - | - | `pki` | | CSR policy | The CA rejects RSA keys below 3072 bits and ECDSA curves other than P-256, P-384, P-521; Ed25519 is accepted; the CSR signature is verified. | - | - | `pki` | | ACME certificate (web port only) | ECDSA P-256 requested by default; RSA 2048 for clients that do not support ECDSA (behavior of the `golang.org/x/crypto/acme/autocert` library) | Issued by the ACME CA | Renewed automatically by the library | `pki` | @@ -104,8 +104,12 @@ syslog) set a minimum of TLS 1.2 and otherwise use Go defaults. serial numbers are stored in the `revoked_certificates` table. Every gRPC call and every new stream on both ports passes an interceptor that requires a verified client certificate (except the two enrollment calls) and rejects the -call if the serial number is revoked. **Revoke Certificate** and -**Decommission** on the **Nodes** page write that record; see +call if the serial number is revoked. An open policy stream re-checks its +certificate every minute and closes when it is revoked or expired, and the +policy port closes every connection after at most 24 hours. +**Revoke Certificate** and **Decommission** on the **Nodes** page revoke the +node's current and previous certificates; a renewal revokes the certificate +it replaces once the new one is in use. See [TLS and certificates](../configuration/tls.md). **PKCS#11.** HSM support for the CA key is compiled only with the `pkcs11` @@ -150,7 +154,7 @@ See [Multi-factor authentication](../configuration/mfa.md) for setup. |---|---|---| | Enrollment token | 256 bits from `crypto/rand`, hex-encoded; only its SHA-256 hash is stored; valid 5 minutes; consumed by the first enrollment attempt whether it succeeds or not; bound to one node group | `services` | | Admin token for the token-creation RPC | `security.admin_token`; compared in constant time; RPC disabled when empty | `grpc` | -| Kerberos enrollment | SPNEGO / Kerberos AP-REQ validated with the server's service keytab (`gokrb5` library). Bor does not restrict encryption types: they are those of the keytab and the KDC. Create the keytab with AES encryption types only. | `services` | +| Kerberos enrollment | SPNEGO / Kerberos AP-REQ validated with the server's service keytab (`gokrb5` library). Only machine principals (`host/`, `$`) of `kerberos.realm` are accepted, and the node name must belong to the principal. Bor does not restrict encryption types: they are those of the keytab and the KDC. Create the keytab with AES encryption types only. | `services` | See [Kerberos](../configuration/kerberos.md). @@ -292,7 +296,7 @@ Bor does not do or only partly does. | (e) Vulnerability handling and disclosure | Private disclosure channel (see [Vulnerability reporting](#vulnerability-reporting)) | Subscribe to GitHub releases | No published CVE / advisory feed | | (h) Cryptography and encryption | See [Cryptographic inventory](#cryptographic-inventory) | Enable FIPS mode if required; use TLS for PostgreSQL and LDAP | Database at-rest encryption is the operator's responsibility | | (i) Access control and asset management | RBAC with granular permissions; node inventory; certificate-based agent identity; revocation | Assign least-privilege roles ([Permissions](../reference/permissions.md)) | Node-group-scoped roles are not available in the community edition | -| (j) Multi-factor authentication | TOTP and WebAuthn, global enforcement, step-up for recovery-key reveals | Turn on **Require MFA for all users** ([Hardening guide](hardening.md#enforce-multi-factor-authentication)) | MFA is off by default; `bor-server --reset-mfa` bypasses the audit log | +| (j) Multi-factor authentication | TOTP and WebAuthn, global enforcement, step-up for recovery-key reveals | Turn on **Require MFA for all users** ([Hardening guide](hardening.md#enforce-multi-factor-authentication)) | MFA is off by default | ### EU Cybersecurity Act / EUCC @@ -382,8 +386,11 @@ over TCP with `sslmode: disable`. For PostgreSQL over TCP use | Integrity | None built in. Anyone with write access to the database can change or delete records, and retention purges them. For tamper evidence, forward events in real time to a SIEM or write-once store. | Not recorded in the audit log: read-only (`GET`) requests other than those -listed, failed (non-`2xx`) change requests other than permission denials, and -`bor-server --reset-mfa` (it runs outside the server process). +listed, and failed (non-`2xx`) change requests other than permission +denials. `bor-server --reset-mfa` writes `auth.mfa.reset` records directly +to the database (actor `local:`); it refuses to reset +MFA when it cannot write them. These records are not forwarded to syslog, +because the command runs outside the server process. Usage, filters, retention and syslog configuration: [Audit log](../user-guide/audit-log.md). @@ -413,20 +420,15 @@ risk assessment. | Area | Limitation | Mitigation | |---|---|---| -| Agent certificate renewal | The agent checks its certificate only when the service starts. An agent that runs for more than 60 days without a restart lets its certificate expire, after which the node must be re-enrolled. | Restart agents or reboot endpoints regularly; alert on `bor_node_certificate_expiry_seconds` ([Monitoring](../operations/monitoring.md)). | | Server certificate renewal | The internally issued server certificate (1 year) is regenerated only at server start. | Restart the server at least yearly, or use your own certificate or ACME for the web port. | -| Revocation of open streams | Revocation is checked when a call or stream starts. A policy stream that is already open is not closed when its certificate is revoked. | Restart the server or wait for the agent to reconnect. | -| Renewed certificates | On renewal the previous certificate is not revoked and stays valid until it expires (up to 30 days). Revoking a node from the UI revokes only the current serial, and a successful renewal clears the node's revocation records. | After revoking a node that renewed recently, **Decommission** it instead, which deletes the node record so its other certificates can no longer renew or stream. | -| Kerberos enrollment | The node name sent by the agent is not checked against the Kerberos principal, and the principal's realm is not compared with `kerberos.realm`. Any principal that can obtain a ticket for the service can enroll a new node into the default node group. | Restrict which principals can get service tickets; review new nodes; use token enrollment where this matters. | -| Audit source IP | The audit log takes the client IP from `X-Forwarded-For` or `X-Real-IP` when present, without checking for a trusted proxy, so a client can set it. Rate limiting uses the TCP peer address and is not affected. | Treat audit IP addresses as informational, or strip these headers at a trusted reverse proxy. | -| IP anonymization coverage | `BOR_AUDIT_ANONYMIZE_IPS` does not apply to sign-in and logout events. | Account for this in your records of processing. | +| Revocation of open streams | An open policy stream notices a revocation at its next check, up to one minute later. | None needed for most uses; decommission the node for the same effect plus removal of the record. | +| Kerberos enrollment | Any machine account of the realm that can obtain a ticket for the Bor service principal can enroll itself into the default node group. | Restrict which principals can get service tickets; keep the default group free of sensitive policies; review new nodes. | | Audit log integrity | No hash chain or signatures. | Forward events to a SIEM in real time. | | Compliance report integrity (Bor Enterprise) | A report's SHA-256 is re-checked and its signature verified on every open, but anyone with write access to the database can replace a bundle together with its hash; only the signature, the forwarded audit event and downloaded copies survive that. | Forward the audit log (the `report.generate` event carries the hash) to a SIEM or write-once store; keep downloaded bundles and signatures outside the server and verify them with `bor-verify`. | | Backup codes | Hashed with bcrypt at cost 10 (not a FIPS-approved algorithm); 40 bits of entropy per code. | Rate limiting applies to the sign-in endpoints. | | `BOR_MFA_SECRET` | No minimum length is enforced; when unset, `jwt_secret` is used, so changing `jwt_secret` breaks existing TOTP enrollments. | Set a dedicated 32-byte random value before users enroll. | | WebAuthn | No attestation, user verification only preferred. | Combine with a password (always required) and enforce MFA. | -| CSRF and metrics token comparison | Compared with ordinary string comparison rather than constant-time comparison. | Low risk; keep the metrics listener on loopback. | | Rate limiting behind a proxy | All clients behind a reverse proxy share the proxy's address for rate limiting. | Prefer direct TLS to the web port. | | PKCS#11 | Not included in packaged binaries; escrow KEK in an HSM not implemented. | Build with `make server-pkcs11` if you need an HSM for the CA. | | Database at rest | Bor does not encrypt PostgreSQL data or backups. | Use encrypted storage and encrypted backups. | -| MFA reset | `bor-server --reset-mfa` is not written to the audit log. | Record its use in your change log. | +| MFA reset | `bor-server --reset-mfa` writes its audit records to the database only; they are not forwarded to syslog. | Record its use in your change log as well. | diff --git a/doc/security/hardening.md b/doc/security/hardening.md index b4fccbf5..3bd21ef6 100644 --- a/doc/security/hardening.md +++ b/doc/security/hardening.md @@ -281,8 +281,10 @@ 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: +directly; it does not need the service to be running. It writes +`auth.mfa.reset` records (actor `local:`) to the +audit log and refuses to reset MFA if it cannot. The records are not +forwarded to syslog, so also record the reset in your change log: ```bash sudo -u bor bor-server --reset-mfa admin @@ -294,6 +296,8 @@ your secrets are in `server.env`, export them into the environment first. See 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`. +The agent enrollment calls on the enrollment port are limited to 60 per +client IP address per minute (`security.enroll_rate_limit`). ## Secure LDAP @@ -395,20 +399,21 @@ retention. 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. +every new stream, and an open stream re-checks it every minute, so a +revocation takes effect within a minute. - **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](../operations/monitoring.md)). An - expired certificate cannot be renewed; the node must be re-enrolled. +- **Renewal**: when the certificate expires within 30 days (checked at start + and every 12 hours), `bor-agent` requests a new one from port 8444, + authenticating with its current certificate. The replaced certificate is + revoked once the agent connects with the new one. Alert on + `bor_node_certificate_expiry_seconds` (see [Monitoring](../operations/monitoring.md)) + to catch machines that stay switched off: an expired certificate cannot be + renewed, and 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. diff --git a/doc/user-guide/audit-log.md b/doc/user-guide/audit-log.md index e5be6f07..409815e4 100644 --- a/doc/user-guide/audit-log.md +++ b/doc/user-guide/audit-log.md @@ -107,10 +107,15 @@ BOR_AUDIT_ANONYMIZE_IPS=true ``` Restart the server after changing it (`sudo systemctl restart -bor-server`). It affects new records only. Note that IP addresses are taken -from `X-Forwarded-For` or `X-Real-IP` when present, so a reverse proxy in -front of Bor must set those headers for the logged address to be the -client's. +bor-server`). It affects new records only, including sign-in and sign-out +events. + +The recorded address is the TCP peer of the request. Behind a reverse +proxy, list the proxy in `audit.trusted_proxies` (`BOR_TRUSTED_PROXIES`, see +[Server configuration](../configuration/server.md#audit)); the server then +takes the client address from the proxy's `X-Forwarded-For` or `X-Real-IP` +header. Those headers are ignored from any other address, so a client +cannot choose the address that is logged. ## Forward to a SIEM over syslog diff --git a/doc/user-guide/nodes.md b/doc/user-guide/nodes.md index b5aab07e..c627c570 100644 --- a/doc/user-guide/nodes.md +++ b/doc/user-guide/nodes.md @@ -206,11 +206,12 @@ Decommissioning requires `node:delete`. ## Revoke a certificate -**Revoke Certificate** in the node details revokes the node's current -client certificate without deleting the node record. Use it when you -suspect the machine's credentials are compromised. The panel confirms with -**Certificate revoked**: the agent is denied on its next connection -attempt. The node record, its notes and its group memberships remain. To +**Revoke Certificate** in the node details revokes the node's client +certificates (the current one and, shortly after a renewal, the one it +replaced) without deleting the node record. Use it when you suspect the +machine's credentials are compromised. The panel confirms with +**Certificate revoked**: an open policy stream closes within a minute and +every further request from the agent is denied. The node record, its notes and its group memberships remain. To bring the machine back, [re-enroll](#re-enroll-a-node) it. This action requires `node:create`. diff --git a/server/internal/api/audit_middleware.go b/server/internal/api/audit_middleware.go index 7f25a05c..41e6063a 100644 --- a/server/internal/api/audit_middleware.go +++ b/server/internal/api/audit_middleware.go @@ -10,7 +10,9 @@ import ( "io" "net" "net/http" + "net/netip" "strings" + "sync/atomic" "github.com/VuteTech/Bor/server/internal/services" auditpb "github.com/VuteTech/Bor/server/pkg/grpc/audit" @@ -209,41 +211,110 @@ func parseResourceFromPath(path string) (resource, action string) { return resourceType, resourceID } +// clientIPPolicy is the deployment's policy for recording client IPs, +// set once at startup by ConfigureClientIP. +type clientIPPolicy struct { + trustedProxies []netip.Prefix + anonymize bool +} + +var ipPolicy atomic.Pointer[clientIPPolicy] + +// ConfigureClientIP sets which reverse proxies are trusted to report the +// client address in X-Forwarded-For / X-Real-IP, and whether audit records +// anonymize IPs by default. Requests from any other peer are recorded with +// their TCP peer address, so a client cannot choose the IP that is audited. +func ConfigureClientIP(trustedProxies []netip.Prefix, anonymize bool) { + ipPolicy.Store(&clientIPPolicy{ + trustedProxies: append([]netip.Prefix(nil), trustedProxies...), + anonymize: anonymize, + }) +} + +func currentIPPolicy() *clientIPPolicy { + if p := ipPolicy.Load(); p != nil { + return p + } + return &clientIPPolicy{} +} + +func (p *clientIPPolicy) trusted(addr netip.Addr) bool { + addr = addr.Unmap() + for _, pfx := range p.trustedProxies { + if pfx.Contains(addr) { + return true + } + } + return false +} + // AuditClientIP returns the client IP for an audit record, anonymized when // anonymize is set (GDPR). Used by server extensions. func AuditClientIP(r *http.Request, anonymize bool) string { return extractAuditIP(r, anonymize) } +// defaultAuditIP returns the client IP for an audit record, anonymized when +// the deployment enables BOR_AUDIT_ANONYMIZE_IPS. +func defaultAuditIP(r *http.Request) string { + return extractAuditIP(r, currentIPPolicy().anonymize) +} + // extractAuditIP extracts the client IP, optionally anonymizing it for GDPR. func extractAuditIP(r *http.Request, anonymize bool) string { ip := extractIP(r) - if anonymize { + if anonymize || currentIPPolicy().anonymize { return anonymizeIP(ip) } return ip } -// extractIP extracts the client IP address from the request. +// extractIP returns the client IP address of the request. Forwarding +// headers are honoured only when the TCP peer is a configured trusted proxy; +// X-Forwarded-For is then walked from the right, skipping trusted hops, so +// the first address not added by our own proxies is the one recorded. func extractIP(r *http.Request) string { - // Check X-Forwarded-For first (behind proxy) - if xff := r.Header.Get("X-Forwarded-For"); xff != "" { - parts := strings.SplitN(xff, ",", 2) - return strings.TrimSpace(parts[0]) + peer := peerIP(r.RemoteAddr) + pol := currentIPPolicy() + peerAddr, err := netip.ParseAddr(peer) + if err != nil || !pol.trusted(peerAddr) { + return peer } - // Check X-Real-IP - if xri := r.Header.Get("X-Real-IP"); xri != "" { - return xri + if xff := r.Header.Values("X-Forwarded-For"); len(xff) > 0 { + hops := strings.Split(strings.Join(xff, ","), ",") + leftmost := "" + for i := len(hops) - 1; i >= 0; i-- { + hop := strings.TrimSpace(hops[i]) + a, perr := netip.ParseAddr(hop) + if perr != nil { + // A malformed hop means the chain beyond it cannot be trusted. + break + } + leftmost = a.Unmap().String() + if !pol.trusted(a) { + return leftmost + } + } + if leftmost != "" { + return leftmost + } } - // Fall back to RemoteAddr - addr := r.RemoteAddr - // Remove port if present - if idx := strings.LastIndex(addr, ":"); idx != -1 { - return addr[:idx] + if xri := strings.TrimSpace(r.Header.Get("X-Real-IP")); xri != "" { + if a, perr := netip.ParseAddr(xri); perr == nil { + return a.Unmap().String() + } + } + return peer +} + +// peerIP strips the port from an http.Request RemoteAddr. +func peerIP(remoteAddr string) string { + if host, _, err := net.SplitHostPort(remoteAddr); err == nil { + return host } - return addr + return remoteAddr } // anonymizeIP truncates an IP address for GDPR data minimization: diff --git a/server/internal/api/audit_middleware_test.go b/server/internal/api/audit_middleware_test.go index 9095f8f0..fa71a64d 100644 --- a/server/internal/api/audit_middleware_test.go +++ b/server/internal/api/audit_middleware_test.go @@ -7,6 +7,7 @@ package api import ( "net/http" "net/http/httptest" + "net/netip" "testing" ) @@ -60,37 +61,67 @@ func TestMethodToAction(t *testing.T) { } func TestExtractIP(t *testing.T) { + proxies := []netip.Prefix{ + netip.MustParsePrefix("10.0.0.0/8"), + netip.MustParsePrefix("192.0.2.1/32"), + } tests := []struct { name string - xff string + trusted []netip.Prefix + xff []string xri string remoteAddr string want string }{ - {"xff first", "1.2.3.4, 5.6.7.8", "", "9.10.11.12:1234", "1.2.3.4"}, - {"xri", "", "10.0.0.1", "9.10.11.12:1234", "10.0.0.1"}, - {"remoteAddr with port", "", "", "192.168.1.1:4567", "192.168.1.1"}, - {"remoteAddr no port", "", "", "192.168.1.1", "192.168.1.1"}, + {"no proxies: xff ignored", nil, []string{"1.2.3.4"}, "", "9.10.11.12:1234", "9.10.11.12"}, + {"no proxies: xri ignored", nil, nil, "1.2.3.4", "9.10.11.12:1234", "9.10.11.12"}, + {"untrusted peer: xff ignored", proxies, []string{"1.2.3.4"}, "", "9.10.11.12:1234", "9.10.11.12"}, + {"trusted peer: single hop", proxies, []string{"1.2.3.4"}, "", "10.1.1.1:1234", "1.2.3.4"}, + {"trusted peer: spoofed left entry skipped", proxies, []string{"6.6.6.6, 1.2.3.4"}, "", "10.1.1.1:1234", "1.2.3.4"}, + {"trusted chain skipped", proxies, []string{"1.2.3.4, 192.0.2.1"}, "", "10.1.1.1:1234", "1.2.3.4"}, + {"multiple header lines", proxies, []string{"6.6.6.6", "1.2.3.4"}, "", "10.1.1.1:1234", "1.2.3.4"}, + {"all hops trusted", proxies, []string{"10.2.2.2"}, "", "10.1.1.1:1234", "10.2.2.2"}, + {"malformed hop stops the walk", proxies, []string{"1.2.3.4, garbage, 10.3.3.3"}, "", "10.1.1.1:1234", "10.3.3.3"}, + {"trusted peer: xri", proxies, nil, "5.6.7.8", "10.1.1.1:1234", "5.6.7.8"}, + {"trusted peer: bad xri", proxies, nil, "nope", "10.1.1.1:1234", "10.1.1.1"}, + {"ipv6 peer", nil, nil, "", "[2001:db8::1]:443", "2001:db8::1"}, + {"remoteAddr no port", nil, nil, "", "192.168.1.1", "192.168.1.1"}, } + t.Cleanup(func() { ConfigureClientIP(nil, false) }) for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { + ConfigureClientIP(tt.trusted, false) req := httptest.NewRequest(http.MethodGet, "/", http.NoBody) req.RemoteAddr = tt.remoteAddr - if tt.xff != "" { - req.Header.Set("X-Forwarded-For", tt.xff) + for _, v := range tt.xff { + req.Header.Add("X-Forwarded-For", v) } if tt.xri != "" { req.Header.Set("X-Real-IP", tt.xri) } - got := extractIP(req) - if got != tt.want { + if got := extractIP(req); got != tt.want { t.Errorf("extractIP() = %q, want %q", got, tt.want) } }) } } +func TestDefaultAuditIPAnonymizes(t *testing.T) { + t.Cleanup(func() { ConfigureClientIP(nil, false) }) + req := httptest.NewRequest(http.MethodPost, "/api/v1/auth/login", http.NoBody) + req.RemoteAddr = "203.0.113.77:5555" + + ConfigureClientIP(nil, false) + if got := defaultAuditIP(req); got != "203.0.113.77" { + t.Errorf("defaultAuditIP() = %q, want plain address", got) + } + ConfigureClientIP(nil, true) + if got := defaultAuditIP(req); got != "203.0.113.0" { + t.Errorf("defaultAuditIP() = %q, want anonymized 203.0.113.0", got) + } +} + func TestStatusRecorder(t *testing.T) { rec := httptest.NewRecorder() sr := &statusRecorder{ResponseWriter: rec, statusCode: http.StatusOK} diff --git a/server/internal/api/auth.go b/server/internal/api/auth.go index f869f722..6f21a12c 100644 --- a/server/internal/api/auth.go +++ b/server/internal/api/auth.go @@ -82,7 +82,7 @@ func (h *AuthHandler) emitAuthAudit(r *http.Request, action, username string, su Action: action, Resource: &auditpb.Resource{Type: "auth", Id: username}, Outcome: outcome, - SrcIp: extractIP(r), + SrcIp: defaultAuditIP(r), Payload: &auditpb.AuditEvent_HttpChange{ HttpChange: &auditpb.HttpPayload{ Method: r.Method, diff --git a/server/internal/api/middleware.go b/server/internal/api/middleware.go index 9d5a1150..46a63597 100644 --- a/server/internal/api/middleware.go +++ b/server/internal/api/middleware.go @@ -7,6 +7,7 @@ package api import ( "context" "crypto/rand" + "crypto/subtle" "encoding/hex" "net" "net/http" @@ -16,6 +17,7 @@ import ( "time" "github.com/VuteTech/Bor/server/internal/authz" + "github.com/VuteTech/Bor/server/internal/ratelimit" "github.com/VuteTech/Bor/server/internal/services" ) @@ -277,7 +279,7 @@ func CSRFMiddleware(next http.Handler) http.Handler { } header := r.Header.Get("X-CSRF-Token") - if header == "" || header != cookie.Value { + if header == "" || subtle.ConstantTimeCompare([]byte(header), []byte(cookie.Value)) != 1 { http.Error(w, `{"error":"invalid CSRF token"}`, http.StatusForbidden) return } @@ -286,65 +288,6 @@ func CSRFMiddleware(next http.Handler) http.Handler { }) } -// ipBucket tracks request counts within a sliding time window for one IP. -type ipBucket struct { - count int - windowStart time.Time -} - -// ipRateLimiter is a simple per-IP sliding-window rate limiter. -type ipRateLimiter struct { - mu sync.Mutex - clients map[string]*ipBucket - rate int // max requests allowed per window - window time.Duration // length of the window -} - -// newIPRateLimiter creates a rate limiter and starts a background cleanup goroutine. -func newIPRateLimiter(rate int, window time.Duration) *ipRateLimiter { - rl := &ipRateLimiter{ - clients: make(map[string]*ipBucket), - rate: rate, - window: window, - } - go rl.cleanupLoop() - return rl -} - -// allow returns true if the IP is within the rate limit, false if it should be rejected. -func (rl *ipRateLimiter) allow(ip string) bool { - rl.mu.Lock() - defer rl.mu.Unlock() - - now := time.Now() - b, ok := rl.clients[ip] - if !ok || now.Sub(b.windowStart) >= rl.window { - rl.clients[ip] = &ipBucket{count: 1, windowStart: now} - return true - } - if b.count >= rl.rate { - return false - } - b.count++ - return true -} - -// cleanupLoop removes stale entries every two windows to bound memory use. -func (rl *ipRateLimiter) cleanupLoop() { - ticker := time.NewTicker(rl.window * 2) - defer ticker.Stop() - for range ticker.C { - rl.mu.Lock() - now := time.Now() - for ip, b := range rl.clients { - if now.Sub(b.windowStart) >= rl.window*2 { - delete(rl.clients, ip) - } - } - rl.mu.Unlock() - } -} - // clientIP extracts the request's remote IP from RemoteAddr, ignoring the port. // RemoteAddr is set by the HTTP server and cannot be spoofed by the client. func clientIP(r *http.Request) string { @@ -358,11 +301,11 @@ func clientIP(r *http.Request) string { // NewRateLimitMiddleware returns a middleware that limits callers to rate requests // per window per remote IP. Excess requests receive 429 with a Retry-After header. func NewRateLimitMiddleware(rate int, window time.Duration) func(http.Handler) http.Handler { - rl := newIPRateLimiter(rate, window) + rl := ratelimit.New(rate, window) retryAfter := strconv.Itoa(int(window.Seconds())) return func(next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - if !rl.allow(clientIP(r)) { + if !rl.Allow(clientIP(r)) { w.Header().Set("Retry-After", retryAfter) http.Error(w, `{"error":"rate limit exceeded, try again later"}`, http.StatusTooManyRequests) return diff --git a/server/internal/api/nodes.go b/server/internal/api/nodes.go index d0e5c041..3fa0a842 100644 --- a/server/internal/api/nodes.go +++ b/server/internal/api/nodes.go @@ -245,7 +245,7 @@ func (h *NodeHandler) Delete(w http.ResponseWriter, r *http.Request) { // cannot be reused (the DB record that app-level checks rely on is gone, // but the cert remains cryptographically valid until its 90-day expiry). if h.enrollSvc != nil && node.CertSerial != nil && *node.CertSerial != "" { - if err := h.enrollSvc.RevokeCertificate(r.Context(), id, *node.CertSerial, "node deleted"); err != nil { + if _, err := h.enrollSvc.RevokeNode(r.Context(), id, "node deleted"); err != nil { log.Printf("Failed to revoke certificate for node %s before delete: %v", id, err) //nolint:gosec // id comes from URL path parameter http.Error(w, `{"error":"failed to revoke node certificate"}`, http.StatusInternalServerError) return @@ -410,8 +410,8 @@ func (h *NodeHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) { } // RevokeNodeCertificate handles POST /api/v1/nodes/{id}/revoke. -// It revokes the current mTLS certificate of the node, preventing it from -// connecting to the policy gRPC server until a new certificate is issued. +// It revokes the node's mTLS certificates, preventing it from connecting to +// the policy gRPC server until it is enrolled again. func (h *NodeHandler) RevokeNodeCertificate(w http.ResponseWriter, r *http.Request, nodeID string) { node, ok := h.loadWritableNode(w, r, nodeID, "create") if !ok { @@ -429,7 +429,9 @@ func (h *NodeHandler) RevokeNodeCertificate(w http.ResponseWriter, r *http.Reque if reason == "" { reason = "manually revoked" } - if err := h.enrollSvc.RevokeCertificate(r.Context(), nodeID, *node.CertSerial, reason); err != nil { + // Revokes the current certificate and, shortly after a renewal, the one + // it replaced. An open policy stream closes within a minute. + if _, err := h.enrollSvc.RevokeNode(r.Context(), nodeID, reason); err != nil { log.Printf("Failed to revoke certificate for node %s: %v", nodeID, err) //nolint:gosec // nodeID comes from authenticated request http.Error(w, `{"error":"failed to revoke certificate"}`, http.StatusInternalServerError) return diff --git a/server/internal/api/permission_gate.go b/server/internal/api/permission_gate.go index a044184c..dcb872bd 100644 --- a/server/internal/api/permission_gate.go +++ b/server/internal/api/permission_gate.go @@ -215,7 +215,7 @@ func auditSrcIP(r *http.Request) string { if st := authzFromRequest(r); st != nil && st.gate != nil { return extractAuditIP(r, st.gate.anonymizeIPs) } - return extractIP(r) + return defaultAuditIP(r) } // writeJSONError writes {"error": msg} with the given status. diff --git a/server/internal/config/config.go b/server/internal/config/config.go index 65b26976..b686360a 100644 --- a/server/internal/config/config.go +++ b/server/internal/config/config.go @@ -10,6 +10,7 @@ import ( "encoding/hex" "fmt" "log" + "net/netip" "os" "strconv" "strings" @@ -95,6 +96,10 @@ type AuditConfig struct { Syslog SyslogConfig RetentionDays int // BOR_AUDIT_RETENTION_DAYS – purge audit logs older than N days (default 365; 0 disables) AnonymizeIPs bool // BOR_AUDIT_ANONYMIZE_IPS – truncate IPs to /24 (v4) or /48 (v6) before storing + // TrustedProxies lists the reverse proxies whose X-Forwarded-For and + // X-Real-IP headers are believed when recording a client IP. A request + // from any other peer is recorded with its TCP peer address. + TrustedProxies []netip.Prefix // BOR_TRUSTED_PROXIES - comma-separated IPs or CIDRs (default: none) } // SyslogConfig holds configuration for the syslog audit sink. @@ -219,6 +224,9 @@ type SecurityConfig struct { TLSKeyFile string AdminToken string // BOR_ADMIN_TOKEN – static admin token for gRPC enrollment calls AdminPassword string // BOR_ADMIN_PASSWORD – initial admin password (used once on first startup when no users exist) + // EnrollRateLimit caps Enroll, KerberosEnroll and CreateEnrollmentToken + // calls per client IP per minute. 0 disables the limit. + EnrollRateLimit int // BOR_ENROLL_RATE_LIMIT, default 60 } // TLSConfig holds UI HTTPS TLS configuration. @@ -366,6 +374,7 @@ type fileConfig struct { RefreshLifetime string `yaml:"refresh_lifetime"` AdminToken string `yaml:"admin_token"` AdminPassword string `yaml:"admin_password"` + EnrollRateLimit *int `yaml:"enroll_rate_limit"` } `yaml:"security"` TLS struct { CertFile string `yaml:"cert_file"` @@ -457,8 +466,9 @@ type fileConfig struct { HTTPPort int `yaml:"http_port"` } `yaml:"acme"` Audit struct { - RetentionDays int `yaml:"retention_days"` - Syslog struct { + RetentionDays int `yaml:"retention_days"` + TrustedProxies []string `yaml:"trusted_proxies"` + Syslog struct { Enabled bool `yaml:"enabled"` Network string `yaml:"network"` Addr string `yaml:"addr"` @@ -623,6 +633,27 @@ func Load() (*Config, error) { if err != nil { return nil, fmt.Errorf("invalid BOR_AUDIT_RETENTION_DAYS: %w", err) } + enrollRateLimit := 60 + if fc.Security.EnrollRateLimit != nil { + enrollRateLimit = *fc.Security.EnrollRateLimit + } + if env := os.Getenv("BOR_ENROLL_RATE_LIMIT"); env != "" { + if enrollRateLimit, err = strconv.Atoi(env); err != nil { + return nil, fmt.Errorf("invalid BOR_ENROLL_RATE_LIMIT: %w", err) + } + } + if enrollRateLimit < 0 { + return nil, fmt.Errorf("invalid BOR_ENROLL_RATE_LIMIT: must be >= 0") + } + + trustedProxyList := fc.Audit.TrustedProxies + if env := os.Getenv("BOR_TRUSTED_PROXIES"); env != "" { + trustedProxyList = splitComma(env) + } + trustedProxies, err := parsePrefixes(trustedProxyList) + if err != nil { + return nil, fmt.Errorf("invalid BOR_TRUSTED_PROXIES: %w", err) + } // ─── JWT lifetimes ──────────────────────────────────────────────────── jwtLifetimeStr := getEnv("BOR_JWT_LIFETIME", fc.Security.JWTLifetime) @@ -709,6 +740,7 @@ func Load() (*Config, error) { TLSKeyFile: getEnv("TLS_KEY_FILE", ""), AdminToken: getEnv("BOR_ADMIN_TOKEN", fc.Security.AdminToken), AdminPassword: getEnv("BOR_ADMIN_PASSWORD", fc.Security.AdminPassword), + EnrollRateLimit: enrollRateLimit, }, TLS: TLSConfig{ CertFile: tlsCertFile, @@ -800,8 +832,9 @@ func Load() (*Config, error) { HTTPPort: acmeHTTPPort, }, Audit: AuditConfig{ - RetentionDays: auditRetentionDays, - AnonymizeIPs: getEnvBool("BOR_AUDIT_ANONYMIZE_IPS", false), + RetentionDays: auditRetentionDays, + AnonymizeIPs: getEnvBool("BOR_AUDIT_ANONYMIZE_IPS", false), + TrustedProxies: trustedProxies, Syslog: SyslogConfig{ Enabled: syslogEnabled, Network: syslogNetwork, @@ -953,6 +986,33 @@ func splitComma(s string) []string { return out } +// parsePrefixes parses IP addresses and CIDR prefixes. A bare address +// becomes a single-host prefix. +func parsePrefixes(items []string) ([]netip.Prefix, error) { + out := make([]netip.Prefix, 0, len(items)) + for _, item := range items { + item = strings.TrimSpace(item) + if item == "" { + continue + } + if strings.Contains(item, "/") { + p, err := netip.ParsePrefix(item) + if err != nil { + return nil, fmt.Errorf("%q: %w", item, err) + } + out = append(out, p.Masked()) + continue + } + a, err := netip.ParseAddr(item) + if err != nil { + return nil, fmt.Errorf("%q: %w", item, err) + } + a = a.Unmap() + out = append(out, netip.PrefixFrom(a, a.BitLen())) + } + return out, nil +} + // parseGroupRoleMap parses a string like "Domain Admins=Super Admin,IT Staff=Org Admin" // into a map[string]string. Entries with no '=' separator are silently skipped. func parseGroupRoleMap(s string) map[string]string { diff --git a/server/internal/database/migrations/000048_node_previous_cert.down.sql b/server/internal/database/migrations/000048_node_previous_cert.down.sql new file mode 100644 index 00000000..2f52e50a --- /dev/null +++ b/server/internal/database/migrations/000048_node_previous_cert.down.sql @@ -0,0 +1,4 @@ +-- SPDX-License-Identifier: AGPL-3.0-only +-- Copyright (C) 2026 Vute Tech LTD + +ALTER TABLE nodes DROP COLUMN IF EXISTS previous_cert_serial; diff --git a/server/internal/database/migrations/000048_node_previous_cert.up.sql b/server/internal/database/migrations/000048_node_previous_cert.up.sql new file mode 100644 index 00000000..26ae5dc9 --- /dev/null +++ b/server/internal/database/migrations/000048_node_previous_cert.up.sql @@ -0,0 +1,9 @@ +-- SPDX-License-Identifier: AGPL-3.0-only +-- Copyright (C) 2026 Vute Tech LTD + +-- The certificate a node held before its latest renewal. It stays valid +-- until the node first connects with the renewed certificate, so a node +-- that lost the renewal response can still retry; it is then revoked. +-- Empty when there is no such certificate. +ALTER TABLE nodes + ADD COLUMN previous_cert_serial TEXT NOT NULL DEFAULT ''; diff --git a/server/internal/database/revocations.go b/server/internal/database/revocations.go index 9f741bf5..ee9452a8 100644 --- a/server/internal/database/revocations.go +++ b/server/internal/database/revocations.go @@ -7,7 +7,9 @@ package database import ( "context" "database/sql" + "errors" "fmt" + "time" "github.com/VuteTech/Bor/server/internal/models" ) @@ -59,16 +61,6 @@ func (r *RevocationRepository) GetByNodeID(ctx context.Context, nodeID string) ( return &rev, nil } -// DeleteByNodeID removes all revocations for a node (used when re-enrolling). -func (r *RevocationRepository) DeleteByNodeID(ctx context.Context, nodeID string) error { - _, err := r.db.ExecContext(ctx, - `DELETE FROM revoked_certificates WHERE node_id = $1`, nodeID) - if err != nil { - return fmt.Errorf("failed to delete revocations for node: %w", err) - } - return nil -} - // ListAll returns all revoked certificates ordered by revoked_at descending. func (r *RevocationRepository) ListAll(ctx context.Context) ([]*models.RevokedCertificate, error) { rows, err := r.db.QueryContext(ctx, @@ -87,3 +79,159 @@ func (r *RevocationRepository) ListAll(ctx context.Context) ([]*models.RevokedCe } return revs, rows.Err() } + +// ErrStaleCertificate is returned by RotateCertificate when the certificate +// presented for renewal is neither the node's current nor its previous one. +var ErrStaleCertificate = errors.New("certificate is not the node's current certificate") + +// RotateCertificate records a renewed certificate for a node, in one +// transaction. presentedSerial is the certificate the node authenticated +// the renewal with: +// +// - the current certificate: it becomes the previous one and stays valid +// until the node connects with the new one (see ConfirmCertificateInUse); +// an older previous certificate, if any, is revoked. +// - the previous certificate: the node lost the answer to an earlier +// renewal. The certificate issued then was never used and is revoked; +// the previous certificate stays as it is. +// +// Any other certificate is refused with ErrStaleCertificate. It returns the +// serials it revoked. +func (r *RevocationRepository) RotateCertificate(ctx context.Context, nodeID, presentedSerial, newSerial string, notAfter time.Time) (revoked []string, err error) { + tx, err := r.db.BeginTx(ctx, nil) + if err != nil { + return nil, fmt.Errorf("begin certificate rotation: %w", err) + } + defer func() { + if err != nil { + _ = tx.Rollback() + } + }() + + var current sql.NullString + var previous string + err = tx.QueryRowContext(ctx, + `SELECT cert_serial, previous_cert_serial FROM nodes WHERE id = $1 FOR UPDATE`, + nodeID).Scan(¤t, &previous) + if err != nil { + return nil, fmt.Errorf("load node certificate: %w", err) + } + + newPrevious := previous + switch { + case current.Valid && current.String != "" && presentedSerial == current.String: + if previous != "" { + revoked = append(revoked, previous) + } + newPrevious = current.String + case previous != "" && presentedSerial == previous: + if current.Valid && current.String != "" { + revoked = append(revoked, current.String) + } + default: + return nil, ErrStaleCertificate + } + + for _, serial := range revoked { + if _, err = tx.ExecContext(ctx, + `INSERT INTO revoked_certificates (node_id, serial, reason) VALUES ($1, $2, $3)`, + nodeID, serial, "superseded by renewal"); err != nil { + return nil, fmt.Errorf("revoke superseded certificate: %w", err) + } + } + if _, err = tx.ExecContext(ctx, + `UPDATE nodes SET cert_serial = $1, cert_not_after = $2, previous_cert_serial = $3, updated_at = NOW() WHERE id = $4`, + newSerial, notAfter, newPrevious, nodeID); err != nil { + return nil, fmt.Errorf("update node certificate: %w", err) + } + if err = tx.Commit(); err != nil { + return nil, fmt.Errorf("commit certificate rotation: %w", err) + } + return revoked, nil +} + +// ConfirmCertificateInUse is called when a node connects with +// presentedSerial. When that is the node's current certificate and a +// previous one is still valid, the previous one is revoked. It returns the +// revoked serial, or "" when nothing changed. +func (r *RevocationRepository) ConfirmCertificateInUse(ctx context.Context, nodeID, presentedSerial string) (revoked string, err error) { + tx, err := r.db.BeginTx(ctx, nil) + if err != nil { + return "", fmt.Errorf("begin certificate confirmation: %w", err) + } + defer func() { + if err != nil { + _ = tx.Rollback() + } + }() + + var current sql.NullString + var previous string + err = tx.QueryRowContext(ctx, + `SELECT cert_serial, previous_cert_serial FROM nodes WHERE id = $1 FOR UPDATE`, + nodeID).Scan(¤t, &previous) + if err != nil { + return "", fmt.Errorf("load node certificate: %w", err) + } + if previous == "" || !current.Valid || current.String != presentedSerial { + err = tx.Rollback() + return "", err + } + if _, err = tx.ExecContext(ctx, + `INSERT INTO revoked_certificates (node_id, serial, reason) VALUES ($1, $2, $3)`, + nodeID, previous, "superseded by renewal"); err != nil { + return "", fmt.Errorf("revoke previous certificate: %w", err) + } + if _, err = tx.ExecContext(ctx, + `UPDATE nodes SET previous_cert_serial = '', updated_at = NOW() WHERE id = $1`, nodeID); err != nil { + return "", fmt.Errorf("clear previous certificate: %w", err) + } + if err = tx.Commit(); err != nil { + return "", fmt.Errorf("commit certificate confirmation: %w", err) + } + return previous, nil +} + +// RevokeNode revokes every certificate a node holds (current and previous) +// in one transaction and returns the revoked serials. +func (r *RevocationRepository) RevokeNode(ctx context.Context, nodeID, reason string) (revoked []string, err error) { + tx, err := r.db.BeginTx(ctx, nil) + if err != nil { + return nil, fmt.Errorf("begin node revocation: %w", err) + } + defer func() { + if err != nil { + _ = tx.Rollback() + } + }() + + var current sql.NullString + var previous string + err = tx.QueryRowContext(ctx, + `SELECT cert_serial, previous_cert_serial FROM nodes WHERE id = $1 FOR UPDATE`, + nodeID).Scan(¤t, &previous) + if err != nil { + return nil, fmt.Errorf("load node certificate: %w", err) + } + if current.Valid && current.String != "" { + revoked = append(revoked, current.String) + } + if previous != "" { + revoked = append(revoked, previous) + } + for _, serial := range revoked { + if _, err = tx.ExecContext(ctx, + `INSERT INTO revoked_certificates (node_id, serial, reason) VALUES ($1, $2, $3)`, + nodeID, serial, reason); err != nil { + return nil, fmt.Errorf("record revocation: %w", err) + } + } + if _, err = tx.ExecContext(ctx, + `UPDATE nodes SET previous_cert_serial = '', updated_at = NOW() WHERE id = $1`, nodeID); err != nil { + return nil, fmt.Errorf("clear previous certificate: %w", err) + } + if err = tx.Commit(); err != nil { + return nil, fmt.Errorf("commit node revocation: %w", err) + } + return revoked, nil +} diff --git a/server/internal/grpc/enrollment_server.go b/server/internal/grpc/enrollment_server.go index f5a01720..d4dd7d51 100644 --- a/server/internal/grpc/enrollment_server.go +++ b/server/internal/grpc/enrollment_server.go @@ -139,10 +139,12 @@ func (s *EnrollmentServer) KerberosEnroll(ctx context.Context, req *pb.KerberosE "kerberos_default_node_group not configured; cannot place agent into a node group") } - nodeName := req.GetNodeName() - if nodeName == "" { - // Fall back to the hostname embedded in the Kerberos principal. - nodeName = services.PrincipalToHostname(principal) + // The node name is bound to the machine account that authenticated, so + // one host's keytab cannot enroll under another host's name. + nodeName, err := services.KerberosNodeName(principal, req.GetNodeName()) + if err != nil { + log.Printf("Kerberos enrollment refused: %v", err) + return nil, status.Errorf(codes.PermissionDenied, "%v", err) } // CN forced to the server-assigned node name — see EnrollmentService.SignCSR. diff --git a/server/internal/grpc/interceptor.go b/server/internal/grpc/interceptor.go index a8f62937..570c62ad 100644 --- a/server/internal/grpc/interceptor.go +++ b/server/internal/grpc/interceptor.go @@ -6,6 +6,7 @@ package grpc import ( "context" + "crypto/x509" "google.golang.org/grpc" "google.golang.org/grpc/codes" @@ -97,6 +98,20 @@ func verifyClientCert(ctx context.Context) error { return nil } +// verifiedClientCert returns the verified TLS client certificate of the +// call. +func verifiedClientCert(ctx context.Context) (*x509.Certificate, error) { + p, ok := peer.FromContext(ctx) + if !ok { + return nil, status.Errorf(codes.Unauthenticated, "no peer info") + } + tlsInfo, ok := p.AuthInfo.(credentials.TLSInfo) + if !ok || len(tlsInfo.State.VerifiedChains) == 0 { + return nil, status.Errorf(codes.Unauthenticated, "no verified client certificate") + } + return tlsInfo.State.VerifiedChains[0][0], nil +} + // extractCertSerial extracts the serial number (as a lowercase hex string) from // the verified TLS client certificate in the context. func extractCertSerial(ctx context.Context) (string, error) { diff --git a/server/internal/grpc/ratelimit_interceptor.go b/server/internal/grpc/ratelimit_interceptor.go new file mode 100644 index 00000000..aa326c45 --- /dev/null +++ b/server/internal/grpc/ratelimit_interceptor.go @@ -0,0 +1,56 @@ +// SPDX-License-Identifier: AGPL-3.0-only +// Copyright (C) 2026 Vute Tech LTD +// Copyright (C) 2026 Bor contributors + +package grpc + +import ( + "context" + "log" + "net" + + "google.golang.org/grpc" + "google.golang.org/grpc/codes" + "google.golang.org/grpc/peer" + "google.golang.org/grpc/status" + + "github.com/VuteTech/Bor/server/internal/ratelimit" +) + +// RateLimitInterceptor limits the given unary methods per client IP address +// (the TCP peer, which a client cannot choose). Calls over the limit fail +// with ResourceExhausted. Methods not listed pass through. A nil limiter +// disables the check. +func RateLimitInterceptor(methods map[string]bool, l *ratelimit.Limiter) grpc.UnaryServerInterceptor { + return func( + ctx context.Context, + req any, + info *grpc.UnaryServerInfo, + handler grpc.UnaryHandler, + ) (any, error) { + if l == nil || !methods[info.FullMethod] { + return handler(ctx, req) + } + ip := peerHost(ctx) + if !l.Allow(ip) { + log.Printf("rate limit: rejected %s from %s", info.FullMethod, ip) + return nil, status.Errorf(codes.ResourceExhausted, + "too many requests, retry in %s", l.Window()) + } + return handler(ctx, req) + } +} + +// peerHost returns the IP of the gRPC peer without its port, or "unknown" +// when the context carries no peer. +func peerHost(ctx context.Context) string { + p, ok := peer.FromContext(ctx) + if !ok || p.Addr == nil { + return "unknown" + } + addr := p.Addr.String() + if host, _, err := net.SplitHostPort(addr); err == nil { + return host + } + return addr +} diff --git a/server/internal/grpc/ratelimit_interceptor_test.go b/server/internal/grpc/ratelimit_interceptor_test.go new file mode 100644 index 00000000..66ccc882 --- /dev/null +++ b/server/internal/grpc/ratelimit_interceptor_test.go @@ -0,0 +1,62 @@ +// SPDX-License-Identifier: AGPL-3.0-only +// Copyright (C) 2026 Vute Tech LTD +// Copyright (C) 2026 Bor contributors + +package grpc + +import ( + "context" + "net" + "testing" + "time" + + "google.golang.org/grpc" + "google.golang.org/grpc/codes" + "google.golang.org/grpc/peer" + "google.golang.org/grpc/status" + + "github.com/VuteTech/Bor/server/internal/ratelimit" +) + +func TestRateLimitInterceptor(t *testing.T) { + const enroll = "/bor.enrollment.v1.EnrollmentService/Enroll" + const other = "/bor.enrollment.v1.EnrollmentService/RenewCertificate" + ic := RateLimitInterceptor(map[string]bool{enroll: true}, ratelimit.New(2, time.Minute)) + handler := func(context.Context, any) (any, error) { return "ok", nil } + + ctxFor := func(ip string, port int) context.Context { + return peer.NewContext(context.Background(), &peer.Peer{ + Addr: &net.TCPAddr{IP: net.ParseIP(ip), Port: port}, + }) + } + call := func(ctx context.Context, method string) codes.Code { + _, err := ic(ctx, nil, &grpc.UnaryServerInfo{FullMethod: method}, handler) + return status.Code(err) + } + + // Different source ports from one host share a bucket. + if c := call(ctxFor("198.51.100.7", 1000), enroll); c != codes.OK { + t.Fatalf("call 1: %v", c) + } + if c := call(ctxFor("198.51.100.7", 1001), enroll); c != codes.OK { + t.Fatalf("call 2: %v", c) + } + if c := call(ctxFor("198.51.100.7", 1002), enroll); c != codes.ResourceExhausted { + t.Fatalf("call 3: got %v, want ResourceExhausted", c) + } + if c := call(ctxFor("198.51.100.8", 1000), enroll); c != codes.OK { + t.Fatalf("other host: %v", c) + } + for i := 0; i < 5; i++ { + if c := call(ctxFor("198.51.100.7", 1000), other); c != codes.OK { + t.Fatalf("unlisted method throttled: %v", c) + } + } + + off := RateLimitInterceptor(map[string]bool{enroll: true}, nil) + for i := 0; i < 5; i++ { + if _, err := off(ctxFor("198.51.100.7", 1), nil, &grpc.UnaryServerInfo{FullMethod: enroll}, handler); err != nil { + t.Fatalf("nil limiter rejected a call: %v", err) + } + } +} diff --git a/server/internal/grpc/server.go b/server/internal/grpc/server.go index 43b7a5b5..41e6a619 100644 --- a/server/internal/grpc/server.go +++ b/server/internal/grpc/server.go @@ -6,7 +6,9 @@ package grpc import ( "context" + "crypto/x509" "encoding/json" + "errors" "log" "regexp" "slices" @@ -260,6 +262,20 @@ func (s *PolicyServer) SubscribePolicyUpdates(req *pb.SubscribePolicyUpdatesRequ return status.Errorf(codes.NotFound, "node not found for client_id: %s", clientID) } + // The certificate this stream authenticated with: once a renewed + // certificate is in use the one it replaced is revoked, and the stream + // ends when this certificate is revoked or expires. + streamCert, err := verifiedClientCert(ctx) + if err != nil { + return err + } + streamSerial := streamCert.SerialNumber.Text(16) + if s.enrollSvc != nil { + if confirmErr := s.enrollSvc.ConfirmCertificateInUse(ctx, node.ID, streamSerial); confirmErr != nil { + log.Printf("Failed to confirm certificate of node %s: %v", clientID, confirmErr) + } + } + lastKnown := req.GetLastKnownRevision() currentRev := s.hub.Revision() @@ -316,11 +332,19 @@ func (s *PolicyServer) SubscribePolicyUpdates(req *pb.SubscribePolicyUpdatesRequ updates, cancel := s.hub.Subscribe(ctx, clientID) defer cancel() + certCheck := time.NewTicker(streamCertCheckInterval) + defer certCheck.Stop() + for { select { case <-ctx.Done(): log.Printf("Client %s stream ended: %v", clientID, ctx.Err()) return nil + case <-certCheck.C: + if err := s.streamCertStillValid(ctx, streamCert); err != nil { + log.Printf("Closing policy stream of %s: %v", clientID, err) + return err + } case ev := <-updates: if IsResyncSignal(ev.update) { // The node's group membership may have changed since the @@ -346,6 +370,33 @@ func (s *PolicyServer) SubscribePolicyUpdates(req *pb.SubscribePolicyUpdatesRequ } } +// streamCertCheckInterval is how often an open policy stream re-checks +// that its client certificate is neither revoked nor expired. Revocation +// is checked when every call starts; this bounds how long an already open +// stream outlives a revocation. +const streamCertCheckInterval = time.Minute + +// streamCertStillValid returns a gRPC error when cert has expired or been +// revoked since the stream opened. A failed revocation lookup keeps the +// stream open; the next check retries. +func (s *PolicyServer) streamCertStillValid(ctx context.Context, cert *x509.Certificate) error { + if time.Now().After(cert.NotAfter) { + return status.Errorf(codes.Unauthenticated, "client certificate expired") + } + if s.enrollSvc == nil { + return nil + } + revoked, err := s.enrollSvc.IsRevoked(ctx, cert.SerialNumber.Text(16)) + if err != nil { + log.Printf("Revocation check for open stream failed: %v", err) + return nil + } + if revoked { + return status.Errorf(codes.Unauthenticated, "client certificate revoked") + } + return nil +} + // groupsOverlap returns true if nodeGroups and eventGroups share at least one // element, or if eventGroups is empty (meaning the event targets all agents). func groupsOverlap(nodeGroups, eventGroups []string) bool { @@ -672,7 +723,8 @@ func (s *PolicyServer) RenewCertificate(ctx context.Context, req *pb.RenewCertif if !ok || len(tlsInfo.State.VerifiedChains) == 0 { return nil, status.Errorf(codes.Unauthenticated, "no client certificate") } - clientCN := tlsInfo.State.VerifiedChains[0][0].Subject.CommonName + clientCert := tlsInfo.State.VerifiedChains[0][0] + clientCN := clientCert.Subject.CommonName // Look up node by name (CN = node name set during enrollment). node, err := s.nodeSvc.GetNodeByName(ctx, clientCN) @@ -683,7 +735,10 @@ func (s *PolicyServer) RenewCertificate(ctx context.Context, req *pb.RenewCertif return nil, status.Errorf(codes.NotFound, "node not registered: %s", clientCN) } - certPEM, err := s.enrollSvc.RenewCertificate(ctx, node.ID, node.Name, req.GetCsrPem()) + certPEM, err := s.enrollSvc.RenewCertificate(ctx, node.ID, node.Name, clientCert.SerialNumber.Text(16), req.GetCsrPem()) + if errors.Is(err, services.ErrStaleCertificate) { + return nil, status.Errorf(codes.PermissionDenied, "this certificate has been replaced; it cannot be renewed") + } if err != nil { return nil, status.Errorf(codes.Internal, "failed to renew certificate: %v", err) } diff --git a/server/internal/metrics/server.go b/server/internal/metrics/server.go index 6374d482..d8dafac8 100644 --- a/server/internal/metrics/server.go +++ b/server/internal/metrics/server.go @@ -5,6 +5,7 @@ package metrics import ( + "crypto/subtle" "log" "net/http" "strings" @@ -66,10 +67,12 @@ func NewServer(addr, bearerToken string, ext []prometheus.Collector, cs ...prome // bearerTokenMiddleware rejects requests that do not carry a valid Bearer token. func bearerTokenMiddleware(token string, next http.Handler) http.Handler { - expected := "Bearer " + token + expected := []byte(token) return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { auth := r.Header.Get("Authorization") - if !strings.EqualFold(auth[:min(len(auth), 7)], "bearer ") || auth != expected { + const prefix = "bearer " + if len(auth) < len(prefix) || !strings.EqualFold(auth[:len(prefix)], prefix) || + subtle.ConstantTimeCompare([]byte(auth[len(prefix):]), expected) != 1 { http.Error(w, "Unauthorized", http.StatusUnauthorized) return } diff --git a/server/internal/ratelimit/ratelimit.go b/server/internal/ratelimit/ratelimit.go new file mode 100644 index 00000000..68b90be6 --- /dev/null +++ b/server/internal/ratelimit/ratelimit.go @@ -0,0 +1,86 @@ +// SPDX-License-Identifier: AGPL-3.0-only +// Copyright (C) 2026 Vute Tech LTD +// Copyright (C) 2026 Bor contributors + +// Package ratelimit provides a fixed-window request limiter keyed by client +// address, shared by the HTTP API and the gRPC enrollment service. +package ratelimit + +import ( + "sync" + "time" +) + +type bucket struct { + count int + windowStart time.Time +} + +// Limiter allows at most Rate requests per Window for each key (normally a +// client IP address). +type Limiter struct { + mu sync.Mutex + clients map[string]*bucket + rate int + window time.Duration + now func() time.Time +} + +// New creates a Limiter and starts a background goroutine that drops idle +// entries to bound memory use. +func New(rate int, window time.Duration) *Limiter { + l := newLimiter(rate, window, time.Now) + go l.cleanupLoop() + return l +} + +func newLimiter(rate int, window time.Duration, now func() time.Time) *Limiter { + return &Limiter{ + clients: make(map[string]*bucket), + rate: rate, + window: window, + now: now, + } +} + +// Window returns the length of the limiter's window. +func (l *Limiter) Window() time.Duration { return l.window } + +// Allow reports whether a request for key is within the limit, counting it +// if so. +func (l *Limiter) Allow(key string) bool { + l.mu.Lock() + defer l.mu.Unlock() + + now := l.now() + b, ok := l.clients[key] + if !ok || now.Sub(b.windowStart) >= l.window { + l.clients[key] = &bucket{count: 1, windowStart: now} + return true + } + if b.count >= l.rate { + return false + } + b.count++ + return true +} + +// cleanupLoop removes stale entries every two windows. +func (l *Limiter) cleanupLoop() { + ticker := time.NewTicker(l.window * 2) + defer ticker.Stop() + for range ticker.C { + l.sweep() + } +} + +func (l *Limiter) sweep() { + l.mu.Lock() + defer l.mu.Unlock() + now := l.now() + for key, b := range l.clients { + if now.Sub(b.windowStart) >= l.window*2 { + delete(l.clients, key) + } + } +} diff --git a/server/internal/ratelimit/ratelimit_test.go b/server/internal/ratelimit/ratelimit_test.go new file mode 100644 index 00000000..24ecf0f5 --- /dev/null +++ b/server/internal/ratelimit/ratelimit_test.go @@ -0,0 +1,51 @@ +// SPDX-License-Identifier: AGPL-3.0-only +// Copyright (C) 2026 Vute Tech LTD +// Copyright (C) 2026 Bor contributors + +package ratelimit + +import ( + "testing" + "time" +) + +func TestLimiter(t *testing.T) { + now := time.Unix(1_700_000_000, 0) + l := newLimiter(2, time.Minute, func() time.Time { return now }) + + steps := []struct { + name string + key string + advance time.Duration + want bool + }{ + {"first", "a", 0, true}, + {"second", "a", 0, true}, + {"third over limit", "a", 0, false}, + {"other key independent", "b", 0, true}, + {"still limited inside window", "a", 59 * time.Second, false}, + {"new window", "a", time.Second, true}, + } + for _, s := range steps { + now = now.Add(s.advance) + if got := l.Allow(s.key); got != s.want { + t.Fatalf("%s: Allow(%q) = %v, want %v", s.name, s.key, got, s.want) + } + } +} + +func TestLimiterSweep(t *testing.T) { + now := time.Unix(1_700_000_000, 0) + l := newLimiter(1, time.Minute, func() time.Time { return now }) + l.Allow("a") + now = now.Add(time.Minute) + l.Allow("b") + now = now.Add(time.Minute) + l.sweep() + if _, ok := l.clients["a"]; ok { + t.Error("stale entry a was not removed") + } + if _, ok := l.clients["b"]; !ok { + t.Error("recent entry b was removed") + } +} diff --git a/server/internal/services/enrollment.go b/server/internal/services/enrollment.go index 724a610c..3c8c5b90 100644 --- a/server/internal/services/enrollment.go +++ b/server/internal/services/enrollment.go @@ -12,6 +12,7 @@ import ( "crypto/x509" "encoding/hex" "fmt" + "log" "time" "github.com/VuteTech/Bor/server/internal/database" @@ -119,28 +120,55 @@ func (s *EnrollmentService) SetNodeCertificate(ctx context.Context, nodeID, seri return s.nodeSvc.UpdateNodeCertificate(ctx, nodeID, serial, notAfter) } +// ErrStaleCertificate is returned by RenewCertificate when the node +// authenticated with a certificate it no longer holds. +var ErrStaleCertificate = database.ErrStaleCertificate + // RenewCertificate signs a new CSR for an existing node (cert renewal). -// It replaces the node's cert record and clears any prior revocation for that node. // nodeName is the authoritative identity (CN) carried over from the node's // existing certificate; it is forced onto the renewed certificate. -func (s *EnrollmentService) RenewCertificate(ctx context.Context, nodeID, nodeName string, csrPEM []byte) ([]byte, error) { +// presentedSerial is the certificate the node authenticated with. That +// certificate stays valid until the node first connects with the new one +// (ConfirmCertificateInUse), so a lost response can be retried; any +// certificate it supersedes is revoked. Earlier revocations are kept. +func (s *EnrollmentService) RenewCertificate(ctx context.Context, nodeID, nodeName, presentedSerial string, csrPEM []byte) ([]byte, error) { certPEM, serial, notAfter, err := pki.SignCSR(csrPEM, s.caCert, s.caKey, nodeName) if err != nil { return nil, fmt.Errorf("failed to sign renewal CSR: %w", err) } - if err := s.nodeSvc.UpdateNodeCertificate(ctx, nodeID, serial, notAfter); err != nil { - return nil, fmt.Errorf("failed to update node certificate: %w", err) + revoked, err := s.revokeRepo.RotateCertificate(ctx, nodeID, presentedSerial, serial, notAfter) + if err != nil { + return nil, fmt.Errorf("failed to record renewed certificate: %w", err) } - // Clear any stale revocation so the new cert is accepted. - if err := s.revokeRepo.DeleteByNodeID(ctx, nodeID); err != nil { - return nil, fmt.Errorf("failed to clear revocations: %w", err) + for _, r := range revoked { + log.Printf("Revoked superseded certificate %s of node %s", r, nodeName) } return certPEM, nil } -// RevokeCertificate revokes the certificate of a node by serial number. -func (s *EnrollmentService) RevokeCertificate(ctx context.Context, nodeID, serial, reason string) error { - return s.revokeRepo.Revoke(ctx, nodeID, serial, reason) +// ConfirmCertificateInUse is called when a node connects with the +// certificate presentedSerial. Once the renewed certificate is in use, the +// one it replaced is revoked. +func (s *EnrollmentService) ConfirmCertificateInUse(ctx context.Context, nodeID, presentedSerial string) error { + revoked, err := s.revokeRepo.ConfirmCertificateInUse(ctx, nodeID, presentedSerial) + if err != nil { + return err + } + if revoked != "" { + log.Printf("Node %s connected with its renewed certificate; revoked the previous certificate %s", nodeID, revoked) + } + return nil +} + +// IsRevoked reports whether the certificate serial has been revoked. +func (s *EnrollmentService) IsRevoked(ctx context.Context, serial string) (bool, error) { + return s.revokeRepo.IsRevoked(ctx, serial) +} + +// RevokeNode revokes every certificate the node holds: the current one and, +// shortly after a renewal, the one it replaced. It returns the serials. +func (s *EnrollmentService) RevokeNode(ctx context.Context, nodeID, reason string) ([]string, error) { + return s.revokeRepo.RevokeNode(ctx, nodeID, reason) } // CreateNodeOnEnroll creates a Node record in the database for a newly diff --git a/server/internal/services/kerberos.go b/server/internal/services/kerberos.go index b237d695..01528bd3 100644 --- a/server/internal/services/kerberos.go +++ b/server/internal/services/kerberos.go @@ -93,12 +93,65 @@ func (s *KerberosService) ValidateToken(tokenBytes []byte) (string, error) { return "", fmt.Errorf("kerberos AP_REQ rejected") } + // Only principals of the configured realm may enroll; a ticket from a + // trusted foreign realm is refused. + if s.config.Realm != "" && !strings.EqualFold(creds.Realm(), s.config.Realm) { + return "", fmt.Errorf("principal realm %q is not the configured realm %q", creds.Realm(), s.config.Realm) + } + // Build the full principal string: primary/instance@REALM // For machine accounts this is typically "host/hostname.example.com@REALM". principal := creds.CName().PrincipalNameString() + "@" + creds.Realm() return principal, nil } +// KerberosNodeName returns the node name a Kerberos-enrolled agent gets, +// bound to the machine identity in its principal: +// +// - host/@REALM (FreeIPA, MIT): the FQDN or its first label. +// - $@REALM (Active Directory, Samba AD machine accounts): NAME, or +// an FQDN whose first label is NAME. +// +// requested is the name the agent asked for; empty selects the name from +// the principal. Comparison ignores case. Any other principal (users, +// service principals) cannot enroll a node. +func KerberosNodeName(principal, requested string) (string, error) { + noRealm := principal + if idx := strings.LastIndex(noRealm, "@"); idx >= 0 { + noRealm = noRealm[:idx] + } + + var fqdn, short string + switch { + case strings.HasPrefix(noRealm, "host/"): + fqdn = strings.TrimSuffix(strings.TrimPrefix(noRealm, "host/"), ".") + short, _, _ = strings.Cut(fqdn, ".") + case strings.HasSuffix(noRealm, "$") && !strings.ContainsAny(noRealm, "/"): + short = strings.TrimSuffix(noRealm, "$") + default: + return "", fmt.Errorf("principal %q is not a machine account (host/ or $)", principal) + } + if short == "" { + return "", fmt.Errorf("principal %q names no host", principal) + } + + if requested == "" { + if fqdn != "" { + return fqdn, nil + } + return strings.ToLower(short), nil + } + reqShort, _, _ := strings.Cut(requested, ".") + switch { + case fqdn != "" && strings.EqualFold(requested, fqdn): + case strings.EqualFold(requested, short): + case fqdn == "" && strings.EqualFold(reqShort, short): + default: + return "", fmt.Errorf("node name %q does not match principal %q", requested, principal) + } + return requested, nil +} + // RealmFromPrincipal returns the realm portion of a Kerberos principal string, // e.g. "EXAMPLE.COM" from "host/worker01.example.com@EXAMPLE.COM". func RealmFromPrincipal(principal string) string { @@ -107,20 +160,3 @@ func RealmFromPrincipal(principal string) string { } return "" } - -// PrincipalToHostname returns the host component from a host-keytab principal, -// e.g. "worker01.example.com" from "host/worker01.example.com@EXAMPLE.COM". -// Returns the full primary/instance string for non-host principals. -func PrincipalToHostname(principal string) string { - // Strip realm - noRealm := principal - if idx := strings.LastIndex(principal, "@"); idx >= 0 { - noRealm = principal[:idx] - } - // Return instance part for host/... principals - parts := strings.SplitN(noRealm, "/", 2) - if len(parts) == 2 { - return parts[1] - } - return noRealm -} diff --git a/server/internal/services/kerberos_test.go b/server/internal/services/kerberos_test.go new file mode 100644 index 00000000..b2a7b901 --- /dev/null +++ b/server/internal/services/kerberos_test.go @@ -0,0 +1,45 @@ +// SPDX-License-Identifier: AGPL-3.0-only +// Copyright (C) 2026 Vute Tech LTD +// Copyright (C) 2026 Bor contributors + +package services + +import "testing" + +func TestKerberosNodeName(t *testing.T) { + tests := []struct { + name string + principal string + requested string + want string + wantErr bool + }{ + {"host principal, no name", "host/ws01.example.com@EXAMPLE.COM", "", "ws01.example.com", false}, + {"host principal, fqdn", "host/ws01.example.com@EXAMPLE.COM", "ws01.example.com", "ws01.example.com", false}, + {"host principal, short name", "host/ws01.example.com@EXAMPLE.COM", "ws01", "ws01", false}, + {"host principal, case ignored", "host/ws01.example.com@EXAMPLE.COM", "WS01.Example.com", "WS01.Example.com", false}, + {"host principal, other host", "host/ws01.example.com@EXAMPLE.COM", "ws02", "", true}, + {"host principal, other domain", "host/ws01.example.com@EXAMPLE.COM", "ws01.evil.com", "", true}, + {"host principal, trailing dot", "host/ws01.example.com.@EXAMPLE.COM", "", "ws01.example.com", false}, + {"ad machine account, no name", "WS01$@EXAMPLE.COM", "", "ws01", false}, + {"ad machine account, short", "WS01$@EXAMPLE.COM", "ws01", "ws01", false}, + {"ad machine account, fqdn", "WS01$@EXAMPLE.COM", "ws01.corp.example.com", "ws01.corp.example.com", false}, + {"ad machine account, other host", "WS01$@EXAMPLE.COM", "ws02.corp.example.com", "", true}, + {"user principal", "alice@EXAMPLE.COM", "", "", true}, + {"user principal posing as host", "alice@EXAMPLE.COM", "alice", "", true}, + {"service principal", "HTTP/web.example.com@EXAMPLE.COM", "web.example.com", "", true}, + {"empty host", "host/@EXAMPLE.COM", "", "", true}, + {"bare dollar", "$@EXAMPLE.COM", "", "", true}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, err := KerberosNodeName(tt.principal, tt.requested) + if (err != nil) != tt.wantErr { + t.Fatalf("KerberosNodeName(%q, %q) error = %v, wantErr %v", tt.principal, tt.requested, err, tt.wantErr) + } + if got != tt.want { + t.Errorf("KerberosNodeName(%q, %q) = %q, want %q", tt.principal, tt.requested, got, tt.want) + } + }) + } +} diff --git a/server/pkg/app/app.go b/server/pkg/app/app.go index e22e1f20..c5a93a3d 100644 --- a/server/pkg/app/app.go +++ b/server/pkg/app/app.go @@ -14,11 +14,13 @@ import ( "crypto" "crypto/tls" "crypto/x509" + "encoding/json" "fmt" "log" "net/http" "os" "os/signal" + osuser "os/user" "path/filepath" "strings" "syscall" @@ -36,6 +38,7 @@ import ( "github.com/VuteTech/Bor/server/internal/metrics" "github.com/VuteTech/Bor/server/internal/models" "github.com/VuteTech/Bor/server/internal/pki" + "github.com/VuteTech/Bor/server/internal/ratelimit" "github.com/VuteTech/Bor/server/internal/services" "github.com/VuteTech/Bor/server/pkg/edition" "github.com/VuteTech/Bor/server/pkg/ext" @@ -60,6 +63,12 @@ func grpcHardeningOptions() []grpc.ServerOption { grpc.KeepaliveParams(keepalive.ServerParameters{ Time: 1 * time.Hour, Timeout: 20 * time.Second, + // Agents reconnect at least daily, so the TLS handshake (and the + // certificate checks with it) happens again: a renewed agent + // certificate is taken into use and an expired one cannot keep + // an old connection. Open streams get a minute to finish. + MaxConnectionAge: 24 * time.Hour, + MaxConnectionAgeGrace: time.Minute, }), } } @@ -550,6 +559,10 @@ func Run(opts Options) { // Setup HTTP routes mux := http.NewServeMux() + // Client IPs in audit records: forwarding headers are believed only from + // the configured reverse proxies. + api.ConfigureClientIP(cfg.Audit.TrustedProxies, cfg.Audit.AnonymizeIPs) + // Audit middleware for logging state-changing API calls auditMw := api.AuditMiddleware(auditSvc, cfg.Audit.AnonymizeIPs) @@ -977,8 +990,24 @@ func Run(opts Options) { enrollSrvImpl.WithKerberosService(kerberosvc) } + // Per-IP throttle on the unauthenticated bootstrap calls and on the + // admin-token call, so neither token guessing nor CSR signing can be + // driven at line rate. Runs before the certificate check. + var enrollLimiter *ratelimit.Limiter + if cfg.Security.EnrollRateLimit > 0 { + enrollLimiter = ratelimit.New(cfg.Security.EnrollRateLimit, time.Minute) + } + throttledMethods := map[string]bool{ + "/bor.enrollment.v1.EnrollmentService/Enroll": true, + "/bor.enrollment.v1.EnrollmentService/KerberosEnroll": true, + "/bor.enrollment.v1.EnrollmentService/CreateEnrollmentToken": true, + } + enrollGrpcSrv := grpc.NewServer(append(grpcHardeningOptions(), - grpc.UnaryInterceptor(grpcserver.RequireClientCertInterceptor(exemptMethods, revocationRepo)), + grpc.ChainUnaryInterceptor( + grpcserver.RateLimitInterceptor(throttledMethods, enrollLimiter), + grpcserver.RequireClientCertInterceptor(exemptMethods, revocationRepo), + ), grpc.StreamInterceptor(grpcserver.RequireClientCertStreamInterceptor(exemptMethods, revocationRepo)), )...) enrollpb.RegisterEnrollmentServiceServer(enrollGrpcSrv, enrollSrvImpl) @@ -1181,12 +1210,49 @@ func ResetMFA(username string) error { return fmt.Errorf("user %q not found", username) } + // The reset is recorded before it happens: if the audit log cannot be + // written, the reset is refused, so a second factor is never removed + // without a trace. + auditRepo := database.NewAuditLogRepository(db) + if err := auditRepo.Create(ctx, resetMFAAuditEntry(user.Username, "requested")); err != nil { + return fmt.Errorf("write audit record (MFA not reset): %w", err) + } + mfaRepo := database.NewMFARepository(db) - err = mfaRepo.Delete(ctx, user.ID) - if err != nil { + if err := mfaRepo.Delete(ctx, user.ID); err != nil { + if aerr := auditRepo.Create(ctx, resetMFAAuditEntry(user.Username, "failed")); aerr != nil { + log.Printf("reset-mfa: write failure audit record: %v", aerr) + } return fmt.Errorf("delete MFA record: %w", err) } + if err := auditRepo.Create(ctx, resetMFAAuditEntry(user.Username, "completed")); err != nil { + log.Printf("reset-mfa: write completion audit record: %v", err) + } log.Printf("MFA disabled for user %q (id=%s)", username, user.ID) return nil } + +// resetMFAAuditEntry builds the audit record for the -reset-mfa command. +// The actor is the operating system account that ran the command, since no +// Bor user is signed in. +func resetMFAAuditEntry(target, stage string) *models.AuditLog { + osUser := "unknown" + if u, err := osuser.Current(); err == nil && u.Username != "" { + osUser = u.Username + } + host, _ := os.Hostname() + details, _ := json.Marshal(map[string]string{ + "via": "bor-server -reset-mfa", + "stage": stage, + "os_user": osUser, + "host": host, + }) + return &models.AuditLog{ + Username: "local:" + osUser, + Action: "auth.mfa.reset", + ResourceType: "auth", + ResourceID: target, + Details: string(details), + } +}