diff --git a/cmd/dump.go b/cmd/dump.go index 74ed694..48ae09f 100644 --- a/cmd/dump.go +++ b/cmd/dump.go @@ -2,7 +2,9 @@ package cmd import ( "context" + "encoding/base64" "fmt" + "os" "strings" "github.com/google/uuid" @@ -13,6 +15,7 @@ import ( "github.com/databacker/api/go/api" "github.com/databacker/mysql-backup/pkg/compression" "github.com/databacker/mysql-backup/pkg/core" + "github.com/databacker/mysql-backup/pkg/encrypt" "github.com/databacker/mysql-backup/pkg/storage" "github.com/databacker/mysql-backup/pkg/util" ) @@ -132,6 +135,66 @@ func dumpCmd(passedExecs execs, cmdConfig *cmdConfiguration) (*cobra.Command, er } } + // encryption algorithm: check config, then CLI/env var overrides + var ( + encryptionAlgo string + encryptionKey []byte + encryptor encrypt.Encryptor + ) + if cmdConfig.configuration != nil && dumpConfig != nil && dumpConfig.Encryption != nil { + if dumpConfig.Encryption.Algorithm == nil { + return fmt.Errorf("encryption algorithm must be set in config file") + } + encryptionAlgo = string(*dumpConfig.Encryption.Algorithm) + switch { + case dumpConfig.Encryption.Key != nil && *dumpConfig.Encryption.Key != "" && dumpConfig.Encryption.KeyPath != nil && *dumpConfig.Encryption.KeyPath != "": + return fmt.Errorf("encryption key and path cannot both be set in config file") + case dumpConfig.Encryption.Key != nil && *dumpConfig.Encryption.Key == "" && dumpConfig.Encryption.KeyPath != nil && *dumpConfig.Encryption.KeyPath == "": + return fmt.Errorf("must set at least one of encryption key or path in config file") + case dumpConfig.Encryption.Key != nil && *dumpConfig.Encryption.Key != "": + encryptionKey, err = base64.StdEncoding.DecodeString(*dumpConfig.Encryption.Key) + if err != nil { + return fmt.Errorf("error decoding encryption key from config file: %v", err) + } + case dumpConfig.Encryption.KeyPath != nil && *dumpConfig.Encryption.KeyPath != "": + key, err := os.ReadFile(*dumpConfig.Encryption.KeyPath) + if err != nil { + return fmt.Errorf("error reading encryption key from path: %v", err) + } + encryptionKey = key + } + } + encryptionVar := v.GetString("encryption") + if encryptionVar != "" { + encryptionAlgo = encryptionVar + } + if encryptionAlgo != "" { + keyContent := v.GetString("encryption-key") + keyPath := v.GetString("encryption-key-path") + switch { + case keyContent != "" && keyPath != "": + return fmt.Errorf("encryption key and path cannot both be set in CLI") + case keyContent == "" && keyPath == "": + return fmt.Errorf("must set at least one of encryption key or path in CLI") + case keyContent != "": + encryptionKey, err = base64.StdEncoding.DecodeString(keyContent) + if err != nil { + return fmt.Errorf("error decoding encryption key from CLI flag: %v", err) + } + case keyPath != "": + key, err := os.ReadFile(keyPath) + if err != nil { + return fmt.Errorf("error reading encryption key from path: %v", err) + } + encryptionKey = key + } + + encryptor, err = encrypt.GetEncryptor(encryptionAlgo, encryptionKey) + if err != nil { + return fmt.Errorf("failure to get encryptor '%s': %v", encryptionAlgo, err) + } + } + // retention, if enabled retention := v.GetString("retention") if retention == "" && cmdConfig.configuration != nil && cmdConfig.configuration.Prune != nil && cmdConfig.configuration.Prune.Retention != nil { @@ -173,6 +236,7 @@ func dumpCmd(passedExecs execs, cmdConfig *cmdConfiguration) (*cobra.Command, er DBNames: include, DBConn: cmdConfig.dbconn, Compressor: compressor, + Encryptor: encryptor, Exclude: exclude, PreBackupScripts: preBackupScripts, PostBackupScripts: postBackupScripts, @@ -262,6 +326,10 @@ S3: If it is a URL of the format s3://bucketname/path then it will connect via S // retention flags.String("retention", "", "Retention period for backups. Optional. If not specified, no pruning will be done. Can be number of backups or time-based. For time-based, the format is: 1d, 1w, 1m, 1y for days, weeks, months, years, respectively. For number-based, the format is: 1c, 2c, 3c, etc. for the count of backups to keep.") + // encryption options + flags.String("encryption", "", fmt.Sprintf("Encryption algorithm to use, none if blank. Supported are: %s. Format must match the specific algorithm.", strings.Join(encrypt.All, ", "))) + flags.String("encryption-key", "", "Encryption key to use, base64-encoded. Useful for debugging, not recommended for production. If encryption is enabled, and both are provided or neither is provided, returns an error.") + flags.String("encryption-key-path", "", "Path to encryption key file. If encryption is enabled, and both are provided or neither is provided, returns an error.") return cmd, nil } diff --git a/go.mod b/go.mod index 0b1df9f..3feb0fb 100644 --- a/go.mod +++ b/go.mod @@ -31,7 +31,7 @@ require ( ) require ( - github.com/databacker/api/go/api v0.0.0-20241202154620-01b0380f21cb + github.com/databacker/api/go/api v0.0.0-20250418100420-12e1adda1303 github.com/google/go-cmp v0.6.0 go.opentelemetry.io/otel v1.31.0 go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.19.0 @@ -41,10 +41,13 @@ require ( ) require ( + filippo.io/age v1.2.1 // indirect + github.com/InfiniteLoopSpace/go_S-MIME v0.0.0-20181221134359-3f58f9a4b2b6 // indirect github.com/cenkalti/backoff/v4 v4.2.1 // indirect github.com/containerd/log v0.1.0 // indirect github.com/distribution/reference v0.6.0 // indirect github.com/felixge/httpsnoop v1.0.3 // indirect + github.com/github/smimesign v0.2.0 // indirect github.com/go-logr/logr v1.4.2 // indirect github.com/go-logr/stdr v1.2.2 // indirect github.com/golang/protobuf v1.5.4 // indirect @@ -107,11 +110,11 @@ require ( github.com/spf13/jwalterweatherman v1.0.0 // indirect github.com/stretchr/objx v0.5.2 // indirect github.com/subosito/gotenv v1.2.0 // indirect - golang.org/x/crypto v0.36.0 + golang.org/x/crypto v0.37.0 golang.org/x/net v0.38.0 // indirect - golang.org/x/sys v0.31.0 // indirect - golang.org/x/text v0.23.0 // indirect - golang.org/x/tools v0.21.1-0.20240508182429-e35e4ccd0d2d // indirect + golang.org/x/sys v0.32.0 // indirect + golang.org/x/text v0.24.0 // indirect + golang.org/x/tools v0.22.0 // indirect gopkg.in/ini.v1 v1.51.0 // indirect gopkg.in/yaml.v2 v2.4.0 // indirect gotest.tools/v3 v3.4.0 // indirect diff --git a/go.sum b/go.sum index 1d6129f..42323ca 100644 --- a/go.sum +++ b/go.sum @@ -1,9 +1,13 @@ cloud.google.com/go v0.26.0/go.mod h1:aQUYkXzVsufM+DwF1aE+0xfcU+56JwCaLick0ClmMTw= +filippo.io/age v1.2.1 h1:X0TZjehAZylOIj4DubWYU1vWQxv9bJpo+Uu2/LGhi1o= +filippo.io/age v1.2.1/go.mod h1:JL9ew2lTN+Pyft4RiNGguFfOpewKwSHm5ayKD/A4004= github.com/AdaLogics/go-fuzz-headers v0.0.0-20230811130428-ced1acdcaa24 h1:bvDV9vkmnHYOMsOr4WLk+Vo07yKIzd94sVoIqshQ4bU= github.com/AdaLogics/go-fuzz-headers v0.0.0-20230811130428-ced1acdcaa24/go.mod h1:8o94RPi1/7XTJvwPpRSzSUedZrtlirdB3r9Z20bi2f8= github.com/Azure/go-ansiterm v0.0.0-20210617225240-d185dfc1b5a1 h1:UQHMgLO+TxOElx5B5HZ4hJQsoJ/PvUvKRhJHDQXO8P8= github.com/Azure/go-ansiterm v0.0.0-20210617225240-d185dfc1b5a1/go.mod h1:xomTg63KZ2rFqZQzSB4Vz2SUXa1BpHTVz9L5PTmPC4E= github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU= +github.com/InfiniteLoopSpace/go_S-MIME v0.0.0-20181221134359-3f58f9a4b2b6 h1:TkEaE2dfSBN9onWsQ1pC9EVMmVDJqkYWNUwS6+EYxlM= +github.com/InfiniteLoopSpace/go_S-MIME v0.0.0-20181221134359-3f58f9a4b2b6/go.mod h1:yhh4MGRGdTpTET5RhSJx4XNCEkJljP3k8MxTTB3joQA= github.com/Microsoft/go-winio v0.6.2 h1:F2VQgta7ecxGYO8k3ZZz3RS8fVIXVxONVUPlNERoyfY= github.com/Microsoft/go-winio v0.6.2/go.mod h1:yd8OoFMLzJbo9gZq8j5qaps8bJ9aShtEA8Ipt1oGCvU= github.com/Microsoft/hcsshim v0.11.7 h1:vl/nj3Bar/CvJSYo7gIQPyRWc9f3c6IeSNavBTSZNZQ= @@ -56,6 +60,7 @@ github.com/beorn7/perks v0.0.0-20180321164747-3a771d992973/go.mod h1:Dwedo/Wpr24 github.com/beorn7/perks v1.0.0/go.mod h1:KWe93zE9D1o94FZ5RNwFwVgaQK1VOXiVxmqh+CedLV8= github.com/cenkalti/backoff/v4 v4.2.1 h1:y4OZtCnogmCPw98Zjyt5a6+QwPLGkiQsYW5oUqylYbM= github.com/cenkalti/backoff/v4 v4.2.1/go.mod h1:Y3VNntkOUPxTVeUxJ/G5vcM//AlwfmyYozVcomhLiZE= +github.com/certifi/gocertifi v0.0.0-20180118203423-deb3ae2ef261/go.mod h1:GJKEexRPVJrBSOjoqN5VNOIKJ5Q3RViH6eu3puDRwx4= github.com/cespare/xxhash v1.1.0/go.mod h1:XrSqR1VqqWfGrhpAt58auRo0WTKS1nRRg3ghfAqPWnc= github.com/client9/misspell v0.3.4/go.mod h1:qj6jICC3Q7zFZvVWo7KLAzC3yx5G7kyvSDkc90ppPyw= github.com/cloudsoda/go-smb2 v0.0.0-20231106205947-b0758ecc4c67 h1:KzZU0EMkUm4vX/jPp5d/VttocDpocL/8QP0zyiI9Xiw= @@ -72,6 +77,10 @@ github.com/coreos/pkg v0.0.0-20180928190104-399ea9e2e55f/go.mod h1:E3G3o1h8I7cfc github.com/cpuguy83/go-md2man/v2 v2.0.3/go.mod h1:tgQtvFlXSQOSOSIRvRPT7W67SCa46tRHOmNcaadrF8o= github.com/databacker/api/go/api v0.0.0-20241202154620-01b0380f21cb h1:9PthuA+o1wBZuTkNc2LLXQfI5+Myy+ok8nD3bQzd7DA= github.com/databacker/api/go/api v0.0.0-20241202154620-01b0380f21cb/go.mod h1:bQhbl71Lk1ATni0H+u249hjoQ8ShAdVNcNjnw6z+SbE= +github.com/databacker/api/go/api v0.0.0-20250418091750-e67e3226ca5f h1:vuPsDEgli1S6khpEwY721epJnZiFtPSPHuxyMz9SJUY= +github.com/databacker/api/go/api v0.0.0-20250418091750-e67e3226ca5f/go.mod h1:bQhbl71Lk1ATni0H+u249hjoQ8ShAdVNcNjnw6z+SbE= +github.com/databacker/api/go/api v0.0.0-20250418100420-12e1adda1303 h1:TVLyJzdvDvWIEs1/v6G0rQPpZeUsArQ7skzicjfCV8I= +github.com/databacker/api/go/api v0.0.0-20250418100420-12e1adda1303/go.mod h1:bQhbl71Lk1ATni0H+u249hjoQ8ShAdVNcNjnw6z+SbE= github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 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= @@ -96,6 +105,8 @@ github.com/fsnotify/fsnotify v1.6.0/go.mod h1:sl3t1tCWJFWoRz9R8WJCbQihKKwmorjAbS github.com/geoffgarside/ber v1.1.0 h1:qTmFG4jJbwiSzSXoNJeHcOprVzZ8Ulde2Rrrifu5U9w= github.com/geoffgarside/ber v1.1.0/go.mod h1:jVPKeCbj6MvQZhwLYsGwaGI52oUorHoHKNecGT85ZCc= github.com/ghodss/yaml v1.0.0/go.mod h1:4dBDuWmgqj2HViK6kFavaiC9ZROes6MMH2rRYeMEF04= +github.com/github/smimesign v0.2.0 h1:Hho4YcX5N1I9XNqhq0fNx0Sts8MhLonHd+HRXVGNjvk= +github.com/github/smimesign v0.2.0/go.mod h1:iZiiwNT4HbtGRVqCQu7uJPEZCuEE5sfSSttcnePkDl4= github.com/go-kit/kit v0.8.0/go.mod h1:xBxKIO96dXMWWy0MnWVtmwkA9/13aqxPnvrjFYMA2as= github.com/go-logfmt/logfmt v0.3.0/go.mod h1:Qt1PoO58o5twSAckw1HlFXLmHsOX5/0LbT9GBnD5lWE= github.com/go-logfmt/logfmt v0.4.0/go.mod h1:3RMwSq7FuexP4Kalkev3ejPJsZTpXXBr9+V4qmtdjCk= @@ -198,10 +209,12 @@ github.com/opencontainers/go-digest v1.0.0 h1:apOUWs51W5PlhuyGyz9FCeeBIOUDA/6nW8 github.com/opencontainers/go-digest v1.0.0/go.mod h1:0JzlMkj0TRzQZfJkVvzbP0HBR3IKzErnv2BNG4W4MAM= github.com/opencontainers/image-spec v1.1.0 h1:8SG7/vwALn54lVB/0yZ/MMwhFrPYtpEHQb2IpWsCzug= github.com/opencontainers/image-spec v1.1.0/go.mod h1:W4s4sFTMaBeK1BQLXbG4AdM2szdn85PY75RI83NrTrM= +github.com/pborman/getopt v0.0.0-20180811024354-2b5b3bfb099b/go.mod h1:85jBQOZwpVEaDAr341tbn15RS4fCAsIst0qp7i8ex1o= github.com/pelletier/go-toml v1.2.0/go.mod h1:5z9KED0ma1S8pY6P1sdut58dfprrGBbd/94hg7ilaic= github.com/pelletier/go-toml v1.9.5 h1:4yBQzkHv+7BHq2PQUZF3Mx0IYxG7LsP222s7Agd3ve8= github.com/pelletier/go-toml v1.9.5/go.mod h1:u1nR/EPcESfeI/szUZKdtJ0xRNbUoANCkoOuaOx1Y+c= github.com/pkg/errors v0.8.0/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= +github.com/pkg/errors v0.8.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= @@ -293,11 +306,14 @@ go.uber.org/multierr v1.1.0/go.mod h1:wR5kodmAFQ0UK8QlbwjlSNy0Z68gJhDJUG5sjR94q/ go.uber.org/zap v1.10.0/go.mod h1:vwi/ZaCAaUcBkycHslxD9B2zi4UTXhF60s6SWpuDF0Q= golang.org/x/crypto v0.0.0-20180904163835-0709b304e793/go.mod h1:6SG95UA2DQfeDnfUPMdvaQW0Q7yPrPDi9nlGo2tz2b4= golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= +golang.org/x/crypto v0.0.0-20190426145343-a29dc8fdc734/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= golang.org/x/crypto v0.36.0 h1:AnAEvhDddvBdpY+uR+MyHmuZzzNqXSe/GvuDeob5L34= golang.org/x/crypto v0.36.0/go.mod h1:Y4J0ReaxCR1IMaabaSMugxJES1EpwhBHhv2bDHklZvc= +golang.org/x/crypto v0.37.0 h1:kJNSjF/Xp7kU0iB2Z+9viTPMW4EqqsrywMXLJOOsXSE= +golang.org/x/crypto v0.37.0/go.mod h1:vg+k43peMZ0pUMhYmVAWysMK35e6ioLh3wB8ZCAfbVc= golang.org/x/lint v0.0.0-20181026193005-c67002cb31c3/go.mod h1:UVdnD1Gm6xHRNCYTkRU2/jEulfH38KcIWyp/GAMgvoE= golang.org/x/lint v0.0.0-20190313153728-d0100b6bd8b3/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc= golang.org/x/mod v0.2.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= @@ -350,6 +366,8 @@ golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.7.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.31.0 h1:ioabZlmFYtWhL+TRYpcnNlLwhyxaM9kWTDEmfnprqik= golang.org/x/sys v0.31.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k= +golang.org/x/sys v0.32.0 h1:s77OFDvIQeibCmezSnk/q6iAfkdiQaJi4VzroCFrN20= +golang.org/x/sys v0.32.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k= golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= golang.org/x/term v0.1.0/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= @@ -363,6 +381,8 @@ golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8= golang.org/x/text v0.9.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8= golang.org/x/text v0.23.0 h1:D71I7dUrlY+VX0gQShAThNGHFxZ13dGLBHQLVl1mJlY= golang.org/x/text v0.23.0/go.mod h1:/BLNzu4aZCJ1+kcD0DNRotWKage4q2rGVAg4o22unh4= +golang.org/x/text v0.24.0 h1:dd5Bzh4yt5KYA8f9CJHCP4FB4D51c2c6JvN37xJJkJ0= +golang.org/x/text v0.24.0/go.mod h1:L8rBsPeo2pSS+xqN0d5u2ikmjtmoJbDBT1b7nHvFCdU= golang.org/x/time v0.0.0-20190308202827-9d24e82272b4/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= golang.org/x/time v0.0.0-20220210224613-90d013bbcef8 h1:vVKdlvoWBphwdxWKrFZEuM0kGgGLxUOYcY4U/2Vjg44= golang.org/x/time v0.0.0-20220210224613-90d013bbcef8/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= @@ -381,6 +401,7 @@ golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU= golang.org/x/tools v0.8.0/go.mod h1:JxBZ99ISMI5ViVkT1tr6tdNmXeTrcpVSD3vZ1RsRdN4= golang.org/x/tools v0.21.1-0.20240508182429-e35e4ccd0d2d h1:vU5i/LfpvrRCpgM/VPfJLg5KjxD3E+hfT1SH+d9zLwg= golang.org/x/tools v0.21.1-0.20240508182429-e35e4ccd0d2d/go.mod h1:aiJjzUbINMkxbQROHiO6hDPo2LHcIPhhQsa9DLh0yGk= +golang.org/x/tools v0.22.0/go.mod h1:aCwcsjqvq7Yqt6TNyX7QMU2enbQ/Gt0bo6krSeEri+c= golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= diff --git a/pkg/config/process.go b/pkg/config/process.go index 2a67428..8129ba1 100644 --- a/pkg/config/process.go +++ b/pkg/config/process.go @@ -181,9 +181,9 @@ func decryptConfig(spec api.EncryptedSpec, credentials []string) (api.Config, er hkdfReader := hkdf.New(sha256.New, sharedSecret[:], nil, []byte(api.SymmetricKey)) var symmetricKeySize int switch *spec.Algorithm { - case api.AesGcm256: + case api.EncryptedSpecAlgorithmAes256Gcm: symmetricKeySize = 32 - case api.Chacha20Poly1305: + case api.EncryptedSpecAlgorithmChacha20Poly1305: symmetricKeySize = 32 default: return plainConfig, fmt.Errorf("unsupported algorithm: %s", *spec.Algorithm) @@ -202,7 +202,7 @@ func decryptConfig(spec api.EncryptedSpec, credentials []string) (api.Config, er return plainConfig, fmt.Errorf("failed to decode encrypted data: %w", err) } switch *spec.Algorithm { - case api.AesGcm256: + case api.EncryptedSpecAlgorithmAes256Gcm: // Decrypt with AES-GCM block, err := aes.NewCipher(symmetricKey) if err != nil { @@ -212,7 +212,7 @@ func decryptConfig(spec api.EncryptedSpec, credentials []string) (api.Config, er if err != nil { return plainConfig, fmt.Errorf("failed to initialize AES-GCM: %w", err) } - case api.Chacha20Poly1305: + case api.EncryptedSpecAlgorithmChacha20Poly1305: // Decrypt with ChaCha20Poly1305 aead, err = chacha20poly1305.New(symmetricKey) if err != nil { diff --git a/pkg/config/process_test.go b/pkg/config/process_test.go index 60f5188..1869575 100644 --- a/pkg/config/process_test.go +++ b/pkg/config/process_test.go @@ -169,7 +169,7 @@ func TestDecryptConfig(t *testing.T) { // Embed the nonce in the ciphertext fullCiphertext := append(nonce, ciphertext...) - algo := api.AesGcm256 + algo := api.EncryptedSpecAlgorithmAes256Gcm data := base64.StdEncoding.EncodeToString(fullCiphertext) // this is a valid spec, we want to be able to change fields diff --git a/pkg/core/dump.go b/pkg/core/dump.go index 229988d..de50488 100644 --- a/pkg/core/dump.go +++ b/pkg/core/dump.go @@ -34,6 +34,7 @@ func (e *Executor) Dump(ctx context.Context, opts DumpOptions) (DumpResults, err dbnames := opts.DBNames dbconn := opts.DBConn compressor := opts.Compressor + encryptor := opts.Encryptor compact := opts.Compact suppressUseDatabase := opts.SuppressUseDatabase maxAllowedPacket := opts.MaxAllowedPacket @@ -132,6 +133,14 @@ func (e *Executor) Dump(ctx context.Context, opts DumpOptions) (DumpResults, err tarSpan.End() return results, fmt.Errorf("failed to create compressor: %v", err) } + if encryptor != nil { + cw, err = encryptor.Encrypt(cw) + if err != nil { + tarSpan.SetStatus(codes.Error, err.Error()) + tarSpan.End() + return results, fmt.Errorf("failed to create encryptor: %v", err) + } + } if err := archive.Tar(workdir, cw); err != nil { tarSpan.SetStatus(codes.Error, err.Error()) tarSpan.End() diff --git a/pkg/core/dumpoptions.go b/pkg/core/dumpoptions.go index ae71ad9..82883f7 100644 --- a/pkg/core/dumpoptions.go +++ b/pkg/core/dumpoptions.go @@ -3,6 +3,7 @@ package core import ( "github.com/databacker/mysql-backup/pkg/compression" "github.com/databacker/mysql-backup/pkg/database" + "github.com/databacker/mysql-backup/pkg/encrypt" "github.com/databacker/mysql-backup/pkg/storage" "github.com/google/uuid" ) @@ -13,6 +14,7 @@ type DumpOptions struct { DBNames []string DBConn database.Connection Compressor compression.Compressor + Encryptor encrypt.Encryptor Exclude []string PreBackupScripts string PostBackupScripts string diff --git a/pkg/encrypt/aes256cbc.go b/pkg/encrypt/aes256cbc.go new file mode 100644 index 0000000..a25567e --- /dev/null +++ b/pkg/encrypt/aes256cbc.go @@ -0,0 +1,196 @@ +package encrypt + +import ( + "bytes" + "crypto/aes" + "crypto/cipher" + "crypto/rand" + "fmt" + "io" +) + +var _ Encryptor = &AES256CBC{} + +type AES256CBC struct { + key []byte + iv []byte + prependIV bool +} + +func NewAES256CBC(key, iv []byte, prependIV bool) (*AES256CBC, error) { + if len(key) != 32 { + return nil, fmt.Errorf("key must be 32 bytes") + } + return &AES256CBC{ + key: key, + iv: iv, + prependIV: prependIV, + }, nil +} + +type cbcEncryptWriter struct { + writer io.Writer + mode cipher.BlockMode + buf []byte + closed bool +} + +func (w *cbcEncryptWriter) Write(p []byte) (int, error) { + total := 0 + for len(p) > 0 { + n := aes.BlockSize - len(w.buf) + if n > len(p) { + n = len(p) + } + w.buf = append(w.buf, p[:n]...) + p = p[n:] + total += n + + if len(w.buf) == aes.BlockSize { + block := make([]byte, aes.BlockSize) + w.mode.CryptBlocks(block, w.buf) + if _, err := w.writer.Write(block); err != nil { + return total, err + } + w.buf = w.buf[:0] + } + } + return total, nil +} + +func (w *cbcEncryptWriter) Close() error { + if w.closed { + return nil + } + w.closed = true + + // PKCS#7 padding + padLen := aes.BlockSize - len(w.buf)%aes.BlockSize + padding := bytes.Repeat([]byte{byte(padLen)}, padLen) + w.buf = append(w.buf, padding...) + + // Encrypt all remaining blocks in w.buf + out := make([]byte, len(w.buf)) + w.mode.CryptBlocks(out, w.buf) + + // Write to the underlying writer + _, err := w.writer.Write(out) + return err +} + +type cbcDecryptWriter struct { + writer io.Writer + block cipher.Block + mode cipher.BlockMode + buf []byte + iv []byte + closed bool + readIV bool +} + +func (w *cbcDecryptWriter) Write(p []byte) (int, error) { + total := 0 + w.buf = append(w.buf, p...) + + if !w.readIV && len(w.buf) >= aes.BlockSize { + w.iv = w.buf[:aes.BlockSize] + w.mode = cipher.NewCBCDecrypter(w.block, w.iv) + w.buf = w.buf[aes.BlockSize:] + w.readIV = true + } + + for len(w.buf) >= aes.BlockSize*2 { + block := w.buf[:aes.BlockSize] + dst := make([]byte, aes.BlockSize) + w.mode.CryptBlocks(dst, block) + if _, err := w.writer.Write(dst); err != nil { + return total, err + } + w.buf = w.buf[aes.BlockSize:] + total += aes.BlockSize + } + + return total, nil +} + +func (w *cbcDecryptWriter) Close() error { + if w.closed { + return nil + } + w.closed = true + + if len(w.buf) != aes.BlockSize { + return fmt.Errorf("incomplete final block") + } + block := make([]byte, aes.BlockSize) + w.mode.CryptBlocks(block, w.buf) + + // Remove PKCS#7 padding + padLen := int(block[len(block)-1]) + if padLen <= 0 || padLen > aes.BlockSize { + return fmt.Errorf("invalid padding") + } + for _, b := range block[len(block)-padLen:] { + if int(b) != padLen { + return fmt.Errorf("invalid padding content") + } + } + _, err := w.writer.Write(block[:len(block)-padLen]) + return err +} +func (s *AES256CBC) Name() string { + return string(AlgoDirectAES256CBC) +} + +func (s *AES256CBC) Description() string { + return "AES-256-CBC output format with IV prepended; should work with `openssl enc -d -aes-256-cbc -K -iv auto`." +} + +func (s *AES256CBC) Decrypt(out io.Writer) (io.WriteCloser, error) { + if len(s.key) != 32 { + return nil, fmt.Errorf("key must be 32 bytes") + } + + block, err := aes.NewCipher(s.key) + if err != nil { + return nil, err + } + + // The returned WriteCloser will buffer input until it receives at least one full block + return &cbcDecryptWriter{ + writer: out, + block: block, + }, nil +} + +func (s *AES256CBC) Encrypt(out io.Writer) (io.WriteCloser, error) { + if len(s.key) != 32 { + return nil, fmt.Errorf("key must be 32 bytes") + } + + block, err := aes.NewCipher(s.key) + if err != nil { + return nil, err + } + + iv := s.iv + if len(iv) == 0 { + iv = make([]byte, aes.BlockSize) + if _, err := rand.Read(iv); err != nil { + return nil, fmt.Errorf("failed to generate IV: %w", err) + } + } + if s.prependIV { + if _, err := out.Write(iv); err != nil { + return nil, fmt.Errorf("failed to write IV to output: %w", err) + } + } + + mode := cipher.NewCBCEncrypter(block, iv) + + return &cbcEncryptWriter{ + writer: out, + mode: mode, + buf: make([]byte, 0, aes.BlockSize), + }, nil +} diff --git a/pkg/encrypt/age.go b/pkg/encrypt/age.go new file mode 100644 index 0000000..e25d354 --- /dev/null +++ b/pkg/encrypt/age.go @@ -0,0 +1,86 @@ +package encrypt + +import ( + "bytes" + "fmt" + "io" + + "filippo.io/age" +) + +var _ Encryptor = &AgeChacha20Poly1305{} + +type AgeChacha20Poly1305 struct { + recipientPubKey string + identityKey string +} + +func NewAgeChacha20Poly1305(recipientPubKey []byte) (*AgeChacha20Poly1305, error) { + key := string(recipientPubKey) + return &AgeChacha20Poly1305{recipientPubKey: key}, nil +} + +func (a *AgeChacha20Poly1305) Name() string { + return string(AlgoAgeChacha20Poly1305) +} + +func (a *AgeChacha20Poly1305) Description() string { + return "age format with encryption using chacha20 and poly1305." +} + +func (a *AgeChacha20Poly1305) Decrypt(out io.Writer) (io.WriteCloser, error) { + identity, err := age.ParseX25519Identity(a.identityKey) + if err != nil { + return nil, fmt.Errorf("invalid age identity: %w", err) + } + + // Buffer encrypted input until Close() + buf := &bytes.Buffer{} + return &ageDecryptWriter{ + identity: identity, + buf: buf, + out: out, + }, nil +} + +func (a *AgeChacha20Poly1305) Encrypt(out io.Writer) (io.WriteCloser, error) { + // Parse the recipient's X25519 public key + recipient, err := age.ParseX25519Recipient(a.recipientPubKey) + if err != nil { + return nil, fmt.Errorf("invalid recipient public key: %w", err) + } + + // Create an age Writer that encrypts to the recipient + ageWriter, err := age.Encrypt(out, recipient) + if err != nil { + return nil, fmt.Errorf("failed to initialize age writer: %w", err) + } + + return ageWriter, nil // already an io.WriteCloser +} + +type ageDecryptWriter struct { + identity *age.X25519Identity + buf *bytes.Buffer + out io.Writer + closed bool +} + +func (w *ageDecryptWriter) Write(p []byte) (int, error) { + return w.buf.Write(p) +} + +func (w *ageDecryptWriter) Close() error { + if w.closed { + return nil + } + w.closed = true + + reader, err := age.Decrypt(w.buf, w.identity) + if err != nil { + return fmt.Errorf("age decryption failed: %w", err) + } + + _, err = io.Copy(w.out, reader) + return err +} diff --git a/pkg/encrypt/chacha20poly1305.go b/pkg/encrypt/chacha20poly1305.go new file mode 100644 index 0000000..4ac438e --- /dev/null +++ b/pkg/encrypt/chacha20poly1305.go @@ -0,0 +1,134 @@ +package encrypt + +import ( + "bytes" + "crypto/cipher" + "crypto/rand" + "fmt" + "io" + + "golang.org/x/crypto/chacha20poly1305" +) + +var _ Encryptor = &Chacha20Poly1305{} + +type Chacha20Poly1305 struct { + key []byte +} + +func NewChacha20Poly1305(key []byte) (*Chacha20Poly1305, error) { + if len(key) != 32 { + return nil, fmt.Errorf("key length must be 32 bytes for Chacha20Poly1305, not %d", len(key)) + } + return &Chacha20Poly1305{key: key}, nil +} + +func (s *Chacha20Poly1305) Name() string { + return string(AlgoChacha20Poly1305) +} + +func (s *Chacha20Poly1305) Description() string { + return "Chacha20-Poly1305 encryption." +} + +func (s *Chacha20Poly1305) Decrypt(out io.Writer) (io.WriteCloser, error) { + if len(s.key) != chacha20poly1305.KeySize { + return nil, fmt.Errorf("key must be 32 bytes") + } + + aead, err := chacha20poly1305.New(s.key) + if err != nil { + return nil, fmt.Errorf("failed to create chacha20poly1305: %w", err) + } + + return &chacha20DecryptWriter{ + aead: aead, + out: out, + buf: &bytes.Buffer{}, + }, nil +} + +func (s *Chacha20Poly1305) Encrypt(out io.Writer) (io.WriteCloser, error) { + if len(s.key) != chacha20poly1305.KeySize { + return nil, fmt.Errorf("key must be 32 bytes") + } + + aead, err := chacha20poly1305.New(s.key) + if err != nil { + return nil, fmt.Errorf("failed to create chacha20poly1305: %w", err) + } + + nonce := make([]byte, chacha20poly1305.NonceSize) + if _, err := rand.Read(nonce); err != nil { + return nil, fmt.Errorf("failed to generate nonce: %w", err) + } + + // Write the nonce first + if _, err := out.Write(nonce); err != nil { + return nil, fmt.Errorf("failed to write nonce: %w", err) + } + + return &chacha20EncryptWriter{ + aead: aead, + nonce: nonce, + out: out, + buffer: &bytes.Buffer{}, + }, nil +} + +type chacha20EncryptWriter struct { + aead cipher.AEAD + nonce []byte + out io.Writer + buffer *bytes.Buffer + closed bool +} + +func (w *chacha20EncryptWriter) Write(p []byte) (int, error) { + return w.buffer.Write(p) +} + +func (w *chacha20EncryptWriter) Close() error { + if w.closed { + return nil + } + w.closed = true + + ciphertext := w.aead.Seal(nil, w.nonce, w.buffer.Bytes(), nil) + _, err := w.out.Write(ciphertext) + return err +} + +type chacha20DecryptWriter struct { + aead cipher.AEAD + out io.Writer + buf *bytes.Buffer + closed bool +} + +func (w *chacha20DecryptWriter) Write(p []byte) (int, error) { + return w.buf.Write(p) +} + +func (w *chacha20DecryptWriter) Close() error { + if w.closed { + return nil + } + w.closed = true + + data := w.buf.Bytes() + if len(data) < chacha20poly1305.NonceSize { + return fmt.Errorf("missing nonce or ciphertext") + } + + nonce := data[:chacha20poly1305.NonceSize] + ciphertext := data[chacha20poly1305.NonceSize:] + + plaintext, err := w.aead.Open(nil, nonce, ciphertext, nil) + if err != nil { + return fmt.Errorf("decryption failed: %w", err) + } + + _, err = w.out.Write(plaintext) + return err +} diff --git a/pkg/encrypt/const.go b/pkg/encrypt/const.go new file mode 100644 index 0000000..fd3fb2f --- /dev/null +++ b/pkg/encrypt/const.go @@ -0,0 +1,19 @@ +package encrypt + +import "github.com/databacker/api/go/api" + +const ( + AlgoSMimeAES256CBC = api.EncryptionAlgorithmSmimeAes256Cbc + AlgoDirectAES256CBC = api.EncryptionAlgorithmAes256Cbc + AlgoPBKDF2AES256CBC = api.EncryptionAlgorithmPbkdf2Aes256Cbc + AlgoAgeChacha20Poly1305 = api.EncryptionAlgorithmAgeChacha20Poly1305 + AlgoChacha20Poly1305 = api.EncryptionAlgorithmChacha20Poly1305 +) + +var All = []string{ + string(AlgoSMimeAES256CBC), + string(AlgoDirectAES256CBC), + string(AlgoPBKDF2AES256CBC), + string(AlgoAgeChacha20Poly1305), + string(AlgoChacha20Poly1305), +} diff --git a/pkg/encrypt/encryptor.go b/pkg/encrypt/encryptor.go new file mode 100644 index 0000000..47cf446 --- /dev/null +++ b/pkg/encrypt/encryptor.go @@ -0,0 +1,38 @@ +package encrypt + +import ( + "fmt" + "io" + + "github.com/databacker/api/go/api" +) + +type Encryptor interface { + Name() string + Description() string + Decrypt(out io.Writer) (io.WriteCloser, error) + Encrypt(out io.Writer) (io.WriteCloser, error) +} + +func GetEncryptor(name string, key []byte) (Encryptor, error) { + var ( + enc Encryptor + err error + ) + nameEnum := api.EncryptionAlgorithm(name) + switch nameEnum { + case AlgoSMimeAES256CBC: + enc, err = NewSMimeAES256CBC(key) + case AlgoDirectAES256CBC: + enc, err = NewAES256CBC(key, nil, true) + case AlgoPBKDF2AES256CBC: + enc, err = NewPBKDF2AES256CBC(key) + case AlgoAgeChacha20Poly1305: + enc, err = NewAgeChacha20Poly1305(key) + case AlgoChacha20Poly1305: + enc, err = NewChacha20Poly1305(key) + default: + return nil, fmt.Errorf("unknown encryption format: %s", name) + } + return enc, err +} diff --git a/pkg/encrypt/encryptor_test.go b/pkg/encrypt/encryptor_test.go new file mode 100644 index 0000000..a90463f --- /dev/null +++ b/pkg/encrypt/encryptor_test.go @@ -0,0 +1,288 @@ +package encrypt + +import ( + "bytes" + "crypto/rand" + "crypto/rsa" + "crypto/x509" + "crypto/x509/pkix" + "encoding/hex" + "encoding/pem" + "fmt" + "io" + "math/big" + "os" + "os/exec" + "path/filepath" + "testing" + "time" + + "filippo.io/age" + "github.com/databacker/api/go/api" + "golang.org/x/crypto/chacha20poly1305" +) + +func TestEncryptors(t *testing.T) { + cleartext, err := generateRandomCleartext(1024) + if err != nil { + t.Fatalf("failed to generate cleartext: %v", err) + } + + for _, tt := range All { + t.Run(tt, func(t *testing.T) { + var ( + encKey, decKey []byte + err error + tmpdir = t.TempDir() + ) + switch tt { + case string(api.EncryptionAlgorithmSmimeAes256Cbc): + encKey, decKey, err = generateSelfSignedCert() + if err != nil { + t.Fatalf("failed to generate self-signed cert: %v", err) + } + _ = os.WriteFile(filepath.Join(tmpdir, "cert.pem"), encKey, 0644) + _ = os.WriteFile(filepath.Join(tmpdir, "key.pem"), decKey, 0644) + case string(api.EncryptionAlgorithmAes256Cbc): + encKey, err = generateRandomKey(32) + if err != nil { + t.Fatalf("failed to generate AES256 key: %v", err) + } + decKey = encKey + case string(api.EncryptionAlgorithmPbkdf2Aes256Cbc): + encKey = []byte("testpassword") + decKey = encKey + case string(api.EncryptionAlgorithmChacha20Poly1305): + encKey, err = generateRandomKey(chacha20poly1305.KeySize) + if err != nil { + t.Fatalf("failed to generate AES256 key: %v", err) + } + decKey = encKey + case string(api.EncryptionAlgorithmAgeChacha20Poly1305): + // Step 2: Generate age key pair (X25519) + identity, err := age.GenerateX25519Identity() + if err != nil { + t.Fatalf("failed to generate age identity: %v", err) + } + recipient := identity.Recipient().String() // string form of public key + encKey = []byte(recipient) + decKey = []byte(identity.String()) // string form of private key + default: + t.Fatalf("unsupported encryptor: %s", tt) + } + + encryptor, err := GetEncryptor(tt, encKey) + if err != nil { + t.Fatalf("failed to get encryptor: %v", err) + } + var encrypted bytes.Buffer + + writer, err := encryptor.Encrypt(&encrypted) + if err != nil { + t.Fatalf("Encrypt setup failed: %v", err) + } + + if _, err := writer.Write(cleartext); err != nil { + t.Fatalf("writing to Encryptor failed: %v", err) + } + + if err := writer.Close(); err != nil { + t.Fatalf("closing Encryptor failed: %v", err) + } + + switch encryptor.Name() { + case string(api.EncryptionAlgorithmAes256Cbc): + // Only works if IV is prepended + b := encrypted.Bytes() + if len(b) < 16 { + t.Fatalf("ciphertext too short for AES256CBC: got %d bytes", len(b)) + } + iv := b[:16] + ciphertext := b[16:] + keyHex := hex.EncodeToString(decKey) + cmd := exec.Command("openssl", "enc", + "-d", "-aes-256-cbc", + "-K", keyHex, + "-iv", hex.EncodeToString(iv)) // IV is embedded + var ( + decrypted bytes.Buffer + stderr bytes.Buffer + ) + cmd.Stdin = bytes.NewReader(ciphertext) + cmd.Stdout = &decrypted + cmd.Stderr = &stderr + + err := cmd.Run() + if err != nil { + t.Errorf("OpenSSL failed to decrypt AES256CBC: %v; %s", err, stderr.Bytes()) + return + } + + if !bytes.Equal(decrypted.Bytes(), cleartext) { + t.Error("decrypted output does not match original cleartext") + } + + case string(api.EncryptionAlgorithmSmimeAes256Cbc): + cmd := exec.Command("openssl", "smime", + "-decrypt", + "-inform", "DER", + "-recip", filepath.Join(tmpdir, "cert.pem"), + "-inkey", filepath.Join(tmpdir, "key.pem")) + + var ( + decrypted bytes.Buffer + stderr bytes.Buffer + ) + cmd.Stdin = bytes.NewReader(encrypted.Bytes()) + cmd.Stdout = &decrypted + cmd.Stderr = &stderr + + err := cmd.Run() + if err != nil { + t.Errorf("OpenSSL failed to decrypt SMIME: %v; %s", err, stderr.Bytes()) + return + } + + if !bytes.Equal(decrypted.Bytes(), cleartext) { + t.Error("SMIME decrypted output does not match original cleartext") + } + case string(api.EncryptionAlgorithmPbkdf2Aes256Cbc): + cmd := exec.Command("openssl", "enc", + "-d", "-aes-256-cbc", + "-pbkdf2", + "-pass", fmt.Sprintf("pass:%s", string(decKey))) + + var ( + decrypted bytes.Buffer + stderr bytes.Buffer + ) + cmd.Stdin = bytes.NewReader(encrypted.Bytes()) + cmd.Stdout = &decrypted + cmd.Stderr = &stderr + + if err := cmd.Run(); err != nil { + t.Errorf("OpenSSL failed to decrypt PBKDF2AES256CBC: %v; %s", err, stderr.Bytes()) + return + } + + if !bytes.Equal(decrypted.Bytes(), cleartext) { + t.Error("PBKDF2AES256CBC decrypted output does not match original cleartext") + } + case string(api.EncryptionAlgorithmChacha20Poly1305): + cipherData := encrypted.Bytes() + + const ( + nonceSize = 12 + tagSize = 16 + ) + + if len(cipherData) < nonceSize+tagSize { + t.Fatalf("ciphertext too short for ChaCha20-Poly1305: got %d bytes", len(cipherData)) + } + + nonce := cipherData[:nonceSize] + tag := cipherData[len(cipherData)-tagSize:] + ciphertext := cipherData[nonceSize : len(cipherData)-tagSize] + + key := decKey + if len(key) != chacha20poly1305.KeySize { + t.Fatalf("invalid key size: expected %d bytes, got %d", chacha20poly1305.KeySize, len(key)) + } + + aead, err := chacha20poly1305.New(key) + if err != nil { + t.Fatalf("failed to create AEAD: %v", err) + } + + // AEAD expects tag to be appended to ciphertext + cipherAndTag := append(ciphertext, tag...) + + plaintext, err := aead.Open(nil, nonce, cipherAndTag, nil) + if err != nil { + t.Errorf("Go AEAD failed to decrypt ChaCha20Poly1305: %v", err) + return + } + + if !bytes.Equal(plaintext, cleartext) { + t.Error("ChaCha20Poly1305 decrypted output does not match original cleartext") + } + case string(api.EncryptionAlgorithmAgeChacha20Poly1305): + parsedIdentities, err := age.ParseIdentities(bytes.NewReader(decKey)) + if err != nil { + t.Fatalf("failed to parse identity: %v", err) + } + + if len(parsedIdentities) != 1 { + t.Fatalf("expected 1 identity, got %d", len(parsedIdentities)) + } + + x25519Ident, ok := parsedIdentities[0].(*age.X25519Identity) + if !ok { + t.Fatalf("parsed identity is not X25519") + } + r := bytes.NewReader(encrypted.Bytes()) + ageReader, err := age.Decrypt(r, x25519Ident) + if err != nil { + t.Fatalf("failed to create age decryptor: %v", err) + } + + decrypted, err := io.ReadAll(ageReader) + if err != nil { + t.Fatalf("failed to read decrypted data: %v", err) + } + + if !bytes.Equal(decrypted, cleartext) { + t.Error("decrypted output does not match original cleartext") + } + default: + t.Logf("No OpenSSL validation for: %s", encryptor.Name()) + } + }) + } +} + +func generateRandomCleartext(size int) ([]byte, error) { + buf := make([]byte, size) + _, err := rand.Read(buf) + return buf, err +} + +func generateRandomKey(size int) ([]byte, error) { + key := make([]byte, size) + _, err := rand.Read(key) + return key, err +} + +func generateSelfSignedCert() (certPEM, keyPEM []byte, err error) { + privKey, err := rsa.GenerateKey(rand.Reader, 2048) + if err != nil { + return nil, nil, err + } + + template := x509.Certificate{ + SerialNumber: big.NewInt(1), + Subject: pkix.Name{CommonName: "Test SMIME Cert"}, + NotBefore: time.Now().Add(-1 * time.Hour), + NotAfter: time.Now().Add(24 * time.Hour), + KeyUsage: x509.KeyUsageKeyEncipherment | x509.KeyUsageDigitalSignature, + ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageEmailProtection}, + } + + derBytes, err := x509.CreateCertificate(rand.Reader, &template, &template, &privKey.PublicKey, privKey) + if err != nil { + return nil, nil, err + } + + certPEMBuffer := new(bytes.Buffer) + if err := pem.Encode(certPEMBuffer, &pem.Block{Type: "CERTIFICATE", Bytes: derBytes}); err != nil { + return nil, nil, err + } + + keyPEMBuffer := new(bytes.Buffer) + privBytes := x509.MarshalPKCS1PrivateKey(privKey) + if err := pem.Encode(keyPEMBuffer, &pem.Block{Type: "RSA PRIVATE KEY", Bytes: privBytes}); err != nil { + return nil, nil, err + } + + return certPEMBuffer.Bytes(), keyPEMBuffer.Bytes(), nil +} diff --git a/pkg/encrypt/pbkdf2aes256cbc.go b/pkg/encrypt/pbkdf2aes256cbc.go new file mode 100644 index 0000000..313f526 --- /dev/null +++ b/pkg/encrypt/pbkdf2aes256cbc.go @@ -0,0 +1,129 @@ +package encrypt + +import ( + "crypto/rand" + "crypto/sha256" + "fmt" + "io" + + "golang.org/x/crypto/pbkdf2" +) + +const ( + pbkdf2KeyLen = 32 + pbkdf2SaltSize = 16 + pbkdf2Iterations = 10000 +) + +var _ Encryptor = &PBKDF2AES256CBC{} + +type PBKDF2AES256CBC struct { + passphrase []byte +} + +func NewPBKDF2AES256CBC(passphrase []byte) (*PBKDF2AES256CBC, error) { + if len(passphrase) == 0 { + return nil, fmt.Errorf("passphrase cannot be empty") + } + return &PBKDF2AES256CBC{passphrase: passphrase}, nil +} + +func (s *PBKDF2AES256CBC) Name() string { + return string(AlgoPBKDF2AES256CBC) +} + +func (s *PBKDF2AES256CBC) Description() string { + return "PBKDF2 with AES256-CBC encryption. Should work with `openssl enc -d -aes-256-cbc -pbkdf2 -pass `" +} + +func (s *PBKDF2AES256CBC) Decrypt(out io.Writer) (io.WriteCloser, error) { + // Return a WriteCloser that buffers the salt, derives the key, and then streams decryption + pr := &pbkdf2DecryptReader{ + passphrase: s.passphrase, + out: out, + buf: make([]byte, 0, pbkdf2SaltSize), + } + return pr, nil +} + +func (s *PBKDF2AES256CBC) Encrypt(out io.Writer) (io.WriteCloser, error) { + // Step 1: Generate a random salt (used by OpenSSL) + salt := make([]byte, 8) + if _, err := rand.Read(salt); err != nil { + return nil, fmt.Errorf("failed to generate salt: %w", err) + } + + // Step 3: Derive 32-byte key using PBKDF2 with SHA-256 + keyComplete := pbkdf2.Key(s.passphrase, salt, pbkdf2Iterations, 48, sha256.New) + key := keyComplete[:pbkdf2KeyLen] + iv := keyComplete[32:] + + // Step 3: Write non-standard header and salt to output stream + if _, err := out.Write([]byte("Salted__")); err != nil { + return nil, fmt.Errorf("failed to write salt: %w", err) + } + if _, err := out.Write(salt); err != nil { + return nil, fmt.Errorf("failed to write salt: %w", err) + } + + // Step 4: Delegate to AES256CBC using the derived key + aes, err := NewAES256CBC(key, iv, false) + if err != nil { + return nil, err + } + return aes.Encrypt(out) +} + +type pbkdf2DecryptReader struct { + passphrase []byte + out io.Writer + buf []byte + aes io.WriteCloser + err error + closed bool +} + +func (r *pbkdf2DecryptReader) Write(p []byte) (int, error) { + if r.aes != nil { + return r.aes.Write(p) + } + + // Buffer salt + needed := pbkdf2SaltSize - len(r.buf) + if needed > len(p) { + r.buf = append(r.buf, p...) + return len(p), nil + } + + r.buf = append(r.buf, p[:needed]...) + // Derive key + key := pbkdf2.Key(r.passphrase, r.buf, pbkdf2Iterations, 32, sha256.New) + + // Initialize AES decryption + aes, err := NewAES256CBC(key, nil, false) + if err != nil { + r.err = err + return 0, err + } + + r.aes, err = aes.Decrypt(r.out) + if err != nil { + r.err = err + return 0, err + } + + // Write remaining data after salt + n, err := r.aes.Write(p[needed:]) + return needed + n, err +} + +func (r *pbkdf2DecryptReader) Close() error { + if r.closed { + return nil + } + r.closed = true + if r.aes != nil { + return r.aes.Close() + } + return r.err +} diff --git a/pkg/encrypt/smime.go b/pkg/encrypt/smime.go new file mode 100644 index 0000000..0aaad82 --- /dev/null +++ b/pkg/encrypt/smime.go @@ -0,0 +1,83 @@ +package encrypt + +import ( + "bytes" + "crypto/x509" + "encoding/pem" + "fmt" + "io" + + cms "github.com/InfiniteLoopSpace/go_S-MIME/cms" +) + +var _ Encryptor = &SMimeAES256CBC{} + +type SMimeAES256CBC struct { + recipientCert *x509.Certificate +} + +func NewSMimeAES256CBC(certPEM []byte) (*SMimeAES256CBC, error) { + block, _ := pem.Decode(certPEM) + if block == nil { + return nil, fmt.Errorf("failed to decode recipient cert PEM") + } + cert, err := x509.ParseCertificate(block.Bytes) + if err != nil { + return nil, fmt.Errorf("invalid recipient cert: %w", err) + } + return &SMimeAES256CBC{recipientCert: cert}, nil +} + +func (s *SMimeAES256CBC) Name() string { + return string(AlgoSMimeAES256CBC) +} + +func (s *SMimeAES256CBC) Description() string { + return "SMIME with AES256-CBC encryption. Should work with `openssl smime -decrypt -inform DER -recip -inkey `" +} + +func (s *SMimeAES256CBC) Decrypt(out io.Writer) (io.WriteCloser, error) { + return nil, fmt.Errorf("decrypt not implemented for SMIME") +} + +func (s *SMimeAES256CBC) Encrypt(out io.Writer) (io.WriteCloser, error) { + buf := &bytes.Buffer{} + return &streamingEncryptWriter{ + recipient: s.recipientCert, + plaintext: buf, + out: out, + }, nil +} + +type streamingEncryptWriter struct { + recipient *x509.Certificate + plaintext *bytes.Buffer + out io.Writer + closed bool +} + +func (w *streamingEncryptWriter) Write(p []byte) (int, error) { + return w.plaintext.Write(p) +} + +func (w *streamingEncryptWriter) Close() error { + if w.closed { + return nil + } + w.closed = true + + // Create a new S/MIME wrapper + s, err := cms.New() + if err != nil { + return fmt.Errorf("failed to create smime instance: %w", err) + } + + // Encrypt the buffered plaintext using AES-256-CBC and wrap in CMS + der, err := s.Encrypt(w.plaintext.Bytes(), []*x509.Certificate{w.recipient}) + if err != nil { + return fmt.Errorf("S/MIME encryption failed: %w", err) + } + + _, err = w.out.Write(der) + return err +}