121 lines
3.7 KiB
Go
121 lines
3.7 KiB
Go
package transport
|
|
|
|
import (
|
|
"crypto/rsa"
|
|
"crypto/tls"
|
|
"crypto/x509"
|
|
"encoding/pem"
|
|
"errors"
|
|
"fmt"
|
|
|
|
"github.com/jasper/quptime/internal/trust"
|
|
)
|
|
|
|
// MinTLS is the minimum protocol version both sides require.
|
|
const MinTLS = tls.VersionTLS13
|
|
|
|
// TLSAssets bundles the on-disk material needed to spin up either a
|
|
// listener or a dialer. Build it once at daemon start and pass to
|
|
// ServerConfig / ClientConfig.
|
|
type TLSAssets struct {
|
|
Cert []byte // PEM-encoded leaf cert
|
|
Key *rsa.PrivateKey
|
|
Trust *trust.Store
|
|
}
|
|
|
|
// tlsCert wraps the local PEM cert + RSA key into a tls.Certificate.
|
|
func (a *TLSAssets) tlsCert() (tls.Certificate, error) {
|
|
block, _ := pem.Decode(a.Cert)
|
|
if block == nil {
|
|
return tls.Certificate{}, errors.New("cert PEM has no block")
|
|
}
|
|
leaf, err := x509.ParseCertificate(block.Bytes)
|
|
if err != nil {
|
|
return tls.Certificate{}, fmt.Errorf("parse leaf: %w", err)
|
|
}
|
|
return tls.Certificate{
|
|
Certificate: [][]byte{block.Bytes},
|
|
PrivateKey: a.Key,
|
|
Leaf: leaf,
|
|
}, nil
|
|
}
|
|
|
|
// ServerConfig produces a tls.Config suitable for an inter-node
|
|
// listener. Peers must present a certificate, and that certificate's
|
|
// fingerprint must already be present in the trust store.
|
|
func (a *TLSAssets) ServerConfig() (*tls.Config, error) {
|
|
cert, err := a.tlsCert()
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
return &tls.Config{
|
|
Certificates: []tls.Certificate{cert},
|
|
MinVersion: MinTLS,
|
|
ClientAuth: tls.RequireAnyClientCert,
|
|
InsecureSkipVerify: true, // we do our own pinning via VerifyPeerCertificate
|
|
VerifyPeerCertificate: a.Trust.VerifyPeerCert,
|
|
}, nil
|
|
}
|
|
|
|
// ClientConfig produces a tls.Config suitable for dialing a peer.
|
|
// expectedNodeID is optional: if non-empty, the handshake also
|
|
// verifies that the cert's fingerprint matches the trust entry for
|
|
// that node ID.
|
|
func (a *TLSAssets) ClientConfig(expectedNodeID string) (*tls.Config, error) {
|
|
cert, err := a.tlsCert()
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
verify := a.Trust.VerifyPeerCert
|
|
if expectedNodeID != "" {
|
|
verify = a.makeStrictVerifier(expectedNodeID)
|
|
}
|
|
return &tls.Config{
|
|
Certificates: []tls.Certificate{cert},
|
|
MinVersion: MinTLS,
|
|
InsecureSkipVerify: true, // we do our own pinning via VerifyPeerCertificate
|
|
VerifyPeerCertificate: verify,
|
|
}, nil
|
|
}
|
|
|
|
// InsecureBootstrapConfig is the client-side TLS config used only by
|
|
// the TOFU prefetch (FetchPeerCert). It accepts any peer cert because
|
|
// the caller has not yet established trust; the certificate is
|
|
// surfaced to the operator for manual approval before being added to
|
|
// the store. Never use this anywhere else.
|
|
func (a *TLSAssets) InsecureBootstrapConfig() (*tls.Config, error) {
|
|
cert, err := a.tlsCert()
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
return &tls.Config{
|
|
Certificates: []tls.Certificate{cert},
|
|
MinVersion: MinTLS,
|
|
InsecureSkipVerify: true,
|
|
}, nil
|
|
}
|
|
|
|
// makeStrictVerifier returns a VerifyPeerCertificate callback that
|
|
// pins the connection to the trust entry of a specific node ID.
|
|
func (a *TLSAssets) makeStrictVerifier(expectedNodeID string) func([][]byte, [][]*x509.Certificate) error {
|
|
return func(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)
|
|
}
|
|
entry, ok := a.Trust.Get(expectedNodeID)
|
|
if !ok {
|
|
return fmt.Errorf("no trust entry for node %s", expectedNodeID)
|
|
}
|
|
got := fingerprintOf(cert)
|
|
if got != entry.Fingerprint {
|
|
return fmt.Errorf("fingerprint mismatch for %s: got %s want %s",
|
|
expectedNodeID, got, entry.Fingerprint)
|
|
}
|
|
return nil
|
|
}
|
|
}
|