feat: pinned master key TLS and per-agent tokens
This commit is contained in:
@@ -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[:])
|
||||||
|
}
|
||||||
@@ -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"))
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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")
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user