feat: master/agent phase 1 — live inventory (#6) #19
@@ -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