PKI Package (internal/pki/): - CA initialization with configurable validity and key size - Server certificate generation with DNS/IP SANs - Agent certificate generation (agent ID in CN) - Certificate revocation list (CRL) support - mTLS TLS configuration helpers - File-based certificate store with JSON persistence CLI Commands (cmd/tyto/): - `tyto pki init-ca` - Initialize new Certificate Authority - `tyto pki gen-server` - Generate server certificate - `tyto pki gen-agent` - Generate agent certificate - `tyto pki revoke` - Revoke certificate by serial - `tyto pki list` - List all certificates - `tyto pki info` - Show CA information Security Features: - RSA 4096-bit keys by default - TLS 1.2 minimum version - Client certificate verification for mTLS - CRL checking in TLS handshake - Agent ID extraction from verified certificates 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
232 lines
5.5 KiB
Go
232 lines
5.5 KiB
Go
package pki
|
|
|
|
import (
|
|
"crypto/rand"
|
|
"crypto/rsa"
|
|
"crypto/x509"
|
|
"crypto/x509/pkix"
|
|
"encoding/pem"
|
|
"fmt"
|
|
"math/big"
|
|
"os"
|
|
"path/filepath"
|
|
"time"
|
|
)
|
|
|
|
// CA represents a Certificate Authority for signing certificates.
|
|
type CA struct {
|
|
cert *x509.Certificate
|
|
key *rsa.PrivateKey
|
|
certPEM []byte
|
|
keyPEM []byte
|
|
store *Store
|
|
}
|
|
|
|
// CAConfig contains options for creating a new CA.
|
|
type CAConfig struct {
|
|
CommonName string
|
|
Organization string
|
|
Country string
|
|
Validity time.Duration
|
|
KeySize int
|
|
}
|
|
|
|
// DefaultCAConfig returns a CAConfig with sensible defaults.
|
|
func DefaultCAConfig() CAConfig {
|
|
return CAConfig{
|
|
CommonName: "Tyto CA",
|
|
Organization: "Tyto",
|
|
Country: "US",
|
|
Validity: DefaultCAValidity,
|
|
KeySize: DefaultKeySize,
|
|
}
|
|
}
|
|
|
|
// InitCA creates a new Certificate Authority with the given configuration.
|
|
func InitCA(cfg CAConfig) (*CA, error) {
|
|
if cfg.KeySize == 0 {
|
|
cfg.KeySize = DefaultKeySize
|
|
}
|
|
if cfg.Validity == 0 {
|
|
cfg.Validity = DefaultCAValidity
|
|
}
|
|
|
|
// Generate private key
|
|
key, err := rsa.GenerateKey(rand.Reader, cfg.KeySize)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to generate CA key: %w", err)
|
|
}
|
|
|
|
// Generate serial number
|
|
serialNumber, err := rand.Int(rand.Reader, new(big.Int).Lsh(big.NewInt(1), 128))
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to generate serial number: %w", err)
|
|
}
|
|
|
|
now := time.Now()
|
|
template := &x509.Certificate{
|
|
SerialNumber: serialNumber,
|
|
Subject: pkix.Name{
|
|
CommonName: cfg.CommonName,
|
|
Organization: []string{cfg.Organization},
|
|
Country: []string{cfg.Country},
|
|
},
|
|
NotBefore: now,
|
|
NotAfter: now.Add(cfg.Validity),
|
|
KeyUsage: x509.KeyUsageCertSign | x509.KeyUsageCRLSign | x509.KeyUsageDigitalSignature,
|
|
BasicConstraintsValid: true,
|
|
IsCA: true,
|
|
MaxPathLen: 1,
|
|
}
|
|
|
|
// Self-sign the certificate
|
|
certDER, err := x509.CreateCertificate(rand.Reader, template, template, &key.PublicKey, key)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to create CA certificate: %w", err)
|
|
}
|
|
|
|
cert, err := x509.ParseCertificate(certDER)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to parse CA certificate: %w", err)
|
|
}
|
|
|
|
// Encode to PEM
|
|
certPEM := pem.EncodeToMemory(&pem.Block{
|
|
Type: "CERTIFICATE",
|
|
Bytes: certDER,
|
|
})
|
|
|
|
keyPEM := pem.EncodeToMemory(&pem.Block{
|
|
Type: "RSA PRIVATE KEY",
|
|
Bytes: x509.MarshalPKCS1PrivateKey(key),
|
|
})
|
|
|
|
return &CA{
|
|
cert: cert,
|
|
key: key,
|
|
certPEM: certPEM,
|
|
keyPEM: keyPEM,
|
|
}, nil
|
|
}
|
|
|
|
// LoadCA loads an existing CA from PEM-encoded certificate and key.
|
|
func LoadCA(certPEM, keyPEM []byte) (*CA, error) {
|
|
// Parse certificate
|
|
certBlock, _ := pem.Decode(certPEM)
|
|
if certBlock == nil {
|
|
return nil, fmt.Errorf("failed to decode CA certificate PEM")
|
|
}
|
|
|
|
cert, err := x509.ParseCertificate(certBlock.Bytes)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to parse CA certificate: %w", err)
|
|
}
|
|
|
|
if !cert.IsCA {
|
|
return nil, fmt.Errorf("certificate is not a CA")
|
|
}
|
|
|
|
// Parse private key
|
|
keyBlock, _ := pem.Decode(keyPEM)
|
|
if keyBlock == nil {
|
|
return nil, fmt.Errorf("failed to decode CA key PEM")
|
|
}
|
|
|
|
var key *rsa.PrivateKey
|
|
switch keyBlock.Type {
|
|
case "RSA PRIVATE KEY":
|
|
key, err = x509.ParsePKCS1PrivateKey(keyBlock.Bytes)
|
|
case "PRIVATE KEY":
|
|
parsedKey, parseErr := x509.ParsePKCS8PrivateKey(keyBlock.Bytes)
|
|
if parseErr != nil {
|
|
return nil, fmt.Errorf("failed to parse CA key: %w", parseErr)
|
|
}
|
|
var ok bool
|
|
key, ok = parsedKey.(*rsa.PrivateKey)
|
|
if !ok {
|
|
return nil, fmt.Errorf("CA key is not RSA")
|
|
}
|
|
default:
|
|
return nil, fmt.Errorf("unsupported key type: %s", keyBlock.Type)
|
|
}
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to parse CA key: %w", err)
|
|
}
|
|
|
|
return &CA{
|
|
cert: cert,
|
|
key: key,
|
|
certPEM: certPEM,
|
|
keyPEM: keyPEM,
|
|
}, nil
|
|
}
|
|
|
|
// LoadCAFromDir loads a CA from a directory containing ca.crt and ca.key files.
|
|
func LoadCAFromDir(dir string) (*CA, error) {
|
|
certPath := filepath.Join(dir, "ca.crt")
|
|
keyPath := filepath.Join(dir, "ca.key")
|
|
|
|
certPEM, err := os.ReadFile(certPath)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to read CA certificate: %w", err)
|
|
}
|
|
|
|
keyPEM, err := os.ReadFile(keyPath)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to read CA key: %w", err)
|
|
}
|
|
|
|
ca, err := LoadCA(certPEM, keyPEM)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
// Initialize store for this directory
|
|
ca.store = NewStore(dir)
|
|
|
|
return ca, nil
|
|
}
|
|
|
|
// SaveToDir saves the CA certificate and key to a directory.
|
|
func (ca *CA) SaveToDir(dir string) error {
|
|
if err := os.MkdirAll(dir, 0700); err != nil {
|
|
return fmt.Errorf("failed to create CA directory: %w", err)
|
|
}
|
|
|
|
certPath := filepath.Join(dir, "ca.crt")
|
|
keyPath := filepath.Join(dir, "ca.key")
|
|
|
|
if err := os.WriteFile(certPath, ca.certPEM, 0644); err != nil {
|
|
return fmt.Errorf("failed to write CA certificate: %w", err)
|
|
}
|
|
|
|
if err := os.WriteFile(keyPath, ca.keyPEM, 0600); err != nil {
|
|
return fmt.Errorf("failed to write CA key: %w", err)
|
|
}
|
|
|
|
// Initialize store
|
|
ca.store = NewStore(dir)
|
|
|
|
return nil
|
|
}
|
|
|
|
// Certificate returns the CA certificate.
|
|
func (ca *CA) Certificate() *x509.Certificate {
|
|
return ca.cert
|
|
}
|
|
|
|
// CertificatePEM returns the PEM-encoded CA certificate.
|
|
func (ca *CA) CertificatePEM() []byte {
|
|
return ca.certPEM
|
|
}
|
|
|
|
// Info returns metadata about the CA certificate.
|
|
func (ca *CA) Info() CertInfo {
|
|
return CertInfoFromX509(ca.cert, CertTypeCA)
|
|
}
|
|
|
|
// Store returns the certificate store associated with this CA.
|
|
func (ca *CA) Store() *Store {
|
|
return ca.store
|
|
}
|