feat: pinned master key TLS and per-agent tokens

This commit is contained in:
2026-10-12 14:32:40 +02:00
parent b482c2e29c
commit 61e738e39d
4 changed files with 208 additions and 0 deletions
+31
View File
@@ -0,0 +1,31 @@
// Package agenttoken creates and hashes the per-agent tokens agents present
// to the master.
package agenttoken
import (
"crypto/rand"
"crypto/sha256"
"encoding/base64"
"encoding/hex"
"fmt"
)
const prefix = "pua_"
// Generate returns a new random agent token. Show it to the operator once;
// store only Hash(token).
func Generate() (string, error) {
var b [32]byte
if _, err := rand.Read(b[:]); err != nil {
return "", fmt.Errorf("generate agent token: %w", err)
}
return prefix + base64.RawURLEncoding.EncodeToString(b[:]), nil
}
// Hash returns the value stored for token. A plain SHA-256 is sufficient
// because tokens carry 256 bits of randomness; salting protects low-entropy
// passwords, which these are not.
func Hash(token string) string {
sum := sha256.Sum256([]byte(token))
return hex.EncodeToString(sum[:])
}
+25
View File
@@ -0,0 +1,25 @@
package agenttoken
import (
"regexp"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestGenerate(t *testing.T) {
a, err := Generate()
require.NoError(t, err)
b, err := Generate()
require.NoError(t, err)
assert.Regexp(t, regexp.MustCompile(`^pua_[A-Za-z0-9_-]{43}$`), a)
assert.NotEqual(t, a, b)
}
func TestHash(t *testing.T) {
assert.Equal(t, Hash("pua_x"), Hash("pua_x"))
assert.NotEqual(t, Hash("pua_x"), Hash("pua_y"))
assert.Regexp(t, regexp.MustCompile(`^[0-9a-f]{64}$`), Hash("pua_x"))
}
+91
View File
@@ -0,0 +1,91 @@
// Package pin lets agents trust the master without a certificate authority.
// The master serves a self-signed certificate for its own key pair; agents
// are configured with the fingerprint of that public key and accept the
// connection only if it matches. The TLS handshake proves the master holds
// the private key, the same way SSH host keys work.
package pin
import (
"crypto"
"crypto/ed25519"
"crypto/rand"
"crypto/sha256"
"crypto/subtle"
"crypto/tls"
"crypto/x509"
"crypto/x509/pkix"
"encoding/hex"
"errors"
"fmt"
"math/big"
"time"
)
// GenerateKey creates a new master key pair.
func GenerateKey() (ed25519.PrivateKey, error) {
_, priv, err := ed25519.GenerateKey(rand.Reader)
if err != nil {
return nil, fmt.Errorf("generate key: %w", err)
}
return priv, nil
}
// Certificate returns a self-signed TLS certificate for key. Its validity is
// effectively unlimited because trust comes from the pinned fingerprint, not
// from the certificate's dates or issuer.
func Certificate(key ed25519.PrivateKey) (tls.Certificate, error) {
tmpl := &x509.Certificate{
SerialNumber: big.NewInt(1),
Subject: pkix.Name{CommonName: "package-updater-master"},
NotBefore: time.Now().Add(-time.Hour),
NotAfter: time.Now().AddDate(100, 0, 0),
KeyUsage: x509.KeyUsageDigitalSignature,
ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth},
}
der, err := x509.CreateCertificate(rand.Reader, tmpl, tmpl, key.Public(), key)
if err != nil {
return tls.Certificate{}, fmt.Errorf("create certificate: %w", err)
}
return tls.Certificate{Certificate: [][]byte{der}, PrivateKey: key}, nil
}
// Fingerprint returns "sha256:" + hex SHA-256 of pub's PKIX DER encoding.
// This is the MASTER_KEY value agents are configured with.
func Fingerprint(pub crypto.PublicKey) (string, error) {
der, err := x509.MarshalPKIXPublicKey(pub)
if err != nil {
return "", fmt.Errorf("marshal public key: %w", err)
}
sum := sha256.Sum256(der)
return "sha256:" + hex.EncodeToString(sum[:]), nil
}
// ClientConfig returns a TLS config that accepts only a server whose
// certificate carries the public key with the given fingerprint.
func ClientConfig(fingerprint string) *tls.Config {
return &tls.Config{
MinVersion: tls.VersionTLS13,
// Chain and hostname verification are replaced by the fingerprint
// check below: the master's certificate is self-signed by design.
// The handshake still verifies the server's signature with the
// certificate's key, so presenting a copied certificate fails.
InsecureSkipVerify: true, //nolint:gosec // see comment above
VerifyPeerCertificate: func(raw [][]byte, _ [][]*x509.Certificate) error {
if len(raw) == 0 {
return errors.New("pin: server sent no certificate")
}
cert, err := x509.ParseCertificate(raw[0])
if err != nil {
return fmt.Errorf("pin: parse certificate: %w", err)
}
got, err := Fingerprint(cert.PublicKey)
if err != nil {
return err
}
if subtle.ConstantTimeCompare([]byte(got), []byte(fingerprint)) != 1 {
return fmt.Errorf("pin: fingerprint mismatch: server presented %s", got)
}
return nil
},
}
}
+61
View File
@@ -0,0 +1,61 @@
package pin
import (
"crypto/tls"
"net/http"
"net/http/httptest"
"regexp"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func newPinnedServer(t *testing.T) (*httptest.Server, string) {
t.Helper()
key, err := GenerateKey()
require.NoError(t, err)
cert, err := Certificate(key)
require.NoError(t, err)
fp, err := Fingerprint(key.Public())
require.NoError(t, err)
srv := httptest.NewUnstartedServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
_, _ = w.Write([]byte("ok"))
}))
srv.TLS = &tls.Config{Certificates: []tls.Certificate{cert}}
srv.StartTLS()
t.Cleanup(srv.Close)
return srv, fp
}
func clientFor(fp string) *http.Client {
return &http.Client{Transport: &http.Transport{TLSClientConfig: ClientConfig(fp)}}
}
func TestFingerprintFormat(t *testing.T) {
key, err := GenerateKey()
require.NoError(t, err)
fp, err := Fingerprint(key.Public())
require.NoError(t, err)
assert.Regexp(t, regexp.MustCompile(`^sha256:[0-9a-f]{64}$`), fp)
}
func TestClientConfigAcceptsPinnedKey(t *testing.T) {
srv, fp := newPinnedServer(t)
resp, err := clientFor(fp).Get(srv.URL)
require.NoError(t, err)
_ = resp.Body.Close()
assert.Equal(t, http.StatusOK, resp.StatusCode)
}
func TestClientConfigRejectsOtherKey(t *testing.T) {
srv, _ := newPinnedServer(t)
other, err := GenerateKey()
require.NoError(t, err)
otherFP, err := Fingerprint(other.Public())
require.NoError(t, err)
_, err = clientFor(otherFP).Get(srv.URL)
assert.ErrorContains(t, err, "fingerprint mismatch")
}