Browse Source

Add ECH keypair generator

世界 2 years ago
parent
commit
983a4222ad

+ 39 - 0
cmd/sing-box/cmd_generate_ech.go

@@ -0,0 +1,39 @@
+package main
+
+import (
+	"os"
+
+	"github.com/sagernet/sing-box/common/tls"
+	"github.com/sagernet/sing-box/log"
+
+	"github.com/spf13/cobra"
+)
+
+var pqSignatureSchemesEnabled bool
+
+var commandGenerateECHKeyPair = &cobra.Command{
+	Use:   "ech-keypair <plain_server_name>",
+	Short: "Generate TLS ECH key pair",
+	Args:  cobra.ExactArgs(1),
+	Run: func(cmd *cobra.Command, args []string) {
+		err := generateECHKeyPair(args[0])
+		if err != nil {
+			log.Fatal(err)
+		}
+	},
+}
+
+func init() {
+	commandGenerateECHKeyPair.Flags().BoolVar(&pqSignatureSchemesEnabled, "pq-signature-schemes-enabled", false, "Enable PQ signature schemes")
+	commandGenerate.AddCommand(commandGenerateECHKeyPair)
+}
+
+func generateECHKeyPair(serverName string) error {
+	configPem, keyPem, err := tls.ECHKeygenDefault(serverName, pqSignatureSchemesEnabled)
+	if err != nil {
+		return err
+	}
+	os.Stdout.WriteString(configPem)
+	os.Stdout.WriteString(keyPem)
+	return nil
+}

+ 169 - 0
common/tls/ech_keygen.go

@@ -0,0 +1,169 @@
+//go:build with_ech
+
+package tls
+
+import (
+	"bytes"
+	"encoding/binary"
+	"encoding/pem"
+
+	cftls "github.com/sagernet/cloudflare-tls"
+	E "github.com/sagernet/sing/common/exceptions"
+
+	"github.com/cloudflare/circl/hpke"
+	"github.com/cloudflare/circl/kem"
+)
+
+func ECHKeygenDefault(serverName string, pqSignatureSchemesEnabled bool) (configPem string, keyPem string, err error) {
+	cipherSuites := []echCipherSuite{
+		{
+			kdf:  hpke.KDF_HKDF_SHA256,
+			aead: hpke.AEAD_AES128GCM,
+		}, {
+			kdf:  hpke.KDF_HKDF_SHA256,
+			aead: hpke.AEAD_ChaCha20Poly1305,
+		},
+	}
+
+	keyConfig := []myECHKeyConfig{
+		{id: 0, kem: hpke.KEM_X25519_HKDF_SHA256},
+	}
+	if pqSignatureSchemesEnabled {
+		keyConfig = append(keyConfig, myECHKeyConfig{id: 1, kem: hpke.KEM_X25519_KYBER768_DRAFT00})
+	}
+
+	keyPairs, err := echKeygen(0xfe0d, serverName, keyConfig, cipherSuites)
+	if err != nil {
+		return
+	}
+
+	var configBuffer bytes.Buffer
+	var totalLen uint16
+	for _, keyPair := range keyPairs {
+		totalLen += uint16(len(keyPair.rawConf))
+	}
+	binary.Write(&configBuffer, binary.BigEndian, totalLen)
+	for _, keyPair := range keyPairs {
+		configBuffer.Write(keyPair.rawConf)
+	}
+
+	var keyBuffer bytes.Buffer
+	for _, keyPair := range keyPairs {
+		keyBuffer.Write(keyPair.rawKey)
+	}
+
+	configPem = string(pem.EncodeToMemory(&pem.Block{Type: "ECH CONFIGS", Bytes: configBuffer.Bytes()}))
+	keyPem = string(pem.EncodeToMemory(&pem.Block{Type: "ECH KEYS", Bytes: keyBuffer.Bytes()}))
+	return
+}
+
+type echKeyConfigPair struct {
+	id      uint8
+	key     cftls.EXP_ECHKey
+	rawKey  []byte
+	conf    myECHKeyConfig
+	rawConf []byte
+}
+
+type echCipherSuite struct {
+	kdf  hpke.KDF
+	aead hpke.AEAD
+}
+
+type myECHKeyConfig struct {
+	id   uint8
+	kem  hpke.KEM
+	seed []byte
+}
+
+func echKeygen(version uint16, serverName string, conf []myECHKeyConfig, suite []echCipherSuite) ([]echKeyConfigPair, error) {
+	be := binary.BigEndian
+	// prepare for future update
+	if version != 0xfe0d {
+		return nil, E.New("unsupported ECH version", version)
+	}
+
+	suiteBuf := make([]byte, 0, len(suite)*4+2)
+	suiteBuf = be.AppendUint16(suiteBuf, uint16(len(suite))*4)
+	for _, s := range suite {
+		if !s.kdf.IsValid() || !s.aead.IsValid() {
+			return nil, E.New("invalid HPKE cipher suite")
+		}
+		suiteBuf = be.AppendUint16(suiteBuf, uint16(s.kdf))
+		suiteBuf = be.AppendUint16(suiteBuf, uint16(s.aead))
+	}
+
+	pairs := []echKeyConfigPair{}
+	for _, c := range conf {
+		pair := echKeyConfigPair{}
+		pair.id = c.id
+		pair.conf = c
+
+		if !c.kem.IsValid() {
+			return nil, E.New("invalid HPKE KEM")
+		}
+
+		kpGenerator := c.kem.Scheme().GenerateKeyPair
+		if len(c.seed) > 0 {
+			kpGenerator = func() (kem.PublicKey, kem.PrivateKey, error) {
+				pub, sec := c.kem.Scheme().DeriveKeyPair(c.seed)
+				return pub, sec, nil
+			}
+			if len(c.seed) < c.kem.Scheme().PrivateKeySize() {
+				return nil, E.New("HPKE KEM seed too short")
+			}
+		}
+
+		pub, sec, err := kpGenerator()
+		if err != nil {
+			return nil, E.Cause(err, "generate ECH config key pair")
+		}
+		b := []byte{}
+		b = be.AppendUint16(b, version)
+		b = be.AppendUint16(b, 0) // length field
+		// contents
+		// key config
+		b = append(b, c.id)
+		b = be.AppendUint16(b, uint16(c.kem))
+		pubBuf, err := pub.MarshalBinary()
+		if err != nil {
+			return nil, E.Cause(err, "serialize ECH public key")
+		}
+		b = be.AppendUint16(b, uint16(len(pubBuf)))
+		b = append(b, pubBuf...)
+
+		b = append(b, suiteBuf...)
+		// end key config
+		// max name len, not supported
+		b = append(b, 0)
+		// server name
+		b = append(b, byte(len(serverName)))
+		b = append(b, []byte(serverName)...)
+		// extensions, not supported
+		b = be.AppendUint16(b, 0)
+
+		be.PutUint16(b[2:], uint16(len(b)-4))
+
+		pair.rawConf = b
+
+		secBuf, err := sec.MarshalBinary()
+		sk := []byte{}
+		sk = be.AppendUint16(sk, uint16(len(secBuf)))
+		sk = append(sk, secBuf...)
+		sk = be.AppendUint16(sk, uint16(len(b)))
+		sk = append(sk, b...)
+
+		cfECHKeys, err := cftls.EXP_UnmarshalECHKeys(sk)
+		if err != nil {
+			return nil, E.Cause(err, "bug: can't parse generated ECH server key")
+		}
+		if len(cfECHKeys) != 1 {
+			return nil, E.New("bug: unexpected server key count")
+		}
+		pair.key = cfECHKeys[0]
+		pair.rawKey = sk
+
+		pairs = append(pairs, pair)
+	}
+	return pairs, nil
+}

