Skip to content

Commit

Permalink
Introduce BootstrapToken newtype and overhaul its generation
Browse files Browse the repository at this point in the history
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 <[email protected]>
  • Loading branch information
twz123 committed Feb 6, 2024
1 parent 3a518d8 commit 4aef460
Show file tree
Hide file tree
Showing 8 changed files with 234 additions and 96 deletions.
12 changes: 6 additions & 6 deletions cmd/token/preshared.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -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)
Expand All @@ -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)
}
Expand All @@ -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)
}
Expand Down
36 changes: 0 additions & 36 deletions internal/autopilot/pkg/random/random.go

This file was deleted.

36 changes: 0 additions & 36 deletions internal/pkg/random/random.go

This file was deleted.

8 changes: 4 additions & 4 deletions pkg/token/kubeconfig.go
Original file line number Diff line number Diff line change
Expand Up @@ -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: {
Expand All @@ -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
Expand All @@ -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)
}
5 changes: 3 additions & 2 deletions pkg/token/kubeconfig_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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))
}
25 changes: 13 additions & 12 deletions pkg/token/manager.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
)

Expand Down Expand Up @@ -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
Expand All @@ -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
Expand Down
115 changes: 115 additions & 0 deletions pkg/token/token.go
Original file line number Diff line number Diff line change
@@ -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
}
Loading

0 comments on commit 4aef460

Please sign in to comment.