k3s/pkg/clientaccess/token.go
Brad Davidson 992e64993d Add support for kubeadm token and client certificate auth
Allow bootstrapping with kubeadm bootstrap token strings or existing
Kubelet certs. This allows agents to join the cluster using kubeadm
bootstrap tokens, as created with the `k3s token create` command.

When the token expires or is deleted, agents can successfully restart by
authenticating with their kubelet certificate via node authentication.
If the token is gone and the node is deleted from the cluster, node auth
will fail and they will be prevented from rejoining the cluster until
provided with a valid token.

Servers still must be bootstrapped with the static cluster token, as
they will need to know it to decrypt the bootstrap data.

Signed-off-by: Brad Davidson <brad.davidson@rancher.com>
2023-02-07 14:55:04 -08:00

451 lines
13 KiB
Go

package clientaccess
import (
"bytes"
"crypto/sha256"
"crypto/tls"
"crypto/x509"
"encoding/hex"
"fmt"
"io"
"net/http"
"net/url"
"os"
"strings"
"time"
"github.com/k3s-io/k3s/pkg/kubeadm"
"github.com/pkg/errors"
certutil "github.com/rancher/dynamiclistener/cert"
"github.com/sirupsen/logrus"
)
const (
tokenPrefix = "K10"
caHashLength = sha256.Size * 2
defaultClientTimeout = 10 * time.Second
)
var (
defaultClient = &http.Client{
Timeout: defaultClientTimeout,
}
insecureClient = &http.Client{
Timeout: defaultClientTimeout,
Transport: &http.Transport{
TLSClientConfig: &tls.Config{
InsecureSkipVerify: true,
},
},
}
)
// Info contains fields that track parsed parts of a cluster join token
type Info struct {
*kubeadm.BootstrapTokenString
CACerts []byte
BaseURL string
Username string
Password string
CertFile string
KeyFile string
caHash string
}
// ValidationOption is a callback to mutate the token prior to use
type ValidationOption func(*Info)
// WithClientCertificate configures certs and keys to be used
// to authenticate the request.
func WithClientCertificate(certFile, keyFile string) ValidationOption {
return func(i *Info) {
i.CertFile = certFile
i.KeyFile = keyFile
}
}
// WithUser overrides the username from the token with the provided value.
func WithUser(username string) ValidationOption {
return func(i *Info) {
i.Username = username
}
}
// String returns the token data in K10 format
func (i *Info) String() string {
creds := i.Username + ":" + i.Password
if i.BootstrapTokenString != nil {
creds = i.BootstrapTokenString.String()
}
digest, _ := hashCA(i.CACerts)
return tokenPrefix + digest + "::" + creds
}
// Token returns the bootstrap token string, if available.
func (i *Info) Token() string {
if i.BootstrapTokenString != nil {
return i.BootstrapTokenString.String()
}
return ""
}
// ParseAndValidateToken parses a token, downloads and validates the server's CA bundle,
// and validates it according to the caHash from the token if set.
func ParseAndValidateToken(server string, token string, options ...ValidationOption) (*Info, error) {
info, err := parseToken(token)
if err != nil {
return nil, err
}
for _, option := range options {
option(info)
}
if err := info.setAndValidateServer(server); err != nil {
return nil, err
}
return info, nil
}
// setAndValidateServer updates the remote server's cert info, and validates it against the provided hash
func (i *Info) setAndValidateServer(server string) error {
if err := i.setServer(server); err != nil {
return err
}
return i.validateCAHash()
}
// validateCACerts returns a boolean indicating whether or not a CA bundle matches the
// provided hash, and a string containing the hash of the CA bundle.
func validateCACerts(cacerts []byte, hash string) (bool, string) {
newHash, _ := hashCA(cacerts)
return hash == newHash, newHash
}
// hashCA returns the hex-encoded SHA256 digest of a CA bundle.
// If the certificate bundle contains only a single certificate, a legacy hash is generated from
// the literal bytes of the file; usually a PEM-encoded self-signed cluster CA certificate.
// If the certificate bundle contains more than one certificate, the hash is instead generated
// from the DER-encoded root certificate in the bundle. This allows for rotating or renewing the
// cluster CA, as long as the root CA remains the same.
func hashCA(b []byte) (string, error) {
certs, err := certutil.ParseCertsPEM(b)
if err != nil {
return "", err
}
if len(certs) > 1 {
// Bundle contains more than one cert; find the root for the first cert in the bundle and
// hash the DER of this, instead of just hashing the raw bytes of the whole file.
roots := x509.NewCertPool()
intermediates := x509.NewCertPool()
for i, cert := range certs {
if i > 0 {
if len(cert.AuthorityKeyId) == 0 || bytes.Equal(cert.AuthorityKeyId, cert.SubjectKeyId) {
roots.AddCert(cert)
} else {
intermediates.AddCert(cert)
}
}
}
if chains, err := certs[0].Verify(x509.VerifyOptions{Roots: roots, Intermediates: intermediates}); err == nil {
// It's possible but unlikely that there could be multiple valid chains back to a root
// certificate. Just use the first.
chain := chains[0]
b = chain[len(chain)-1].Raw
}
}
digest := sha256.Sum256(b)
return hex.EncodeToString(digest[:]), nil
}
// ParseUsernamePassword returns the username and password portion of a token string,
// along with a bool indicating if the token was successfully parsed.
func ParseUsernamePassword(token string) (string, string, bool) {
info, err := parseToken(token)
if err != nil {
return "", "", false
}
return info.Username, info.Password, true
}
// parseToken parses a token into an Info struct
func parseToken(token string) (*Info, error) {
var info Info
if len(token) == 0 {
return nil, errors.New("token must not be empty")
}
// Turn bare password or bootstrap token into full K10 token with empty CA hash,
// for consistent parsing in the section below.
if !strings.HasPrefix(token, tokenPrefix) {
_, err := kubeadm.NewBootstrapTokenString(token)
if err != nil {
token = tokenPrefix + ":::" + token
} else {
token = tokenPrefix + "::" + token
}
}
// Strip off the prefix.
token = token[len(tokenPrefix):]
// Split into CA hash and creds.
parts := strings.SplitN(token, "::", 2)
token = parts[0]
if len(parts) > 1 {
hashLen := len(parts[0])
if hashLen > 0 && hashLen != caHashLength {
return nil, errors.New("invalid token CA hash length")
}
info.caHash = parts[0]
token = parts[1]
}
// Try to parse creds as bootstrap token string; fall back to basic auth.
// If neither works, error.
bts, err := kubeadm.NewBootstrapTokenString(token)
if err != nil {
parts = strings.SplitN(token, ":", 2)
if len(parts) != 2 || len(parts[1]) == 0 {
return nil, errors.New("invalid token format")
}
info.Username = parts[0]
info.Password = parts[1]
} else {
info.BootstrapTokenString = bts
}
return &info, nil
}
// GetHTTPClient returns a http client that validates TLS server certificates using the provided CA bundle.
// If the CA bundle is empty, it validates using the default http client using the OS CA bundle.
// If the CA bundle is not empty but does not contain any valid certs, it validates using
// an empty CA bundle (which will always fail).
// If valid cert+key paths can be loaded from the provided paths, they are used for client cert auth.
func GetHTTPClient(cacerts []byte, certFile, keyFile string) *http.Client {
if len(cacerts) == 0 {
return defaultClient
}
tlsConfig := &tls.Config{
RootCAs: x509.NewCertPool(),
}
tlsConfig.RootCAs.AppendCertsFromPEM(cacerts)
// Try to load certs from the provided cert and key. We ignore errors,
// as it is OK if the paths were empty or the files don't currently exist.
cert, err := tls.LoadX509KeyPair(certFile, keyFile)
if err == nil {
tlsConfig.Certificates = []tls.Certificate{cert}
}
return &http.Client{
Timeout: defaultClientTimeout,
Transport: &http.Transport{
DisableKeepAlives: true,
TLSClientConfig: tlsConfig,
},
}
}
// Get makes a request to a subpath of info's BaseURL
func (i *Info) Get(path string) ([]byte, error) {
u, err := url.Parse(i.BaseURL)
if err != nil {
return nil, err
}
p, err := url.Parse(path)
if err != nil {
return nil, err
}
p.Scheme = u.Scheme
p.Host = u.Host
return get(p.String(), GetHTTPClient(i.CACerts, i.CertFile, i.KeyFile), i.Username, i.Password, i.Token())
}
// Put makes a request to a subpath of info's BaseURL
func (i *Info) Put(path string, body []byte) error {
u, err := url.Parse(i.BaseURL)
if err != nil {
return err
}
p, err := url.Parse(path)
if err != nil {
return err
}
p.Scheme = u.Scheme
p.Host = u.Host
return put(p.String(), body, GetHTTPClient(i.CACerts, i.CertFile, i.KeyFile), i.Username, i.Password, i.Token())
}
// setServer sets the BaseURL and CACerts fields of the Info by connecting to the server
// and storing the CA bundle.
func (i *Info) setServer(server string) error {
url, err := url.Parse(server)
if err != nil {
return errors.Wrapf(err, "Invalid server url, failed to parse: %s", server)
}
if url.Scheme != "https" {
return errors.New("only https:// URLs are supported, invalid scheme: " + server)
}
for strings.HasSuffix(url.Path, "/") {
url.Path = url.Path[:len(url.Path)-1]
}
cacerts, err := getCACerts(*url)
if err != nil {
return err
}
i.BaseURL = url.String()
i.CACerts = cacerts
return nil
}
// ValidateCAHash validates that info's caHash matches the CACerts hash.
func (i *Info) validateCAHash() error {
if len(i.caHash) > 0 && len(i.CACerts) == 0 {
// Warn if the user provided a CA hash but we're not going to validate because it's already trusted
logrus.Warn("Cluster CA certificate is trusted by the host CA bundle. " +
"Token CA hash will not be validated.")
} else if len(i.caHash) == 0 && len(i.CACerts) > 0 {
// Warn if the CA is self-signed but the user didn't provide a hash to validate it against
logrus.Warn("Cluster CA certificate is not trusted by the host CA bundle, but the token does not include a CA hash. " +
"Use the full token from the server's node-token file to enable Cluster CA validation.")
} else if len(i.CACerts) > 0 && len(i.caHash) > 0 {
// only verify CA hash if the server cert is not trusted by the OS CA bundle
if ok, serverHash := validateCACerts(i.CACerts, i.caHash); !ok {
return fmt.Errorf("token CA hash does not match the Cluster CA certificate hash: %s != %s", i.caHash, serverHash)
}
}
return nil
}
// getCACerts retrieves the CA bundle from a server.
// An error is raised if the CA bundle cannot be retrieved,
// or if the server's cert is not signed by the returned bundle.
func getCACerts(u url.URL) ([]byte, error) {
u.Path = "/cacerts"
url := u.String()
// This first request is expected to fail. If the server has
// a cert that can be validated using the default CA bundle, return
// success with no CA certs.
_, err := get(url, defaultClient, "", "", "")
if err == nil {
return nil, nil
}
// Download the CA bundle using a client that does not validate certs.
cacerts, err := get(url, insecureClient, "", "", "")
if err != nil {
return nil, errors.Wrap(err, "failed to get CA certs")
}
// Request the CA bundle again, validating that the CA bundle can be loaded
// and used to validate the server certificate. This should only fail if we somehow
// get an empty CA bundle. or if the dynamiclistener cert is incorrectly signed.
_, err = get(url, GetHTTPClient(cacerts, "", ""), "", "", "")
if err != nil {
return nil, errors.Wrap(err, "CA cert validation failed")
}
return cacerts, nil
}
// get makes a request to a url using a provided client, username, and password,
// returning the response body.
func get(u string, client *http.Client, username, password, token string) ([]byte, error) {
req, err := http.NewRequest(http.MethodGet, u, nil)
if err != nil {
return nil, err
}
if token != "" {
req.Header.Add("Authorization", "Bearer "+token)
} else if username != "" {
req.SetBasicAuth(username, password)
}
resp, err := client.Do(req)
if err != nil {
return nil, err
}
defer resp.Body.Close()
if resp.StatusCode < 200 || resp.StatusCode > 299 {
return nil, fmt.Errorf("%s: %s", u, resp.Status)
}
return io.ReadAll(resp.Body)
}
// put makes a request to a url using a provided client, username, and password
// only an error is returned
func put(u string, body []byte, client *http.Client, username, password, token string) error {
req, err := http.NewRequest(http.MethodPut, u, bytes.NewBuffer(body))
if err != nil {
return err
}
if token != "" {
req.Header.Add("Authorization", "Bearer "+token)
} else if username != "" {
req.SetBasicAuth(username, password)
}
resp, err := client.Do(req)
if err != nil {
return err
}
defer resp.Body.Close()
respBody, _ := io.ReadAll(resp.Body)
if resp.StatusCode < 200 || resp.StatusCode > 299 {
return fmt.Errorf("%s: %s %s", u, resp.Status, string(respBody))
}
return nil
}
// FormatToken takes a username:password string or join token, and a path to a certificate bundle, and
// returns a string containing the full K10 format token string. If the credentials are
// empty, an empty token is returned. If the certificate bundle does not exist or does not
// contain a valid bundle, an error is returned.
func FormatToken(creds, certFile string) (string, error) {
if len(creds) == 0 {
return "", nil
}
b, err := os.ReadFile(certFile)
if err != nil {
return "", err
}
return FormatTokenBytes(creds, b)
}
// FormatTokenBytes has the same interface as FormatToken, but accepts a byte slice instead
// of file path.
func FormatTokenBytes(creds string, b []byte) (string, error) {
if len(creds) == 0 {
return "", nil
}
digest, err := hashCA(b)
if err != nil {
return "", err
}
return tokenPrefix + digest + "::" + creds, nil
}