nuts-foundation/nuts-node

View on GitHub
http/tokenV2/authorized_keys.go

Summary

Maintainability
A
0 mins
Test Coverage
C
78%
/*
 * Copyright (C) 2023 Nuts community
 *
 * This program is free software: you can redistribute it and/or modify
 * it under the terms of the GNU General Public License as published by
 * the Free Software Foundation, either version 3 of the License, or
 * (at your option) any later version.
 *
 * This program is distributed in the hope that it will be useful,
 * but WITHOUT ANY WARRANTY; without even the implied warranty of
 * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
 * GNU General Public License for more details.
 *
 * You should have received a copy of the GNU General Public License
 * along with this program.  If not, see <https://www.gnu.org/licenses/>.
 *
 */

package tokenV2

import (
    "crypto"
    "crypto/ecdsa"
    "crypto/ed25519"
    "crypto/rsa"
    b64 "encoding/base64"
    "fmt"
    "strings"

    "golang.org/x/crypto/ssh"

    "github.com/nuts-foundation/nuts-node/http/log"

    "github.com/lestrrat-go/jwx/v2/jwk"
)

// minimumRSAKeySize defines the minimum length in bits of RSA keys
const minimumRSAKeySize = 2048

// authorizedKey is an SSH authorized key
type authorizedKey struct {
    keyID   string
    key     ssh.PublicKey
    comment string
    options []string
    jwkSet  jwk.Set
}

// String returns a string representation of an authorized key
func (a authorizedKey) String() string {
    encodedOptions := strings.Join(a.options, ",")
    if encodedOptions != "" {
        encodedOptions += " "
    }
    return fmt.Sprintf("%v%v %v %v", encodedOptions, a.key.Type(), b64.StdEncoding.EncodeToString(a.key.Marshal()), a.comment)
}

// cryptoPublicKey converts a standard SSH library key to a stdlib crypto/* key
func cryptoPublicKey(key ssh.PublicKey) (interface{}, error) {
    // Ensure the provided key implements the optional ssh.CryptoPublicKey interface, which
    // is able to return standard go crypto primitives. These primitives are needed to convert
    // the key into a JWX jwk key.
    var standardKey interface{}
    if cryptoPublicKey, ok := key.(ssh.CryptoPublicKey); ok {
        // Convert the ssh.PublicKey type to a go standard library crypto type (of unknown/interface{} type).
        standardKey = cryptoPublicKey.CryptoPublicKey()
    } else {
        return nil, fmt.Errorf("key (%T) does not implement the ssh.CryptoPublicKey interface and cannot be converted", key)
    }

    return standardKey, nil
}

// jwkFromSSHKey converts a standard SSH library key to a JWX jwk.Key type
func jwkFromSSHKey(key ssh.PublicKey) (jwk.Key, error) {
    // Convert the SSH key to a stdlib crypto/* key
    cryptoPublicKey, err := cryptoPublicKey(key)
    if err != nil {
        return nil, err
    }

    // Use the crypto/* key type to create the jwk key type
    converted, err := jwk.FromRaw(cryptoPublicKey)
    if err != nil {
        return nil, err
    }

    // On successful conversion also set the key ID
    if err := converted.Set(jwk.KeyIDKey, ssh.FingerprintSHA256(key)); err != nil {
        return nil, fmt.Errorf("failed to set key id: %w", err)
    }

    return converted, nil
}

