From 4aef460a41b5054202fe1cf54604b1b3aa3a6dca Mon Sep 17 00:00:00 2001 From: Tom Wieczorek Date: Tue, 6 Feb 2024 09:14:33 +0100 Subject: [PATCH] Introduce BootstrapToken newtype and overhaul its generation The previous approach in generating tokens was not uniformly random, but had a bias for certain values. Introduce a newtype over the raw token bytes and implement a non-biased token generation. Moreover, make accessing the secret part of the token private. Signed-off-by: Tom Wieczorek --- cmd/token/preshared.go | 12 +-- internal/autopilot/pkg/random/random.go | 36 -------- internal/pkg/random/random.go | 36 -------- pkg/token/kubeconfig.go | 8 +- pkg/token/kubeconfig_test.go | 5 +- pkg/token/manager.go | 25 +++--- pkg/token/token.go | 115 ++++++++++++++++++++++++ pkg/token/token_test.go | 93 +++++++++++++++++++ 8 files changed, 234 insertions(+), 96 deletions(-) delete mode 100644 internal/autopilot/pkg/random/random.go delete mode 100644 internal/pkg/random/random.go create mode 100644 pkg/token/token.go create mode 100644 pkg/token/token_test.go diff --git a/cmd/token/preshared.go b/cmd/token/preshared.go index 4e25a534be0f..bc9e61fbb68a 100644 --- a/cmd/token/preshared.go +++ b/cmd/token/preshared.go @@ -85,10 +85,10 @@ func preSharedCmd() *cobra.Command { return cmd } -func createSecret(role string, validity time.Duration, outDir string) (string, error) { +func createSecret(role string, validity time.Duration, outDir string) (*token.BootstrapToken, error) { secret, token, err := token.RandomBootstrapSecret(role, validity) if err != nil { - return "", fmt.Errorf("failed to generate bootstrap secret: %w", err) + return nil, fmt.Errorf("failed to generate bootstrap secret: %w", err) } if err := file.WriteAtomically(filepath.Join(outDir, secret.Name+".yaml"), 0640, func(unbuffered io.Writer) error { @@ -100,13 +100,13 @@ func createSecret(role string, validity time.Duration, outDir string) (string, e } return w.Flush() }); err != nil { - return "", fmt.Errorf("failed to save bootstrap secret: %w", err) + return nil, fmt.Errorf("failed to save bootstrap secret: %w", err) } return token, nil } -func createKubeConfig(tokenString, role, joinURL, certPath, outDir string) error { +func createKubeConfig(tok *token.BootstrapToken, role, joinURL, certPath, outDir string) error { caCert, err := os.ReadFile(certPath) if err != nil { return fmt.Errorf("error reading certificate: %w", err) @@ -121,7 +121,7 @@ func createKubeConfig(tokenString, role, joinURL, certPath, outDir string) error default: return fmt.Errorf("unknown role: %s", role) } - kubeconfig, err := token.GenerateKubeconfig(joinURL, caCert, userName, tokenString) + kubeconfig, err := token.GenerateKubeconfig(joinURL, caCert, userName, tok) if err != nil { return fmt.Errorf("error generating kubeconfig: %w", err) } @@ -131,7 +131,7 @@ func createKubeConfig(tokenString, role, joinURL, certPath, outDir string) error return fmt.Errorf("error encoding token: %w", err) } - err = file.WriteContentAtomically(filepath.Join(outDir, "token_"+tokenString), []byte(encodedToken), 0640) + err = file.WriteContentAtomically(filepath.Join(outDir, "token_"+tok.ID()), []byte(encodedToken), 0640) if err != nil { return fmt.Errorf("error writing kubeconfig: %w", err) } diff --git a/internal/autopilot/pkg/random/random.go b/internal/autopilot/pkg/random/random.go deleted file mode 100644 index 861d33dfb21a..000000000000 --- a/internal/autopilot/pkg/random/random.go +++ /dev/null @@ -1,36 +0,0 @@ -// Copyright 2021 k0s authors -// -// 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 random - -import ( - "crypto/rand" -) - -var letters = "abcdefghijklmnopqrstuvwxyz0123456789" - -// String generates a random string with given length -func String(length int) string { - - bytes := make([]byte, length) - if _, err := rand.Read(bytes); err != nil { - // Not much we can do on broken system - panic("random is broken: " + err.Error()) - } - - for i, b := range bytes { - bytes[i] = letters[b%byte(len(letters))] - } - return string(bytes) -} diff --git a/internal/pkg/random/random.go b/internal/pkg/random/random.go deleted file mode 100644 index fe69bfbfcc9f..000000000000 --- a/internal/pkg/random/random.go +++ /dev/null @@ -1,36 +0,0 @@ -/* -Copyright 2021 k0s authors - -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 random - -import "crypto/rand" - -var letters = "abcdefghijklmnopqrstuvwxyz0123456789" - -// String generates a random string with given length -func String(length int) string { - - bytes := make([]byte, length) - if _, err := rand.Read(bytes); err != nil { - // Not much we can do on broken system - panic("random is broken: " + err.Error()) - } - - for i, b := range bytes { - bytes[i] = letters[b%byte(len(letters))] - } - return string(bytes) -} diff --git a/pkg/token/kubeconfig.go b/pkg/token/kubeconfig.go index d9a4de4387a8..92753d3d42a1 100644 --- a/pkg/token/kubeconfig.go +++ b/pkg/token/kubeconfig.go @@ -61,7 +61,7 @@ func CreateKubeletBootstrapToken(ctx context.Context, api *v1beta1.APISpec, k0sV return JoinEncode(bytes.NewReader(kubeconfig)) } -func GenerateKubeconfig(joinURL string, caCert []byte, userName string, token string) ([]byte, error) { +func GenerateKubeconfig(joinURL string, caCert []byte, userName string, token *BootstrapToken) ([]byte, error) { const k0sContextName = "k0s" kubeconfig, err := clientcmd.Write(clientcmdapi.Config{ Clusters: map[string]*clientcmdapi.Cluster{k0sContextName: { @@ -74,7 +74,7 @@ func GenerateKubeconfig(joinURL string, caCert []byte, userName string, token st }}, CurrentContext: k0sContextName, AuthInfos: map[string]*clientcmdapi.AuthInfo{userName: { - Token: token, + Token: token.token(), }}, }) return kubeconfig, err @@ -101,10 +101,10 @@ func loadCACert(k0sVars *config.CfgVars) ([]byte, error) { return caCert, nil } -func loadToken(ctx context.Context, k0sVars *config.CfgVars, role string, expiry time.Duration) (string, error) { +func loadToken(ctx context.Context, k0sVars *config.CfgVars, role string, expiry time.Duration) (*BootstrapToken, error) { manager, err := NewManager(filepath.Join(k0sVars.AdminKubeConfigPath)) if err != nil { - return "", err + return nil, err } return manager.Create(ctx, expiry, role) } diff --git a/pkg/token/kubeconfig_test.go b/pkg/token/kubeconfig_test.go index 4e67f34856ba..5f527088d127 100644 --- a/pkg/token/kubeconfig_test.go +++ b/pkg/token/kubeconfig_test.go @@ -41,10 +41,11 @@ preferences: {} users: - name: the user user: - token: the token + token: abcdef.0123456789abcdef ` - kubeconfig, err := GenerateKubeconfig("the join URL", []byte("the cert"), "the user", "the token") + tok := BootstrapToken{'a', 'b', 'c', 'd', 'e', 'f', '.', '0', '1', '2', '3', '4', '5', '6', '7', '8', '9', 'a', 'b', 'c', 'd', 'e', 'f'} + kubeconfig, err := GenerateKubeconfig("the join URL", []byte("the cert"), "the user", &tok) require.NoError(t, err) assert.Equal(t, expected, string(kubeconfig)) } diff --git a/pkg/token/manager.go b/pkg/token/manager.go index dcea57727bd5..13e022c1f3d6 100644 --- a/pkg/token/manager.go +++ b/pkg/token/manager.go @@ -28,7 +28,6 @@ import ( "k8s.io/apimachinery/pkg/fields" "k8s.io/client-go/kubernetes" - "github.com/k0sproject/k0s/internal/pkg/random" k8sutil "github.com/k0sproject/k0s/pkg/kubernetes" ) @@ -66,19 +65,21 @@ type Manager struct { client kubernetes.Interface } -func RandomBootstrapSecret(role string, valid time.Duration) (*corev1.Secret, string, error) { - tokenID := random.String(6) - tokenSecret := random.String(16) +func RandomBootstrapSecret(role string, valid time.Duration) (*corev1.Secret, *BootstrapToken, error) { + token, err := randomToken() + if err != nil { + return nil, nil, fmt.Errorf("failed to generate bootstrap token: %w", err) + } s := corev1.Secret{ ObjectMeta: metav1.ObjectMeta{ - Name: fmt.Sprintf("bootstrap-token-%s", tokenID), + Name: fmt.Sprintf("bootstrap-token-%s", token.ID()), Namespace: "kube-system", }, Type: corev1.SecretTypeBootstrapToken, StringData: map[string]string{ - "token-id": tokenID, - "token-secret": tokenSecret, + "token-id": token.ID(), + "token-secret": token.secret(), // This "usage-" is shared for all roles of the token which allows // them to execute calls to the k0s API. This is done because we @@ -105,22 +106,22 @@ func RandomBootstrapSecret(role string, valid time.Duration) (*corev1.Secret, st s.StringData["usage-bootstrap-signing"] = "false" s.StringData["usage-controller-join"] = "true" default: - return nil, "", fmt.Errorf("unsupported role %q", role) + return nil, nil, fmt.Errorf("unsupported role %q", role) } - return &s, fmt.Sprintf("%s.%s", tokenID, tokenSecret), nil + return &s, token, nil } // Create creates a new bootstrap token -func (m *Manager) Create(ctx context.Context, valid time.Duration, role string) (string, error) { +func (m *Manager) Create(ctx context.Context, valid time.Duration, role string) (*BootstrapToken, error) { secret, token, err := RandomBootstrapSecret(role, valid) if err != nil { - return "", err + return nil, err } _, err = m.client.CoreV1().Secrets("kube-system").Create(ctx, secret, metav1.CreateOptions{}) if err != nil { - return "", err + return nil, err } return token, nil diff --git a/pkg/token/token.go b/pkg/token/token.go new file mode 100644 index 000000000000..1e359149b607 --- /dev/null +++ b/pkg/token/token.go @@ -0,0 +1,115 @@ +/* +Copyright 2024 k0s authors + +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 token + +import ( + "crypto/rand" + "encoding/binary" + "fmt" + "math/bits" +) + +// A Kubernetes bootstrap token. Matches the regex [a-z0-9]{6}\.[a-z0-9]{16}. +// The first part is the "Token ID", the second part is the "Token Secret". +// +// https://kubernetes.io/docs/reference/access-authn-authz/bootstrap-tokens/#token-format +type BootstrapToken [23]byte + +func randomToken() (*BootstrapToken, error) { + rng := func(b []byte) error { _, err := rand.Read(b); return err } + tok, err := generate23Base36(rng) + if err != nil { + return nil, err + } + + tok[6] = '.' // Set the 7th byte to a dot to match the token format. + return (*BootstrapToken)(tok), nil +} + +func (t *BootstrapToken) String() string { + return fmt.Sprintf("%s.****************", t.ID()) +} + +func (t *BootstrapToken) ID() string { + return string(t[:6]) +} + +func (t *BootstrapToken) secret() string { + return string(t[7:]) +} + +func (t *BootstrapToken) token() string { + return string(t[:]) +} + +// Generate a random 23-byte base36 encoded string using the provided random +// number generator. +func generate23Base36(rng func([]byte) error) (*[23]byte, error) { + // Generate a 119-bit (2^118 < 36^23 < 2^119) random number represented as + // two 64 bit digits by using 15 random bytes and clearing the most + // significant bit of the most significant byte. The number is then is + // converted to a base36 encoded string and stored in the provided buffer. + + var ( + hi uint64 // High 55 bits of the 119-bit random number. + lo uint64 // Low 64 bits of the 119-bit random number. + data [23]byte // Temporary and result buffer. + ) + + // Continuously generate random numbers until one fits within the desired + // range [0, 36^23-1]. Each iteration has a ~93.8% probability of success. + for { + // Generate 15 random bytes (= 120 random bits). + if err := rng(data[:15]); err != nil { + return nil, err + } + + // Zero out the most significant bit of the most significant byte to go + // from 120 to 119 random bits. + data[14] = data[14] & 0x7F + + // The upper bound of the range (36^23-1). + const himax, lomax uint64 = 33809425810441975, 107593809847648255 + + // Interpret the last 8 bytes as the high part and first 8 as the low part. + hi = binary.LittleEndian.Uint64(data[8:]) + lo = binary.LittleEndian.Uint64(data[:8]) + + // Check if the generated number is within the valid range. + if hi < himax || (hi == himax && lo <= lomax) { + break // The number is in range, proceed. + } + } + + // Convert the number to its base36 representation. + const alphabet = "0123456789abcdefghijklmnopqrstuvwxyz" + i := 22 // Fill from right to left. + for hi > 0 || lo > 0 { + var hiMod, mod uint64 + hi, hiMod = hi/36, hi%36 + lo, mod = bits.Div64(hiMod, lo, 36) + data[i] = alphabet[mod] + i-- + } + + // Left-pad the remaining digits with '0'. + for ; i >= 0; i-- { + data[i] = '0' + } + + return &data, nil +} diff --git a/pkg/token/token_test.go b/pkg/token/token_test.go new file mode 100644 index 000000000000..f0384e9df93e --- /dev/null +++ b/pkg/token/token_test.go @@ -0,0 +1,93 @@ +/* +Copyright 2024 k0s authors + +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 token + +import ( + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/mock" +) + +func TestGenerate23Base36(t *testing.T) { + for _, test := range []struct { + name string + expected string + rng [][]byte + }{ + {"high_bit_set", "00000000000000000000001", [][]byte{ + // this is 1 plus the high bit set, which is to be ignored + {1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 128}, + }}, + {"max", "zzzzzzzzzzzzzzzzzzzzzzz", [][]byte{ + // this is 36^23-1, so all z's + {255, 255, 255, 255, 255, 63, 126, 1, 247, 198, 125, 95, 126, 29, 120, 0}, + }}, + {"skips_overflow", "00000000000000000000001", [][]byte{ + // this is 36^23 which is an overflow -> ask for more bytes + {0, 0, 0, 0, 0, 64, 126, 1, 247, 198, 125, 95, 126, 29, 120, 0}, + // the second one is a one, once again + {1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 128}, + }}, + } { + t.Run(test.name, func(t *testing.T) { + rng := new(mockRNG) + for _, bytes := range test.rng { + rng.add(bytes) + } + + data, err := generate23Base36(rng.generate) + + assert.NoError(t, err) + assert.Equal(t, test.expected, string(data[:])) + rng.AssertExpectations(t) + }) + } + + t.Run("forwards_rng_err", func(t *testing.T) { + rng := new(mockRNG) + rng.onGenerate().Return(assert.AnError) + + data, err := generate23Base36(rng.generate) + + assert.Nil(t, data) + assert.Same(t, assert.AnError, err) + rng.AssertExpectations(t) + }) +} + +type mockRNG struct { + mock.Mock +} + +func (m *mockRNG) generate(b []byte) error { + args := m.Called(b) + return args.Error(0) +} + +func (m *mockRNG) onGenerate() *mock.Call { + return m.On("generate", mock.AnythingOfType("[]uint8")) +} + +func (m *mockRNG) add(data []byte) { + m.onGenerate().Return(nil).Once().Run(func(args mock.Arguments) { + arg := args.Get(0).([]byte) + for i := range arg { + arg[i] = data[i] + } + }) +}