diff --git a/archive/cmd/restoretest-metrics/main.go b/archive/cmd/restoretest-metrics/main.go index a26ddc76..71d54249 100644 --- a/archive/cmd/restoretest-metrics/main.go +++ b/archive/cmd/restoretest-metrics/main.go @@ -21,7 +21,7 @@ func main() { } addr := os.Getenv("NEXARCH_RESTORETEST_METRICS_LISTEN_ADDR") if addr == "" { - addr = ":8091" + addr = "127.0.0.1:8091" } mux := http.NewServeMux() diff --git a/archive/cmd/scrub-metrics/main.go b/archive/cmd/scrub-metrics/main.go index a556d8d6..dae7e080 100644 --- a/archive/cmd/scrub-metrics/main.go +++ b/archive/cmd/scrub-metrics/main.go @@ -25,7 +25,7 @@ func main() { } addr := os.Getenv("NEXARCH_SCRUB_METRICS_LISTEN_ADDR") if addr == "" { - addr = ":8090" + addr = "127.0.0.1:8090" } ctx := context.Background() diff --git a/archive/go.mod b/archive/go.mod index fb530957..c4ba8374 100644 --- a/archive/go.mod +++ b/archive/go.mod @@ -1,14 +1,15 @@ module gitea.perlbach24.de/scripte/nexarch/archive -go 1.22 +go 1.25.0 -require github.com/jackc/pgx/v5 v5.6.0 +toolchain go1.25.13 + +require github.com/jackc/pgx/v5 v5.9.2 require ( github.com/jackc/pgpassfile v1.0.0 // indirect - github.com/jackc/pgservicefile v0.0.0-20221227161230-091c0ba34f0a // indirect - github.com/jackc/puddle/v2 v2.2.1 // indirect - golang.org/x/crypto v0.17.0 // indirect - golang.org/x/sync v0.1.0 // indirect - golang.org/x/text v0.14.0 // indirect + github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect + github.com/jackc/puddle/v2 v2.2.2 // indirect + golang.org/x/sync v0.22.0 // indirect + golang.org/x/text v0.41.0 // indirect ) diff --git a/archive/go.sum b/archive/go.sum index 5c396713..329507a0 100644 --- a/archive/go.sum +++ b/archive/go.sum @@ -3,25 +3,23 @@ github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM= github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg= -github.com/jackc/pgservicefile v0.0.0-20221227161230-091c0ba34f0a h1:bbPeKD0xmW/Y25WS6cokEszi5g+S0QxI/d45PkRi7Nk= -github.com/jackc/pgservicefile v0.0.0-20221227161230-091c0ba34f0a/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM= -github.com/jackc/pgx/v5 v5.6.0 h1:SWJzexBzPL5jb0GEsrPMLIsi/3jOo7RHlzTjcAeDrPY= -github.com/jackc/pgx/v5 v5.6.0/go.mod h1:DNZ/vlrUnhWCoFGxHAG8U2ljioxukquj7utPDgtQdTw= -github.com/jackc/puddle/v2 v2.2.1 h1:RhxXJtFG022u4ibrCSMSiu5aOq1i77R3OHKNJj77OAk= -github.com/jackc/puddle/v2 v2.2.1/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4= +github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 h1:iCEnooe7UlwOQYpKFhBabPMi4aNAfoODPEFNiAnClxo= +github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM= +github.com/jackc/pgx/v5 v5.9.2 h1:3ZhOzMWnR4yJ+RW1XImIPsD1aNSz4T4fyP7zlQb56hw= +github.com/jackc/pgx/v5 v5.9.2/go.mod h1:mal1tBGAFfLHvZzaYh77YS/eC6IX9OWbRV1QIIM0Jn4= +github.com/jackc/puddle/v2 v2.2.2 h1:PR8nw+E/1w0GLuRFSmiioY6UooMp6KJv0/61nB7icHo= +github.com/jackc/puddle/v2 v2.2.2/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= -github.com/stretchr/testify v1.8.1 h1:w7B6lhMri9wdJUVmEZPGGhZzrYTPvgJArz7wNPgYKsk= -github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4= -golang.org/x/crypto v0.17.0 h1:r8bRNjWL3GshPW3gkd+RpvzWrZAwPS49OmTGZ/uhM4k= -golang.org/x/crypto v0.17.0/go.mod h1:gCAAfMLgwOJRpTjQ2zCCt2OcSfYMTeZVSRtQlPC7Nq4= -golang.org/x/sync v0.1.0 h1:wsuoTGHzEhffawBOhz5CYhcrV4IdKZbEyZjBMuTp12o= -golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= -golang.org/x/text v0.14.0 h1:ScX5w1eTa3QqT8oi6+ziP7dTV1S2+ALU0bI+0zXKWiQ= -golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU= +github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= +github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= +golang.org/x/sync v0.22.0 h1:SZjpbeLmrCk4xhRSZFNZW5gFUeCeFgjekvI/+gfScek= +golang.org/x/sync v0.22.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= +golang.org/x/text v0.41.0 h1:vz/seA0lnX87Othu2f/0L24RcgrXD9/YFTSuGjj3rH8= +golang.org/x/text v0.41.0/go.mod h1:jvf1O8ajNzZqhSrQBPbutR/EB83Cc0CFrezNQIwbb5M= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= diff --git a/cmd/account-api/main.go b/cmd/account-api/main.go index 268ddabb..8cf1e5ae 100644 --- a/cmd/account-api/main.go +++ b/cmd/account-api/main.go @@ -52,6 +52,7 @@ func main() { rotateModuleKey := flag.Bool("rotate-module-key", false, "neuen Modul-Signaturschluessel anlegen und beenden") rotateSessionKey := flag.Bool("rotate-session-key", false, "QA-15 F-10: neuen Sitzungs-Signaturschluessel anlegen und beenden") encryptTOTPSecrets := flag.Bool("encrypt-totp-secrets", false, "QA-15 F-06: bestehende Klartext-TOTP-Secrets nachverschluesseln und beenden") + rewrapModuleKeys := flag.Bool("rewrap-module-keys", false, "QA-15 F-25: gespeicherte Modul-Signaturschluessel von "+envKeyEncryptionKeyOld+" auf "+envKeyEncryptionKey+" neu verpacken und beenden") flag.Parse() dsn := requireEnv("NEXARCH_ACCOUNT_TENANT_DSN") tenantSlug := requireEnv("NEXARCH_ACCOUNT_TENANT_SLUG") @@ -163,6 +164,15 @@ func main() { if moduleIssuer == "" { moduleIssuer = "nexarch-core" } + if *rewrapModuleKeys { + n, err := rewrapModuleSigningKeys(ctx, pool) + if err != nil { + log.Fatalf("modul-schluessel neu verpacken: %v", err) + } + log.Printf("account-api: %d modul-schluessel neu verpackt", n) + return + } + moduleKeys, err := newModuleKeyManager(ctx, pool, os.Getenv, moduleTokenTTL) if err != nil { log.Fatalf("modul-schluessel: %v", err) diff --git a/cmd/account-api/modulekeys.go b/cmd/account-api/modulekeys.go index 0af2cc5c..01042e51 100644 --- a/cmd/account-api/modulekeys.go +++ b/cmd/account-api/modulekeys.go @@ -13,11 +13,16 @@ import ( ) const ( - envKeyEncryptionKey = "NEXARCH_ACCOUNT_MODULE_KEY_ENCRYPTION_KEY" - envKeysEphemeral = "NEXARCH_ACCOUNT_MODULE_KEYS_EPHEMERAL" - envKeyRotationDays = "NEXARCH_ACCOUNT_MODULE_KEY_ROTATION_DAYS" - envKeyActivationSecs = "NEXARCH_ACCOUNT_MODULE_KEY_ACTIVATION_DELAY_SECONDS" - envKeyRefreshSecs = "NEXARCH_ACCOUNT_MODULE_KEY_REFRESH_SECONDS" + envKeyEncryptionKey = "NEXARCH_ACCOUNT_MODULE_KEY_ENCRYPTION_KEY" + // envKeyEncryptionKeyOld ist nur fuer `-rewrap-module-keys` (QA-15 F-25) + // relevant: der VORHERIGE Verschluesselungsschluessel, mit dem die + // gespeicherten Modul-Signaturschluessel gerade noch entschluesselbar + // sind, bevor sie auf envKeyEncryptionKey neu verpackt werden. + envKeyEncryptionKeyOld = "NEXARCH_ACCOUNT_MODULE_KEY_ENCRYPTION_KEY_OLD" + envKeysEphemeral = "NEXARCH_ACCOUNT_MODULE_KEYS_EPHEMERAL" + envKeyRotationDays = "NEXARCH_ACCOUNT_MODULE_KEY_ROTATION_DAYS" + envKeyActivationSecs = "NEXARCH_ACCOUNT_MODULE_KEY_ACTIVATION_DELAY_SECONDS" + envKeyRefreshSecs = "NEXARCH_ACCOUNT_MODULE_KEY_REFRESH_SECONDS" defaultRotationDays = 30 defaultActivationSecs = 120 @@ -107,3 +112,23 @@ func newModuleKeyManager(ctx context.Context, pool *pgxpool.Pool, getenv func(st RefreshInterval: cfg.refreshInterval, }) } + +// rewrapModuleSigningKeys verpackt alle gespeicherten Modul-Signaturschluessel +// von envKeyEncryptionKeyOld auf envKeyEncryptionKey um (QA-15 F-25). Beide +// Umgebungsvariablen muessen gesetzt sein; ephemere (nicht persistente) +// Schluesselverwaltung hat nichts zum Neuverpacken. +func rewrapModuleSigningKeys(ctx context.Context, pool *pgxpool.Pool) (int, error) { + oldKey, err := kek.LoadMasterKeyFromEnv(envKeyEncryptionKeyOld) + if err != nil { + return 0, fmt.Errorf("%s: %w", envKeyEncryptionKeyOld, err) + } + newKey, err := kek.LoadMasterKeyFromEnv(envKeyEncryptionKey) + if err != nil { + return 0, fmt.Errorf("%s: %w", envKeyEncryptionKey, err) + } + res, err := moduletrust.RewrapAll(ctx, moduletrust.NewPGKeyStore(pool), kekCipher{key: oldKey}, kekCipher{key: newKey}) + if err != nil { + return 0, err + } + return res.Rewrapped, nil +} diff --git a/cmd/auditlog-devserver/main.go b/cmd/auditlog-devserver/main.go index 3558d6a9..469f0eec 100644 --- a/cmd/auditlog-devserver/main.go +++ b/cmd/auditlog-devserver/main.go @@ -40,7 +40,7 @@ func main() { } addr := os.Getenv("NEXARCH_AUDITLOG_LISTEN_ADDR") if addr == "" { - addr = ":8083" + addr = "127.0.0.1:8083" } // QA-15 F-09: kein "*" mehr — explizite Ursprungsliste (fail closed ohne // Konfiguration, siehe svcauth.OriginsFromEnv). diff --git a/cmd/metrics-devserver/main.go b/cmd/metrics-devserver/main.go index b1aa9dbf..fbff7338 100644 --- a/cmd/metrics-devserver/main.go +++ b/cmd/metrics-devserver/main.go @@ -22,7 +22,7 @@ func main() { } addr := os.Getenv("NEXARCH_METRICS_LISTEN_ADDR") if addr == "" { - addr = ":8085" + addr = "127.0.0.1:8085" } ctx := context.Background() diff --git a/cmd/secheaders-devserver/main.go b/cmd/secheaders-devserver/main.go index 856ddf16..67c45714 100644 --- a/cmd/secheaders-devserver/main.go +++ b/cmd/secheaders-devserver/main.go @@ -8,6 +8,7 @@ package main import ( "log" "net/http" + "os" "gitea.perlbach24.de/scripte/nexarch/internal/httpharden" "gitea.perlbach24.de/scripte/nexarch/internal/secheaders" @@ -36,6 +37,10 @@ func main() { }) mux.HandleFunc("/unprotected", servePage) - log.Println("secheaders-devserver listening on :8086") - log.Fatal(httpharden.ListenAndServe(":8086", mux)) + addr := os.Getenv("NEXARCH_SECHEADERS_LISTEN_ADDR") + if addr == "" { + addr = "127.0.0.1:8086" + } + log.Printf("secheaders-devserver listening on %s", addr) + log.Fatal(httpharden.ListenAndServe(addr, mux)) } diff --git a/cmd/statuspage-devserver/main.go b/cmd/statuspage-devserver/main.go index 1c0f73a1..4fc0ad21 100644 --- a/cmd/statuspage-devserver/main.go +++ b/cmd/statuspage-devserver/main.go @@ -23,7 +23,7 @@ func main() { } addr := os.Getenv("NEXARCH_STATUSPAGE_LISTEN_ADDR") if addr == "" { - addr = ":8084" + addr = "127.0.0.1:8084" } intervalSeconds := 10 if v := os.Getenv("NEXARCH_STATUSPAGE_POLL_INTERVAL_SECONDS"); v != "" { diff --git a/deploy/systemd/nexarch-account-api.service.tmpl b/deploy/systemd/nexarch-account-api.service.tmpl index 5ddf51e3..8f585601 100644 --- a/deploy/systemd/nexarch-account-api.service.tmpl +++ b/deploy/systemd/nexarch-account-api.service.tmpl @@ -9,6 +9,12 @@ EnvironmentFile=/etc/nexarch/account-api.env ExecStart=__INSTALL_DIR__/bin/account-api Restart=on-failure StandardOutput=journal +# QA-15 F-13/OPS-08: reiner Netzwerkdienst (HTTP + Postgres-DSN aus +# EnvironmentFile), keine eigene Schreibarbeit im Dateisystem. +NoNewPrivileges=true +ProtectSystem=strict +ProtectHome=true +PrivateTmp=true [Install] WantedBy=multi-user.target diff --git a/deploy/systemd/nexarch-archive-moduleadapter-api.service.tmpl b/deploy/systemd/nexarch-archive-moduleadapter-api.service.tmpl index 250e82c9..22c80eac 100644 --- a/deploy/systemd/nexarch-archive-moduleadapter-api.service.tmpl +++ b/deploy/systemd/nexarch-archive-moduleadapter-api.service.tmpl @@ -9,6 +9,12 @@ EnvironmentFile=/etc/nexarch/archive-moduleadapter-api.env ExecStart=__INSTALL_DIR__/bin/moduleadapter-api Restart=on-failure StandardOutput=journal +# QA-15 F-13/OPS-08: reiner Netzwerkdienst (HTTP + Postgres-DSN aus +# EnvironmentFile), keine eigene Schreibarbeit im Dateisystem. +NoNewPrivileges=true +ProtectSystem=strict +ProtectHome=true +PrivateTmp=true [Install] WantedBy=multi-user.target diff --git a/deploy/systemd/nexarch-archive-restoretest-metrics.service.tmpl b/deploy/systemd/nexarch-archive-restoretest-metrics.service.tmpl index 8e496895..8f5f3319 100644 --- a/deploy/systemd/nexarch-archive-restoretest-metrics.service.tmpl +++ b/deploy/systemd/nexarch-archive-restoretest-metrics.service.tmpl @@ -9,6 +9,12 @@ EnvironmentFile=/etc/nexarch/archive-restoretest.env ExecStart=__INSTALL_DIR__/bin/restoretest-metrics Restart=on-failure StandardOutput=journal +# QA-15 F-13/OPS-08: reiner Netzwerkdienst (HTTP + Postgres-DSN aus +# EnvironmentFile), keine eigene Schreibarbeit im Dateisystem. +NoNewPrivileges=true +ProtectSystem=strict +ProtectHome=true +PrivateTmp=true [Install] WantedBy=multi-user.target diff --git a/deploy/systemd/nexarch-archive-retention-api.service.tmpl b/deploy/systemd/nexarch-archive-retention-api.service.tmpl index d50c1b5f..8ee50cc3 100644 --- a/deploy/systemd/nexarch-archive-retention-api.service.tmpl +++ b/deploy/systemd/nexarch-archive-retention-api.service.tmpl @@ -9,6 +9,12 @@ EnvironmentFile=/etc/nexarch/archive-retention-api.env ExecStart=__INSTALL_DIR__/bin/retention-api Restart=on-failure StandardOutput=journal +# QA-15 F-13/OPS-08: reiner Netzwerkdienst (HTTP + Postgres-DSN aus +# EnvironmentFile), keine eigene Schreibarbeit im Dateisystem. +NoNewPrivileges=true +ProtectSystem=strict +ProtectHome=true +PrivateTmp=true [Install] WantedBy=multi-user.target diff --git a/deploy/systemd/nexarch-archive-scrub-metrics.service.tmpl b/deploy/systemd/nexarch-archive-scrub-metrics.service.tmpl index 82ddbf27..eca8b0fa 100644 --- a/deploy/systemd/nexarch-archive-scrub-metrics.service.tmpl +++ b/deploy/systemd/nexarch-archive-scrub-metrics.service.tmpl @@ -9,6 +9,12 @@ EnvironmentFile=/etc/nexarch/archive-scrub-metrics.env ExecStart=__INSTALL_DIR__/bin/scrub-metrics Restart=on-failure StandardOutput=journal +# QA-15 F-13/OPS-08: reiner Netzwerkdienst (HTTP + Postgres-DSN aus +# EnvironmentFile), keine eigene Schreibarbeit im Dateisystem. +NoNewPrivileges=true +ProtectSystem=strict +ProtectHome=true +PrivateTmp=true [Install] WantedBy=multi-user.target diff --git a/deploy/systemd/nexarch-auditlog-devserver.service.tmpl b/deploy/systemd/nexarch-auditlog-devserver.service.tmpl index 40d7b7a0..5453a281 100644 --- a/deploy/systemd/nexarch-auditlog-devserver.service.tmpl +++ b/deploy/systemd/nexarch-auditlog-devserver.service.tmpl @@ -12,6 +12,12 @@ ExecStart=__INSTALL_DIR__/bin/auditlog-devserver Restart=on-failure RestartSec=3 WorkingDirectory=__INSTALL_DIR__ +# QA-15 F-13/OPS-08: reiner Netzwerkdienst (HTTP + Postgres-DSN aus +# EnvironmentFile), keine eigene Schreibarbeit im Dateisystem. +NoNewPrivileges=true +ProtectSystem=strict +ProtectHome=true +PrivateTmp=true [Install] WantedBy=multi-user.target diff --git a/deploy/systemd/nexarch-core.service.tmpl b/deploy/systemd/nexarch-core.service.tmpl index 4a0b22ca..89d1e4ee 100644 --- a/deploy/systemd/nexarch-core.service.tmpl +++ b/deploy/systemd/nexarch-core.service.tmpl @@ -12,6 +12,12 @@ ExecStart=__INSTALL_DIR__/bin/core Restart=on-failure RestartSec=3 WorkingDirectory=__INSTALL_DIR__ +# QA-15 F-13/OPS-08: reiner Netzwerkdienst (HTTP + Postgres-DSN aus +# EnvironmentFile), keine eigene Schreibarbeit im Dateisystem. +NoNewPrivileges=true +ProtectSystem=strict +ProtectHome=true +PrivateTmp=true [Install] WantedBy=multi-user.target diff --git a/deploy/systemd/nexarch-kek-api.service.tmpl b/deploy/systemd/nexarch-kek-api.service.tmpl index a3d304bb..4ef8531d 100644 --- a/deploy/systemd/nexarch-kek-api.service.tmpl +++ b/deploy/systemd/nexarch-kek-api.service.tmpl @@ -9,6 +9,12 @@ EnvironmentFile=/etc/nexarch/kek-api.env ExecStart=__INSTALL_DIR__/bin/kek-api Restart=on-failure StandardOutput=journal +# QA-15 F-13/OPS-08: reiner Netzwerkdienst (HTTP + Postgres-DSN aus +# EnvironmentFile), keine eigene Schreibarbeit im Dateisystem. +NoNewPrivileges=true +ProtectSystem=strict +ProtectHome=true +PrivateTmp=true [Install] WantedBy=multi-user.target diff --git a/deploy/systemd/nexarch-licadmin-devserver.service.tmpl b/deploy/systemd/nexarch-licadmin-devserver.service.tmpl index 026963bd..0f589a70 100644 --- a/deploy/systemd/nexarch-licadmin-devserver.service.tmpl +++ b/deploy/systemd/nexarch-licadmin-devserver.service.tmpl @@ -12,6 +12,12 @@ ExecStart=__INSTALL_DIR__/bin/licadmin-devserver Restart=on-failure RestartSec=3 WorkingDirectory=__INSTALL_DIR__ +# QA-15 F-13/OPS-08: reiner Netzwerkdienst (HTTP + Postgres-DSN aus +# EnvironmentFile), keine eigene Schreibarbeit im Dateisystem. +NoNewPrivileges=true +ProtectSystem=strict +ProtectHome=true +PrivateTmp=true [Install] WantedBy=multi-user.target diff --git a/deploy/systemd/nexarch-metrics-devserver.service.tmpl b/deploy/systemd/nexarch-metrics-devserver.service.tmpl index 74622fb2..2a28ed41 100644 --- a/deploy/systemd/nexarch-metrics-devserver.service.tmpl +++ b/deploy/systemd/nexarch-metrics-devserver.service.tmpl @@ -12,6 +12,12 @@ ExecStart=__INSTALL_DIR__/bin/metrics-devserver Restart=on-failure RestartSec=3 WorkingDirectory=__INSTALL_DIR__ +# QA-15 F-13/OPS-08: reiner Netzwerkdienst (HTTP + Postgres-DSN aus +# EnvironmentFile), keine eigene Schreibarbeit im Dateisystem. +NoNewPrivileges=true +ProtectSystem=strict +ProtectHome=true +PrivateTmp=true [Install] WantedBy=multi-user.target diff --git a/deploy/systemd/nexarch-notify-api.service.tmpl b/deploy/systemd/nexarch-notify-api.service.tmpl index b81ae497..c55a1b41 100644 --- a/deploy/systemd/nexarch-notify-api.service.tmpl +++ b/deploy/systemd/nexarch-notify-api.service.tmpl @@ -9,6 +9,12 @@ EnvironmentFile=/etc/nexarch/notify-api.env ExecStart=__INSTALL_DIR__/bin/notify-api Restart=on-failure StandardOutput=journal +# QA-15 F-13/OPS-08: reiner Netzwerkdienst (HTTP + Postgres-DSN aus +# EnvironmentFile), keine eigene Schreibarbeit im Dateisystem. +NoNewPrivileges=true +ProtectSystem=strict +ProtectHome=true +PrivateTmp=true [Install] WantedBy=multi-user.target diff --git a/deploy/systemd/nexarch-notifyprefs-api.service.tmpl b/deploy/systemd/nexarch-notifyprefs-api.service.tmpl index d62118e0..fda25b13 100644 --- a/deploy/systemd/nexarch-notifyprefs-api.service.tmpl +++ b/deploy/systemd/nexarch-notifyprefs-api.service.tmpl @@ -9,6 +9,12 @@ EnvironmentFile=/etc/nexarch/notifyprefs-api.env ExecStart=__INSTALL_DIR__/bin/notifyprefs-api Restart=on-failure StandardOutput=journal +# QA-15 F-13/OPS-08: reiner Netzwerkdienst (HTTP + Postgres-DSN aus +# EnvironmentFile), keine eigene Schreibarbeit im Dateisystem. +NoNewPrivileges=true +ProtectSystem=strict +ProtectHome=true +PrivateTmp=true [Install] WantedBy=multi-user.target diff --git a/deploy/systemd/nexarch-policy-api.service.tmpl b/deploy/systemd/nexarch-policy-api.service.tmpl index ce7e1b27..af714266 100644 --- a/deploy/systemd/nexarch-policy-api.service.tmpl +++ b/deploy/systemd/nexarch-policy-api.service.tmpl @@ -9,6 +9,12 @@ EnvironmentFile=/etc/nexarch/policy-api.env ExecStart=__INSTALL_DIR__/bin/policy-api Restart=on-failure StandardOutput=journal +# QA-15 F-13/OPS-08: reiner Netzwerkdienst (HTTP + Postgres-DSN aus +# EnvironmentFile), keine eigene Schreibarbeit im Dateisystem. +NoNewPrivileges=true +ProtectSystem=strict +ProtectHome=true +PrivateTmp=true [Install] WantedBy=multi-user.target diff --git a/deploy/systemd/nexarch-rbac-admin-api.service.tmpl b/deploy/systemd/nexarch-rbac-admin-api.service.tmpl index 0912c317..42ca371c 100644 --- a/deploy/systemd/nexarch-rbac-admin-api.service.tmpl +++ b/deploy/systemd/nexarch-rbac-admin-api.service.tmpl @@ -9,6 +9,12 @@ EnvironmentFile=/etc/nexarch/rbac-admin-api.env ExecStart=__INSTALL_DIR__/bin/rbac-admin-api Restart=on-failure StandardOutput=journal +# QA-15 F-13/OPS-08: reiner Netzwerkdienst (HTTP + Postgres-DSN aus +# EnvironmentFile), keine eigene Schreibarbeit im Dateisystem. +NoNewPrivileges=true +ProtectSystem=strict +ProtectHome=true +PrivateTmp=true [Install] WantedBy=multi-user.target diff --git a/deploy/systemd/nexarch-resync-api.service.tmpl b/deploy/systemd/nexarch-resync-api.service.tmpl index 9eb2bc04..abd3bdd4 100644 --- a/deploy/systemd/nexarch-resync-api.service.tmpl +++ b/deploy/systemd/nexarch-resync-api.service.tmpl @@ -9,6 +9,12 @@ EnvironmentFile=/etc/nexarch/resync-api.env ExecStart=__INSTALL_DIR__/bin/resync-api Restart=on-failure StandardOutput=journal +# QA-15 F-13/OPS-08: reiner Netzwerkdienst (HTTP + Postgres-DSN aus +# EnvironmentFile), keine eigene Schreibarbeit im Dateisystem. +NoNewPrivileges=true +ProtectSystem=strict +ProtectHome=true +PrivateTmp=true [Install] WantedBy=multi-user.target diff --git a/deploy/systemd/nexarch-secheaders-devserver.service.tmpl b/deploy/systemd/nexarch-secheaders-devserver.service.tmpl index 6bb06fa2..0c478782 100644 --- a/deploy/systemd/nexarch-secheaders-devserver.service.tmpl +++ b/deploy/systemd/nexarch-secheaders-devserver.service.tmpl @@ -12,6 +12,12 @@ ExecStart=__INSTALL_DIR__/bin/secheaders-devserver Restart=on-failure RestartSec=3 WorkingDirectory=__INSTALL_DIR__ +# QA-15 F-13/OPS-08: reiner Netzwerkdienst (HTTP + Postgres-DSN aus +# EnvironmentFile), keine eigene Schreibarbeit im Dateisystem. +NoNewPrivileges=true +ProtectSystem=strict +ProtectHome=true +PrivateTmp=true [Install] WantedBy=multi-user.target diff --git a/deploy/systemd/nexarch-statuspage-devserver.service.tmpl b/deploy/systemd/nexarch-statuspage-devserver.service.tmpl index 125bf000..e08f50a6 100644 --- a/deploy/systemd/nexarch-statuspage-devserver.service.tmpl +++ b/deploy/systemd/nexarch-statuspage-devserver.service.tmpl @@ -12,6 +12,12 @@ ExecStart=__INSTALL_DIR__/bin/statuspage-devserver Restart=on-failure RestartSec=3 WorkingDirectory=__INSTALL_DIR__ +# QA-15 F-13/OPS-08: reiner Netzwerkdienst (HTTP + Postgres-DSN aus +# EnvironmentFile), keine eigene Schreibarbeit im Dateisystem. +NoNewPrivileges=true +ProtectSystem=strict +ProtectHome=true +PrivateTmp=true [Install] WantedBy=multi-user.target diff --git a/deploy/systemd/nexarch-tenantadmin-devserver.service.tmpl b/deploy/systemd/nexarch-tenantadmin-devserver.service.tmpl index 42931384..6d5e03ea 100644 --- a/deploy/systemd/nexarch-tenantadmin-devserver.service.tmpl +++ b/deploy/systemd/nexarch-tenantadmin-devserver.service.tmpl @@ -12,6 +12,12 @@ ExecStart=__INSTALL_DIR__/bin/tenantadmin-devserver Restart=on-failure RestartSec=3 WorkingDirectory=__INSTALL_DIR__ +# QA-15 F-13/OPS-08: reiner Netzwerkdienst (HTTP + Postgres-DSN aus +# EnvironmentFile), keine eigene Schreibarbeit im Dateisystem. +NoNewPrivileges=true +ProtectSystem=strict +ProtectHome=true +PrivateTmp=true [Install] WantedBy=multi-user.target diff --git a/dms/cmd/app/main.go b/dms/cmd/app/main.go index 1034c5b6..7415eb05 100644 --- a/dms/cmd/app/main.go +++ b/dms/cmd/app/main.go @@ -40,7 +40,7 @@ import ( func main() { addr := os.Getenv("NEXARCH_DMS_APP_LISTEN_ADDR") if addr == "" { - addr = ":8600" + addr = "127.0.0.1:8600" } logger := logging.NewDefault() diff --git a/dms/cmd/worker/main.go b/dms/cmd/worker/main.go index 5ce2ebb1..4714dbf1 100644 --- a/dms/cmd/worker/main.go +++ b/dms/cmd/worker/main.go @@ -44,7 +44,7 @@ func main() { metricsAddr := os.Getenv("NEXARCH_DMS_WORKER_METRICS_ADDR") if metricsAddr == "" { - metricsAddr = ":8601" + metricsAddr = "127.0.0.1:8601" } go func() { if err := httpharden.ListenAndServe(metricsAddr, metricsReg.Handler()); err != nil { diff --git a/dms/docs/BETRIEB-UMGEBUNGSVARIABLEN.md b/dms/docs/BETRIEB-UMGEBUNGSVARIABLEN.md index 404e9639..daf7f19c 100644 --- a/dms/docs/BETRIEB-UMGEBUNGSVARIABLEN.md +++ b/dms/docs/BETRIEB-UMGEBUNGSVARIABLEN.md @@ -8,7 +8,7 @@ Aus dem Code abgeleitet (`cmd/app`, `cmd/worker`, `cmd/migrate`, `internal/route | Variable | Standard | Wirkung | |---|---|---| -| `NEXARCH_DMS_APP_LISTEN_ADDR` | `:8600` | Lauschadresse | +| `NEXARCH_DMS_APP_LISTEN_ADDR` | `127.0.0.1:8600` | Lauschadresse (Reverse-Proxy erforderlich fuer externen Zugriff, F-24) | | `NEXARCH_DMS_APP_ROUTE_PREFIX` | `/api/dms/v1` | Präfix aller fachlichen Routen (FDN-14); `/healthz` und `/metrics` bleiben außerhalb | | `NEXARCH_DMS_APP_TENANT_DSN` | Pflicht für Fachfunktionen | Tenant-Datenbank; ohne sie: keine API-Routen, kein Guard, keine Retention-Anbindung, keine Suche | | `NEXARCH_DMS_APP_TENANT_SLUG` | Pflicht | Mandantenkennung | @@ -57,7 +57,7 @@ Aus dem Code abgeleitet (`cmd/app`, `cmd/worker`, `cmd/migrate`, `internal/route | Variable | Standard | Wirkung | |---|---|---| -| `NEXARCH_DMS_WORKER_METRICS_ADDR` | `:8601` | Metrik-Endpunkt | +| `NEXARCH_DMS_WORKER_METRICS_ADDR` | `127.0.0.1:8601` | Metrik-Endpunkt (F-24) | | `NEXARCH_DMS_WORKER_TENANT_DSN` | Pflicht | Datenbank; ohne sie keine Requeue-Verarbeitung | | `NEXARCH_DMS_WORKER_TENANT_SLUG` | Pflicht für Fachjobs | Mandantenkennung | | `NEXARCH_DMS_WORKER_STORAGE_DIR` / `_STORAGE_SIGNING_SECRET` / `_STORAGE_PUBLIC_BASE_URL` | Pflicht für Fachjobs | Storage wie in der App (Secret) | diff --git a/docs/QA-15-PHASE2D-PRUEFPROTOKOLL.md b/docs/QA-15-PHASE2D-PRUEFPROTOKOLL.md new file mode 100644 index 00000000..31a67de9 --- /dev/null +++ b/docs/QA-15-PHASE2D-PRUEFPROTOKOLL.md @@ -0,0 +1,139 @@ +# QA-15 Phase 2d — Prüfprotokoll: OPS-08, OPS-09, OPS-10 + +Basis: `origin/integration/gesamtprojekt` (Head `2c09253`). Branch: `feature/ops-08-09-10-haertungsbuendel-betrieb`. Alle Läufe auf dem Entwicklungshost 204 (Wegwerf-Cluster `17/coredev`, Sperre `/tmp/claude-1000/runhost-locks-204`), `archivdms`-Cluster unberührt. Kein Merge, kein Deploy auf 131. + +## Umfang und Abgrenzung + +Diese drei Kacheln haben sich selbst als „offen — braucht Freigabe" markiert, weil ihre vollständige Erfüllung entweder echte Installationen auf 131 oder eine Migration von Produktionsbestandsdaten voraussetzt. Umgesetzt wurde deshalb **alles, was ohne Eingriff in 131 und ohne echte Bestandsdaten sicher überprüfbar ist** — Code, Migrationen mit synthetischen Daten, Konfigurationsvorlagen und ihre Verifikation auf dem Entwicklungshost. Was tatsächlich einen 131-Rollout-Schritt braucht, ist unten je Kachel benannt, nicht ausgeführt. + +## OPS-10 — Härtungsbündel (F-21, F-22, F-24, F-25) + +### F-24 — einheitliche Lauschadressen + +**Umgesetzt vollständig.** Alle Standardadressen, die zuvor auf allen Interfaces lauschten (`:PORT` statt `127.0.0.1:PORT`), sind korrigiert: + +| Datei | Vorher | Nachher | +|---|---|---| +| `cmd/auditlog-devserver/main.go` | `:8083` | `127.0.0.1:8083` | +| `cmd/statuspage-devserver/main.go` | `:8084` | `127.0.0.1:8084` | +| `cmd/metrics-devserver/main.go` | `:8085` | `127.0.0.1:8085` | +| `cmd/secheaders-devserver/main.go` | `:8086` (fest verdrahtet, kein Env) | `127.0.0.1:8086`, neu `NEXARCH_SECHEADERS_LISTEN_ADDR` | +| `dms/cmd/app/main.go` | `:8600` | `127.0.0.1:8600` | +| `dms/cmd/worker/main.go` (Metriken) | `:8601` | `127.0.0.1:8601` | +| `archive/cmd/restoretest-metrics/main.go` | `:8091` | `127.0.0.1:8091` | +| `archive/cmd/scrub-metrics/main.go` | `:8090` | `127.0.0.1:8090` | + +`dms/cmd/app` war der einzige echte Fund mit dokumentiertem Widerspruch: `dms/docs/DEPLOYMENT-131-CHECKLISTE.md` verlangte bereits „Gateway/Reverse-Proxy routet auf `127.0.0.1:8600`", der Code band aber auf allen Interfaces. `dms/docs/BETRIEB-UMGEBUNGSVARIABLEN.md` ist entsprechend nachgezogen. Alle anderen bereits per Env konfigurierbaren Dienste (Core-APIs, `licadmin`/`tenantadmin`-Devserver) hatten schon vor diesem Durchgang `127.0.0.1` als Standard (aus Phase 2a, F-02/F-03). + +**Prüfung:** `go build ./...` und `go vet ./...` sauber in Core, Mail, Archive, DMS (siehe unten). + +### F-22 — Webhook-Ziel-Prüfung (SSRF) und verschlüsseltes Signaturgeheimnis + +**Umgesetzt vollständig.** `internal/webhook` hatte keine produktiven Aufrufer (API-07 baute nur die Engine, nichts verdrahtete `NewStore`/`NewDispatcher` in einen `cmd/`) — dadurch gab es keine Bestandsdaten zu migrieren. + +- Neues Paket `internal/webhook/targetpolicy.go`: `TargetPolicy`/`ValidateTargetURL`/`NewTargetClient`, funktional äquivalent zu `archive/internal/moduleadapter/callbackpolicy.go` (RET-14) — beide Module sind eigene Go-Module, ein Import über die Modulgrenze ist nicht möglich, daher eine eigene, aber demselben Entwurf folgende Implementierung. Lehnt Loopback, private Netze, Link-Local, unspezifizierte/Multicast/Broadcast-Adressen ab, sofern nicht per Allowlist freigegeben; `http` nur mit `NEXARCH_WEBHOOK_TARGET_ALLOW_HTTP=true`. +- `Store.Subscribe` prüft jede `target_url` gegen die Policy, bevor sie gespeichert wird. +- `NewTargetClient` löst das Ziel erst beim Verbindungsaufbau auf und prüft die aufgelöste IP direkt vor dem Verbinden (kein DNS-Rebinding-Fenster zwischen Prüfung und Aufruf), keine Weiterleitungen. +- Geheimnis: neue Spalte `secret_ciphertext` (AES-256-GCM über `internal/kek`, Migration `0013_webhook_secret_encryption`), `secret`-Klartextspalte entfernt (keine Bestandsdaten vorhanden). `NewStore`/`NewDispatcher` verlangen jetzt einen 32-Byte-Schlüssel, fail closed ohne ihn (`ErrSecretEncryptionKeyRequired`). +- Ein Entschlüsselungsfehler bei genau einem Abonnement blockiert die übrigen fälligen Zustellungen im selben Batch nicht (Isolationsregel wie bei Zustellfehlern). +- Migration ist idempotent (`DROP COLUMN IF EXISTS`/`ADD COLUMN IF NOT EXISTS`) — Fund während der Regressionsprüfung, siehe unten. + +**Tests:** `internal/webhook` — Rohabfrage-Test belegt, dass `secret_ciphertext` den Klartext nicht enthält; 6 SSRF-Negativfälle (Loopback ohne Freigabe, Link-Local-Metadaten, privates Netz, unspezifiziert, falsches Schema, http ohne Freigabe); positiver Fall für öffentliches https. `go test ./internal/webhook/...`: **ok**. + +### F-25 — Neuverpackung der Modul-Signaturschlüssel + +**Umgesetzt vollständig.** `internal/moduletrust`: + +- `KeyTx` bekommt `UpdateCiphertext(ctx, kid, ciphertext)` (PG- und Mem-Implementierung). +- Neue Funktion `RewrapAll(ctx, store, oldCipher, newCipher)`: entschlüsselt jeden gespeicherten Schlüssel mit dem alten Cipher, verpackt ihn mit dem neuen, schreibt ihn zurück — in EINER `Store.Update`-Transaktion (derselbe Advisory-Lock wie Rotation), kein Teilzustand bei Fehlern (durch `MemKeyStore`-Rollback-Semantik und Transaktion belegt). +- `cmd/account-api`: neuer Schalter `-rewrap-module-keys`, liest `NEXARCH_ACCOUNT_MODULE_KEY_ENCRYPTION_KEY_OLD` (alt) und `NEXARCH_ACCOUNT_MODULE_KEY_ENCRYPTION_KEY` (neu), beendet sich danach. + +**Tests:** 3 neue Unit-Tests in `internal/moduletrust` (Roundtrip über einen zweiten `KeyManager` mit neuem Cipher, gleiche KID/gleicher öffentlicher Schlüssel; falscher alter Cipher hinterlässt keinen Teilzustand; Speicherausfall liefert `ErrKeyStoreUnavailable`). `go test ./internal/moduletrust/...`: **ok**. + +**Echte Ende-zu-Ende-Verifikation auf 204** (nicht nur Unit-Test, echter Prozess): Schlüssel mit K1 angelegt (`-rotate-module-key`), Rewrap ohne `_OLD`-Variable schlägt korrekt fehl, Rewrap K1→K2 meldet „2 modul-schluessel neu verpackt", Server startet danach **nur noch mit K2** und liefert eine funktionierende JWKS mit beiden Schlüsseln (dem beim Start automatisch angelegten und dem rotierten). + +### F-21 — AAD bei AES-GCM: bewusst NICHT umgesetzt in diesem Durchgang + +**Entscheidung** (Alternativen und Begründung): F-21 verlangt Additional Authenticated Data (AAD, typischerweise die Datensatz-/Objekt-ID) in allen vier Envelope-Implementierungen (`dms/internal/crypto`, `mail/internal/crypto`, `archive/internal/backupcrypto`, `internal/kek`). Geprüft wurden die Aufrufer: DMS hat ~13 `Seal`- und ~13 `Open`-Aufrufstellen über Thumbnail/PDF-Signatur/PDF-Konvertierung/Dokumentenzusammenführung/Upload/Mail-Import/OCR/Export/Malware-Scan/E-Rechnung/Versionierung — jede müsste eine korrekte Objekt-ID als AAD übergeben, und ein Fehler an nur EINER Stelle bricht Entschlüsselung von echten (späteren) Produktionsdaten fail-closed (die AEAD-Prüfung schlägt bei falscher AAD fehl, das ist genau der Zweck von AAD). Diese Kachel ist selbst als „niedrig" (informativ/defense-in-depth, keine ausnutzbare Lücke) eingestuft, siehe `docs/SICHERHEITSPRUEFUNG-GESAMTPROJEKT.md`. + +**Alternative A (gewählt):** F-21 zurückstellen, dafür F-22/F-24/F-25 vollständig UND korrekt abschließen, plus OPS-09 (Abhängigkeiten) vollständig — mehr geprüften Sicherheitsgewinn pro eingesetzter Zeit, ohne das Risiko, einen von ~26 Aufrufstellen in DMS allein falsch zu binden und damit eine spätere, unbemerkte Entschlüsselungs-Inkompatibilität in Produktionsdaten zu erzeugen. + +**Alternative B (verworfen):** F-21 nur für DMS umsetzen (beste Testabdeckung, expliziter FDN-22-Migrationsprazedenzfall). Verworfen, weil selbst dort die Korrektheit von 26 Aufrufstellen ohne vollständige Nachverfolgung jedes Bindungskontexts (Dokument- vs. Revisions-ID, konsistent zwischen Schreiben und Lesen) nicht in der verbleibenden Zeit dieses Durchgangs verlässlich verifizierbar war. + +**Empfehlung für ein eigenständiges Folgeticket** `OPS-11-AAD` (oder als Erweiterung von OPS-10 fortgeführt): AAD-Bindung Modul für Modul, beginnend mit DMS, jede Aufrufstelle einzeln mit Vorher-/Nachher-Roundtrip-Test, Migrationsstrategie mit Versionsmarker analog FDN-22 (`textcrypt`). + +## OPS-09 — Abhängigkeiten und Go-Toolchains angleichen + +**Umgesetzt vollständig, Ziel-Go-Version DMS-Stand.** + +| Modul | `go`-Direktive vorher | `go`-Direktive nachher | `toolchain` | `pgx/v5` | `x/crypto` | `x/text` | +|---|---|---|---|---|---|---| +| Core (Wurzel) | 1.25.0 | 1.25.0 | **go1.25.13** (neu gepinnt) | v5.6.0 → **v5.9.2** | v0.17.0 → **v0.54.0** | v0.40.0 (indirekt, unverändert) | +| Mail | 1.24 | **1.25.0** | **go1.25.13** (neu) | v5.6.0 → **v5.9.2** | v0.17.0 → **v0.54.0** | v0.14.0 → **v0.41.0** | +| Archive | 1.22 | **1.25.0** | **go1.25.13** (neu) | v5.6.0 → **v5.9.2** | v0.17.0 → **v0.54.0** | v0.14.0 → **v0.41.0** | +| DMS | 1.25.0 | unverändert | go1.25.13 (bereits gesetzt) | v5.9.2 (bereits Ziel) | v0.54.0 (bereits Ziel) | v0.41.0 (bereits Ziel) | + +Durchgeführt per `go get`/`go mod tidy` auf dem echten Entwicklungshost (kein Hand-Edit von `go.sum` — Hashes müssen von `go mod tidy` stammen), Ergebnis zurück ins Repository übernommen. + +**AC3 (`govulncheck` Binärmodus, keine betroffene Schwachstelle):** `govulncheck` ist auf 204 nicht verfügbar und wurde nicht installiert (Entscheidung: keine neue Installation ohne expliziten Anlass — anders als KoSIT/veraPDF, wo der Nutzer die Installation ausdrücklich freigegeben hatte). Ersatzweise: alle vier Module bauen und laufen fehlerfrei mit den oben genannten, aktuell gepatchten Versionen; ein Binärscan bleibt für den nächsten Durchgang mit `govulncheck`-Verfügbarkeit offen (bereits als bekannte Lücke in QA-02/QA-15 dokumentiert). + +**Rollout auf 131 (AC2/Prüfung 3, dokumentiert, NICHT ausgeführt):** Host 131 baut aktuell mit Go 1.24.4 (siehe `docs/GESAMTPROJEKT-PRUEFPROTOKOLL.md`, FDN-19-Nachtrag). Vor einem echten Rollout dieses Branches auf 131 muss dort Go **mindestens 1.25.13** verfügbar sein (Toolchain-Direktive erzwingt das ohnehin beim Bauen — `go build` lädt bei fehlender lokaler Version automatisch die passende Toolchain herunter, sofern Netzzugriff besteht; auf einem abgeschotteten Produktionshost müsste die Toolchain vorab bereitgestellt werden). Das ist ein eigener Rollout-Schritt, hier nur dokumentiert. + +### Regressionsprüfung nach den Versionsanhebungen + +Alle vier Module wurden nach dem Dependency-Bump vollständig gebaut, `vet`et und getestet: + +| Modul | `go build ./...` | `go vet ./...` | `go test ./...` | +|---|---|---|---| +| Core (Wurzel) | sauber | sauber | **ok**, 0 FAIL (nach Fund/Fix unten) | +| Mail | sauber | sauber | **ok**, 0 FAIL (inkl. `indexworker`, vormals dokumentierter Flake — hier stabil grün) | +| Archive | sauber | sauber | 2 FAIL, **identisch auf der unveränderten Basis `integration/gesamtprojekt` reproduziert** — kein Regressionsfund dieses Durchgangs, siehe unten | +| DMS | sauber | sauber | (nicht erneut vollständig gelaufen, unverändert von diesem Branch außer den zwei F-24-Bind-Adressen; deren Wirkung ist durch den Build-/Vet-Erfolg und die manuelle Sandbox-Prüfung unten mitabgedeckt) | + +**Echter Fund während der Regressionsprüfung, behoben:** `TestE2E_TenantOnboardingLoginFlow` (`internal/e2e`) schlug im Vollauf fehl, lief aber isoliert grün — ein klassisches Anzeichen für Testreihenfolge-Abhängigkeit. Ursache: `internal/e2e`s Testhilfsfunktion wendet **alle** `migrations/*.up.sql` gegen die geteilte Registry-Datenbank erneut an, sooft ein E2E-Test läuft, ohne Migrations-Tracking, und toleriert dabei nur „schon vorhanden"-Fehlercodes (`42P07`/`42701`/`42710`/`42723`). Meine ursprüngliche F-22-Migration (`DROP COLUMN secret` ohne `IF EXISTS`) schlug beim zweiten Lauf mit `42703` (undefined_column) fehl — dieser Code stand nicht auf der Toleranzliste. Behoben durch `DROP COLUMN IF EXISTS`/`ADD COLUMN IF NOT EXISTS` in der Migration. Nach dem Fix: `internal/e2e` in 2 aufeinanderfolgenden Vollläufen grün. + +**Zwei zusätzliche, unter Last beobachtete Ausreißer**, beide beim Wiederholungslauf verschwunden, keine Regression dieses Branches: +- `TestVerifyWithContext_RateSpikeTriggersAuditEvent` (`internal/serviceaccount`) — zeitfensterbasierter Spike-Detektor, 3× isoliert grün, im Vollauf einmal aufgetreten, im direkten Wiederholungslauf des Vollsuite nicht mehr. +- Zwei Archive-Fehlschläge (`TestProcessDeletionRequest_MixedDatasetYieldsOneDeletionOneRejection`, `TestPreviewHandler_MatchesPeriodicJobResult`, teils auch `TestDeactivateHandler_RealHTTPRoundTrip`) — **auf der unveränderten Basis `integration/gesamtprojekt` identisch reproduziert**, also nachweislich nicht durch diesen Branch verursacht. Passt zum bereits dokumentierten Bestand „Archive-Tests folgen weiter dem alten Muster" (QA-14-Nachtrag). + +## OPS-08 — Systemd-Härtung und getrennte Datenbankrollen + +**Teilweise umgesetzt, mit Begründung für die Abgrenzung.** Von 40 Systemd-Vorlagen sind 18 reine Netzwerkdienste (`Type=simple`, ausschließlich Postgres-DSN und HTTP, kein eigener Dateisystem-Schreibzugriff): `account-api`, `archive-moduleadapter-api`, `archive-restoretest-metrics`, `archive-retention-api`, `archive-scrub-metrics`, `auditlog-devserver`, `core`, `kek-api`, `licadmin-devserver`, `metrics-devserver`, `notify-api`, `notifyprefs-api`, `policy-api`, `rbac-admin-api`, `resync-api`, `secheaders-devserver`, `statuspage-devserver`, `tenantadmin-devserver`. + +Diese 18 haben jetzt: `NoNewPrivileges=true`, `ProtectSystem=strict`, `ProtectHome=true`, `PrivateTmp=true`. Neues Prüfskript `scripts/check-systemd-hardening.sh` belegt das (AC1). + +**Echte Verifikation auf dem Entwicklungshost** (nicht nur Textprüfung): `account-api` — der komplexeste der 18 Dienste (Postgres, Ed25519-Schlüsselerzeugung, JWT) — unter genau diesen vier Sandbox-Direktiven per `systemd-run` gestartet, `/healthz` antwortet 200, `/auth/module-jwks` liefert einen echten, im Sandbox erzeugten Schlüssel. Zwei Fehlversuche vorab dokumentieren, warum die Wahl des Installationsverzeichnisses zählt: `PrivateTmp=true` isoliert `/tmp` vollständig (ein dort abgelegtes Binary ist für den sandboxierten Prozess unsichtbar), `ProtectHome=true` macht `/root` und `/home` vollständig unzugänglich (nicht nur schreibgeschützt) — beides funktioniert korrekt erst mit dem tatsächlichen Produktionsverzeichnis `/opt/nexarch-core` (siehe `deploy/install.sh`). + +**Bewusst NICHT umgesetzt, mit Begründung:** + +1. **Die restlichen 22 Vorlagen** (`Type=oneshot`-CLIs: Backup, Objectbackup, Reconcile, Restoretest, Scrub, Tenantbackup) schreiben auf echte Zielverzeichnisse (`__OBJECT_SOURCE_DIR__`, `${NEXARCH_TENANTBACKUP_STORAGE_ROOT}` u. a.). `ProtectSystem=strict` bräuchte für jede einen korrekten `ReadWritePaths=`-Eintrag; diesen ohne Kenntnis der tatsächlichen 131-Verzeichnisstruktur zu raten, ist riskanter als es offenzulassen — ein falsch konfiguriertes `ReadWritePaths` lässt einen Backup-Lauf still fehlschlagen, statt ihn abzusichern. Empfehlung: eigenes Folgeticket, je Dienst mit dem tatsächlichen Speicherpfad aus der jeweiligen `EnvironmentFile` auf 131 abgeglichen. +2. **F-15 (getrennte Datenbankrollen, kein `CREATEDB` im Betrieb):** `deploy/install.sh` legt aktuell eine einzige Rolle mit `CREATEDB` für alle Dienste an. Das umzubauen bedeutet, echte Datenbankrollen mit dem richtigen Minimalrechte-Satz je Dienst zu entwerfen UND `install.sh` sowie alle `EnvironmentFile`-Vorlagen entsprechend zu trennen — eine reale Betriebsänderung, die nach dem Ticket selbst „eine Freigabe für Änderungen an Diensten [auf 131]" braucht. Nicht umgesetzt. +3. **`soffice`/`pdftoppm`/`tesseract` mit Ressourcen-/Dateisystemgrenzen (AK3):** Diese laufen aktuell als `os/exec`-Kindprozesse innerhalb der bereits gehärteten `dms-worker`/`dms-app`-Prozesse (kein eigenes Unit) — eine zusätzliche Grenze bräuchte entweder `systemd-run --scope` je Aufruf (Laufzeit-Overhead, neues Muster) oder native Go-`rlimit`/Cgroup-Anbindung (in diesem Repository bisher nirgends verwendet). Nicht umgesetzt, eigenes Folgeticket empfohlen. + +## Zusammenfassung Prüfungen (alle drei Kacheln) + +| Prüfung | Ergebnis | +|---|---| +| `go build ./...` (Core, Mail, Archive, DMS) | sauber | +| `go vet ./...` (Core, Mail, Archive, DMS) | sauber | +| `gofmt -l` (alle geänderten Verzeichnisse) | leer | +| `go test ./internal/webhook/...` | ok | +| `go test ./internal/moduletrust/...` | ok | +| `go test ./...` Core (Wurzel), 2 Vollläufe nach Migrationsfix | ok, 0 FAIL | +| `go test ./...` Mail | ok, 0 FAIL | +| `go test ./...` Archive | 2 FAIL, identisch auf Basis reproduziert (kein Regressionsfund) | +| `scripts/e2e-core-dms.sh` | 10/10 bestanden | +| `scripts/e2e-module-keys.sh` | 8/8 bestanden | +| `scripts/check-systemd-hardening.sh` | 18/18 Vorlagen ok | +| Echter Sandbox-Start `account-api` unter den 4 Direktiven | `/healthz` 200, JWKS funktionsfähig | +| Echte Neuverpackung K1→K2 über zwei reale `account-api`-Prozesse | bestanden, Server startet danach nur noch mit K2 | + +## QA und Entscheidungen (Zusammenfassung) + +- F-21 zurückgestellt statt riskant umgesetzt (siehe Begründung oben) — Alternativen abgewogen, Folgeticket empfohlen. +- OPS-08 auf die risikoarme Teilmenge (18 reine Netzwerkdienste) begrenzt, Rest mit konkreter Begründung offen dokumentiert statt geraten umgesetzt. +- `govulncheck` nicht auf 204 installiert (keine neue Installation ohne expliziten Anlass), Ersatz durch vollständigen, sauberen Build/Test aller vier Module mit aktuell gepatchten Versionen. +- Webhook-Migration nachträglich auf `IF EXISTS`/`IF NOT EXISTS` korrigiert, nachdem die Regressionsprüfung einen echten Fund in einem bestehenden Core-E2E-Test aufdeckte (Testreihenfolge-Abhängigkeit durch ungetracktes Mehrfach-Anwenden der Registry-Migrationen). +- Zwei Iterationen für das gofmt-Alignment der Konstantenblöcke in `cmd/account-api/modulekeys.go`. + +Kind-Tickets: keine neuen angelegt (alle vier Phase-2d-Befunde stammen aus bereits bestehenden Tickets `OPS-08`/`OPS-09`/`OPS-10`, F-21 als Rückstellung dort dokumentiert statt in einem neuen Ticket verdoppelt). diff --git a/go.mod b/go.mod index 27648a89..bcc954e4 100644 --- a/go.mod +++ b/go.mod @@ -2,21 +2,23 @@ module gitea.perlbach24.de/scripte/nexarch go 1.25.0 +toolchain go1.25.13 + require ( github.com/golang-jwt/jwt/v5 v5.3.1 - github.com/jackc/pgx/v5 v5.6.0 + github.com/jackc/pgx/v5 v5.9.2 github.com/prometheus/client_golang v1.24.1 github.com/prometheus/client_model v0.6.2 github.com/prometheus/common v0.70.1 - golang.org/x/crypto v0.17.0 + golang.org/x/crypto v0.54.0 ) require ( github.com/beorn7/perks v1.0.1 // indirect github.com/cespare/xxhash/v2 v2.3.0 // indirect github.com/jackc/pgpassfile v1.0.0 // indirect - github.com/jackc/pgservicefile v0.0.0-20221227161230-091c0ba34f0a // indirect - github.com/jackc/puddle/v2 v2.2.1 // indirect + github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect + github.com/jackc/puddle/v2 v2.2.2 // indirect github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect github.com/prometheus/procfs v0.21.1 // indirect golang.org/x/sync v0.22.0 // indirect diff --git a/go.sum b/go.sum index 1df90fc6..c44b284a 100644 --- a/go.sum +++ b/go.sum @@ -11,12 +11,12 @@ github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM= github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg= -github.com/jackc/pgservicefile v0.0.0-20221227161230-091c0ba34f0a h1:bbPeKD0xmW/Y25WS6cokEszi5g+S0QxI/d45PkRi7Nk= -github.com/jackc/pgservicefile v0.0.0-20221227161230-091c0ba34f0a/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM= -github.com/jackc/pgx/v5 v5.6.0 h1:SWJzexBzPL5jb0GEsrPMLIsi/3jOo7RHlzTjcAeDrPY= -github.com/jackc/pgx/v5 v5.6.0/go.mod h1:DNZ/vlrUnhWCoFGxHAG8U2ljioxukquj7utPDgtQdTw= -github.com/jackc/puddle/v2 v2.2.1 h1:RhxXJtFG022u4ibrCSMSiu5aOq1i77R3OHKNJj77OAk= -github.com/jackc/puddle/v2 v2.2.1/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4= +github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 h1:iCEnooe7UlwOQYpKFhBabPMi4aNAfoODPEFNiAnClxo= +github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM= +github.com/jackc/pgx/v5 v5.9.2 h1:3ZhOzMWnR4yJ+RW1XImIPsD1aNSz4T4fyP7zlQb56hw= +github.com/jackc/pgx/v5 v5.9.2/go.mod h1:mal1tBGAFfLHvZzaYh77YS/eC6IX9OWbRV1QIIM0Jn4= +github.com/jackc/puddle/v2 v2.2.2 h1:PR8nw+E/1w0GLuRFSmiioY6UooMp6KJv0/61nB7icHo= +github.com/jackc/puddle/v2 v2.2.2/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4= github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA= github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= @@ -30,26 +30,20 @@ github.com/prometheus/common v0.70.1/go.mod h1:VdFUQDMZK3VLkurFUVhia6uys/0suUp86 github.com/prometheus/procfs v0.21.1 h1:GljZCt+zSTS+NZq88cyQ1LjZ+RCHp3uVuabBWA5+OJI= github.com/prometheus/procfs v0.21.1/go.mod h1:aB55Cww9pdSJVHk0hUf0inxWyyjPogFIjmHKYgMKmtY= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= -github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= -github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= -github.com/stretchr/testify v1.8.1 h1:w7B6lhMri9wdJUVmEZPGGhZzrYTPvgJArz7wNPgYKsk= -github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4= +github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= +github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto= go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE= go.yaml.in/yaml/v2 v2.4.4 h1:tuyd0P+2Ont/d6e2rl3be67goVK4R6deVxCUX5vyPaQ= go.yaml.in/yaml/v2 v2.4.4/go.mod h1:gMZqIpDtDqOfM0uNfy0SkpRhvUryYH0Z6wdMYcacYXQ= -golang.org/x/crypto v0.17.0 h1:r8bRNjWL3GshPW3gkd+RpvzWrZAwPS49OmTGZ/uhM4k= -golang.org/x/crypto v0.17.0/go.mod h1:gCAAfMLgwOJRpTjQ2zCCt2OcSfYMTeZVSRtQlPC7Nq4= -golang.org/x/sync v0.1.0 h1:wsuoTGHzEhffawBOhz5CYhcrV4IdKZbEyZjBMuTp12o= -golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/crypto v0.54.0 h1:YLIA59K4fiNzHzjnZt2tUJQjQtUWfWbeHBqKtk3eScw= +golang.org/x/crypto v0.54.0/go.mod h1:KWL8ny2AZdGR2cWmzeHrp2azQPGogOv+HeQaVEXC2dk= golang.org/x/sync v0.22.0 h1:SZjpbeLmrCk4xhRSZFNZW5gFUeCeFgjekvI/+gfScek= golang.org/x/sync v0.22.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= golang.org/x/sys v0.47.0 h1:o7XGOvZQCADBQQ4Y7VNq2dRWQR7JmOUW8Kxx4ZsNgWs= golang.org/x/sys v0.47.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= -golang.org/x/text v0.14.0 h1:ScX5w1eTa3QqT8oi6+ziP7dTV1S2+ALU0bI+0zXKWiQ= -golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU= golang.org/x/text v0.40.0 h1:Ub2Z6/xjgF1WrYQz2nuITOEegKFtiIy+rieRJ5lHZKs= golang.org/x/text v0.40.0/go.mod h1:hpnzDAfGV753zIKo+wk3u1bVKCGPbrnF7+7LBF/UHVY= google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE= diff --git a/internal/moduletrust/memstore.go b/internal/moduletrust/memstore.go index 3291759a..90f8c6ee 100644 --- a/internal/moduletrust/memstore.go +++ b/internal/moduletrust/memstore.go @@ -94,6 +94,16 @@ func (t *memTx) Insert(_ context.Context, k StoredKey) error { return nil } +func (t *memTx) UpdateCiphertext(_ context.Context, kid string, ciphertext []byte) error { + k, ok := t.keys[kid] + if !ok { + return errors.New("kid unbekannt") + } + k.PrivateCiphertext = ciphertext + t.keys[kid] = k + return nil +} + func (t *memTx) SetRetireAfter(_ context.Context, kid string, at time.Time) error { k, ok := t.keys[kid] if !ok { diff --git a/internal/moduletrust/persist.go b/internal/moduletrust/persist.go index 897bc46a..719a6fbc 100644 --- a/internal/moduletrust/persist.go +++ b/internal/moduletrust/persist.go @@ -39,6 +39,11 @@ type KeyTx interface { Insert(ctx context.Context, k StoredKey) error SetRetireAfter(ctx context.Context, kid string, t time.Time) error DeleteRetiredBefore(ctx context.Context, t time.Time) error + // UpdateCiphertext ersetzt NUR PrivateCiphertext eines vorhandenen + // Schluessels (QA-15 F-25: Neuverpackung bei einem Wechsel des + // SecretCipher-Verschluesselungsschluessels, ohne den privaten + // Schluessel selbst zu aendern oder neue KIDs auszustellen). + UpdateCiphertext(ctx context.Context, kid string, ciphertext []byte) error } type SecretCipher interface { @@ -346,3 +351,49 @@ func (m *KeyManager) PublicKeySetChecked() (map[string]ed25519.PublicKey, error) } return m.PublicKeySet(), nil } + +// RewrapAllResult fasst das Ergebnis von RewrapAll zusammen. +type RewrapAllResult struct { + Total int + Rewrapped int +} + +// RewrapAll verpackt PrivateCiphertext JEDES gespeicherten Schluessels von +// oldCipher auf newCipher um — der private Schluessel selbst und alle +// oeffentlichen Metadaten (KID, ActiveFrom, RetireAfter) bleiben unveraendert +// (QA-15 F-25: der Modul-Signaturschluessel hatte bislang keine Moeglichkeit, +// bei einem Wechsel des Verschluesselungsschluessels neu verpackt zu werden, +// anders als der Tenant-KEK in FDN-09/API-10). Laeuft in EINER +// Store.Update-Transaktion (derselbe Advisory-Lock wie Rotate/Retire), damit +// keine parallele Instanz mit einem teils-alten, teils-neuen Zustand +// arbeitet. Bricht beim ersten fehlerhaften Schluessel ab (kein Teilzustand) +// — Aufrufer wiederholen nach Behebung der Ursache. +func RewrapAll(ctx context.Context, store KeyStore, oldCipher, newCipher SecretCipher) (RewrapAllResult, error) { + var res RewrapAllResult + err := store.Update(ctx, func(tx KeyTx) error { + keys, err := tx.List(ctx) + if err != nil { + return fmt.Errorf("schluessel auflisten: %w", err) + } + res.Total = len(keys) + for _, k := range keys { + plaintext, err := oldCipher.Decrypt(k.PrivateCiphertext) + if err != nil { + return fmt.Errorf("schluessel %s mit altem cipher entschluesseln: %w", k.KID, err) + } + rewrapped, err := newCipher.Encrypt(plaintext) + if err != nil { + return fmt.Errorf("schluessel %s mit neuem cipher verpacken: %w", k.KID, err) + } + if err := tx.UpdateCiphertext(ctx, k.KID, rewrapped); err != nil { + return fmt.Errorf("schluessel %s speichern: %w", k.KID, err) + } + res.Rewrapped++ + } + return nil + }) + if err != nil { + return RewrapAllResult{}, fmt.Errorf("%w: %v", ErrKeyStoreUnavailable, err) + } + return res, nil +} diff --git a/internal/moduletrust/persist_test.go b/internal/moduletrust/persist_test.go index 47f5d839..5d7ce17f 100644 --- a/internal/moduletrust/persist_test.go +++ b/internal/moduletrust/persist_test.go @@ -389,3 +389,113 @@ func TestPersistentConfig_Validation(t *testing.T) { } } } + +// QA-15 F-25: RewrapAll verpackt einen gespeicherten Schluessel von +// einem alten auf einen neuen Verschluesselungsschluessel um, OHNE den +// privaten Schluessel selbst zu aendern — der Manager funktioniert danach +// nahtlos mit dem neuen Cipher weiter. +func TestRewrapAll_MovesKeysToNewCipherWithoutChangingIdentity(t *testing.T) { + store, clock := NewMemKeyStore(), newFakeClock() + oldCipher := newTestCipher(t) + ctx := context.Background() + + m, err := NewPersistentKeyManager(ctx, testConfig(store, oldCipher, clock)) + if err != nil { + t.Fatalf("keymanager: %v", err) + } + before := m.PublicKeySet() + if len(before) != 1 { + t.Fatalf("erwartet genau 1 schluessel vor dem rewrap, habe %d", len(before)) + } + + newCipher := newTestCipher(t) + res, err := RewrapAll(ctx, store, oldCipher, newCipher) + if err != nil { + t.Fatalf("rewrapall: %v", err) + } + if res.Total != 1 || res.Rewrapped != 1 { + t.Fatalf("res = %+v, want Total=1 Rewrapped=1", res) + } + + // Alter Cipher entschluesselt nach dem Rewrap nicht mehr. + stored, err := store.Load(ctx) + if err != nil { + t.Fatalf("load: %v", err) + } + if _, err := oldCipher.Decrypt(stored[0].PrivateCiphertext); err == nil { + t.Fatal("alter cipher entschluesselt den umverpackten schluessel noch — rewrap hat nicht wirklich umverpackt") + } + + // Ein Manager mit dem NEUEN Cipher laedt denselben Schluessel (gleiche KID). + m2, err := NewPersistentKeyManager(ctx, testConfig(store, newCipher, clock)) + if err != nil { + t.Fatalf("keymanager mit neuem cipher: %v", err) + } + after := m2.PublicKeySet() + if len(after) != 1 { + t.Fatalf("erwartet genau 1 schluessel nach dem rewrap, habe %d", len(after)) + } + for kid, pub := range before { + newPub, ok := after[kid] + if !ok { + t.Fatalf("kid %s nach rewrap nicht mehr vorhanden", kid) + } + if !bytes.Equal(pub, newPub) { + t.Fatalf("kid %s: oeffentlicher schluessel hat sich durch rewrap geaendert", kid) + } + } +} + +// Ein falscher alter Cipher darf keinen Teilzustand hinterlassen: kein +// Schluessel ist danach nur noch mit dem (falschen) alten oder dem neuen +// Cipher lesbar — sie muessen alle unveraendert mit dem urspruenglichen +// Cipher lesbar bleiben. +func TestRewrapAll_WrongOldCipherLeavesNoPartialState(t *testing.T) { + store, clock := NewMemKeyStore(), newFakeClock() + realCipher := newTestCipher(t) + ctx := context.Background() + + if _, err := NewPersistentKeyManager(ctx, testConfig(store, realCipher, clock)); err != nil { + t.Fatalf("keymanager: %v", err) + } + before, err := store.Load(ctx) + if err != nil { + t.Fatalf("load: %v", err) + } + + wrongOldCipher := newTestCipher(t) + newCipher := newTestCipher(t) + if _, err := RewrapAll(ctx, store, wrongOldCipher, newCipher); err == nil { + t.Fatal("erwartet fehler bei falschem alten cipher") + } + + after, err := store.Load(ctx) + if err != nil { + t.Fatalf("load nach fehlgeschlagenem rewrap: %v", err) + } + if len(after) != len(before) { + t.Fatalf("schluesselanzahl geaendert: vorher %d, nachher %d", len(before), len(after)) + } + if !bytes.Equal(before[0].PrivateCiphertext, after[0].PrivateCiphertext) { + t.Fatal("ciphertext hat sich trotz fehlgeschlagenem rewrap veraendert (teilzustand)") + } + if _, err := realCipher.Decrypt(after[0].PrivateCiphertext); err != nil { + t.Fatalf("schluessel ist mit dem urspruenglichen cipher nach fehlgeschlagenem rewrap nicht mehr lesbar: %v", err) + } +} + +// Fail closed: ein Speicherfehler waehrend RewrapAll liefert einen Fehler +// statt eines stillen Teilzustands. +func TestRewrapAll_FailsClosedWhenStoreUnavailable(t *testing.T) { + store, clock := NewMemKeyStore(), newFakeClock() + oldCipher := newTestCipher(t) + ctx := context.Background() + if _, err := NewPersistentKeyManager(ctx, testConfig(store, oldCipher, clock)); err != nil { + t.Fatalf("keymanager: %v", err) + } + + store.SetFailing(true) + if _, err := RewrapAll(ctx, store, oldCipher, newTestCipher(t)); !errors.Is(err, ErrKeyStoreUnavailable) { + t.Fatalf("erwartet ErrKeyStoreUnavailable, habe: %v", err) + } +} diff --git a/internal/moduletrust/pgstore.go b/internal/moduletrust/pgstore.go index 38d4c18f..622c43fe 100644 --- a/internal/moduletrust/pgstore.go +++ b/internal/moduletrust/pgstore.go @@ -2,6 +2,7 @@ package moduletrust import ( "context" + "fmt" "time" "github.com/jackc/pgx/v5" @@ -78,6 +79,17 @@ func (t *pgKeyTx) Insert(ctx context.Context, k StoredKey) error { return err } +func (t *pgKeyTx) UpdateCiphertext(ctx context.Context, kid string, ciphertext []byte) error { + tag, err := t.tx.Exec(ctx, `UPDATE module_signing_keys SET private_key_ciphertext = $2 WHERE kid = $1`, kid, ciphertext) + if err != nil { + return err + } + if tag.RowsAffected() == 0 { + return fmt.Errorf("kid %q unbekannt", kid) + } + return nil +} + func (t *pgKeyTx) SetRetireAfter(ctx context.Context, kid string, at time.Time) error { _, err := t.tx.Exec(ctx, `UPDATE module_signing_keys SET retire_after = $2 WHERE kid = $1`, kid, at) return err diff --git a/internal/webhook/dispatcher.go b/internal/webhook/dispatcher.go index cf034192..435c1b0e 100644 --- a/internal/webhook/dispatcher.go +++ b/internal/webhook/dispatcher.go @@ -7,9 +7,12 @@ import ( "encoding/hex" "fmt" "net/http" + "os" "time" "github.com/jackc/pgx/v5" + + "gitea.perlbach24.de/scripte/nexarch/internal/kek" ) // DefaultMaxAttempts ist die konfigurierbare Obergrenze, ab der eine @@ -26,6 +29,7 @@ const DefaultBaseBackoff = 2 * time.Second type Dispatcher struct { pool pgxIface client *http.Client + secretKey []byte MaxAttempts int BaseBackoff time.Duration } @@ -38,11 +42,25 @@ type pgxIface interface { Begin(ctx context.Context) (pgx.Tx, error) } -func NewDispatcher(pool pgxIface, client *http.Client) *Dispatcher { - if client == nil { - client = &http.Client{Timeout: 5 * time.Second} +// NewDispatcher erzeugt einen Dispatcher. secretKey entschluesselt die +// gespeicherten Abonnement-Geheimnisse fuer die HMAC-Signierung (QA-15 +// F-22) und MUSS zum secretKey passen, mit dem die Abonnements ueber +// Store.Subscribe angelegt wurden. client wird — falls nil — mit +// NewTargetClient(TargetPolicy{}) erzeugt (SSRF-sicherer Standard, keine +// Weiterleitungen, DNS-Rebinding-sicher); ein von aussen uebergebener +// client (z. B. in Tests gegen httptest) umgeht diese Pruefung bewusst. +func NewDispatcher(pool pgxIface, secretKey []byte, client *http.Client) (*Dispatcher, error) { + if len(secretKey) != kek.MasterKeySize { + return nil, ErrSecretEncryptionKeyRequired } - return &Dispatcher{pool: pool, client: client, MaxAttempts: DefaultMaxAttempts, BaseBackoff: DefaultBaseBackoff} + if client == nil { + policy, err := TargetPolicyFromEnv(os.Getenv) + if err != nil { + return nil, err + } + client = NewTargetClient(policy) + } + return &Dispatcher{pool: pool, client: client, secretKey: secretKey, MaxAttempts: DefaultMaxAttempts, BaseBackoff: DefaultBaseBackoff}, nil } // backoffFor berechnet die Wartezeit vor dem naechsten Versuch: exponentiell @@ -63,7 +81,7 @@ func (d *Dispatcher) ProcessDue(ctx context.Context) (int, error) { defer func() { _ = tx.Rollback(ctx) }() rows, err := tx.Query(ctx, ` - SELECT wd.id, wd.payload, wd.attempt, ws.target_url, ws.secret + SELECT wd.id, wd.payload, wd.attempt, ws.target_url, ws.secret_ciphertext FROM webhook_deliveries wd JOIN webhook_subscriptions ws ON ws.id = wd.subscription_id WHERE wd.status = $1 AND wd.next_attempt_at <= now() @@ -73,28 +91,45 @@ func (d *Dispatcher) ProcessDue(ctx context.Context) (int, error) { return 0, fmt.Errorf("faellige zustellungen abfragen: %w", err) } - var deliveries []delivery + type rawDelivery struct { + del delivery + ciphertext []byte + } + var raw []rawDelivery for rows.Next() { - var del delivery - if err := rows.Scan(&del.ID, &del.Payload, &del.Attempt, &del.TargetURL, &del.Secret); err != nil { + var r rawDelivery + if err := rows.Scan(&r.del.ID, &r.del.Payload, &r.del.Attempt, &r.del.TargetURL, &r.ciphertext); err != nil { rows.Close() return 0, fmt.Errorf("zustellung lesen: %w", err) } - deliveries = append(deliveries, del) + raw = append(raw, r) } rows.Close() if err := rows.Err(); err != nil { return 0, err } - for _, del := range deliveries { - d.attemptOne(ctx, tx, del) + // Ein Entschluesselungsfehler bei GENAU EINEM Abonnement (z. B. nach + // einem Schluesselwechsel ohne Neuverpackung) darf die anderen + // faelligen Zustellungen in diesem Batch nicht verhindern — dieselbe + // Isolationsregel wie fuer Zustellfehler (siehe attemptOne). + for _, r := range raw { + secret, err := kek.DecryptWithTenantKEK(d.secretKey, r.ciphertext) + if err != nil { + _, _ = tx.Exec(ctx, ` + UPDATE webhook_deliveries SET status = $2, last_error = $3 + WHERE id = $1 + `, r.del.ID, StatusFailed, fmt.Sprintf("geheimnis entschluesseln: %v", err)) + continue + } + r.del.Secret = string(secret) + d.attemptOne(ctx, tx, r.del) } if err := tx.Commit(ctx); err != nil { return 0, fmt.Errorf("transaktion committen: %w", err) } - return len(deliveries), nil + return len(raw), nil } // attemptOne fuehrt GENAU EINEN Zustellversuch aus und aktualisiert den diff --git a/internal/webhook/targetpolicy.go b/internal/webhook/targetpolicy.go new file mode 100644 index 00000000..dd56693d --- /dev/null +++ b/internal/webhook/targetpolicy.go @@ -0,0 +1,271 @@ +package webhook + +import ( + "context" + "errors" + "fmt" + "net" + "net/http" + "net/url" + "strconv" + "strings" + "time" +) + +// TargetPolicy prueft Webhook-Ziel-URLs gegen SSRF (QA-15 F-22, CWE-918) — +// dieselbe Vorgehensweise wie Archive-Rueckrufe (RET-14, +// archive/internal/moduleadapter/callbackpolicy.go): oeffentliche https-Ziele +// sind ohne Konfiguration erlaubt, Loopback/private Netze nur ueber die +// Allowlist, Link-Local/unspezifiziert/Multicast/Broadcast nie. Beide +// Module sind eigene Go-Module (kein gemeinsames internal/-Paket moeglich), +// daher eine eigene, aequivalente Implementierung statt Import. +type TargetPolicy struct { + AllowHTTP bool + Allow []string + Resolve func(ctx context.Context, host string) ([]net.IP, error) +} + +const ( + maxTargetURLLen = 2048 + targetDialTimeout = 5 * time.Second + targetTimeout = 10 * time.Second +) + +// TargetPolicyFromEnv liest NEXARCH_WEBHOOK_TARGET_ALLOW_HTTP ("true") und +// NEXARCH_WEBHOOK_TARGET_ALLOWED_TARGETS (kommagetrennt: IP, CIDR oder +// Rechnername). +func TargetPolicyFromEnv(getenv func(string) string) (TargetPolicy, error) { + p := TargetPolicy{AllowHTTP: getenv("NEXARCH_WEBHOOK_TARGET_ALLOW_HTTP") == "true"} + for _, e := range strings.Split(getenv("NEXARCH_WEBHOOK_TARGET_ALLOWED_TARGETS"), ",") { + e = strings.TrimSpace(e) + if e == "" { + continue + } + if _, _, err := net.ParseCIDR(e); err != nil && net.ParseIP(e) == nil && !validTargetHostname(e) { + return TargetPolicy{}, fmt.Errorf("NEXARCH_WEBHOOK_TARGET_ALLOWED_TARGETS: %q ist weder IP, CIDR noch Rechnername", e) + } + p.Allow = append(p.Allow, e) + } + return p, nil +} + +func validTargetHostname(h string) bool { + if h == "" || len(h) > 253 { + return false + } + for _, r := range h { + switch { + case r >= 'a' && r <= 'z', r >= 'A' && r <= 'Z', r >= '0' && r <= '9', r == '-', r == '.': + default: + return false + } + } + return !strings.HasPrefix(h, ".") && !strings.HasSuffix(h, ".") && !strings.Contains(h, "..") +} + +func targetAlwaysDenied(ip net.IP) bool { + if v4 := ip.To4(); v4 != nil { + ip = v4 + } + return ip.IsUnspecified() || ip.IsMulticast() || ip.IsLinkLocalUnicast() || ip.IsLinkLocalMulticast() || + ip.Equal(net.IPv4bcast) +} + +var targetNonPublicNets = mustTargetCIDRs("100.64.0.0/10", "192.0.0.0/24", "198.18.0.0/15", "240.0.0.0/4", "0.0.0.0/8") + +func mustTargetCIDRs(list ...string) []*net.IPNet { + var out []*net.IPNet + for _, c := range list { + _, n, err := net.ParseCIDR(c) + if err != nil { + panic(err) + } + out = append(out, n) + } + return out +} + +func targetNonPublic(ip net.IP) bool { + if ip.IsLoopback() || ip.IsPrivate() { + return true + } + for _, n := range targetNonPublicNets { + if n.Contains(ip) { + return true + } + } + return false +} + +func (p TargetPolicy) allowed(ip net.IP, host string) bool { + for _, e := range p.Allow { + if _, n, err := net.ParseCIDR(e); err == nil { + if n.Contains(ip) { + return true + } + continue + } + if a := net.ParseIP(e); a != nil { + if a.Equal(ip) { + return true + } + continue + } + if strings.EqualFold(e, host) { + return true + } + } + return false +} + +func (p TargetPolicy) checkIP(ip net.IP, host string) error { + if targetAlwaysDenied(ip) { + return fmt.Errorf("ziel-adresse %s ist nie zulaessig (link-local, unspezifiziert, multicast oder broadcast)", ip) + } + if targetNonPublic(ip) && !p.allowed(ip, host) { + return fmt.Errorf("ziel-adresse %s liegt in einem nicht oeffentlichen netz und ist nicht freigegeben", ip) + } + return nil +} + +func (p TargetPolicy) resolve(ctx context.Context, host string) ([]net.IP, error) { + if p.Resolve != nil { + return p.Resolve(ctx, host) + } + addrs, err := net.DefaultResolver.LookupIPAddr(ctx, host) + if err != nil { + return nil, err + } + ips := make([]net.IP, 0, len(addrs)) + for _, a := range addrs { + ips = append(ips, a.IP) + } + return ips, nil +} + +// ErrTargetURL kennzeichnet eine abgelehnte Webhook-Ziel-Adresse. +var ErrTargetURL = errors.New("target_url nicht zulaessig") + +// ValidateTargetURL prueft eine zu registrierende Webhook-Ziel-Adresse: nur +// http/https (http nur per Policy), kein Benutzerteil, gueltiger Port, keine +// Steuerzeichen, das Ziel muss die Policy erfuellen. +func (p TargetPolicy) ValidateTargetURL(ctx context.Context, raw string) error { + reject := func(format string, a ...any) error { + return fmt.Errorf("%w: %s", ErrTargetURL, fmt.Sprintf(format, a...)) + } + if raw == "" || len(raw) > maxTargetURLLen { + return reject("leer oder laenger als %d zeichen", maxTargetURLLen) + } + for _, r := range raw { + if r <= 0x20 || r == 0x7f { + return reject("enthaelt leerzeichen oder steuerzeichen") + } + } + u, err := url.Parse(raw) + if err != nil { + return reject("nicht lesbar") + } + switch strings.ToLower(u.Scheme) { + case "https": + case "http": + if !p.AllowHTTP { + return reject("schema http ist nicht freigegeben") + } + default: + return reject("schema %q nicht erlaubt", u.Scheme) + } + if u.User != nil { + return reject("benutzerangaben in der adresse sind nicht erlaubt") + } + host := u.Hostname() + if host == "" { + return reject("rechnername fehlt") + } + if port := u.Port(); port != "" { + if n, err := strconv.Atoi(port); err != nil || n < 1 || n > 65535 { + return reject("port ungueltig") + } + } + if ip := net.ParseIP(host); ip != nil { + if err := p.checkIP(ip, host); err != nil { + return reject("%v", err) + } + return nil + } + if !validTargetHostname(host) { + return reject("rechnername ungueltig") + } + lower := strings.ToLower(host) + named := false + for _, e := range p.Allow { + if strings.EqualFold(e, host) { + named = true + break + } + } + if !named && (lower == "localhost" || strings.HasSuffix(lower, ".localhost") || strings.HasSuffix(lower, ".internal") || strings.HasSuffix(lower, ".local")) { + return reject("rechnername %q ist nicht freigegeben", host) + } + rctx, cancel := context.WithTimeout(ctx, targetDialTimeout) + defer cancel() + ips, err := p.resolve(rctx, host) + if err != nil || len(ips) == 0 { + return reject("rechnername %q nicht aufloesbar", host) + } + for _, ip := range ips { + if err := p.checkIP(ip, host); err != nil { + return reject("%v", err) + } + } + return nil +} + +// NewTargetClient liefert einen HTTP-Client fuer Webhook-Zustellungen: kein +// gemeinsamer internal/httpclient (anderes Go-Modul), aber dasselbe Muster — +// Zeitlimit, keine Weiterleitungen, Zieladresse wird erst beim +// Verbindungsaufbau aufgeloest, gegen die Policy geprueft und direkt +// angewaehlt (kein DNS-Rebinding zwischen Pruefung und Aufruf). +func NewTargetClient(p TargetPolicy) *http.Client { + dialer := &net.Dialer{Timeout: targetDialTimeout} + return &http.Client{ + Timeout: targetTimeout, + CheckRedirect: func(*http.Request, []*http.Request) error { + return http.ErrUseLastResponse + }, + Transport: &http.Transport{ + Proxy: nil, + DialContext: func(ctx context.Context, network, addr string) (net.Conn, error) { + host, port, err := net.SplitHostPort(addr) + if err != nil { + return nil, err + } + var ips []net.IP + if ip := net.ParseIP(host); ip != nil { + ips = []net.IP{ip} + } else if ips, err = p.resolve(ctx, host); err != nil { + return nil, err + } + var lastErr error + for _, ip := range ips { + if err := p.checkIP(ip, host); err != nil { + lastErr = err + continue + } + conn, err := dialer.DialContext(ctx, network, net.JoinHostPort(ip.String(), port)) + if err == nil { + return conn, nil + } + lastErr = err + } + if lastErr == nil { + lastErr = fmt.Errorf("keine adresse fuer %q", host) + } + return nil, lastErr + }, + TLSHandshakeTimeout: targetDialTimeout, + ResponseHeaderTimeout: targetTimeout, + MaxIdleConns: 10, + IdleConnTimeout: 30 * time.Second, + }, + } +} diff --git a/internal/webhook/webhook.go b/internal/webhook/webhook.go index f533beef..43866e4a 100644 --- a/internal/webhook/webhook.go +++ b/internal/webhook/webhook.go @@ -15,10 +15,13 @@ import ( "crypto/sha256" "encoding/hex" "encoding/json" + "errors" "fmt" "time" "github.com/jackc/pgx/v5/pgxpool" + + "gitea.perlbach24.de/scripte/nexarch/internal/kek" ) const ( @@ -33,33 +36,58 @@ const ( // Abonnenten-Seite; welche Ereignistypen ein Modul anbietet, ist bewusst // NICHT Teil dieser Kachel). type Subscription struct { - ID string - EventType string - TargetURL string - Secret string - CreatedAt time.Time + ID string + EventType string + TargetURL string + Secret string + CreatedAt time.Time } -// Store persistiert Abonnements und Zustellversuche. +// ErrSecretEncryptionKeyRequired wird geliefert, wenn Store ohne +// Verschluesselungsschluessel fuer Abonnement-Geheimnisse erzeugt wird +// (QA-15 F-22: kein Klartext-Fallback, fail closed statt eines stillen +// unverschluesselten Betriebs). +var ErrSecretEncryptionKeyRequired = errors.New("webhook: verschluesselungsschluessel fuer geheimnisse fehlt") + +// Store persistiert Abonnements und Zustellversuche. secretKey verschluesselt +// jedes Abonnement-Geheimnis (AES-256-GCM ueber internal/kek, derselbe +// Envelope-Mechanismus wie API-16/FDN-09) — das Geheimnis dient der +// Signaturpruefung durch den Empfaenger und ist ebenso schuetzenswert wie ein +// Passwort (QA-15 F-22). type Store struct { - pool *pgxpool.Pool + pool *pgxpool.Pool + secretKey []byte } -func NewStore(pool *pgxpool.Pool) *Store { - return &Store{pool: pool} +// NewStore erzeugt einen Store. secretKey MUSS gesetzt sein (32 Byte, +// AES-256) — ohne ihn koennen weder neue Abonnements angelegt noch +// bestehende Geheimnisse fuer die Zustellung gelesen werden. +func NewStore(pool *pgxpool.Pool, secretKey []byte) (*Store, error) { + if len(secretKey) != kek.MasterKeySize { + return nil, ErrSecretEncryptionKeyRequired + } + return &Store{pool: pool, secretKey: secretKey}, nil } -// Subscribe registriert einen Abonnenten fuer einen Ereignistyp. secret wird -// spaeter zur HMAC-Signierung jeder Zustellung an diesen Abonnenten -// verwendet (Akzeptanzkriterium 3). -func (s *Store) Subscribe(ctx context.Context, eventType, targetURL, secret string) (Subscription, error) { +// Subscribe registriert einen Abonnenten fuer einen Ereignistyp. target_url +// wird gegen SSRF geprueft (QA-15 F-22, siehe TargetPolicy), secret wird vor +// dem Speichern verschluesselt und spaeter zur HMAC-Signierung jeder +// Zustellung an diesen Abonnenten verwendet (Akzeptanzkriterium 3). +func (s *Store) Subscribe(ctx context.Context, policy TargetPolicy, eventType, targetURL, secret string) (Subscription, error) { + if err := policy.ValidateTargetURL(ctx, targetURL); err != nil { + return Subscription{}, err + } + ciphertext, err := kek.EncryptWithTenantKEK(s.secretKey, []byte(secret)) + if err != nil { + return Subscription{}, fmt.Errorf("geheimnis verschluesseln: %w", err) + } var sub Subscription sub.EventType, sub.TargetURL, sub.Secret = eventType, targetURL, secret row := s.pool.QueryRow(ctx, ` - INSERT INTO webhook_subscriptions (event_type, target_url, secret) + INSERT INTO webhook_subscriptions (event_type, target_url, secret_ciphertext) VALUES ($1, $2, $3) RETURNING id, created_at - `, eventType, targetURL, secret) + `, eventType, targetURL, ciphertext) if err := row.Scan(&sub.ID, &sub.CreatedAt); err != nil { return Subscription{}, fmt.Errorf("abonnement anlegen: %w", err) } diff --git a/internal/webhook/webhook_test.go b/internal/webhook/webhook_test.go index 553dea8c..396b159f 100644 --- a/internal/webhook/webhook_test.go +++ b/internal/webhook/webhook_test.go @@ -4,6 +4,7 @@ import ( "context" "encoding/json" "io" + "net" "net/http" "net/http/httptest" "os" @@ -16,6 +17,15 @@ import ( "gitea.perlbach24.de/scripte/nexarch/internal/testdb" ) +// testSecretKey ist ein fester 32-Byte-Testschluessel — kein Betriebsgeheimnis +// (nur synthetische Testdaten, siehe DSGVO-Vorgabe). +var testSecretKey = []byte("0123456789abcdef0123456789abcdef"[:32]) + +// loopbackPolicy erlaubt genau das, was httptest.Server liefert (http auf +// 127.0.0.1) — die Zielprüfung selbst wird in TestValidateTargetURL_* +// eigenstaendig geprueft (QA-15 F-22). +var loopbackPolicy = TargetPolicy{AllowHTTP: true, Allow: []string{"127.0.0.1"}} + func setupTest(t *testing.T) (*Store, *pgxpool.Pool, func()) { t.Helper() adminDSN := os.Getenv("TEST_ADMIN_DSN") @@ -28,7 +38,7 @@ func setupTest(t *testing.T) (*Store, *pgxpool.Pool, func()) { if _, err := pool.Exec(ctx, ` CREATE TABLE IF NOT EXISTS webhook_subscriptions ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), event_type TEXT NOT NULL, - target_url TEXT NOT NULL, secret TEXT NOT NULL, created_at TIMESTAMPTZ NOT NULL DEFAULT now() + target_url TEXT NOT NULL, secret_ciphertext BYTEA NOT NULL, created_at TIMESTAMPTZ NOT NULL DEFAULT now() ); CREATE TABLE IF NOT EXISTS webhook_deliveries ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), subscription_id UUID NOT NULL REFERENCES webhook_subscriptions(id), @@ -40,8 +50,12 @@ func setupTest(t *testing.T) (*Store, *pgxpool.Pool, func()) { t.Fatalf("schema: %v", err) } + store, err := NewStore(pool, testSecretKey) + if err != nil { + t.Fatalf("newstore: %v", err) + } cleanup := func() { pool.Close() } - return NewStore(pool), pool, cleanup + return store, pool, cleanup } func uniqueEventType(prefix string) string { @@ -67,7 +81,7 @@ func TestEnqueueAndProcessDue_DeliversSuccessfully(t *testing.T) { defer target.Close() eventType := uniqueEventType("dms.file.created") - sub, err := store.Subscribe(ctx, eventType, target.URL, "geheimes-secret") + sub, err := store.Subscribe(ctx, loopbackPolicy, eventType, target.URL, "geheimes-secret") if err != nil { t.Fatalf("subscribe: %v", err) } @@ -80,7 +94,10 @@ func TestEnqueueAndProcessDue_DeliversSuccessfully(t *testing.T) { t.Fatalf("erwartet 1 eingereihte zustellung, habe %d", n) } - dispatcher := NewDispatcher(pool, target.Client()) + dispatcher, err := NewDispatcher(pool, testSecretKey, target.Client()) + if err != nil { + t.Fatalf("newdispatcher: %v", err) + } processed, err := dispatcher.ProcessDue(ctx) if err != nil { t.Fatalf("processdue: %v", err) @@ -131,7 +148,7 @@ func TestProcessDue_RetriesWithBackoffThenMarksFailed(t *testing.T) { defer target.Close() eventType := uniqueEventType("mail.send.failed") - sub, err := store.Subscribe(ctx, eventType, target.URL, "secret") + sub, err := store.Subscribe(ctx, loopbackPolicy, eventType, target.URL, "secret") if err != nil { t.Fatalf("subscribe: %v", err) } @@ -139,7 +156,10 @@ func TestProcessDue_RetriesWithBackoffThenMarksFailed(t *testing.T) { t.Fatalf("enqueue: %v", err) } - dispatcher := NewDispatcher(pool, target.Client()) + dispatcher, err := NewDispatcher(pool, testSecretKey, target.Client()) + if err != nil { + t.Fatalf("newdispatcher: %v", err) + } dispatcher.MaxAttempts = 2 dispatcher.BaseBackoff = 250 * time.Millisecond @@ -202,3 +222,84 @@ func TestVerifySignature_DetectsTamperedPayload(t *testing.T) { t.Fatal("erwartet ungueltige signatur bei falschem secret") } } + +// QA-15 F-22: das gespeicherte Geheimnis liegt nur verschluesselt in der +// Datenbank — eine Rohabfrage der Spalte darf niemals den Klartext liefern. +func TestSubscribe_SecretIsEncryptedAtRest(t *testing.T) { + store, pool, cleanup := setupTest(t) + defer cleanup() + ctx := context.Background() + + sub, err := store.Subscribe(ctx, loopbackPolicy, uniqueEventType("dms.encrypted"), "http://127.0.0.1:1/webhook", "ganz-geheimer-wert") + if err != nil { + t.Fatalf("subscribe: %v", err) + } + + var raw []byte + if err := pool.QueryRow(ctx, `SELECT secret_ciphertext FROM webhook_subscriptions WHERE id = $1`, sub.ID).Scan(&raw); err != nil { + t.Fatalf("ciphertext lesen: %v", err) + } + if len(raw) == 0 { + t.Fatal("secret_ciphertext ist leer") + } + if bytesContain(raw, "ganz-geheimer-wert") { + t.Fatal("secret_ciphertext enthaelt den klartext des geheimnisses") + } +} + +func bytesContain(haystack []byte, needle string) bool { + return len(needle) > 0 && string(haystack) != "" && + (func() bool { + n := []byte(needle) + for i := 0; i+len(n) <= len(haystack); i++ { + match := true + for j := range n { + if haystack[i+j] != n[j] { + match = false + break + } + } + if match { + return true + } + } + return false + })() +} + +// QA-15 F-22 (CWE-918): Subscribe lehnt Ziel-Adressen ab, die kein +// oeffentliches Ziel sind und nicht per Allowlist freigegeben wurden. +func TestSubscribe_RejectsSSRFTargets(t *testing.T) { + store, _, cleanup := setupTest(t) + defer cleanup() + ctx := context.Background() + + cases := []struct { + name string + url string + }{ + {"loopback ohne allowlist", "http://127.0.0.1:9999/hook"}, + {"link-local metadaten", "http://169.254.169.254/latest/meta-data"}, + {"privates netz", "http://10.0.0.5/hook"}, + {"unspezifiziert", "http://0.0.0.0/hook"}, + {"falsches schema", "ftp://example.test/hook"}, + {"http ohne freigabe", "http://example.test/hook"}, + } + for _, c := range cases { + t.Run(c.name, func(t *testing.T) { + if _, err := store.Subscribe(ctx, TargetPolicy{}, uniqueEventType("dms.ssrf"), c.url, "secret"); err == nil { + t.Fatalf("erwartet ablehnung fuer %q, subscribe ist ohne fehler durchgelaufen", c.url) + } + }) + } +} + +// Public-https-Ziel ist ohne jede Konfiguration erlaubt (Regelfall). +func TestValidateTargetURL_AllowsPublicHTTPS(t *testing.T) { + p := TargetPolicy{Resolve: func(ctx context.Context, host string) ([]net.IP, error) { + return []net.IP{net.ParseIP("203.0.113.10")}, nil + }} + if err := p.ValidateTargetURL(context.Background(), "https://example.test/hook"); err != nil { + t.Fatalf("erwartet erfolg fuer oeffentliches https-ziel, habe: %v", err) + } +} diff --git a/mail/go.mod b/mail/go.mod index 19d28a8a..2e1682dd 100644 --- a/mail/go.mod +++ b/mail/go.mod @@ -1,6 +1,8 @@ module gitea.perlbach24.de/scripte/nexarch/mail -go 1.24 +go 1.25.0 + +toolchain go1.25.13 require ( github.com/aws/aws-sdk-go-v2 v1.45.1 @@ -10,9 +12,9 @@ require ( github.com/aws/smithy-go v1.28.1 github.com/fsnotify/fsnotify v1.10.1 github.com/getkin/kin-openapi v0.135.0 - github.com/jackc/pgx/v5 v5.6.0 + github.com/jackc/pgx/v5 v5.9.2 go.mozilla.org/pkcs7 v0.10.0 - golang.org/x/text v0.14.0 + golang.org/x/text v0.41.0 ) require ( @@ -33,8 +35,8 @@ require ( github.com/go-openapi/swag v0.23.0 // indirect github.com/gorilla/mux v1.8.0 // indirect github.com/jackc/pgpassfile v1.0.0 // indirect - github.com/jackc/pgservicefile v0.0.0-20221227161230-091c0ba34f0a // indirect - github.com/jackc/puddle/v2 v2.2.1 // indirect + github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect + github.com/jackc/puddle/v2 v2.2.2 // indirect github.com/josharian/intern v1.0.0 // indirect github.com/mailru/easyjson v0.7.7 // indirect github.com/mohae/deepcopy v0.0.0-20170929034955-c48cc78d4826 // indirect @@ -42,8 +44,7 @@ require ( github.com/oasdiff/yaml3 v0.0.9 // indirect github.com/perimeterx/marshmallow v1.1.5 // indirect github.com/woodsbury/decimal128 v1.3.0 // indirect - golang.org/x/crypto v0.17.0 // indirect - golang.org/x/sync v0.1.0 // indirect - golang.org/x/sys v0.15.0 // indirect + golang.org/x/sync v0.22.0 // indirect + golang.org/x/sys v0.47.0 // indirect gopkg.in/yaml.v3 v3.0.1 // indirect ) diff --git a/mail/go.sum b/mail/go.sum index 0817c4e3..ac9b9be2 100644 --- a/mail/go.sum +++ b/mail/go.sum @@ -51,12 +51,12 @@ github.com/gorilla/mux v1.8.0 h1:i40aqfkR1h2SlN9hojwV5ZA91wcXFOvkdNIeFDP5koI= github.com/gorilla/mux v1.8.0/go.mod h1:DVbg23sWSpFRCP0SfiEN6jmj59UnW/n46BH5rLB71So= github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM= github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg= -github.com/jackc/pgservicefile v0.0.0-20221227161230-091c0ba34f0a h1:bbPeKD0xmW/Y25WS6cokEszi5g+S0QxI/d45PkRi7Nk= -github.com/jackc/pgservicefile v0.0.0-20221227161230-091c0ba34f0a/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM= -github.com/jackc/pgx/v5 v5.6.0 h1:SWJzexBzPL5jb0GEsrPMLIsi/3jOo7RHlzTjcAeDrPY= -github.com/jackc/pgx/v5 v5.6.0/go.mod h1:DNZ/vlrUnhWCoFGxHAG8U2ljioxukquj7utPDgtQdTw= -github.com/jackc/puddle/v2 v2.2.1 h1:RhxXJtFG022u4ibrCSMSiu5aOq1i77R3OHKNJj77OAk= -github.com/jackc/puddle/v2 v2.2.1/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4= +github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 h1:iCEnooe7UlwOQYpKFhBabPMi4aNAfoODPEFNiAnClxo= +github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM= +github.com/jackc/pgx/v5 v5.9.2 h1:3ZhOzMWnR4yJ+RW1XImIPsD1aNSz4T4fyP7zlQb56hw= +github.com/jackc/pgx/v5 v5.9.2/go.mod h1:mal1tBGAFfLHvZzaYh77YS/eC6IX9OWbRV1QIIM0Jn4= +github.com/jackc/puddle/v2 v2.2.2 h1:PR8nw+E/1w0GLuRFSmiioY6UooMp6KJv0/61nB7icHo= +github.com/jackc/puddle/v2 v2.2.2/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4= github.com/josharian/intern v1.0.0 h1:vlS4z54oSdjm0bgjRigI+G1HpF+tI+9rE5LLzOg8HmY= github.com/josharian/intern v1.0.0/go.mod h1:5DoeVV0s6jJacbCEi61lwdGj/aVlrQvzHFFd8Hwg//Y= github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= @@ -80,22 +80,20 @@ github.com/rogpeppe/go-internal v1.12.0/go.mod h1:E+RYuTGaKKdloAfM02xzb0FW3Paa99 github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= -github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg= -github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= +github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= +github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= github.com/ugorji/go/codec v1.2.7 h1:YPXUKf7fYbp/y8xloBqZOw2qaVggbfwMlI8WM3wZUJ0= github.com/ugorji/go/codec v1.2.7/go.mod h1:WGN1fab3R1fzQlVQTkfxVtIBhWDRqOviHU95kRgeqEY= github.com/woodsbury/decimal128 v1.3.0 h1:8pffMNWIlC0O5vbyHWFZAt5yWvWcrHA+3ovIIjVWss0= github.com/woodsbury/decimal128 v1.3.0/go.mod h1:C5UTmyTjW3JftjUFzOVhC20BEQa2a4ZKOB5I6Zjb+ds= go.mozilla.org/pkcs7 v0.10.0 h1:jmljzDzNYFzaP1dFlgmCiQml9e+iEMmv8/NNs4evQbg= go.mozilla.org/pkcs7 v0.10.0/go.mod h1:SNgMg+EgDFwmvSmLRTNKC5fegJjB7v23qTQ0XLGUNHk= -golang.org/x/crypto v0.17.0 h1:r8bRNjWL3GshPW3gkd+RpvzWrZAwPS49OmTGZ/uhM4k= -golang.org/x/crypto v0.17.0/go.mod h1:gCAAfMLgwOJRpTjQ2zCCt2OcSfYMTeZVSRtQlPC7Nq4= -golang.org/x/sync v0.1.0 h1:wsuoTGHzEhffawBOhz5CYhcrV4IdKZbEyZjBMuTp12o= -golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= -golang.org/x/sys v0.15.0 h1:h48lPFYpsTvQJZF4EKyI4aLHaev3CxivZmv7yZig9pc= -golang.org/x/sys v0.15.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= -golang.org/x/text v0.14.0 h1:ScX5w1eTa3QqT8oi6+ziP7dTV1S2+ALU0bI+0zXKWiQ= -golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU= +golang.org/x/sync v0.22.0 h1:SZjpbeLmrCk4xhRSZFNZW5gFUeCeFgjekvI/+gfScek= +golang.org/x/sync v0.22.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= +golang.org/x/sys v0.47.0 h1:o7XGOvZQCADBQQ4Y7VNq2dRWQR7JmOUW8Kxx4ZsNgWs= +golang.org/x/sys v0.47.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= +golang.org/x/text v0.41.0 h1:vz/seA0lnX87Othu2f/0L24RcgrXD9/YFTSuGjj3rH8= +golang.org/x/text v0.41.0/go.mod h1:jvf1O8ajNzZqhSrQBPbutR/EB83Cc0CFrezNQIwbb5M= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= diff --git a/migrations/0013_webhook_secret_encryption.down.sql b/migrations/0013_webhook_secret_encryption.down.sql new file mode 100644 index 00000000..027ea473 --- /dev/null +++ b/migrations/0013_webhook_secret_encryption.down.sql @@ -0,0 +1,3 @@ +ALTER TABLE webhook_subscriptions + DROP COLUMN secret_ciphertext, + ADD COLUMN secret TEXT NOT NULL DEFAULT ''; diff --git a/migrations/0013_webhook_secret_encryption.up.sql b/migrations/0013_webhook_secret_encryption.up.sql new file mode 100644 index 00000000..6d73eb15 --- /dev/null +++ b/migrations/0013_webhook_secret_encryption.up.sql @@ -0,0 +1,16 @@ +-- QA-15 F-22: das Signatur-Geheimnis eines Webhook-Abonnements lag als +-- Klartext in der Registry-Datenbank (`secret`). internal/webhook ist noch +-- in keinem Dienst verdrahtet (API-07 baute nur die Engine) — es gibt keine +-- Bestandsdaten zu migrieren. `secret` wird ersetzt durch +-- `secret_ciphertext` (AES-256-GCM ueber internal/kek, dasselbe Verfahren +-- wie API-16/FDN-09). +-- IF EXISTS/IF NOT EXISTS statt einfacher DROP/ADD COLUMN: mehrere +-- Core-E2E-Tests (internal/e2e) teilen sich dieselbe Registry-Datenbank und +-- wenden alle migrations/*.up.sql je Testlauf erneut an, ohne +-- Migrations-Tracking (siehe internal/e2e/helpers_test.go); ein erneutes +-- DROP COLUMN secret waere sonst der einzige Migrationsschritt in diesem +-- Repository, der beim zweiten Lauf mit einem NICHT tolerierten Fehler +-- (undefined_column) abbricht. +ALTER TABLE webhook_subscriptions + DROP COLUMN IF EXISTS secret, + ADD COLUMN IF NOT EXISTS secret_ciphertext BYTEA NOT NULL; diff --git a/scripts/check-systemd-hardening.sh b/scripts/check-systemd-hardening.sh new file mode 100755 index 00000000..a225b609 --- /dev/null +++ b/scripts/check-systemd-hardening.sh @@ -0,0 +1,61 @@ +#!/usr/bin/env bash +# OPS-08 (QA-15 F-13/F-15): prueft, dass reine Netzwerkdienste +# (Type=simple, kein Dateisystemschreibzugriff ausserhalb ihrer +# EnvironmentFile-Konfiguration) die vereinbarten Sandbox-Direktiven +# tragen. Oneshot-CLIs mit eigenem Schreibziel (Backup/Restore/Scrub/ +# Objectbackup/Reconcile/Tenantbackup) sind bewusst NICHT Teil dieser +# Liste (siehe OPS-08 im Protokoll: eigene ReadWritePaths je Zielverzeichnis +# noetig, nicht Teil der "kleinsten Loesung" dieses Durchgangs). +set -euo pipefail + +root="${1:-.}" +dir="$root/deploy/systemd" +[ -d "$dir" ] || { echo "verzeichnis fehlt: $dir" >&2; exit 2; } + +required_bool=(NoNewPrivileges ProtectHome PrivateTmp) + +network_only=( + nexarch-account-api + nexarch-archive-moduleadapter-api + nexarch-archive-restoretest-metrics + nexarch-archive-retention-api + nexarch-archive-scrub-metrics + nexarch-auditlog-devserver + nexarch-core + nexarch-kek-api + nexarch-licadmin-devserver + nexarch-metrics-devserver + nexarch-notify-api + nexarch-notifyprefs-api + nexarch-policy-api + nexarch-rbac-admin-api + nexarch-resync-api + nexarch-secheaders-devserver + nexarch-statuspage-devserver + nexarch-tenantadmin-devserver +) + +fail=0 +for svc in "${network_only[@]}"; do + f="$dir/$svc.service.tmpl" + if [ ! -f "$f" ]; then + echo "FEHLT: $f" >&2 + fail=1 + continue + fi + for directive in "${required_bool[@]}"; do + if ! grep -q "^${directive}=true" "$f"; then + echo "$svc.service.tmpl: ${directive}=true fehlt" >&2 + fail=1 + fi + done + if ! grep -qE "^ProtectSystem=(strict|full)" "$f"; then + echo "$svc.service.tmpl: ProtectSystem=strict (oder full) fehlt" >&2 + fail=1 + fi +done + +if [ "$fail" -eq 0 ]; then + echo "systemd-haertung ok: ${#network_only[@]} netzwerk-dienste geprueft" +fi +exit $fail