Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions k8s/providers/hetzner/apps/unifi/git-repository.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -13,3 +13,7 @@ spec:
url: https://github.com/devantler-tech/unifi
ref:
commit: 7b17f7e33ef507c24c395b884a433c62b92ace98
verify:
mode: HEAD
secretRef:
name: unifi-git-signing-keys
1 change: 1 addition & 0 deletions k8s/providers/hetzner/apps/unifi/kustomization.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
41 changes: 41 additions & 0 deletions k8s/providers/hetzner/apps/unifi/secret-git-signing-keys.yaml
Original file line number Diff line number Diff line change
@@ -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-----
78 changes: 77 additions & 1 deletion scripts/validate-eks-ci-role-policy/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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)
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Resolved
}
}
if count != 1 {
return fmt.Errorf("unifi Git signing key Secret count is %d, want exactly 1", count)
}
return nil
}

Expand Down Expand Up @@ -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)
Expand Down
95 changes: 95 additions & 0 deletions scripts/validate-eks-ci-role-policy/main_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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",
},
},
},
}
}
Expand Down Expand Up @@ -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{
Expand All @@ -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")
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Resolved
}

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.
Expand Down
Loading