Add CRL endpoint, sign-CSR web route, and policy-based issuance authorization
- Register handleSignCSR route in webserver (was dead code)
- Add GET /v1/pki/{mount}/issuer/{name}/crl REST endpoint and
PKIService.GetCRL gRPC RPC for DER-encoded CRL generation
- Replace admin-only gates on issue/renew/sign-csr with policy-based
access control: admins grant-all, authenticated users subject to
identifier ownership (CN/SANs not held by another user's active cert)
and optional policy overrides via ca/{mount}/id/{identifier} resources
- Add PolicyChecker to engine.Request and policy.Match() method to
distinguish matched rules from default deny
- Update and expand CA engine tests for ownership, revocation freeing,
and policy override scenarios
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -6,12 +6,14 @@ import (
|
||||
"crypto"
|
||||
"crypto/ecdsa"
|
||||
"crypto/ed25519"
|
||||
"crypto/rand"
|
||||
"crypto/rsa"
|
||||
"crypto/x509"
|
||||
"encoding/json"
|
||||
"encoding/pem"
|
||||
"errors"
|
||||
"fmt"
|
||||
"math/big"
|
||||
"net"
|
||||
"strings"
|
||||
"sync"
|
||||
@@ -24,13 +26,14 @@ import (
|
||||
)
|
||||
|
||||
var (
|
||||
ErrSealed = errors.New("ca: engine is sealed")
|
||||
ErrIssuerNotFound = errors.New("ca: issuer not found")
|
||||
ErrIssuerExists = errors.New("ca: issuer already exists")
|
||||
ErrCertNotFound = errors.New("ca: certificate not found")
|
||||
ErrUnknownProfile = errors.New("ca: unknown profile")
|
||||
ErrForbidden = errors.New("ca: forbidden")
|
||||
ErrUnauthorized = errors.New("ca: authentication required")
|
||||
ErrSealed = errors.New("ca: engine is sealed")
|
||||
ErrIssuerNotFound = errors.New("ca: issuer not found")
|
||||
ErrIssuerExists = errors.New("ca: issuer already exists")
|
||||
ErrCertNotFound = errors.New("ca: certificate not found")
|
||||
ErrUnknownProfile = errors.New("ca: unknown profile")
|
||||
ErrForbidden = errors.New("ca: forbidden")
|
||||
ErrUnauthorized = errors.New("ca: authentication required")
|
||||
ErrIdentifierInUse = errors.New("ca: identifier already issued to another user")
|
||||
)
|
||||
|
||||
// issuerState holds in-memory state for a loaded issuer.
|
||||
@@ -360,6 +363,155 @@ func (e *CAEngine) GetChainPEM(issuerName string) ([]byte, error) {
|
||||
return chain, nil
|
||||
}
|
||||
|
||||
// GetCRLDER generates and returns a DER-encoded CRL for the named issuer,
|
||||
// covering all revoked leaf certificates signed by that issuer.
|
||||
func (e *CAEngine) GetCRLDER(ctx context.Context, issuerName string) ([]byte, error) {
|
||||
e.mu.RLock()
|
||||
defer e.mu.RUnlock()
|
||||
|
||||
if e.rootCert == nil {
|
||||
return nil, ErrSealed
|
||||
}
|
||||
|
||||
is, ok := e.issuers[issuerName]
|
||||
if !ok {
|
||||
return nil, ErrIssuerNotFound
|
||||
}
|
||||
|
||||
// Scan all cert records for revoked certs issued by this issuer.
|
||||
paths, err := e.barrier.List(ctx, e.mountPath+"certs/")
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("ca: list certs for CRL: %w", err)
|
||||
}
|
||||
|
||||
var entries []x509.RevocationListEntry
|
||||
for _, p := range paths {
|
||||
if !strings.HasSuffix(p, ".json") {
|
||||
continue
|
||||
}
|
||||
data, err := e.barrier.Get(ctx, e.mountPath+"certs/"+p)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
var record CertRecord
|
||||
if err := json.Unmarshal(data, &record); err != nil {
|
||||
continue
|
||||
}
|
||||
if !record.Revoked || record.Issuer != issuerName {
|
||||
continue
|
||||
}
|
||||
serial := new(big.Int)
|
||||
serial.SetString(record.Serial, 16)
|
||||
entries = append(entries, x509.RevocationListEntry{
|
||||
SerialNumber: serial,
|
||||
RevocationTime: record.RevokedAt,
|
||||
})
|
||||
}
|
||||
|
||||
template := &x509.RevocationList{
|
||||
Number: big.NewInt(time.Now().Unix()),
|
||||
ThisUpdate: time.Now(),
|
||||
NextUpdate: time.Now().Add(24 * time.Hour),
|
||||
RevokedCertificateEntries: entries,
|
||||
}
|
||||
|
||||
signer, ok2 := is.key.(crypto.Signer)
|
||||
if !ok2 {
|
||||
return nil, fmt.Errorf("ca: issuer key does not implement crypto.Signer")
|
||||
}
|
||||
return x509.CreateRevocationList(rand.Reader, template, is.cert, signer)
|
||||
}
|
||||
|
||||
// mountName extracts the user-facing mount name from the mount path.
|
||||
// Mount paths are "engine/{type}/{name}/".
|
||||
func (e *CAEngine) mountName() string {
|
||||
parts := strings.Split(strings.TrimSuffix(e.mountPath, "/"), "/")
|
||||
if len(parts) >= 3 {
|
||||
return parts[2]
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// authorizeIssuance checks whether the caller may issue a cert with the given
|
||||
// identifiers (CN + SANs). For each identifier:
|
||||
// 1. If a policy rule explicitly allows or denies it, that decision wins.
|
||||
// 2. If no policy covers it, the default ownership rule applies: the identifier
|
||||
// must not be held by an active cert issued by another user.
|
||||
//
|
||||
// Caller must hold e.mu (at least RLock).
|
||||
func (e *CAEngine) authorizeIssuance(ctx context.Context, req *engine.Request, cn string, sans []string) error {
|
||||
mountName := e.mountName()
|
||||
|
||||
var needOwnershipCheck []string
|
||||
for _, id := range append([]string{cn}, sans...) {
|
||||
if id == "" {
|
||||
continue
|
||||
}
|
||||
if req.CheckPolicy != nil {
|
||||
resource := "ca/" + mountName + "/id/" + id
|
||||
effect, matched := req.CheckPolicy(resource, "write")
|
||||
if matched {
|
||||
if effect == "deny" {
|
||||
return ErrForbidden
|
||||
}
|
||||
continue // policy explicitly allows
|
||||
}
|
||||
}
|
||||
needOwnershipCheck = append(needOwnershipCheck, id)
|
||||
}
|
||||
|
||||
if len(needOwnershipCheck) == 0 {
|
||||
return nil
|
||||
}
|
||||
return e.checkIdentifierOwnership(ctx, needOwnershipCheck, req.CallerInfo.Username)
|
||||
}
|
||||
|
||||
// checkIdentifierOwnership scans all active (non-revoked, non-expired) cert
|
||||
// records and returns ErrIdentifierInUse if any of the given identifiers are
|
||||
// held by a cert issued by a different user.
|
||||
//
|
||||
// Caller must hold e.mu (at least RLock).
|
||||
func (e *CAEngine) checkIdentifierOwnership(ctx context.Context, identifiers []string, username string) error {
|
||||
paths, err := e.barrier.List(ctx, e.mountPath+"certs/")
|
||||
if err != nil {
|
||||
return fmt.Errorf("ca: list certs for ownership check: %w", err)
|
||||
}
|
||||
|
||||
now := time.Now()
|
||||
for _, p := range paths {
|
||||
if !strings.HasSuffix(p, ".json") {
|
||||
continue
|
||||
}
|
||||
data, err := e.barrier.Get(ctx, e.mountPath+"certs/"+p)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
var record CertRecord
|
||||
if err := json.Unmarshal(data, &record); err != nil {
|
||||
continue
|
||||
}
|
||||
if record.Revoked || now.After(record.ExpiresAt) {
|
||||
continue
|
||||
}
|
||||
if strings.EqualFold(record.IssuedBy, username) {
|
||||
continue
|
||||
}
|
||||
|
||||
// Check for overlap with the requested identifiers.
|
||||
held := make(map[string]bool)
|
||||
held[strings.ToLower(record.CN)] = true
|
||||
for _, san := range record.SANs {
|
||||
held[strings.ToLower(san)] = true
|
||||
}
|
||||
for _, id := range identifiers {
|
||||
if held[strings.ToLower(id)] {
|
||||
return fmt.Errorf("%w: %s", ErrIdentifierInUse, id)
|
||||
}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// --- Operation handlers ---
|
||||
|
||||
func (e *CAEngine) handleImportRoot(ctx context.Context, req *engine.Request) (*engine.Response, error) {
|
||||
@@ -664,7 +816,7 @@ func (e *CAEngine) handleIssue(ctx context.Context, req *engine.Request) (*engin
|
||||
if req.CallerInfo == nil {
|
||||
return nil, ErrUnauthorized
|
||||
}
|
||||
if !req.CallerInfo.IsAdmin {
|
||||
if !req.CallerInfo.IsUser() {
|
||||
return nil, ErrForbidden
|
||||
}
|
||||
|
||||
@@ -686,6 +838,16 @@ func (e *CAEngine) handleIssue(ctx context.Context, req *engine.Request) (*engin
|
||||
return nil, fmt.Errorf("ca: common_name is required")
|
||||
}
|
||||
|
||||
// Parse SANs.
|
||||
var dnsNames []string
|
||||
var ipAddrs []string
|
||||
if v, ok := req.Data["dns_names"].([]interface{}); ok {
|
||||
dnsNames = toStringSlice(v)
|
||||
}
|
||||
if v, ok := req.Data["ip_addresses"].([]interface{}); ok {
|
||||
ipAddrs = toStringSlice(v)
|
||||
}
|
||||
|
||||
e.mu.Lock()
|
||||
defer e.mu.Unlock()
|
||||
|
||||
@@ -698,6 +860,14 @@ func (e *CAEngine) handleIssue(ctx context.Context, req *engine.Request) (*engin
|
||||
return nil, ErrIssuerNotFound
|
||||
}
|
||||
|
||||
// Authorization: admins bypass all issuance checks.
|
||||
if !req.CallerInfo.IsAdmin {
|
||||
sans := append(dnsNames, ipAddrs...)
|
||||
if err := e.authorizeIssuance(ctx, req, cn, sans); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
|
||||
profile, ok := GetProfile(profileName)
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("%w: %s", ErrUnknownProfile, profileName)
|
||||
@@ -724,16 +894,6 @@ func (e *CAEngine) handleIssue(ctx context.Context, req *engine.Request) (*engin
|
||||
keySize = int(v)
|
||||
}
|
||||
|
||||
// Parse SANs.
|
||||
var dnsNames []string
|
||||
var ipAddrs []string
|
||||
if v, ok := req.Data["dns_names"].([]interface{}); ok {
|
||||
dnsNames = toStringSlice(v)
|
||||
}
|
||||
if v, ok := req.Data["ip_addresses"].([]interface{}); ok {
|
||||
ipAddrs = toStringSlice(v)
|
||||
}
|
||||
|
||||
// Generate leaf key pair and CSR.
|
||||
ks := certgen.KeySpec{Algorithm: keyAlg, Size: keySize}
|
||||
_, leafKey, err := ks.Generate()
|
||||
@@ -914,7 +1074,7 @@ func (e *CAEngine) handleRenew(ctx context.Context, req *engine.Request) (*engin
|
||||
if req.CallerInfo == nil {
|
||||
return nil, ErrUnauthorized
|
||||
}
|
||||
if !req.CallerInfo.IsAdmin {
|
||||
if !req.CallerInfo.IsUser() {
|
||||
return nil, ErrForbidden
|
||||
}
|
||||
|
||||
@@ -947,6 +1107,24 @@ func (e *CAEngine) handleRenew(ctx context.Context, req *engine.Request) (*engin
|
||||
return nil, fmt.Errorf("ca: parse cert record: %w", err)
|
||||
}
|
||||
|
||||
// Authorization: admins bypass; otherwise check policy then ownership.
|
||||
if !req.CallerInfo.IsAdmin {
|
||||
allowed := false
|
||||
if req.CheckPolicy != nil {
|
||||
resource := "ca/" + e.mountName() + "/id/" + serial
|
||||
effect, matched := req.CheckPolicy(resource, "write")
|
||||
if matched {
|
||||
if effect == "deny" {
|
||||
return nil, ErrForbidden
|
||||
}
|
||||
allowed = true
|
||||
}
|
||||
}
|
||||
if !allowed && !strings.EqualFold(record.IssuedBy, req.CallerInfo.Username) {
|
||||
return nil, ErrForbidden
|
||||
}
|
||||
}
|
||||
|
||||
// Look up issuer.
|
||||
is, ok := e.issuers[record.Issuer]
|
||||
if !ok {
|
||||
@@ -1043,7 +1221,7 @@ func (e *CAEngine) handleSignCSR(ctx context.Context, req *engine.Request) (*eng
|
||||
if req.CallerInfo == nil {
|
||||
return nil, ErrUnauthorized
|
||||
}
|
||||
if !req.CallerInfo.IsAdmin {
|
||||
if !req.CallerInfo.IsUser() {
|
||||
return nil, ErrForbidden
|
||||
}
|
||||
|
||||
@@ -1097,6 +1275,14 @@ func (e *CAEngine) handleSignCSR(ctx context.Context, req *engine.Request) (*eng
|
||||
return nil, ErrIssuerNotFound
|
||||
}
|
||||
|
||||
// Authorization: admins bypass; otherwise check identifiers from the CSR.
|
||||
if !req.CallerInfo.IsAdmin {
|
||||
sans := append(csr.DNSNames, ipStrings(csr.IPAddresses)...)
|
||||
if err := e.authorizeIssuance(ctx, req, csr.Subject.CommonName, sans); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
|
||||
leafCert, err := profile.SignRequest(is.cert, csr, is.key)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("ca: sign CSR: %w", err)
|
||||
|
||||
Reference in New Issue
Block a user