Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -92,6 +92,10 @@ JWT_SECRET=change-me-in-production
# BOR_CA_KEY_FILE=/etc/bor/pki/ca.key
# Directory for the auto-generated CA (default: /var/lib/bor/pki/ca).
# BOR_CA_AUTOGEN_DIR=/var/lib/bor/pki/ca
# Validity of the auto-generated CA certificate: years (10y), days (3650d) or
# a duration (87600h), between 2 and 30 years (default: 10y). Applied only
# when the CA is first generated, so set it before the first start.
# BOR_CA_LIFETIME=10y

# ── PKCS#11 HSM for CA private key (optional; requires make server-pkcs11) ────
# Store the CA private key in a hardware security module rather than a file.
Expand Down
1 change: 1 addition & 0 deletions doc/configuration/server.md
Original file line number Diff line number Diff line change
Expand Up @@ -218,6 +218,7 @@ and the auto-generated server certificate.
| `ca.cert_file` | `BOR_CA_CERT_FILE` | `""` | PEM CA certificate to use instead of the auto-generated CA. Must be set together with `key_file`. |
| `ca.key_file` | `BOR_CA_KEY_FILE` | `""` | PEM private key of the CA (PKCS#8 or traditional ECDSA/RSA). |
| `ca.autogen_dir` | `BOR_CA_AUTOGEN_DIR` | `/var/lib/bor/pki/ca` | Directory for the auto-generated CA (`ca.crt`, `ca.key`). With PKCS#11, the CA certificate is stored here as `ca.crt`. |
| `ca.lifetime` | `BOR_CA_LIFETIME` | `10y` | Validity of the auto-generated CA certificate: years (`10y`), days (`3650d`) or a duration (`87600h`), between 2 and 30 years. Applied only when the CA certificate is first generated, so set it before the first start; an existing CA is never re-issued. Ignored when `cert_file`/`key_file` point to your own CA. |
| `ca.pkcs11.lib` | `BOR_CA_PKCS11_LIB` | `""` | Path to the PKCS#11 module (`.so`) of the HSM. |
| `ca.pkcs11.token_label` | `BOR_CA_PKCS11_TOKEN_LABEL` | `""` | Label of the HSM token. |
| `ca.pkcs11.key_label` | `BOR_CA_PKCS11_KEY_LABEL` | `""` | Label of the CA private key on the token. |
Expand Down
5 changes: 3 additions & 2 deletions doc/configuration/tls.md
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,7 @@ On first start, if no CA is configured, the server creates one in
| Files | `ca.crt` and `ca.key` (PKCS#8), both mode `0600` |
| Key | ECDSA P-384 |
| Subject | `CN=Bor Internal CA, O=Bor` |
| Validity | 10 years from creation |
| Validity | 10 years from creation by default; set `ca.lifetime` (`BOR_CA_LIFETIME`) before the first start to change it |
| Constraints | CA, path length 0 (can sign only end-entity certificates) |

The CA signs two kinds of certificates:
Expand Down Expand Up @@ -418,7 +418,8 @@ BOR_CA_PKCS11_PIN=123456
Behavior at startup:

- If no key with `key_label` exists on the token, the server generates an
ECDSA P-384 key on the token and a new 10-year CA certificate.
ECDSA P-384 key on the token and a new CA certificate valid for
`ca.lifetime` (10 years by default).
- If the key exists but the certificate file is missing, a new CA
certificate is created for that key.
- Otherwise both are loaded.
Expand Down
2 changes: 1 addition & 1 deletion doc/operations/backup.md
Original file line number Diff line number Diff line change
Expand Up @@ -46,7 +46,7 @@ Not needed in a backup:

Unless you configured your own CA with `ca.cert_file` and `ca.key_file`, the
server generates an internal CA on first start in
`/var/lib/bor/pki/ca` (ECDSA P-384, valid 10 years) and uses it to:
`/var/lib/bor/pki/ca` (ECDSA P-384, valid 10 years unless `ca.lifetime` was set before the first start) and uses it to:

- sign every agent's client certificate during enrollment, and
- sign the server certificate in `/var/lib/bor/pki/ui` that agents verify on
Expand Down
4 changes: 2 additions & 2 deletions doc/security/compliance.md
Original file line number Diff line number Diff line change
Expand Up @@ -91,8 +91,8 @@ syslog) set a minimum of TLS 1.2 and otherwise use Go defaults.

| Item | Algorithm / curve | Signature | Lifetime and rotation | Package |
|---|---|---|---|---|
| Internal CA (auto-generated in `ca.autogen_dir`) | ECDSA P-384 | Self-signed, ECDSA with SHA-384 | 10 years. No automatic rotation; rotating the CA means re-enrolling every agent. | `pki` |
| Internal CA in an HSM (PKCS#11, optional build) | ECDSA P-384 generated on the token | ECDSA with SHA-384 | 10 years | `pki` (`pkcs11` build tag) |
| Internal CA (auto-generated in `ca.autogen_dir`) | ECDSA P-384 | Self-signed, ECDSA with SHA-384 | 10 years by default, configurable before the first start with `ca.lifetime`. No automatic rotation; rotating the CA means re-enrolling every agent. | `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` |
| Certificate serial numbers | 128-bit random from `crypto/rand` | - | - | `pki` |
Expand Down
2 changes: 1 addition & 1 deletion doc/security/hardening.md
Original file line number Diff line number Diff line change
Expand Up @@ -47,7 +47,7 @@ Keys and certificates generated by Bor:

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

Expand Down
1 change: 1 addition & 0 deletions podman-compose.yml.sample
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,7 @@ services:
# - BOR_CA_CERT_FILE=/etc/bor/pki/ca/ca.crt
# - BOR_CA_KEY_FILE=/etc/bor/pki/ca/ca.key
# - BOR_CA_AUTOGEN_DIR=/var/lib/bor/pki/ca
# - BOR_CA_LIFETIME=10y
# - BOR_TLS_CERT_FILE=/etc/bor/pki/ui/server.crt
# - BOR_TLS_KEY_FILE=/etc/bor/pki/ui/server.key
# - BOR_TLS_AUTOGEN_DIR=/var/lib/bor/pki/ui
Expand Down
53 changes: 53 additions & 0 deletions server/internal/config/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -249,6 +249,10 @@ type CAConfig struct {
KeyFile string // BOR_CA_KEY_FILE – path to CA private key (unused when PKCS11 is set)
AutogenDir string // BOR_CA_AUTOGEN_DIR – dir for auto-generated CA
PKCS11 PKCS11Config // optional: load CA key from PKCS#11 HSM instead of a file
// Lifetime is the validity of the auto-generated CA certificate
// (BOR_CA_LIFETIME, default 10y). It is applied only when the CA
// certificate is first generated; an existing CA is never re-issued.
Lifetime time.Duration
}

// LDAPConfig holds LDAP connection configuration.
Expand Down Expand Up @@ -358,6 +362,7 @@ type fileConfig struct {
CertFile string `yaml:"cert_file"`
KeyFile string `yaml:"key_file"`
AutogenDir string `yaml:"autogen_dir"`
Lifetime string `yaml:"lifetime"`
PKCS11 struct {
Lib string `yaml:"lib"`
TokenLabel string `yaml:"token_label"`
Expand Down Expand Up @@ -541,6 +546,11 @@ func Load() (*Config, error) {
return nil, fmt.Errorf("both BOR_CA_CERT_FILE and BOR_CA_KEY_FILE must be set, or neither")
}

caLifetime, err := parseCALifetime(getEnv("BOR_CA_LIFETIME", fc.CA.Lifetime))
if err != nil {
return nil, fmt.Errorf("invalid BOR_CA_LIFETIME: %w", err)
}

// ─── CA PKCS#11 (optional HSM) ─────────────────────────────────────────
pkcs11Lib := getEnv("BOR_CA_PKCS11_LIB", fc.CA.PKCS11.Lib)
pkcs11TokenLabel := getEnv("BOR_CA_PKCS11_TOKEN_LABEL", fc.CA.PKCS11.TokenLabel)
Expand Down Expand Up @@ -689,6 +699,7 @@ func Load() (*Config, error) {
CertFile: caCertFile,
KeyFile: caKeyFile,
AutogenDir: getEnv("BOR_CA_AUTOGEN_DIR", fc.CA.AutogenDir),
Lifetime: caLifetime,
PKCS11: PKCS11Config{
Lib: pkcs11Lib,
TokenLabel: pkcs11TokenLabel,
Expand Down Expand Up @@ -799,6 +810,7 @@ func defaultFileConfig() fileConfig {
fc.Security.RefreshLifetime = "24h"
fc.TLS.AutogenDir = "/var/lib/bor/pki/ui"
fc.CA.AutogenDir = "/var/lib/bor/pki/ca"
fc.CA.Lifetime = "10y"
fc.LDAP.Host = "localhost"
fc.LDAP.Port = 389
fc.LDAP.UserFilter = "(uid=%s)"
Expand Down Expand Up @@ -855,6 +867,47 @@ func getEnv(key, defaultValue string) string {
return defaultValue
}

// Bounds for the auto-generated CA lifetime. The lower bound keeps the CA
// valid for longer than the server certificate it signs (1 year); the upper
// bound rejects lifetimes that no key of this strength should be trusted for.
const (
minCALifetime = 2 * 365 * 24 * time.Hour
maxCALifetime = 30 * 365 * 24 * time.Hour
)

// parseCALifetime parses a CA lifetime. Besides Go durations ("87600h") it
// accepts a whole number of years ("10y", 365 days each) or days ("3650d").
func parseCALifetime(s string) (time.Duration, error) {
s = strings.TrimSpace(s)
var d time.Duration
switch {
case strings.HasSuffix(s, "y"), strings.HasSuffix(s, "d"):
n, err := strconv.Atoi(s[:len(s)-1])
if err != nil {
return 0, fmt.Errorf("%q is not a whole number of years or days", s)
}
// Guard the multiplication below against overflow.
if n < 0 || n > 100*365 {
return 0, fmt.Errorf("%q is out of range", s)
}
unit := 24 * time.Hour
if strings.HasSuffix(s, "y") {
unit *= 365
}
d = time.Duration(n) * unit
default:
var err error
d, err = time.ParseDuration(s)
if err != nil {
return 0, fmt.Errorf("%q: use years (10y), days (3650d) or a duration (87600h)", s)
}
}
if d < minCALifetime || d > maxCALifetime {
return 0, fmt.Errorf("%q is outside the allowed range of 2 to 30 years", s)
}
return d, nil
}

func getEnvBool(key string, defaultValue bool) bool {
val := os.Getenv(key)
switch val {
Expand Down
46 changes: 46 additions & 0 deletions server/internal/config/config_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ package config
import (
"os"
"testing"
"time"
)

func TestLoad_Defaults(t *testing.T) {
Expand Down Expand Up @@ -39,6 +40,51 @@ func TestLoad_Defaults(t *testing.T) {
if cfg.CA.AutogenDir != "/var/lib/bor/pki/ca" {
t.Errorf("CA.AutogenDir = %q, want %q", cfg.CA.AutogenDir, "/var/lib/bor/pki/ca")
}
if want := 10 * 365 * 24 * time.Hour; cfg.CA.Lifetime != want {
t.Errorf("CA.Lifetime = %v, want %v", cfg.CA.Lifetime, want)
}
}

func TestLoad_CALifetime(t *testing.T) {
const day = 24 * time.Hour
tests := []struct {
name string
value string
want time.Duration
wantErr bool
}{
{name: "years", value: "5y", want: 5 * 365 * day},
{name: "days", value: "1000d", want: 1000 * day},
{name: "go duration", value: "43800h", want: 43800 * time.Hour},
{name: "surrounding space", value: " 3y ", want: 3 * 365 * day},
{name: "lower bound", value: "2y", want: 2 * 365 * day},
{name: "upper bound", value: "30y", want: 30 * 365 * day},
{name: "too short", value: "1y", wantErr: true},
{name: "too long", value: "31y", wantErr: true},
{name: "no unit", value: "10", wantErr: true},
{name: "fractional years", value: "2.5y", wantErr: true},
{name: "negative", value: "-5y", wantErr: true},
{name: "overflow", value: "999999999999y", wantErr: true},
{name: "garbage", value: "forever", wantErr: true},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
t.Setenv("BOR_CA_LIFETIME", tt.value)
cfg, err := Load()
if tt.wantErr {
if err == nil {
t.Fatalf("Load() with BOR_CA_LIFETIME=%q succeeded, want error", tt.value)
}
return
}
if err != nil {
t.Fatalf("Load() error = %v", err)
}
if cfg.CA.Lifetime != tt.want {
t.Errorf("CA.Lifetime = %v, want %v", cfg.CA.Lifetime, tt.want)
}
})
}
}

func TestLoad_FailFast_TLSCertWithoutKey(t *testing.T) {
Expand Down
18 changes: 11 additions & 7 deletions server/internal/pki/pkcs11.go
Original file line number Diff line number Diff line change
Expand Up @@ -50,14 +50,18 @@ import (
// - If a key with keyLabel already exists on the token and certPath exists,
// both are loaded and returned.
// - If the key exists but certPath is missing, a new self-signed CA
// certificate is generated (ECDSA P-384, 10 years) and written to certPath.
// certificate is generated (ECDSA P-384, valid for lifetime) and written
// to certPath. A non-positive lifetime means DefaultCALifetime.
// - If no key with keyLabel is found on the token, a new ECDSA P-384 key is
// generated on the HSM, then a CA certificate is created and written to certPath.
//
// The returned crypto.Signer wraps the HSM key; signing operations happen
// inside the HSM. The PKCS#11 context is intentionally kept open for the
// process lifetime so the signer remains valid.
func EnsureCAWithHSM(certPath, lib, tokenLabel, keyLabel, pin string) (*x509.Certificate, crypto.Signer, error) {
func EnsureCAWithHSM(certPath, lib, tokenLabel, keyLabel, pin string, lifetime time.Duration) (*x509.Certificate, crypto.Signer, error) {
if lifetime <= 0 {
lifetime = DefaultCALifetime
}
ctx, err := crypto11.Configure(&crypto11.Config{
Path: lib,
TokenLabel: tokenLabel,
Expand Down Expand Up @@ -88,7 +92,7 @@ func EnsureCAWithHSM(certPath, lib, tokenLabel, keyLabel, pin string) (*x509.Cer
return nil, nil, fmt.Errorf("failed to create CA cert directory: %w", mkErr)
}
log.Printf("pki: generating CA certificate for HSM key %q -> %s", keyLabel, certPath)
if genErr := generateCACert(certPath, signer); genErr != nil {
if genErr := generateCACert(certPath, signer, lifetime); genErr != nil {
return nil, nil, genErr
}
}
Expand All @@ -101,9 +105,9 @@ func EnsureCAWithHSM(certPath, lib, tokenLabel, keyLabel, pin string) (*x509.Cer
return caCert, signer, nil
}

// generateCACert creates a self-signed CA certificate using the given signer
// and writes it to certPath (PEM, 0o644).
func generateCACert(certPath string, signer crypto.Signer) error {
// generateCACert creates a self-signed CA certificate valid for lifetime using
// the given signer and writes it to certPath (PEM, 0o644).
func generateCACert(certPath string, signer crypto.Signer, lifetime time.Duration) error {
serialNumber, err := rand.Int(rand.Reader, new(big.Int).Lsh(big.NewInt(1), 128))
if err != nil {
return fmt.Errorf("failed to generate CA serial number: %w", err)
Expand All @@ -116,7 +120,7 @@ func generateCACert(certPath string, signer crypto.Signer) error {
CommonName: "Bor Internal CA",
},
NotBefore: time.Now().Add(-1 * time.Minute),
NotAfter: time.Now().Add(10 * 365 * 24 * time.Hour),
NotAfter: time.Now().Add(lifetime),
KeyUsage: x509.KeyUsageCertSign | x509.KeyUsageCRLSign,
BasicConstraintsValid: true,
IsCA: true,
Expand Down
3 changes: 2 additions & 1 deletion server/internal/pki/pkcs11_stub.go
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ import (
"crypto"
"crypto/x509"
"errors"
"time"
)

// ErrPKCS11NotCompiled is returned by EnsureCAWithHSM in builds without the
Expand All @@ -26,6 +27,6 @@ var ErrPKCS11NotCompiled = errors.New(

// EnsureCAWithHSM is not available in this build.
// Rebuild with -tags pkcs11 to enable PKCS#11 HSM support.
func EnsureCAWithHSM(_, _, _, _, _ string) (*x509.Certificate, crypto.Signer, error) {
func EnsureCAWithHSM(_, _, _, _, _ string, _ time.Duration) (*x509.Certificate, crypto.Signer, error) {
return nil, nil, ErrPKCS11NotCompiled
}
27 changes: 20 additions & 7 deletions server/internal/pki/pki.go
Original file line number Diff line number Diff line change
Expand Up @@ -136,15 +136,28 @@ func EnsureServerCert(dir string, caCert *x509.Certificate, caKey crypto.Signer,
return certPath, keyPath, nil
}

// EnsureCA checks for an existing CA cert/key at dir/ca.crt and dir/ca.key.
// If they do not exist it generates a new CA (ECDSA P-384, 10 years).
// DefaultCALifetime is the validity of a newly generated internal CA
// certificate when no lifetime is configured.
const DefaultCALifetime = 10 * 365 * 24 * time.Hour

// EnsureCA is EnsureCAWithLifetime with DefaultCALifetime.
func EnsureCA(dir string) (certPath, keyPath string, err error) {
return EnsureCAWithLifetime(dir, DefaultCALifetime)
}

// EnsureCAWithLifetime checks for an existing CA cert/key at dir/ca.crt and
// dir/ca.key. If they do not exist it generates a new CA (ECDSA P-384) valid
// for lifetime; a non-positive lifetime means DefaultCALifetime. The lifetime
// only applies at generation time: an existing CA is returned unchanged.
//
// ECDSA P-384 provides 192-bit security — appropriate for a CA with a
// 10-year lifetime. Satisfies FIPS 140-3, BSI TR-02102-1, ANSSI RGS,
// and ETSI TS 119 312.
// ECDSA P-384 provides 192-bit security - appropriate for a long-lived CA.
// Satisfies FIPS 140-3, BSI TR-02102-1, ANSSI RGS, and ETSI TS 119 312.
//
// Returns the paths to the CA cert and key files.
func EnsureCA(dir string) (certPath, keyPath string, err error) {
func EnsureCAWithLifetime(dir string, lifetime time.Duration) (certPath, keyPath string, err error) {
if lifetime <= 0 {
lifetime = DefaultCALifetime
}
certPath = filepath.Join(dir, "ca.crt")
keyPath = filepath.Join(dir, "ca.key")

Expand Down Expand Up @@ -174,7 +187,7 @@ func EnsureCA(dir string) (certPath, keyPath string, err error) {
CommonName: "Bor Internal CA",
},
NotBefore: time.Now().Add(-1 * time.Minute),
NotAfter: time.Now().Add(10 * 365 * 24 * time.Hour),
NotAfter: time.Now().Add(lifetime),
KeyUsage: x509.KeyUsageCertSign | x509.KeyUsageCRLSign,
BasicConstraintsValid: true,
IsCA: true,
Expand Down
42 changes: 42 additions & 0 deletions server/internal/pki/pki_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ import (
"os"
"path/filepath"
"testing"
"time"
)

func TestEnsureServerCert_SelfSigned(t *testing.T) {
Expand Down Expand Up @@ -196,6 +197,47 @@ func TestEnsureServerCert_RegeneratesWhenCAChanges(t *testing.T) {
}
}

func TestEnsureCAWithLifetime(t *testing.T) {
const day = 24 * time.Hour
tests := []struct {
name string
lifetime time.Duration
want time.Duration
}{
{name: "custom", lifetime: 3 * 365 * day, want: 3 * 365 * day},
{name: "zero falls back to default", lifetime: 0, want: DefaultCALifetime},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
dir := t.TempDir()
certPath, keyPath, err := EnsureCAWithLifetime(dir, tt.lifetime)
if err != nil {
t.Fatalf("EnsureCAWithLifetime() error = %v", err)
}
caCert, _, err := LoadCA(certPath, keyPath)
if err != nil {
t.Fatalf("LoadCA() error = %v", err)
}
got := time.Until(caCert.NotAfter)
if got > tt.want || got < tt.want-time.Minute {
t.Errorf("CA expires in %v, want about %v", got, tt.want)
}

// An existing CA keeps its lifetime.
if _, _, err = EnsureCAWithLifetime(dir, 20*365*day); err != nil {
t.Fatalf("EnsureCAWithLifetime() second call error = %v", err)
}
again, _, err := LoadCA(certPath, keyPath)
if err != nil {
t.Fatalf("LoadCA() error = %v", err)
}
if !again.NotAfter.Equal(caCert.NotAfter) {
t.Errorf("existing CA was re-issued: NotAfter %v -> %v", caCert.NotAfter, again.NotAfter)
}
})
}
}

func TestEnsureCA(t *testing.T) {
dir := t.TempDir()

Expand Down
Loading
Loading