You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
 
 
 
 
 
 

637 lines
20 KiB

// Package tlscert owns the protected HTTPS certificate library. Public callers
// receive metadata or CSR material only; private keys leave this package only
// when an active key pair is written to the local HTTPS runtime paths.
package tlscert
import (
"crypto"
"crypto/rand"
"crypto/rsa"
"crypto/sha256"
"crypto/tls"
"crypto/x509"
"crypto/x509/pkix"
"encoding/hex"
"encoding/pem"
"errors"
"fmt"
"math/big"
"net"
"os"
"path/filepath"
"sort"
"strings"
"sync"
"time"
"tera-clone/services/tera-api/internal/storage/atomicfile"
)
const (
FactoryCertificateID = "factory-default"
MaximumCertificates = 16
maximumPEMBytes = 1 << 20
)
type Subject struct {
Country string
Province string
Locality string
Organization string
OrganizationalUnit string
CommonName string
Email string
}
type Metadata struct {
CertificateID string
Subject string
Issuer string
CommonName string
NotBefore time.Time
NotAfter time.Time
FingerprintSHA256 string
Source string
PrivateKeyStored bool
KeyBits int
}
type ImportRequest struct {
CertificateID string
CertificatePEM []byte
PrivateKeyPEM []byte
Password string
}
type Options struct {
Root string
ActiveCert string
ActiveKey string
Clock func() time.Time
}
type record struct {
metadata Metadata
certPEM []byte
keyPEM []byte
}
type Repository struct {
mu sync.Mutex
root string
activeCert string
activeKey string
clock func() time.Time
records map[string]record
}
var (
memoryFactoryOnce sync.Once
memoryFactory record
memoryFactoryErr error
)
func Open(options Options) (*Repository, error) {
clock := options.Clock
if clock == nil {
clock = time.Now
}
repository := &Repository{
root: strings.TrimSpace(options.Root), activeCert: strings.TrimSpace(options.ActiveCert),
activeKey: strings.TrimSpace(options.ActiveKey), clock: clock, records: make(map[string]record),
}
if repository.root != "" {
if !filepath.IsAbs(repository.root) {
return nil, errors.New("TLS certificate store root must be an absolute path")
}
if err := os.MkdirAll(repository.root, 0o700); err != nil {
return nil, fmt.Errorf("create TLS certificate store: %w", err)
}
if err := repository.load(); err != nil {
return nil, err
}
}
if _, ok := repository.records[FactoryCertificateID]; !ok {
var generated record
var err error
if repository.root == "" {
memoryFactoryOnce.Do(func() {
memoryFactory, memoryFactoryErr = generateRecord(FactoryCertificateID, Subject{Organization: "Teraclone Co.,Ltd", CommonName: "tera-management.local"}, "factory", time.Date(2026, 1, 1, 0, 0, 0, 0, time.UTC))
})
generated, err = cloneRecord(memoryFactory), memoryFactoryErr
} else {
generated, err = generateRecord(FactoryCertificateID, Subject{Organization: "Teraclone Co.,Ltd", CommonName: "tera-management.local"}, "factory", clock())
}
if err != nil {
return nil, err
}
repository.records[FactoryCertificateID] = generated
if err := repository.persist(generated); err != nil {
return nil, err
}
}
return repository, nil
}
func cloneRecord(source record) record {
source.certPEM = append([]byte(nil), source.certPEM...)
source.keyPEM = append([]byte(nil), source.keyPEM...)
return source
}
func (r *Repository) List(activeID string) []Metadata {
r.mu.Lock()
defer r.mu.Unlock()
result := make([]Metadata, 0, len(r.records))
for _, item := range r.records {
result = append(result, item.metadata)
}
sort.Slice(result, func(i, j int) bool { return result[i].CertificateID < result[j].CertificateID })
return result
}
func (r *Repository) Metadata(certificateID string) (Metadata, error) {
id, err := normalizeID(certificateID)
if err != nil {
return Metadata{}, err
}
r.mu.Lock()
defer r.mu.Unlock()
item, ok := r.records[id]
if !ok {
return Metadata{}, errors.New("TLS certificate does not exist")
}
return item.metadata, nil
}
func (r *Repository) Generate(certificateID string, subject Subject) (Metadata, error) {
id, err := normalizeID(certificateID)
if err != nil {
return Metadata{}, err
}
if err := validateSubject(subject); err != nil {
return Metadata{}, err
}
r.mu.Lock()
defer r.mu.Unlock()
if len(r.records) >= MaximumCertificates {
return Metadata{}, fmt.Errorf("TLS certificate key limit of %d reached", MaximumCertificates)
}
if _, exists := r.records[id]; exists {
return Metadata{}, errors.New("TLS certificate already exists")
}
item, err := generateRecord(id, subject, "generated", r.clock())
if err != nil {
return Metadata{}, err
}
if err := r.persist(item); err != nil {
return Metadata{}, err
}
r.records[id] = item
return item.metadata, nil
}
func (r *Repository) Import(request ImportRequest) (Metadata, error) {
id, err := normalizeID(request.CertificateID)
if err != nil {
return Metadata{}, err
}
if len(request.CertificatePEM) == 0 || len(request.CertificatePEM)+len(request.PrivateKeyPEM) > maximumPEMBytes {
return Metadata{}, errors.New("TLS certificate import must contain at most 1 MiB of PEM data")
}
keySource := request.PrivateKeyPEM
if len(keySource) == 0 {
keySource = request.CertificatePEM
}
certPEM, certificate, err := normalizeCertificates(request.CertificatePEM)
if err != nil {
return Metadata{}, err
}
keyPEM, privateKey, err := normalizePrivateKey(keySource, request.Password)
if err != nil {
return Metadata{}, err
}
if _, err := tls.X509KeyPair(certPEM, keyPEM); err != nil {
return Metadata{}, fmt.Errorf("TLS certificate private key does not match certificate: %w", err)
}
item := record{metadata: metadataFromCertificate(id, certificate, privateKey, "imported"), certPEM: certPEM, keyPEM: keyPEM}
r.mu.Lock()
defer r.mu.Unlock()
if len(r.records) >= MaximumCertificates {
return Metadata{}, fmt.Errorf("TLS certificate key limit of %d reached", MaximumCertificates)
}
if _, exists := r.records[id]; exists {
return Metadata{}, errors.New("TLS certificate already exists")
}
if err := r.persist(item); err != nil {
return Metadata{}, err
}
r.records[id] = item
return item.metadata, nil
}
func (r *Repository) CSR(certificateID string, subject Subject) ([]byte, error) {
id, err := normalizeID(certificateID)
if err != nil {
return nil, err
}
r.mu.Lock()
item, ok := r.records[id]
r.mu.Unlock()
if !ok {
return nil, errors.New("TLS certificate private key does not exist")
}
if strings.TrimSpace(subject.CommonName) == "" {
subject.CommonName = item.metadata.CommonName
}
if err := validateSubject(subject); err != nil {
return nil, err
}
privateKey, err := parsePrivateKeyDER(item.keyPEM)
if err != nil {
return nil, err
}
request := x509.CertificateRequest{Subject: subjectName(subject), EmailAddresses: nonEmpty(subject.Email)}
if ip := net.ParseIP(subject.CommonName); ip != nil {
request.IPAddresses = []net.IP{ip}
} else {
request.DNSNames = []string{subject.CommonName}
}
der, err := x509.CreateCertificateRequest(rand.Reader, &request, privateKey)
if err != nil {
return nil, fmt.Errorf("create TLS certificate signing request: %w", err)
}
return pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE REQUEST", Bytes: der}), nil
}
func (r *Repository) Activate(certificateID string) (Metadata, error) {
id, err := normalizeID(certificateID)
if err != nil {
return Metadata{}, err
}
r.mu.Lock()
defer r.mu.Unlock()
item, ok := r.records[id]
if !ok {
return Metadata{}, errors.New("TLS certificate does not exist")
}
now := r.clock().UTC()
// A factory certificate created before the device acquired a clock must
// be reissued after bootstrap. Keep its key and subject; never replace a
// user certificate or a normally dated expired factory certificate.
if id == FactoryCertificateID && item.metadata.Source == "factory" && item.metadata.NotBefore.Year() < 2000 && now.Year() >= 2020 {
repaired, err := reissueBootstrapFactory(item, now)
if err != nil {
return Metadata{}, err
}
if err := r.persist(repaired); err != nil {
return Metadata{}, err
}
r.records[id] = repaired
item = repaired
}
if now.Before(item.metadata.NotBefore) || !now.Before(item.metadata.NotAfter) {
return Metadata{}, errors.New("TLS certificate is not currently valid")
}
if r.activeCert != "" || r.activeKey != "" {
if !filepath.IsAbs(r.activeCert) || !filepath.IsAbs(r.activeKey) {
return Metadata{}, errors.New("TLS active certificate and key paths must be absolute")
}
if err := os.MkdirAll(filepath.Dir(r.activeKey), 0o700); err != nil {
return Metadata{}, err
}
if err := atomicfile.Write(r.activeKey, item.keyPEM, 0o600); err != nil {
return Metadata{}, fmt.Errorf("write active TLS private key: %w", err)
}
if err := atomicfile.Write(r.activeCert, item.certPEM, 0o644); err != nil {
return Metadata{}, fmt.Errorf("write active TLS certificate: %w", err)
}
}
return item.metadata, nil
}
func (r *Repository) Delete(certificateID string) error {
id, err := normalizeID(certificateID)
if err != nil {
return err
}
if id == FactoryCertificateID {
return errors.New("factory TLS certificate cannot be deleted")
}
r.mu.Lock()
defer r.mu.Unlock()
if _, ok := r.records[id]; !ok {
return errors.New("TLS certificate does not exist")
}
if r.root != "" {
for _, extension := range []string{".crt", ".key"} {
if err := os.Remove(filepath.Join(r.root, id+extension)); err != nil && !os.IsNotExist(err) {
return fmt.Errorf("delete TLS certificate material: %w", err)
}
}
}
delete(r.records, id)
return nil
}
func (r *Repository) load() error {
entries, err := os.ReadDir(r.root)
if err != nil {
return fmt.Errorf("read TLS certificate store: %w", err)
}
for _, entry := range entries {
if entry.IsDir() || filepath.Ext(entry.Name()) != ".crt" {
continue
}
id := strings.TrimSuffix(entry.Name(), ".crt")
if _, err := normalizeID(id); err != nil {
return fmt.Errorf("unsafe TLS certificate store entry %q", entry.Name())
}
certPEM, err := os.ReadFile(filepath.Join(r.root, id+".crt"))
if err != nil {
return err
}
keyPEM, err := os.ReadFile(filepath.Join(r.root, id+".key"))
if err != nil {
return fmt.Errorf("read TLS private key %s: %w", id, err)
}
normalizedCert, certificate, err := normalizeCertificates(certPEM)
if err != nil {
return fmt.Errorf("load TLS certificate %s: %w", id, err)
}
normalizedKey, privateKey, err := normalizePrivateKey(keyPEM, "")
if err != nil {
return fmt.Errorf("load TLS private key %s: %w", id, err)
}
if _, err := tls.X509KeyPair(normalizedCert, normalizedKey); err != nil {
return fmt.Errorf("load TLS key pair %s: %w", id, err)
}
source := "protected-store"
if id == FactoryCertificateID {
source = "factory"
}
r.records[id] = record{metadata: metadataFromCertificate(id, certificate, privateKey, source), certPEM: normalizedCert, keyPEM: normalizedKey}
}
return nil
}
func (r *Repository) persist(item record) error {
if r.root == "" {
return nil
}
if err := atomicfile.Write(filepath.Join(r.root, item.metadata.CertificateID+".key"), item.keyPEM, 0o600); err != nil {
return fmt.Errorf("persist TLS private key: %w", err)
}
if err := atomicfile.Write(filepath.Join(r.root, item.metadata.CertificateID+".crt"), item.certPEM, 0o644); err != nil {
return fmt.Errorf("persist TLS certificate: %w", err)
}
return nil
}
func generateRecord(id string, subject Subject, source string, now time.Time) (record, error) {
privateKey, err := rsa.GenerateKey(rand.Reader, 2048)
if err != nil {
return record{}, fmt.Errorf("generate RSA-2048 TLS private key: %w", err)
}
serial, err := rand.Int(rand.Reader, new(big.Int).Lsh(big.NewInt(1), 128))
if err != nil {
return record{}, err
}
template := &x509.Certificate{
SerialNumber: serial, Subject: subjectName(subject), Issuer: subjectName(subject),
NotBefore: now.UTC().Add(-5 * time.Minute), NotAfter: now.UTC().AddDate(10, 0, 0),
KeyUsage: x509.KeyUsageDigitalSignature | x509.KeyUsageKeyEncipherment,
ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth}, BasicConstraintsValid: true,
EmailAddresses: nonEmpty(subject.Email),
}
if ip := net.ParseIP(subject.CommonName); ip != nil {
template.IPAddresses = []net.IP{ip}
} else if subject.CommonName != "" {
template.DNSNames = []string{subject.CommonName}
}
der, err := x509.CreateCertificate(rand.Reader, template, template, &privateKey.PublicKey, privateKey)
if err != nil {
return record{}, err
}
keyDER, err := x509.MarshalPKCS8PrivateKey(privateKey)
if err != nil {
return record{}, err
}
certificate, err := x509.ParseCertificate(der)
if err != nil {
return record{}, err
}
return record{
metadata: metadataFromCertificate(id, certificate, privateKey, source),
certPEM: pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: der}),
keyPEM: pem.EncodeToMemory(&pem.Block{Type: "PRIVATE KEY", Bytes: keyDER}),
}, nil
}
func normalizeCertificates(payload []byte) ([]byte, *x509.Certificate, error) {
remainder := payload
var output []byte
var leaf *x509.Certificate
for {
block, rest := pem.Decode(remainder)
if block == nil {
break
}
remainder = rest
if block.Type != "CERTIFICATE" {
continue
}
certificate, err := x509.ParseCertificate(block.Bytes)
if err != nil {
return nil, nil, errors.New("TLS certificate contains invalid PEM certificate data")
}
if leaf == nil {
leaf = certificate
}
output = append(output, pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: block.Bytes})...)
}
if leaf == nil {
return nil, nil, errors.New("TLS certificate import requires PEM certificate data")
}
return output, leaf, nil
}
func normalizePrivateKey(payload []byte, password string) ([]byte, crypto.PrivateKey, error) {
remainder := payload
for {
block, rest := pem.Decode(remainder)
if block == nil {
break
}
remainder = rest
if !strings.Contains(block.Type, "PRIVATE KEY") {
continue
}
der := block.Bytes
if x509.IsEncryptedPEMBlock(block) {
if password == "" {
return nil, nil, errors.New("TLS private key password is required")
}
var err error
der, err = x509.DecryptPEMBlock(block, []byte(password))
if err != nil {
return nil, nil, errors.New("TLS private key password is incorrect")
}
}
privateKey, err := parsePrivateKeyBytes(der)
if err != nil {
return nil, nil, errors.New("TLS private key uses an unsupported PEM format")
}
normalizedDER, err := x509.MarshalPKCS8PrivateKey(privateKey)
if err != nil {
return nil, nil, errors.New("TLS private key uses an unsupported key type")
}
return pem.EncodeToMemory(&pem.Block{Type: "PRIVATE KEY", Bytes: normalizedDER}), privateKey, nil
}
return nil, nil, errors.New("TLS certificate import requires a PEM private key")
}
func parsePrivateKeyDER(payload []byte) (crypto.PrivateKey, error) {
block, _ := pem.Decode(payload)
if block == nil {
return nil, errors.New("invalid stored TLS private key")
}
return parsePrivateKeyBytes(block.Bytes)
}
func parsePrivateKeyBytes(der []byte) (crypto.PrivateKey, error) {
if key, err := x509.ParsePKCS8PrivateKey(der); err == nil {
return key, nil
}
if key, err := x509.ParsePKCS1PrivateKey(der); err == nil {
return key, nil
}
if key, err := x509.ParseECPrivateKey(der); err == nil {
return key, nil
}
return nil, errors.New("unsupported private key")
}
func metadataFromCertificate(id string, certificate *x509.Certificate, privateKey crypto.PrivateKey, source string) Metadata {
fingerprint := sha256.Sum256(certificate.Raw)
bits := 0
if key, ok := privateKey.(*rsa.PrivateKey); ok {
bits = key.N.BitLen()
}
return Metadata{
CertificateID: id, Subject: certificate.Subject.String(), Issuer: certificate.Issuer.String(),
CommonName: certificate.Subject.CommonName, NotBefore: certificate.NotBefore.UTC(), NotAfter: certificate.NotAfter.UTC(),
FingerprintSHA256: hex.EncodeToString(fingerprint[:]), Source: source, PrivateKeyStored: true, KeyBits: bits,
}
}
func subjectName(subject Subject) pkix.Name {
return pkix.Name{
Country: nonEmpty(subject.Country), Province: nonEmpty(subject.Province), Locality: nonEmpty(subject.Locality),
Organization: nonEmpty(subject.Organization), OrganizationalUnit: nonEmpty(subject.OrganizationalUnit), CommonName: strings.TrimSpace(subject.CommonName),
}
}
func validateSubject(subject Subject) error {
values := []string{subject.Country, subject.Province, subject.Locality, subject.Organization, subject.OrganizationalUnit, subject.CommonName, subject.Email}
for _, value := range values {
if len(value) > 128 || strings.ContainsAny(value, "\r\n\x00") {
return errors.New("TLS certificate subject fields must contain at most 128 non-control characters")
}
}
if strings.TrimSpace(subject.CommonName) == "" {
return errors.New("TLS certificate common name must not be empty")
}
return nil
}
func normalizeID(value string) (string, error) {
id := strings.ToLower(strings.TrimSpace(value))
if id == "" || len(id) > 64 {
return "", errors.New("TLS certificate file name must contain 1 through 64 characters")
}
for _, char := range id {
if char >= 'a' && char <= 'z' || char >= '0' && char <= '9' || char == '.' || char == '_' || char == '-' {
continue
}
return "", errors.New("TLS certificate file name may contain only letters, digits, dot, underscore, or hyphen")
}
return id, nil
}
func nonEmpty(value string) []string {
value = strings.TrimSpace(value)
if value == "" {
return nil
}
return []string{value}
}
// Reset deletes customer key material and generates a fresh factory identity.
func (r *Repository) Reset() (Metadata, error) {
fresh, err := generateRecord(FactoryCertificateID, Subject{Organization: "Teraclone Co.,Ltd", CommonName: "tera-management.local"}, "factory", r.clock())
if err != nil {
return Metadata{}, err
}
r.mu.Lock()
defer r.mu.Unlock()
if r.root != "" {
entries, err := os.ReadDir(r.root)
if err != nil {
return Metadata{}, err
}
for _, entry := range entries {
if entry.IsDir() {
return Metadata{}, errors.New("unexpected directory in TLS store")
}
if err := os.Remove(filepath.Join(r.root, entry.Name())); err != nil {
return Metadata{}, err
}
}
}
if err := r.persist(fresh); err != nil {
return Metadata{}, err
}
r.records = map[string]record{FactoryCertificateID: fresh}
for path, content := range map[string][]byte{r.activeKey: fresh.keyPEM, r.activeCert: fresh.certPEM} {
if path == "" {
continue
}
if err := atomicfile.Write(path, content, 0o600); err != nil {
return Metadata{}, err
}
}
return fresh.metadata, nil
}
func reissueBootstrapFactory(item record, now time.Time) (record, error) {
pair, err := tls.X509KeyPair(item.certPEM, item.keyPEM)
if err != nil {
return record{}, err
}
certificate, err := x509.ParseCertificate(pair.Certificate[0])
if err != nil {
return record{}, err
}
signer, ok := pair.PrivateKey.(crypto.Signer)
if !ok {
return record{}, errors.New("factory certificate signer unavailable")
}
certificate.SerialNumber, err = rand.Int(rand.Reader, new(big.Int).Lsh(big.NewInt(1), 128))
if err != nil {
return record{}, err
}
certificate.NotBefore = now.Add(-5 * time.Minute)
certificate.NotAfter = now.AddDate(10, 0, 0)
der, err := x509.CreateCertificate(rand.Reader, certificate, certificate, signer.Public(), signer)
if err != nil {
return record{}, err
}
certificate, err = x509.ParseCertificate(der)
if err != nil {
return record{}, err
}
item.certPEM = pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: der})
item.metadata = metadataFromCertificate(FactoryCertificateID, certificate, pair.PrivateKey, "factory")
return item, nil
}