-
-
Notifications
You must be signed in to change notification settings - Fork 69
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
Signed-off-by: Knut Ahlers <knut@ahlers.me>
- Loading branch information
Showing
6 changed files
with
429 additions
and
0 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -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) | ||
|
||
// 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() | ||
|
||
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() | ||
|
||
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 | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -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) | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -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 | ||
) |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -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= |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -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, []byte(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...) | ||
} | ||
|
||
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 | ||
} |
Oops, something went wrong.