// parseAuthorizedKeys parses the contents of an SSH authorized_keys file
// into data structures and usable crypto primitives
func parseAuthorizedKeys(contents []byte) ([]authorizedKey, error) {
    // Split the contents by read
    lines := strings.Split(string(contents), "\n")

    // Loop over each line in the authorized_keys file
    var authorizedKeys []authorizedKey
    for _, line := range lines {
        // Split the line into the parseable portion and the commented out (after #) portion
        lineParts := strings.SplitN(line, "#", 2)
        if len(lineParts) == 0 {
            continue
        }
        line := lineParts[0]

        // Trim leading and trailing whitespace
        line = strings.TrimLeft(line, " \t")
        line = strings.TrimRight(line, " \t")

        // Skip empty lines
        if line == "" {
            continue
        }

        // Parse this single authorized key entry
        publicKey, comment, options, rest, err := ssh.ParseAuthorizedKey([]byte(line))
        if err != nil {
            return nil, fmt.Errorf("unparseable line (%v): %w", line, err)
        }

        // Ignore insecure keys
        if secure, err := keyIsSecure(publicKey); !secure || err != nil {
            log.Logger().WithError(err).Warnf("Ignoring insecure authorized_keys entry: %v", line)
            continue
        }

        // Trim whitespace from the comment/username
        comment = strings.TrimSpace(comment)

        // Ignore keys without a comment/username
        if comment == "" {
            log.Logger().Warnf("Ignoring authorized_keys entry without comment/username: %v", line)
            continue
        }

        // Ensure rest is empty, meaning the entire line was parsed
        if rest != nil {
            return nil, fmt.Errorf("line not completely parseable: %v: rest=%v", line, string(rest))
        }

        // Build a JWK key set to represent this authorized public key
        jwkSet, err := buildKeySet(publicKey)
        if err != nil {
            return nil, fmt.Errorf("failed to build key set: %w", err)
        }

        // Get the fingerprint of the key
        fingerprint := ssh.FingerprintSHA256(publicKey)

        // Build the struct
        authorizedKeys = append(authorizedKeys, authorizedKey{
            keyID:   fingerprint,
            key:     publicKey,
            comment: comment,
            options: options,
            jwkSet:  jwkSet,
        })
    }

    return authorizedKeys, nil
}

// keyIsSecure returns true, nil if a key is considered secure
func keyIsSecure(key ssh.PublicKey) (bool, error) {
    // Convert the SSH key to a stdlib crypto/* key
    cryptoPublicKey, err := cryptoPublicKey(key)
    if err != nil {
        return false, err
    }

    // Implement a whitelist of accepted key types
    switch rawKey := cryptoPublicKey.(type) {
    // Accept RSA keys >= 2048-bit in length
    case *rsa.PublicKey:
        // Accept RSA keys large enough
        if bitLen := rawKey.N.BitLen(); bitLen >= minimumRSAKeySize {
            return true, nil
        }

        // Reject RSA keys less than 2048 bits in length as they are considered weak
        return false, fmt.Errorf("key is too weak (rsa keys must be at least %d-bit)", minimumRSAKeySize)

    // Accept ECDSA keys
    case *ecdsa.PublicKey:
        return true, nil

    // Accept Edwards curve keys
    case ed25519.PublicKey:
        return true, nil

    // Reject all other keys by default
    default:
        return false, fmt.Errorf("unsupported key type: %T", cryptoPublicKey)
    }
}

func buildKeySet(key ssh.PublicKey) (jwk.Set, error) {
    // Start with an empty key set
    keySet := jwk.NewSet()

    // Add the key with a primary (SSH) fingerprint kid
    keyPrimary, err := jwkFromSSHKey(key)
    if err != nil {
        return nil, fmt.Errorf("failed to convert SSH key to jwk: %w", err)
    }
    _ = keySet.AddKey(keyPrimary)

    // Create an alternate representaiton of the jwk, which will have a different kid
    keyAlt, err := jwkFromSSHKey(key)
    if err != nil {
        return nil, fmt.Errorf("failed to convert SSH key to jwk: %w", err)
    }

    // Remove any existing key ID
    if err := keyAlt.Remove(jwk.KeyIDKey); err != nil {
        return nil, fmt.Errorf("failed to remove kid: %w", err)
    }

    // Rebuild the key ID using the JWK SHA256 fingerprint
    if err := jwk.AssignKeyID(keyAlt, jwk.WithThumbprintHash(crypto.SHA256)); err != nil {
        return nil, fmt.Errorf("failed to fingerprint key: %w", err)
    }

    // Add the alternate jwk to the key set
    _ = keySet.AddKey(keyAlt)

    // Return the key set which contains the key twice: once with SSH fingerprint and once with JWK fingerprint
    return keySet, nil
}