diff --git a/k8s/providers/hetzner/apps/unifi/git-repository.yaml b/k8s/providers/hetzner/apps/unifi/git-repository.yaml index fa87b521fe..beee6a4db7 100644 --- a/k8s/providers/hetzner/apps/unifi/git-repository.yaml +++ b/k8s/providers/hetzner/apps/unifi/git-repository.yaml @@ -13,3 +13,7 @@ spec: url: https://github.com/devantler-tech/unifi ref: commit: 7b17f7e33ef507c24c395b884a433c62b92ace98 + verify: + mode: HEAD + secretRef: + name: unifi-git-signing-keys diff --git a/k8s/providers/hetzner/apps/unifi/kustomization.yaml b/k8s/providers/hetzner/apps/unifi/kustomization.yaml index df53c45bb4..bacdc3710f 100644 --- a/k8s/providers/hetzner/apps/unifi/kustomization.yaml +++ b/k8s/providers/hetzner/apps/unifi/kustomization.yaml @@ -7,6 +7,7 @@ resources: - role.yaml - role-binding.yaml - network-policy.yaml + - secret-git-signing-keys.yaml - secret-store.yaml - provider-config.yaml - external-secret.yaml diff --git a/k8s/providers/hetzner/apps/unifi/secret-git-signing-keys.yaml b/k8s/providers/hetzner/apps/unifi/secret-git-signing-keys.yaml new file mode 100644 index 0000000000..60b0a594ca --- /dev/null +++ b/k8s/providers/hetzner/apps/unifi/secret-git-signing-keys.yaml @@ -0,0 +1,41 @@ +# Public verification material only: this Secret contains no credential. Flux +# requires trusted PGP keys under an `.asc` key when GitRepository.verify is +# enabled. The block is the active key from GitHub's published web-flow key +# set; the key that signs the pinned UniFi commit has fingerprint +# 968479A1AFF927E37D1A566BB5690EEEBB952194. +apiVersion: v1 +kind: Secret +metadata: + name: unifi-git-signing-keys + namespace: unifi +type: Opaque +stringData: + github.asc: | + -----BEGIN PGP PUBLIC KEY BLOCK----- + + mQINBGWmxXYBEACyN+4viFQM6QQoKr0A2W0rGdMobTJwOZso2QPpewbyBsuL3rNW + 5OmHrWwXAhPKNqUIyOzdq8MoSxoTTuqLksoahixEL/X2nyhOBxR9GkYz/oI9R3nY + cLRaFQoSJoVfOt61opkLUzbWAehpbgT8EKln8JsENq0+0nDlWQi0h2Q9oGmqlgVz + skwmVZ8Leyv4Mg7hN6swyZ7moZfkkpD5+U7Z2XVurCzkSSfg4zb2lMRLJos2eCAc + 749ECsX0t7OBftF+YqgjIXixXsm2RrUqvU47OkOtZeAhvAYenbC3pr9Fha5NxoBU + Ea+11MK9W6OcRhwvxVCUrMUR6FTSZyC//VpXTTtrRlOqpU5wGMbP3zpn9geqOXCl + 8rF7+1gAPG/o+QFQTBsVEruwi4JWogiQuQyOwAIlFe/7dvaxWZGpv/yW2+L3guL7 + xaHKFVGsayhlitQQ5Xa+P1iSgKSXDyReCbWotfqAempPySI25LHh3ScXI6NgdHSr + SBaFojwAfgxbPTEQ6adIsKHCQofrnLrNa3UOeGDGiOOK0aYV3jiEDGAouatkNf2q + 85Eosj1f9laCqAH3YLJD7dcSne1iChK5qRTByMvIyeSD0NbNnVMFOGpXySyWtKb2 + ldpu8AWBQJsJs9FmYBcWAGBA2pp+IxaCn6rBIHIsUVFRN8OVZKsEsBkWywARAQAB + tBtHaXRIdWIgPG5vcmVwbHlAZ2l0aHViLmNvbT6JAk4EEwEKADgWIQSWhHmhr/kn + 430aVmu1aQ7uu5UhlAUCZabFdgIbAwULCQgHAgYVCgkICwIEFgIDAQIeAQIXgAAK + CRC1aQ7uu5UhlIMuEAClvVwC+Neoiq0AdixJZsagKHpx1QrMJWrtMRi4eXVTTaeX + +P1unhC/AmSO4Xxd3uRoejHvfWh4F0gitUJ8XKgiejnmuGcq7Dbt5OoO1JuXGlW2 + BQ+MiGoYVw2B0sOhWDNrIBWOO/WL4LykcGnAtrRXwoS0Wx4MCydztXQY5lcnCWaW + 8rvu7WmduoOikH4HI97rqN5896dc4iBKSx8LZf+46DRCCD/5SfACplBz4hs5zen8 + TL8zd+zxjFrXbzota0jSDEGK9WGO4z55S2xScC6zv6v3Bj1OR8Bs5aodGtmamHZ7 + sE9w0RJoCfNx+9cR/rE82SrOaBpVU7urLe4lg7zaaNhqDdNV8ymuXGmIJarDgrme + iB5bHS+dLFzLUkTgot4RFlPa9bFiJuJN6Tc9tMu5RJQ9l/zKmxDHIKWsAle5R65u + zEq04LugTQBdEorGxfQCsF2ga9ncKTDMiAThWTvZpOP3NJ/athZRmOBpG4B9iR6r + pRU8F/+MokG4fIMwnvtOhWQFiEzdTkJ7U5JAkPtTAmT3/mznwtPEU7DrFWSGAdqg + IMOlxNCBeGvjwLR0qGH7cB9qHDGNoDLkjaUFpu5tPv4/ivkQaHlHJxjT0ILM6jet + CAzKpKh48rm65tmrJX6KVpj0r2kKMscFf7s7XaPlCNCFds/YA+0puPbzJKWKfA== + =NRlX + -----END PGP PUBLIC KEY BLOCK----- diff --git a/scripts/validate-eks-ci-role-policy/main.go b/scripts/validate-eks-ci-role-policy/main.go index 4ddf9f83b3..479d6c8355 100644 --- a/scripts/validate-eks-ci-role-policy/main.go +++ b/scripts/validate-eks-ci-role-policy/main.go @@ -1273,7 +1273,30 @@ const ( // // 2e5ff04e52117cdbdc88261c35845e51a17ff31b709ed5b7d449b5076c08d8e1 // bef0f81a74a01efa995543c82c4adf5c18123f3e1b31d0430719f9a3af53daa5 -const expectedRenderedSurfaceSHA = "3ef2555fe188f77b84a5927739842929f637688713fa6510bf2c682adce3643b" +// +// Measured against main 5dfb6f78 for the UniFi Git signature boundary: the +// five production projections move from 539 to 540 documents. Set difference +// in BOTH directions over apiVersion|kind|namespace|name reports exactly one +// addition and no removal or rename: +// +// v1 Secret unifi/unifi-git-signing-keys +// +// Exactly one existing rendered identity changes: +// +// source.toolkit.fluxcd.io/v1 GitRepository unifi/unifi +// +// Its only delta adds `verify.mode: HEAD` and the exact Secret reference above +// while retaining the already-immutable 40-character commit pin. The Secret +// contains public verification material only; validateUnifiSigningKey pins its +// sole armored key to GitHub's active signer fingerprint +// 968479A1AFF927E37D1A566BB5690EEEBB952194 through the reviewed content hash. +// The pinned commit verifies with that key, while an unsigned empty-commit +// control is rejected. Grant-bearing counts remain byte-for-byte stable: +// Role 11, ClusterRole 24, RoleBinding 15, ClusterRoleBinding 12, and +// ServiceAccount 16. The previous approved aggregate digest was: +// +// 3ef2555fe188f77b84a5927739842929f637688713fa6510bf2c682adce3643b +const expectedRenderedSurfaceSHA = "9309a857065fd3d675fd1a26414311d2c7a43717618c904a980c0fe8177839bd" // authorizationOverlayPaths lists every independently reconciled production // layer where an object can grant privileges to the aws/aws service account. @@ -1342,6 +1365,56 @@ func validatePinnedUnifiSource(document map[string]any, identity resourceIdentit if !ok || !exactGitCommit.MatchString(commit) { return errors.New("unifi GitRepository must pin exactly one full immutable commit") } + verify, ok := spec["verify"].(map[string]any) + if !ok || len(verify) != 2 || verify["mode"] != "HEAD" { + return errors.New("unifi GitRepository must verify the pinned HEAD commit") + } + secretRef, ok := verify["secretRef"].(map[string]any) + if !ok || len(secretRef) != 1 || secretRef["name"] != "unifi-git-signing-keys" { + return errors.New("unifi GitRepository must trust only the reviewed unifi Git signing key Secret") + } + return nil +} + +const expectedUnifiPublicMaterialSHA = "40ce89d21fb075092d256f9fbf62a1c19299d3282cb913d3e61d08235d0c491a" + +// validateUnifiSigningKey pins the public trust root used by the UniFi +// GitRepository. A secretRef alone only proves that Flux will look up a name; +// it says nothing about which signer that Secret actually trusts. +func validateUnifiSigningKey(documents []map[string]any) error { + wantIdentity := resourceIdentity{ + apiVersion: "v1", + kind: "Secret", + namespace: "unifi", + name: "unifi-git-signing-keys", + } + count := 0 + for _, document := range documents { + if identityOf(document) != wantIdentity { + continue + } + count++ + if document["type"] != "Opaque" { + return errors.New("unifi Git signing key Secret must be type Opaque") + } + if _, exists := document["data"]; exists { + return errors.New("unifi Git signing key Secret must not contain data") + } + stringData, ok := document["stringData"].(map[string]any) + if !ok || len(stringData) != 1 { + return errors.New("unifi Git signing key Secret must contain only github.asc") + } + key, ok := stringData["github.asc"].(string) + if !ok || key == "" { + return errors.New("unifi Git signing key Secret must contain github.asc") + } + if actual := fingerprint([]byte(key)); actual != expectedUnifiPublicMaterialSHA { + return fmt.Errorf("unapproved unifi Git signing key fingerprint: %s", actual) + } + } + if count != 1 { + return fmt.Errorf("unifi Git signing key Secret count is %d, want exactly 1", count) + } return nil } @@ -2350,6 +2423,9 @@ func validateRendered(rendered []byte) error { seen := make(map[resourceIdentity]bool, len(expectedRenderedHashes)) surfaceEntries := make([]string, 0, len(expectedRenderedHashes)) problems := make([]error, 0) + if keyErr := validateUnifiSigningKey(documents); keyErr != nil { + problems = append(problems, keyErr) + } substitutionProblems := make([]error, 0) for _, document := range documents { identity := identityOf(document) diff --git a/scripts/validate-eks-ci-role-policy/main_test.go b/scripts/validate-eks-ci-role-policy/main_test.go index badfeab548..e306734fb2 100644 --- a/scripts/validate-eks-ci-role-policy/main_test.go +++ b/scripts/validate-eks-ci-role-policy/main_test.go @@ -270,6 +270,12 @@ func TestValidatePinnedUnifiSource(t *testing.T) { "spec": map[string]any{ "url": url, "ref": ref, + "verify": map[string]any{ + "mode": "HEAD", + "secretRef": map[string]any{ + "name": "unifi-git-signing-keys", + }, + }, }, } } @@ -312,6 +318,47 @@ func TestValidatePinnedUnifiSource(t *testing.T) { document: source("https://github.com/attacker/unifi", map[string]any{"commit": pinnedCommit}), wantError: true, }, + { + name: "missing signature verification", + identity: targetIdentity, + document: map[string]any{ + "spec": map[string]any{ + "url": trustedURL, + "ref": map[string]any{"commit": pinnedCommit}, + }, + }, + wantError: true, + }, + { + name: "tag-only signature verification", + identity: targetIdentity, + document: map[string]any{ + "spec": map[string]any{ + "url": trustedURL, + "ref": map[string]any{"commit": pinnedCommit}, + "verify": map[string]any{ + "mode": "Tag", + "secretRef": map[string]any{"name": "unifi-git-signing-keys"}, + }, + }, + }, + wantError: true, + }, + { + name: "untrusted signature key secret", + identity: targetIdentity, + document: map[string]any{ + "spec": map[string]any{ + "url": trustedURL, + "ref": map[string]any{"commit": pinnedCommit}, + "verify": map[string]any{ + "mode": "HEAD", + "secretRef": map[string]any{"name": "some-other-key"}, + }, + }, + }, + wantError: true, + }, { name: "unrelated source", identity: resourceIdentity{ @@ -337,6 +384,54 @@ func TestValidatePinnedUnifiSource(t *testing.T) { } } +// TestValidateUnifiSigningKey proves the trust root is present exactly once +// and cannot be replaced or widened without moving the reviewed fingerprint. +func TestValidateUnifiSigningKey(t *testing.T) { + manifestPath := filepath.Join( + "..", "..", "k8s", "providers", "hetzner", "apps", "unifi", "secret-git-signing-keys.yaml", + ) + contents, err := os.ReadFile(manifestPath) + if err != nil { + t.Fatalf("read the UniFi signing key manifest: %v", err) + } + documents, err := decodeDocuments(contents) + if err != nil { + t.Fatalf("decode the UniFi signing key manifest: %v", err) + } + if err := validateUnifiSigningKey(documents); err != nil { + t.Fatalf("the reviewed UniFi signing key must validate: %v", err) + } + + if err := validateUnifiSigningKey(nil); err == nil { + t.Fatal("a missing UniFi signing key Secret must be rejected") + } + if err := validateUnifiSigningKey(append(documents, documents[0])); err == nil { + t.Fatal("a duplicate UniFi signing key Secret must be rejected") + } + + tampered, err := decodeDocuments(contents) + if err != nil { + t.Fatalf("decode the tampered-key fixture: %v", err) + } + stringData, ok := tampered[0]["stringData"].(map[string]any) + if !ok { + t.Fatal("the tampered-key fixture must contain stringData") + } + stringData["github.asc"] = "attacker key\n" + if err := validateUnifiSigningKey(tampered); err == nil { + t.Fatal("an unreviewed UniFi signing key must be rejected") + } + + widened, err := decodeDocuments(contents) + if err != nil { + t.Fatalf("decode the widened-key fixture: %v", err) + } + widened[0]["data"] = map[string]any{"attacker.asc": "YXR0YWNrZXI="} + if err := validateUnifiSigningKey(widened); err == nil { + t.Fatal("additional base64-encoded signing keys must be rejected") + } +} + // TestValidateUnifiPruneExemption pins the admission exception that makes the // non-pruning UniFi reconciler deployable without weakening the default rule // for any other Flux Kustomization.