feat(server): TLS hardening per NIST SP 800-52 Rev. 2 + HSTS
The TLS configuration was using Go stdlib defaults — secure for typical
commercial use, but federal evaluators need an explicit cipher
allowlist they can map to a FIPS-validated implementation. Pin the
cipher and curve lists to NIST SP 800-52 Rev. 2 § 3.3 conformant
values:
Ciphers (TLS 1.2):
TLS_ECDHE_ECDSA_WITH_AES_128_GCM_SHA256
TLS_ECDHE_ECDSA_WITH_AES_256_GCM_SHA384
TLS_ECDHE_RSA_WITH_AES_128_GCM_SHA256
TLS_ECDHE_RSA_WITH_AES_256_GCM_SHA384
TLS_ECDHE_ECDSA_WITH_CHACHA20_POLY1305
TLS_ECDHE_RSA_WITH_CHACHA20_POLY1305
Curves: X25519, P-256, P-384
MinVersion: TLS 1.2 (already set; 1.3 used when negotiated)
TLS 1.3 cipher selection is not operator-controllable in Go stdlib
(the runtime picks from a fixed AEAD-only set); all of those
already meet the federal bar so no change needed there.
Also adds HSTSMiddleware emitting `Strict-Transport-Security:
max-age=31536000; includeSubDomains` when zddc-server is itself
terminating TLS (ZDDC_TLS_CERT != none). Behind an upstream proxy
terminating TLS the proxy is responsible for HSTS, so the middleware
only wraps the chain when useTLS=true.
Test coverage:
* TLSConfig(none) returns nil + useTLS=false
* TLSConfig(selfsigned) sets the exact NIST allowlist
* Negative test asserting weak ciphers (CBC, RC4, 3DES, RSA-key-
exchange) are NOT in the list — guardrail against regressions
Federal-readiness gap analysis updated: this control is now partially
complete. OCSP stapling and CT-log inclusion remain on the list for
full DoD STIG conformance.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
ca0364c197
commit
460d5fdada
5 changed files with 170 additions and 6 deletions
|
|
@ -552,10 +552,15 @@ have to redo the gap analysis from scratch.
|
||||||
- **FIPS 140-3 cryptography** (NIST SC-13) — current build uses Go stdlib
|
- **FIPS 140-3 cryptography** (NIST SC-13) — current build uses Go stdlib
|
||||||
crypto. Required: build with `GOEXPERIMENT=systemcrypto` + RHEL FIPS
|
crypto. Required: build with `GOEXPERIMENT=systemcrypto` + RHEL FIPS
|
||||||
userspace, or use the `microsoft/go` (formerly goboring) toolchain.
|
userspace, or use the `microsoft/go` (formerly goboring) toolchain.
|
||||||
- **TLS hardening** (NIST SC-8(1)) — server uses Go stdlib `tls.Config`
|
- **TLS hardening** (NIST SC-8(1)) — *partially complete.* Server now
|
||||||
defaults; no explicit `MinVersion`, `CipherSuites`, or curve list.
|
sets `MinVersion: tls.VersionTLS12`, the NIST SP 800-52 Rev. 2
|
||||||
Required: explicit `MinVersion: tls.VersionTLS12` (TLS 1.3 preferred),
|
AEAD-only cipher allowlist (ECDHE+AES-GCM and ECDHE+ChaCha20Poly1305
|
||||||
DoD-approved cipher allowlist, OCSP stapling, HSTS header.
|
variants), curve preferences (X25519, P-256, P-384), and emits HSTS
|
||||||
|
(`max-age=31536000; includeSubDomains`) when zddc-server itself
|
||||||
|
terminates TLS. *Still required for full DoD STIG conformance:*
|
||||||
|
OCSP stapling, certificate-transparency-log inclusion, and an
|
||||||
|
audit-grade documentation pack mapping the cipher list to FIPS
|
||||||
|
140-3 validated implementations.
|
||||||
- **Authenticated proxy↔server channel** (NIST IA-3) — current trust is
|
- **Authenticated proxy↔server channel** (NIST IA-3) — current trust is
|
||||||
network-level isolation only. Required: mTLS or signed forwarding token
|
network-level isolation only. Required: mTLS or signed forwarding token
|
||||||
(e.g. JWT signed by the proxy with a key zddc-server validates).
|
(e.g. JWT signed by the proxy with a key zddc-server validates).
|
||||||
|
|
|
||||||
|
|
@ -146,9 +146,20 @@ func main() {
|
||||||
"url", cfg.OPAURL,
|
"url", cfg.OPAURL,
|
||||||
"cache_ttl", cfg.OPACacheTTL)
|
"cache_ttl", cfg.OPACacheTTL)
|
||||||
|
|
||||||
mux.Handle("/", handler.ACLMiddleware(cfg, decider, handler.AccessLogMiddleware(auditLogger, handler.CORSMiddleware(cfg, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
// Innermost handler: dispatch.
|
||||||
|
var inner http.Handler = http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
dispatch(cfg, idx, logRing, appsServer, w, r)
|
dispatch(cfg, idx, logRing, appsServer, w, r)
|
||||||
})))))
|
})
|
||||||
|
inner = handler.CORSMiddleware(cfg, inner)
|
||||||
|
// HSTS only when zddc-server itself is the TLS-terminating endpoint.
|
||||||
|
// Behind an upstream proxy terminating TLS (cfg.TLSMode=="none"), the
|
||||||
|
// proxy is responsible for HSTS — adding it here would conflict.
|
||||||
|
if useTLS {
|
||||||
|
inner = handler.HSTSMiddleware(inner)
|
||||||
|
}
|
||||||
|
inner = handler.AccessLogMiddleware(auditLogger, inner)
|
||||||
|
inner = handler.ACLMiddleware(cfg, decider, inner)
|
||||||
|
mux.Handle("/", inner)
|
||||||
|
|
||||||
gzWrapper, err := newGzipWrapper()
|
gzWrapper, err := newGzipWrapper()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|
|
||||||
|
|
@ -91,6 +91,26 @@ func (rw *responseWriter) Write(b []byte) (int, error) {
|
||||||
return n, err
|
return n, err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// HSTSMiddleware sets the Strict-Transport-Security response header,
|
||||||
|
// instructing browsers to refuse plain-HTTP connections to this host
|
||||||
|
// for the next year (NIST SP 800-52 Rev. 2 § 4.4.6, also DoD STIG
|
||||||
|
// expectation; OWASP recommendation max-age >= 1 year). Use ONLY when
|
||||||
|
// zddc-server is itself terminating TLS — when an upstream proxy
|
||||||
|
// terminates, that proxy should set HSTS instead.
|
||||||
|
//
|
||||||
|
// includeSubDomains is set; preload is not (preload requires
|
||||||
|
// pre-submitting the domain to the browser-vendor list — out of
|
||||||
|
// scope for this server, and operators who want it can override
|
||||||
|
// upstream).
|
||||||
|
//
|
||||||
|
// max-age = 31536000 = 365 days.
|
||||||
|
func HSTSMiddleware(next http.Handler) http.Handler {
|
||||||
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
w.Header().Set("Strict-Transport-Security", "max-age=31536000; includeSubDomains")
|
||||||
|
next.ServeHTTP(w, r)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
// AccessLogMiddleware logs a structured line per HTTP request after the
|
// AccessLogMiddleware logs a structured line per HTTP request after the
|
||||||
// response is written.
|
// response is written.
|
||||||
//
|
//
|
||||||
|
|
|
||||||
|
|
@ -38,6 +38,32 @@ func TLSConfig(cfg config.Config) (*tls.Config, bool, error) {
|
||||||
return &tls.Config{
|
return &tls.Config{
|
||||||
Certificates: []tls.Certificate{cert},
|
Certificates: []tls.Certificate{cert},
|
||||||
MinVersion: tls.VersionTLS12,
|
MinVersion: tls.VersionTLS12,
|
||||||
|
// NIST SP 800-52 Rev. 2 conformant cipher allowlist for TLS 1.2.
|
||||||
|
// (TLS 1.3 ciphers are not operator-selectable in Go's stdlib —
|
||||||
|
// the runtime picks from a fixed set of AEAD suites; that's fine
|
||||||
|
// because all of them meet the federal bar.) Order matters when
|
||||||
|
// preferServerCipherSuites was respected by clients; modern Go
|
||||||
|
// uses the runtime's own preference, but the explicit list still
|
||||||
|
// drops every weak suite a client might offer.
|
||||||
|
// AES-128-GCM is listed before AES-256-GCM because hardware
|
||||||
|
// AES-NI makes the 128-bit suite measurably faster with no
|
||||||
|
// security-margin compromise (NIST allows both).
|
||||||
|
CipherSuites: []uint16{
|
||||||
|
tls.TLS_ECDHE_ECDSA_WITH_AES_128_GCM_SHA256,
|
||||||
|
tls.TLS_ECDHE_ECDSA_WITH_AES_256_GCM_SHA384,
|
||||||
|
tls.TLS_ECDHE_RSA_WITH_AES_128_GCM_SHA256,
|
||||||
|
tls.TLS_ECDHE_RSA_WITH_AES_256_GCM_SHA384,
|
||||||
|
tls.TLS_ECDHE_ECDSA_WITH_CHACHA20_POLY1305,
|
||||||
|
tls.TLS_ECDHE_RSA_WITH_CHACHA20_POLY1305,
|
||||||
|
},
|
||||||
|
// NIST SP 800-52 Rev. 2 § 3.3.2: X25519, P-256, P-384.
|
||||||
|
// X25519 first — fastest modern curve, no known weaknesses;
|
||||||
|
// the NIST P-curves follow for clients that don't support it.
|
||||||
|
CurvePreferences: []tls.CurveID{
|
||||||
|
tls.X25519,
|
||||||
|
tls.CurveP256,
|
||||||
|
tls.CurveP384,
|
||||||
|
},
|
||||||
}, true, nil
|
}, true, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
102
zddc/internal/tlsutil/selfsigned_test.go
Normal file
102
zddc/internal/tlsutil/selfsigned_test.go
Normal file
|
|
@ -0,0 +1,102 @@
|
||||||
|
package tlsutil
|
||||||
|
|
||||||
|
import (
|
||||||
|
"crypto/tls"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"codeberg.org/VARASYS/ZDDC/zddc/internal/config"
|
||||||
|
)
|
||||||
|
|
||||||
|
// TestTLSConfig_NoneMode: TLSMode=="none" returns no config and useTLS=false.
|
||||||
|
func TestTLSConfig_NoneMode(t *testing.T) {
|
||||||
|
tlsCfg, useTLS, err := TLSConfig(config.Config{TLSMode: "none"})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("TLSConfig(none): %v", err)
|
||||||
|
}
|
||||||
|
if useTLS {
|
||||||
|
t.Errorf("useTLS = true, want false for TLSMode=none")
|
||||||
|
}
|
||||||
|
if tlsCfg != nil {
|
||||||
|
t.Errorf("tlsCfg = %+v, want nil for TLSMode=none", tlsCfg)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestTLSConfig_SelfSignedHardenedDefaults: the self-signed path returns a
|
||||||
|
// config that conforms to NIST SP 800-52 Rev. 2 — TLS 1.2 minimum, the
|
||||||
|
// AEAD-only cipher allowlist, and the X25519/P-256/P-384 curve list.
|
||||||
|
func TestTLSConfig_SelfSignedHardenedDefaults(t *testing.T) {
|
||||||
|
tlsCfg, useTLS, err := TLSConfig(config.Config{TLSMode: "selfsigned"})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("TLSConfig(selfsigned): %v", err)
|
||||||
|
}
|
||||||
|
if !useTLS {
|
||||||
|
t.Fatal("useTLS = false, want true")
|
||||||
|
}
|
||||||
|
if tlsCfg == nil {
|
||||||
|
t.Fatal("tlsCfg = nil")
|
||||||
|
}
|
||||||
|
|
||||||
|
if tlsCfg.MinVersion != tls.VersionTLS12 {
|
||||||
|
t.Errorf("MinVersion = %#x, want TLS 1.2 (%#x)", tlsCfg.MinVersion, tls.VersionTLS12)
|
||||||
|
}
|
||||||
|
|
||||||
|
wantCiphers := map[uint16]bool{
|
||||||
|
tls.TLS_ECDHE_ECDSA_WITH_AES_128_GCM_SHA256: true,
|
||||||
|
tls.TLS_ECDHE_ECDSA_WITH_AES_256_GCM_SHA384: true,
|
||||||
|
tls.TLS_ECDHE_RSA_WITH_AES_128_GCM_SHA256: true,
|
||||||
|
tls.TLS_ECDHE_RSA_WITH_AES_256_GCM_SHA384: true,
|
||||||
|
tls.TLS_ECDHE_ECDSA_WITH_CHACHA20_POLY1305: true,
|
||||||
|
tls.TLS_ECDHE_RSA_WITH_CHACHA20_POLY1305: true,
|
||||||
|
}
|
||||||
|
if len(tlsCfg.CipherSuites) != len(wantCiphers) {
|
||||||
|
t.Errorf("CipherSuites count = %d, want %d", len(tlsCfg.CipherSuites), len(wantCiphers))
|
||||||
|
}
|
||||||
|
for _, c := range tlsCfg.CipherSuites {
|
||||||
|
if !wantCiphers[c] {
|
||||||
|
t.Errorf("CipherSuites contains unexpected suite %#x; allowlist is the NIST SP 800-52 Rev. 2 set", c)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
wantCurves := map[tls.CurveID]bool{
|
||||||
|
tls.X25519: true,
|
||||||
|
tls.CurveP256: true,
|
||||||
|
tls.CurveP384: true,
|
||||||
|
}
|
||||||
|
if len(tlsCfg.CurvePreferences) != len(wantCurves) {
|
||||||
|
t.Errorf("CurvePreferences count = %d, want %d", len(tlsCfg.CurvePreferences), len(wantCurves))
|
||||||
|
}
|
||||||
|
for _, c := range tlsCfg.CurvePreferences {
|
||||||
|
if !wantCurves[c] {
|
||||||
|
t.Errorf("CurvePreferences contains unexpected curve %v", c)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(tlsCfg.Certificates) != 1 {
|
||||||
|
t.Errorf("Certificates count = %d, want 1", len(tlsCfg.Certificates))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestTLSConfig_NoWeakCiphers: the allowlist must not include any of the
|
||||||
|
// federally-deprecated suites — CBC-mode, RC4, 3DES, SHA-1, NULL, EXPORT.
|
||||||
|
// This is a guardrail against accidental regressions if the list is edited.
|
||||||
|
func TestTLSConfig_NoWeakCiphers(t *testing.T) {
|
||||||
|
tlsCfg, _, err := TLSConfig(config.Config{TLSMode: "selfsigned"})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("TLSConfig: %v", err)
|
||||||
|
}
|
||||||
|
weak := map[uint16]string{
|
||||||
|
tls.TLS_RSA_WITH_AES_128_CBC_SHA: "AES-128-CBC-SHA (CBC mode)",
|
||||||
|
tls.TLS_RSA_WITH_AES_256_CBC_SHA: "AES-256-CBC-SHA (CBC mode)",
|
||||||
|
tls.TLS_RSA_WITH_AES_128_GCM_SHA256: "RSA-AES-128-GCM (no forward secrecy)",
|
||||||
|
tls.TLS_RSA_WITH_AES_256_GCM_SHA384: "RSA-AES-256-GCM (no forward secrecy)",
|
||||||
|
tls.TLS_ECDHE_RSA_WITH_AES_128_CBC_SHA: "ECDHE-RSA-AES-128-CBC-SHA (CBC mode)",
|
||||||
|
tls.TLS_ECDHE_RSA_WITH_AES_256_CBC_SHA: "ECDHE-RSA-AES-256-CBC-SHA (CBC mode)",
|
||||||
|
tls.TLS_ECDHE_RSA_WITH_3DES_EDE_CBC_SHA: "3DES",
|
||||||
|
tls.TLS_RSA_WITH_RC4_128_SHA: "RC4",
|
||||||
|
}
|
||||||
|
for _, c := range tlsCfg.CipherSuites {
|
||||||
|
if name, bad := weak[c]; bad {
|
||||||
|
t.Errorf("CipherSuites includes federally-deprecated suite: %s (%#x)", name, c)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
Reference in a new issue