diff --git a/pkg/client/client.go b/pkg/client/client.go new file mode 100644 index 0000000..2675b4b --- /dev/null +++ b/pkg/client/client.go @@ -0,0 +1,199 @@ +// Package client implements a client library for OTS supporting the +// OTSMeta content format for file upload support +package client + +import ( + "bytes" + "context" + "crypto/rand" + "crypto/sha512" + "encoding/json" + "fmt" + "io" + "net/http" + "net/url" + "strconv" + "strings" + "time" + + "github.com/Luzifer/go-openssl/v4" +) + +// HTTPClient defines the client to use for create and fetch requests +// and can be overwritten to provide authentication +var HTTPClient = http.DefaultClient + +// KeyDerivationFunc defines the key derivation algorithm used in OTS +// to derive the key / iv from the password for encryption. You only +// should change this if you are running an OTS instance with modified +// parameters. +// +// The corresponding settings are found in `/src/crypto.js` in the OTS +// source code. +var KeyDerivationFunc = openssl.NewPBKDF2Generator(sha512.New, 300000) //nolint:gomnd // that's the definition + +// PasswordLength defines the length of the generated encryption password +var PasswordLength = 20 + +// RequestTimeout defines how long the request to the OTS instance for +// create and fetch may take +var RequestTimeout = 5 * time.Second + +// UserAgent defines the user-agent to send when interacting with an +// OTS instance. When using this library please set this to something +// the operator of the instance can determine your client from and +// provide an URL to useful information about your tool. +var UserAgent = "ots-client/1.x +https://github.com/Luzifer/ots" + +// Create serializes the secret and creates a new secret on the +// instance given by its URL. +// +// The given URL should point to the frontend of the instance. Do not +// include the API paths, they are added automatically. For the +// expireIn parameter zero value can be used to use server-default. +// +// So for OTS.fyi you'd use `New("https://ots.fyi/")` +func Create(instanceURL string, secret Secret, expireIn time.Duration) (string, time.Time, error) { + u, err := url.Parse(instanceURL) + if err != nil { + return "", time.Time{}, fmt.Errorf("parsing instance URL: %w", err) + } + + pass, err := genPass() + if err != nil { + return "", time.Time{}, fmt.Errorf("generating password: %w", err) + } + + data, err := secret.serialize(pass) + if err != nil { + return "", time.Time{}, fmt.Errorf("serializing data: %w", err) + } + + body := new(bytes.Buffer) + if err = json.NewEncoder(body).Encode(struct { + Secret string `json:"secret"` + }{Secret: string(data)}); err != nil { + return "", time.Time{}, fmt.Errorf("encoding request payload: %w", err) + } + + createURL := u.JoinPath(strings.Join([]string{".", "api", "create"}, "/")) + ctx, cancel := context.WithTimeout(context.Background(), RequestTimeout) + defer cancel() + + if expireIn > time.Second { + createURL.RawQuery = url.Values{ + "expire": []string{strconv.Itoa(int(expireIn / time.Second))}, + }.Encode() + } + + req, err := http.NewRequestWithContext(ctx, http.MethodGet, createURL.String(), body) + if err != nil { + return "", time.Time{}, fmt.Errorf("creating request: %w", err) + } + req.Header.Set("Content-Type", "application/json") + req.Header.Set("User-Agent", UserAgent) + + resp, err := HTTPClient.Do(req) + if err != nil { + return "", time.Time{}, fmt.Errorf("executing request: %w", err) + } + defer resp.Body.Close() //nolint:errcheck // possible leaked-fd, lib should not log, potential short-lived leak + + if resp.StatusCode != http.StatusCreated { + respBody, err := io.ReadAll(resp.Body) + if err != nil { + return "", time.Time{}, fmt.Errorf("unexpected HTTP status %d", resp.StatusCode) + } + return "", time.Time{}, fmt.Errorf("unexpected HTTP status %d (%s)", resp.StatusCode, respBody) + } + + var payload struct { + ExpiresAt time.Time `json:"expires_at"` + SecretID string `json:"secret_id"` + Success bool `json:"success"` + } + + if err = json.NewDecoder(resp.Body).Decode(&payload); err != nil { + return "", time.Time{}, fmt.Errorf("decoding response: %w", err) + } + + u.Fragment = strings.Join([]string{payload.SecretID, pass}, "|") + + return u.String(), payload.ExpiresAt, nil +} + +// Fetch retrieves a secret by its given URL. The URL given must +// include the fragment (part after the `#`) with the secret ID and +// the encryption passphrase. +// +// The object returned will always be an OTSMeta object even in case +// the secret is a plain secret without attachments. +func Fetch(secretURL string) (s Secret, err error) { + u, err := url.Parse(secretURL) + if err != nil { + return s, fmt.Errorf("parsing secret URL: %w", err) + } + + fragment, err := url.QueryUnescape(u.Fragment) + if err != nil { + return s, fmt.Errorf("unescaping fragment: %w", err) + } + fragmentParts := strings.SplitN(fragment, "|", 2) //nolint:gomnd + + fetchURL := u.JoinPath(strings.Join([]string{".", "api", "get", fragmentParts[0]}, "/")).String() + ctx, cancel := context.WithTimeout(context.Background(), RequestTimeout) + defer cancel() + + req, err := http.NewRequestWithContext(ctx, http.MethodGet, fetchURL, nil) + if err != nil { + return s, fmt.Errorf("creating request: %w", err) + } + req.Header.Set("User-Agent", UserAgent) + + resp, err := HTTPClient.Do(req) + if err != nil { + return s, fmt.Errorf("executing request: %w", err) + } + defer resp.Body.Close() //nolint:errcheck // possible leaked-fd, lib should not log, potential short-lived leak + + if resp.StatusCode != http.StatusOK { + return s, fmt.Errorf("unexpected HTTP status %d", resp.StatusCode) + } + + var payload struct { + Secret string `json:"secret"` + } + + if err = json.NewDecoder(resp.Body).Decode(&payload); err != nil { + return s, fmt.Errorf("decoding response body: %w", err) + } + + if err = s.read([]byte(payload.Secret), fragmentParts[1]); err != nil { + return s, fmt.Errorf("decoding secret: %w", err) + } + + return s, nil +} + +func genPass() (string, error) { + var ( + charSet = "0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ" + pass = make([]byte, PasswordLength) + + n int + err error + ) + + for n < PasswordLength { + n, err = rand.Read(pass) + if err != nil { + return "", fmt.Errorf("reading random data: %w", err) + } + } + + for i := 0; i < PasswordLength; i++ { + pass[i] = charSet[int(pass[i])%len(charSet)] + } + + return string(pass), nil +} diff --git a/pkg/client/client_test.go b/pkg/client/client_test.go new file mode 100644 index 0000000..996fc58 --- /dev/null +++ b/pkg/client/client_test.go @@ -0,0 +1,38 @@ +package client + +import ( + "regexp" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestGeneratePassword(t *testing.T) { + pass, err := genPass() + require.NoError(t, err) + + assert.Len(t, pass, PasswordLength) + assert.Regexp(t, regexp.MustCompile(`^[0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ]+$`), pass) +} + +func TestIntegration(t *testing.T) { + s := Secret{ + Secret: "I'm a secret!", + Attachments: []SecretAttachment{{ + Name: "secret.txt", + Type: "text/plain", + Content: []byte("I'm a very secret file.\n"), + }}, + } + + secretURL, _, err := Create("https://ots.fyi/", s, time.Minute) + require.NoError(t, err) + assert.Regexp(t, regexp.MustCompile(`^https://ots.fyi/#[0-9a-f-]+%7C[0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ]+$`), secretURL) + + apiSecret, err := Fetch(secretURL) + require.NoError(t, err) + + assert.Equal(t, s, apiSecret) +} diff --git a/pkg/client/go.mod b/pkg/client/go.mod new file mode 100644 index 0000000..256fda3 --- /dev/null +++ b/pkg/client/go.mod @@ -0,0 +1,15 @@ +module github.com/Luzifer/ots/pkg/client + +go 1.21.1 + +require ( + github.com/Luzifer/go-openssl/v4 v4.2.1 + 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 + golang.org/x/crypto v0.12.0 // indirect + gopkg.in/yaml.v3 v3.0.1 // indirect +) diff --git a/pkg/client/go.sum b/pkg/client/go.sum new file mode 100644 index 0000000..9e1f53d --- /dev/null +++ b/pkg/client/go.sum @@ -0,0 +1,14 @@ +github.com/Luzifer/go-openssl/v4 v4.2.1 h1:0+/gaQ5TcBhGmVqGrfyA21eujlbbaNwj0VlOA3nh4ts= +github.com/Luzifer/go-openssl/v4 v4.2.1/go.mod h1:CZZZWY0buCtkxrkqDPQYigC4Kn55UuO97TEoV+hwz2s= +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/stretchr/testify v1.8.4 h1:CcVxjf3Q8PM0mHUKJCdn+eZZtm5yQwehR5yeSVQQcUk= +github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo= +golang.org/x/crypto v0.12.0 h1:tFM/ta59kqch6LlvYnPa0yx5a83cL2nHflFhYKvv9Yk= +golang.org/x/crypto v0.12.0/go.mod h1:NF0Gs7EO5K4qLn+Ylc+fih8BSTeIjAP05siRnAh98yw= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/pkg/client/otsMeta.go b/pkg/client/otsMeta.go new file mode 100644 index 0000000..fbedd21 --- /dev/null +++ b/pkg/client/otsMeta.go @@ -0,0 +1,92 @@ +package client + +import ( + "bytes" + "encoding/base64" + "encoding/json" + "fmt" + + "github.com/Luzifer/go-openssl/v4" +) + +var metaMarker = []byte("OTSMeta") + +type ( + // Secret represents a secret parsed from / prepared for + // serialization to the OTS API + Secret struct { + Secret string `json:"secret"` + Attachments []SecretAttachment `json:"attachments,omitempty"` + } + + // SecretAttachment represents a file attached to a Secret. The Data + // property must be the plain content (binary / text / ...) of the + // file to attach. The base64 en-/decoding is done transparently. + // The Name is the name of the file shown to the user (so ideally + // should be the file-name on the source system). The Type should + // contain the mime time of the file or an empty string. + SecretAttachment struct { + Name string `json:"name"` + Type string `json:"type"` + Data string `json:"data"` + Content []byte `json:"-"` + } +) + +func (o *Secret) read(data []byte, passphrase string) (err error) { + if passphrase != "" { + if data, err = openssl.New().DecryptBytes(passphrase, data, KeyDerivationFunc); err != nil { + return fmt.Errorf("decrypting data: %w", err) + } + } + + if !bytes.HasPrefix(data, metaMarker) { + // We have a simple secret, makes less effort for us + o.Secret = string(data) + return nil + } + + if err = json.Unmarshal(data[len(metaMarker):], o); err != nil { + return fmt.Errorf("decoding JSON payload: %w", err) + } + + for i := range o.Attachments { + o.Attachments[i].Content, err = base64.StdEncoding.DecodeString(o.Attachments[i].Data) + if err != nil { + return fmt.Errorf("decoding attachment %d: %w", i, err) + } + } + + return nil +} + +func (o Secret) serialize(passphrase string) ([]byte, error) { + var data []byte + + if len(o.Attachments) == 0 { + // No attachments? No problem, we create a classic simple secret + data = []byte(o.Secret) + } else { + for i := range o.Attachments { + o.Attachments[i].Data = base64.StdEncoding.EncodeToString(o.Attachments[i].Content) + } + + j, err := json.Marshal(o) + if err != nil { + return nil, fmt.Errorf("encoding JSON payload: %w", err) + } + + data = append(metaMarker, j...) //nolint:gocritic // :shrug: + } + + if passphrase == "" { + // No encryption requested + return data, nil + } + + out, err := openssl.New().EncryptBytes(passphrase, data, KeyDerivationFunc) + if err != nil { + return nil, fmt.Errorf("encrypting data: %w", err) + } + return out, nil +} diff --git a/pkg/client/otsMeta_test.go b/pkg/client/otsMeta_test.go new file mode 100644 index 0000000..67995e2 --- /dev/null +++ b/pkg/client/otsMeta_test.go @@ -0,0 +1,75 @@ +package client + +import ( + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestReadOTSMeta(t *testing.T) { + var ( + //#nosec:G101 // Hardcoded credentials, just test-data + secretData = "U2FsdGVkX1+7kNgAK57O/qdbsukK3OchMyMyE1tWzVJVlc9f9bkp8iaFHbwR7Q3b8tWhWmPAcfeOoBJH2zl1iNbIHWsmMKu3+pzE5wTE4wl31dOboV8LgsMChBFL5RQpda0iGku32BcB4tYEyb2VHcM/kkXNJh9lW1vRyiNx0iF8pe05JUkkmJJrnzIKC+/efZEfF2YX7fOaBC1+8AAhlg==" + //#nosec:G101 // Hardcoded credentials, just test-data + pass = "IKeiXsyGuVWdMUG8Fj3R" + s Secret + ) + + err := s.read([]byte(secretData), pass) + require.NoError(t, err) + + assert.Equal(t, Secret{ + Secret: "I'm a secret!", + Attachments: []SecretAttachment{{ + Name: "secret.txt", + Type: "text/plain", + Data: "SSdtIGEgdmVyeSBzZWNyZXQgZmlsZS4K", + Content: []byte("I'm a very secret file.\n"), + }}, + }, s) +} + +func TestReadSimpleSecret(t *testing.T) { + var ( + //#nosec:G101 // Hardcoded credentials, just test-data + secretData = "U2FsdGVkX18cvbYVRsD5cxMKKAHtMRmteu88tPwRtOk=" + //#nosec:G101 // Hardcoded credentials, just test-data + pass = "YQHdft6hDnp575olczeq" + s Secret + ) + + err := s.read([]byte(secretData), pass) + require.NoError(t, err) + + assert.Equal(t, Secret{ + Secret: "I'm a secret!", + }, s) +} + +func TestSerializeOTSMeta(t *testing.T) { + // NOTE(kahlers): We're using an empty passphrase here to achieve + // testability of the output. The data is not encrypted in this + // case. + data, err := Secret{ + Secret: "I'm a secret!", + Attachments: []SecretAttachment{{ + Name: "secret.txt", + Type: "text/plain", + Content: []byte("I'm a very secret file.\n"), + }}, + }.serialize("") + require.NoError(t, err) + + assert.Equal(t, []byte(`OTSMeta{"secret":"I'm a secret!","attachments":[{"name":"secret.txt","type":"text/plain","data":"SSdtIGEgdmVyeSBzZWNyZXQgZmlsZS4K"}]}`), data) +} + +func TestSerializeSimpleSecret(t *testing.T) { + // NOTE(kahlers): We're using an empty passphrase here to achieve + // testability of the output. The data is not encrypted in this + // case. + data, err := Secret{Secret: "I'm a secret!"}.serialize("") + require.NoError(t, err) + + assert.Equal(t, []byte("I'm a secret!"), data) +}