diff --git a/zddc/README.md b/zddc/README.md index f2c0e77..33ec144 100644 --- a/zddc/README.md +++ b/zddc/README.md @@ -552,10 +552,15 @@ have to redo the gap analysis from scratch. - **FIPS 140-3 cryptography** (NIST SC-13) — current build uses Go stdlib crypto. Required: build with `GOEXPERIMENT=systemcrypto` + RHEL FIPS userspace, or use the `microsoft/go` (formerly goboring) toolchain. -- **TLS hardening** (NIST SC-8(1)) — server uses Go stdlib `tls.Config` - defaults; no explicit `MinVersion`, `CipherSuites`, or curve list. - Required: explicit `MinVersion: tls.VersionTLS12` (TLS 1.3 preferred), - DoD-approved cipher allowlist, OCSP stapling, HSTS header. +- **TLS hardening** (NIST SC-8(1)) — *partially complete.* Server now + sets `MinVersion: tls.VersionTLS12`, the NIST SP 800-52 Rev. 2 + AEAD-only cipher allowlist (ECDHE+AES-GCM and ECDHE+ChaCha20Poly1305 + 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 network-level isolation only. Required: mTLS or signed forwarding token (e.g. JWT signed by the proxy with a key zddc-server validates). diff --git a/zddc/cmd/zddc-server/main.go b/zddc/cmd/zddc-server/main.go index 6e9f9a5..51ac972 100644 --- a/zddc/cmd/zddc-server/main.go +++ b/zddc/cmd/zddc-server/main.go @@ -146,9 +146,20 @@ func main() { "url", cfg.OPAURL, "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) - }))))) + }) + 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() if err != nil { diff --git a/zddc/internal/handler/middleware.go b/zddc/internal/handler/middleware.go index f0c1779..04f39c4 100644 --- a/zddc/internal/handler/middleware.go +++ b/zddc/internal/handler/middleware.go @@ -91,6 +91,26 @@ func (rw *responseWriter) Write(b []byte) (int, error) { 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 // response is written. // diff --git a/zddc/internal/tlsutil/selfsigned.go b/zddc/internal/tlsutil/selfsigned.go index 1c8caab..ec959f0 100644 --- a/zddc/internal/tlsutil/selfsigned.go +++ b/zddc/internal/tlsutil/selfsigned.go @@ -38,6 +38,32 @@ func TLSConfig(cfg config.Config) (*tls.Config, bool, error) { return &tls.Config{ Certificates: []tls.Certificate{cert}, 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 } diff --git a/zddc/internal/tlsutil/selfsigned_test.go b/zddc/internal/tlsutil/selfsigned_test.go new file mode 100644 index 0000000..e2b9d66 --- /dev/null +++ b/zddc/internal/tlsutil/selfsigned_test.go @@ -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) + } + } +}