diff --git a/cmd/sing-box/cmd_generate_ech.go b/cmd/sing-box/cmd_generate_ech.go new file mode 100644 index 00000000..4b5b7be3 --- /dev/null +++ b/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 ", + 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 +} diff --git a/common/tls/ech_keygen.go b/common/tls/ech_keygen.go new file mode 100644 index 00000000..1fea131c --- /dev/null +++ b/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 +} diff --git a/common/tls/ech_stub.go b/common/tls/ech_stub.go index 0aab700e..4ee4272c 100644 --- a/common/tls/ech_stub.go +++ b/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 } diff --git a/common/tls/reality_server.go b/common/tls/reality_server.go index f6e30827..a9318798 100644 --- a/common/tls/reality_server.go +++ b/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") } diff --git a/common/tls/std_server.go b/common/tls/std_server.go index 33d92232..36ce67b6 100644 --- a/common/tls/std_server.go +++ b/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 { diff --git a/go.mod b/go.mod index 2ab66f76..954eeb1b 100644 --- a/go.mod +++ b/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 diff --git a/test/ech_test.go b/test/ech_test.go new file mode 100644 index 00000000..b05351b4 --- /dev/null +++ b/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) +}