Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,3 +1,6 @@
# Version X
- support SAN for client certificates

# Version 1.2.1
- run slashing protection database garbage collection periodically
- add commit hash to log on startup
Expand Down
3 changes: 3 additions & 0 deletions docs/configuration.md
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,9 @@ certificates:
# ca-cert is the certificate of the CA that issued the client certificates. If not present Dirk will use
# the standard CA certificates supplied with the server.
ca-cert: file:///home/me/dirk/security/certificates/ca.crt
# Note: Client certificates should include the client identity in Subject Alternative Names (SAN).
# Dirk supports DNS names, IP addresses, and email addresses in SAN fields, with DNS names preferred.
# Legacy certificates using only Common Name (CN) are still supported for backward compatibility.
# storage-path is the path where information created by the slashing protection system is stored. If not
# supplied it will default to using the 'storage' directory in the user's home directory.
storage-path: /home/me/dirk/protection
Expand Down
20 changes: 18 additions & 2 deletions docs/permissions.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,9 +4,25 @@ Dirk has a permissions system that allows fine-grained control of access to Dirk
Dirk permissions have three components: the client, the account, and the operation.

## Clients
Client names are embedded in the certificate that is used to connect to Dirk. These certificates must be issued by either the local certificate authority known to Dirk, or one of the trusted root certificate authorities.
Client identities are extracted from the certificate that is used to connect to Dirk. These certificates must be issued by either the local certificate authority known to Dirk, or one of the trusted root certificate authorities.

Client names should be fully qualified (_i.e._ server.example.com rather than just server) to avoid potential confusion with multiple clients of the same name in different domains.
### Client Identity Extraction
Dirk extracts the client identity from certificates following RFC 6125 compliance by prioritizing Subject Alternative Name (SAN) fields over the deprecated Common Name (CN). The identity extraction follows this priority order:

1. **DNS names from SAN** - Most common for service-to-service authentication (e.g., `validator-01.example.com`)
2. **IP addresses from SAN** - Valid for direct IP-based connections (e.g., `192.168.1.100` or `2001:db8::1`)
3. **Email addresses from SAN** - Common in client certificates for user identity (e.g., `[email protected]`)
4. **Common Name (CN)** - Fallback for backward compatibility with legacy certificates

This approach ensures compatibility with:
- **Modern certificates** issued by contemporary certificate authorities (often with empty CN and SAN-only)
- **Legacy certificates** using only the CN field
- **Multi-identity certificates** with multiple SANs (the first entry of the highest-priority type is used)

**Note:** Dirk does not support URI-based SANs (such as SPIFFE IDs or HTTPS URIs) as these are not commonly used in validator/signer architectures and would complicate the permission matching system.

### Client Identity Best Practices
Client identities should be fully qualified (_i.e._ `server.example.com` rather than just `server`) to avoid potential confusion with multiple clients of the same name in different domains. When using IP addresses, ensure they are static to maintain consistent authorization.

