From 4828ed827097e9a70904a315fa69bca014fceed1 Mon Sep 17 00:00:00 2001 From: Meo597 <197331664+Meo597@users.noreply.github.com> Date: Wed, 14 Oct 2026 08:14:05 +0800 Subject: [PATCH] cmd: add Ed25519 key generation and Lua script signing --- common/lua/privileged.go | 83 ++++++++++++++++++------- common/lua/privileged_sign_test.go | 82 +++++++++++++++++++++++++ common/lua/program_test.go | 23 ------- main/commands/all/commands.go | 3 + main/commands/all/ed25519.go | 53 ++++++++++++++++ main/commands/all/lua/lua.go | 13 ++++ main/commands/all/lua/sign.go | 98 ++++++++++++++++++++++++++++++ 7 files changed, 309 insertions(+), 46 deletions(-) create mode 100644 common/lua/privileged_sign_test.go create mode 100644 main/commands/all/ed25519.go create mode 100644 main/commands/all/lua/lua.go create mode 100644 main/commands/all/lua/sign.go diff --git a/common/lua/privileged.go b/common/lua/privileged.go index 5f6d6bdee..6a0abf95c 100644 --- a/common/lua/privileged.go +++ b/common/lua/privileged.go @@ -46,35 +46,72 @@ func decodePrivilegedValue(value string, size int) ([]byte, error) { } func verifyPrivilegedSignature(code []byte) (bool, error) { - for len(code) != 0 { - line, rest, _ := bytes.Cut(code, []byte{'\n'}) - code = rest + _, end, value, found := privilegedSignatureLine(code) + if !found { + return false, nil + } + + publicKey := privilegedKey.publicKey + if len(publicKey) == 0 { + return false, errors.New("Lua privileged signature requires --lua-privileged-pubkey") + } + signature, err := decodePrivilegedValue(string(bytes.TrimSpace(value)), ed25519.SignatureSize) + if err != nil { + return false, errors.New("invalid Lua privileged signature").Base(err) + } + digest := sha256.Sum256(bytes.TrimSpace(code[end:])) + if !ed25519.Verify(publicKey, digest[:], signature) { + return false, errors.New("Lua privileged signature verification failed") + } + return true, nil +} + +// SignPrivilegedScript signs the script body, then prepends or replaces the signature line. +func SignPrivilegedScript(code []byte, seed string) ([]byte, error) { + decoded, err := decodePrivilegedValue(seed, ed25519.SeedSize) + if err != nil { + return nil, errors.New("invalid Ed25519 private key seed").Base(err) + } + + start, end, _, found := privilegedSignatureLine(code) + body := code + if found { + body = code[end:] + } + + digest := sha256.Sum256(bytes.TrimSpace(body)) + signature := ed25519.Sign(ed25519.NewKeyFromSeed(decoded), digest[:]) + header := []byte("-- @xray:privileged-signature=" + base64.RawURLEncoding.EncodeToString(signature)) + + if !found { + if newline := bytes.IndexByte(code, '\n'); newline > 0 && code[newline-1] == '\r' { + header = append(header, '\r') + } + header = append(header, '\n') + return append(header, code...), nil + } + signed := make([]byte, 0, start+len(header)+len(code)-end) + signed = append(signed, code[:start]...) + signed = append(signed, header...) + return append(signed, code[end:]...), nil +} + +func privilegedSignatureLine(code []byte) (start, end int, value []byte, found bool) { + for rest := code; len(rest) != 0; { + start = len(code) - len(rest) + var line []byte + line, rest, _ = bytes.Cut(rest, []byte{'\n'}) line = bytes.TrimSuffix(line, []byte{'\r'}) if len(bytes.TrimSpace(line)) == 0 { continue } + end = start + len(line) line, found := bytes.CutPrefix(bytes.TrimLeft(line, " \t"), []byte("--")) if !found { - return false, nil + return 0, 0, nil, false } - value, found := bytes.CutPrefix(bytes.TrimLeft(line, " \t"), []byte("@xray:privileged-signature=")) - if !found { - return false, nil - } - - publicKey := privilegedKey.publicKey - if len(publicKey) == 0 { - return false, errors.New("Lua privileged signature requires --lua-privileged-pubkey") - } - signature, err := decodePrivilegedValue(string(bytes.TrimSpace(value)), ed25519.SignatureSize) - if err != nil { - return false, errors.New("invalid Lua privileged signature").Base(err) - } - digest := sha256.Sum256(bytes.TrimSpace(rest)) - if !ed25519.Verify(publicKey, digest[:], signature) { - return false, errors.New("Lua privileged signature verification failed") - } - return true, nil + value, found = bytes.CutPrefix(bytes.TrimLeft(line, " \t"), []byte("@xray:privileged-signature=")) + return start, end, value, found } - return false, nil + return 0, 0, nil, false } diff --git a/common/lua/privileged_sign_test.go b/common/lua/privileged_sign_test.go new file mode 100644 index 000000000..1938e3b97 --- /dev/null +++ b/common/lua/privileged_sign_test.go @@ -0,0 +1,82 @@ +package lua + +import ( + "bytes" + "crypto/ed25519" + "encoding/base64" + "encoding/hex" + "strings" + "testing" +) + +func privilegedSeed(t *testing.T) string { + t.Helper() + seed, err := hex.DecodeString(privilegedTestSeed) + if err != nil { + t.Fatal(err) + } + return base64.RawURLEncoding.EncodeToString(seed) +} + +func TestSignPrivilegedScriptPreservesSource(t *testing.T) { + setTestPrivilegedPublicKey(t, privilegedTestPublicKey) + seed := privilegedSeed(t) + for _, tc := range []struct { + name, prefix, header, body, newline string + }{ + {name: "unsigned", body: privilegedFixtureBody, newline: "\n"}, + {name: "unsigned boundary whitespace", body: " \t\n\n" + privilegedFixtureBody + " \t\n", newline: "\n"}, + {name: "unsigned CRLF", body: strings.ReplaceAll(privilegedFixtureBody, "\n", "\r\n"), newline: "\r\n"}, + {name: "replace invalid header", header: privilegedSignatureHeader + "%", body: "\n" + privilegedFixtureBody, newline: "\n"}, + {name: "replace indented header", prefix: " \t\r\n\n", header: " \t--\t @xray:privileged-signature=% \t", body: "\r\n \t\r\n" + privilegedFixtureBody + "\r\n", newline: "\r\n"}, + {name: "replace valid header", header: privilegedSignatureHeader + privilegedFixtureSignature, body: "\n" + privilegedFixtureBody, newline: "\n"}, + {name: "empty script", newline: "\n"}, + {name: "header without newline", header: privilegedSignatureHeader + "%"}, + {name: "ordinary comment before marker", body: "-- ordinary comment\n" + privilegedSignatureHeader + "%\nvalue = 1", newline: "\n"}, + } { + t.Run(tc.name, func(t *testing.T) { + code := []byte(tc.prefix + tc.header + tc.body) + original := bytes.Clone(code) + signed, err := SignPrivilegedScript(code, seed) + if err != nil { + t.Fatal(err) + } + if !bytes.Equal(code, original) { + t.Fatal("signing modified the input buffer") + } + body := tc.body + if tc.header != "" { + body = strings.TrimPrefix(body, tc.newline) + } + header := privilegedSignatureHeader + testSignature(t, body) + want := header + tc.newline + tc.body + if tc.header != "" { + want = tc.prefix + header + tc.body + } + if string(signed) != want { + t.Fatalf("signed source = %q, want %q", signed, want) + } + program, err := Compile(writeTestScript(t, string(signed))) + if err != nil || program == nil || !program.privileged { + t.Fatalf("signed source Compile = %v, %v; want privileged program", program, err) + } + again, err := SignPrivilegedScript(signed, seed) + if err != nil || !bytes.Equal(again, signed) { + t.Fatalf("resigning changed the source: %q, %v", again, err) + } + }) + } +} + +func TestSignPrivilegedScriptRejectsInvalidSeed(t *testing.T) { + seed := privilegedSeed(t) + for _, value := range []string{ + "", "%", seed + "=", + base64.RawURLEncoding.EncodeToString(make([]byte, ed25519.SeedSize-1)), + base64.RawURLEncoding.EncodeToString(make([]byte, ed25519.PrivateKeySize)), + } { + if signed, err := SignPrivilegedScript([]byte(privilegedFixtureBody), value); signed != nil || err == nil { + t.Fatalf("SignPrivilegedScript with invalid seed = %q, %v; want rejection", signed, err) + } + } +} diff --git a/common/lua/program_test.go b/common/lua/program_test.go index 0d65c23bc..d436201de 100644 --- a/common/lua/program_test.go +++ b/common/lua/program_test.go @@ -382,7 +382,6 @@ func TestProgramPrivilegedSignatureHeader(t *testing.T) { {"after code", "value = 1\n" + header + "\n" + privilegedFixtureBody, false}, {"inside string", `value = "` + header + `"`, false}, {"wrong marker", "-- xray:privileged-signature=" + privilegedFixtureSignature + "\n" + privilegedFixtureBody, false}, - {"old PSK first", "-- @xray:privileged-psk=secret\n" + header + "\n" + privilegedFixtureBody, false}, {"invalid marker after comment", "-- ordinary comment\n" + privilegedSignatureHeader + "%\nvalue = 1", false}, } { t.Run(tc.name, func(t *testing.T) { @@ -492,28 +491,6 @@ func TestProgramInlineSignaturesCannotAuthorize(t *testing.T) { } } -func TestProgramLegacyPSKCannotAuthorize(t *testing.T) { - setTestPrivilegedPublicKey(t, privilegedTestPublicKey) - t.Setenv("xray.lua.privileged.psk", "secret") - t.Setenv("XRAY_LUA_PRIVILEGED_PSK", "secret") - program, err := Compile(writeTestScript(t, "-- @xray:privileged-psk=secret\n"+privilegedFixtureBody)) - if err != nil || program == nil || program.privileged { - t.Fatalf("old PSK authorization = %v, %v; want sandbox", program, err) - } - L, err := program.NewState(context.Background(), nil, nil) - if err != nil { - t.Fatal(err) - } - defer L.Close() - if got := L.GetGlobal("os"); got != glua.LNil { - t.Fatalf("old PSK os = %v, want nil", got) - } - setTestPrivilegedPublicKey(t, "") - if program, err := Compile(writeTestScript(t, privilegedSignatureHeader+privilegedFixtureSignature+"\n"+privilegedFixtureBody)); program != nil || err == nil { - t.Fatalf("legacy environment substituted for public key: %v, %v", program, err) - } -} - func TestProgramSignedDiagnostics(t *testing.T) { setTestPrivilegedPublicKey(t, privilegedTestPublicKey) const invalidBody = "\n\nlocal = 1" diff --git a/main/commands/all/commands.go b/main/commands/all/commands.go index be2771da9..6cce71141 100644 --- a/main/commands/all/commands.go +++ b/main/commands/all/commands.go @@ -4,6 +4,7 @@ import ( "github.com/xtls/xray-core/main/commands/all/api" "github.com/xtls/xray-core/main/commands/all/base64" "github.com/xtls/xray-core/main/commands/all/convert" + "github.com/xtls/xray-core/main/commands/all/lua" "github.com/xtls/xray-core/main/commands/all/tls" "github.com/xtls/xray-core/main/commands/base" ) @@ -14,9 +15,11 @@ func init() { api.CmdAPI, base64.CmdBase64, convert.CmdConvert, + lua.CmdLua, tls.CmdTLS, cmdUUID, cmdX25519, + cmdEd25519, cmdWG, cmdMLDSA65, cmdMLKEM768, diff --git a/main/commands/all/ed25519.go b/main/commands/all/ed25519.go new file mode 100644 index 000000000..c23fe0d96 --- /dev/null +++ b/main/commands/all/ed25519.go @@ -0,0 +1,53 @@ +package all + +import ( + "crypto/ed25519" + "crypto/rand" + "encoding/base64" + "fmt" + + "github.com/xtls/xray-core/main/commands/base" +) + +var cmdEd25519 = &base.Command{ + UsageLine: `{{.Exec}} ed25519 [-i "private key seed (Base64URL)"]`, + Short: "Generate an Ed25519 key pair", + Long: ` +Generate an Ed25519 key pair, or restore it from a 32-byte private key seed +with -i. PrivateKey (seed) and PublicKey use unpadded Base64URL. +`, +} + +var inputEd25519 = cmdEd25519.Flag.String("i", "", "Ed25519 private key seed in unpadded Base64URL.") + +func init() { + cmdEd25519.Run = executeEd25519 +} + +func executeEd25519(cmd *base.Command, args []string) { + if len(args) != 0 { + cmd.Usage() + } + + var privateKey ed25519.PrivateKey + if *inputEd25519 == "" { + _, key, err := ed25519.GenerateKey(rand.Reader) + if err != nil { + base.Fatalf("failed to generate Ed25519 key: %s", err) + } + privateKey = key + } else { + encoding := base64.RawURLEncoding.Strict() + seed, err := encoding.DecodeString(*inputEd25519) + if err != nil || len(seed) != ed25519.SeedSize || encoding.EncodeToString(seed) != *inputEd25519 { + base.Fatalf("invalid Ed25519 private key seed: expected 32 bytes in unpadded Base64URL") + } + privateKey = ed25519.NewKeyFromSeed(seed) + } + + if _, err := fmt.Printf("PrivateKey: %s\nPublicKey: %s\n", + base64.RawURLEncoding.EncodeToString(privateKey.Seed()), + base64.RawURLEncoding.EncodeToString(privateKey.Public().(ed25519.PublicKey))); err != nil { + base.Fatalf("failed to write Ed25519 keys: %s", err) + } +} diff --git a/main/commands/all/lua/lua.go b/main/commands/all/lua/lua.go new file mode 100644 index 000000000..d5ff26082 --- /dev/null +++ b/main/commands/all/lua/lua.go @@ -0,0 +1,13 @@ +package lua + +import "github.com/xtls/xray-core/main/commands/base" + +// CmdLua holds the Lua script commands. +var CmdLua = &base.Command{ + UsageLine: "{{.Exec}} lua", + Short: "Manage Lua scripts", + Long: "{{.Exec}} {{.LongName}} provides tools for local Lua scripts.", + Commands: []*base.Command{ + cmdSign, + }, +} diff --git a/main/commands/all/lua/sign.go b/main/commands/all/lua/sign.go new file mode 100644 index 000000000..a300e1540 --- /dev/null +++ b/main/commands/all/lua/sign.go @@ -0,0 +1,98 @@ +package lua + +import ( + "os" + "path/filepath" + + xlua "github.com/xtls/xray-core/common/lua" + "github.com/xtls/xray-core/main/commands/base" +) + +var cmdSign = &base.Command{ + UsageLine: "{{.Exec}} lua sign [-key seed] [-o output-file] ", + Short: "Sign a local Lua script for privileged execution", + Long: ` +Sign a local Lua file using a 32-byte Ed25519 private key seed in unpadded +Base64URL. Use {{.Exec}} ed25519 to generate a key pair. +Write back to the input file, or use -o to write another file (- for stdout). +`, +} + +var ( + signKey = cmdSign.Flag.String("key", "", "Ed25519 private key seed in unpadded Base64URL.") + signOutput = cmdSign.Flag.String("o", "", "Output file, or - for stdout. Default: input file.") +) + +func init() { + cmdSign.Run = executeSign +} + +func executeSign(cmd *base.Command, args []string) { + if len(args) != 1 { + cmd.Usage() + } + + info, err := os.Stat(args[0]) + if err != nil { + base.Fatalf("failed to inspect Lua file: %s", err) + } + if !info.Mode().IsRegular() { + base.Fatalf("Lua input is not a regular file: %s", args[0]) + } + + code, err := os.ReadFile(args[0]) + if err != nil { + base.Fatalf("failed to read Lua file: %s", err) + } + + signed, err := xlua.SignPrivilegedScript(code, *signKey) + if err != nil { + base.Fatalf("failed to sign Lua file: %s", err) + } + + if *signOutput == "-" { + if _, err := os.Stdout.Write(signed); err != nil { + base.Fatalf("failed to write signed Lua file: %s", err) + } + return + } + output := *signOutput + if output == "" { + output = args[0] + } + if err := writeSignedFile(output, signed, info.Mode().Perm()); err != nil { + base.Fatalf("failed to write signed Lua file: %s", err) + } +} + +func writeSignedFile(path string, code []byte, mode os.FileMode) error { + if info, err := os.Stat(path); err == nil { + if !info.Mode().IsRegular() { + return &os.PathError{Op: "write", Path: path, Err: os.ErrInvalid} + } + mode = info.Mode().Perm() + path, err = filepath.EvalSymlinks(path) + if err != nil { + return err + } + } else if !os.IsNotExist(err) { + return err + } + + file, err := os.CreateTemp(filepath.Dir(path), ".xray-lua-sign-*") + if err != nil { + return err + } + defer os.Remove(file.Name()) + defer file.Close() + if _, err := file.Write(code); err != nil { + return err + } + if err := file.Chmod(mode); err != nil { + return err + } + if err := file.Close(); err != nil { + return err + } + return os.Rename(file.Name(), path) +}