diff --git a/internal/web/service/server.go b/internal/web/service/server.go index e724752..f9a9cc4 100644 --- a/internal/web/service/server.go +++ b/internal/web/service/server.go @@ -6,8 +6,11 @@ import ( "bytes" "cmp" "context" + "crypto/ecdh" + "crypto/rand" "crypto/sha256" "crypto/x509" + "encoding/base64" "encoding/hex" "encoding/json" "encoding/pem" @@ -2305,17 +2308,21 @@ func parseXrayKeyPairOutput(output string) (string, string, error) { return strings.TrimSpace(first[1]), strings.TrimSpace(second[1]), nil } -func (s *ServerService) GetNewX25519Cert() (any, error) { - // Run the command - cmd := exec.CommandContext(context.Background(), xray.GetBinaryPath(), "x25519") - var out bytes.Buffer - cmd.Stdout = &out - err := cmd.Run() +// generateX25519KeyPair returns a fresh X25519 key pair in the exact format +// `xray x25519` prints (base64 RawURLEncoding of the raw 32-byte keys), but +// generated in-process: a control-plane-only panel has no xray binary to shell +// out to, and shelling out made Reality inbound creation fail there. +func generateX25519KeyPair() (privateKey string, publicKey string, err error) { + priv, err := ecdh.X25519().GenerateKey(rand.Reader) if err != nil { - return nil, err + return "", "", err } + return base64.RawURLEncoding.EncodeToString(priv.Bytes()), + base64.RawURLEncoding.EncodeToString(priv.PublicKey().Bytes()), nil +} - privateKey, publicKey, err := parseXrayKeyPairOutput(out.String()) +func (s *ServerService) GetNewX25519Cert() (any, error) { + privateKey, publicKey, err := generateX25519KeyPair() if err != nil { return nil, err } diff --git a/internal/web/service/x25519_keygen_test.go b/internal/web/service/x25519_keygen_test.go new file mode 100644 index 0000000..ce8b966 --- /dev/null +++ b/internal/web/service/x25519_keygen_test.go @@ -0,0 +1,83 @@ +package service + +import ( + "bytes" + "crypto/ecdh" + "encoding/base64" + "strings" + "testing" +) + +// The panel generates Reality keys in-process now (no xray binary on a +// control-plane-only master). These tests pin the wire format to what +// `xray x25519` prints — base64 RawURLEncoding of the raw 32-byte keys — +// and prove the pair is a genuine X25519 pair by re-deriving the public +// key from the private key and by an ECDH agreement check. +func TestGenerateX25519KeyPair_MatchesXrayFormatAndCurve(t *testing.T) { + privB64, pubB64, err := generateX25519KeyPair() + if err != nil { + t.Fatalf("generateX25519KeyPair: %v", err) + } + + for name, k := range map[string]string{"private": privB64, "public": pubB64} { + if len(k) != 43 || strings.ContainsAny(k, "+/=") { + t.Fatalf("%s key %q is not 43-char raw base64url", name, k) + } + raw, err := base64.RawURLEncoding.DecodeString(k) + if err != nil { + t.Fatalf("%s key does not decode as raw base64url: %v", name, err) + } + if len(raw) != 32 { + t.Fatalf("%s key decodes to %d bytes, want 32", name, len(raw)) + } + } + + privRaw, _ := base64.RawURLEncoding.DecodeString(privB64) + pubRaw, _ := base64.RawURLEncoding.DecodeString(pubB64) + + priv, err := ecdh.X25519().NewPrivateKey(privRaw) + if err != nil { + t.Fatalf("NewPrivateKey: %v", err) + } + if got := priv.PublicKey().Bytes(); !bytes.Equal(got, pubRaw) { + t.Fatalf("public key does not correspond to private key:\n got %x\nwant %x", got, pubRaw) + } +} + +func TestGenerateX25519KeyPair_ECDHagreement(t *testing.T) { + privAB64, pubAB64, err := generateX25519KeyPair() + if err != nil { + t.Fatal(err) + } + privBB64, pubBB64, err := generateX25519KeyPair() + if err != nil { + t.Fatal(err) + } + if privAB64 == privBB64 || pubAB64 == pubBB64 { + t.Fatal("two generations produced identical keys") + } + + decode := func(s string) []byte { + raw, err := base64.RawURLEncoding.DecodeString(s) + if err != nil { + t.Fatal(err) + } + return raw + } + privA, _ := ecdh.X25519().NewPrivateKey(decode(privAB64)) + privB, _ := ecdh.X25519().NewPrivateKey(decode(privBB64)) + pubA, _ := ecdh.X25519().NewPublicKey(decode(pubAB64)) + pubB, _ := ecdh.X25519().NewPublicKey(decode(pubBB64)) + + secretAB, err := privA.ECDH(pubB) + if err != nil { + t.Fatal(err) + } + secretBA, err := privB.ECDH(pubA) + if err != nil { + t.Fatal(err) + } + if !bytes.Equal(secretAB, secretBA) { + t.Fatal("ECDH shared secrets differ") + } +}