Compare commits

...

9 Commits

Author SHA1 Message Date
Matt Holt f5592eb4fc Merge branch 'master' into produce-csr 2024-04-11 23:47:56 -04:00
Matt Holt 434d4bba24 Merge branch 'master' into produce-csr 2024-03-06 18:35:43 -07:00
Mohammed Al Sahaf ba1d2a8124 allow more customizable options in CSRs 2023-11-15 22:47:46 +03:00
Mohammed Al Sahaf eba47a3de4 CSRs can only be pkcs10 2023-11-15 22:47:11 +03:00
Mohammed Al Sahaf a9933aace1 refactor and tests 2023-09-18 00:03:31 +03:00
Mohammed Al Sahaf 37c6f1c5b6 add a line break between struct fields
Co-authored-by: Matt Holt <mholt@users.noreply.github.com>
2023-09-15 09:10:54 +00:00
Mohammed Al Sahaf 5610ff9db4 appease the linter 2023-09-06 09:50:17 +03:00
Mohammed Al Sahaf f49d2c5b02 allow customizable CSR key ID/name and key parameters 2023-09-05 17:59:10 +03:00
Mohammed Al Sahaf 5b09e7df3d pki: rough draft for generating CSR through API 2023-08-25 22:00:28 +03:00
4 changed files with 712 additions and 3 deletions
+73 -3
View File
@@ -18,8 +18,10 @@ import (
"encoding/json"
"fmt"
"net/http"
"regexp"
"strings"
"github.com/google/uuid"
"go.uber.org/zap"
"github.com/caddyserver/caddy/v2"
@@ -29,6 +31,12 @@ func init() {
caddy.RegisterModule(adminAPI{})
}
var (
caInfoPathPattern = regexp.MustCompile(`^ca/[^/]+$`)
getCertPathPattern = regexp.MustCompile(`^ca/[^/]+/certificates$`)
produceCSRPathPattern = regexp.MustCompile(`^ca/[^/]+/csr$`)
)
// adminAPI is a module that serves PKI endpoints to retrieve
// information about the CAs being managed by Caddy.
type adminAPI struct {
@@ -71,12 +79,13 @@ func (a *adminAPI) Routes() []caddy.AdminRoute {
// handleAPIEndpoints routes API requests within adminPKIEndpointBase.
func (a *adminAPI) handleAPIEndpoints(w http.ResponseWriter, r *http.Request) error {
uri := strings.TrimPrefix(r.URL.Path, "/pki/")
parts := strings.Split(uri, "/")
switch {
case len(parts) == 2 && parts[0] == "ca" && parts[1] != "":
case caInfoPathPattern.MatchString(uri):
return a.handleCAInfo(w, r)
case len(parts) == 3 && parts[0] == "ca" && parts[1] != "" && parts[2] == "certificates":
case getCertPathPattern.MatchString(uri):
return a.handleCACerts(w, r)
case produceCSRPathPattern.MatchString(uri):
return a.handleCSRGeneration(w, r)
}
return caddy.APIError{
HTTPStatus: http.StatusNotFound,
@@ -168,6 +177,67 @@ func (a *adminAPI) handleCACerts(w http.ResponseWriter, r *http.Request) error {
return nil
}
func (a *adminAPI) handleCSRGeneration(w http.ResponseWriter, r *http.Request) error {
if r.Method != http.MethodPost {
return caddy.APIError{
HTTPStatus: http.StatusMethodNotAllowed,
Err: fmt.Errorf("method not allowed: %v", r.Method),
}
}
ca, err := a.getCAFromAPIRequestPath(r)
if err != nil {
return caddy.APIError{
HTTPStatus: http.StatusBadRequest,
Err: err,
}
}
// Decode the CSR request from the request body
var csrReq csrRequest
if err := json.NewDecoder(r.Body).Decode(&csrReq); err != nil {
return caddy.APIError{
HTTPStatus: http.StatusBadRequest,
Err: fmt.Errorf("failed to decode CSR request: %v", err),
}
}
csrReq.ID = strings.TrimSpace(csrReq.ID)
if len(csrReq.ID) == 0 {
csrReq.ID = uuid.New().String()
}
if err := csrReq.validate(); err != nil {
return caddy.APIError{
HTTPStatus: http.StatusBadRequest,
Err: fmt.Errorf("invalid CSR request: %v", err),
}
}
// Generate the CSR
csr, err := ca.generateCSR(csrReq)
if err != nil {
return caddy.APIError{
HTTPStatus: http.StatusInternalServerError,
Err: fmt.Errorf("failed to generate CSR: %v", err),
}
}
bs, err := pemEncode("CERTIFICATE REQUEST", csr.Raw)
if err != nil {
return caddy.APIError{
HTTPStatus: http.StatusInternalServerError,
Err: fmt.Errorf("failed to encode CSR to PEM: %v", err),
}
}
w.Header().Set("Content-Type", "application/pkcs10")
w.Header().Set("content-disposition", fmt.Sprintf(`attachment; filename="%s.csr"`, csrReq.ID))
if _, err := w.Write(bs); err != nil {
return caddy.APIError{
HTTPStatus: http.StatusInternalServerError,
Err: fmt.Errorf("failed to write CSR response: %v", err),
}
}
return nil
}
func (a *adminAPI) getCAFromAPIRequestPath(r *http.Request) (*CA, error) {
// Grab the CA ID from the request path, it should be the 4th segment (/pki/ca/<ca>)
id := strings.Split(r.URL.Path, "/")[3]
+70
View File
@@ -16,7 +16,9 @@ package caddypki
import (
"crypto"
"crypto/rand"
"crypto/x509"
"crypto/x509/pkix"
"encoding/json"
"errors"
"fmt"
@@ -29,6 +31,8 @@ import (
"github.com/smallstep/certificates/authority"
"github.com/smallstep/certificates/db"
"github.com/smallstep/truststore"
"go.step.sm/crypto/keyutil"
"go.step.sm/crypto/x509util"
"go.uber.org/zap"
"github.com/caddyserver/caddy/v2"
@@ -394,6 +398,10 @@ func (ca CA) storageKeyIntermediateKey() string {
return path.Join(ca.storageKeyCAPrefix(), "intermediate.key")
}
func (ca CA) storageKeyCSRKey(id string) string {
return path.Join(ca.storageKeyCAPrefix(), id+".csr.key")
}
func (ca CA) newReplacer() *caddy.Replacer {
repl := caddy.NewReplacer()
repl.Set("pki.ca.name", ca.Name)
@@ -421,6 +429,68 @@ func (ca CA) installRoot() error {
)
}
func (ca CA) generateCSR(csrReq csrRequest) (csr *x509.CertificateRequest, err error) {
var signer crypto.Signer
csrKeyPEM, err := ca.storage.Load(ca.ctx, ca.storageKeyCSRKey(csrReq.ID))
if err != nil {
if !errors.Is(err, fs.ErrNotExist) {
return csr, fmt.Errorf("loading csr key '%s': %v", csrReq.ID, err)
}
if csrReq.Key == nil {
signer, err = keyutil.GenerateDefaultSigner()
if err != nil {
return csr, err
}
} else {
signer, err = keyutil.GenerateSigner(csrReq.Key.Type.String(), csrReq.Key.Curve.String(), csrReq.Key.Size)
if err != nil {
return csr, err
}
}
csrKeyPEM, err = certmagic.PEMEncodePrivateKey(signer)
if err != nil {
return csr, fmt.Errorf("encoding csr key: %v", err)
}
if err := ca.storage.Store(ca.ctx, ca.storageKeyCSRKey(csrReq.ID), csrKeyPEM); err != nil {
return csr, fmt.Errorf("saving csr key: %v", err)
}
}
if signer == nil {
signer, err = certmagic.PEMDecodePrivateKey(csrKeyPEM)
if err != nil {
return csr, fmt.Errorf("decoding csr key: %v", err)
}
}
var subject pkix.Name
if csrReq.Request != nil && csrReq.Request.Subject != nil {
subject = pkix.Name{
Country: csrReq.Request.Subject.Country,
Organization: csrReq.Request.Subject.Organization,
OrganizationalUnit: csrReq.Request.Subject.OrganizationalUnit,
Locality: csrReq.Request.Subject.Locality,
Province: csrReq.Request.Subject.Province,
StreetAddress: csrReq.Request.Subject.StreetAddress,
PostalCode: csrReq.Request.Subject.PostalCode,
CommonName: csrReq.Request.Subject.CommonName,
}
}
dnsNames, ips, emails, uris := x509util.SplitSANs(csrReq.Request.SANs)
csrBytes, err := x509.CreateCertificateRequest(rand.Reader, &x509.CertificateRequest{
Subject: subject,
DNSNames: dnsNames,
IPAddresses: ips,
EmailAddresses: emails,
URIs: uris,
}, signer)
if err != nil {
return csr, err
}
return x509.ParseCertificateRequest(csrBytes)
}
// AuthorityConfig is used to help a CA configure
// the underlying signing authority.
type AuthorityConfig struct {
+195
View File
@@ -0,0 +1,195 @@
package caddypki
import (
"encoding/json"
"errors"
"fmt"
"strings"
)
// The key type to be used for signing the CSR. The possible types are:
// EC, RSA, and OKP.
type keyType string
const (
keyTypeEC keyType = "EC"
keyTypeRSA keyType = "RSA"
keyTypeOKP keyType = "OKP"
)
var stringToKey = map[string]keyType{
"EC": keyTypeEC,
"RSA": keyTypeRSA,
"OKP": keyTypeOKP,
}
func (kt *keyType) UnmarshalJSON(b []byte) error {
var s string
if err := json.Unmarshal(b, &s); err != nil {
return err
}
switch s {
case string(keyTypeEC), string(keyTypeRSA), string(keyTypeOKP):
*kt = stringToKey[s]
default:
return fmt.Errorf("unknown key type: %s", s)
}
return nil
}
func (kt keyType) String() string {
return string(kt)
}
// The curve to use with key types EC and OKP.
// If the Type is OKP, then acceptable curves are: Ed25519, or X25519
// If the Type is EC, then acceptable curves are: P-256, P-384, or P-521
type curve string
const (
curveEd25519 curve = "Ed25519"
curveX25519 curve = "X25519"
curveP256 curve = "P-256"
curveP384 curve = "P-384"
curveP521 curve = "P-521"
)
var stringToCurve = map[string]curve{
"Ed25519": curveEd25519,
"X25519": curveX25519,
"P-256": curveP256,
"P-384": curveP384,
"P-521": curveP521,
}
func (c *curve) UnmarshalJSON(b []byte) error {
var s string
if err := json.Unmarshal(b, &s); err != nil {
return err
}
switch s {
case string(curveEd25519), string(curveX25519), string(curveP256), string(curveP384), string(curveP521):
*c = stringToCurve[s]
default:
return fmt.Errorf("unknown curve: %s", s)
}
return nil
}
func (c curve) String() string {
return string(c)
}
type keyParameters struct {
// The key type to be used for signing the CSR. The possible types are:
// EC, RSA, and OKP.
// The value of this field is case-sensitive.
Type keyType `json:"type"`
// The curve to use with key types EC and OKP.
// If the Type is OKP, then acceptable curves are: Ed25519, or X25519
// If the Type is EC, then acceptable curves are: P-256, P-384, or P-521
// The value of this field is case-sensitive.
Curve curve `json:"curve,omitempty"`
// Only used with RSA keys and accepts minimum of 2048.
Size int `json:"size,omitempty"`
}
func (kp *keyParameters) validate() error {
if kp == nil {
return nil
}
if kp.Type == keyTypeRSA {
if kp.Size < 2048 {
return fmt.Errorf("minimum RSA key size is 2048 bits: %v", kp.Size)
}
}
if kp.Type == keyTypeEC {
switch kp.Curve {
case curveP256, curveP384, curveP521:
return nil
default:
return fmt.Errorf("unrecognized EC curve: %v", kp.Curve)
}
}
if kp.Type == keyTypeOKP {
switch kp.Curve {
case curveEd25519, curveX25519:
return nil
default:
return fmt.Errorf("unrecognized OKP curve: %v", kp.Curve)
}
}
return nil
}
type csrRequest struct {
// Custom name assigned to the CSR key. If empty, UUID is generated and assigned.
ID string `json:"id,omitempty"`
// Customization knobs of the generated/loaded key, if desired. The format is:
// {
// // Valid values for type are: EC, RSA, and OKP.
// "type": "",
//
// // The curve to use with key types EC and OKP.
// // If the Type is OKP, then acceptable curves are: Ed25519, or X25519
// // If the Type is EC, then acceptable curves are: P-256, P-384, or P-521
// "curve": "",
//
// // Only used with RSA keys and accepts minimum of 2048.
// "size": 0
// }
//
// If empty, sane defaults will be managed internally without exposing their details
// to the user. At the moment, the default parameters are:
// {
// "type": "EC",
// "curve": "P-256"
// }
// The values are case-sensitive.
Key *keyParameters `json:"key,omitempty"`
Request *requestParameters `json:"request,omitempty"`
}
func (c csrRequest) validate() error {
if !c.Request.valid() {
return errors.New("the 'request' field is not valid")
}
return c.Key.validate()
}
type requestParameters struct {
Subject *subject `json:"subject,omitempty"`
// SANs is a list of subject alternative names for the certificate.
SANs []string `json:"sans,omitempty"`
}
type subject struct {
CommonName string `json:"cn,omitempty"`
Country []string `json:"c,omitempty"`
Organization []string `json:"o,omitempty"`
OrganizationalUnit []string `json:"ou,omitempty"`
Locality []string `json:"l,omitempty"`
Province []string `json:"s,omitempty"`
StreetAddress []string `json:"street_address,omitempty"`
PostalCode []string `json:"postal_code,omitempty"`
}
func (rp *requestParameters) valid() bool {
if rp == nil || (len(rp.SANs) == 0 && rp.Subject == nil) {
return false
}
if len(rp.SANs) > 0 {
for _, san := range rp.SANs {
if strings.TrimSpace(san) == "" {
return false
}
}
}
return rp.Subject == nil || (rp.Subject != nil && len(strings.TrimSpace(rp.Subject.CommonName)) > 0)
}
+374
View File
@@ -0,0 +1,374 @@
package caddypki
import (
"encoding/json"
"testing"
)
func TestParseKeyType(t *testing.T) {
tests := []struct {
name string
input string
expected keyType
err string
}{
{
name: "uppercase EC is recognized",
input: `"EC"`,
expected: keyTypeEC,
},
{
name: "lowercase EC is rejected",
input: `"ec"`,
err: "unknown key type: ec",
},
{
name: "mixed case EC is rejected",
input: `"eC"`,
err: "unknown key type: eC",
},
{
name: "uppercase RSA is recognized",
input: `"RSA"`,
expected: keyTypeRSA,
},
{
name: "lowercase rsa is rejected",
input: `"rsa"`,
err: "unknown key type: rsa",
},
{
name: "mixed case RSA is rejected",
input: `"RsA"`,
err: "unknown key type: RsA",
},
{
name: "uppercase OKP is recognized",
input: `"OKP"`,
expected: keyTypeOKP,
},
{
name: "lowercase OKP is rejected",
input: `"okp"`,
err: "unknown key type: okp",
},
{
name: "mixed case OKP is rejected",
input: `"OkP"`,
err: "unknown key type: OkP",
},
{
name: "unknown key type is rejected",
input: `"foo"`,
err: "unknown key type: foo",
},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
var kt keyType
err := json.Unmarshal([]byte(test.input), &kt)
if test.err != "" {
if err == nil {
t.Errorf("expected error %q, but got nil", test.err)
}
if err.Error() != test.err {
t.Errorf("expected error %q, but got %q", test.err, err.Error())
}
return
}
if err != nil {
t.Errorf("expected no error, but got %q", err.Error())
return
}
if kt != test.expected {
t.Errorf("expected %v, but got %v", test.expected, kt)
}
})
}
}
func TestCSRKeyParameterValidate(t *testing.T) {
tests := []struct {
name string
key *keyParameters
wantErr bool
}{
{
name: "empty request is valid",
key: nil,
wantErr: false,
},
{
name: "RSA with size 2048 is valid",
key: &keyParameters{
Type: keyTypeRSA,
Size: 2048,
},
wantErr: false,
},
{
name: "RSA with size less than 2048 is invalid",
key: &keyParameters{
Type: keyTypeRSA,
Size: 1024,
},
wantErr: true,
},
{
name: "EC key with curve P-256 is valid",
key: &keyParameters{
Type: keyTypeEC,
Curve: "P-256",
},
wantErr: false,
},
{
name: "EC key with curve P-256 is valid",
key: &keyParameters{
Type: keyTypeEC,
Curve: "P-256",
},
wantErr: false,
},
{
name: "EC key with curve P-384 is valid",
key: &keyParameters{
Type: keyTypeEC,
Curve: "P-384",
},
wantErr: false,
},
{
name: "EC key with curve P-521 is valid",
key: &keyParameters{
Type: keyTypeEC,
Curve: "P-521",
},
wantErr: false,
},
{
name: "EC key with unknown curve is invalid",
key: &keyParameters{
Type: keyTypeEC,
Curve: "foo",
},
wantErr: true,
},
{
name: "EC key with Ed25519 curve is invalid",
key: &keyParameters{
Type: keyTypeEC,
Curve: "Ed25519",
},
wantErr: true,
},
{
name: "EC key with X25519 curve is invalid",
key: &keyParameters{
Type: keyTypeEC,
Curve: "X25519",
},
wantErr: true,
},
{
name: "OKP key with curve Ed25519 is valid",
key: &keyParameters{
Type: keyTypeOKP,
Curve: "Ed25519",
},
wantErr: false,
},
{
name: "OKP key with curve X25519 is valid",
key: &keyParameters{
Type: keyTypeOKP,
Curve: "X25519",
},
wantErr: false,
},
{
name: "OKP with unknown curve is invalid",
key: &keyParameters{
Type: keyTypeOKP,
Curve: "foo",
},
wantErr: true,
},
{
name: "OKP key with curve P-256 is invalid",
key: &keyParameters{
Type: keyTypeOKP,
Curve: "P-256",
},
wantErr: true,
},
{
name: "OKP key with curve P-384 is invalid",
key: &keyParameters{
Type: keyTypeOKP,
Curve: "P-384",
},
wantErr: true,
},
{
name: "OKP key with curve P-521 is invalid",
key: &keyParameters{
Type: keyTypeOKP,
Curve: "P-521",
},
wantErr: true,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if err := tt.key.validate(); (err != nil) != tt.wantErr {
t.Errorf("keyParameter.validate() error = %v, wantErr %v", err, tt.wantErr)
}
})
}
}
func TestParseCurve(t *testing.T) {
tests := []struct {
name string
input string
expected curve
err string
}{
{
name: "Ed25519 is recognized",
input: `"Ed25519"`,
expected: curveEd25519,
},
{
name: "ed25519 is rejected",
input: `"ed25519"`,
err: "unknown curve: ed25519",
},
{
name: "eD25519 is rejected",
input: `"eD25519"`,
err: "unknown curve: eD25519",
},
{
name: "X25519 is recognized",
input: `"X25519"`,
expected: curveX25519,
},
{
name: "x25519 is rejected",
input: `"x25519"`,
err: "unknown curve: x25519",
},
{
name: "P-256 is recognized",
input: `"P-256"`,
expected: curveP256,
},
{
name: "p-256 is rejected",
input: `"p-256"`,
err: "unknown curve: p-256",
},
{
name: "P-384 is recognized",
input: `"P-384"`,
expected: curveP384,
},
{
name: "p-384 is rejected",
input: `"p-384"`,
err: "unknown curve: p-384",
},
{
name: "P-521 is recognized",
input: `"P-521"`,
expected: curveP521,
},
{
name: "p-521 is rejected",
input: `"p-521"`,
err: "unknown curve: p-521",
},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
var kt curve
err := json.Unmarshal([]byte(test.input), &kt)
if test.err != "" {
if err == nil {
t.Errorf("expected error %q, but got nil", test.err)
}
if err.Error() != test.err {
t.Errorf("expected error %q, but got %q", test.err, err.Error())
}
return
}
if err != nil {
t.Errorf("expected no error, but got %q", err.Error())
return
}
if kt != test.expected {
t.Errorf("expected %v, but got %v", test.expected, kt)
}
})
}
}
func TestRequestParametersValidation(t *testing.T) {
tests := []struct {
name string
req *requestParameters
want bool
}{
{
name: "nil request is invalid",
req: nil,
want: false,
},
{
name: "empty request is invalid",
req: &requestParameters{},
want: false,
},
{
name: "request containing empty SAN value is invalid",
req: &requestParameters{
SANs: []string{"example.com", "", "foo.com"},
},
want: false,
},
{
name: "request with SANs is valid",
req: &requestParameters{
SANs: []string{"example.com"},
},
want: true,
},
{
name: "request with non-empty CommonName is valid",
req: &requestParameters{
Subject: &subject{CommonName: "example.com"},
},
want: true,
},
{
name: "request with empty-space CommonName is invalid",
req: &requestParameters{
Subject: &subject{CommonName: " "},
},
want: false,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if got := tt.req.valid(); got != tt.want {
t.Errorf("requestParameters.valid() = %v, want %v", got, tt.want)
}
})
}
}