Initial PKI backend implementation.

Complete:
* Up-to-date API documents
* Backend configuration (root certificate and private key)
* Highly granular role configuration
* Certificate generation
* CN checking against role
* IP and DNS subject alternative names
* Server, client, and code signing usage types
* Later certificate (but not private key) retrieval
* CRL creation and update
* CRL/CA bare endpoints (for cert extensions)
* Revocation (both Vault-native and by serial number)
* CRL force-rotation endpoint

Missing:
* OCSP support (can't implement without changes in Vault)
* Unit tests

Commit contents (C)2015 Akamai Technologies, Inc. <opensource@akamai.com>
This commit is contained in:
Jeff Mitchell 2015-05-15 12:13:05 -04:00
parent f355049ef1
commit 0d832de65d
12 changed files with 2211 additions and 0 deletions

View File

@ -0,0 +1,67 @@
package pki
import (
"strings"
"github.com/hashicorp/vault/logical"
"github.com/hashicorp/vault/logical/framework"
)
// Factory creates a new backend implementing the logical.Backend interface
func Factory(map[string]string) (logical.Backend, error) {
return Backend(), nil
}
// Backend returns a new Backend framework struct
func Backend() *framework.Backend {
var b backend
b.Backend = &framework.Backend{
Help: strings.TrimSpace(backendHelp),
PathsSpecial: &logical.Paths{
Root: []string{
"config/*",
"revoked/*",
"revoke/*",
"crl/build",
},
Unauthenticated: []string{
"cert/*",
"ca/pem",
"ca",
"crl/pem",
"crl",
},
},
Paths: []*framework.Path{
pathRoles(&b),
pathConfigCA(&b),
pathIssue(&b),
pathRotateCRL(&b),
pathFetchCA(&b),
pathFetchCRL(&b),
pathFetchCRLViaCertPath(&b),
pathFetchValid(&b),
pathFetchRevoked(&b),
pathRevoke(&b),
},
Secrets: []*framework.Secret{
secretCerts(&b),
},
}
return b.Backend
}
type backend struct {
*framework.Backend
}
const backendHelp = `
The PKI backend dynamically generates X509 server and client certificates.
After mounting this backend, configure the CA using the "ca_bundle" endpoint within
the "config/" path.
`

View File

@ -0,0 +1,382 @@
package pki
import (
"bytes"
"crypto"
"crypto/ecdsa"
"crypto/elliptic"
crand "crypto/rand"
"crypto/rsa"
"crypto/sha1"
"crypto/x509"
"crypto/x509/pkix"
"encoding/base64"
"fmt"
"math/big"
mrand "math/rand"
"net"
"regexp"
"strings"
"time"
"github.com/hashicorp/vault/logical"
)
// The type of Private Key, for storage
const (
UnknownPrivateKeyType = iota
RSAPrivateKeyType
ECDSAPrivateKeyType
)
type certUsage int
const (
serverUsage certUsage = 1 << iota
clientUsage
codeSigningUsage
)
type certBundle struct {
PrivateKeyType int `json:"private_key_type"`
PrivateKeyString string `json:"private_key_string"`
CertificateString string `json:"certificate_string"`
}
type rawCertBundle struct {
PrivateKeyType int
PrivateKeyBytes []byte
CertificateBytes []byte
SerialNumber *big.Int
}
type certCreationBundle struct {
RawSigningBundle *rawCertBundle
CACert *x509.Certificate
CommonNames []string
IPSANs []net.IP
KeyType string
KeyBits int
Lease time.Duration
Usage certUsage
}
func (c *certBundle) toRawCertBundle() (*rawCertBundle, error) {
decoder := base64.URLEncoding
result := &rawCertBundle{
PrivateKeyType: c.PrivateKeyType,
}
var err error
if result.PrivateKeyBytes, err = decoder.DecodeString(c.PrivateKeyString); err != nil {
return nil, err
}
if result.CertificateBytes, err = decoder.DecodeString(c.CertificateString); err != nil {
return nil, err
}
if err := result.populateSerialNumber(); err != nil {
return nil, err
}
return result, nil
}
func (r *rawCertBundle) toCertBundle() *certBundle {
encoder := base64.URLEncoding
result := &certBundle{
PrivateKeyType: r.PrivateKeyType,
PrivateKeyString: encoder.EncodeToString(r.PrivateKeyBytes),
CertificateString: encoder.EncodeToString(r.CertificateBytes),
}
return result
}
func (r *rawCertBundle) populateSerialNumber() error {
cert, err := x509.ParseCertificate(r.CertificateBytes)
if err != nil {
return fmt.Errorf("Error encountered parsing certificate bytes from raw bundle")
}
r.SerialNumber = cert.SerialNumber
return nil
}
// "Signer" corresponds to the Go interface that private keys implement
// that provides a Public() function for getting the corresponding public
// key. It can be type converted to private keys.
func (r *rawCertBundle) getSigner() (crypto.Signer, error) {
var signer crypto.Signer
var err error
switch r.PrivateKeyType {
case ECDSAPrivateKeyType:
signer, err = x509.ParseECPrivateKey(r.PrivateKeyBytes)
if err != nil {
return nil, fmt.Errorf("Unable to parse CA's private EC key: %s", err)
}
case RSAPrivateKeyType:
signer, err = x509.ParsePKCS1PrivateKey(r.PrivateKeyBytes)
if err != nil {
return nil, fmt.Errorf("Unable to parse CA's private RSA key: %s", err)
}
default:
return nil, fmt.Errorf("Unable to determine the type of private key")
}
return signer, nil
}
func (r *rawCertBundle) getSubjKeyID() ([]byte, error) {
privateKey, err := r.getSigner()
if err != nil {
return nil, err
}
marshaledKey, err := x509.MarshalPKIXPublicKey(privateKey.Public())
if err != nil {
return nil, fmt.Errorf("Error marshalling public key: %s", err)
}
subjKeyID := sha1.Sum(marshaledKey)
return subjKeyID[:], nil
}
func getCertBundle(s logical.Storage, path string) (*certBundle, error) {
bundle, err := s.Get(path)
if err != nil {
return nil, err
}
if bundle == nil {
return nil, nil
}
var result certBundle
if err := bundle.DecodeJSON(&result); err != nil {
return nil, err
}
return &result, nil
}
func getOctalFormatted(buf []byte, sep string) string {
var ret bytes.Buffer
for _, cur := range buf {
if ret.Len() > 0 {
fmt.Fprintf(&ret, sep)
}
fmt.Fprintf(&ret, "%02x", cur)
}
return ret.String()
}
func fetchCAInfo(req *logical.Request) (*rawCertBundle, *x509.Certificate, error) {
bundle, err := getCertBundle(req.Storage, "config/ca_bundle")
if err != nil {
return nil, nil, fmt.Errorf("Unable to fetch local CA certificate/key: %s", err)
}
if bundle == nil {
return nil, nil, fmt.Errorf("Backend must be configured with a CA certificate/key")
}
rawBundle, err := bundle.toRawCertBundle()
if err != nil {
return nil, nil, err
}
certificates, err := x509.ParseCertificates(rawBundle.CertificateBytes)
switch {
case err != nil:
return nil, nil, err
case len(certificates) != 1:
return nil, nil, fmt.Errorf("Length of CA certificate bundle is wrong")
}
return rawBundle, certificates[0], nil
}
func fetchCertBySerial(req *logical.Request, prefix, serial string) (certEntry *logical.StorageEntry, userError, internalError error) {
var path string
var err error
switch {
case serial == "ca":
path = "ca"
case serial == "crl":
path = "crl"
case strings.HasPrefix(prefix, "revoked/"):
path = "revoked/" + strings.Replace(strings.ToLower(serial), "-", ":", -1)
default:
path = "certs/" + strings.Replace(strings.ToLower(serial), "-", ":", -1)
}
certEntry, err = req.Storage.Get(path)
if err != nil || certEntry == nil {
return nil, fmt.Errorf("Certificate with serial number %s not found (if it has been revoked, the revoked/ endpoint must be used)", serial), nil
}
if len(certEntry.Value) == 0 {
return nil, nil, fmt.Errorf("Returned certificate bytes for serial %s were empty", serial)
}
return
}
func validateCommonNames(req *logical.Request, commonNames []string, role *roleEntry) (string, error) {
// TODO: handle wildcards
hostnameRegex, err := regexp.Compile(`^(([a-zA-Z0-9]|[a-zA-Z0-9][a-zA-Z0-9\-]*[a-zA-Z0-9])\.)*([A-Za-z0-9]|[A-Za-z0-9][A-Za-z0-9\-]*[A-Za-z0-9])$`)
if err != nil {
return "", fmt.Errorf("Error compiling hostname regex: %s", err)
}
subdomainRegex, err := regexp.Compile(`^(([a-zA-Z0-9]|[a-zA-Z0-9][a-zA-Z0-9\-]*[a-zA-Z0-9]))*$`)
if err != nil {
return "", fmt.Errorf("Error compiling subdomain regex: %s", err)
}
for _, name := range commonNames {
if role.AllowLocalhost && name == "localhost" {
continue
}
sanitizedName := name
isWildcard := false
if strings.HasPrefix(name, "*.") {
sanitizedName = name[2:]
isWildcard = true
}
if !hostnameRegex.MatchString(sanitizedName) {
return name, nil
}
if role.AllowAnyName {
continue
}
if role.AllowTokenDisplayName {
if name == req.DisplayName {
continue
}
if role.AllowSubdomains {
if strings.HasSuffix(name, "."+req.DisplayName) {
continue
}
}
}
if len(role.AllowedBaseDomain) != 0 {
if strings.HasSuffix(name, "."+role.AllowedBaseDomain) {
if role.AllowSubdomains {
continue
}
if subdomainRegex.MatchString(strings.TrimSuffix(name, "."+role.AllowedBaseDomain)) {
continue
}
if isWildcard && role.AllowedBaseDomain == sanitizedName {
continue
}
}
}
return name, nil
}
return "", nil
}
func createCertificate(creationInfo *certCreationBundle) (rawBundle *rawCertBundle, userErr, intErr error) {
rawBundle = &rawCertBundle{
SerialNumber: (&big.Int{}).Rand(mrand.New(mrand.NewSource(time.Now().UnixNano())), (&big.Int{}).Exp(big.NewInt(2), big.NewInt(159), nil)),
}
var clientPrivKey crypto.Signer
var err error
switch creationInfo.KeyType {
case "rsa":
rawBundle.PrivateKeyType = RSAPrivateKeyType
clientPrivKey, err = rsa.GenerateKey(crand.Reader, creationInfo.KeyBits)
if err != nil {
return nil, nil, fmt.Errorf("Error generating RSA private key")
}
rawBundle.PrivateKeyBytes = x509.MarshalPKCS1PrivateKey(clientPrivKey.(*rsa.PrivateKey))
case "ecdsa":
rawBundle.PrivateKeyType = ECDSAPrivateKeyType
var curve elliptic.Curve
switch creationInfo.KeyBits {
case 224:
curve = elliptic.P224()
case 256:
curve = elliptic.P256()
case 384:
curve = elliptic.P384()
case 521:
curve = elliptic.P521()
default:
return nil, fmt.Errorf("Unsupported bit length for ECDSA key: %d", creationInfo.KeyBits), nil
}
clientPrivKey, err = ecdsa.GenerateKey(curve, crand.Reader)
if err != nil {
return nil, nil, fmt.Errorf("Error generating ECDSA private key")
}
rawBundle.PrivateKeyBytes, err = x509.MarshalECPrivateKey(clientPrivKey.(*ecdsa.PrivateKey))
if err != nil {
return nil, nil, fmt.Errorf("Error marshalling ECDSA private key")
}
default:
return nil, fmt.Errorf("Unknown key type: %s", creationInfo.KeyType), nil
}
subjKeyID, err := rawBundle.getSubjKeyID()
if err != nil {
return nil, nil, fmt.Errorf("Error getting subject key ID: %s", err)
}
subject := pkix.Name{
Country: creationInfo.CACert.Subject.Country,
Organization: creationInfo.CACert.Subject.Organization,
OrganizationalUnit: creationInfo.CACert.Subject.OrganizationalUnit,
Locality: creationInfo.CACert.Subject.Locality,
Province: creationInfo.CACert.Subject.Province,
StreetAddress: creationInfo.CACert.Subject.StreetAddress,
PostalCode: creationInfo.CACert.Subject.PostalCode,
SerialNumber: rawBundle.SerialNumber.String(),
CommonName: creationInfo.CommonNames[0],
}
certTemplate := &x509.Certificate{
SignatureAlgorithm: x509.SHA256WithRSA,
SerialNumber: rawBundle.SerialNumber,
Subject: subject,
NotBefore: time.Now(),
NotAfter: time.Now().Add(creationInfo.Lease),
KeyUsage: x509.KeyUsage(x509.KeyUsageDigitalSignature | x509.KeyUsageKeyEncipherment | x509.KeyUsageKeyAgreement),
BasicConstraintsValid: true,
IsCA: false,
SubjectKeyId: subjKeyID,
DNSNames: creationInfo.CommonNames,
IPAddresses: creationInfo.IPSANs,
PermittedDNSDomainsCritical: false,
PermittedDNSDomains: nil,
CRLDistributionPoints: creationInfo.CACert.CRLDistributionPoints,
}
if creationInfo.Usage&serverUsage != 0 {
certTemplate.ExtKeyUsage = append(certTemplate.ExtKeyUsage, x509.ExtKeyUsageServerAuth)
}
if creationInfo.Usage&clientUsage != 0 {
certTemplate.ExtKeyUsage = append(certTemplate.ExtKeyUsage, x509.ExtKeyUsageClientAuth)
}
if creationInfo.Usage&codeSigningUsage != 0 {
certTemplate.ExtKeyUsage = append(certTemplate.ExtKeyUsage, x509.ExtKeyUsageCodeSigning)
}
signingPrivKey, err := creationInfo.RawSigningBundle.getSigner()
if err != nil {
return nil, nil, fmt.Errorf("Unable to get signing private key: %s", err)
}
cert, err := x509.CreateCertificate(crand.Reader, certTemplate, creationInfo.CACert, clientPrivKey.Public(), signingPrivKey)
if err != nil {
return nil, nil, fmt.Errorf("Unable to create certificate: %s", err)
}
rawBundle.CertificateBytes = cert
return
}

View File

@ -0,0 +1,153 @@
package pki
import (
"crypto/rand"
"crypto/x509"
"crypto/x509/pkix"
"fmt"
"time"
"github.com/hashicorp/vault/logical"
)
type revocationInfo struct {
CertificateBytes []byte `json:"certificate_bytes"`
RevocationTime int64 `json:"unix_time"`
}
func revokeCert(req *logical.Request, serial string) (*logical.Response, error) {
certEntry, userErr, intErr := fetchCertBySerial(req, "revoked/", serial)
if certEntry != nil {
return nil, nil
}
certEntry, userErr, intErr = fetchCertBySerial(req, "certs/", serial)
switch {
case userErr != nil:
return logical.ErrorResponse(userErr.Error()), nil
case intErr != nil:
return nil, intErr
}
// Possible TODO: use some kind of transaction log in case of an
// error anywhere along here (so we've validated that we got a
// value back, but want to make sure it not only is deleted from
// certs/ but also shows up in revoked/ and a CRL is generated)
err := req.Storage.Delete("certs/" + serial)
if err != nil {
return nil, fmt.Errorf("Error deleting cert from valid-certs location")
}
cert, err := x509.ParseCertificate(certEntry.Value)
if err != nil {
return nil, fmt.Errorf("Error parsing certificate")
}
if cert == nil {
return nil, fmt.Errorf("Got a nil certificate")
}
if cert.NotAfter.Before(time.Now()) {
return nil, nil
}
revInfo := revocationInfo{
CertificateBytes: certEntry.Value,
RevocationTime: time.Now().Unix(),
}
certEntry, err = logical.StorageEntryJSON("revoked/"+serial, revInfo)
if err != nil {
return nil, fmt.Errorf("Error creating revocation entry")
}
err = req.Storage.Put(certEntry)
if err != nil {
return nil, fmt.Errorf("Error saving revoked certificate to new location")
}
err = buildCRL(req)
if err != nil {
return nil, fmt.Errorf("Error encountered during CRL building: %s", err)
}
return &logical.Response{
Data: map[string]interface{}{
"revocation_time": revInfo.RevocationTime,
},
}, nil
}
func buildCRL(req *logical.Request) error {
revokedSerials, err := req.Storage.List("revoked/")
if err != nil {
return fmt.Errorf("Error fetching list of revoked certs: %s", err)
}
revokedCerts := []pkix.RevokedCertificate{}
var revInfo revocationInfo
for _, serial := range revokedSerials {
revokedEntry, err := req.Storage.Get("revoked/" + serial)
if err != nil {
return fmt.Errorf("Unable to fetch revoked cert with serial %s: %s", serial, err)
}
if revokedEntry == nil {
return fmt.Errorf("Revoked certificate entry for serial %s is nil", serial)
}
if revokedEntry.Value == nil || len(revokedEntry.Value) == 0 {
// TODO: In this case, remove it and continue? How likely is this to
// happen? Alternately, could skip it entirely, or could implement a
// delete function so that there is a way to remove these
return fmt.Errorf("Found revoked serial but actual certificate is empty")
}
err = revokedEntry.DecodeJSON(&revInfo)
if err != nil {
return fmt.Errorf("Error decoding revocation entry for serial %s: %s", serial, err)
}
revokedCert, err := x509.ParseCertificate(revInfo.CertificateBytes)
if err != nil {
return fmt.Errorf("Unable to parse stored revoked certificate with serial %s: %s", serial, err)
}
if revokedCert.NotAfter.Before(time.Now()) {
err = req.Storage.Delete(serial)
if err != nil {
return fmt.Errorf("Unable to delete revoked, expired certificate with serial %s: %s", serial, err)
}
continue
}
revokedCerts = append(revokedCerts, pkix.RevokedCertificate{
SerialNumber: revokedCert.SerialNumber,
RevocationTime: time.Unix(revInfo.RevocationTime, 0),
})
}
rawSigningBundle, caCert, err := fetchCAInfo(req)
if err != nil {
return fmt.Errorf("Could not fetch the CA certificate")
}
signingPrivKey, err := rawSigningBundle.getSigner()
if err != nil {
return fmt.Errorf("Unable to get signing private key: %s", err)
}
// TODO: Make expiry configurable
crlBytes, err := caCert.CreateCRL(rand.Reader, signingPrivKey, revokedCerts, time.Now(), time.Now().Add(time.Hour*72))
if err != nil {
return fmt.Errorf("Error creating new CRL: %s", err)
}
err = req.Storage.Put(&logical.StorageEntry{
Key: "crl",
Value: crlBytes,
})
if err != nil {
return fmt.Errorf("Error storing CRL: %s", err)
}
return nil
}

View File

@ -0,0 +1,133 @@
package pki
import (
"crypto/x509"
"encoding/pem"
"github.com/hashicorp/vault/logical"
"github.com/hashicorp/vault/logical/framework"
)
func pathConfigCA(b *backend) *framework.Path {
return &framework.Path{
Pattern: "config/ca",
Fields: map[string]*framework.FieldSchema{
"pem_bundle": &framework.FieldSchema{
Type: framework.TypeString,
Description: "PEM-format, concatenated unencrypted secret key and certificate",
},
},
Callbacks: map[logical.Operation]framework.OperationFunc{
logical.WriteOperation: b.pathCAWrite,
},
HelpSynopsis: pathConfigCAHelpSyn,
HelpDescription: pathConfigCAHelpDesc,
}
}
func (b *backend) pathCAWrite(
req *logical.Request, d *framework.FieldData) (*logical.Response, error) {
pemBundle := d.Get("pem_bundle").(string)
if len(pemBundle) == 0 {
return logical.ErrorResponse("Empty PEM bundle"), nil
}
pemBytes := []byte(pemBundle)
var pemBlock *pem.Block
rawBundle := &rawCertBundle{}
for {
pemBlock, pemBytes = pem.Decode(pemBytes)
if pemBlock == nil {
return logical.ErrorResponse("No PEM data found"), nil
}
if _, err := x509.ParseECPrivateKey(pemBlock.Bytes); err == nil {
if rawBundle.PrivateKeyType != UnknownPrivateKeyType {
return logical.ErrorResponse("More than one private key given; provide only one private key in the bundle"), nil
}
rawBundle.PrivateKeyType = ECDSAPrivateKeyType
rawBundle.PrivateKeyBytes = pemBlock.Bytes
// TODO?: CRLs can only be generated with RSA keys right now, in the
// Go standard library. The plubming is here to support non-RSA keys
// if the library gets support
return logical.ErrorResponse("Only RSA keys are supported at this time due to limitations in the Go standard library"), nil
} else if _, err := x509.ParsePKCS1PrivateKey(pemBlock.Bytes); err == nil {
if rawBundle.PrivateKeyType != UnknownPrivateKeyType {
return logical.ErrorResponse("More than one private key given; provide only one private key in the bundle"), nil
}
rawBundle.PrivateKeyType = RSAPrivateKeyType
rawBundle.PrivateKeyBytes = pemBlock.Bytes
} else if certificates, err := x509.ParseCertificates(pemBlock.Bytes); err == nil {
switch len(certificates) {
case 0:
return logical.ErrorResponse("No certificates found in the bundle"), nil
case 1:
cert := certificates[0]
if !cert.IsCA {
return logical.ErrorResponse("The given certificate is not marked for CA use and cannot be used with this backend"), nil
}
rawBundle.CertificateBytes = pemBlock.Bytes
default:
return logical.ErrorResponse("More than one certificate given; provide only one certificate in the bundle"), nil
}
}
if len(pemBytes) == 0 {
break
}
}
switch {
case rawBundle.PrivateKeyType == UnknownPrivateKeyType:
return logical.ErrorResponse("Unable to figure out the private key type; must be RSA or ECDSA"), nil
case len(rawBundle.PrivateKeyBytes) == 0:
return logical.ErrorResponse("Unable to decode the private key from the bundle"), nil
case len(rawBundle.CertificateBytes) == 0:
return logical.ErrorResponse("Unable to decode the certificate from the bundle"), nil
}
cb := rawBundle.toCertBundle()
entry, err := logical.StorageEntryJSON("config/ca_bundle", cb)
if err != nil {
return nil, err
}
err = req.Storage.Put(entry)
if err != nil {
return nil, err
}
// For ease of later use, also store just the certificate at a known
// location, plus a blank CRL
entry.Key = "ca"
entry.Value = rawBundle.CertificateBytes
err = req.Storage.Put(entry)
if err != nil {
return nil, err
}
entry.Key = "crl"
entry.Value = []byte{}
err = req.Storage.Put(entry)
if err != nil {
return nil, err
}
return nil, nil
}
const pathConfigCAHelpSyn = `
Configure the CA certificate and private key used for generated credentials.
`
const pathConfigCAHelpDesc = `
This configures the CA information used for credentials
generated by this backend. This must be a PEM-format, concatenated
unencrypted secret key and certificate.
For security reasons, you can only view the certificate when reading this endpoint.
`

View File

@ -0,0 +1,194 @@
package pki
import (
"encoding/pem"
"fmt"
"strings"
"github.com/hashicorp/vault/logical"
"github.com/hashicorp/vault/logical/framework"
)
func pathFetchCA(b *backend) *framework.Path {
return &framework.Path{
Pattern: `ca(/pem)?`,
Callbacks: map[logical.Operation]framework.OperationFunc{
logical.ReadOperation: b.pathFetchRead,
},
HelpSynopsis: pathFetchHelpSyn,
HelpDescription: pathFetchHelpDesc,
}
}
func pathFetchCRL(b *backend) *framework.Path {
return &framework.Path{
Pattern: `crl(/pem)?`,
Callbacks: map[logical.Operation]framework.OperationFunc{
logical.ReadOperation: b.pathFetchRead,
},
HelpSynopsis: pathFetchHelpSyn,
HelpDescription: pathFetchHelpDesc,
}
}
func pathFetchValid(b *backend) *framework.Path {
return &framework.Path{
Pattern: `cert/(?P<serial>[0-9A-Fa-f-:]+)`,
Fields: map[string]*framework.FieldSchema{
"serial": &framework.FieldSchema{
Type: framework.TypeString,
Description: "Certificate serial number, in colon- or hyphen-separated octal",
},
},
Callbacks: map[logical.Operation]framework.OperationFunc{
logical.ReadOperation: b.pathFetchRead,
},
HelpSynopsis: pathFetchHelpSyn,
HelpDescription: pathFetchHelpDesc,
}
}
func pathFetchCRLViaCertPath(b *backend) *framework.Path {
return &framework.Path{
Pattern: `cert/crl`,
Callbacks: map[logical.Operation]framework.OperationFunc{
logical.ReadOperation: b.pathFetchRead,
},
HelpSynopsis: pathFetchHelpSyn,
HelpDescription: pathFetchHelpDesc,
}
}
func pathFetchRevoked(b *backend) *framework.Path {
return &framework.Path{
Pattern: `revoked/(?P<serial>[0-9A-Fa-f-:]+)`,
Fields: map[string]*framework.FieldSchema{
"serial": &framework.FieldSchema{
Type: framework.TypeString,
Description: "Certificate serial number, in colon- or hyphen-separated octal",
},
},
Callbacks: map[logical.Operation]framework.OperationFunc{
logical.ReadOperation: b.pathFetchRead,
},
HelpSynopsis: pathFetchHelpSyn,
HelpDescription: pathFetchHelpDesc,
}
}
func (b *backend) pathFetchRead(req *logical.Request, data *framework.FieldData) (response *logical.Response, retErr error) {
var serial string
var pemType string
var contentType string
var certEntry *logical.StorageEntry
var userErr, intErr, err error
var certificate []byte
response = &logical.Response{
Data: map[string]interface{}{},
}
switch {
case req.Path == "ca" || req.Path == "ca/pem":
serial = "ca"
contentType = "application/pkix-cert"
if req.Path == "ca/pem" {
pemType = "CERTIFICATE"
}
case req.Path == "crl" || req.Path == "crl/pem":
serial = "crl"
contentType = "application/pkix-crl"
if req.Path == "crl/pem" {
pemType = "X509 CRL"
}
case req.Path == "cert/crl":
serial = "crl"
pemType = "X509 CRL"
default:
serial = data.Get("serial").(string)
pemType = "CERTIFICATE"
}
if len(serial) == 0 {
response = logical.ErrorResponse("The serial number must be provided")
goto reply
}
_, _, err = fetchCAInfo(req)
if err != nil {
response = logical.ErrorResponse("No CA information configured for this backend")
goto reply
}
certEntry, userErr, intErr = fetchCertBySerial(req, req.Path, serial)
switch {
case userErr != nil:
response = logical.ErrorResponse(userErr.Error())
goto reply
case intErr != nil:
retErr = intErr
goto reply
}
switch {
case strings.HasPrefix(req.Path, "revoked/"):
var revInfo revocationInfo
err := certEntry.DecodeJSON(&revInfo)
if err != nil {
retErr = fmt.Errorf("Error decoding revocation entry for serial %s: %s", serial, err)
goto reply
}
certificate = revInfo.CertificateBytes
response.Data["revocation_time"] = revInfo.RevocationTime
default:
certificate = certEntry.Value
}
if len(pemType) != 0 {
block := pem.Block{
Type: pemType,
Bytes: certEntry.Value,
}
certificate = pem.EncodeToMemory(&block)
}
reply:
switch {
case len(contentType) != 0:
response = &logical.Response{
Data: map[string]interface{}{
logical.HTTPContentType: contentType,
logical.HTTPRawBody: certificate,
}}
if retErr != nil {
b.Logger().Printf("Possible error, but cannot return in raw response: %s. Note that an empty CA probably means none was configured, and an empty CRL is quite possibly correct", retErr)
}
retErr = nil
response.Data[logical.HTTPStatusCode] = 200
case retErr != nil:
response = nil
default:
response.Data["certificate"] = string(certificate)
}
return
}
const pathFetchHelpSyn = `
Fetch a CA, CRL, valid or revoked certificate.
`
const pathFetchHelpDesc = `
This allows certificates to be fetched. If using the fetch/ prefix any valid certificate can be fetched; if using the revoked/ prefix, which requires a root token, revoked certificates can also be fetched.
Using "ca" or "crl" as the value fetches the appropriate information in DER encoding. Add "/pem" to either to get PEM encoding.
`

View File

@ -0,0 +1,207 @@
package pki
import (
"encoding/pem"
"fmt"
"net"
"strings"
"time"
"github.com/hashicorp/vault/logical"
"github.com/hashicorp/vault/logical/framework"
)
func pathIssue(b *backend) *framework.Path {
return &framework.Path{
Pattern: `issue/(?P<role>\w+)`,
Fields: map[string]*framework.FieldSchema{
"role": &framework.FieldSchema{
Type: framework.TypeString,
Description: "The desired role with configuration for this request",
},
"common_name": &framework.FieldSchema{
Type: framework.TypeString,
Description: "The requested common name; if you want more than one, specify the alternative names in the alt_names map",
},
"alt_names": &framework.FieldSchema{
Type: framework.TypeString,
Description: "The requested Subject Alternative Names, if any, in a comma-delimited list",
},
"ip_sans": &framework.FieldSchema{
Type: framework.TypeString,
Description: "The requested IP SANs, if any, in a common-delimited list",
},
"lease": &framework.FieldSchema{
Type: framework.TypeString,
Description: "The requested lease",
},
},
Callbacks: map[logical.Operation]framework.OperationFunc{
logical.WriteOperation: b.pathIssueCert,
},
HelpSynopsis: pathIssueCertHelpSyn,
HelpDescription: pathIssueCertHelpDesc,
}
}
func (b *backend) pathIssueCert(
req *logical.Request, data *framework.FieldData) (*logical.Response, error) {
roleName := data.Get("role").(string)
// Get the common name(s)
var commonNames []string
cn := data.Get("common_name").(string)
if len(cn) == 0 {
return logical.ErrorResponse("The common_name field is required"), nil
}
commonNames = []string{cn}
cnAlt := data.Get("alt_names").(string)
if len(cnAlt) != 0 {
for _, v := range strings.Split(cnAlt, ",") {
commonNames = append(commonNames, v)
}
}
// Get the role
role, err := b.getRole(req.Storage, roleName)
if err != nil {
return nil, err
}
if role == nil {
return logical.ErrorResponse(fmt.Sprintf("Unknown role: %s", roleName)), nil
}
// Get any IP SANs
ipSANs := []net.IP{}
ipAlt := data.Get("ip_sans").(string)
if len(ipAlt) != 0 {
if !role.AllowIPSANs {
return logical.ErrorResponse("IP Subject Alternative Names are not allowed in this role"), nil
}
for _, v := range strings.Split(ipAlt, ",") {
parsedIP := net.ParseIP(v)
if parsedIP == nil {
return logical.ErrorResponse(fmt.Sprintf("The value '%s' is not a valid IP address", v)), nil
}
ipSANs = append(ipSANs, parsedIP)
}
}
leaseField := data.Get("lease").(string)
if len(leaseField) == 0 {
leaseField = role.Lease
}
lease, err := time.ParseDuration(leaseField)
if err != nil {
return logical.ErrorResponse(fmt.Sprintf(
"Invalid requested lease: %s", err)), nil
}
leaseMax, err := time.ParseDuration(role.LeaseMax)
if err != nil {
return logical.ErrorResponse(fmt.Sprintf(
"Invalid lease: %s", err)), nil
}
if time.Now().Add(lease).After(time.Now().Add(leaseMax)) {
return logical.ErrorResponse("Lease expires after maximum allowed by this role"), nil
}
badName, err := validateCommonNames(req, commonNames, role)
if len(badName) != 0 {
return logical.ErrorResponse(fmt.Sprintf("Name %s not allowed by this role", badName)), nil
} else if err != nil {
return nil, fmt.Errorf("Error validating name %s: %s", badName, err)
}
rawSigningBundle, caCert, err := fetchCAInfo(req)
if err != nil {
return logical.ErrorResponse("Could not fetch the CA certificate; has it been set?"), nil
}
if time.Now().Add(lease).After(caCert.NotAfter) {
return logical.ErrorResponse(fmt.Sprintf("Cannot satisfy request, as maximum lease is beyond the expiration of the CA certificate")), nil
}
var usage certUsage
if role.ServerFlag {
usage = usage | serverUsage
}
if role.ClientFlag {
usage = usage | clientUsage
}
creationBundle := &certCreationBundle{
RawSigningBundle: rawSigningBundle,
CACert: caCert,
CommonNames: commonNames,
IPSANs: ipSANs,
KeyType: role.KeyType,
KeyBits: role.KeyBits,
Lease: lease,
Usage: usage,
}
rawBundle, userErr, intErr := createCertificate(creationBundle)
switch {
case userErr != nil:
return logical.ErrorResponse(userErr.Error()), nil
case intErr != nil:
return nil, intErr
}
serial := strings.ToLower(getOctalFormatted(rawBundle.SerialNumber.Bytes(), ":"))
resp := b.Secret(SecretCertsType).Response(map[string]interface{}{}, map[string]interface{}{
"serial": serial,
})
resp.Data["serial"] = serial
block := pem.Block{
Type: "CERTIFICATE",
Bytes: rawBundle.CertificateBytes,
}
certificateString := string(pem.EncodeToMemory(&block))
resp.Data["certificate"] = certificateString
block.Bytes = rawSigningBundle.CertificateBytes
caString := string(pem.EncodeToMemory(&block))
resp.Data["issuing_ca"] = caString
block.Bytes = rawBundle.PrivateKeyBytes
switch rawBundle.PrivateKeyType {
case RSAPrivateKeyType:
block.Type = "RSA PRIVATE KEY"
case ECDSAPrivateKeyType:
block.Type = "EC PRIVATE KEY"
default:
return nil, fmt.Errorf("Could not determine private key type when creating block")
}
resp.Data["private_key"] = string(pem.EncodeToMemory(&block))
resp.Secret.Lease = lease
err = req.Storage.Put(&logical.StorageEntry{
Key: "certs/" + serial,
Value: rawBundle.CertificateBytes,
})
if err != nil {
return nil, fmt.Errorf("Unable to store certificate locally")
}
return resp, nil
}
const pathIssueCertHelpSyn = `
Request certificates using a certain role with the provided common name.
`
const pathIssueCertHelpDesc = `
This path allows requesting certificates to be issued according to the
policy of the given role. The certificate will only be issued if the
requested common name is allowed by the role policy.
`

View File

@ -0,0 +1,77 @@
package pki
import (
"fmt"
"github.com/hashicorp/vault/logical"
"github.com/hashicorp/vault/logical/framework"
)
func pathRevoke(b *backend) *framework.Path {
return &framework.Path{
Pattern: `revoke`,
Fields: map[string]*framework.FieldSchema{
"serial": &framework.FieldSchema{
Type: framework.TypeString,
Description: "Certificate serial number, in colon- or hyphen-separated octal",
},
},
Callbacks: map[logical.Operation]framework.OperationFunc{
logical.WriteOperation: b.pathRevokeWrite,
},
HelpSynopsis: pathRevokeHelpSyn,
HelpDescription: pathRevokeHelpDesc,
}
}
func pathRotateCRL(b *backend) *framework.Path {
return &framework.Path{
Pattern: `crl/rotate`,
Callbacks: map[logical.Operation]framework.OperationFunc{
logical.ReadOperation: b.pathRotateCRLRead,
},
HelpSynopsis: pathRotateCRLHelpSyn,
HelpDescription: pathRotateCRLHelpDesc,
}
}
func (b *backend) pathRevokeWrite(req *logical.Request, data *framework.FieldData) (*logical.Response, error) {
serial := data.Get("serial").(string)
if len(serial) == 0 {
return logical.ErrorResponse("The serial number must be provided"), nil
}
return revokeCert(req, serial)
}
func (b *backend) pathRotateCRLRead(req *logical.Request, data *framework.FieldData) (*logical.Response, error) {
err := buildCRL(req)
if err != nil {
return logical.ErrorResponse(fmt.Sprintf("Error building CRL: %s", err)), err
}
return &logical.Response{
Data: map[string]interface{}{
"success": true,
},
}, nil
}
const pathRevokeHelpSyn = `
Revoke a certificate by serial number.
`
const pathRevokeHelpDesc = `
This allows certificates to be revoked using its serial number. A root token is required.
`
const pathRotateCRLHelpSyn = `
Force a rebuild of the CRL.
`
const pathRotateCRLHelpDesc = `
Force a rebuild of the CRL. This can be used to remove expired certificates from it if no certificates have been revoked. A root token is required.
`

View File

@ -0,0 +1,261 @@
package pki
import (
"fmt"
"time"
"github.com/hashicorp/vault/logical"
"github.com/hashicorp/vault/logical/framework"
)
func pathRoles(b *backend) *framework.Path {
return &framework.Path{
Pattern: "roles/(?P<name>\\w+)",
Fields: map[string]*framework.FieldSchema{
"name": &framework.FieldSchema{
Type: framework.TypeString,
Description: "Name of the role.",
},
"lease": &framework.FieldSchema{
Type: framework.TypeString,
Default: "",
Description: "The lease length if no specific lease length is requested. The lease length controls the expiration of certificates issued by this backend. Defaults to the value of lease_max.",
},
"lease_max": &framework.FieldSchema{
Type: framework.TypeString,
Default: "",
Description: "The maximum allowed lease length.",
},
"allow_localhost": &framework.FieldSchema{
Type: framework.TypeBool,
Default: true,
Description: "Whether to allow \"localhost\" as a valid common name in a request.",
},
"allowed_base_domain": &framework.FieldSchema{
Type: framework.TypeString,
Default: "",
Description: "If set, clients can request certificates for subdomains directly beneath this base domain, including the wildcard subdomain. See the documentation for more information.",
},
"allow_token_displayname": &framework.FieldSchema{
Type: framework.TypeBool,
Default: false,
Description: "If set, clients can request certificates for matching the value of the Display Name on the requesting token. See the documentation for more information.",
},
"allow_subdomains": &framework.FieldSchema{
Type: framework.TypeBool,
Default: false,
Description: "If set, clients can request certificates for subdomains of the CNs allowed by the other role options, including wildcard subdomains. See the documentation for more information.",
},
"allow_any_name": &framework.FieldSchema{
Type: framework.TypeBool,
Default: false,
Description: "If set, clients can request certificates for any CN they like. See the documentation for more information.",
},
"allow_ip_sans": &framework.FieldSchema{
Type: framework.TypeBool,
Default: true,
Description: "If set, IP Subject Alternative Names are allowed. Any valid IP is accepted.",
},
"server_flag": &framework.FieldSchema{
Type: framework.TypeBool,
Default: true,
Description: "If set, certificates are flagged for server use. Defaults to true.",
},
"client_flag": &framework.FieldSchema{
Type: framework.TypeBool,
Default: true,
Description: "If set, certificates are flagged for client use. Defaults to true.",
},
"code_signing_flag": &framework.FieldSchema{
Type: framework.TypeBool,
Default: false,
Description: "If set, certificates are flagged for code signing use. Defaults to false.",
},
"key_type": &framework.FieldSchema{
Type: framework.TypeString,
Default: "rsa",
Description: "The type of key to use; defaults to RSA. \"rsa\" and \"ecdsa\" are the only valid values.",
},
"key_bits": &framework.FieldSchema{
Type: framework.TypeInt,
Default: 2048,
Description: "The number of bits to use. You will almost certainly want to change this if you adjust the key_type.",
},
},
Callbacks: map[logical.Operation]framework.OperationFunc{
logical.ReadOperation: b.pathRoleRead,
logical.WriteOperation: b.pathRoleCreate,
logical.DeleteOperation: b.pathRoleDelete,
},
HelpSynopsis: pathRoleHelpSyn,
HelpDescription: pathRoleHelpDesc,
}
}
func (b *backend) getRole(s logical.Storage, n string) (*roleEntry, error) {
entry, err := s.Get("role/" + n)
if err != nil {
return nil, err
}
if entry == nil {
return nil, nil
}
var result roleEntry
if err := entry.DecodeJSON(&result); err != nil {
return nil, err
}
return &result, nil
}
func (b *backend) pathRoleDelete(
req *logical.Request, data *framework.FieldData) (*logical.Response, error) {
err := req.Storage.Delete("role/" + data.Get("name").(string))
if err != nil {
return nil, err
}
return nil, nil
}
func (b *backend) pathRoleRead(
req *logical.Request, data *framework.FieldData) (*logical.Response, error) {
role, err := b.getRole(req.Storage, data.Get("name").(string))
if err != nil {
return nil, err
}
if role == nil {
return nil, nil
}
resp := &logical.Response{
Data: map[string]interface{}{
"lease_max": role.LeaseMax,
"lease": role.Lease,
"allow_localhost": role.AllowLocalhost,
"allowed_base_domain": role.AllowedBaseDomain,
"allow_token_displayname": role.AllowTokenDisplayName,
"allow_subdomains": role.AllowSubdomains,
"allow_ip_sans": role.AllowIPSANs,
"allow_any_name": role.AllowAnyName,
"server_flag": role.ServerFlag,
"client_flag": role.ClientFlag,
"code_signing_flag": role.CodeSigningFlag,
"key_type": role.KeyType,
"key_bits": role.KeyBits,
},
}
return resp, nil
}
func (b *backend) pathRoleCreate(
req *logical.Request, data *framework.FieldData) (*logical.Response, error) {
name := data.Get("name").(string)
entry := &roleEntry{
LeaseMax: data.Get("lease_max").(string),
Lease: data.Get("lease").(string),
AllowLocalhost: data.Get("allow_localhost").(bool),
AllowedBaseDomain: data.Get("allowed_base_domain").(string),
AllowTokenDisplayName: data.Get("allow_token_displayname").(bool),
AllowSubdomains: data.Get("allow_subdomains").(bool),
AllowAnyName: data.Get("allow_any_name").(bool),
AllowIPSANs: data.Get("allow_ip_sans").(bool),
ServerFlag: data.Get("server_flag").(bool),
ClientFlag: data.Get("client_flag").(bool),
CodeSigningFlag: data.Get("code_signing_flag").(bool),
KeyType: data.Get("key_type").(string),
KeyBits: data.Get("key_bits").(int),
}
if len(entry.LeaseMax) == 0 {
return logical.ErrorResponse("\"lease_max\" value must be supplied"), nil
}
leaseMax, err := time.ParseDuration(entry.LeaseMax)
if err != nil {
return logical.ErrorResponse(fmt.Sprintf(
"Invalid lease: %s", err)), nil
}
switch len(entry.Lease) {
case 0:
entry.Lease = entry.LeaseMax
default:
lease, err := time.ParseDuration(entry.Lease)
if err != nil {
return logical.ErrorResponse(fmt.Sprintf(
"Invalid lease: %s", err)), nil
}
if lease > leaseMax {
return logical.ErrorResponse("\"lease\" value must be less than \"lease_max\" value"), nil
}
}
switch entry.KeyType {
case "rsa":
case "ecdsa":
switch entry.KeyBits {
case 224:
case 256:
case 384:
case 521:
default:
return logical.ErrorResponse(fmt.Sprintf("Unsupported bit length for ECDSA key: %d", entry.KeyBits)), nil
}
default:
return logical.ErrorResponse(fmt.Sprintf("Unknown key type %s", entry.KeyType)), nil
}
// Store it
jsonEntry, err := logical.StorageEntryJSON("role/"+name, entry)
if err != nil {
return nil, err
}
if err := req.Storage.Put(jsonEntry); err != nil {
return nil, err
}
return nil, nil
}
type roleEntry struct {
LeaseMax string `json:"lease_max"`
Lease string `json:"lease"`
AllowLocalhost bool `json:"allow_localhost"`
AllowedBaseDomain string `json:"allowed_base_domain"`
AllowTokenDisplayName bool `json:"allow_token_displaynae"`
AllowSubdomains bool `json:"allow_subdomains"`
AllowAnyName bool `json:"allow_any_name"`
AllowIPSANs bool `json:"allow_ip_sans"`
ServerFlag bool `json:"server_flag"`
ClientFlag bool `json:"client_flag"`
CodeSigningFlag bool `json:"code_signing_flag"`
KeyType string `json:"key_type"`
KeyBits int `json:"key_bits"`
}
const pathRoleHelpSyn = `
Manage the roles that can be created with this backend.
`
const pathRoleHelpDesc = `
This path lets you manage the roles that can be created with this backend.
`

View File

@ -0,0 +1,54 @@
package pki
import (
"fmt"
"strings"
"time"
"github.com/hashicorp/vault/logical"
"github.com/hashicorp/vault/logical/framework"
)
// SecretCertsType is the name used to identify this type
const SecretCertsType = "pki"
func secretCerts(b *backend) *framework.Secret {
return &framework.Secret{
Type: SecretCertsType,
Fields: map[string]*framework.FieldSchema{
"certificate": &framework.FieldSchema{
Type: framework.TypeString,
Description: "The PEM-encoded concatenated certificate and issuing certificate authority",
},
"private_key": &framework.FieldSchema{
Type: framework.TypeString,
Description: "The PEM-encoded private key for the certificate",
},
"serial": &framework.FieldSchema{
Type: framework.TypeString,
Description: "The serial number of the certificate, for handy reference",
},
},
DefaultDuration: 168 * time.Hour,
DefaultGracePeriod: 10 * time.Minute,
Revoke: b.secretCredsRevoke,
}
}
func (b *backend) secretCredsRevoke(
req *logical.Request, d *framework.FieldData) (*logical.Response, error) {
if req.Secret == nil {
return nil, fmt.Errorf("Secret is nil in request")
}
serialInt, ok := req.Secret.InternalData["serial"]
if !ok {
return nil, fmt.Errorf("Could not find serial in internal secret data")
}
serial := strings.Replace(strings.ToLower(serialInt.(string)), "-", ":", -1)
return revokeCert(req, serial)
}

View File

@ -15,6 +15,7 @@ import (
"github.com/hashicorp/vault/builtin/logical/aws" "github.com/hashicorp/vault/builtin/logical/aws"
"github.com/hashicorp/vault/builtin/logical/consul" "github.com/hashicorp/vault/builtin/logical/consul"
"github.com/hashicorp/vault/builtin/logical/mysql" "github.com/hashicorp/vault/builtin/logical/mysql"
"github.com/hashicorp/vault/builtin/logical/pki"
"github.com/hashicorp/vault/builtin/logical/postgresql" "github.com/hashicorp/vault/builtin/logical/postgresql"
"github.com/hashicorp/vault/builtin/logical/transit" "github.com/hashicorp/vault/builtin/logical/transit"
@ -65,6 +66,7 @@ func Commands(metaPtr *command.Meta) map[string]cli.CommandFactory {
"aws": aws.Factory, "aws": aws.Factory,
"consul": consul.Factory, "consul": consul.Factory,
"postgresql": postgresql.Factory, "postgresql": postgresql.Factory,
"pki": pki.Factory,
"transit": transit.Factory, "transit": transit.Factory,
"mysql": mysql.Factory, "mysql": mysql.Factory,
}, },

View File

@ -0,0 +1,677 @@
---
layout: "docs"
page_title: "Secret Backend: PKI"
sidebar_current: "docs-secrets-pki"
description: |-
The PKI secret backend for Vault generates TLS certificates.
---
# PKI Secret Backend
Name: `pki`
The PKI secret backend for Vault generates X.509 certificates dynamically based on configured roles. This means services can get certificates needed for both client and server authentication without going through the usual manual process of generating a private key and CSR, submitting to a CA, and waiting for a verification and signing process to complete. Vault's built-in authentication and authorization mechanisms provide the verification functionality.
By keeping leases relatively short, revocations are less likely to be needed, keeping CRLs short and helping the backend scale to large workloads. This in turn allows each instance of a running application to have a unique certificate, eliminating sharing and the accompanying pain of revocation and rollover.
In addition, by allowing revocation to mostly be forgone, this backend allows for ephemeral certificates; certificates can be fetched and stored in memory upon application startup and discarded upon shutdown, without ever being written to disk.
This page will show a quick start for this backend. For detailed documentation on every path, use `vault help` after mounting the backend.
## Considerations
To successfully deploy this backend, there are a number of important considerations to be aware of, as well as some preparatory steps that should be undertaken. You should read all of these *before* using this backend or generating the CA to use with this backend.
### Never use root CAs
Vault storage is secure, but not as secure as a piece of paper in a bank vault. It is, after all, networked software. Your long-lived self-signed root CA's private key should instead be used to issue a shorter-lived intermediate CA certificate, and this is what you should put into Vault. This aligns with industry best practices.
### One CA Certificate, One Backend
In order to vastly simplify both the configuration and codebase of the PKI backend, only one CA certificate is allowed per backend. If you want to issue certificates from multiple CAs, mount the PKI backend at multiple mount points with separate CA certificates in each.
This also provides a convenient method of switching to a new CA certificate while keeping CRLs valid from the old CA certificate; simply mount a new backend and issue from there.
### Keep certificate lifetimes short, for CRL's sake
This backend aligns with Vault's philosophy of short-lived secrets. As such it is not expected that CRLs will grow large; the only place a private key is ever returned is to the requesting client (this backend does *not* store generated private keys). In most cases, if the key is lost, the certificate can simply be ignored, as it will expire shortly.
If a certificate must truly be revoked, the normal Vault revocation function can be used; alternately a root token can be used to revoke the certificate using the certificate's serial number. Any revocation action will cause the CRL to be regenerated. When the CRL is regenerated, any expired certificates are removed from the CRL (and any revoked, expired certificate are removed from backend storage).
This backend does not support multiple CRL endpoints with sliding date windows; often such mechanisms will have the transition point a few days apart, but this gets into the expected realm of the actual certificate validity periods issued from this backend. A good rule of thumb for this backend would be to simply not issue certificates with a validity period greater than your maximum comfortable CRL lifetime. Alternately, you can control CRL caching behavior on the client to ensure that checks happen more often.
Often multiple endpoints are used in case a single CRL endpoint is down so that clients don't have to figure out what to do with a lack of response. Run Vault in HA mode, and the CRL endpoint should be available even if a particular node is down.
### You must configure CRL information *in advance*
This backend serves CRLs from a predictable location. That location must be encoded into your CA certificate if you want to allow applications to use the CRL endpoint encoded in certificates to find the CRL. Instructions for doing so are below. If you need to adjust this later, you will have to generate a new CA certificate using the same private key if you want to keep validity for already-issued certificates.
### No OCSP support, yet
Vault's architecture does not currently allow for a binary protocol such as OCSP to be supported by a backend. As such, you should configure your software to use CRLs for revocation information, with a caching lifetime that feels good to you. Since you are following the advice above about keeping lifetimes short (right?), CRLs should not grow too large.
## Quick Start
### CA certificate
In order for this backend to serve CRL information at the expected location, you will need to generate your CA certificate with this information. For OpenSSL, this means putting a value in the CA section with the appropriate URL; in this example the PKI backend is mounted at `pki`:
```text
crlDistributionPoints = URI:https://vault.example.com:8200/v1/pki/crl
```
Adjust the URI as appropriate.
### Vault
The first step to using the PKI backend is to mount it. Unlike the `generic` backend, the `pki` backend is not mounted by default.
```text
$ vault mount pki
Successfully mounted 'pki' at 'pki'!
```
Next, Vault must be configured with a root certificate and associated private key. This is done by writing the contents of a file or *stdin*:
```text
$ vault write pki/config/ca value="@ca_bundle.pem"
Success! Data written to: pki/config/ca
```
or
```
$ cat bundle.pem | vault write pki/config/ca value="-"
Success! Data written to: pki/config/ca
```
Although in this example the value being piped into *stdin* could be passed directly into the Vault CLI command, a more complex usage might be to use [Ansible](http://www.ansible.com) to securely store the certificate and private key in an `ansible-vault` file, then have an `ansible-playbook` command decrypt this value and pass it in to Vault.
The next step is to configure a role. A role is a logical name that maps to a policy used to generated those credentials. For example, let's create an "example-dot-com" role:
```text
$ vault write pki/roles/example-dot-com \
allowed_base_domain="example.com" \
allow_subdomains="true" lease_max="72h"
Success! Data written to: pki/roles/example-dot-com
```
By writing to the `roles/example-dot-com` path we are defining the `example-dot-com` role. To generate a new set of credentials, we simply write to the `issue` endpoint with that role name: Vault is now configured to create and manage certificates!
```text
$ vault write pki/issue/example-dot-com common_name=blah.example.com
Key Value
lease_id pki/issue/example-dot-com/819393b5-e1a1-9efd-b72f-4dc3a1972e31
lease_duration 259200
lease_renewable false
certificate -----BEGIN CERTIFICATE-----
MIIECDCCAvKgAwIBAgIUXmLrLkTdBIOOIYg2/BXO7docKfUwCwYJKoZIhvcNAQEL
...
az3gfwlOqVTdgi/ZVAtIzhSEJ0OY136bq4NOaw==
-----END CERTIFICATE-----
issuing_ca -----BEGIN CERTIFICATE-----
MIIDUTCCAjmgAwIBAgIJAKM+z4MSfw2mMA0GCSqGSIb3DQEBCwUAMBsxGTAXBgNV
...
-----END CERTIFICATE-----
private_key -----BEGIN RSA PRIVATE KEY-----
MIIEowIBAAKCAQEA0cczc7Y2yIu7aD/IaDi23Io+tvvDS9XaXXDUFW1kqd58P83r
...
3xhCNnZ3CMQaM2I48sloVK/XoikMLb5MZwOUQn/V+TrhWP4Lu7qD
-----END RSA PRIVATE KEY-----
serial 5e:62:eb:2e:44:dd:04:83:8e:21:88:36:fc:15:ce:ed:da:1c:29:f5
```
Note that this is a write, not a read, to allow values to be passed in at request time.
Vault has now generated a new set of credentials using the `example-dot-com` role configuration. Here we see the dynamically generated private key and certificate. The issuing CA certificate is returned as well.
Using ACLs, it is possible to restrict using the pki backend such that trusted operators can manage the role definitions, and both users and applications are restricted in the credentials they are allowed to read.
If you get stuck at any time, simply run `vault help pki` or with a subpath for interactive help output.
## API
### /pki/ca(/pem)
#### GET
<dl class="api">
<dt>Description</dt>
<dd>
Retrieves the CA certificate *in raw DER-encoded form*.
This is a bare endpoint that does not return a
standard Vault data structure. If `/pem` is added to the
endpoint, the CA certificate is returned in PEM format.
<br /><br />This is an unauthenticated endpoint.
</dd>
<dt>Method</dt>
<dd>GET</dd>
<dt>URL</dt>
<dd>`/pki/ca(/pem)`</dd>
<dt>Parameters</dt>
<dd>
None
</dd>
<dt>Returns</dt>
<dd>
```
<binary DER-encoded certficiate>
```
</dd>
</dl>
### /pki/cert/
#### GET
<dl class="api">
<dt>Description</dt>
<dd>
Retrieves one of a selection of certificates. Valid values: `ca`
for the CA certificate, `crl` for the current CRL, or a serial
number in either hyphen-separated or colon-separated octal format.
This endpoint returns the certificate in PEM formatting in the
`certificate` key of the JSON object.
<br /><br />This is an unauthenticated endpoint.
</dd>
<dt>Method</dt>
<dd>GET</dd>
<dt>URL</dt>
<dd>`/pki/cert/<serial>`</dd>
<dt>Parameters</dt>
<dd>
None
</dd>
<dt>Returns</dt>
<dd>
```javascript
{
"data": {
"certificate": "-----BEGIN CERTIFICATE-----\nMIIGmDCCBYCgAwIBAgIHBzEB3fTzhTANBgkqhkiG9w0BAQsFADCBjDELMAkGA1UE\n..."
}
}
...
```
</dd>
</dl>
### /pki/config/ca
#### POST
<dl class="api">
<dt>Description</dt>
<dd>
A PEM file containing the issuing CA certificate
and its private key, concatenated.
<br /><br />This is a root-protected endpoint.
<br /><br />The information can be provided from a file via a `curl`
command similar to the following:<br/>
```text
curl -X POST --data "@cert_and_key.pem" ...
```
</dd>
<dt>Method</dt>
<dd>POST</dd>
<dt>URL</dt>
<dd>`/pki/config/ca`</dd>
<dt>Parameters</dt>
<dd>
<ul>
<li>
<span class="param">pem_bundle</span>
<span class="param-flags">required</span>
The key and certificate concatenated in PEM format.
</li>
</ul>
</dd>
<dt>Returns</dt>
<dd>
A `204` response code.
</dd>
</dl>
### /pki/crl(/pem)
#### GET
<dl class="api">
<dt>Description</dt>
<dd>
Retrieves the current CRL *in raw DER-encoded form*. This endpoint
is suitable for usage in the CRL Distribution Points extension in a
CA certificate. This is a bare endpoint that does not return a
standard Vault data structure. If `/pem` is added to the endpoint,
the CRL is returned in PEM format.
<br /><br />This is an unauthenticated endpoint.
</dd>
<dt>Method</dt>
<dd>GET</dd>
<dt>URL</dt>
<dd>`/pki/crl(/pem)`</dd>
<dt>Parameters</dt>
<dd>
None
</dd>
<dt>Returns</dt>
<dd>
```
<binary DER-encoded CRL>
```
</dd>
</dl>
### /pki/crl/rotate
#### GET
<dl class="api">
<dt>Description</dt>
<dd>
This endpoint forces a rotation of the CRL. This can be used
by administrators to cut the size of the CRL if it contains
a number of certificates that have now expired, but has
not been rotated due to no further certificates being revoked.
<br /><br />This is a root-protected endpoint.
</dd>
<dt>Method</dt>
<dd>GET</dd>
<dt>URL</dt>
<dd>`/pki/crl/rotate`</dd>
<dt>Parameters</dt>
<dd>
None
</dd>
<dt>Returns</dt>
<dd>
```javascript
{
"data": {
"success": true
}
}
```
</dd>
</dl>
### /pki/issue/
#### GET
<dl class="api">
<dt>Description</dt>
<dd>
Generates a new set of credentials (private key and
certificate) based on the named role. The issuing CA
certificate is returned as well, so that only the root CA
need be in a client's trust store.
<br /><br />*The private key is _not_ stored.
If you do not save the private key, you will need to
request a new certificate.*
</dd>
<dt>Method</dt>
<dd>GET</dd>
<dt>URL</dt>
<dd>`/pki/issue/<name>`</dd>
<dt>Parameters</dt>
<dd>
<ul>
<li>
<span class="param">common_name</span>
<span class="param-flags">required</span>
The requested CN for the certificate. If the CN is allowed
by role policy, it will be issued.
</li>
<li>
<span class="param">alt_names</span>
<span class="param-flags">optional</span>
Requested Subject Alternative Names, in a comma-delimited
list. If any requested names do not match role policy,
the entire request will be denied.
</li>
<span class="param">ip_sans</span>
<span class="param-flags">optional</span>
Requested IP Subject Alternative Names, in a comma-delimited
list. Only valid if the role allows IP SANs (which is the
default).
</li>
<span class="param">lease</span>
<span class="param-flags">optional</span>
Requested lease time. Cannot be greater than the role's
`lease_max` parameter. If not provided, the role's `lease`
value will be used.
</li>
</ul>
</dd>
<dt>Returns</dt>
<dd>
```javascript
{
"lease_id": "pki/issue/test/7ad6cfa5-f04f-c62a-d477-f33210475d05",
"renewable": false,
"lease_duration": 21600,
"data": {
"certificate": "-----BEGIN CERTIFICATE-----\nMIIDzDCCAragAwIBAgIUOd0ukLcjH43TfTHFG9qE0FtlMVgwCwYJKoZIhvcNAQEL\n...\numkqeYeO30g1uYvDuWLXVA==\n-----END CERTIFICATE-----\n",
"issuing_ca": "-----BEGIN CERTIFICATE-----\nMIIDUTCCAjmgAwIBAgIJAKM+z4MSfw2mMA0GCSqGSIb3DQEBCwUAMBsxGTAXBgNV\n...\nG/7g4koczXLoUM3OQXd5Aq2cs4SS1vODrYmgbioFsQ3eDHd1fg==\n-----END CERTIFICATE-----\n",
"private_key": "-----BEGIN RSA PRIVATE KEY-----\nMIIEowIBAAKCAQEAnVHfwoKsUG1GDVyWB1AFroaKl2ImMBO8EnvGLRrmobIkQvh+\n...\nQN351pgTphi6nlCkGPzkDuwvtxSxiCWXQcaxrHAL7MiJpPzkIBq1\n-----END RSA PRIVATE KEY-----\n",
"serial": "39:dd:2e:90:b7:23:1f:8d:d3:7d:31:c5:1b:da:84:d0:5b:65:31:58"
},
"auth": null
}
```
</dd>
</dl>
### /pki/revoke
#### POST
<dl class="api">
<dt>Description</dt>
<dd>
Revokes a certificate using its serial number. This is an
alternative option to the standard method of revoking
using Vault lease IDs. A successful revocation will
rotate the CRL.
<br /><br />This is a root-protected endpoint.
</dd>
<dt>Method</dt>
<dd>POST</dd>
<dt>URL</dt>
<dd>`/pki/revoke`</dd>
<dt>Parameters</dt>
<dd>
<ul>
<li>
<span class="param">serial</span>
<span class="param-flags">required</span>
The serial number of the certificate to revoke, in
hyphen-separated or colon-separated octal.
</li>
</ul>
</dd>
<dt>Returns</dt>
<dd>
```javascript
{
"data": {
"revocation_time": 1433269787
}
}
```
</dd>
</dl>
### /pki/revoked/
#### GET
<dl class="api">
<dt>Description</dt>
<dd>
Retrieves a revoked certificate and its revocation time. The serial
number must be in either hyphen-separated or colon-separated octal format.
<br /><br />This is a root-protected endpoint.
</dd>
<dt>Method</dt>
<dd>GET</dd>
<dt>URL</dt>
<dd>`/pki/revoked/<serial>`</dd>
<dt>Parameters</dt>
<dd>
None
</dd>
<dt>Returns</dt>
<dd>
```javascript
{
"data": {
"revocation_time": 1433269787,
"certificate": "-----BEGIN CERTIFICATE-----\nMIIGmDCCBYCgAwIBAgIHBzEB3fTzhTANBgkqhkiG9w0BAQsFADCBjDELMAkGA1UE\n..."
}
}
...
```
</dd>
</dl>
### /pki/roles/
#### POST
<dl class="api">
<dt>Description</dt>
<dd>
Creates or updates the role definition. Note that
the `allowed_base_domain`, `allow_token_displayname`,
`allow_subdomains`, and `allow_any_name` attributes
are additive; between them nearly and across multiple
roles nearly any issuing policy can be accommodated.
`server_flag`, `client_flag`, and `code_signing_flag`
are additive as well. If a client requests a
certificate that is not allowed by the CN policy in
the role, the request is denied.
</dd>
<dt>Method</dt>
<dd>POST</dd>
<dt>URL</dt>
<dd>`/pki/roles/<name>`</dd>
<dt>Parameters</dt>
<dd>
<ul>
<li>
<span class="param">lease</span>
<span class="param-flags">optional</span>
The lease value provided as a string duration
with time suffix. Hour is the largest suffix.
If not set, uses the value of `lease_max`.
</li>
<li>
<span class="param">lease_max</span>
<span class="param-flags">required</span>
The maximum lease value provided as a string duration
with time suffix. Hour is the largest suffix.
</li>
<li>
<span class="param">allow_localhost</span>
<span class="param-flags">optional</span>
If set, clients can request certificates for `localhost`
as one of the requested common names. This is useful
for testing and to allow clients on a single host to
talk securely.
Defaults to true.
</li>
<li>
<span class="param">allowed_base_domain</span>
<span class="param-flags">optional</span>
If set, clients can request certificates for subdomains
directly off of this base domain. _This includes the
wildcard subdomain._ For instance, a base_domain of
`example.com` allows clients to request certificates for
`foo.example.com` and `*.example.com`. To allow further
levels of subdomains, enable the `allow_subdomains` option.
There is no default.
</li>
<li>
<span class="param">allow_token_displayname</span>
<span class="param-flags">optional</span>
If set, clients can request certificates matching
the value of Display Name from the requesting token.
Remember, this stacks with the other CN options,
including `allowed_base_domain`. Defaults to `false`.
</li>
<li>
<span class="param">allow_subdomains</span>
<span class="param-flags">optional</span>
If set, clients can request certificates with CNs that
are subdomains of the CNs allowed by the other role
options. _This includes wildcard subdomains._ This is
redundant when using the `allow_any_name` option.
Defaults to `false`.
</li>
<li>
<span class="param">allow_any_name</span>
<span class="param-flags">optional</span>
If set, clients can request any CN. Useful in some
circumstances, but make sure you understand whether it
is appropriate for your installation before enabling it.
Defaults to `false`.
</li>
<li>
<span class="param">allow_ip_sans</span>
<span class="param-flags">optional</span>
If set, clients can request IP Subject Alternative
Names. Unlike CNs, no authorization checking is
performed except to verify that the given values
are valid IP addresses. Defaults to `true`.
<li>
<span class="param">server_flag</span>
<span class="param-flags">optional</span>
If set, certificates are flagged for server use.
Defaults to `true`.
</li>
<li>
<span class="param">client_flag</span>
<span class="param-flags">optional</span>
If set, certificates are flagged for client use.
Defaults to `true`.
</li>
<li>
<span class="param">code_signing_flag</span>
<span class="param-flags">optional</span>
If set, certificates are flagged for code signing
use. Defaults to `false`.
</li>
<li>
<span class="param">key_type</span>
<span class="param-flags">optional</span>
The type of key to generate for generated private
keys. Currently, `rsa` and `ecdsa` are supported.
Defaults to `rsa`.
</li>
<li>
<span class="param">key_bits</span>
<span class="param-flags">optional</span>
The number of bits to use for the generated keys.
Defaults to `2048`; this will need to be changed for
`ecdsa` keys. See https://golang.org/pkg/crypto/elliptic/#Curve
for an overview of allowed bit lengths for `ecdsa`.
</li>
</ul>
</dd>
<dt>Returns</dt>
<dd>
A `204` response code.
</dd>
</dl>
#### GET
<dl class="api">
<dt>Description</dt>
<dd>
Queries the role definition.
</dd>
<dt>Method</dt>
<dd>GET</dd>
<dt>URL</dt>
<dd>`/pki/roles/<name>`</dd>
<dt>Parameters</dt>
<dd>
None
</dd>
<dt>Returns</dt>
<dd>
```javascript
{
"data": {
"allow_any_name": false,
"allow_ip_sans": true,
"allow_localhost": true,
"allow_subdomains": false,
"allow_token_displayname": false,
"allowed_base_domain": "example.com",
"client_flag": true,
"code_signing_flag": false,
"key_bits": 2048,
"key_type": "rsa",
"lease": "6h",
"lease_max": "12h",
"server_flag": true
}
}
```
</dd>
</dl>
#### DELETE
<dl class="api">
<dt>Description</dt>
<dd>
Deletes the role definition.
</dd>
<dt>Method</dt>
<dd>DELETE</dd>
<dt>URL</dt>
<dd>`/pki/roles/<name>`</dd>
<dt>Parameters</dt>
<dd>
None
</dd>
<dt>Returns</dt>
<dd>
A `204` response code.
</dd>
</dl>

View File

@ -106,6 +106,10 @@
<a href="/docs/secrets/consul/index.html">Consul</a> <a href="/docs/secrets/consul/index.html">Consul</a>
</li> </li>
<li<%= sidebar_current("docs-secrets-pki") %>>
<a href="/docs/secrets/pki/index.html">PKI (Certificates)</a>
</li>
<li<%= sidebar_current("docs-secrets-postgresql") %>> <li<%= sidebar_current("docs-secrets-postgresql") %>>
<a href="/docs/secrets/postgresql/index.html">PostgreSQL</a> <a href="/docs/secrets/postgresql/index.html">PostgreSQL</a>
</li> </li>