This commit is contained in:
@@ -0,0 +1,206 @@
|
||||
package gatewaytls
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"crypto/sha256"
|
||||
"crypto/tls"
|
||||
"crypto/x509"
|
||||
"encoding/hex"
|
||||
"encoding/pem"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net"
|
||||
"net/url"
|
||||
"os"
|
||||
"slices"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
type Material struct {
|
||||
ServerTLS *tls.Config
|
||||
ControlTLS *tls.Config
|
||||
CertificateIdentity string
|
||||
}
|
||||
|
||||
func Load(certFile, keyFile, streamingCAFile, controlCAFile, gatewayID, publicIdentity string) (Material, error) {
|
||||
certificate, err := tls.LoadX509KeyPair(certFile, keyFile)
|
||||
if err != nil {
|
||||
return Material{}, fmt.Errorf("load gateway certificate: %w", err)
|
||||
}
|
||||
if len(certificate.Certificate) == 0 {
|
||||
return Material{}, errors.New("gateway certificate chain is empty")
|
||||
}
|
||||
leaf, err := x509.ParseCertificate(certificate.Certificate[0])
|
||||
if err != nil {
|
||||
return Material{}, errors.New("parse gateway leaf certificate")
|
||||
}
|
||||
certificate.Leaf = leaf
|
||||
if now := time.Now(); now.Before(leaf.NotBefore) || now.After(leaf.NotAfter) {
|
||||
return Material{}, errors.New("gateway leaf certificate is not currently valid")
|
||||
}
|
||||
if !hasUsage(leaf, x509.ExtKeyUsageServerAuth) || !hasUsage(leaf, x509.ExtKeyUsageClientAuth) {
|
||||
return Material{}, errors.New("gateway leaf certificate requires ServerAuth and ClientAuth")
|
||||
}
|
||||
if !gatewayURIAllowed(leaf.URIs, gatewayID) {
|
||||
return Material{}, errors.New("gateway leaf certificate URI identity mismatch")
|
||||
}
|
||||
if !validPublicDNSName(publicIdentity) || !slices.Contains(leaf.DNSNames, publicIdentity) || leaf.VerifyHostname(publicIdentity) != nil {
|
||||
return Material{}, errors.New("gateway public identity must match a DNS SAN")
|
||||
}
|
||||
streamingCAs, err := loadCAPool(streamingCAFile)
|
||||
if err != nil {
|
||||
return Material{}, fmt.Errorf("load streaming CA: %w", err)
|
||||
}
|
||||
controlRoots, err := loadControlRootPool(controlCAFile)
|
||||
if err != nil {
|
||||
return Material{}, fmt.Errorf("load control root: %w", err)
|
||||
}
|
||||
digest := sha256.Sum256(leaf.Raw)
|
||||
return Material{
|
||||
ServerTLS: &tls.Config{
|
||||
MinVersion: tls.VersionTLS13, Certificates: []tls.Certificate{certificate},
|
||||
ClientAuth: tls.RequireAndVerifyClientCert, ClientCAs: streamingCAs,
|
||||
},
|
||||
ControlTLS: &tls.Config{
|
||||
MinVersion: tls.VersionTLS13, Certificates: []tls.Certificate{certificate}, RootCAs: controlRoots,
|
||||
},
|
||||
CertificateIdentity: "sha256:" + hex.EncodeToString(digest[:]),
|
||||
}, nil
|
||||
}
|
||||
|
||||
func loadCAPool(path string) (*x509.CertPool, error) {
|
||||
encoded, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
pool := x509.NewCertPool()
|
||||
count := 0
|
||||
for len(bytes.TrimSpace(encoded)) > 0 {
|
||||
block, rest := pem.Decode(encoded)
|
||||
if block == nil {
|
||||
return nil, errors.New("CA PEM contains invalid data")
|
||||
}
|
||||
encoded = rest
|
||||
if block.Type != "CERTIFICATE" {
|
||||
return nil, errors.New("CA PEM contains a non-certificate block")
|
||||
}
|
||||
certificate, parseErr := x509.ParseCertificate(block.Bytes)
|
||||
if parseErr != nil || !certificate.IsCA || certificate.KeyUsage&x509.KeyUsageCertSign == 0 {
|
||||
return nil, errors.New("CA PEM contains a non-CA certificate")
|
||||
}
|
||||
pool.AddCert(certificate)
|
||||
count++
|
||||
}
|
||||
if count == 0 {
|
||||
return nil, errors.New("CA PEM contains no certificate")
|
||||
}
|
||||
return pool, nil
|
||||
}
|
||||
|
||||
func loadControlRootPool(path string) (*x509.CertPool, error) {
|
||||
encoded, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var certificates []*x509.Certificate
|
||||
for len(bytes.TrimSpace(encoded)) > 0 {
|
||||
block, rest := pem.Decode(encoded)
|
||||
if block == nil {
|
||||
return nil, errors.New("control trust PEM contains invalid data")
|
||||
}
|
||||
encoded = rest
|
||||
if block.Type != "CERTIFICATE" {
|
||||
return nil, errors.New("control trust PEM contains a non-certificate block")
|
||||
}
|
||||
certificate, parseErr := x509.ParseCertificate(block.Bytes)
|
||||
if parseErr != nil {
|
||||
return nil, errors.New("control trust PEM contains an invalid certificate")
|
||||
}
|
||||
certificates = append(certificates, certificate)
|
||||
}
|
||||
if len(certificates) == 0 {
|
||||
return nil, errors.New("control trust PEM contains no certificate")
|
||||
}
|
||||
|
||||
pool := x509.NewCertPool()
|
||||
allCAs := true
|
||||
for _, certificate := range certificates {
|
||||
if !certificate.IsCA || certificate.KeyUsage&x509.KeyUsageCertSign == 0 {
|
||||
allCAs = false
|
||||
break
|
||||
}
|
||||
}
|
||||
if allCAs {
|
||||
for _, certificate := range certificates {
|
||||
pool.AddCert(certificate)
|
||||
}
|
||||
return pool, nil
|
||||
}
|
||||
|
||||
leaf := certificates[0]
|
||||
if len(certificates) != 1 || leaf.IsCA || !hasUsage(leaf, x509.ExtKeyUsageServerAuth) {
|
||||
return nil, errors.New("control trust PEM must contain CAs or one ServerAuth leaf")
|
||||
}
|
||||
pool.AddCert(leaf)
|
||||
return pool, nil
|
||||
}
|
||||
|
||||
func hasUsage(certificate *x509.Certificate, wanted x509.ExtKeyUsage) bool {
|
||||
for _, usage := range certificate.ExtKeyUsage {
|
||||
if usage == wanted {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func gatewayURIAllowed(uris []*url.URL, gatewayID string) bool {
|
||||
if !validUUID(gatewayID) {
|
||||
return false
|
||||
}
|
||||
prefix := "/gateway/" + gatewayID + "/credential/"
|
||||
for _, uri := range uris {
|
||||
if uri == nil || uri.Scheme != "spiffe" || uri.Host != "versevdi" || uri.User != nil || uri.Opaque != "" || uri.RawPath != "" ||
|
||||
uri.RawQuery != "" || uri.ForceQuery || uri.Fragment != "" || uri.RawFragment != "" || !strings.HasPrefix(uri.Path, prefix) {
|
||||
continue
|
||||
}
|
||||
if validUUID(strings.TrimPrefix(uri.Path, prefix)) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func validPublicDNSName(name string) bool {
|
||||
if len(name) == 0 || len(name) > 253 || net.ParseIP(name) != nil {
|
||||
return false
|
||||
}
|
||||
for _, label := range strings.Split(name, ".") {
|
||||
if len(label) == 0 || len(label) > 63 || label[0] == '-' || label[len(label)-1] == '-' {
|
||||
return false
|
||||
}
|
||||
for _, character := range []byte(label) {
|
||||
if !(character >= 'a' && character <= 'z' || character >= '0' && character <= '9' || character == '-') {
|
||||
return false
|
||||
}
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func validUUID(value string) bool {
|
||||
if len(value) != 36 || value != strings.ToLower(value) || value[8] != '-' || value[13] != '-' || value[18] != '-' || value[23] != '-' {
|
||||
return false
|
||||
}
|
||||
decoded, err := hex.DecodeString(strings.ReplaceAll(value, "-", ""))
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
for _, b := range decoded {
|
||||
if b != 0 {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
@@ -0,0 +1,474 @@
|
||||
package gatewaytls
|
||||
|
||||
import (
|
||||
"crypto/ecdsa"
|
||||
"crypto/ed25519"
|
||||
"crypto/elliptic"
|
||||
"crypto/rand"
|
||||
"crypto/sha256"
|
||||
"crypto/tls"
|
||||
"crypto/x509"
|
||||
"crypto/x509/pkix"
|
||||
"encoding/hex"
|
||||
"encoding/pem"
|
||||
"math/big"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"net/url"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
const (
|
||||
testGatewayID = "7d0d1308-5e50-45ef-984c-4508ca899f5a"
|
||||
testCredentialID = "ba4d93f8-d692-4210-97db-5a561bfb32d3"
|
||||
)
|
||||
|
||||
type testCA struct {
|
||||
certificate *x509.Certificate
|
||||
key *ecdsa.PrivateKey
|
||||
der []byte
|
||||
}
|
||||
|
||||
func TestLoadSeparatesTrustPoolsAndDerivesGatewayIdentity(t *testing.T) {
|
||||
streamingCA := newTestCA(t, "streaming-ca")
|
||||
controlCA := newTestCA(t, "control-ca")
|
||||
identityCA := newTestCA(t, "identity-ca")
|
||||
uri := mustURL(t, "spiffe://versevdi/gateway/"+testGatewayID+"/credential/"+testCredentialID)
|
||||
leafDER, key := newLeaf(t, identityCA, "gateway.example", []*url.URL{uri}, []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth, x509.ExtKeyUsageClientAuth})
|
||||
certFile, keyFile := writeKeyPair(t, leafDER, identityCA.der, key)
|
||||
streamingFile := writeCertificate(t, "streaming-ca.pem", streamingCA.der)
|
||||
controlFile := writeCertificate(t, "control-ca.pem", controlCA.der)
|
||||
|
||||
material, err := Load(certFile, keyFile, streamingFile, controlFile, testGatewayID, "gateway.example")
|
||||
if err != nil {
|
||||
t.Fatalf("Load() error = %v", err)
|
||||
}
|
||||
digest := sha256.Sum256(leafDER)
|
||||
if material.CertificateIdentity != "sha256:"+hex.EncodeToString(digest[:]) {
|
||||
t.Fatalf("certificate identity = %q", material.CertificateIdentity)
|
||||
}
|
||||
if material.ServerTLS.MinVersion != tls.VersionTLS13 || material.ControlTLS.MinVersion != tls.VersionTLS13 || material.ServerTLS.ClientAuth != tls.RequireAndVerifyClientCert ||
|
||||
material.ServerTLS.ClientCAs == nil || material.ControlTLS.RootCAs == nil ||
|
||||
len(material.ServerTLS.Certificates) != 1 || len(material.ControlTLS.Certificates) != 1 {
|
||||
t.Fatalf("TLS material = server:%#v control:%#v", material.ServerTLS, material.ControlTLS)
|
||||
}
|
||||
if got := material.ServerTLS.ClientCAs.Subjects(); len(got) != 1 || string(got[0]) != string(streamingCA.certificate.RawSubject) {
|
||||
t.Fatalf("streaming ClientCAs = %x", got)
|
||||
}
|
||||
if got := material.ControlTLS.RootCAs.Subjects(); len(got) != 1 || string(got[0]) != string(controlCA.certificate.RawSubject) {
|
||||
t.Fatalf("control RootCAs = %x", got)
|
||||
}
|
||||
if string(material.ServerTLS.Certificates[0].Certificate[0]) != string(material.ControlTLS.Certificates[0].Certificate[0]) {
|
||||
t.Fatal("server and control TLS did not use the same gateway leaf")
|
||||
}
|
||||
|
||||
trustedControlLeaf, _ := newLeaf(t, controlCA, "control.example", nil, []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth})
|
||||
parsedTrusted, _ := x509.ParseCertificate(trustedControlLeaf)
|
||||
if _, err := parsedTrusted.Verify(x509.VerifyOptions{Roots: material.ControlTLS.RootCAs, DNSName: "control.example"}); err != nil {
|
||||
t.Fatalf("control CA did not verify control server: %v", err)
|
||||
}
|
||||
wrongControlLeaf, _ := newLeaf(t, streamingCA, "control.example", nil, []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth})
|
||||
parsedWrong, _ := x509.ParseCertificate(wrongControlLeaf)
|
||||
if _, err := parsedWrong.Verify(x509.VerifyOptions{Roots: material.ControlTLS.RootCAs, DNSName: "control.example"}); err == nil {
|
||||
t.Fatal("control RootCAs trusted the streaming CA")
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadTrustsExactSelfSignedControlLeaf(t *testing.T) {
|
||||
streamingCA := newTestCA(t, "streaming-ca")
|
||||
identityCA := newTestCA(t, "identity-ca")
|
||||
uri := mustURL(t, "spiffe://versevdi/gateway/"+testGatewayID+"/credential/"+testCredentialID)
|
||||
gatewayLeaf, gatewayKey := newLeaf(t, identityCA, "gateway.example", []*url.URL{uri}, []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth, x509.ExtKeyUsageClientAuth})
|
||||
certFile, keyFile := writeKeyPair(t, gatewayLeaf, identityCA.der, gatewayKey)
|
||||
streamingFile := writeCertificate(t, "streaming-ca.pem", streamingCA.der)
|
||||
|
||||
controlLeaf, controlKey := newSelfSignedServerLeaf(t, net.ParseIP("127.0.0.1"))
|
||||
controlFile := writeCertificate(t, "control-leaf.pem", controlLeaf)
|
||||
material, err := Load(certFile, keyFile, streamingFile, controlFile, testGatewayID, "gateway.example")
|
||||
if err != nil {
|
||||
t.Fatalf("Load() error = %v", err)
|
||||
}
|
||||
if material.ControlTLS.MinVersion != tls.VersionTLS13 {
|
||||
t.Fatalf("ControlTLS.MinVersion = %d, want TLS 1.3", material.ControlTLS.MinVersion)
|
||||
}
|
||||
|
||||
clientCAs := x509.NewCertPool()
|
||||
clientCAs.AddCert(identityCA.certificate)
|
||||
trusted := newControlServer(t, controlLeaf, controlKey, clientCAs)
|
||||
defer trusted.Close()
|
||||
response, err := (&http.Client{Transport: &http.Transport{TLSClientConfig: material.ControlTLS}}).Get(trusted.URL)
|
||||
if err != nil {
|
||||
t.Fatalf("pinned self-signed control leaf handshake: %v", err)
|
||||
}
|
||||
response.Body.Close()
|
||||
if response.StatusCode != http.StatusNoContent {
|
||||
t.Fatalf("trusted control response status = %d", response.StatusCode)
|
||||
}
|
||||
|
||||
wrongLeaf, wrongKey := newSelfSignedServerLeaf(t, net.ParseIP("127.0.0.1"))
|
||||
wrong := newControlServer(t, wrongLeaf, wrongKey, clientCAs)
|
||||
defer wrong.Close()
|
||||
if _, err := (&http.Client{Transport: &http.Transport{TLSClientConfig: material.ControlTLS}}).Get(wrong.URL); err == nil {
|
||||
t.Fatal("different self-signed leaf with the same SAN completed handshake")
|
||||
}
|
||||
|
||||
mismatchedLeaf, mismatchedKey := newSelfSignedServerLeaf(t, net.ParseIP("127.0.0.2"))
|
||||
mismatchedFile := writeCertificate(t, "mismatched-control-leaf.pem", mismatchedLeaf)
|
||||
mismatchedMaterial, err := Load(certFile, keyFile, streamingFile, mismatchedFile, testGatewayID, "gateway.example")
|
||||
if err != nil {
|
||||
t.Fatalf("Load() mismatched control leaf error = %v", err)
|
||||
}
|
||||
mismatched := newControlServer(t, mismatchedLeaf, mismatchedKey, clientCAs)
|
||||
defer mismatched.Close()
|
||||
if _, err := (&http.Client{Transport: &http.Transport{TLSClientConfig: mismatchedMaterial.ControlTLS}}).Get(mismatched.URL); err == nil {
|
||||
t.Fatal("control leaf without the request hostname/SAN completed handshake")
|
||||
}
|
||||
|
||||
if _, err := Load(certFile, keyFile, controlFile, controlFile, testGatewayID, "gateway.example"); err == nil {
|
||||
t.Fatal("Load() accepted a non-CA leaf as streaming trust")
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadTrustsExactCAIssuedControlLeaf(t *testing.T) {
|
||||
streamingCA := newTestCA(t, "streaming-ca")
|
||||
controlCA := newTestCA(t, "control-ca")
|
||||
identityCA := newTestCA(t, "identity-ca")
|
||||
uri := mustURL(t, "spiffe://versevdi/gateway/"+testGatewayID+"/credential/"+testCredentialID)
|
||||
gatewayLeaf, gatewayKey := newLeaf(t, identityCA, "gateway.example", []*url.URL{uri}, []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth, x509.ExtKeyUsageClientAuth})
|
||||
certFile, keyFile := writeKeyPair(t, gatewayLeaf, identityCA.der, gatewayKey)
|
||||
streamingFile := writeCertificate(t, "streaming-ca.pem", streamingCA.der)
|
||||
|
||||
activeLeaf, activeKey := newCAIssuedControlLeaf(t, controlCA, net.ParseIP("127.0.0.1"))
|
||||
controlFile := writeCertificate(t, "control-leaf.pem", activeLeaf)
|
||||
material, err := Load(certFile, keyFile, streamingFile, controlFile, testGatewayID, "gateway.example")
|
||||
if err != nil {
|
||||
t.Fatalf("Load() error = %v", err)
|
||||
}
|
||||
|
||||
clientCAs := x509.NewCertPool()
|
||||
clientCAs.AddCert(identityCA.certificate)
|
||||
trusted := newControlServer(t, activeLeaf, activeKey, clientCAs, controlCA.der)
|
||||
defer trusted.Close()
|
||||
response, err := (&http.Client{Transport: &http.Transport{TLSClientConfig: material.ControlTLS}}).Get(trusted.URL)
|
||||
if err != nil {
|
||||
t.Fatalf("pinned CA-issued control leaf handshake: %v", err)
|
||||
}
|
||||
response.Body.Close()
|
||||
if response.TLS == nil || response.TLS.Version != tls.VersionTLS13 {
|
||||
t.Fatalf("pinned control leaf TLS version = %v, want TLS 1.3", response.TLS)
|
||||
}
|
||||
if response.StatusCode != http.StatusNoContent {
|
||||
t.Fatalf("trusted control response status = %d", response.StatusCode)
|
||||
}
|
||||
|
||||
siblingLeaf, siblingKey := newCAIssuedControlLeaf(t, controlCA, net.ParseIP("127.0.0.1"))
|
||||
sibling := newControlServer(t, siblingLeaf, siblingKey, clientCAs, controlCA.der)
|
||||
defer sibling.Close()
|
||||
if _, err := (&http.Client{Transport: &http.Transport{TLSClientConfig: material.ControlTLS}}).Get(sibling.URL); err == nil {
|
||||
t.Fatal("sibling control leaf from the same issuer and SAN completed handshake")
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadRejectsControlLeafBundlesAndNonServerAuth(t *testing.T) {
|
||||
streamingCA := newTestCA(t, "streaming-ca")
|
||||
controlCA := newTestCA(t, "control-ca")
|
||||
identityCA := newTestCA(t, "identity-ca")
|
||||
uri := mustURL(t, "spiffe://versevdi/gateway/"+testGatewayID+"/credential/"+testCredentialID)
|
||||
gatewayLeaf, gatewayKey := newLeaf(t, identityCA, "gateway.example", []*url.URL{uri}, []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth, x509.ExtKeyUsageClientAuth})
|
||||
certFile, keyFile := writeKeyPair(t, gatewayLeaf, identityCA.der, gatewayKey)
|
||||
streamingFile := writeCertificate(t, "streaming-ca.pem", streamingCA.der)
|
||||
|
||||
serverLeaf, _ := newCAIssuedControlLeaf(t, controlCA, net.ParseIP("127.0.0.1"))
|
||||
nonServerLeaf, _ := newLeaf(t, controlCA, "control.example", nil, []x509.ExtKeyUsage{x509.ExtKeyUsageClientAuth})
|
||||
for _, test := range []struct {
|
||||
name string
|
||||
certificates [][]byte
|
||||
}{
|
||||
{"leaf and issuer chain", [][]byte{serverLeaf, controlCA.der}},
|
||||
{"non ServerAuth leaf", [][]byte{nonServerLeaf}},
|
||||
} {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
controlFile := writeCertificateBundle(t, "control.pem", test.certificates...)
|
||||
if _, err := Load(certFile, keyFile, streamingFile, controlFile, testGatewayID, "gateway.example"); err == nil {
|
||||
t.Fatal("Load() accepted invalid control trust material")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadRejectsWrongPublicNameURIAndLeafUsage(t *testing.T) {
|
||||
streamingCA := newTestCA(t, "streaming-ca")
|
||||
controlCA := newTestCA(t, "control-ca")
|
||||
identityCA := newTestCA(t, "identity-ca")
|
||||
streamingFile := writeCertificate(t, "streaming-ca.pem", streamingCA.der)
|
||||
controlFile := writeCertificate(t, "control-ca.pem", controlCA.der)
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
uriGatewayID string
|
||||
publicIdentity string
|
||||
leafDNSName string
|
||||
usages []x509.ExtKeyUsage
|
||||
}{
|
||||
{"wrong public name", testGatewayID, "other.example", "gateway.example", []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth, x509.ExtKeyUsageClientAuth}},
|
||||
{"noncanonical public identity", testGatewayID, " gateway.example ", "gateway.example", []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth, x509.ExtKeyUsageClientAuth}},
|
||||
{"wildcard is not an exact public identity", testGatewayID, "gateway.example", "*.example", []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth, x509.ExtKeyUsageClientAuth}},
|
||||
{"wrong URI gateway", "c1f5d25a-47c6-44ed-b9be-e0e8e87d4ae2", "gateway.example", "gateway.example", []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth, x509.ExtKeyUsageClientAuth}},
|
||||
{"missing client auth", testGatewayID, "gateway.example", "gateway.example", []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth}},
|
||||
}
|
||||
for _, test := range tests {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
uri := mustURL(t, "spiffe://versevdi/gateway/"+test.uriGatewayID+"/credential/"+testCredentialID)
|
||||
leafDER, key := newLeaf(t, identityCA, test.leafDNSName, []*url.URL{uri}, test.usages)
|
||||
certFile, keyFile := writeKeyPair(t, leafDER, identityCA.der, key)
|
||||
if _, err := Load(certFile, keyFile, streamingFile, controlFile, testGatewayID, test.publicIdentity); err == nil {
|
||||
t.Fatal("Load() accepted invalid gateway certificate identity")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadRejectsNonCanonicalPublicDNSIdentity(t *testing.T) {
|
||||
streamingCA := newTestCA(t, "streaming-ca")
|
||||
controlCA := newTestCA(t, "control-ca")
|
||||
identityCA := newTestCA(t, "identity-ca")
|
||||
streamingFile := writeCertificate(t, "streaming-ca.pem", streamingCA.der)
|
||||
controlFile := writeCertificate(t, "control-ca.pem", controlCA.der)
|
||||
uri := mustURL(t, "spiffe://versevdi/gateway/"+testGatewayID+"/credential/"+testCredentialID)
|
||||
tooLongLabel := strings.Repeat("a", 64) + ".example"
|
||||
tooLongName := strings.Repeat("a", 63) + "." + strings.Repeat("b", 63) + "." + strings.Repeat("c", 63) + "." + strings.Repeat("d", 62)
|
||||
|
||||
for _, test := range []struct {
|
||||
name string
|
||||
leafDNSName string
|
||||
publicIdentity string
|
||||
}{
|
||||
{"uppercase exact SAN", "Gateway.example", "Gateway.example"},
|
||||
{"empty label", "gateway..example", "gateway..example"},
|
||||
{"leading hyphen", "-gateway.example", "-gateway.example"},
|
||||
{"trailing hyphen", "gateway-.example", "gateway-.example"},
|
||||
{"wildcard", "*.example", "*.example"},
|
||||
{"label exceeds 63 bytes", tooLongLabel, tooLongLabel},
|
||||
{"name exceeds 253 bytes", tooLongName, tooLongName},
|
||||
{"IP address", "127.0.0.1", "127.0.0.1"},
|
||||
{"port", "gateway.example:443", "gateway.example:443"},
|
||||
{"path", "gateway.example/path", "gateway.example/path"},
|
||||
{"whitespace", "gateway .example", "gateway .example"},
|
||||
{"trailing dot", "gateway.example.", "gateway.example."},
|
||||
{"Unicode", "gateway.example", "gäteway.example"},
|
||||
} {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
leafDER, key := newLeaf(t, identityCA, test.leafDNSName, []*url.URL{uri}, []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth, x509.ExtKeyUsageClientAuth})
|
||||
certFile, keyFile := writeKeyPair(t, leafDER, identityCA.der, key)
|
||||
if _, err := Load(certFile, keyFile, streamingFile, controlFile, testGatewayID, test.publicIdentity); err == nil {
|
||||
t.Fatal("Load() accepted a noncanonical public DNS identity")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadAcceptsCanonicalPublicDNSIdentity(t *testing.T) {
|
||||
streamingCA := newTestCA(t, "streaming-ca")
|
||||
controlCA := newTestCA(t, "control-ca")
|
||||
identityCA := newTestCA(t, "identity-ca")
|
||||
streamingFile := writeCertificate(t, "streaming-ca.pem", streamingCA.der)
|
||||
controlFile := writeCertificate(t, "control-ca.pem", controlCA.der)
|
||||
uri := mustURL(t, "spiffe://versevdi/gateway/"+testGatewayID+"/credential/"+testCredentialID)
|
||||
|
||||
for _, publicIdentity := range []string{"gateway.example", "gateway"} {
|
||||
t.Run(publicIdentity, func(t *testing.T) {
|
||||
leafDER, key := newLeaf(t, identityCA, publicIdentity, []*url.URL{uri}, []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth, x509.ExtKeyUsageClientAuth})
|
||||
certFile, keyFile := writeKeyPair(t, leafDER, identityCA.der, key)
|
||||
if _, err := Load(certFile, keyFile, streamingFile, controlFile, testGatewayID, publicIdentity); err != nil {
|
||||
t.Fatalf("Load() error = %v", err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadRejectsLegacyAndNoncanonicalGatewayURIs(t *testing.T) {
|
||||
streamingCA := newTestCA(t, "streaming-ca")
|
||||
controlCA := newTestCA(t, "control-ca")
|
||||
identityCA := newTestCA(t, "identity-ca")
|
||||
streamingFile := writeCertificate(t, "streaming-ca.pem", streamingCA.der)
|
||||
controlFile := writeCertificate(t, "control-ca.pem", controlCA.der)
|
||||
canonical := "spiffe://versevdi/gateway/" + testGatewayID + "/credential/" + testCredentialID
|
||||
|
||||
for _, test := range []struct {
|
||||
name string
|
||||
uri string
|
||||
}{
|
||||
{"legacy URN", "urn:versevdi:gateway:" + testGatewayID + ":credential:" + testCredentialID},
|
||||
{"opaque URI", "spiffe:gateway/" + testGatewayID + "/credential/" + testCredentialID},
|
||||
{"leading path slash", "spiffe://versevdi//gateway/" + testGatewayID + "/credential/" + testCredentialID},
|
||||
{"trailing path slash", canonical + "/"},
|
||||
{"percent encoded credential", "spiffe://versevdi/gateway/" + testGatewayID + "/credential/%62a4d93f8-d692-4210-97db-5a561bfb32d3"},
|
||||
{"force query", canonical + "?"},
|
||||
{"query", canonical + "?version=1"},
|
||||
{"fragment", canonical + "#fragment"},
|
||||
{"user", "spiffe://gateway@versevdi/gateway/" + testGatewayID + "/credential/" + testCredentialID},
|
||||
} {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
uri := mustURL(t, test.uri)
|
||||
leafDER, key := newLeaf(t, identityCA, "gateway.example", []*url.URL{uri}, []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth, x509.ExtKeyUsageClientAuth})
|
||||
certFile, keyFile := writeKeyPair(t, leafDER, identityCA.der, key)
|
||||
if _, err := Load(certFile, keyFile, streamingFile, controlFile, testGatewayID, "gateway.example"); err == nil {
|
||||
t.Fatal("Load() accepted a legacy or noncanonical gateway URI")
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
t.Run("uppercase UUIDs", func(t *testing.T) {
|
||||
gatewayID := strings.ToUpper(testGatewayID)
|
||||
uri := mustURL(t, "spiffe://versevdi/gateway/"+gatewayID+"/credential/"+strings.ToUpper(testCredentialID))
|
||||
leafDER, key := newLeaf(t, identityCA, "gateway.example", []*url.URL{uri}, []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth, x509.ExtKeyUsageClientAuth})
|
||||
certFile, keyFile := writeKeyPair(t, leafDER, identityCA.der, key)
|
||||
if _, err := Load(certFile, keyFile, streamingFile, controlFile, gatewayID, "gateway.example"); err == nil {
|
||||
t.Fatal("Load() accepted noncanonical uppercase UUIDs")
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func newTestCA(t *testing.T, commonName string) testCA {
|
||||
t.Helper()
|
||||
key, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
template := &x509.Certificate{
|
||||
SerialNumber: big.NewInt(time.Now().UnixNano()), Subject: pkix.Name{CommonName: commonName},
|
||||
NotBefore: time.Now().Add(-time.Hour), NotAfter: time.Now().Add(time.Hour),
|
||||
IsCA: true, BasicConstraintsValid: true, KeyUsage: x509.KeyUsageCertSign | x509.KeyUsageDigitalSignature,
|
||||
}
|
||||
der, err := x509.CreateCertificate(rand.Reader, template, template, &key.PublicKey, key)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
certificate, err := x509.ParseCertificate(der)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return testCA{certificate: certificate, key: key, der: der}
|
||||
}
|
||||
|
||||
func newLeaf(t *testing.T, ca testCA, dnsName string, uris []*url.URL, usages []x509.ExtKeyUsage) ([]byte, ed25519.PrivateKey) {
|
||||
t.Helper()
|
||||
publicKey, key, err := ed25519.GenerateKey(rand.Reader)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
template := &x509.Certificate{
|
||||
SerialNumber: big.NewInt(time.Now().UnixNano()), Subject: pkix.Name{CommonName: dnsName}, DNSNames: []string{dnsName}, URIs: uris,
|
||||
NotBefore: time.Now().Add(-time.Hour), NotAfter: time.Now().Add(time.Hour),
|
||||
BasicConstraintsValid: true, KeyUsage: x509.KeyUsageDigitalSignature, ExtKeyUsage: usages,
|
||||
}
|
||||
der, err := x509.CreateCertificate(rand.Reader, template, ca.certificate, publicKey, ca.key)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return der, key
|
||||
}
|
||||
|
||||
func newSelfSignedServerLeaf(t *testing.T, ip net.IP) ([]byte, *ecdsa.PrivateKey) {
|
||||
t.Helper()
|
||||
key, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
template := &x509.Certificate{
|
||||
SerialNumber: big.NewInt(time.Now().UnixNano()), Subject: pkix.Name{CommonName: "control.example"}, IPAddresses: []net.IP{ip},
|
||||
NotBefore: time.Now().Add(-time.Hour), NotAfter: time.Now().Add(time.Hour),
|
||||
BasicConstraintsValid: true, KeyUsage: x509.KeyUsageDigitalSignature, ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth},
|
||||
}
|
||||
der, err := x509.CreateCertificate(rand.Reader, template, template, &key.PublicKey, key)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return der, key
|
||||
}
|
||||
|
||||
func newCAIssuedControlLeaf(t *testing.T, ca testCA, ip net.IP) ([]byte, ed25519.PrivateKey) {
|
||||
t.Helper()
|
||||
publicKey, key, err := ed25519.GenerateKey(rand.Reader)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
template := &x509.Certificate{
|
||||
SerialNumber: big.NewInt(time.Now().UnixNano()), Subject: pkix.Name{CommonName: "control.example"}, IPAddresses: []net.IP{ip},
|
||||
NotBefore: time.Now().Add(-time.Hour), NotAfter: time.Now().Add(time.Hour),
|
||||
BasicConstraintsValid: true, KeyUsage: x509.KeyUsageDigitalSignature, ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth},
|
||||
}
|
||||
der, err := x509.CreateCertificate(rand.Reader, template, ca.certificate, publicKey, ca.key)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return der, key
|
||||
}
|
||||
|
||||
func newControlServer(t *testing.T, leaf []byte, key any, clientCAs *x509.CertPool, chain ...[]byte) *httptest.Server {
|
||||
t.Helper()
|
||||
server := httptest.NewUnstartedServer(http.HandlerFunc(func(response http.ResponseWriter, request *http.Request) {
|
||||
if request.TLS == nil || len(request.TLS.PeerCertificates) == 0 {
|
||||
http.Error(response, "mTLS required", http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
response.WriteHeader(http.StatusNoContent)
|
||||
}))
|
||||
certificates := append([][]byte{leaf}, chain...)
|
||||
server.TLS = &tls.Config{
|
||||
MinVersion: tls.VersionTLS13, Certificates: []tls.Certificate{{Certificate: certificates, PrivateKey: key}},
|
||||
ClientAuth: tls.RequireAndVerifyClientCert, ClientCAs: clientCAs,
|
||||
}
|
||||
server.StartTLS()
|
||||
return server
|
||||
}
|
||||
|
||||
func writeKeyPair(t *testing.T, leafDER, issuerDER []byte, key ed25519.PrivateKey) (string, string) {
|
||||
t.Helper()
|
||||
directory := t.TempDir()
|
||||
certFile := filepath.Join(directory, "gateway.pem")
|
||||
keyFile := filepath.Join(directory, "gateway-key.pem")
|
||||
certificatePEM := append(pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: leafDER}), pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: issuerDER})...)
|
||||
privateDER, err := x509.MarshalPKCS8PrivateKey(key)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(certFile, certificatePEM, 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(keyFile, pem.EncodeToMemory(&pem.Block{Type: "PRIVATE KEY", Bytes: privateDER}), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return certFile, keyFile
|
||||
}
|
||||
|
||||
func writeCertificate(t *testing.T, name string, der []byte) string {
|
||||
return writeCertificateBundle(t, name, der)
|
||||
}
|
||||
|
||||
func writeCertificateBundle(t *testing.T, name string, certificates ...[]byte) string {
|
||||
t.Helper()
|
||||
path := filepath.Join(t.TempDir(), name)
|
||||
var encoded []byte
|
||||
for _, certificate := range certificates {
|
||||
encoded = append(encoded, pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: certificate})...)
|
||||
}
|
||||
if err := os.WriteFile(path, encoded, 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return path
|
||||
}
|
||||
|
||||
func mustURL(t *testing.T, value string) *url.URL {
|
||||
t.Helper()
|
||||
parsed, err := url.Parse(value)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return parsed
|
||||
}
|
||||
+11
-29
@@ -2,14 +2,11 @@ package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/tls"
|
||||
"crypto/x509"
|
||||
"errors"
|
||||
"flag"
|
||||
"fmt"
|
||||
"log"
|
||||
"net"
|
||||
"os"
|
||||
"os/signal"
|
||||
"strings"
|
||||
"syscall"
|
||||
@@ -17,6 +14,7 @@ import (
|
||||
|
||||
"net/http"
|
||||
|
||||
"git.sechmachine.io.vn/sechmachine/VerseVDI-Data-Plane/cmd/internal/gatewaytls"
|
||||
"git.sechmachine.io.vn/sechmachine/VerseVDI-Data-Plane/gateway"
|
||||
protocol "git.sechmachine.io.vn/sechmachine/VerseVDI-Protocol/gen/go/protocol"
|
||||
)
|
||||
@@ -28,20 +26,20 @@ func main() {
|
||||
}
|
||||
|
||||
func run() error {
|
||||
var listen, advertiseAddress, controlPlane, certFile, keyFile, clientCAFile string
|
||||
var gatewayID, instanceIdentity, certificateIdentity, publicIdentity string
|
||||
var listen, advertiseAddress, controlPlane, certFile, keyFile, streamingCAFile, controlCAFile string
|
||||
var gatewayID, instanceIdentity, publicIdentity string
|
||||
flag.StringVar(&listen, "listen", "0.0.0.0:443", "gateway QUIC listen address")
|
||||
flag.StringVar(&advertiseAddress, "advertise-address", "", "client-visible gateway address host:port")
|
||||
flag.StringVar(&controlPlane, "control-plane", "", "Connection Server HTTPS base URL")
|
||||
flag.StringVar(&certFile, "cert", "", "gateway certificate PEM")
|
||||
flag.StringVar(&keyFile, "key", "", "gateway private key PEM")
|
||||
flag.StringVar(&clientCAFile, "client-ca", "", "Connection Server/client CA PEM")
|
||||
flag.StringVar(&streamingCAFile, "streaming-ca", "", "CA PEM for QUIC streaming clients")
|
||||
flag.StringVar(&controlCAFile, "control-ca", "", "CA bundle or exact Server leaf PEM")
|
||||
flag.StringVar(&gatewayID, "gateway-id", "", "stable gateway identifier")
|
||||
flag.StringVar(&instanceIdentity, "instance-identity", "", "gateway instance identity")
|
||||
flag.StringVar(&certificateIdentity, "certificate-identity", "", "gateway certificate identity")
|
||||
flag.StringVar(&publicIdentity, "public-identity", "gateway", "gateway public identity")
|
||||
flag.StringVar(&publicIdentity, "public-identity", "", "gateway DNS identity from the certificate SAN")
|
||||
flag.Parse()
|
||||
for name, value := range map[string]string{"control-plane": controlPlane, "advertise-address": advertiseAddress, "cert": certFile, "key": keyFile, "client-ca": clientCAFile, "gateway-id": gatewayID, "instance-identity": instanceIdentity, "certificate-identity": certificateIdentity} {
|
||||
for name, value := range map[string]string{"control-plane": controlPlane, "advertise-address": advertiseAddress, "cert": certFile, "key": keyFile, "streaming-ca": streamingCAFile, "control-ca": controlCAFile, "gateway-id": gatewayID, "instance-identity": instanceIdentity, "public-identity": publicIdentity} {
|
||||
if value == "" {
|
||||
return fmt.Errorf("-%s is required", name)
|
||||
}
|
||||
@@ -49,20 +47,20 @@ func run() error {
|
||||
if err := validateAdvertisedAddress(advertiseAddress); err != nil {
|
||||
return err
|
||||
}
|
||||
serverTLS, clientTLS, err := loadTLS(certFile, keyFile, clientCAFile)
|
||||
tlsMaterial, err := gatewaytls.Load(certFile, keyFile, streamingCAFile, controlCAFile, gatewayID, publicIdentity)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
transport := &http.Transport{TLSClientConfig: clientTLS}
|
||||
transport := &http.Transport{TLSClientConfig: tlsMaterial.ControlTLS}
|
||||
controlPlaneClient := gateway.NewControlPlaneClient(controlPlane, &http.Client{Transport: transport, Timeout: 5 * time.Second})
|
||||
provider := gateway.NewApolloAdapter(gateway.NewNativeApolloBackend(), gateway.ProviderIdentity{})
|
||||
capabilities := gateway.DefaultCapabilities()
|
||||
features := gateway.DefaultFeatures()
|
||||
server, err := gateway.NewServer(gateway.ServerConfig{ListenAddress: listen, TLSConfig: serverTLS, GatewayID: gatewayID, Features: features, Capabilities: capabilities, ProviderCapabilities: capabilities, Admission: controlPlaneClient, ProviderStateReporter: controlPlaneClient, ClipboardAuditReporter: controlPlaneClient, Provider: provider, PacerKbps: 100000})
|
||||
server, err := gateway.NewServer(gateway.ServerConfig{ListenAddress: listen, TLSConfig: tlsMaterial.ServerTLS, GatewayID: gatewayID, Features: features, Capabilities: capabilities, ProviderCapabilities: capabilities, Admission: controlPlaneClient, ProviderStateReporter: controlPlaneClient, ClipboardAuditReporter: controlPlaneClient, Provider: provider, PacerKbps: 100000})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
registration := protocol.GatewayRegistration{Version: "1", GatewayID: gatewayID, InstanceIdentity: instanceIdentity, CertificateIdentity: certificateIdentity, PublicIdentity: publicIdentity, Address: advertiseAddress, ProviderIdentity: "server-derived", ProtocolMinVersion: 1, ProtocolMaxVersion: 1, ConnectionCapacity: 8, BandwidthCapacityKbps: 100000, Features: features, Capabilities: capabilities}
|
||||
registration := protocol.GatewayRegistration{Version: "1", GatewayID: gatewayID, InstanceIdentity: instanceIdentity, CertificateIdentity: tlsMaterial.CertificateIdentity, PublicIdentity: publicIdentity, Address: advertiseAddress, ProviderIdentity: "server-derived", ProtocolMinVersion: 1, ProtocolMaxVersion: 1, ConnectionCapacity: 8, BandwidthCapacityKbps: 100000, Features: features, Capabilities: capabilities}
|
||||
if _, err := controlPlaneClient.Register(context.Background(), registration); err != nil {
|
||||
_ = server.Close()
|
||||
return err
|
||||
@@ -183,22 +181,6 @@ func providerStateName(value uint64) string {
|
||||
}
|
||||
}
|
||||
|
||||
func loadTLS(certFile, keyFile, clientCAFile string) (*tls.Config, *tls.Config, error) {
|
||||
certificate, err := tls.LoadX509KeyPair(certFile, keyFile)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
caBytes, err := os.ReadFile(clientCAFile)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
clientCAs := x509.NewCertPool()
|
||||
if !clientCAs.AppendCertsFromPEM(caBytes) {
|
||||
return nil, nil, errors.New("client CA PEM contains no certificate")
|
||||
}
|
||||
return &tls.Config{MinVersion: tls.VersionTLS13, Certificates: []tls.Certificate{certificate}, ClientAuth: tls.RequireAndVerifyClientCert, ClientCAs: clientCAs}, &tls.Config{MinVersion: tls.VersionTLS13, Certificates: []tls.Certificate{certificate}, RootCAs: clientCAs}, nil
|
||||
}
|
||||
|
||||
func parseProviderIdentity(value string) (gateway.ProviderIdentity, error) {
|
||||
uniqueID, fingerprint, ok := strings.Cut(value, "#")
|
||||
if !ok || uniqueID == "" || fingerprint == "" {
|
||||
|
||||
Reference in New Issue
Block a user