// 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 }