+ 8 - 2
common/tls/ech_stub.go

@@ -10,10 +10,16 @@ import (
 	E "github.com/sagernet/sing/common/exceptions"
 )
 
+var errECHNotIncluded = E.New(`ECH is not included in this build, rebuild with -tags with_ech`)
+
 func NewECHServer(ctx context.Context, logger log.Logger, options option.InboundTLSOptions) (ServerConfig, error) {
-	return nil, E.New(`ECH is not included in this build, rebuild with -tags with_ech`)
+	return nil, errECHNotIncluded
 }
 
 func NewECHClient(ctx context.Context, serverAddress string, options option.OutboundTLSOptions) (Config, error) {
-	return nil, E.New(`ECH is not included in this build, rebuild with -tags with_ech`)
+	return nil, errECHNotIncluded
+}
+
+func ECHKeygenDefault(host string, pqSignatureSchemesEnabled bool) (configPem string, keyPem string, err error) {
+	return "", "", errECHNotIncluded
 }

+ 2 - 2
common/tls/reality_server.go

@@ -67,10 +67,10 @@ func NewRealityServer(ctx context.Context, logger log.Logger, options option.Inb
 			return nil, E.New("unknown cipher_suite: ", cipherSuite)
 		}
 	}
-	if options.Certificate != "" || options.CertificatePath != "" {
+	if len(options.Certificate) > 0 || options.CertificatePath != "" {
 		return nil, E.New("certificate is unavailable in reality")
 	}
-	if options.Key != "" || options.KeyPath != "" {
+	if len(options.Key) > 0 || options.KeyPath != "" {
 		return nil, E.New("key is unavailable in reality")
 	}
 

+ 5 - 4
common/tls/std_server.go

@@ -5,6 +5,7 @@ import (
 	"crypto/tls"
 	"net"
 	"os"
+	"strings"
 
 	"github.com/sagernet/sing-box/adapter"
 	"github.com/sagernet/sing-box/log"
@@ -212,8 +213,8 @@ func NewSTDServer(ctx context.Context, logger log.Logger, options option.Inbound
 	var certificate []byte
 	var key []byte
 	if acmeService == nil {
-		if options.Certificate != "" {
-			certificate = []byte(options.Certificate)
+		if len(options.Certificate) > 0 {
+			certificate = []byte(strings.Join(options.Certificate, "\n"))
 		} else if options.CertificatePath != "" {
 			content, err := os.ReadFile(options.CertificatePath)
 			if err != nil {
@@ -221,8 +222,8 @@ func NewSTDServer(ctx context.Context, logger log.Logger, options option.Inbound
 			}
 			certificate = content
 		}
-		if options.Key != "" {
-			key = []byte(options.Key)
+		if len(options.Key) > 0 {
+			key = []byte(strings.Join(options.Key, "\n"))
 		} else if options.KeyPath != "" {
 			content, err := os.ReadFile(options.KeyPath)
 			if err != nil {

+ 1 - 1
go.mod

@@ -6,6 +6,7 @@ require (
 	berty.tech/go-libtor v1.0.385
 	github.com/Dreamacro/clash v1.17.0
 	github.com/caddyserver/certmagic v0.19.2
+	github.com/cloudflare/circl v1.3.3
 	github.com/cretz/bine v0.2.0
 	github.com/dustin/go-humanize v1.0.1
 	github.com/fsnotify/fsnotify v1.6.0
@@ -59,7 +60,6 @@ require (
 	github.com/Dreamacro/protobytes v0.0.0-20230617041236-6500a9f4f158 // indirect
 	github.com/ajg/form v1.5.1 // indirect
 	github.com/andybalholm/brotli v1.0.5 // indirect
-	github.com/cloudflare/circl v1.3.3 // indirect
 	github.com/davecgh/go-spew v1.1.1 // indirect
 	github.com/go-ole/go-ole v1.3.0 // indirect
 	github.com/go-task/slim-sprig v0.0.0-20230315185526-52ccab3ef572 // indirect

+ 91 - 0
test/ech_test.go

@@ -0,0 +1,91 @@
+package main
+
+import (
+	"net/netip"
+	"testing"
+
+	"github.com/sagernet/sing-box/common/tls"
+	C "github.com/sagernet/sing-box/constant"
+	"github.com/sagernet/sing-box/option"
+	"github.com/sagernet/sing/common"
+)
+
+func TestECH(t *testing.T) {
+	_, certPem, keyPem := createSelfSignedCertificate(t, "example.org")
+	echConfig, echKey := common.Must2(tls.ECHKeygenDefault("example.org", false))
+	startInstance(t, option.Options{
+		Inbounds: []option.Inbound{
+			{
+				Type: C.TypeMixed,
+				Tag:  "mixed-in",
+				MixedOptions: option.HTTPMixedInboundOptions{
+					ListenOptions: option.ListenOptions{
+						Listen:     option.NewListenAddress(netip.IPv4Unspecified()),
+						ListenPort: clientPort,
+					},
+				},
+			},
+			{
+				Type: C.TypeTrojan,
+				TrojanOptions: option.TrojanInboundOptions{
+					ListenOptions: option.ListenOptions{
+						Listen:     option.NewListenAddress(netip.IPv4Unspecified()),
+						ListenPort: serverPort,
+					},
+					Users: []option.TrojanUser{
+						{
+							Name:     "sekai",
+							Password: "password",
+						},
+					},
+					TLS: &option.InboundTLSOptions{
+						Enabled:         true,
+						ServerName:      "example.org",
+						CertificatePath: certPem,
+						KeyPath:         keyPem,
+						ECH: &option.InboundECHOptions{
+							Enabled: true,
+							Key:     []string{echKey},
+						},
+					},
+				},
+			},
+		},
+		Outbounds: []option.Outbound{
+			{
+				Type: C.TypeDirect,
+			},
+			{
+				Type: C.TypeTrojan,
+				Tag:  "trojan-out",
+				TrojanOptions: option.TrojanOutboundOptions{
+					ServerOptions: option.ServerOptions{
+						Server:     "127.0.0.1",
+						ServerPort: serverPort,
+					},
+					Password: "password",
+					TLS: &option.OutboundTLSOptions{
+						Enabled:         true,
+						ServerName:      "example.org",
+						CertificatePath: certPem,
+						ECH: &option.OutboundECHOptions{
+							Enabled: true,
+							Config:  []string{echConfig},
+						},
+					},
+				},
+			},
+		},
+		Route: &option.RouteOptions{
+			Rules: []option.Rule{
+				{
+					DefaultOptions: option.DefaultRule{
+						Inbound:  []string{"mixed-in"},
+						Outbound: "trojan-out",
+					},
+				},
+			},
+		},
+	})
+	testSuit(t, clientPort, testPort)
+}