573 lines
15 KiB
Go
573 lines
15 KiB
Go
|
package main
|
||
|
|
||
|
/*
|
||
|
Signs all host keys.
|
||
|
*/
|
||
|
|
||
|
import (
|
||
|
"bufio"
|
||
|
"bytes"
|
||
|
"crypto"
|
||
|
"encoding/json"
|
||
|
"encoding/pem"
|
||
|
"fmt"
|
||
|
"io"
|
||
|
"io/ioutil"
|
||
|
"net/http"
|
||
|
"os"
|
||
|
"path/filepath"
|
||
|
"strconv"
|
||
|
"strings"
|
||
|
"time"
|
||
|
|
||
|
"github.com/pkg/errors"
|
||
|
"github.com/smallstep/cli/crypto/keys"
|
||
|
"github.com/smallstep/cli/crypto/pemutil"
|
||
|
"github.com/urfave/cli"
|
||
|
"golang.org/x/crypto/ssh"
|
||
|
)
|
||
|
|
||
|
// Options available via a signing request.
|
||
|
type httpSignRequest struct {
|
||
|
Environment string
|
||
|
APIKey string
|
||
|
Type string
|
||
|
KeyID string
|
||
|
ValidPrincipals []string
|
||
|
Options map[string]string
|
||
|
Extensions map[string]string
|
||
|
PublicKeys []string
|
||
|
Duration time.Duration
|
||
|
}
|
||
|
|
||
|
// The standard API responses.
|
||
|
type httpSignResponse struct {
|
||
|
Successful bool
|
||
|
Message string
|
||
|
SignedKeys []string
|
||
|
}
|
||
|
|
||
|
// Flags for the sign command.
|
||
|
func signFlags() []cli.Flag {
|
||
|
return []cli.Flag{
|
||
|
cli.StringFlag{Name: "environment, e"},
|
||
|
cli.StringFlag{Name: "key, k"},
|
||
|
}
|
||
|
}
|
||
|
|
||
|
// Group and user data structure.
|
||
|
type Group struct {
|
||
|
Name string
|
||
|
ID uint64
|
||
|
Users []string
|
||
|
}
|
||
|
type User struct {
|
||
|
Name string
|
||
|
ID uint64
|
||
|
GID uint64
|
||
|
FullName string
|
||
|
HomeDir string
|
||
|
Shell string
|
||
|
Disabled bool
|
||
|
}
|
||
|
|
||
|
// UNIX Accounts tool set designed to easily get a group or user's information.
|
||
|
type UNIXAccounts struct {
|
||
|
Groups []*Group
|
||
|
Users []*User
|
||
|
}
|
||
|
|
||
|
// Read the /etc/group and /etc/passwd files to parse information.
|
||
|
func (u *UNIXAccounts) init() error {
|
||
|
// We do not want to parse twice.
|
||
|
if len(u.Groups) != 0 || len(u.Users) != 0 {
|
||
|
return fmt.Errorf("Already parsed accounts.")
|
||
|
}
|
||
|
|
||
|
// Open the group file.
|
||
|
groupFile, err := os.Open("/etc/group")
|
||
|
if err != nil {
|
||
|
return err
|
||
|
}
|
||
|
|
||
|
defer groupFile.Close()
|
||
|
|
||
|
groupReader := bufio.NewReader(groupFile)
|
||
|
for {
|
||
|
// Read a line and truncate it.
|
||
|
line, err := groupReader.ReadString('\n')
|
||
|
line = strings.TrimSuffix(line, "\n")
|
||
|
|
||
|
// If end of line, we are done reading.
|
||
|
if err == io.EOF {
|
||
|
break
|
||
|
}
|
||
|
// If error, something is wrong.
|
||
|
if err != nil {
|
||
|
return err
|
||
|
}
|
||
|
|
||
|
// Ignore comments.
|
||
|
if line[0] == '#' {
|
||
|
continue
|
||
|
}
|
||
|
|
||
|
// Fields are separated with a :.
|
||
|
fields := strings.Split(line, ":")
|
||
|
|
||
|
// Groups should have 4 fields. Nothing more, nothing less.
|
||
|
if len(fields) != 4 {
|
||
|
continue
|
||
|
}
|
||
|
|
||
|
// Parse information.
|
||
|
group := new(Group)
|
||
|
group.Name = fields[0]
|
||
|
group.ID, _ = strconv.ParseUint(fields[2], 10, 32)
|
||
|
group.Users = strings.Split(fields[3], ",")
|
||
|
|
||
|
// Add group to array.
|
||
|
u.Groups = append(u.Groups, group)
|
||
|
}
|
||
|
|
||
|
// Open the user file.
|
||
|
passwdFile, err := os.Open("/etc/passwd")
|
||
|
if err != nil {
|
||
|
return err
|
||
|
}
|
||
|
|
||
|
defer passwdFile.Close()
|
||
|
|
||
|
passwdReader := bufio.NewReader(passwdFile)
|
||
|
for {
|
||
|
// Read a line and truncate it.
|
||
|
line, err := passwdReader.ReadString('\n')
|
||
|
line = strings.TrimSuffix(line, "\n")
|
||
|
|
||
|
// If end of line, we are done.
|
||
|
if err == io.EOF {
|
||
|
break
|
||
|
}
|
||
|
// If error, something is wrong.
|
||
|
if err != nil {
|
||
|
return err
|
||
|
}
|
||
|
|
||
|
// Ignore comments.
|
||
|
if line[0] == '#' {
|
||
|
continue
|
||
|
}
|
||
|
|
||
|
// Fields are separated with a :.
|
||
|
fields := strings.Split(line, ":")
|
||
|
|
||
|
// Users have 7 fields. No more or less.
|
||
|
if len(fields) != 7 {
|
||
|
continue
|
||
|
}
|
||
|
|
||
|
// Prase information.
|
||
|
user := new(User)
|
||
|
user.Name = fields[0]
|
||
|
user.ID, _ = strconv.ParseUint(fields[2], 10, 32)
|
||
|
user.GID, _ = strconv.ParseUint(fields[3], 10, 32)
|
||
|
user.FullName = fields[4]
|
||
|
user.HomeDir = filepath.Clean(fields[5])
|
||
|
user.Shell = fields[6]
|
||
|
|
||
|
// A user is disabled if their shell is set to nologin or false. Users with no shell should also be disabled.
|
||
|
user.Disabled = false
|
||
|
if strings.Contains(user.Shell, "nologin") {
|
||
|
user.Disabled = true
|
||
|
}
|
||
|
if strings.Contains(user.Shell, "false") {
|
||
|
user.Disabled = true
|
||
|
}
|
||
|
if user.Shell == "" {
|
||
|
user.Disabled = true
|
||
|
}
|
||
|
|
||
|
// Add user to array.
|
||
|
u.Users = append(u.Users, user)
|
||
|
}
|
||
|
return nil
|
||
|
}
|
||
|
|
||
|
// Find user info for ID.
|
||
|
func (u *UNIXAccounts) userWithID(id uint64) *User {
|
||
|
for _, user := range u.Users {
|
||
|
if user.ID == id {
|
||
|
return user
|
||
|
}
|
||
|
}
|
||
|
return nil
|
||
|
}
|
||
|
|
||
|
// Find user info for name.
|
||
|
func (u *UNIXAccounts) userWithName(name string) *User {
|
||
|
for _, user := range u.Users {
|
||
|
if user.Name == name {
|
||
|
return user
|
||
|
}
|
||
|
}
|
||
|
return nil
|
||
|
}
|
||
|
|
||
|
// Find group info for ID.
|
||
|
func (u *UNIXAccounts) groupWithID(id uint64) *Group {
|
||
|
for _, group := range u.Groups {
|
||
|
if group.ID == id {
|
||
|
return group
|
||
|
}
|
||
|
}
|
||
|
return nil
|
||
|
}
|
||
|
|
||
|
// Find group info for name.
|
||
|
func (u *UNIXAccounts) groupWithName(name string) *Group {
|
||
|
for _, group := range u.Groups {
|
||
|
if group.Name == name {
|
||
|
return group
|
||
|
}
|
||
|
}
|
||
|
return nil
|
||
|
}
|
||
|
|
||
|
// Get all user accounts which are members of a group.
|
||
|
func (u *UNIXAccounts) usersInGroup(group *Group) []*User {
|
||
|
var users []*User
|
||
|
// Users with the Group ID set to the group's ID are a member.
|
||
|
for _, user := range u.Users {
|
||
|
if user.GID == group.ID {
|
||
|
users = append(users, user)
|
||
|
}
|
||
|
}
|
||
|
// Find user info for each member.
|
||
|
for _, name := range group.Users {
|
||
|
user := u.userWithName(name)
|
||
|
if user == nil {
|
||
|
continue
|
||
|
}
|
||
|
// If the member was added previously, we do not want duplicates.
|
||
|
alreadyExists := false
|
||
|
for _, usr := range users {
|
||
|
if usr == user {
|
||
|
alreadyExists = true
|
||
|
break
|
||
|
}
|
||
|
}
|
||
|
if !alreadyExists {
|
||
|
// The member is not a duplicate, so we add it to the array.
|
||
|
users = append(users, user)
|
||
|
}
|
||
|
}
|
||
|
return users
|
||
|
}
|
||
|
|
||
|
// The sign command calls this function.
|
||
|
func sign(c *cli.Context) error {
|
||
|
// Load the configuration.
|
||
|
config := initConfig(c)
|
||
|
// If we want to just sign a single key, pass it via the argument.
|
||
|
userKey := c.String("key")
|
||
|
// Filter the environments to a single environment.
|
||
|
environmentFilter := c.String("environment")
|
||
|
|
||
|
// If key path set in config or user key is passed in arguments,
|
||
|
// we will ignore environments in configuration and just sign the user key.
|
||
|
if config.SignOptions.KeyPath != "" || userKey != "" {
|
||
|
userPrivKey := config.SignOptions.KeyPath
|
||
|
if userKey != "" {
|
||
|
userPrivKey = userKey
|
||
|
}
|
||
|
userPubKey := userPrivKey + ".pub"
|
||
|
|
||
|
// If the user key was not generated, we need to make a new key.
|
||
|
if _, err := os.Stat(userPrivKey); err != nil {
|
||
|
// Make a key using our default type.
|
||
|
pub, priv, err := keys.GenerateKeyPair(config.KeyDefaults.Type, config.KeyDefaults.Curve, config.KeyDefaults.Size)
|
||
|
if err != nil {
|
||
|
return err
|
||
|
}
|
||
|
// If we cannot sign with the key, something is wrong.
|
||
|
if _, ok := priv.(crypto.Signer); !ok {
|
||
|
return errors.Errorf("key of type %T is not a crypto.Signer", priv)
|
||
|
}
|
||
|
// Get the public key in a form that we can generate an authorized key file.
|
||
|
sshKey, err := ssh.NewPublicKey(pub)
|
||
|
if err != nil {
|
||
|
return errors.Wrapf(err, "error converting public key")
|
||
|
}
|
||
|
// Get the private key in a format which can be saved to disk.
|
||
|
p, err := pemutil.Serialize(priv, pemutil.WithOpenSSH(true))
|
||
|
if err != nil {
|
||
|
return err
|
||
|
}
|
||
|
privKeyB := pem.EncodeToMemory(p)
|
||
|
|
||
|
// Prepare the authorized key.
|
||
|
pubKeyB := ssh.MarshalAuthorizedKey(sshKey)
|
||
|
|
||
|
// Save the private key.
|
||
|
f, err := os.Create(userPrivKey)
|
||
|
if err != nil {
|
||
|
return err
|
||
|
}
|
||
|
f.Write(privKeyB)
|
||
|
f.Close()
|
||
|
os.Chmod(userPrivKey, 0600)
|
||
|
os.Chown(userPrivKey, int(config.SignOptions.UID), int(config.SignOptions.GID))
|
||
|
|
||
|
// Save the public key.
|
||
|
f, err = os.Create(userPubKey)
|
||
|
if err != nil {
|
||
|
return err
|
||
|
}
|
||
|
f.Write(pubKeyB)
|
||
|
f.Close()
|
||
|
os.Chmod(userPubKey, 0600)
|
||
|
os.Chown(userPubKey, int(config.SignOptions.UID), int(config.SignOptions.GID))
|
||
|
}
|
||
|
|
||
|
// Setup a signing request.
|
||
|
sr := httpSignRequest{}
|
||
|
sr.Environment = config.SignOptions.Environment
|
||
|
sr.APIKey = config.SignOptions.APIKey
|
||
|
sr.Type = "user"
|
||
|
sr.KeyID = config.SignOptions.KeyID
|
||
|
sr.ValidPrincipals = config.SignOptions.ValidPrincipals
|
||
|
sr.Options = config.SignOptions.Options
|
||
|
sr.Extensions = config.SignOptions.Extensions
|
||
|
sr.Duration = config.SignOptions.Duration
|
||
|
|
||
|
// If a key ID is not set in the configuration, we will use the hostname.
|
||
|
if sr.KeyID == "" {
|
||
|
sr.KeyID, _ = os.Hostname()
|
||
|
}
|
||
|
|
||
|
// Read the user public key.
|
||
|
pubKeyFile, err := ioutil.ReadFile(userPubKey)
|
||
|
if err != nil {
|
||
|
return fmt.Errorf("Unable to read public key: %v", err)
|
||
|
}
|
||
|
|
||
|
sr.PublicKeys = append(sr.PublicKeys, string(pubKeyFile))
|
||
|
|
||
|
// Convert signing request into JSON for the request.
|
||
|
srData, err := json.Marshal(sr)
|
||
|
if err != nil {
|
||
|
return err
|
||
|
}
|
||
|
|
||
|
// Setup request.
|
||
|
req, _ := http.NewRequest("POST", config.CertServer+"/sign", bytes.NewBuffer(srData))
|
||
|
req.Header.Add("content-type", "application/json")
|
||
|
|
||
|
// Send the request.
|
||
|
res, err := http.DefaultClient.Do(req)
|
||
|
if err != nil {
|
||
|
return err
|
||
|
}
|
||
|
defer res.Body.Close()
|
||
|
|
||
|
// Parse the response.
|
||
|
var signResponse httpSignResponse
|
||
|
decoder := json.NewDecoder(res.Body)
|
||
|
err = decoder.Decode(&signResponse)
|
||
|
if err != nil {
|
||
|
return err
|
||
|
}
|
||
|
|
||
|
// If successful, we need to pull the signed keys and store them/update the sshd configurations.
|
||
|
if signResponse.Successful && len(signResponse.SignedKeys) == 1 {
|
||
|
// The name of the host certificate file is the same as the public key,
|
||
|
// but with `-cert` pre-pended to the extension.
|
||
|
certKeyFile := strings.Replace(userPubKey, ".pub", "-cert.pub", 1)
|
||
|
|
||
|
// Save the certificate file.
|
||
|
f, err := os.Create(certKeyFile)
|
||
|
if err != nil {
|
||
|
return err
|
||
|
}
|
||
|
f.WriteString(signResponse.SignedKeys[0])
|
||
|
f.Close()
|
||
|
os.Chmod(certKeyFile, 0600)
|
||
|
os.Chown(certKeyFile, int(config.SignOptions.UID), int(config.SignOptions.GID))
|
||
|
} else {
|
||
|
fmt.Println("Response from server:")
|
||
|
// The request was not successful, so there is likely a message with an error.
|
||
|
fmt.Println(signResponse.Message)
|
||
|
}
|
||
|
} else {
|
||
|
// Get all UNIX Accounts.
|
||
|
accounts := new(UNIXAccounts)
|
||
|
err := accounts.init()
|
||
|
if err != nil {
|
||
|
return err
|
||
|
}
|
||
|
|
||
|
// Go through the environments.
|
||
|
for _, environment := range config.Evironments {
|
||
|
// If we are filtering to a sepcific environment, ones which are not that environment should be skipped.
|
||
|
if environmentFilter != "" && environmentFilter != environment.Name {
|
||
|
continue
|
||
|
}
|
||
|
// If the system group is not set or if this is not setup to sign user keys, we ignore this environment.
|
||
|
if environment.SystemGroup == "" {
|
||
|
continue
|
||
|
}
|
||
|
|
||
|
fmt.Println("Signing keys for environment", environment.Name)
|
||
|
|
||
|
// Get the group for the environment from the UNIX Accounts.
|
||
|
group := accounts.groupWithName(environment.SystemGroup)
|
||
|
if group == nil {
|
||
|
continue
|
||
|
}
|
||
|
|
||
|
// Find all members of the group and loop to sign a key for each member.
|
||
|
users := accounts.usersInGroup(group)
|
||
|
for _, user := range users {
|
||
|
// If the user is disabled, we need to ignore.
|
||
|
if user.Disabled {
|
||
|
continue
|
||
|
}
|
||
|
// If the user does not already have an .ssh directory... We will ignore it.
|
||
|
if _, err := os.Stat(user.HomeDir + "/.ssh"); err != nil {
|
||
|
continue
|
||
|
}
|
||
|
|
||
|
// Get the path for the user key.
|
||
|
userPrivKey := user.HomeDir + "/.ssh/id_cert_" + environment.Name
|
||
|
userPubKey := userPrivKey + ".pub"
|
||
|
|
||
|
// If the user key was not generated, we need to make a new key.
|
||
|
if _, err := os.Stat(userPrivKey); err != nil {
|
||
|
// Make a key using our default type.
|
||
|
pub, priv, err := keys.GenerateKeyPair(config.KeyDefaults.Type, config.KeyDefaults.Curve, config.KeyDefaults.Size)
|
||
|
if err != nil {
|
||
|
return err
|
||
|
}
|
||
|
// If we cannot sign with the key, something is wrong.
|
||
|
if _, ok := priv.(crypto.Signer); !ok {
|
||
|
return errors.Errorf("key of type %T is not a crypto.Signer", priv)
|
||
|
}
|
||
|
// Get the public key in a form that we can generate an authorized key file.
|
||
|
sshKey, err := ssh.NewPublicKey(pub)
|
||
|
if err != nil {
|
||
|
return errors.Wrapf(err, "error converting public key")
|
||
|
}
|
||
|
// Get the private key in a format which can be saved to disk.
|
||
|
p, err := pemutil.Serialize(priv, pemutil.WithOpenSSH(true))
|
||
|
if err != nil {
|
||
|
return err
|
||
|
}
|
||
|
privKeyB := pem.EncodeToMemory(p)
|
||
|
|
||
|
// Prepare the authorized key.
|
||
|
pubKeyB := ssh.MarshalAuthorizedKey(sshKey)
|
||
|
|
||
|
// Save the private key.
|
||
|
f, err := os.Create(userPrivKey)
|
||
|
if err != nil {
|
||
|
return err
|
||
|
}
|
||
|
f.Write(privKeyB)
|
||
|
f.Close()
|
||
|
os.Chmod(userPrivKey, 0600)
|
||
|
os.Chown(userPrivKey, int(user.ID), int(user.GID))
|
||
|
|
||
|
// Save the public key.
|
||
|
f, err = os.Create(userPubKey)
|
||
|
if err != nil {
|
||
|
return err
|
||
|
}
|
||
|
f.Write(pubKeyB)
|
||
|
f.Close()
|
||
|
os.Chmod(userPubKey, 0600)
|
||
|
os.Chown(userPubKey, int(user.ID), int(user.GID))
|
||
|
}
|
||
|
|
||
|
// Setup a signing request.
|
||
|
sr := httpSignRequest{}
|
||
|
sr.Environment = environment.Name
|
||
|
sr.APIKey = environment.APIKey
|
||
|
sr.Type = "user"
|
||
|
|
||
|
// The key ID can have a place holder for the username.
|
||
|
keyID := environment.SignOptions.KeyID
|
||
|
keyID = strings.Replace(keyID, "USERNAME", user.Name, 10)
|
||
|
sr.KeyID = keyID
|
||
|
|
||
|
sr.ValidPrincipals = environment.SignOptions.ValidPrincipals
|
||
|
sr.Options = environment.SignOptions.Options
|
||
|
sr.Extensions = environment.SignOptions.Extensions
|
||
|
sr.Duration = environment.SignOptions.Duration
|
||
|
|
||
|
// If a key ID is not set in the configuration, we will use the hostname.
|
||
|
if sr.KeyID == "" {
|
||
|
sr.KeyID, _ = os.Hostname()
|
||
|
}
|
||
|
|
||
|
// Read the user public key.
|
||
|
pubKeyFile, err := ioutil.ReadFile(userPubKey)
|
||
|
if err != nil {
|
||
|
return fmt.Errorf("Unable to read public key: %v", err)
|
||
|
}
|
||
|
|
||
|
sr.PublicKeys = append(sr.PublicKeys, string(pubKeyFile))
|
||
|
|
||
|
// Convert signing request into JSON for the request.
|
||
|
srData, err := json.Marshal(sr)
|
||
|
if err != nil {
|
||
|
return err
|
||
|
}
|
||
|
|
||
|
// Setup request.
|
||
|
req, _ := http.NewRequest("POST", config.CertServer+"/sign", bytes.NewBuffer(srData))
|
||
|
req.Header.Add("content-type", "application/json")
|
||
|
|
||
|
// Send the request.
|
||
|
res, err := http.DefaultClient.Do(req)
|
||
|
if err != nil {
|
||
|
return err
|
||
|
}
|
||
|
defer res.Body.Close()
|
||
|
|
||
|
// Parse the response.
|
||
|
var signResponse httpSignResponse
|
||
|
decoder := json.NewDecoder(res.Body)
|
||
|
err = decoder.Decode(&signResponse)
|
||
|
if err != nil {
|
||
|
return err
|
||
|
}
|
||
|
|
||
|
// If successful, we need to pull the signed keys and store them/update the sshd configurations.
|
||
|
if signResponse.Successful && len(signResponse.SignedKeys) == 1 {
|
||
|
// The name of the host certificate file is the same as the public key,
|
||
|
// but with `-cert` pre-pended to the extension.
|
||
|
certKeyFile := strings.Replace(userPubKey, ".pub", "-cert.pub", 1)
|
||
|
|
||
|
// Save the certificate file.
|
||
|
f, err := os.Create(certKeyFile)
|
||
|
if err != nil {
|
||
|
return err
|
||
|
}
|
||
|
f.WriteString(signResponse.SignedKeys[0])
|
||
|
f.Close()
|
||
|
os.Chmod(certKeyFile, 0600)
|
||
|
os.Chown(certKeyFile, int(user.ID), int(user.GID))
|
||
|
|
||
|
fmt.Println("Signed key for", user.Name)
|
||
|
} else {
|
||
|
fmt.Println("Unable to sign key for", user.Name)
|
||
|
// The request was not successful, so there is likely a message with an error.
|
||
|
fmt.Println(signResponse.Message)
|
||
|
}
|
||
|
}
|
||
|
}
|
||
|
}
|
||
|
|
||
|
return nil
|
||
|
}
|