diff --git a/internal/agenttoken/agenttoken.go b/internal/agenttoken/agenttoken.go new file mode 100644 index 0000000..42ae710 --- /dev/null +++ b/internal/agenttoken/agenttoken.go @@ -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[:]) +} diff --git a/internal/agenttoken/agenttoken_test.go b/internal/agenttoken/agenttoken_test.go new file mode 100644 index 0000000..3819bb7 --- /dev/null +++ b/internal/agenttoken/agenttoken_test.go @@ -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")) +} diff --git a/internal/pin/pin.go b/internal/pin/pin.go new file mode 100644 index 0000000..b5f0bb0 --- /dev/null +++ b/internal/pin/pin.go @@ -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 + }, + } +} diff --git a/internal/pin/pin_test.go b/internal/pin/pin_test.go new file mode 100644 index 0000000..9280bdb --- /dev/null +++ b/internal/pin/pin_test.go @@ -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") +}