mirror of
https://github.com/Luzifer/ots.git
synced 2025-04-19 06:55:51 -04:00
Implement client library
Signed-off-by: Knut Ahlers <knut@ahlers.me>
This commit is contained in:
parent
c5124731f5
commit
9178b36189
199
pkg/client/client.go
Normal file
199
pkg/client/client.go
Normal file
@ -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
|
||||
}
|
38
pkg/client/client_test.go
Normal file
38
pkg/client/client_test.go
Normal file
@ -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)
|
||||
}
|
15
pkg/client/go.mod
Normal file
15
pkg/client/go.mod
Normal file
@ -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
|
||||
)
|
14
pkg/client/go.sum
Normal file
14
pkg/client/go.sum
Normal file
@ -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=
|
92
pkg/client/otsMeta.go
Normal file
92
pkg/client/otsMeta.go
Normal file
@ -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
|
||||
}
|
75
pkg/client/otsMeta_test.go
Normal file
75
pkg/client/otsMeta_test.go
Normal file
@ -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)
|
||||
}
|
Loading…
x
Reference in New Issue
Block a user