diff --git a/build/components/versions.yml b/build/components/versions.yml index dc4335948f..b466a63b68 100644 --- a/build/components/versions.yml +++ b/build/components/versions.yml @@ -5,7 +5,7 @@ firmware: core: 3p-kubevirt: v1.6.2-v12n.53 3p-containerized-data-importer: v1.60.3-v12n.21 - distribution: 2.8.3 + distribution: 3.1.1 package: acl: v2.3.1 bzip2: bzip2-1.0.8 diff --git a/images/dvcr/dvcr-k8s-auth/access.go b/images/dvcr/dvcr-k8s-auth/access.go new file mode 100644 index 0000000000..bca02337e3 --- /dev/null +++ b/images/dvcr/dvcr-k8s-auth/access.go @@ -0,0 +1,269 @@ +/* +Copyright 2026 Flant JSC + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +// Build tag dvcr_registry: this file depends on the distribution registry module +// and is only compiled inside the werf DVCR build (which passes -tags dvcr_registry). +// The dependency-free policy in policy.go stays unit-testable without it. + +//go:build dvcr_registry + +package dvcrk8s + +import ( + "crypto" + "crypto/subtle" + "crypto/x509" + "encoding/pem" + "errors" + "fmt" + "log" + "net/http" + "os" + "strings" + + jose "github.com/go-jose/go-jose/v4" + + "github.com/distribution/distribution/v3/registry/auth" + "github.com/distribution/distribution/v3/registry/auth/token" +) + +// jwtSigningAlgs are the JWS algorithms accepted for scoped tokens. ES256 only: +// tokens are minted by the virtualization-controller with an ECDSA P-256 key. +var jwtSigningAlgs = []jose.SignatureAlgorithm{jose.ES256} + +func init() { + if err := auth.Register("dvcr-k8s", auth.InitFunc(newAccessController)); err != nil { + log.Printf("failed to register dvcr-k8s auth: %v", err) + } +} + +type accessController struct { + realm string + + adminUsername string + adminPassword []byte + + pullerUsername string + pullerPassword []byte + + jwtIssuer string + jwtAudience string + trustedKeys map[string]crypto.PublicKey +} + +// newAccessController builds the controller from the `auth: { dvcr-k8s: {...} }` +// config block. Options: +// +// realm string (WWW-Authenticate realm) +// adminusername string +// adminpasswordfile string (path to the admin read-write password) +// pullerusername string +// pullerpasswordfile string (path to the node-puller password) +// jwtissuer string (accepted token issuer) +// jwtaudience string (accepted token audience) +// jwtpublickeyfile string (PEM PKIX public key that signs scoped tokens) +// jwtkeyid string (key id the token header must reference, default "dvcr") +func newAccessController(options map[string]interface{}) (auth.AccessController, error) { + realm, err := optString(options, "realm", "dvcr") + if err != nil { + return nil, err + } + + adminUser, err := optString(options, "adminusername", "admin") + if err != nil { + return nil, err + } + adminPass, err := readSecretFile(options, "adminpasswordfile") + if err != nil { + return nil, err + } + + pullerUser, err := optString(options, "pullerusername", "node-puller") + if err != nil { + return nil, err + } + pullerPass, err := readSecretFile(options, "pullerpasswordfile") + if err != nil { + return nil, err + } + + jwtIssuer, err := optString(options, "jwtissuer", "") + if err != nil { + return nil, err + } + jwtAudience, err := optString(options, "jwtaudience", "") + if err != nil { + return nil, err + } + keyID, err := optString(options, "jwtkeyid", "dvcr") + if err != nil { + return nil, err + } + keyPath, err := optString(options, "jwtpublickeyfile", "") + if err != nil { + return nil, err + } + if keyPath == "" { + return nil, errors.New("dvcr-k8s: jwtpublickeyfile is required") + } + pub, err := loadPublicKey(keyPath) + if err != nil { + return nil, fmt.Errorf("dvcr-k8s: load jwt public key: %w", err) + } + + return &accessController{ + realm: realm, + adminUsername: adminUser, + adminPassword: adminPass, + pullerUsername: pullerUser, + pullerPassword: pullerPass, + jwtIssuer: jwtIssuer, + jwtAudience: jwtAudience, + trustedKeys: map[string]crypto.PublicKey{keyID: pub}, + }, nil +} + +// Authorized implements auth.AccessController for distribution v3. +func (ac *accessController) Authorized(req *http.Request, accessRecords ...auth.Access) (*auth.Grant, error) { + username, password, ok := req.BasicAuth() + if !ok || password == "" { + return nil, &challenge{realm: ac.realm, err: auth.ErrInvalidCredential} + } + + subject, name, err := ac.classify(username, password) + if err != nil { + // Bad credential -> 401 challenge so the client may retry with valid creds. + return nil, &challenge{realm: ac.realm, err: err} + } + + if !Authorize(subject, toPolicyAccess(accessRecords)) { + // Authenticated but not permitted -> 403 (plain error, not a challenge). + return nil, fmt.Errorf("dvcr-k8s: access denied for %q: %w", name, auth.ErrAuthenticationFailure) + } + + return &auth.Grant{User: auth.UserInfo{Name: name}}, nil +} + +// classify maps a Basic credential to an authorization Subject. The static admin +// and node-puller passwords are matched in constant time; any other credential is +// treated as a signed scoped token, its password verified as a JWT (fail-closed). +func (ac *accessController) classify(username, password string) (Subject, string, error) { + if len(ac.adminPassword) > 0 && username == ac.adminUsername && + subtle.ConstantTimeCompare([]byte(password), ac.adminPassword) == 1 { + return Subject{Role: RoleAdmin}, username, nil + } + + if len(ac.pullerPassword) > 0 && username == ac.pullerUsername && + subtle.ConstantTimeCompare([]byte(password), ac.pullerPassword) == 1 { + return Subject{Role: RolePuller}, username, nil + } + + grants, err := ac.verifyJWT(password) + if err != nil { + return Subject{}, "", fmt.Errorf("scoped token: %w", err) + } + return Subject{Role: RoleScoped, Grants: grants}, "scoped:" + username, nil +} + +// verifyJWT parses and verifies a scoped token, reusing distribution's own token +// verification (signature, issuer, audience, nbf/exp), and returns its grants. +func (ac *accessController) verifyJWT(raw string) ([]Grant, error) { + tok, err := token.NewToken(raw, jwtSigningAlgs) + if err != nil { + return nil, err + } + claims, err := tok.Verify(token.VerifyOptions{ + TrustedIssuers: []string{ac.jwtIssuer}, + AcceptedAudiences: []string{ac.jwtAudience}, + TrustedKeys: ac.trustedKeys, + // Non-nil empty pool: distribution tries a token's x5c/jwk chain before the + // pinned kid, and with nil Roots that chain validates against the system CAs. + // An empty pool rejects every chain, leaving only the pinned key. + Roots: x509.NewCertPool(), + }) + if err != nil { + return nil, err + } + grants := make([]Grant, 0, len(claims.Access)) + for _, ra := range claims.Access { + if ra == nil { + continue + } + grants = append(grants, Grant{Type: ra.Type, Name: ra.Name, Actions: ra.Actions}) + } + return grants, nil +} + +func toPolicyAccess(records []auth.Access) []Access { + out := make([]Access, len(records)) + for i, r := range records { + out[i] = Access{Type: r.Type, Name: r.Name, Action: r.Action} + } + return out +} + +// challenge implements auth.Challenge for a 401 Basic auth response. +type challenge struct { + realm string + err error +} + +func (ch *challenge) SetHeaders(_ *http.Request, w http.ResponseWriter) { + w.Header().Set("WWW-Authenticate", fmt.Sprintf("Basic realm=%q", ch.realm)) +} + +func (ch *challenge) Error() string { + return fmt.Sprintf("dvcr-k8s basic auth challenge for realm %q: %v", ch.realm, ch.err) +} + +func loadPublicKey(path string) (crypto.PublicKey, error) { + data, err := os.ReadFile(path) + if err != nil { + return nil, err + } + block, _ := pem.Decode(data) + if block == nil { + return nil, errors.New("no PEM block found") + } + return x509.ParsePKIXPublicKey(block.Bytes) +} + +func optString(options map[string]interface{}, key, def string) (string, error) { + v, present := options[key] + if !present { + return def, nil + } + s, ok := v.(string) + if !ok { + return "", fmt.Errorf("dvcr-k8s: option %q must be a string", key) + } + return s, nil +} + +func readSecretFile(options map[string]interface{}, key string) ([]byte, error) { + path, err := optString(options, key, "") + if err != nil { + return nil, err + } + if path == "" { + return nil, nil + } + data, err := os.ReadFile(path) + if err != nil { + return nil, fmt.Errorf("dvcr-k8s: read %q from %s: %w", key, path, err) + } + return []byte(strings.TrimSpace(string(data))), nil +} diff --git a/images/dvcr/dvcr-k8s-auth/access_test.go b/images/dvcr/dvcr-k8s-auth/access_test.go new file mode 100644 index 0000000000..575de37eef --- /dev/null +++ b/images/dvcr/dvcr-k8s-auth/access_test.go @@ -0,0 +1,200 @@ +/* +Copyright 2026 Flant JSC + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +//go:build dvcr_registry + +// Integration tests for verifyJWT: mint tokens as the controller (golang-jwt) and +// as an x5c attacker (go-jose), verify through the real distribution backend. The +// only end-to-end check of the empty-Roots x5c defense. + +package dvcrk8s + +import ( + "crypto" + "crypto/ecdsa" + "crypto/elliptic" + "crypto/rand" + "crypto/x509" + "crypto/x509/pkix" + "encoding/base64" + "encoding/json" + "math/big" + "testing" + "time" + + golangjwt "github.com/golang-jwt/jwt/v5" + + jose "github.com/go-jose/go-jose/v4" + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +func TestAccessController(t *testing.T) { + RegisterFailHandler(Fail) + RunSpecs(t, "DVCR Auth Suite") +} + +const ( + testIssuer = "virtualization-controller" + testAudience = "dvcr" + testKeyID = "dvcr" +) + +func newController(pub crypto.PublicKey) *accessController { + return &accessController{ + realm: "dvcr", + jwtIssuer: testIssuer, + jwtAudience: testAudience, + trustedKeys: map[string]crypto.PublicKey{testKeyID: pub}, + } +} + +func testClaims(now time.Time) map[string]any { + return map[string]any{ + "iss": testIssuer, + "aud": testAudience, + "iat": now.Unix(), + "nbf": now.Add(-30 * time.Second).Unix(), + "exp": now.Add(time.Hour).Unix(), + "access": []map[string]any{ + {"type": "repository", "name": "cvi/img", "actions": []string{"pull", "push"}}, + }, + } +} + +// mintKidToken signs a token the way the controller does: golang-jwt/v5, ES256, +// kid header, no embedded key material. +func mintKidToken(key *ecdsa.PrivateKey, claims map[string]any) string { + GinkgoHelper() + tok := golangjwt.NewWithClaims(golangjwt.SigningMethodES256, golangjwt.MapClaims(claims)) + tok.Header["kid"] = testKeyID + raw, err := tok.SignedString(key) + Expect(err).NotTo(HaveOccurred()) + return raw +} + +// mintX5cToken forges a token carrying its own self-signed certificate in the +// x5c header — the attack the empty-Roots pool must defeat. +func mintX5cToken(claims map[string]any) string { + GinkgoHelper() + key, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) + Expect(err).NotTo(HaveOccurred()) + tmpl := &x509.Certificate{ + SerialNumber: big.NewInt(1), + Subject: pkix.Name{CommonName: "attacker"}, + NotBefore: time.Now().Add(-time.Hour), + NotAfter: time.Now().Add(time.Hour), + } + certDER, err := x509.CreateCertificate(rand.Reader, tmpl, tmpl, &key.PublicKey, key) + Expect(err).NotTo(HaveOccurred()) + opts := (&jose.SignerOptions{}).WithHeader("x5c", []string{base64.StdEncoding.EncodeToString(certDER)}) + signer, err := jose.NewSigner(jose.SigningKey{Algorithm: jose.ES256, Key: key}, opts) + Expect(err).NotTo(HaveOccurred()) + payload, err := json.Marshal(claims) + Expect(err).NotTo(HaveOccurred()) + jws, err := signer.Sign(payload) + Expect(err).NotTo(HaveOccurred()) + raw, err := jws.CompactSerialize() + Expect(err).NotTo(HaveOccurred()) + return raw +} + +func newKey() *ecdsa.PrivateKey { + GinkgoHelper() + key, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) + Expect(err).NotTo(HaveOccurred()) + return key +} + +var _ = Describe("verifyJWT", func() { + It("accepts a valid kid-signed token and returns its access grants", func() { + key := newKey() + ac := newController(&key.PublicKey) + + grants, err := ac.verifyJWT(mintKidToken(key, testClaims(time.Now()))) + Expect(err).NotTo(HaveOccurred()) + Expect(grants).To(HaveLen(1)) + Expect(grants[0].Name).To(Equal("cvi/img")) + Expect(grants[0].Type).To(Equal("repository")) + }) + + // The core security guarantee: a token whose signing key is smuggled in via + // x5c must not be trusted, even though its self-signed cert is internally valid. + It("rejects an x5c-smuggled token", func() { + key := newKey() + ac := newController(&key.PublicKey) + + _, err := ac.verifyJWT(mintX5cToken(testClaims(time.Now()))) + Expect(err).To(HaveOccurred()) + }) + + DescribeTable("rejects an invalid token", + func(makeToken func(key *ecdsa.PrivateKey) string) { + key := newKey() + ac := newController(&key.PublicKey) + + _, err := ac.verifyJWT(makeToken(key)) + Expect(err).To(HaveOccurred()) + }, + Entry("expired", func(key *ecdsa.PrivateKey) string { + claims := testClaims(time.Now()) + claims["exp"] = time.Now().Add(-time.Hour).Unix() + return mintKidToken(key, claims) + }), + Entry("wrong issuer", func(key *ecdsa.PrivateKey) string { + claims := testClaims(time.Now()) + claims["iss"] = "someone-else" + return mintKidToken(key, claims) + }), + Entry("wrong audience", func(key *ecdsa.PrivateKey) string { + claims := testClaims(time.Now()) + claims["aud"] = "not-dvcr" + return mintKidToken(key, claims) + }), + Entry("untrusted signing key", func(*ecdsa.PrivateKey) string { + return mintKidToken(newKey(), testClaims(time.Now())) + }), + Entry("tampered payload", func(key *ecdsa.PrivateKey) string { + raw := mintKidToken(key, testClaims(time.Now())) + return raw[:len(raw)-3] + "AAA" + }), + ) +}) + +var _ = Describe("classify", func() { + It("maps static credentials and scoped tokens to roles", func() { + key := newKey() + ac := newController(&key.PublicKey) + ac.adminUsername = "admin" + ac.adminPassword = []byte("admin-pass") + ac.pullerUsername = "node-puller" + ac.pullerPassword = []byte("puller-pass") + + s, _, err := ac.classify("admin", "admin-pass") + Expect(err).NotTo(HaveOccurred()) + Expect(s.Role).To(Equal(RoleAdmin)) + + s, _, err = ac.classify("node-puller", "puller-pass") + Expect(err).NotTo(HaveOccurred()) + Expect(s.Role).To(Equal(RolePuller)) + + // A scoped token presented under the admin username must not become admin. + scoped := mintKidToken(key, testClaims(time.Now())) + s, _, err = ac.classify("admin", scoped) + Expect(err).NotTo(HaveOccurred()) + Expect(s.Role).To(Equal(RoleScoped)) + }) +}) diff --git a/images/dvcr/dvcr-k8s-auth/go.mod b/images/dvcr/dvcr-k8s-auth/go.mod new file mode 100644 index 0000000000..33e89c08c2 --- /dev/null +++ b/images/dvcr/dvcr-k8s-auth/go.mod @@ -0,0 +1,29 @@ +// Local module for unit-testing the DVCR authorization code. The requires below +// exist only for access_test.go (build tag dvcr_registry) to verify tokens +// against the real distribution backend. The .go sources are copied into the +// distribution module at build time (see images/dvcr/werf.inc.yaml); this go.mod +// and the *_test.go files are not used in that build. +module github.com/deckhouse/virtualization/dvcr-k8s-auth + +go 1.25.0 + +require ( + github.com/distribution/distribution/v3 v3.1.1 + github.com/go-jose/go-jose/v4 v4.1.4 + github.com/golang-jwt/jwt/v5 v5.3.0 + github.com/onsi/ginkgo/v2 v2.23.3 + github.com/onsi/gomega v1.37.0 +) + +require ( + github.com/go-logr/logr v1.4.3 // indirect + github.com/go-task/slim-sprig/v3 v3.0.0 // indirect + github.com/google/go-cmp v0.7.0 // indirect + github.com/google/pprof v0.0.0-20241210010833-40e02aabc2ad // indirect + github.com/sirupsen/logrus v1.9.4 // indirect + golang.org/x/net v0.52.0 // indirect + golang.org/x/sys v0.42.0 // indirect + golang.org/x/text v0.35.0 // indirect + golang.org/x/tools v0.42.0 // indirect + gopkg.in/yaml.v3 v3.0.1 // indirect +) diff --git a/images/dvcr/dvcr-k8s-auth/go.sum b/images/dvcr/dvcr-k8s-auth/go.sum new file mode 100644 index 0000000000..6ef30bb181 --- /dev/null +++ b/images/dvcr/dvcr-k8s-auth/go.sum @@ -0,0 +1,41 @@ +github.com/AdaLogics/go-fuzz-headers v0.0.0-20221103172237-443f56ff4ba8 h1:d+pBUmsteW5tM87xmVXHZ4+LibHRFn40SPAoZJOg2ak= +github.com/AdaLogics/go-fuzz-headers v0.0.0-20221103172237-443f56ff4ba8/go.mod h1:i9fr2JpcEcY/IHEvzCM3qXUZYOQHgR89dt4es1CgMhc= +github.com/cyphar/filepath-securejoin v0.2.4 h1:Ugdm7cg7i6ZK6x3xDF1oEu1nfkyfH53EtKeQYTC3kyg= +github.com/cyphar/filepath-securejoin v0.2.4/go.mod h1:aPGpWjXOXUn2NCNjFvBE6aRxGGx79pTxQpKOJNYHHl4= +github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM= +github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/distribution/distribution/v3 v3.1.1 h1:KUbk7C8CfaLXy8kbf/hGq9cad/wCoLB6dbWH6DMbmX0= +github.com/distribution/distribution/v3 v3.1.1/go.mod h1:d7lXwZpph0bVcOj4Aqn0nMrWHIwRQGdiV5TLeI+/w6Y= +github.com/go-jose/go-jose/v4 v4.1.4 h1:moDMcTHmvE6Groj34emNPLs/qtYXRVcd6S7NHbHz3kA= +github.com/go-jose/go-jose/v4 v4.1.4/go.mod h1:x4oUasVrzR7071A4TnHLGSPpNOm2a21K9Kf04k1rs08= +github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI= +github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= +github.com/go-task/slim-sprig/v3 v3.0.0 h1:sUs3vkvUymDpBKi3qH1YSqBQk9+9D/8M2mN1vB6EwHI= +github.com/go-task/slim-sprig/v3 v3.0.0/go.mod h1:W848ghGpv3Qj3dhTPRyJypKRiqCdHZiAzKg9hl15HA8= +github.com/golang-jwt/jwt/v5 v5.3.0 h1:pv4AsKCKKZuqlgs5sUmn4x8UlGa0kEVt/puTpKx9vvo= +github.com/golang-jwt/jwt/v5 v5.3.0/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE= +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/google/pprof v0.0.0-20241210010833-40e02aabc2ad h1:a6HEuzUHeKH6hwfN/ZoQgRgVIWFJljSWa/zetS2WTvg= +github.com/google/pprof v0.0.0-20241210010833-40e02aabc2ad/go.mod h1:vavhavw2zAxS5dIdcRluK6cSGGPlZynqzFM8NdvU144= +github.com/onsi/ginkgo/v2 v2.23.3 h1:edHxnszytJ4lD9D5Jjc4tiDkPBZ3siDeJJkUZJJVkp0= +github.com/onsi/ginkgo/v2 v2.23.3/go.mod h1:zXTP6xIp3U8aVuXN8ENK9IXRaTjFnpVB9mGmaSRvxnM= +github.com/onsi/gomega v1.37.0 h1:CdEG8g0S133B4OswTDC/5XPSzE1OeP29QOioj2PID2Y= +github.com/onsi/gomega v1.37.0/go.mod h1:8D9+Txp43QWKhM24yyOBEdpkzN8FvJyAwecBgsU4KU0= +github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U= +github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/sirupsen/logrus v1.9.4 h1:TsZE7l11zFCLZnZ+teH4Umoq5BhEIfIzfRDZ1Uzql2w= +github.com/sirupsen/logrus v1.9.4/go.mod h1:ftWc9WdOfJ0a92nsE2jF5u5ZwH8Bv2zdeOC42RjbV2g= +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/net v0.52.0 h1:He/TN1l0e4mmR3QqHMT2Xab3Aj3L9qjbhRm78/6jrW0= +golang.org/x/net v0.52.0/go.mod h1:R1MAz7uMZxVMualyPXb+VaqGSa3LIaUqk0eEt3w36Sw= +golang.org/x/sys v0.42.0 h1:omrd2nAlyT5ESRdCLYdm3+fMfNFE/+Rf4bDIQImRJeo= +golang.org/x/sys v0.42.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= +golang.org/x/text v0.35.0 h1:JOVx6vVDFokkpaq1AEptVzLTpDe9KGpj5tR4/X+ybL8= +golang.org/x/text v0.35.0/go.mod h1:khi/HExzZJ2pGnjenulevKNX1W67CUy0AsXcNubPGCA= +golang.org/x/tools v0.42.0 h1:uNgphsn75Tdz5Ji2q36v/nsFSfR/9BRFvqhGBaJGd5k= +golang.org/x/tools v0.42.0/go.mod h1:Ma6lCIwGZvHK6XtgbswSoWroEkhugApmsXyrUmBhfr0= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/images/dvcr/dvcr-k8s-auth/policy.go b/images/dvcr/dvcr-k8s-auth/policy.go new file mode 100644 index 0000000000..60b8ea03ad --- /dev/null +++ b/images/dvcr/dvcr-k8s-auth/policy.go @@ -0,0 +1,125 @@ +/* +Copyright 2026 Flant JSC + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +// Package dvcrk8s implements the DVCR multi-tenant authorization policy. +// +// This file holds the pure, dependency-free authorization decision logic so it +// can be unit-tested without the distribution registry runtime. The distribution +// auth.AccessController glue (Basic parsing, JWT verification) lives in access.go +// and only translates registry types into the Subject/Access values decided here. +package dvcrk8s + +import ( + "path" + "strings" +) + +// Role is the authorization role derived from the presented credential. +type Role int + +const ( + // RoleNone denies everything (fail-closed default). + RoleNone Role = iota + // RoleAdmin grants full access. Presented by the virtualization-controller + // with the static read-write password. + RoleAdmin + // RolePuller grants pull-only access to any repository. Presented by node + // containerd with the static node-puller password. + RolePuller + // RoleScoped grants exactly the access carried in the credential. Presented by + // importer/uploader Pods with a signed JWT the controller minted for the single + // repository that Pod reads from / writes to. + RoleScoped +) + +// Subject is the authenticated caller together with its authorization scope. +type Subject struct { + Role Role + // Grants is the set of allowed repository actions carried by a RoleScoped + // credential (the JWT's access claim). Ignored for other roles. + Grants []Grant +} + +// Access mirrors the subset of distribution's auth.Access needed for a single +// authorization decision (one requested resource + action). +type Access struct { + // Type is the resource type: "repository" or "registry". + Type string + // Name is the repository path without tag (e.g. "vi/ns/name"), or "catalog" + // for the registry-wide catalog resource. + Name string + // Action is "pull", "push", "delete" or "*". + Action string +} + +// Grant is one entry of a RoleScoped credential's access claim: a resource with +// the set of actions permitted on it. Mirrors distribution's token ResourceActions. +type Grant struct { + Type string + Name string + Actions []string +} + +// Authorize returns true only if the subject is allowed every requested access. +// A single denied access denies the whole request (fail-closed). An empty access +// list is allowed (distribution uses it for unauthenticated capability probes). +func Authorize(s Subject, accesses []Access) bool { + for i := range accesses { + if !authorizeOne(s, accesses[i]) { + return false + } + } + return true +} + +func authorizeOne(s Subject, a Access) bool { + switch s.Role { + case RoleAdmin: + return true + case RolePuller: + // Nodes only ever pull image layers; never push or delete, never enumerate. + return a.Type == "repository" && a.Action == "pull" + case RoleScoped: + return grantsCover(s.Grants, a) + default: + return false + } +} + +// grantsCover reports whether any grant permits the requested access. Names are +// path-cleaned on both sides so trailing slashes or "." segments cannot smuggle a +// mismatch past an exact string compare. +func grantsCover(grants []Grant, a Access) bool { + name := cleanName(a.Name) + for i := range grants { + g := grants[i] + if g.Type != a.Type || cleanName(g.Name) != name { + continue + } + for _, act := range g.Actions { + if act == a.Action || act == "*" { + return true + } + } + } + return false +} + +// cleanName normalizes a resource name by anchoring path.Clean at root, so +// "cvi/foo/", "cvi/foo" and "cvi/./foo" compare equal and no "../" can escape. +func cleanName(name string) string { + return strings.TrimPrefix(path.Clean("/"+name), "/") +} diff --git a/images/dvcr/dvcr-k8s-auth/policy_test.go b/images/dvcr/dvcr-k8s-auth/policy_test.go new file mode 100644 index 0000000000..7c7b1c9cd6 --- /dev/null +++ b/images/dvcr/dvcr-k8s-auth/policy_test.go @@ -0,0 +1,99 @@ +/* +Copyright 2026 Flant JSC + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +// Plain stdlib table tests (not ginkgo): this package is dependency-free on +// purpose so it can be compiled into the upstream distribution binary without +// dragging test frameworks into that build. +package dvcrk8s + +import "testing" + +func repo(name, action string) Access { + return Access{Type: "repository", Name: name, Action: action} +} + +func grantRepo(name string, actions ...string) Grant { + return Grant{Type: "repository", Name: name, Actions: actions} +} + +func TestAuthorize(t *testing.T) { + // A Pod scoped to its own cvi repository (the CVI-from-PVC case). + scopedCVI := Subject{Role: RoleScoped, Grants: []Grant{grantRepo("cvi/my-image", "pull", "push")}} + // A Pod scoped to a namespaced vd plus a DVCR source it pulls from. + scopedVD := Subject{Role: RoleScoped, Grants: []Grant{ + grantRepo("vd/nsA/disk", "pull", "push"), + grantRepo("cvi/base", "pull"), + }} + + tests := []struct { + name string + subject Subject + accesses []Access + want bool + }{ + // --- scoped: exactly the granted repo/action --- + {"scoped push own cvi", scopedCVI, []Access{repo("cvi/my-image", "push")}, true}, + {"scoped pull own cvi", scopedCVI, []Access{repo("cvi/my-image", "pull")}, true}, + {"scoped delete own cvi denied", scopedCVI, []Access{repo("cvi/my-image", "delete")}, false}, + + // --- scoped: any other repository is denied (the core guarantee) --- + {"scoped push other cvi denied", scopedCVI, []Access{repo("cvi/other", "push")}, false}, + {"scoped push tenant repo denied", scopedCVI, []Access{repo("vd/nsB/disk", "push")}, false}, + {"scoped prefix confusion denied", scopedCVI, []Access{repo("cvi/my-image-evil", "push")}, false}, + {"scoped traversal denied", scopedCVI, []Access{repo("cvi/my-image/../other", "push")}, false}, + {"scoped catalog denied", scopedCVI, []Access{{Type: "registry", Name: "catalog", Action: "*"}}, false}, + + // --- scoped: name normalization compares equal --- + {"scoped trailing slash ok", scopedCVI, []Access{repo("cvi/my-image/", "push")}, true}, + + // --- scoped: destination push + source pull (cross-repo, both granted) --- + {"scoped push dst pull src ok", scopedVD, []Access{ + repo("vd/nsA/disk", "push"), + repo("cvi/base", "pull"), + }, true}, + {"scoped push src denied", scopedVD, []Access{repo("cvi/base", "push")}, false}, + {"scoped mixed one bad denies all", scopedVD, []Access{ + repo("vd/nsA/disk", "push"), + repo("cvi/other", "pull"), + }, false}, + + // --- puller (nodes) --- + {"puller pull any ok", Subject{Role: RolePuller}, []Access{repo("vi/nsB/img", "pull")}, true}, + {"puller pull cvi ok", Subject{Role: RolePuller}, []Access{repo("cvi/ubuntu", "pull")}, true}, + {"puller push denied", Subject{Role: RolePuller}, []Access{repo("vi/nsB/img", "push")}, false}, + {"puller delete denied", Subject{Role: RolePuller}, []Access{repo("vi/nsB/img", "delete")}, false}, + {"puller catalog denied", Subject{Role: RolePuller}, []Access{{Type: "registry", Name: "catalog", Action: "*"}}, false}, + + // --- admin (controller) --- + {"admin all repo ok", Subject{Role: RoleAdmin}, []Access{repo("vi/nsB/img", "push"), repo("cvi/x", "delete")}, true}, + {"admin catalog ok", Subject{Role: RoleAdmin}, []Access{{Type: "registry", Name: "catalog", Action: "*"}}, true}, + + // --- fail-closed default --- + {"none denied", Subject{Role: RoleNone}, []Access{repo("cvi/x", "pull")}, false}, + {"scoped no grants denied", Subject{Role: RoleScoped}, []Access{repo("cvi/x", "pull")}, false}, + + // --- empty access list is allowed (capability probe) --- + {"empty access allowed", scopedCVI, nil, true}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := Authorize(tt.subject, tt.accesses); got != tt.want { + t.Errorf("Authorize(%+v, %+v) = %v, want %v", tt.subject, tt.accesses, got, tt.want) + } + }) + } +} diff --git a/images/dvcr/registry-main/main.go b/images/dvcr/registry-main/main.go new file mode 100644 index 0000000000..21363b2b70 --- /dev/null +++ b/images/dvcr/registry-main/main.go @@ -0,0 +1,47 @@ +/* +Copyright 2026 Flant JSC + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +// Command dvcr-registry is the distribution registry entrypoint for DVCR. It is +// upstream cmd/registry plus a blank import of the dvcr-k8s auth plugin, so the +// same binary serves both htpasswd (feature flag off) and dvcr-k8s multi-tenant +// authorization (flag on). Built into a binary named "registry" so the existing +// consumers (dvcr-cleaner's `registry garbage-collect`, the final image) are +// unchanged. This file is copied into the distribution module at build time. +package main + +import ( + _ "net/http/pprof" + + "github.com/distribution/distribution/v3/registry" + _ "github.com/distribution/distribution/v3/registry/auth/dvcrk8s" + _ "github.com/distribution/distribution/v3/registry/auth/htpasswd" + _ "github.com/distribution/distribution/v3/registry/auth/silly" + _ "github.com/distribution/distribution/v3/registry/auth/token" + _ "github.com/distribution/distribution/v3/registry/proxy" + _ "github.com/distribution/distribution/v3/registry/storage/driver/azure" + _ "github.com/distribution/distribution/v3/registry/storage/driver/filesystem" + _ "github.com/distribution/distribution/v3/registry/storage/driver/gcs" + _ "github.com/distribution/distribution/v3/registry/storage/driver/inmemory" + _ "github.com/distribution/distribution/v3/registry/storage/driver/middleware/cloudfront" + _ "github.com/distribution/distribution/v3/registry/storage/driver/middleware/redirect" + _ "github.com/distribution/distribution/v3/registry/storage/driver/middleware/rewrite" + _ "github.com/distribution/distribution/v3/registry/storage/driver/s3-aws" +) + +func main() { + // nolint:errcheck // RootCmd halts the program on failure of its subcommands. + registry.RootCmd.Execute() +} diff --git a/images/dvcr/werf.inc.yaml b/images/dvcr/werf.inc.yaml index a80724e9fc..35b50c45fe 100644 --- a/images/dvcr/werf.inc.yaml +++ b/images/dvcr/werf.inc.yaml @@ -48,6 +48,25 @@ import: add: /src/distribution to: /distribution before: install +git: +# DVCR multi-tenant authorization plugin sources, compiled into the registry +# binary (see images/dvcr/dvcr-k8s-auth and images/dvcr/registry-main). +- add: {{ .ModuleDir }}/images/{{ .ImageName }}/dvcr-k8s-auth + to: /plugin/dvcrk8s + includePaths: + - "*.go" + excludePaths: + - "*_test.go" + stageDependencies: + install: + - "**/*.go" +- add: {{ .ModuleDir }}/images/{{ .ImageName }}/registry-main + to: /plugin/registry-main + includePaths: + - "*.go" + stageDependencies: + install: + - "**/*.go" secrets: - id: GOPROXY value: {{ .GOPROXY }} @@ -58,19 +77,19 @@ shell: export GOOS=linux export GOARCH=amd64 export CGO_ENABLED=0 - - # Docker distribution v2.8.3 is not a go module, backoff to GOPATH build. Remove these lines on migration to distribution v3.0.0. - export GOPATH=$(go env GOPATH) - export GOROOT=$(go env GOROOT) - export GO111MODULE=off + export GO111MODULE=on mkdir -p /container-registry-binary - mkdir -p $GOPATH/src/github.com/docker + cd /distribution - cd $GOPATH/src/github.com/docker - mv /distribution . - cd distribution + # Graft the DVCR auth plugin and its entrypoint into the distribution module. + # The plugin depends only on the distribution auth package, go-jose/v4 (already + # a direct dependency of distribution v3) and the standard library, so no go.mod + # changes are required. + mkdir -p /distribution/registry/auth/dvcrk8s /distribution/cmd/dvcr-registry + cp /plugin/dvcrk8s/*.go /distribution/registry/auth/dvcrk8s/ + cp /plugin/registry-main/*.go /distribution/cmd/dvcr-registry/ export VERSION={{ $version }} {{- $_ := set $ "ProjectName" (list .ImageName "dvcr" | join "/") }} - {{- include "image-build.build" (set $ "BuildCommand" `go build -o /container-registry-binary/ -ldflags '-s -w -X registry/version.Version=v$VERSION -X registry/version.Revision=v$VERSION' ./cmd/registry`) | nindent 6 }} + {{- include "image-build.build" (set $ "BuildCommand" `go build -tags dvcr_registry -o /container-registry-binary/registry -ldflags '-s -w -X github.com/distribution/distribution/v3/version.version=v$VERSION -X github.com/distribution/distribution/v3/version.revision=v$VERSION -X github.com/distribution/distribution/v3/version.mainpkg=github.com/distribution/distribution/v3' ./cmd/dvcr-registry`) | nindent 6 }} diff --git a/images/hooks/go.mod b/images/hooks/go.mod index e88c332ea2..da2d916f61 100644 --- a/images/hooks/go.mod +++ b/images/hooks/go.mod @@ -44,6 +44,7 @@ require ( github.com/go-task/slim-sprig/v3 v3.0.0 // indirect github.com/gogo/protobuf v1.3.2 // indirect github.com/gojuno/minimock/v3 v3.4.5 // indirect + github.com/golang-jwt/jwt/v5 v5.2.2 // indirect github.com/google/btree v1.1.3 // indirect github.com/google/certificate-transparency-go v1.1.7 // indirect github.com/google/gnostic-models v0.7.0 // indirect diff --git a/images/hooks/go.sum b/images/hooks/go.sum index fcb300fc87..55b1a059bf 100644 --- a/images/hooks/go.sum +++ b/images/hooks/go.sum @@ -110,6 +110,8 @@ github.com/gogo/protobuf v1.3.2 h1:Ov1cvc58UF3b5XjBnZv7+opcTcQFZebYjWzi34vdm4Q= github.com/gogo/protobuf v1.3.2/go.mod h1:P1XiOD3dCwIKUDQYPy72D8LYyHL2YPYrpS2s69NZV8Q= github.com/gojuno/minimock/v3 v3.4.5 h1:Jcb0tEYZvVlQNtAAYpg3jCOoSwss2c1/rNugYTzj304= github.com/gojuno/minimock/v3 v3.4.5/go.mod h1:o9F8i2IT8v3yirA7mmdpNGzh1WNesm6iQakMtQV6KiE= +github.com/golang-jwt/jwt/v5 v5.2.2 h1:Rl4B7itRWVtYIHFrSNd7vhTiz9UpLdi6gZhZ3wEeDy8= +github.com/golang-jwt/jwt/v5 v5.2.2/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk= github.com/golang/glog v0.0.0-20160126235308-23def4e6c14b/go.mod h1:SBH7ygxi8pfUlaOkMMuAQtPIUF8ecWP5IEl/CR7VP2Q= github.com/golang/mock v1.1.1/go.mod h1:oTYuIxOrZwtPieC+H1uAHpcLFnEyAGVDL/k47Jfbm0A= github.com/golang/protobuf v1.2.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= diff --git a/images/hooks/pkg/hooks/generate-secret-for-dvcr/hook.go b/images/hooks/pkg/hooks/generate-secret-for-dvcr/hook.go index a64c2deb4d..536a254421 100644 --- a/images/hooks/pkg/hooks/generate-secret-for-dvcr/hook.go +++ b/images/hooks/pkg/hooks/generate-secret-for-dvcr/hook.go @@ -18,11 +18,15 @@ package generate_secret_for_dvcr import ( "context" + "crypto/ecdsa" + "crypto/elliptic" + crand "crypto/rand" + "crypto/x509" "encoding/base64" + "encoding/pem" "fmt" - "math/rand/v2" + "math/big" "strings" - "time" "golang.org/x/crypto/bcrypt" "k8s.io/utils/ptr" @@ -33,17 +37,23 @@ import ( ) const ( - dvcrSecrets = "dvcr-secrets" - passwordRWValuePath = "virtualization.internal.dvcr.passwordRW" - saltValuePath = "virtualization.internal.dvcr.salt" - htpasswdValuePath = "virtualization.internal.dvcr.htpasswd" - user = "admin" + dvcrSecrets = "dvcr-secrets" + passwordRWValuePath = "virtualization.internal.dvcr.passwordRW" + passwordROValuePath = "virtualization.internal.dvcr.passwordRO" + saltValuePath = "virtualization.internal.dvcr.salt" + htpasswdValuePath = "virtualization.internal.dvcr.htpasswd" + tokenPrivateKeyValuePath = "virtualization.internal.dvcr.tokenPrivateKey" + tokenPublicKeyValuePath = "virtualization.internal.dvcr.tokenPublicKey" + user = "admin" ) type dvcrSecretData struct { - PasswordRW string `json:"passwordRW"` - Salt string `json:"salt"` - Htpasswd string `json:"htpasswd"` + PasswordRW string `json:"passwordRW"` + PasswordRO string `json:"passwordRO"` + Salt string `json:"salt"` + Htpasswd string `json:"htpasswd"` + TokenPrivateKey string `json:"tokenPrivateKey"` + TokenPublicKey string `json:"tokenPublicKey"` } var _ = registry.RegisterFunc(configDVCRSecrets, handlerDVCRSecrets) @@ -101,6 +111,22 @@ func handlerDVCRSecrets(ctx context.Context, input *pkg.HookInput) error { input.Values.Set(passwordRWValuePath, passwordRW) } + // passwordRO is the pull-only node-puller credential used by the dvcr-k8s + // authorization backend. It carries no push rights, so it does not need an + // htpasswd entry (nodes fall back to admin only when the backend is htpasswd). + passwordRO := dataFromSecret.PasswordRO + passwordROBytes, err := base64.StdEncoding.DecodeString(passwordRO) + passwordRODecoded := string(passwordROBytes) + if err != nil || passwordRODecoded == "" { + input.Logger.Info("Regenerate PasswordRO") + passwordRODecoded = alphaNum(32) + passwordRO = base64.StdEncoding.EncodeToString([]byte(passwordRODecoded)) + } + if passwordRO != dataFromValues.PasswordRO { + input.Logger.Info("Set PasswordRO to values") + input.Values.Set(passwordROValuePath, passwordRO) + } + salt := dataFromSecret.Salt saltBytes, err := base64.StdEncoding.DecodeString(salt) saltDecoded := string(saltBytes) @@ -130,6 +156,31 @@ func handlerDVCRSecrets(ctx context.Context, input *pkg.HookInput) error { input.Values.Set(htpasswdValuePath, htpasswd) } + // tokenPrivateKey/tokenPublicKey is the ECDSA keypair used to mint and verify + // scoped DVCR tokens when per-namespace authorization is on. Regenerate the + // pair whenever the private key is missing or unparseable. + tokenPrivateKey := dataFromSecret.TokenPrivateKey + tokenPublicKey := dataFromSecret.TokenPublicKey + privBytes, err := base64.StdEncoding.DecodeString(tokenPrivateKey) + pubBytes, pubErr := base64.StdEncoding.DecodeString(tokenPublicKey) + if err != nil || pubErr != nil || !validateECKeypair(privBytes, pubBytes) { + input.Logger.Info("Regenerate DVCR token keypair") + privPEM, pubPEM, genErr := generateECKeypair() + if genErr != nil { + return fmt.Errorf("generate DVCR token keypair: %w", genErr) + } + tokenPrivateKey = base64.StdEncoding.EncodeToString(privPEM) + tokenPublicKey = base64.StdEncoding.EncodeToString(pubPEM) + } + if tokenPrivateKey != dataFromValues.TokenPrivateKey { + input.Logger.Info("Set DVCR token private key to values") + input.Values.Set(tokenPrivateKeyValuePath, tokenPrivateKey) + } + if tokenPublicKey != dataFromValues.TokenPublicKey { + input.Logger.Info("Set DVCR token public key to values") + input.Values.Set(tokenPublicKeyValuePath, tokenPublicKey) + } + return nil } @@ -150,10 +201,72 @@ func getDVCRSecretsFromSecrets(input *pkg.HookInput) (dvcrSecretData, error) { func getDVCRSecretsFromValues(input *pkg.HookInput) dvcrSecretData { return dvcrSecretData{ - PasswordRW: input.Values.Get(passwordRWValuePath).String(), - Salt: input.Values.Get(saltValuePath).String(), - Htpasswd: input.Values.Get(htpasswdValuePath).String(), + PasswordRW: input.Values.Get(passwordRWValuePath).String(), + PasswordRO: input.Values.Get(passwordROValuePath).String(), + Salt: input.Values.Get(saltValuePath).String(), + Htpasswd: input.Values.Get(htpasswdValuePath).String(), + TokenPrivateKey: input.Values.Get(tokenPrivateKeyValuePath).String(), + TokenPublicKey: input.Values.Get(tokenPublicKeyValuePath).String(), + } +} + +// generateECKeypair returns a fresh ECDSA P-256 keypair as PKCS8/PKIX PEM. The +// private key mints scoped DVCR tokens (controller); the public key verifies them +// (registry). +func generateECKeypair() (privPEM, pubPEM []byte, err error) { + key, err := ecdsa.GenerateKey(elliptic.P256(), crand.Reader) + if err != nil { + return nil, nil, err + } + privDER, err := x509.MarshalPKCS8PrivateKey(key) + if err != nil { + return nil, nil, err + } + pubDER, err := x509.MarshalPKIXPublicKey(&key.PublicKey) + if err != nil { + return nil, nil, err + } + privPEM = pem.EncodeToMemory(&pem.Block{Type: "PRIVATE KEY", Bytes: privDER}) + pubPEM = pem.EncodeToMemory(&pem.Block{Type: "PUBLIC KEY", Bytes: pubDER}) + return privPEM, pubPEM, nil +} + +// validateECKeypair reports whether privPEM parses as ECDSA and pubPEM is its +// matching public key. A bad public key (shipped to the registry) would reject +// every scoped token, so it must force regenerating the pair too. +func validateECKeypair(privPEM, pubPEM []byte) bool { + priv := parseECPrivateKey(privPEM) + if priv == nil { + return false + } + block, _ := pem.Decode(pubPEM) + if block == nil { + return false + } + pub, err := x509.ParsePKIXPublicKey(block.Bytes) + if err != nil { + return false } + ecPub, ok := pub.(*ecdsa.PublicKey) + return ok && ecPub.Equal(&priv.PublicKey) +} + +func parseECPrivateKey(pemBytes []byte) *ecdsa.PrivateKey { + block, _ := pem.Decode(pemBytes) + if block == nil { + return nil + } + if k, err := x509.ParsePKCS8PrivateKey(block.Bytes); err == nil { + if ec, ok := k.(*ecdsa.PrivateKey); ok { + return ec + } + return nil + } + ec, err := x509.ParseECPrivateKey(block.Bytes) + if err != nil { + return nil + } + return ec } func generateHtpasswd(password string) (string, error) { @@ -184,11 +297,14 @@ const letterBytes = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456 //nolint:unparam // we need to pass the length func alphaNum(length int) string { - rnd := rand.New(rand.NewPCG(uint64(time.Now().UnixNano()), 0)) - + max := big.NewInt(int64(len(letterBytes))) b := make([]byte, length) for i := range b { - b[i] = letterBytes[rnd.IntN(len(letterBytes))] + n, err := crand.Int(crand.Reader, max) + if err != nil { + panic(fmt.Sprintf("generate random password: %v", err)) + } + b[i] = letterBytes[n.Int64()] } return string(b) } diff --git a/images/hooks/pkg/hooks/generate-secret-for-dvcr/hook_test.go b/images/hooks/pkg/hooks/generate-secret-for-dvcr/hook_test.go index 58abc79085..61cc63294c 100644 --- a/images/hooks/pkg/hooks/generate-secret-for-dvcr/hook_test.go +++ b/images/hooks/pkg/hooks/generate-secret-for-dvcr/hook_test.go @@ -38,36 +38,78 @@ func TestSetDVCRSecrets(t *testing.T) { RunSpecs(t, "DVCR Secrets Suite") } +var _ = Describe("validateECKeypair", func() { + It("accepts a matching pair", func() { + priv, pub, err := generateECKeypair() + Expect(err).ToNot(HaveOccurred()) + Expect(validateECKeypair(priv, pub)).To(BeTrue()) + }) + + It("rejects a public key from a different pair", func() { + priv, _, err := generateECKeypair() + Expect(err).ToNot(HaveOccurred()) + _, otherPub, err := generateECKeypair() + Expect(err).ToNot(HaveOccurred()) + Expect(validateECKeypair(priv, otherPub)).To(BeFalse()) + }) + + It("rejects a missing or corrupt public key", func() { + priv, _, err := generateECKeypair() + Expect(err).ToNot(HaveOccurred()) + Expect(validateECKeypair(priv, nil)).To(BeFalse()) + Expect(validateECKeypair(priv, []byte("not a pem"))).To(BeFalse()) + }) + + It("rejects a missing or corrupt private key", func() { + _, pub, err := generateECKeypair() + Expect(err).ToNot(HaveOccurred()) + Expect(validateECKeypair(nil, pub)).To(BeFalse()) + Expect(validateECKeypair([]byte("not a pem"), pub)).To(BeFalse()) + }) +}) + var _ = Describe("DVCR Secrets", func() { const ( defaultPasswordBase64 = "dkREU0I1N1JXeVVFd1NwN3VubDA3SHlnWUx3MzlOTlY=" // "vDDSB57RWyUEwSp7unl07HygYLw39NNV" + defaultPullerBase64 = "bm9kZVB1bGxlclNlY3JldDAxMjM0NTY3ODlhYmNkZWY=" // "nodePullerSecret0123456789abcdef" defaultSaltBase64 = "bldlM01vZjVySlFNc3I2MDVFNDdBM1pYOU9IQ1dnVkY=" // "nWe3Mof5rJQMsr605E47A3ZX9OHCWgVF" defaultHtpasswdBase64 = "YWRtaW46JDJhJDEwJHZza21wTjVLSERKUlpNU1pWd3RZWU91YmtEcTEueEF2MXVRQkkvSzFHQ1dpNUpxSnF5amdt" // "admin:$2a$10$vskmpN5KHDJRZMSZVwtYYOubkDq1.xAv1uQBI/K1GCWi5JqJqyjgm" ) var ( - dc *mock.DependencyContainerMock - snapshots *mock.SnapshotsMock - values *mock.OutputPatchableValuesCollectorMock + dc *mock.DependencyContainerMock + snapshots *mock.SnapshotsMock + values *mock.OutputPatchableValuesCollectorMock + validTokenKey string + validTokenPub string ) - prepareValuesGet := func(passwordRW, salt, htpasswd string) { + // prepareValuesGet mocks the values-side reads. The token keypair is always a + // valid pair matching the snapshot, so these tests never exercise keypair + // regeneration (its own coverage lives in signer_test.go and the hook helpers). + prepareValuesGet := func(passwordRW, passwordRO, salt, htpasswd string) { values.GetMock.When(passwordRWValuePath).Then(gjson.Result{Type: gjson.String, Str: passwordRW}) + values.GetMock.When(passwordROValuePath).Then(gjson.Result{Type: gjson.String, Str: passwordRO}) values.GetMock.When(saltValuePath).Then(gjson.Result{Type: gjson.String, Str: salt}) values.GetMock.When(htpasswdValuePath).Then(gjson.Result{Type: gjson.String, Str: htpasswd}) + values.GetMock.When(tokenPrivateKeyValuePath).Then(gjson.Result{Type: gjson.String, Str: validTokenKey}) + values.GetMock.When(tokenPublicKeyValuePath).Then(gjson.Result{Type: gjson.String, Str: validTokenPub}) } setSnapshots := func(snaps ...pkg.Snapshot) { snapshots.GetMock.When(dvcrSecrets).Then(snaps) } - newSnapshot := func(passwordRW, salt, htpasswd string) pkg.Snapshot { + newSnapshot := func(passwordRW, passwordRO, salt, htpasswd string) pkg.Snapshot { return mock.NewSnapshotMock(GinkgoT()).UnmarshalToMock.Set(func(v any) (err error) { data, ok := v.(*dvcrSecretData) Expect(ok).To(BeTrue()) data.PasswordRW = passwordRW + data.PasswordRO = passwordRO data.Salt = salt data.Htpasswd = htpasswd + data.TokenPrivateKey = validTokenKey + data.TokenPublicKey = validTokenPub return nil }) } @@ -91,6 +133,11 @@ var _ = Describe("DVCR Secrets", func() { snapshots = mock.NewSnapshotsMock(GinkgoT()) values = mock.NewPatchableValuesCollectorMock(GinkgoT()) values.GetMock.When(settings.InternalValuesConfigCopyPath).Then(gjson.Result{}) + + priv, pub, err := generateECKeypair() + Expect(err).ToNot(HaveOccurred()) + validTokenKey = base64.StdEncoding.EncodeToString(priv) + validTokenPub = base64.StdEncoding.EncodeToString(pub) }) AfterEach(func() { @@ -100,8 +147,8 @@ var _ = Describe("DVCR Secrets", func() { }) It("Should set secrets from secret to values", func() { - prepareValuesGet("", "", "") - setSnapshots(newSnapshot(defaultPasswordBase64, defaultSaltBase64, defaultHtpasswdBase64)) + prepareValuesGet("", "", "", "") + setSnapshots(newSnapshot(defaultPasswordBase64, defaultPullerBase64, defaultSaltBase64, defaultHtpasswdBase64)) values.SetMock.Set(func(path string, v any) { value, ok := v.(string) @@ -110,6 +157,8 @@ var _ = Describe("DVCR Secrets", func() { switch path { case passwordRWValuePath: Expect(value).To(Equal(defaultPasswordBase64)) + case passwordROValuePath: + Expect(value).To(Equal(defaultPullerBase64)) case saltValuePath: Expect(value).To(Equal(defaultSaltBase64)) case htpasswdValuePath: @@ -123,8 +172,8 @@ var _ = Describe("DVCR Secrets", func() { }) It("Should regenerate all secrets", func() { - prepareValuesGet("", "", "") - setSnapshots(newSnapshot("", "", "")) + prepareValuesGet("", "", "", "") + setSnapshots(newSnapshot("", "", "", "")) var ( passwordRW string @@ -144,6 +193,8 @@ var _ = Describe("DVCR Secrets", func() { case passwordRWValuePath: passwordRW = value Expect(value).To(HaveLen(32)) + case passwordROValuePath: + Expect(value).To(HaveLen(32)) case saltValuePath: Expect(value).To(HaveLen(32)) case htpasswdValuePath: @@ -159,8 +210,8 @@ var _ = Describe("DVCR Secrets", func() { }) It("Should regenerate only salt", func() { - prepareValuesGet(defaultPasswordBase64, "", defaultHtpasswdBase64) - setSnapshots(newSnapshot(defaultPasswordBase64, "", defaultHtpasswdBase64)) + prepareValuesGet(defaultPasswordBase64, defaultPullerBase64, "", defaultHtpasswdBase64) + setSnapshots(newSnapshot(defaultPasswordBase64, defaultPullerBase64, "", defaultHtpasswdBase64)) values.SetMock.Set(func(path string, v any) { valueBase64, ok := v.(string) @@ -183,8 +234,8 @@ var _ = Describe("DVCR Secrets", func() { }) DescribeTable("Should regenerate only password and htpasswd", func(passwordRW, htpasswd string) { - prepareValuesGet(passwordRW, "salt", htpasswd) - setSnapshots(newSnapshot(passwordRW, "salt", htpasswd)) + prepareValuesGet(passwordRW, defaultPullerBase64, "salt", htpasswd) + setSnapshots(newSnapshot(passwordRW, defaultPullerBase64, "salt", htpasswd)) bytes, err := base64.StdEncoding.DecodeString(passwordRW) Expect(err).ToNot(HaveOccurred()) diff --git a/images/virtualization-artifact/go.mod b/images/virtualization-artifact/go.mod index b12e2ad771..84fcea18bf 100644 --- a/images/virtualization-artifact/go.mod +++ b/images/virtualization-artifact/go.mod @@ -40,6 +40,8 @@ require ( sigs.k8s.io/yaml v1.6.0 ) +require github.com/golang-jwt/jwt/v5 v5.2.2 + require ( cel.dev/expr v0.25.1 // indirect github.com/Azure/go-ansiterm v0.0.0-20230124172434-306776ec8161 // indirect diff --git a/images/virtualization-artifact/pkg/config/load_dvcr_settings.go b/images/virtualization-artifact/pkg/config/load_dvcr_settings.go index 5deadedef3..2d5fc2bc74 100644 --- a/images/virtualization-artifact/pkg/config/load_dvcr_settings.go +++ b/images/virtualization-artifact/pkg/config/load_dvcr_settings.go @@ -21,6 +21,7 @@ import ( "os" "github.com/deckhouse/virtualization-controller/pkg/dvcr" + "github.com/deckhouse/virtualization-controller/pkg/dvcr/registrytoken" ) const ( @@ -40,6 +41,9 @@ const ( DVCRImageMonitorScheduleVar = "DVCR_IMAGE_MONITOR_SCHEDULE" // DVCRGCScheduleVar is an env variable holds the cron schedule to run DVCR garbage collection. DVCRGCScheduleVar = "DVCR_GC_SCHEDULE" + // DVCRTokenPrivateKeyVar holds the PEM ECDSA private key used to mint scoped + // per-namespace DVCR tokens. + DVCRTokenPrivateKeyVar = "DVCR_TOKEN_PRIVATE_KEY" // UploaderIngressHostVar is a env variable UploaderIngressHostVar = "UPLOADER_INGRESS_HOST" @@ -89,5 +93,15 @@ func LoadDVCRSettingsFromEnvs(controllerNamespace string) (*dvcr.Settings, error dvcrSettings.GCSchedule = dvcr.DefaultGCSchedule } + keyPEM := os.Getenv(DVCRTokenPrivateKeyVar) + if keyPEM == "" { + return nil, fmt.Errorf("environment variable %q undefined, specify DVCR settings", DVCRTokenPrivateKeyVar) + } + signer, err := registrytoken.NewSignerFromPEM([]byte(keyPEM)) + if err != nil { + return nil, fmt.Errorf("init DVCR token signer: %w", err) + } + dvcrSettings.TokenSigner = signer + return dvcrSettings, nil } diff --git a/images/virtualization-artifact/pkg/controller/importer/settings.go b/images/virtualization-artifact/pkg/controller/importer/settings.go index 3560c7884e..98605ea1c7 100644 --- a/images/virtualization-artifact/pkg/controller/importer/settings.go +++ b/images/virtualization-artifact/pkg/controller/importer/settings.go @@ -69,11 +69,7 @@ type Settings struct { } func ApplyDVCRDestinationSettings(podEnvVars *Settings, dvcrSettings *dvcr.Settings, supGen supplements.Generator, dvcrImageName string) { - authSecret := dvcrSettings.AuthSecret - if supplements.ShouldCopyDVCRAuthSecret(dvcrSettings, supGen) { - authSecret = supGen.DVCRAuthSecret().Name - } - podEnvVars.DestinationAuthSecret = authSecret + podEnvVars.DestinationAuthSecret = supGen.DVCRAuthSecret().Name podEnvVars.DestinationInsecureTLS = dvcrSettings.InsecureTLS podEnvVars.DestinationEndpoint = dvcrImageName } diff --git a/images/virtualization-artifact/pkg/controller/service/disk_service.go b/images/virtualization-artifact/pkg/controller/service/disk_service.go index eae8af0931..ee6651540b 100644 --- a/images/virtualization-artifact/pkg/controller/service/disk_service.go +++ b/images/virtualization-artifact/pkg/controller/service/disk_service.go @@ -123,7 +123,7 @@ func (s DiskService) Start( return nil } - return supplements.EnsureForDataVolume(ctx, s.client, sup, dvBuilder.GetResource(), s.dvcrSettings) + return supplements.EnsureForDataVolume(ctx, s.client, sup, dvBuilder.GetResource(), s.dvcrSettings, dataVolumeTokenScope(s.dvcrSettings, source)) } func (s DiskService) GetVolumeAndAccessModes(ctx context.Context, obj client.Object, sc *storagev1.StorageClass) (corev1.PersistentVolumeMode, corev1.PersistentVolumeAccessMode, error) { @@ -163,7 +163,7 @@ func (s DiskService) StartImmediate( return nil } - return supplements.EnsureForDataVolume(ctx, s.client, dataVolumeSupplement, dvBuilder.GetResource(), s.dvcrSettings) + return supplements.EnsureForDataVolume(ctx, s.client, dataVolumeSupplement, dvBuilder.GetResource(), s.dvcrSettings, dataVolumeTokenScope(s.dvcrSettings, source)) } func (s DiskService) CheckProvisioning(ctx context.Context, pvc *corev1.PersistentVolumeClaim) error { diff --git a/images/virtualization-artifact/pkg/controller/service/dvcr_token_scope.go b/images/virtualization-artifact/pkg/controller/service/dvcr_token_scope.go new file mode 100644 index 0000000000..c3ec5b0d4e --- /dev/null +++ b/images/virtualization-artifact/pkg/controller/service/dvcr_token_scope.go @@ -0,0 +1,56 @@ +/* +Copyright 2026 Flant JSC + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package service + +import ( + cdiv1 "kubevirt.io/containerized-data-importer-api/pkg/apis/core/v1beta1" + + "github.com/deckhouse/virtualization-controller/pkg/controller/importer" + "github.com/deckhouse/virtualization-controller/pkg/controller/uploader" + "github.com/deckhouse/virtualization-controller/pkg/dvcr" + "github.com/deckhouse/virtualization-controller/pkg/dvcr/registrytoken" +) + +// importerTokenScope is the DVCR access an importer Pod needs: push+pull on its +// destination repository, plus pull on the source repository when the source is +// itself a DVCR image (dvcr-artifact copies both through the same credential). +func importerTokenScope(s *dvcr.Settings, settings *importer.Settings) []registrytoken.Access { + access := []registrytoken.Access{repoAccess(s.RepoPath(settings.DestinationEndpoint), "pull", "push")} + if settings.Source == importer.SourceDVCR && settings.Endpoint != "" { + access = append(access, repoAccess(s.RepoPath(settings.Endpoint), "pull")) + } + return access +} + +// uploaderTokenScope is the DVCR access an uploader Pod needs: push+pull on its +// destination repository (an uploader has no DVCR source). +func uploaderTokenScope(s *dvcr.Settings, settings *uploader.Settings) []registrytoken.Access { + return []registrytoken.Access{repoAccess(s.RepoPath(settings.DestinationEndpoint), "pull", "push")} +} + +// dataVolumeTokenScope is the DVCR access a CDI DataVolume needs: pull-only on the +// source repository it imports from (it writes to a PVC, not to DVCR). +func dataVolumeTokenScope(s *dvcr.Settings, source *cdiv1.DataVolumeSource) []registrytoken.Access { + if source == nil || source.Registry == nil || source.Registry.URL == nil { + return nil + } + return []registrytoken.Access{repoAccess(s.RepoPath(*source.Registry.URL), "pull")} +} + +func repoAccess(name string, actions ...string) registrytoken.Access { + return registrytoken.Access{Type: "repository", Name: name, Actions: actions} +} diff --git a/images/virtualization-artifact/pkg/controller/service/importer_service.go b/images/virtualization-artifact/pkg/controller/service/importer_service.go index a8478837d3..13e23b09bc 100644 --- a/images/virtualization-artifact/pkg/controller/service/importer_service.go +++ b/images/virtualization-artifact/pkg/controller/service/importer_service.go @@ -94,7 +94,7 @@ func (s ImporterService) Start( return fmt.Errorf("failed to create NetworkPolicy: %w", err) } - return supplements.EnsureForPod(ctx, s.client, sup, pod, caBundle, s.dvcrSettings) + return supplements.EnsureForPod(ctx, s.client, sup, pod, caBundle, s.dvcrSettings, importerTokenScope(s.dvcrSettings, settings)) } func (s ImporterService) StartWithPodSetting( @@ -118,7 +118,7 @@ func (s ImporterService) StartWithPodSetting( return err } - return supplements.EnsureForPod(ctx, s.client, sup, pod, caBundle, s.dvcrSettings) + return supplements.EnsureForPod(ctx, s.client, sup, pod, caBundle, s.dvcrSettings, importerTokenScope(s.dvcrSettings, settings)) } func (s ImporterService) CleanUp(ctx context.Context, sup supplements.Generator) (bool, error) { diff --git a/images/virtualization-artifact/pkg/controller/service/uploader_service.go b/images/virtualization-artifact/pkg/controller/service/uploader_service.go index 461f37e513..73335d983f 100644 --- a/images/virtualization-artifact/pkg/controller/service/uploader_service.go +++ b/images/virtualization-artifact/pkg/controller/service/uploader_service.go @@ -96,7 +96,7 @@ func (s UploaderService) Start( return fmt.Errorf("failed to create NetworkPolicy: %w", err) } - err = supplements.EnsureForPod(ctx, s.client, sup, pod, caBundle, s.dvcrSettings) + err = supplements.EnsureForPod(ctx, s.client, sup, pod, caBundle, s.dvcrSettings, uploaderTokenScope(s.dvcrSettings, settings)) if err != nil { return err } diff --git a/images/virtualization-artifact/pkg/controller/supplements/copier/auth_secret.go b/images/virtualization-artifact/pkg/controller/supplements/copier/auth_secret.go index cd461109c7..ccbc873d06 100644 --- a/images/virtualization-artifact/pkg/controller/supplements/copier/auth_secret.go +++ b/images/virtualization-artifact/pkg/controller/supplements/copier/auth_secret.go @@ -18,14 +18,31 @@ package copier import ( "context" + "encoding/base64" + "encoding/json" + "fmt" + "time" corev1 "k8s.io/api/core/v1" + k8serrors "k8s.io/apimachinery/pkg/api/errors" "sigs.k8s.io/controller-runtime/pkg/client" - "github.com/deckhouse/virtualization-controller/pkg/auth" - "github.com/deckhouse/virtualization-controller/pkg/common/object" + "github.com/deckhouse/virtualization-controller/pkg/dvcr/registrytoken" ) +// ScopedTokenUsername is the Basic-auth username presented alongside a scoped +// DVCR token. The registry authorizes on the token in the password; the username +// is a fixed, human-readable marker. +const ScopedTokenUsername = "dvcr-jwt" + +// scopedTokenExpiryAnnotation records the token expiry (RFC3339) so a reconcile +// re-mints only when it is close to expiring, not on every pass. +const scopedTokenExpiryAnnotation = "dvcr.deckhouse.io/scoped-token-expires-at" + +// scopedTokenRefreshThreshold: re-mint once validity drops below this. Reconciles +// run far more often than an hour, so a valid token stays available cheaply. +const scopedTokenRefreshThreshold = time.Hour + // AuthSecret copies auth credentials from the source Secret into // Destination Secret and ensure its data is CDI compatible: // type: Opaque @@ -39,33 +56,97 @@ type AuthSecret struct { Secret } -// CopyCDICompatible transforms auth credentials in dockerconfigjson format into CDI compatible: -// a Secret with two fields: accessKeyId and secretKey. -// ref is registry url or image name. It is used to select a desired auth pair from the config. -func (a AuthSecret) CopyCDICompatible(ctx context.Context, client client.Client, ref string) error { - srcObj, err := object.FetchObject(ctx, a.Source, client, &corev1.Secret{}) +// CreateScopedTokenCDI mints a scoped DVCR token for access and stores it in a +// CDI-compatible Opaque Secret (accessKeyId/secretKey). The importer Pod +// authenticates to DVCR with a credential scoped to exactly the repositories in +// access, instead of the shared read-write credential. +func (a AuthSecret) CreateScopedTokenCDI(ctx context.Context, client client.Client, signer *registrytoken.Signer, access []registrytoken.Access) error { + return a.ensureScopedToken(ctx, client, signer, access, func(raw string) (map[string][]byte, corev1.SecretType, error) { + return map[string][]byte{ + "accessKeyId": []byte(ScopedTokenUsername), + "secretKey": []byte(raw), + }, corev1.SecretTypeOpaque, nil + }) +} + +// CreateScopedTokenDockerConfig mints a scoped DVCR token for access and stores +// it as a dockerconfigjson Secret keyed by registryURL, so the dvcr-artifact +// importer/uploader Pod reads it through the standard destination auth config. +func (a AuthSecret) CreateScopedTokenDockerConfig(ctx context.Context, client client.Client, signer *registrytoken.Signer, access []registrytoken.Access, registryURL string) error { + return a.ensureScopedToken(ctx, client, signer, access, func(raw string) (map[string][]byte, corev1.SecretType, error) { + cfg, err := dockerConfigJSON(registryURL, ScopedTokenUsername, raw) + if err != nil { + return nil, "", err + } + return map[string][]byte{corev1.DockerConfigJsonKey: cfg}, corev1.SecretTypeDockerConfigJson, nil + }) +} + +// ensureScopedToken writes the destination Secret with a freshly minted token +// only when it is missing or near expiry, so an import that outlives one token +// gets a valid one on a later reconcile instead of a stale 403. +func (a AuthSecret) ensureScopedToken(ctx context.Context, client client.Client, signer *registrytoken.Signer, access []registrytoken.Access, build func(raw string) (map[string][]byte, corev1.SecretType, error)) error { + now := time.Now() + + existing := &corev1.Secret{} + err := client.Get(ctx, a.Destination, existing) + switch { + case err == nil: + if scopedTokenValidFor(existing, now) > scopedTokenRefreshThreshold { + return nil + } + case !k8serrors.IsNotFound(err): + return err + } + + raw, err := signer.Sign(access, now) + if err != nil { + return fmt.Errorf("mint scoped DVCR token: %w", err) + } + data, secretType, err := build(raw) if err != nil { return err } - destData := srcObj.Data - destType := srcObj.Type - if srcObj.Type == corev1.SecretTypeDockerConfigJson { - cfg, err := auth.ReadDockerConfigJSON(srcObj.Data[corev1.DockerConfigJsonKey]) - if err != nil { - return err - } - username, password, err := auth.CredsFromRegistryAuthFile(cfg, ref) - if err != nil { - return err + secret := a.makeSecret(data, secretType) + secret.Annotations[scopedTokenExpiryAnnotation] = now.Add(registrytoken.DefaultTTL).Format(time.RFC3339) + + if createErr := client.Create(ctx, secret); createErr != nil { + if !k8serrors.IsAlreadyExists(createErr) { + return createErr } - destData = map[string][]byte{ - "accessKeyId": []byte(username), - "secretKey": []byte(password), + existing.Data = secret.Data + if existing.Annotations == nil { + existing.Annotations = map[string]string{} } - destType = corev1.SecretTypeOpaque + existing.Annotations[scopedTokenExpiryAnnotation] = secret.Annotations[scopedTokenExpiryAnnotation] + return client.Update(ctx, existing) } + return nil +} + +// scopedTokenValidFor returns how long the Secret's scoped token stays valid, or +// zero if the expiry annotation is missing, unparseable, or already past. +func scopedTokenValidFor(secret *corev1.Secret, now time.Time) time.Duration { + raw := secret.Annotations[scopedTokenExpiryAnnotation] + if raw == "" { + return 0 + } + exp, err := time.Parse(time.RFC3339, raw) + if err != nil || exp.Before(now) { + return 0 + } + return exp.Sub(now) +} - _, err = a.Create(ctx, client, destData, destType) - return err +// dockerConfigJSON builds a minimal ~/.docker/config.json for a single registry. +func dockerConfigJSON(registryURL, username, password string) ([]byte, error) { + entry := map[string]string{ + "username": username, + "password": password, + "auth": base64.StdEncoding.EncodeToString([]byte(username + ":" + password)), + } + return json.Marshal(map[string]any{ + "auths": map[string]any{registryURL: entry}, + }) } diff --git a/images/virtualization-artifact/pkg/controller/supplements/copier/auth_secret_test.go b/images/virtualization-artifact/pkg/controller/supplements/copier/auth_secret_test.go new file mode 100644 index 0000000000..a32ffaae37 --- /dev/null +++ b/images/virtualization-artifact/pkg/controller/supplements/copier/auth_secret_test.go @@ -0,0 +1,123 @@ +/* +Copyright 2026 Flant JSC + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package copier + +import ( + "context" + "crypto/ecdsa" + "crypto/elliptic" + "crypto/rand" + "crypto/x509" + "encoding/pem" + "testing" + "time" + + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" + corev1 "k8s.io/api/core/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/runtime" + "k8s.io/apimachinery/pkg/types" + "sigs.k8s.io/controller-runtime/pkg/client/fake" + + "github.com/deckhouse/virtualization-controller/pkg/dvcr/registrytoken" +) + +func TestCopier(t *testing.T) { + RegisterFailHandler(Fail) + RunSpecs(t, "Copier Suite") +} + +func newTestSigner() *registrytoken.Signer { + key, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) + Expect(err).ToNot(HaveOccurred()) + der, err := x509.MarshalPKCS8PrivateKey(key) + Expect(err).ToNot(HaveOccurred()) + signer, err := registrytoken.NewSignerFromPEM(pem.EncodeToMemory(&pem.Block{Type: "PRIVATE KEY", Bytes: der})) + Expect(err).ToNot(HaveOccurred()) + return signer +} + +var _ = Describe("Scoped token secret refresh", func() { + var ( + ctx context.Context + scheme *runtime.Scheme + signer *registrytoken.Signer + dest types.NamespacedName + access []registrytoken.Access + ) + + BeforeEach(func() { + ctx = context.Background() + scheme = runtime.NewScheme() + Expect(corev1.AddToScheme(scheme)).To(Succeed()) + signer = newTestSigner() + dest = types.NamespacedName{Name: "dvcr-auth", Namespace: "default"} + access = []registrytoken.Access{{Type: "repository", Name: "cvi/img", Actions: []string{"pull", "push"}}} + }) + + authSecret := func() AuthSecret { + return AuthSecret{Secret: Secret{Destination: dest}} + } + + It("creates the secret with an expiry annotation", func() { + c := fake.NewClientBuilder().WithScheme(scheme).Build() + Expect(authSecret().CreateScopedTokenCDI(ctx, c, signer, access)).To(Succeed()) + + got := &corev1.Secret{} + Expect(c.Get(ctx, dest, got)).To(Succeed()) + Expect(got.Data).To(HaveKey("secretKey")) + Expect(got.Annotations).To(HaveKey(scopedTokenExpiryAnnotation)) + }) + + It("does not re-mint while the token is still fresh", func() { + c := fake.NewClientBuilder().WithScheme(scheme).Build() + Expect(authSecret().CreateScopedTokenCDI(ctx, c, signer, access)).To(Succeed()) + first := &corev1.Secret{} + Expect(c.Get(ctx, dest, first)).To(Succeed()) + + Expect(authSecret().CreateScopedTokenCDI(ctx, c, signer, access)).To(Succeed()) + second := &corev1.Secret{} + Expect(c.Get(ctx, dest, second)).To(Succeed()) + Expect(second.Data["secretKey"]).To(Equal(first.Data["secretKey"])) + }) + + It("re-mints when the token is close to expiring", func() { + c := fake.NewClientBuilder().WithScheme(scheme).Build() + Expect(authSecret().CreateScopedTokenCDI(ctx, c, signer, access)).To(Succeed()) + stale := &corev1.Secret{} + Expect(c.Get(ctx, dest, stale)).To(Succeed()) + before := stale.Data["secretKey"] + + stale.Annotations[scopedTokenExpiryAnnotation] = time.Now().Add(time.Minute).Format(time.RFC3339) + Expect(c.Update(ctx, stale)).To(Succeed()) + + Expect(authSecret().CreateScopedTokenCDI(ctx, c, signer, access)).To(Succeed()) + after := &corev1.Secret{} + Expect(c.Get(ctx, dest, after)).To(Succeed()) + Expect(after.Data["secretKey"]).ToNot(Equal(before)) + }) + + It("scopedTokenValidFor returns zero for missing, unparseable or past expiry", func() { + now := time.Now() + Expect(scopedTokenValidFor(&corev1.Secret{}, now)).To(BeZero()) + bad := &corev1.Secret{ObjectMeta: metav1.ObjectMeta{Annotations: map[string]string{scopedTokenExpiryAnnotation: "garbage"}}} + Expect(scopedTokenValidFor(bad, now)).To(BeZero()) + past := &corev1.Secret{ObjectMeta: metav1.ObjectMeta{Annotations: map[string]string{scopedTokenExpiryAnnotation: now.Add(-time.Hour).Format(time.RFC3339)}}} + Expect(scopedTokenValidFor(past, now)).To(BeZero()) + }) +}) diff --git a/images/virtualization-artifact/pkg/controller/supplements/ensure.go b/images/virtualization-artifact/pkg/controller/supplements/ensure.go index 8a9a62b0e0..66c11833c4 100644 --- a/images/virtualization-artifact/pkg/controller/supplements/ensure.go +++ b/images/virtualization-artifact/pkg/controller/supplements/ensure.go @@ -34,6 +34,7 @@ import ( podutil "github.com/deckhouse/virtualization-controller/pkg/common/pod" "github.com/deckhouse/virtualization-controller/pkg/controller/supplements/copier" "github.com/deckhouse/virtualization-controller/pkg/dvcr" + "github.com/deckhouse/virtualization-controller/pkg/dvcr/registrytoken" ) type DataSource interface { @@ -45,7 +46,7 @@ type DataSource interface { // EnsureForPod make supplements for importer or uploader Pod: // - It creates ConfigMap with caBundle for http and containerImage data sources. // - It copies DVCR auth Secret to use DVCR as destination. -func EnsureForPod(ctx context.Context, client client.Client, supGen Generator, pod *corev1.Pod, ds DataSource, dvcrSettings *dvcr.Settings) error { +func EnsureForPod(ctx context.Context, client client.Client, supGen Generator, pod *corev1.Pod, ds DataSource, dvcrSettings *dvcr.Settings, scope []registrytoken.Access) error { // Create ConfigMap with caBundle. if ds.HasCABundle() { caBundleCM := supGen.CABundleConfigMap() @@ -59,23 +60,15 @@ func EnsureForPod(ctx context.Context, client client.Client, supGen Generator, p } } - // Create Secret with auth config to use DVCR as destination. - if ShouldCopyDVCRAuthSecret(dvcrSettings, supGen) { - authSecret := supGen.DVCRAuthSecret() - authCopier := copier.AuthSecret{ - Secret: copier.Secret{ - Source: types.NamespacedName{ - Name: dvcrSettings.AuthSecret, - Namespace: dvcrSettings.AuthSecretNamespace, - }, - Destination: authSecret, - OwnerReference: podutil.MakeOwnerReference(pod), - }, - } - err := authCopier.Copy(ctx, client) - if err != nil { - return err - } + // Create Secret with a scoped token to use DVCR as destination. + authCopier := copier.AuthSecret{ + Secret: copier.Secret{ + Destination: supGen.DVCRAuthSecret(), + OwnerReference: podutil.MakeOwnerReference(pod), + }, + } + if err := authCopier.CreateScopedTokenDockerConfig(ctx, client, dvcrSettings.TokenSigner, scope, dvcrSettings.RegistryURL); err != nil { + return err } // Copy imagePullSecret if namespaces are differ (e.g. CVMI). @@ -100,14 +93,6 @@ func EnsureForPod(ctx context.Context, client client.Client, supGen Generator, p return nil } -func ShouldCopyDVCRAuthSecret(dvcrSettings *dvcr.Settings, supGen Generator) bool { - if dvcrSettings.AuthSecret == "" { - return false - } - // Should copy if namespaces are different. - return dvcrSettings.AuthSecretNamespace != supGen.Namespace() -} - func ShouldCopyUploaderTLSSecret(dvcrSettings *dvcr.Settings, supGen Generator) bool { if dvcrSettings.UploaderIngressSettings.TLSSecret == "" { return false @@ -127,24 +112,15 @@ func ShouldCopyImagePullSecret(ctrImg *datasource.ContainerRegistry, targetNS st return imgPullNS != "" && imgPullNS != targetNS } -func EnsureForDataVolume(ctx context.Context, client client.Client, supGen DataVolumeSupplement, dv *cdiv1.DataVolume, dvcrSettings *dvcr.Settings) error { - if dvcrSettings.AuthSecret != "" { - authSecret := supGen.DVCRAuthSecretForDV() - authCopier := copier.AuthSecret{ - Secret: copier.Secret{ - Source: types.NamespacedName{ - Name: dvcrSettings.AuthSecret, - Namespace: dvcrSettings.AuthSecretNamespace, - }, - Destination: authSecret, - OwnerReference: dvutil.MakeOwnerReference(dv), - }, - } - - err := authCopier.CopyCDICompatible(ctx, client, dvcrSettings.RegistryURL) - if err != nil { - return err - } +func EnsureForDataVolume(ctx context.Context, client client.Client, supGen DataVolumeSupplement, dv *cdiv1.DataVolume, dvcrSettings *dvcr.Settings, scope []registrytoken.Access) error { + authCopier := copier.AuthSecret{ + Secret: copier.Secret{ + Destination: supGen.DVCRAuthSecretForDV(), + OwnerReference: dvutil.MakeOwnerReference(dv), + }, + } + if err := authCopier.CreateScopedTokenCDI(ctx, client, dvcrSettings.TokenSigner, scope); err != nil { + return err } // CABundle needs transformation, so it always copied. diff --git a/images/virtualization-artifact/pkg/controller/uploader/settings.go b/images/virtualization-artifact/pkg/controller/uploader/settings.go index 13f12013c8..cca5395f5d 100644 --- a/images/virtualization-artifact/pkg/controller/uploader/settings.go +++ b/images/virtualization-artifact/pkg/controller/uploader/settings.go @@ -31,11 +31,7 @@ type Settings struct { } func ApplyDVCRDestinationSettings(podEnvVars *Settings, dvcrSettings *dvcr.Settings, supGen supplements.Generator, dvcrImageName string) { - authSecret := dvcrSettings.AuthSecret - if supplements.ShouldCopyDVCRAuthSecret(dvcrSettings, supGen) { - authSecret = supGen.DVCRAuthSecret().Name - } - podEnvVars.DestinationAuthSecret = authSecret + podEnvVars.DestinationAuthSecret = supGen.DVCRAuthSecret().Name podEnvVars.DestinationInsecureTLS = dvcrSettings.InsecureTLS podEnvVars.DestinationEndpoint = dvcrImageName } diff --git a/images/virtualization-artifact/pkg/controller/vi/internal/source/step/create_pod_step_test.go b/images/virtualization-artifact/pkg/controller/vi/internal/source/step/create_pod_step_test.go index a8ffb8bb9c..e79cd60ef7 100644 --- a/images/virtualization-artifact/pkg/controller/vi/internal/source/step/create_pod_step_test.go +++ b/images/virtualization-artifact/pkg/controller/vi/internal/source/step/create_pod_step_test.go @@ -288,7 +288,7 @@ var _ = Describe("CreatePodStep", func() { envSettings := step.getEnvSettings(vi, supplements.NewGenerator("vi", vi.Name, vi.Namespace, vi.UID), volumeMode) Expect(envSettings.Source).To(Equal(expectedSource)) - Expect(envSettings.DestinationAuthSecret).To(Equal("dvcr-secret")) + Expect(envSettings.DestinationAuthSecret).To(Equal("d8v-vi-dvcr-auth-vi-vi-uid")) Expect(envSettings.DestinationEndpoint).To(Equal("registry.example.com/vi/default/vi:vi-uid")) }, Entry("filesystem by default", nil, importer.SourceFilesystem), @@ -317,7 +317,7 @@ var _ = Describe("CreatePodStep", func() { Expect(importerStub.startCalls).To(Equal(1)) Expect(importerStub.settings).ToNot(BeNil()) Expect(importerStub.settings.Source).To(Equal(importer.SourceBlockDevice)) - Expect(importerStub.settings.DestinationAuthSecret).To(Equal("dvcr-secret")) + Expect(importerStub.settings.DestinationAuthSecret).To(Equal("d8v-vi-dvcr-auth-vi-vi-uid")) Expect(importerStub.settings.DestinationEndpoint).To(Equal("registry.example.com/vi/default/vi:vi-uid")) Expect(vi.Status.Progress).To(Equal("0%")) Expect(vi.Status.Target.RegistryURL).To(Equal("registry.example.com/custom:tag")) diff --git a/images/virtualization-artifact/pkg/dvcr/dvcr.go b/images/virtualization-artifact/pkg/dvcr/dvcr.go index 561fc75980..12d0a18b80 100644 --- a/images/virtualization-artifact/pkg/dvcr/dvcr.go +++ b/images/virtualization-artifact/pkg/dvcr/dvcr.go @@ -19,8 +19,11 @@ package dvcr import ( "fmt" "path" + "strings" "sigs.k8s.io/controller-runtime/pkg/client" + + "github.com/deckhouse/virtualization-controller/pkg/dvcr/registrytoken" ) type Settings struct { @@ -42,6 +45,11 @@ type Settings struct { ImageMonitorSchedule string // GCSchedule is a cron formatted schedule to periodically run a garbage collection. GCSchedule string + // TokenSigner mints the scoped per-namespace DVCR tokens: importer and uploader + // Pods authenticate with a token minted for the single repository they use, + // instead of the shared read-write credential, which is then no longer copied + // into tenant namespaces. + TokenSigner *registrytoken.Signer } type UploaderIngressSettings struct { @@ -75,3 +83,19 @@ func (s *Settings) RegistryImageForVD(obj client.Object) string { imgPath := path.Clean(fmt.Sprintf(VMDImageTmpl, obj.GetNamespace(), obj.GetName(), obj.GetUID())) return path.Join(s.RegistryURL, imgPath) } + +// RepoPath extracts the repository path (e.g. "vi/ns/name", "cvi/name") from a +// DVCR image reference by stripping the optional docker:// scheme, the registry +// host and the tag/digest. It is the name used in a scoped token's access claim. +func (s *Settings) RepoPath(imageRef string) string { + ref := strings.TrimPrefix(imageRef, "docker://") + ref = strings.TrimPrefix(ref, s.RegistryURL) + ref = strings.TrimPrefix(ref, "/") + if i := strings.Index(ref, "@"); i >= 0 { + ref = ref[:i] + } + if slash := strings.LastIndex(ref, "/"); strings.LastIndex(ref, ":") > slash { + ref = ref[:strings.LastIndex(ref, ":")] + } + return path.Clean(ref) +} diff --git a/images/virtualization-artifact/pkg/dvcr/registrytoken/signer.go b/images/virtualization-artifact/pkg/dvcr/registrytoken/signer.go new file mode 100644 index 0000000000..f5223e8224 --- /dev/null +++ b/images/virtualization-artifact/pkg/dvcr/registrytoken/signer.go @@ -0,0 +1,101 @@ +/* +Copyright 2026 Flant JSC + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +// Package registrytoken mints scoped JWT credentials for DVCR. An importer or +// uploader Pod presents such a token instead of the shared read-write password; +// the token authorizes exactly the repositories that Pod reads from and writes to. +// The DVCR registry verifies it with distribution's own token backend (the format +// matches its ClaimSet / ResourceActions), so no shared read-write credential is +// copied into tenant namespaces. +package registrytoken + +import ( + "crypto/ecdsa" + "crypto/x509" + "encoding/pem" + "errors" + "fmt" + "time" + + "github.com/golang-jwt/jwt/v5" +) + +const ( + // Issuer is the token issuer; the registry accepts only this value. + Issuer = "virtualization-controller" + // Audience is the token audience; the registry accepts only this value. + Audience = "dvcr" + // KeyID is the JWS `kid` header the registry maps to the trusted public key. + KeyID = "dvcr" + // DefaultTTL is the scoped token lifetime. Generous on purpose: an import may + // run for hours, and the token is scoped to a single repository, so a long + // validity window is a small exposure. If imports ever outlive this, bind the + // TTL to the Pod's activeDeadlineSeconds instead. + DefaultTTL = 24 * time.Hour +) + +// Access is one entry of a token's access claim: a resource and the actions +// permitted on it. JSON tags match distribution's token.ResourceActions. +type Access struct { + Type string `json:"type"` + Name string `json:"name"` + Actions []string `json:"actions"` +} + +// Signer mints ES256-signed scoped tokens with an ECDSA P-256 private key. +type Signer struct { + key *ecdsa.PrivateKey +} + +// NewSignerFromPEM builds a Signer from a PKCS8 (or SEC1) PEM-encoded ECDSA key. +func NewSignerFromPEM(keyPEM []byte) (*Signer, error) { + block, _ := pem.Decode(keyPEM) + if block == nil { + return nil, errors.New("registrytoken: no PEM block in private key") + } + key, err := parseECKey(block.Bytes) + if err != nil { + return nil, fmt.Errorf("registrytoken: parse private key: %w", err) + } + return &Signer{key: key}, nil +} + +func parseECKey(der []byte) (*ecdsa.PrivateKey, error) { + if k, err := x509.ParsePKCS8PrivateKey(der); err == nil { + ec, ok := k.(*ecdsa.PrivateKey) + if !ok { + return nil, errors.New("private key is not ECDSA") + } + return ec, nil + } + return x509.ParseECPrivateKey(der) +} + +// Sign issues a token granting access, valid for DefaultTTL. now is passed +// explicitly to keep the function testable. +func (s *Signer) Sign(access []Access, now time.Time) (string, error) { + claims := jwt.MapClaims{ + "iss": Issuer, + "aud": Audience, + "iat": now.Unix(), + "nbf": now.Add(-30 * time.Second).Unix(), + "exp": now.Add(DefaultTTL).Unix(), + "access": access, + } + tok := jwt.NewWithClaims(jwt.SigningMethodES256, claims) + tok.Header["kid"] = KeyID + return tok.SignedString(s.key) +} diff --git a/images/virtualization-artifact/pkg/dvcr/registrytoken/signer_test.go b/images/virtualization-artifact/pkg/dvcr/registrytoken/signer_test.go new file mode 100644 index 0000000000..c1e14ad557 --- /dev/null +++ b/images/virtualization-artifact/pkg/dvcr/registrytoken/signer_test.go @@ -0,0 +1,151 @@ +/* +Copyright 2026 Flant JSC + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package registrytoken + +import ( + "crypto/ecdsa" + "crypto/elliptic" + "crypto/rand" + "crypto/rsa" + "crypto/x509" + "encoding/pem" + "testing" + "time" + + "github.com/golang-jwt/jwt/v5" + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +func TestRegistryToken(t *testing.T) { + RegisterFailHandler(Fail) + RunSpecs(t, "RegistryToken Suite") +} + +// newSignerPEM returns a fresh PKCS8-encoded ECDSA private key and its public key. +func newSignerPEM() ([]byte, *ecdsa.PublicKey) { + key, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) + Expect(err).NotTo(HaveOccurred()) + der, err := x509.MarshalPKCS8PrivateKey(key) + Expect(err).NotTo(HaveOccurred()) + return pem.EncodeToMemory(&pem.Block{Type: "PRIVATE KEY", Bytes: der}), &key.PublicKey +} + +var _ = Describe("Signer", func() { + now := time.Unix(1_700_000_000, 0) + + It("mints a token the registry accepts, with the expected claims", func() { + keyPEM, pub := newSignerPEM() + signer, err := NewSignerFromPEM(keyPEM) + Expect(err).NotTo(HaveOccurred()) + + access := []Access{{Type: "repository", Name: "cvi/my-image", Actions: []string{"pull", "push"}}} + raw, err := signer.Sign(access, now) + Expect(err).NotTo(HaveOccurred()) + + // Verify signature and claims the way the registry will. + parsed, err := jwt.Parse(raw, func(*jwt.Token) (any, error) { return pub, nil }, + jwt.WithValidMethods([]string{"ES256"}), + jwt.WithTimeFunc(func() time.Time { return now.Add(time.Minute) })) + Expect(err).NotTo(HaveOccurred()) + + Expect(parsed.Header["kid"]).To(Equal(KeyID)) + claims := parsed.Claims.(jwt.MapClaims) + Expect(claims["iss"]).To(Equal(Issuer)) + Expect(claims["aud"]).To(Equal(Audience)) + + acc, ok := claims["access"].([]any) + Expect(ok).To(BeTrue()) + Expect(acc).To(HaveLen(1)) + entry := acc[0].(map[string]any) + Expect(entry["name"]).To(Equal("cvi/my-image")) + Expect(entry["type"]).To(Equal("repository")) + + // Time claims must frame exactly [iat-30s, iat+DefaultTTL] so the registry + // accepts a token immediately and rejects it past its lifetime. + Expect(int64(claims["iat"].(float64))).To(Equal(now.Unix())) + Expect(int64(claims["nbf"].(float64))).To(Equal(now.Add(-30 * time.Second).Unix())) + Expect(int64(claims["exp"].(float64))).To(Equal(now.Add(DefaultTTL).Unix())) + }) + + It("binds the token to its signing key: a wrong public key must not verify", func() { + keyPEM, _ := newSignerPEM() + signer, err := NewSignerFromPEM(keyPEM) + Expect(err).NotTo(HaveOccurred()) + _, otherPub := newSignerPEM() + + raw, err := signer.Sign(nil, now) + Expect(err).NotTo(HaveOccurred()) + + _, err = jwt.Parse(raw, func(*jwt.Token) (any, error) { return otherPub, nil }, + jwt.WithValidMethods([]string{"ES256"}), + jwt.WithTimeFunc(func() time.Time { return now.Add(time.Minute) })) + Expect(err).To(HaveOccurred()) + }) + + It("mints a token that no longer verifies past DefaultTTL", func() { + keyPEM, pub := newSignerPEM() + signer, err := NewSignerFromPEM(keyPEM) + Expect(err).NotTo(HaveOccurred()) + + raw, err := signer.Sign(nil, now) + Expect(err).NotTo(HaveOccurred()) + + _, err = jwt.Parse(raw, func(*jwt.Token) (any, error) { return pub, nil }, + jwt.WithTimeFunc(func() time.Time { return now.Add(DefaultTTL + time.Hour) })) + Expect(err).To(HaveOccurred()) + }) + + // Key-parsing branches: both accepted EC formats (PKCS8, SEC1) and the + // rejected inputs (non-PEM bytes, a non-ECDSA key). + DescribeTable("NewSignerFromPEM", + func(pemFn func() []byte, wantErr bool) { + signer, err := NewSignerFromPEM(pemFn()) + if wantErr { + Expect(err).To(HaveOccurred()) + return + } + Expect(err).NotTo(HaveOccurred()) + _, err = signer.Sign(nil, now) + Expect(err).NotTo(HaveOccurred()) + }, + Entry("PKCS8 EC key", func() []byte { + key, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) + Expect(err).NotTo(HaveOccurred()) + der, err := x509.MarshalPKCS8PrivateKey(key) + Expect(err).NotTo(HaveOccurred()) + return pem.EncodeToMemory(&pem.Block{Type: "PRIVATE KEY", Bytes: der}) + }, false), + Entry("SEC1 EC key", func() []byte { + key, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) + Expect(err).NotTo(HaveOccurred()) + der, err := x509.MarshalECPrivateKey(key) + Expect(err).NotTo(HaveOccurred()) + return pem.EncodeToMemory(&pem.Block{Type: "EC PRIVATE KEY", Bytes: der}) + }, false), + Entry("not a PEM block", func() []byte { + return []byte("definitely not pem") + }, true), + Entry("RSA key is not ECDSA", func() []byte { + key, err := rsa.GenerateKey(rand.Reader, 2048) + Expect(err).NotTo(HaveOccurred()) + der, err := x509.MarshalPKCS8PrivateKey(key) + Expect(err).NotTo(HaveOccurred()) + return pem.EncodeToMemory(&pem.Block{Type: "PRIVATE KEY", Bytes: der}) + }, true), + ) +}) diff --git a/openapi/values.yaml b/openapi/values.yaml index f716251736..e727b02fec 100644 --- a/openapi/values.yaml +++ b/openapi/values.yaml @@ -33,6 +33,15 @@ properties: default: "" passwordRW: type: string + passwordRO: + type: string + default: "" + tokenPrivateKey: + type: string + default: "" + tokenPublicKey: + type: string + default: "" htpasswd: type: string salt: diff --git a/templates/dvcr/_helpers.tpl b/templates/dvcr/_helpers.tpl index 490274a8a8..cb4ce81061 100644 --- a/templates/dvcr/_helpers.tpl +++ b/templates/dvcr/_helpers.tpl @@ -14,13 +14,6 @@ true - name: REGISTRY_HTTP_TLS_KEY value: /etc/ssl/docker/tls.key -- name: REGISTRY_AUTH - value: "htpasswd" -- name: REGISTRY_AUTH_HTPASSWD_REALM - value: "Registry Realm" -- name: REGISTRY_AUTH_HTPASSWD_PATH - value: "/auth/htpasswd" - - name: REGISTRY_HTTP_SECRET valueFrom: secretKeyRef: @@ -91,6 +84,12 @@ true items: - key: htpasswd path: htpasswd + - key: passwordRW + path: passwordRW + - key: passwordRO + path: passwordRO + - key: tokenPublicKey + path: tokenPublicKey {{- end -}} diff --git a/templates/dvcr/configmap.yaml b/templates/dvcr/configmap.yaml index 79e855ad0e..91420293c6 100644 --- a/templates/dvcr/configmap.yaml +++ b/templates/dvcr/configmap.yaml @@ -19,6 +19,17 @@ data: readonly: enabled: true {{- end }} + auth: + dvcr-k8s: + realm: "dvcr" + adminusername: "admin" + adminpasswordfile: /auth/passwordRW + pullerusername: "node-puller" + pullerpasswordfile: /auth/passwordRO + jwtissuer: "virtualization-controller" + jwtaudience: "dvcr" + jwtkeyid: "dvcr" + jwtpublickeyfile: /auth/tokenPublicKey http: addr: :5000 headers: diff --git a/templates/dvcr/nodegroupconfiguration.yaml b/templates/dvcr/nodegroupconfiguration.yaml index c35af00c03..eb84c43bfe 100644 --- a/templates/dvcr/nodegroupconfiguration.yaml +++ b/templates/dvcr/nodegroupconfiguration.yaml @@ -1,7 +1,10 @@ {{- if ne (dig "dvcr" "serviceIP" "" .Values.virtualization.internal) "" }} --- {{- $ca := printf "%s" .Values.virtualization.internal.dvcr.cert.ca }} - {{- $password := printf "admin:%s" (printf "%s" .Values.virtualization.internal.dvcr.passwordRW | b64dec) | b64enc }} + {{- /* Nodes only pull images; use the pull-only node-puller credential. */ -}} + {{- $authUser := "node-puller" }} + {{- $authPass := .Values.virtualization.internal.dvcr.passwordRO }} + {{- $password := printf "%s:%s" $authUser (printf "%s" $authPass | b64dec) | b64enc }} {{- $registry := include "dvcr.get_registry" (list .) }} {{- $endpoint := .Values.virtualization.internal.dvcr.serviceIP }} diff --git a/templates/dvcr/secret.yaml b/templates/dvcr/secret.yaml index 15004d2184..eefe322cbb 100644 --- a/templates/dvcr/secret.yaml +++ b/templates/dvcr/secret.yaml @@ -24,8 +24,11 @@ metadata: type: Opaque data: passwordRW: {{ .Values.virtualization.internal.dvcr.passwordRW | quote }} + passwordRO: {{ .Values.virtualization.internal.dvcr.passwordRO | quote }} htpasswd: {{ .Values.virtualization.internal.dvcr.htpasswd | quote }} salt: {{ .Values.virtualization.internal.dvcr.salt | quote }} + tokenPrivateKey: {{ .Values.virtualization.internal.dvcr.tokenPrivateKey | quote }} + tokenPublicKey: {{ .Values.virtualization.internal.dvcr.tokenPublicKey | quote }} --- {{- /* TODO: delete this secret, because containerd has credentials to auth to the dvcr. This secret was needed to create virtual machines from dvcr images */ -}} diff --git a/templates/virtualization-controller/_helpers.tpl b/templates/virtualization-controller/_helpers.tpl index fc6e340119..a883bdc746 100644 --- a/templates/virtualization-controller/_helpers.tpl +++ b/templates/virtualization-controller/_helpers.tpl @@ -40,6 +40,11 @@ true value: "*/5 * * * *" - name: DVCR_GC_SCHEDULE value: "{{ .Values.virtualization.internal.moduleConfig | dig "dvcr" "gc" "schedule" "" }}" +- name: DVCR_TOKEN_PRIVATE_KEY + valueFrom: + secretKeyRef: + name: dvcr-secrets + key: tokenPrivateKey - name: VIRTUAL_MACHINE_CIDRS value: {{ join "," .Values.virtualization.internal.moduleConfig.virtualMachineCIDRs | quote }} {{- if (hasKey .Values.virtualization.internal.moduleConfig "virtualImages") }}