diff --git a/README.md b/README.md index b7743b3..17e7c75 100644 --- a/README.md +++ b/README.md @@ -3,9 +3,10 @@ This repo has a few helpful crypto utilities that I've used or plan to use. --- -## Note +**Note:** + Use at your own risk! -While I've done my best to ensure that these functions are working as intended, I don't recommend using any of this for anything that could risk life, limb, property, or any serious material harm without extensive security review. +While I've done my best to ensure that this functionality is working as intended, I don't recommend using any of this for anything that could risk life, limb, property, or any serious material harm without extensive security review. If you suspect that a security issue exists, please notify me by creating an issue here on GitHub, and I will address it as soon as possible. If you are a security professional, I can always use another set of eyes to verify the security and integrity of these utilities. @@ -14,3 +15,9 @@ When the time comes where I am no longer maintaining this repository, either by If this is the case - and even before then - feel free to fork this repository and enhance as you see fit. --- + +## Packages +* **xor:** Provides some utilities for XOR screening, including an io.Reader and io.Writer implementation that screens in flight. + +## Applications +* **xorgen:** Provides a CLI that can be used with go:generate comments to easily embed XOR screened and compressed files. diff --git a/cmd/xorgen/internal/tmpl/screen_embed.go.tmpl b/cmd/xorgen/internal/tmpl/screen_embed.go.tmpl new file mode 100644 index 0000000..497aa7c --- /dev/null +++ b/cmd/xorgen/internal/tmpl/screen_embed.go.tmpl @@ -0,0 +1,58 @@ +// Code generated by xorgen, DO NOT EDIT. +package {{.Package}} + +import ( + "bytes" +{{- if .Compressed }} + "compress/gzip" + "github.com/saylorsolutions/gocryptx/pkg/xor" + "io" +{{- else }} + "github.com/saylorsolutions/gocryptx/pkg/xor" +{{- end }} +) + +var ( + key{{.FileMethodName}} = {{ .KeyString }} + data{{.FileMethodName}} = {{ .DataString }} + offset{{.FileMethodName}} = {{ .Offset }} +) + +func {{if .Exposed}}U{{else}}u{{end}}nscreen{{.FileMethodName}}() ([]byte, error) { + r, err := xor.NewReader(bytes.NewReader(data{{.FileMethodName}}), key{{.FileMethodName}}, offset{{.FileMethodName}}) + if err != nil { + return nil, err + } +{{- if .Compressed }} + uncompress, err := gzip.NewReader(r) + if err != nil { + return nil, err + } + var out bytes.Buffer + _, err = io.Copy(&out, uncompress) + if err != nil { + return nil, err + } + uncompress.Close() + return out.Bytes(), nil +{{- else }} + out := make([]byte, len(data)) + _, err = r.Read(out) + if err != nil { + return nil, err + } + return out, nil +{{- end }} +} + +func {{if .Exposed}}S{{else}}s{{end}}tream{{.FileMethodName}}() (io.Reader, error) { +{{- if .Compressed }} + r, err := xor.NewReader(bytes.NewReader(data{{.FileMethodName}}), key{{.FileMethodName}}, offset{{.FileMethodName}}) + if err != nil { + return nil, err + } + return gzip.NewReader(r) +{{- else }} + return xor.NewReader(bytes.NewReader(data), key, offset) +{{- end }} +} diff --git a/cmd/xorgen/internal/tmpl/template.go b/cmd/xorgen/internal/tmpl/template.go new file mode 100644 index 0000000..2509da8 --- /dev/null +++ b/cmd/xorgen/internal/tmpl/template.go @@ -0,0 +1,221 @@ +package tmpl + +import ( + "bytes" + "compress/gzip" + _ "embed" + "fmt" + "github.com/saylorsolutions/gocryptx/pkg/xor" + "io" + "os" + "path/filepath" + "regexp" + "text/template" + "unicode" +) + +const ( + idealMinKeyLen = 20 +) + +var ( + //go:embed screen_embed.go.tmpl + tmplText string + tmplTemplate = template.Must(template.New("template").Parse(tmplText)) +) + +type Params struct { + Package string + Exposed bool + Compressed bool + FileMethodName string + KeyString string + DataString string + Offset int + + keyData []byte + fileData []byte + targetFileName string +} + +// ParamOpt operates on Params in a standard and predictable way, and is used in GenerateFile. +// If any ParamOpt returns an error, then file generation ceases and the error is returned. +type ParamOpt = func(params *Params) error + +// CompressData indicates that data should be compressed. +func CompressData(val ...bool) ParamOpt { + return func(params *Params) error { + if len(val) > 0 { + params.Compressed = val[0] + return nil + } + params.Compressed = true + return nil + } +} + +// ExposeFunctions indicates that generated functions should be exposed. +func ExposeFunctions(val ...bool) ParamOpt { + return func(params *Params) error { + if len(val) > 0 { + params.Exposed = val[0] + return nil + } + params.Exposed = true + return nil + } +} + +// UseKeyOffset sets a key to be used instead of generating one randomly. +func UseKeyOffset(key []byte, offset int) ParamOpt { + return func(params *Params) error { + params.keyData = key + params.Offset = offset + return nil + } +} + +// RandomKey generates a random key and offset based on the payload size. +func RandomKey() ParamOpt { + return randomKey +} + +// GenerateFile will generate a file embedding the input file with XOR screening. +// Various generation options may be passed as zero or more ParamOpt. +func GenerateFile(input string, opts ...ParamOpt) error { + params := new(Params) + if err := populateContextData(params); err != nil { + return err + } + if err := populateFileData(params, input); err != nil { + return err + } + + for _, opt := range opts { + if err := opt(params); err != nil { + return err + } + } + + if len(params.keyData) == 0 { + if err := randomKey(params); err != nil { + return err + } + } + if err := screenData(params); err != nil { + return err + } + + out, err := os.Create(params.targetFileName + ".go") + if err != nil { + return err + } + defer func() { + _ = out.Close() + }() + + if err := tmplTemplate.Execute(out, params); err != nil { + return err + } + return nil +} + +func populateContextData(params *Params) error { + cwd, err := os.Getwd() + if err != nil { + return err + } + params.Package = filepath.Base(cwd) + return nil +} + +var ( + fileCleansePattern = regexp.MustCompile(`[^a-zA-Z0-9_]`) +) + +func populateFileData(params *Params, file string) error { + f, err := os.Open(file) + if err != nil { + return err + } + defer func() { + _ = f.Close() + }() + + data, err := io.ReadAll(f) + if err != nil { + return err + } + params.fileData = data + _, fname := filepath.Split(file) + params.FileMethodName = fileCleansePattern.ReplaceAllString(unicap(fname), "_") + params.targetFileName = fileCleansePattern.ReplaceAllString(fname, "_") + return nil +} + +func randomKey(params *Params) error { + var ( + key []byte + offset int + err error + length = len(params.fileData) + ) + switch { + case length > 3*idealMinKeyLen: + key, offset, err = xor.GenKeyAndOffset(length / 3) + case length > 2*idealMinKeyLen: + key, offset, err = xor.GenKeyAndOffset(length / 2) + default: + key, offset, err = xor.GenKeyAndOffset(length) + } + if err != nil { + return err + } + params.keyData = key + params.Offset = offset + return nil +} + +func screenData(params *Params) error { + var buf bytes.Buffer + + if params.Compressed { + var buf bytes.Buffer + w, err := gzip.NewWriterLevel(&buf, gzip.BestCompression) + if err != nil { + return err + } + _, err = w.Write(params.fileData) + if err != nil { + return err + } + if err := w.Close(); err != nil { + return err + } + params.fileData = buf.Bytes() + } + + w, err := xor.NewWriter(&buf, params.keyData, params.Offset) + if err != nil { + return err + } + _, err = w.Write(params.fileData) + if err != nil { + return err + } + params.KeyString = fmt.Sprintf("%#v", params.keyData) + params.DataString = fmt.Sprintf("%#v", buf.Bytes()) + return nil +} + +func unicap(s string) string { + runes := []rune(s) + switch len(runes) { + case 0: + return "" + case 1: + return string(unicode.ToUpper(runes[0])) + default: + return string(append([]rune{unicode.ToUpper(runes[0])}, runes[1:]...)) + } +} diff --git a/cmd/xorgen/internal/tmpl/test.txt b/cmd/xorgen/internal/tmpl/test.txt new file mode 100644 index 0000000..5a891f1 --- /dev/null +++ b/cmd/xorgen/internal/tmpl/test.txt @@ -0,0 +1 @@ +A test message that should be screened \ No newline at end of file diff --git a/cmd/xorgen/internal/tmpl/test_txt.go b/cmd/xorgen/internal/tmpl/test_txt.go new file mode 100644 index 0000000..f9f6547 --- /dev/null +++ b/cmd/xorgen/internal/tmpl/test_txt.go @@ -0,0 +1,41 @@ +// Code generated by xorgen, DO NOT EDIT. +package tmpl + +import ( + "bytes" + "compress/gzip" + "github.com/saylorsolutions/gocryptx/pkg/xor" + "io" +) + +var ( + keyTest_txt = []byte{0x9f, 0xfe, 0xfb, 0xc2, 0x8a, 0x3, 0x5c, 0x5f, 0x7d, 0x68, 0x42, 0x75, 0x50, 0x12, 0x9d, 0xec, 0xc3, 0x4f, 0xdf, 0x98, 0x75, 0x2d, 0xcc, 0xf3, 0xf3, 0xf4, 0x9b, 0xd3, 0x7, 0x92, 0xf0, 0xf1, 0x51, 0x63, 0x3a, 0xbf, 0x5f, 0x58} + dataTest_txt = []byte{0xee, 0xda, 0x6b, 0x3a, 0xbf, 0x5f, 0x58, 0x9f, 0xfc, 0x4, 0xb0, 0xde, 0x2b, 0x15, 0x72, 0x53, 0x39, 0x8a, 0x38, 0x7d, 0x3c, 0xd3, 0xa0, 0x8c, 0x1a, 0xf7, 0x51, 0x3d, 0x1, 0x9d, 0xdb, 0x3d, 0x3c, 0xb4, 0x1e, 0x4e, 0xc3, 0xb8, 0xbb, 0x4, 0x4b, 0x74, 0x91, 0x15, 0x15, 0x52, 0xb5, 0xb6, 0xc3, 0x8e, 0x3, 0x5c, 0xa0, 0x82, 0xc3, 0x57, 0x6e, 0xda, 0x34, 0x9d, 0xec, 0xc3} + offsetTest_txt = 31 +) + +func UnscreenTest_txt() ([]byte, error) { + r, err := xor.NewReader(bytes.NewReader(dataTest_txt), keyTest_txt, offsetTest_txt) + if err != nil { + return nil, err + } + uncompress, err := gzip.NewReader(r) + if err != nil { + return nil, err + } + var out bytes.Buffer + _, err = io.Copy(&out, uncompress) + if err != nil { + return nil, err + } + uncompress.Close() + return out.Bytes(), nil +} + +func StreamTest_txt() (io.Reader, error) { + r, err := xor.NewReader(bytes.NewReader(dataTest_txt), keyTest_txt, offsetTest_txt) + if err != nil { + return nil, err + } + return gzip.NewReader(r) +} diff --git a/cmd/xorgen/internal/tmpl/tmpl_test.go b/cmd/xorgen/internal/tmpl/tmpl_test.go new file mode 100644 index 0000000..a0e2dc5 --- /dev/null +++ b/cmd/xorgen/internal/tmpl/tmpl_test.go @@ -0,0 +1,24 @@ +//go:generate xorgen -Ec test.txt +package tmpl + +import ( + "github.com/stretchr/testify/assert" + "io" + "strings" + "testing" +) + +func TestUnscreenTest_txt(t *testing.T) { + data, err := UnscreenTest_txt() + assert.NoError(t, err) + assert.Equal(t, "A test message that should be screened", string(data)) +} + +func TestStreamTest_txt(t *testing.T) { + r, err := StreamTest_txt() + assert.NoError(t, err) + var buf strings.Builder + _, err = io.Copy(&buf, r) + assert.NoError(t, err) + assert.Equal(t, "A test message that should be screened", buf.String()) +} diff --git a/cmd/xorgen/main.go b/cmd/xorgen/main.go new file mode 100644 index 0000000..40a9536 --- /dev/null +++ b/cmd/xorgen/main.go @@ -0,0 +1,102 @@ +package main + +import ( + "bytes" + "encoding/hex" + "fmt" + "github.com/saylorsolutions/gocryptx/cmd/xorgen/internal/tmpl" + flag "github.com/spf13/pflag" + "io" + "os" + "strings" +) + +func main() { + var ( + helpFlag bool + exposedFlag bool + compressFlag bool + ) + flags := flag.NewFlagSet("xorgen", flag.ContinueOnError) + flags.BoolVarP(&helpFlag, "help", "h", false, "Prints this usage information.") + flags.BoolVarP(&exposedFlag, "exposed", "E", false, "Make the unscreen function exposed from the file. It's recommended to only expose from within an internal package.") + flags.BoolVarP(&compressFlag, "compressed", "c", false, "Payload should be gzip compressed when embedded, which includes a checksum to help prevent tampering.") + flags.Usage = func() { + fmt.Printf(` +xorgen generates code to embed XOR obfuscated (and optionally compressed) data by generating a *.go file based on the input file. This pairs well with go:generate comments. +The name of the generated Go file will be based on the name of the input file, replacing characters that match the regex pattern [^a-zA-Z0-9_] with "_". +For example, given a file called super-secret.txt, a Go file will be created in the current directory called super_secret_txt.go, containing a function called unscreenSuper_secret_txt. +See the -E flag below to make it an exposed function, and make sure you review the SECURITY notes below if you're unfamiliar with XOR screening. + +USAGE: xorgen FILE [KEY] + +Note: If a key argument is given, it will be used with offset 0. + +ARGS: + FILE is the input file to be embedded. + KEY is optional and may be specified to override secure random generation behavior. + +FLAGS: +%s +SECURITY: + This is not encryption, this is obfuscation, and they are very different things! +XOR screening is intended to hide embedded data from passive binary analysis only, since XOR screening is easily reversible. +It's noteworthy that using gzip compression could make part of the XOR key easier to recover, since the gzip header is somewhat predictable. +This isn't really important to the threat model of this obfuscation method, since the plain text key is stored right next to the screened data. +`, flags.FlagUsages()) + } + if len(os.Args) == 1 { + flags.Usage() + return + } + if err := flags.Parse(os.Args[1:]); err != nil { + flags.Usage() + fatal("Error parsing flags: %v", err) + } + if helpFlag { + flags.Usage() + return + } + + switch flags.NArg() { + case 0: + fatal("Missing required FILE argument") + case 1: + err := tmpl.GenerateFile( + flags.Arg(0), + tmpl.RandomKey(), + tmpl.CompressData(compressFlag), + tmpl.ExposeFunctions(exposedFlag), + ) + if err != nil { + fatal("Failed to generate file: %v", err) + } + default: + var key bytes.Buffer + _, err := io.Copy(&key, hex.NewDecoder(strings.NewReader(flags.Arg(1)))) + if err != nil { + fatal("Failed to decode KEY, must be a hex string with only the characters a-f, A-F, or 0-9") + } + err = tmpl.GenerateFile( + flags.Arg(0), + tmpl.UseKeyOffset(key.Bytes(), 0), + tmpl.CompressData(compressFlag), + tmpl.ExposeFunctions(exposedFlag), + ) + if err != nil { + fatal("Failed to generate file: %v", err) + } + } +} + +func fatal(msg string, args ...any) { + echo(msg, args...) + os.Exit(1) +} + +func echo(msg string, args ...any) { + if !strings.HasSuffix(msg, "\n") { + msg += "\n" + } + fmt.Printf(msg, args...) +} diff --git a/go.mod b/go.mod index 78c2c64..ffd7ee0 100644 --- a/go.mod +++ b/go.mod @@ -7,5 +7,6 @@ require github.com/stretchr/testify v1.8.4 require ( github.com/davecgh/go-spew v1.1.1 // indirect github.com/pmezard/go-difflib v1.0.0 // indirect + github.com/spf13/pflag v1.0.5 // indirect gopkg.in/yaml.v3 v3.0.1 // indirect ) diff --git a/go.sum b/go.sum index fa4b6e6..01fa189 100644 --- a/go.sum +++ b/go.sum @@ -2,6 +2,8 @@ github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/spf13/pflag v1.0.5 h1:iy+VFUOCP1a+8yFto/drg2CJ5u0yRoB7fZw3DKv/JXA= +github.com/spf13/pflag v1.0.5/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= github.com/stretchr/testify v1.8.4 h1:CcVxjf3Q8PM0mHUKJCdn+eZZtm5yQwehR5yeSVQQcUk= github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM=