Files

126 lines
3.9 KiB
Go

// Package tlsauto makes a self-signed certificate for a node so hub↔node traffic is
// encrypted without any CA. The hub pins the certificate's fingerprint.
package tlsauto
import (
"crypto/ecdsa"
"crypto/elliptic"
"crypto/rand"
"crypto/sha256"
"crypto/tls"
"crypto/x509"
"crypto/x509/pkix"
"encoding/hex"
"encoding/pem"
"errors"
"math/big"
"net"
"os"
"path/filepath"
"strings"
"time"
)
// Fingerprint formats a certificate's SHA-256 the way the UI shows it.
func Fingerprint(der []byte) string {
sum := sha256.Sum256(der)
h := hex.EncodeToString(sum[:])
var b strings.Builder
for i := 0; i < len(h); i += 2 {
if i > 0 {
b.WriteByte(':')
}
b.WriteString(strings.ToUpper(h[i : i+2]))
}
return b.String()
}
// Ensure returns the cert/key paths and fingerprint, generating them on first use.
func Ensure(dir string) (certPath, keyPath, fingerprint string, err error) {
certPath, keyPath = filepath.Join(dir, "cert.pem"), filepath.Join(dir, "key.pem")
if _, err := os.Stat(certPath); err == nil {
if _, err := os.Stat(keyPath); err == nil {
c, err := tls.LoadX509KeyPair(certPath, keyPath)
if err == nil && len(c.Certificate) > 0 {
return certPath, keyPath, Fingerprint(c.Certificate[0]), nil
}
}
}
priv, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
if err != nil {
return "", "", "", err
}
serial, err := rand.Int(rand.Reader, new(big.Int).Lsh(big.NewInt(1), 127))
if err != nil {
return "", "", "", err
}
host, _ := os.Hostname()
tmpl := &x509.Certificate{
SerialNumber: serial,
Subject: pkix.Name{CommonName: "hamlab@" + host, Organization: []string{"Ham Lab"}},
NotBefore: time.Now().Add(-time.Hour),
NotAfter: time.Now().AddDate(10, 0, 0),
KeyUsage: x509.KeyUsageDigitalSignature | x509.KeyUsageKeyEncipherment,
ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth},
BasicConstraintsValid: true,
DNSNames: []string{host, "localhost"},
IPAddresses: []net.IP{net.ParseIP("127.0.0.1")},
}
if addrs, err := net.InterfaceAddrs(); err == nil {
for _, a := range addrs {
if ipn, ok := a.(*net.IPNet); ok && !ipn.IP.IsLoopback() && ipn.IP.To4() != nil {
tmpl.IPAddresses = append(tmpl.IPAddresses, ipn.IP)
}
}
}
der, err := x509.CreateCertificate(rand.Reader, tmpl, tmpl, &priv.PublicKey, priv)
if err != nil {
return "", "", "", err
}
keyDER, err := x509.MarshalECPrivateKey(priv)
if err != nil {
return "", "", "", err
}
if err := os.MkdirAll(dir, 0o700); err != nil {
return "", "", "", err
}
if err := os.WriteFile(certPath, pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: der}), 0o644); err != nil {
return "", "", "", err
}
if err := os.WriteFile(keyPath, pem.EncodeToMemory(&pem.Block{Type: "EC PRIVATE KEY", Bytes: keyDER}), 0o600); err != nil {
return "", "", "", err
}
return certPath, keyPath, Fingerprint(der), nil
}
// PinnedConfig returns a client TLS config that accepts exactly one certificate.
func PinnedConfig(fingerprint string) *tls.Config {
return &tls.Config{
InsecureSkipVerify: true, // we verify by fingerprint below, not by CA
VerifyPeerCertificate: func(raw [][]byte, _ [][]*x509.Certificate) error {
if len(raw) == 0 {
return errors.New("no certificate presented")
}
if Fingerprint(raw[0]) != fingerprint {
return errors.New("certificate fingerprint changed: " + Fingerprint(raw[0]))
}
return nil
},
}
}
// Peek connects and returns the fingerprint the server presents (trust on first use).
func Peek(addr string) (string, error) {
d := &net.Dialer{Timeout: 5 * time.Second}
conn, err := tls.DialWithDialer(d, "tcp", addr, &tls.Config{InsecureSkipVerify: true})
if err != nil {
return "", err
}
defer conn.Close()
certs := conn.ConnectionState().PeerCertificates
if len(certs) == 0 {
return "", errors.New("no certificate presented")
}
return Fingerprint(certs[0].Raw), nil
}