160 lines
4.1 KiB
Go
160 lines
4.1 KiB
Go
// Package trust is the local fingerprint trust store. Every inbound
|
|
// or outbound mTLS connection must present a peer cert whose
|
|
// fingerprint is recorded here, otherwise it is refused.
|
|
//
|
|
// The trust store is NOT replicated automatically by the cluster
|
|
// config: each node's operator confirms each new peer's fingerprint
|
|
// out of band (the "trust on first use" prompt). After confirmation,
|
|
// the cluster replication layer is free to propagate the peer's
|
|
// public material to other nodes — but it cannot grant trust on
|
|
// behalf of a node that has not yet trusted it.
|
|
package trust
|
|
|
|
import (
|
|
"crypto/x509"
|
|
"errors"
|
|
"fmt"
|
|
"os"
|
|
"sync"
|
|
"time"
|
|
|
|
"gopkg.in/yaml.v3"
|
|
|
|
"github.com/jasper/quptime/internal/config"
|
|
"github.com/jasper/quptime/internal/crypto"
|
|
)
|
|
|
|
// Entry is one trusted peer.
|
|
type Entry struct {
|
|
NodeID string `yaml:"node_id"`
|
|
Address string `yaml:"address"`
|
|
Fingerprint string `yaml:"fingerprint"`
|
|
PublicKeyPEM string `yaml:"public_key_pem"`
|
|
CertPEM string `yaml:"cert_pem,omitempty"`
|
|
AddedAt time.Time `yaml:"added_at"`
|
|
}
|
|
|
|
// Store is the persistent trust list with an in-memory cache.
|
|
type Store struct {
|
|
mu sync.RWMutex
|
|
entries map[string]Entry // keyed by NodeID
|
|
}
|
|
|
|
// Load reads trust.yaml. A missing file yields an empty store.
|
|
func Load() (*Store, error) {
|
|
s := &Store{entries: map[string]Entry{}}
|
|
raw, err := os.ReadFile(config.TrustFilePath())
|
|
if err != nil {
|
|
if os.IsNotExist(err) {
|
|
return s, nil
|
|
}
|
|
return nil, err
|
|
}
|
|
wrap := struct {
|
|
Entries []Entry `yaml:"entries"`
|
|
}{}
|
|
if err := yaml.Unmarshal(raw, &wrap); err != nil {
|
|
return nil, fmt.Errorf("parse trust.yaml: %w", err)
|
|
}
|
|
for _, e := range wrap.Entries {
|
|
s.entries[e.NodeID] = e
|
|
}
|
|
return s, nil
|
|
}
|
|
|
|
// Save writes trust.yaml atomically.
|
|
func (s *Store) Save() error {
|
|
s.mu.RLock()
|
|
defer s.mu.RUnlock()
|
|
list := make([]Entry, 0, len(s.entries))
|
|
for _, e := range s.entries {
|
|
list = append(list, e)
|
|
}
|
|
out, err := yaml.Marshal(struct {
|
|
Entries []Entry `yaml:"entries"`
|
|
}{Entries: list})
|
|
if err != nil {
|
|
return err
|
|
}
|
|
return config.AtomicWrite(config.TrustFilePath(), out, 0o600)
|
|
}
|
|
|
|
// Add inserts or replaces a trust entry by NodeID.
|
|
func (s *Store) Add(e Entry) error {
|
|
if e.NodeID == "" || e.Fingerprint == "" {
|
|
return errors.New("trust entry needs node_id and fingerprint")
|
|
}
|
|
if e.AddedAt.IsZero() {
|
|
e.AddedAt = time.Now().UTC()
|
|
}
|
|
s.mu.Lock()
|
|
s.entries[e.NodeID] = e
|
|
s.mu.Unlock()
|
|
return s.Save()
|
|
}
|
|
|
|
// Remove drops the entry for nodeID. Returns true if anything was
|
|
// actually removed.
|
|
func (s *Store) Remove(nodeID string) (bool, error) {
|
|
s.mu.Lock()
|
|
_, ok := s.entries[nodeID]
|
|
if ok {
|
|
delete(s.entries, nodeID)
|
|
}
|
|
s.mu.Unlock()
|
|
if !ok {
|
|
return false, nil
|
|
}
|
|
return true, s.Save()
|
|
}
|
|
|
|
// List returns a copy of all entries.
|
|
func (s *Store) List() []Entry {
|
|
s.mu.RLock()
|
|
defer s.mu.RUnlock()
|
|
out := make([]Entry, 0, len(s.entries))
|
|
for _, e := range s.entries {
|
|
out = append(out, e)
|
|
}
|
|
return out
|
|
}
|
|
|
|
// Get returns the entry for the given NodeID and whether it was found.
|
|
func (s *Store) Get(nodeID string) (Entry, bool) {
|
|
s.mu.RLock()
|
|
defer s.mu.RUnlock()
|
|
e, ok := s.entries[nodeID]
|
|
return e, ok
|
|
}
|
|
|
|
// LookupByFingerprint finds the entry matching the given fingerprint
|
|
// regardless of NodeID. Useful when validating incoming TLS handshakes
|
|
// where the cert's CommonName carries the NodeID claim.
|
|
func (s *Store) LookupByFingerprint(fp string) (Entry, bool) {
|
|
s.mu.RLock()
|
|
defer s.mu.RUnlock()
|
|
for _, e := range s.entries {
|
|
if e.Fingerprint == fp {
|
|
return e, true
|
|
}
|
|
}
|
|
return Entry{}, false
|
|
}
|
|
|
|
// VerifyPeerCert is the tls.Config VerifyPeerCertificate callback. It
|
|
// rejects any cert whose fingerprint isn't in the trust store.
|
|
func (s *Store) VerifyPeerCert(rawCerts [][]byte, _ [][]*x509.Certificate) error {
|
|
if len(rawCerts) == 0 {
|
|
return errors.New("peer presented no certificate")
|
|
}
|
|
cert, err := x509.ParseCertificate(rawCerts[0])
|
|
if err != nil {
|
|
return fmt.Errorf("parse peer cert: %w", err)
|
|
}
|
|
fp := crypto.Fingerprint(cert)
|
|
if _, ok := s.LookupByFingerprint(fp); !ok {
|
|
return fmt.Errorf("peer cert fingerprint %s not in trust store", fp)
|
|
}
|
|
return nil
|
|
}
|