forked from TrueCloudLab/certificates
323 lines
9.3 KiB
Go
323 lines
9.3 KiB
Go
|
package vaultcas
|
||
|
|
||
|
import (
|
||
|
"context"
|
||
|
"crypto/x509"
|
||
|
"encoding/pem"
|
||
|
"strings"
|
||
|
"time"
|
||
|
|
||
|
"github.com/pkg/errors"
|
||
|
"github.com/smallstep/certificates/api"
|
||
|
"github.com/smallstep/certificates/cas/apiv1"
|
||
|
|
||
|
vault "github.com/hashicorp/vault/api"
|
||
|
auth "github.com/hashicorp/vault/api/auth/approle"
|
||
|
certutil "github.com/hashicorp/vault/sdk/helper/certutil"
|
||
|
mapstructure "github.com/mitchellh/mapstructure"
|
||
|
)
|
||
|
|
||
|
func init() {
|
||
|
apiv1.Register(apiv1.VaultCAS, func(ctx context.Context, opts apiv1.Options) (apiv1.CertificateAuthorityService, error) {
|
||
|
return New(ctx, opts)
|
||
|
})
|
||
|
}
|
||
|
|
||
|
type VaultOptions struct {
|
||
|
PKI string `json:"pki,omitempty"`
|
||
|
PKIRole string `json:"pkiRole,omitempty`
|
||
|
PKIRoleRSA string `json:"pkiRoleRSA,omitempty`
|
||
|
PKIRoleEC string `json:"pkiRoleEC,omitempty`
|
||
|
PKIRoleED25519 string `json:"PKIRoleED25519,omitempty`
|
||
|
RoleID string `json:"roleID,omitempty"`
|
||
|
SecretID string `json:"secretID,omitempty"`
|
||
|
AppRole string `json:"appRole,omitempty"`
|
||
|
IsWrappingToken bool `json:"isWrappingToken,omitempty"`
|
||
|
}
|
||
|
|
||
|
// VaultCAS implements a Certificate Authority Service using Hashicorp Vault.
|
||
|
type VaultCAS struct {
|
||
|
client *vault.Client
|
||
|
config VaultOptions
|
||
|
fingerprint string
|
||
|
}
|
||
|
|
||
|
func loadOptions(config map[string]interface{}) (vc VaultOptions, err error) {
|
||
|
err = mapstructure.Decode(config, &vc)
|
||
|
if err != nil {
|
||
|
return vc, err
|
||
|
}
|
||
|
|
||
|
if vc.PKI == "" {
|
||
|
vc.PKI = "pki" // use default pki vault name
|
||
|
}
|
||
|
|
||
|
// pkirole or per key type must be defined
|
||
|
if vc.PKIRole == "" && vc.PKIRoleRSA == "" && vc.PKIRoleEC == "" && vc.PKIRoleED25519 == "" {
|
||
|
return vc, errors.New("loadOptions you must define a pki role")
|
||
|
}
|
||
|
|
||
|
// if pkirole is empty all others keys must be set
|
||
|
if vc.PKIRole == "" && (vc.PKIRoleRSA == "" || vc.PKIRoleEC == "" || vc.PKIRoleED25519 == "") {
|
||
|
return vc, errors.New("loadOptions if 'pkiRole' is empty, PKIRoleRSA, PKIRoleEC and PKIRoleED25519 cannot be empty")
|
||
|
}
|
||
|
|
||
|
// if pkirole is not empty, use it as default for unset keys
|
||
|
if vc.PKIRole != "" {
|
||
|
if vc.PKIRoleRSA == "" {
|
||
|
vc.PKIRoleRSA = vc.PKIRole
|
||
|
}
|
||
|
if vc.PKIRoleEC == "" {
|
||
|
vc.PKIRoleEC = vc.PKIRole
|
||
|
}
|
||
|
if vc.PKIRoleED25519 == "" {
|
||
|
vc.PKIRoleED25519 = vc.PKIRole
|
||
|
}
|
||
|
}
|
||
|
|
||
|
if vc.RoleID == "" {
|
||
|
return vc, errors.New("loadOptions 'roleID' cannot be empty")
|
||
|
}
|
||
|
|
||
|
if vc.SecretID == "" {
|
||
|
return vc, errors.New("loadOptions 'secretID' cannot be empty")
|
||
|
}
|
||
|
|
||
|
if vc.AppRole == "" {
|
||
|
vc.AppRole = "auth/approle"
|
||
|
}
|
||
|
|
||
|
return vc, nil
|
||
|
}
|
||
|
|
||
|
func getCertificateAndChain(certb certutil.CertBundle) (*x509.Certificate, []*x509.Certificate, error) {
|
||
|
cert, err := parseCertificate(certb.Certificate)
|
||
|
if err != nil {
|
||
|
return nil, nil, err
|
||
|
}
|
||
|
chain := make([]*x509.Certificate, len(certb.CAChain))
|
||
|
for i := range certb.CAChain {
|
||
|
chain[i], err = parseCertificate(certb.CAChain[i])
|
||
|
if err != nil {
|
||
|
return nil, nil, err
|
||
|
}
|
||
|
}
|
||
|
return cert, chain, nil
|
||
|
}
|
||
|
|
||
|
func parseCertificate(pemCert string) (*x509.Certificate, error) {
|
||
|
block, _ := pem.Decode([]byte(pemCert))
|
||
|
if block == nil {
|
||
|
return nil, errors.Errorf("parseCertificate: error decoding certificate: not a valid PEM encoded block '%v'", pemCert)
|
||
|
}
|
||
|
cert, err := x509.ParseCertificate(block.Bytes)
|
||
|
if err != nil {
|
||
|
return nil, errors.Wrap(err, "parseCertificate: error parsing certificate")
|
||
|
}
|
||
|
return cert, nil
|
||
|
}
|
||
|
|
||
|
func parseCertificateRequest(pemCsr string) (*x509.CertificateRequest, error) {
|
||
|
block, _ := pem.Decode([]byte(pemCsr))
|
||
|
if block == nil {
|
||
|
return nil, errors.New("error decoding certificate request: not a valid PEM encoded block")
|
||
|
}
|
||
|
cr, err := x509.ParseCertificateRequest(block.Bytes)
|
||
|
if err != nil {
|
||
|
return nil, errors.Wrap(err, "error parsing certificate request")
|
||
|
}
|
||
|
return cr, nil
|
||
|
}
|
||
|
|
||
|
func (v *VaultCAS) createCertificate(cr *x509.CertificateRequest, lifetime time.Duration) (*x509.Certificate, []*x509.Certificate, error) {
|
||
|
sans := make([]string, 0, len(cr.DNSNames)+len(cr.EmailAddresses)+len(cr.IPAddresses)+len(cr.URIs))
|
||
|
sans = append(sans, cr.DNSNames...)
|
||
|
sans = append(sans, cr.EmailAddresses...)
|
||
|
for _, ip := range cr.IPAddresses {
|
||
|
sans = append(sans, ip.String())
|
||
|
}
|
||
|
for _, u := range cr.URIs {
|
||
|
sans = append(sans, u.String())
|
||
|
}
|
||
|
|
||
|
commonName := cr.Subject.CommonName
|
||
|
if commonName == "" && len(sans) > 0 {
|
||
|
commonName = sans[0]
|
||
|
}
|
||
|
|
||
|
var vaultPKIRole string
|
||
|
csr := api.CertificateRequest{CertificateRequest: cr}
|
||
|
|
||
|
switch {
|
||
|
case csr.PublicKeyAlgorithm == x509.RSA:
|
||
|
vaultPKIRole = v.config.PKIRoleRSA
|
||
|
case csr.PublicKeyAlgorithm == x509.ECDSA:
|
||
|
vaultPKIRole = v.config.PKIRoleEC
|
||
|
case csr.PublicKeyAlgorithm == x509.Ed25519:
|
||
|
vaultPKIRole = v.config.PKIRoleED25519
|
||
|
default:
|
||
|
return nil, nil, errors.Errorf("createCertificate: Unsupported public key algorithm '%v'", csr.PublicKeyAlgorithm)
|
||
|
}
|
||
|
|
||
|
certPemBytes := pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE REQUEST", Bytes: csr.Raw})
|
||
|
if certPemBytes == nil {
|
||
|
return nil, nil, errors.Errorf("createCertificate: Failed to encode pem '%v'", csr.Raw)
|
||
|
}
|
||
|
|
||
|
y := map[string]interface{}{
|
||
|
"csr": string(certPemBytes),
|
||
|
"format": "pem_bundle",
|
||
|
"ttl": lifetime.Seconds(),
|
||
|
}
|
||
|
|
||
|
secret, err := v.client.Logical().Write(v.config.PKI+"/sign/"+vaultPKIRole, y)
|
||
|
if err != nil {
|
||
|
return nil, nil, errors.Wrapf(err, "createCertificate: unable to sign certificate %v", y)
|
||
|
}
|
||
|
if secret == nil {
|
||
|
return nil, nil, errors.New("createCertificate: secret sign is empty")
|
||
|
}
|
||
|
|
||
|
var certBundle certutil.CertBundle
|
||
|
|
||
|
err = mapstructure.Decode(secret.Data, &certBundle)
|
||
|
if err != nil {
|
||
|
return nil, nil, err
|
||
|
}
|
||
|
|
||
|
// Return certificate and certificate chain
|
||
|
return getCertificateAndChain(certBundle)
|
||
|
}
|
||
|
|
||
|
// New creates a new CertificateAuthorityService implementation
|
||
|
// using Hashicorp Vault
|
||
|
func New(ctx context.Context, opts apiv1.Options) (*VaultCAS, error) {
|
||
|
if opts.CertificateAuthority == "" {
|
||
|
return nil, errors.New("vaultCAS 'certificateAuthority' cannot be empty")
|
||
|
}
|
||
|
|
||
|
if opts.CertificateAuthorityFingerprint == "" {
|
||
|
return nil, errors.New("vaultCAS 'certificateAuthorityFingerprint' cannot be empty")
|
||
|
}
|
||
|
|
||
|
vc, err := loadOptions(opts.Config)
|
||
|
if err != nil {
|
||
|
return nil, err
|
||
|
}
|
||
|
|
||
|
config := vault.DefaultConfig()
|
||
|
config.Address = opts.CertificateAuthority
|
||
|
|
||
|
client, err := vault.NewClient(config)
|
||
|
if err != nil {
|
||
|
return nil, errors.Wrap(err, "unable to initialize vault client")
|
||
|
}
|
||
|
|
||
|
var appRoleAuth *auth.AppRoleAuth
|
||
|
if vc.IsWrappingToken == true {
|
||
|
appRoleAuth, err = auth.NewAppRoleAuth(
|
||
|
vc.RoleID,
|
||
|
&auth.SecretID{FromString: vc.SecretID},
|
||
|
auth.WithWrappingToken(),
|
||
|
auth.WithMountPath(vc.AppRole),
|
||
|
)
|
||
|
} else {
|
||
|
appRoleAuth, err = auth.NewAppRoleAuth(
|
||
|
vc.RoleID,
|
||
|
&auth.SecretID{FromString: vc.SecretID},
|
||
|
auth.WithMountPath(vc.AppRole),
|
||
|
)
|
||
|
}
|
||
|
if err != nil {
|
||
|
return nil, errors.Wrap(err, "unable to initialize AppRole auth method")
|
||
|
}
|
||
|
|
||
|
authInfo, err := client.Auth().Login(ctx, appRoleAuth)
|
||
|
if err != nil {
|
||
|
return nil, errors.Wrap(err, "unable to login to AppRole auth method")
|
||
|
}
|
||
|
if authInfo == nil {
|
||
|
return nil, errors.New("no auth info was returned after login")
|
||
|
}
|
||
|
|
||
|
return &VaultCAS{
|
||
|
client: client,
|
||
|
config: vc,
|
||
|
fingerprint: opts.CertificateAuthorityFingerprint,
|
||
|
}, nil
|
||
|
}
|
||
|
|
||
|
// CreateCertificate signs a new certificate using Hashicorp Vault.
|
||
|
func (v *VaultCAS) CreateCertificate(req *apiv1.CreateCertificateRequest) (*apiv1.CreateCertificateResponse, error) {
|
||
|
switch {
|
||
|
case req.CSR == nil:
|
||
|
return nil, errors.New("CreateCertificate: `CSR` cannot be nil")
|
||
|
case req.Lifetime == 0:
|
||
|
return nil, errors.New("CreateCertificate: `LIFETIME` cannot be 0")
|
||
|
}
|
||
|
|
||
|
cert, chain, err := v.createCertificate(req.CSR, req.Lifetime)
|
||
|
if err != nil {
|
||
|
return nil, err
|
||
|
}
|
||
|
|
||
|
return &apiv1.CreateCertificateResponse{
|
||
|
Certificate: cert,
|
||
|
CertificateChain: chain,
|
||
|
}, nil
|
||
|
}
|
||
|
|
||
|
func (v *VaultCAS) GetCertificateAuthority(req *apiv1.GetCertificateAuthorityRequest) (*apiv1.GetCertificateAuthorityResponse, error) {
|
||
|
secret, err := v.client.Logical().Read(v.config.PKI + "/cert/ca")
|
||
|
if err != nil {
|
||
|
return nil, errors.Wrap(err, "GetCertificateAuthority: unable to read root")
|
||
|
}
|
||
|
if secret == nil {
|
||
|
return nil, errors.New("GetCertificateAuthority: secret root is empty")
|
||
|
}
|
||
|
|
||
|
var certBundle certutil.CertBundle
|
||
|
|
||
|
err = mapstructure.Decode(secret.Data, &certBundle)
|
||
|
if err != nil {
|
||
|
return nil, err
|
||
|
}
|
||
|
|
||
|
cert, _, err := getCertificateAndChain(certBundle)
|
||
|
if err != nil {
|
||
|
return nil, err
|
||
|
}
|
||
|
return &apiv1.GetCertificateAuthorityResponse{
|
||
|
RootCertificate: cert,
|
||
|
}, nil
|
||
|
}
|
||
|
|
||
|
// RenewCertificate will always return a non-implemented error as renewals
|
||
|
// are not supported yet.
|
||
|
func (v *VaultCAS) RenewCertificate(req *apiv1.RenewCertificateRequest) (*apiv1.RenewCertificateResponse, error) {
|
||
|
return nil, apiv1.ErrNotImplemented{Message: "vaultCAS does not support renewals"}
|
||
|
}
|
||
|
|
||
|
func (v *VaultCAS) RevokeCertificate(req *apiv1.RevokeCertificateRequest) (*apiv1.RevokeCertificateResponse, error) {
|
||
|
if req.SerialNumber == "" && req.Certificate == nil {
|
||
|
return nil, errors.New("RevokeCertificate `serialNumber` or `certificate` are required")
|
||
|
}
|
||
|
|
||
|
serialNumber := req.SerialNumber
|
||
|
if req.Certificate != nil {
|
||
|
serialNumber = req.Certificate.SerialNumber.String()
|
||
|
}
|
||
|
|
||
|
serialNumberDash := strings.ReplaceAll(serialNumber, ":", "-")
|
||
|
|
||
|
_, err := v.client.Logical().Write(v.config.PKI+"/revoke/"+serialNumberDash, nil)
|
||
|
if err != nil {
|
||
|
return nil, errors.Wrap(err, "RevokeCertificate unable to revoke certificate")
|
||
|
}
|
||
|
|
||
|
return &apiv1.RevokeCertificateResponse{
|
||
|
Certificate: req.Certificate,
|
||
|
CertificateChain: nil,
|
||
|
}, nil
|
||
|
}
|