## Accounts
Accounts are standard `ethdo` account specifiers of the form `wallet/account`. It is possible for either or both of `wallet` and `account` to be regular expressions. Some examples of account specifiers are:
Expand Down
11 changes: 11 additions & 0 deletions services/api/grpc/handlers/helpers.go
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,17 @@ func GenerateCredentials(ctx context.Context) *checker.Credentials {
if client, ok := ctx.Value(&interceptors.ClientName{}).(string); ok {
res.Client = client
}
if identitySource, ok := ctx.Value(&interceptors.ClientIdentitySource{}).(string); ok {
res.ClientIdentitySource = identitySource
}
if certSANs, ok := ctx.Value(&interceptors.ClientCertificateSANs{}).(*interceptors.CertificateSANs); ok {
// Convert from interceptors type to checker type.
res.ClientCertificateSANs = &checker.CertificateSANs{
DNSNames: certSANs.DNSNames,
IPAddresses: certSANs.IPAddresses,
EmailAddresses: certSANs.EmailAddresses,
}
}
if ip, ok := ctx.Value(&interceptors.ExternalIP{}).(string); ok {
res.IP = ip
}
Expand Down
93 changes: 90 additions & 3 deletions services/api/grpc/interceptors/clientinfo.go
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ package interceptors

import (
"context"
"crypto/x509"

"google.golang.org/grpc"
"google.golang.org/grpc/codes"
Expand All @@ -23,10 +24,37 @@ import (
"google.golang.org/grpc/status"
)

// ClientName is a context tag for the CN of the client's certificate.
// ClientName is a context tag for the identity extracted from the client's certificate.
type ClientName struct{}

// ClientInfoInterceptor adds the client certificate common name to incoming requests.
// ClientIdentitySource is a context tag for the source of the client identity.
type ClientIdentitySource struct{}

// ClientCertificateSANs is a context tag for all SANs from the client's certificate.
type ClientCertificateSANs struct{}

// ClientInfoInterceptor adds the client certificate identity to incoming requests.
//
// Identity is extracted from the client certificate using a prioritized approach
// that complies with RFC 6125 (domain name verification) by preferring Subject
// Alternative Name (SAN) fields over the deprecated Common Name (CN).
//
// The identity extraction follows this priority order:
// 1. DNS names from SAN - Most common for service-to-service authentication
// 2. IP addresses from SAN - Valid for direct IP-based connections
// 3. Email addresses from SAN - Common in client certificates for user identity
// 4. Common Name (CN) - Fallback for backward compatibility with legacy certificates
//
// Note on URI SANs: We intentionally do not support URI-based SANs (e.g., SPIFFE IDs,
// https:// URIs) because:
// - They are not commonly used in Dirk's validator/signer architecture
// - URI schemes vary widely and require additional parsing/validation logic
// - The permission system expects simple string identities (hostnames, IPs, emails)
// - Adding URI support would complicate authorization rules without clear benefit
//
// If URI SAN support is needed in the future, it should be added with careful
// consideration of which URI schemes to accept and how to normalize them for
// permission matching.
func ClientInfoInterceptor() grpc.UnaryServerInterceptor {
return func(ctx context.Context, req any, _ *grpc.UnaryServerInfo, handler grpc.UnaryHandler) (any, error) {
grpcPeer, ok := peer.FromContext(ctx)
Expand All @@ -40,10 +68,69 @@ func ClientInfoInterceptor() grpc.UnaryServerInterceptor {
peerCerts := authState.PeerCertificates
if len(peerCerts) > 0 {
peerCert := peerCerts[0]
newCtx = context.WithValue(ctx, &ClientName{}, peerCert.Subject.CommonName)
clientIdentity, identitySource := extractClientIdentity(peerCert)
certificateSANs := extractCertificateSANs(peerCert)

newCtx = context.WithValue(ctx, &ClientName{}, clientIdentity)
newCtx = context.WithValue(newCtx, &ClientIdentitySource{}, identitySource)
newCtx = context.WithValue(newCtx, &ClientCertificateSANs{}, certificateSANs)
}
}

return handler(newCtx, req)
}
}

// CertificateSANs contains all Subject Alternative Name values from a certificate.
type CertificateSANs struct {
// DNSNames contains all DNS names from the certificate's SAN extension.
DNSNames []string
// IPAddresses contains all IP addresses from the certificate's SAN extension (as strings).
IPAddresses []string
// EmailAddresses contains all email addresses from the certificate's SAN extension.
EmailAddresses []string
}

// extractClientIdentity extracts the client identity from an x509 certificate.
func extractClientIdentity(cert *x509.Certificate) (string, string) {
// Priority 1: DNS names from SAN (RFC 6125 compliant).
if len(cert.DNSNames) > 0 && cert.DNSNames[0] != "" {
return cert.DNSNames[0], "san-dns"
}

// Priority 2: IP addresses from SAN.
if len(cert.IPAddresses) > 0 {
return cert.IPAddresses[0].String(), "san-ip"
}

// Priority 3: Email addresses from SAN.
if len(cert.EmailAddresses) > 0 && cert.EmailAddresses[0] != "" {
return cert.EmailAddresses[0], "san-email"
}

// Priority 4: CN fallback for backward compatibility with legacy certificates.
if cert.Subject.CommonName != "" {
return cert.Subject.CommonName, "cn"
}

return "", ""
}

// extractCertificateSANs extracts all Subject Alternative Names from a certificate.
func extractCertificateSANs(cert *x509.Certificate) *CertificateSANs {
sans := &CertificateSANs{
DNSNames: make([]string, len(cert.DNSNames)),
IPAddresses: make([]string, len(cert.IPAddresses)),
EmailAddresses: make([]string, len(cert.EmailAddresses)),
}

copy(sans.DNSNames, cert.DNSNames)

for i, ip := range cert.IPAddresses {
sans.IPAddresses[i] = ip.String()
}

copy(sans.EmailAddresses, cert.EmailAddresses)

return sans
}
Loading