ZDDC/zddc/internal/tlsutil/selfsigned_test.go
ZDDC 460d5fdada 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>
2026-05-04 17:55:52 -05:00

102 lines
3.5 KiB
Go

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)
}
}
}