Add blob service and support for signing and verifying HTTP requests (#178)
Alternative to #177 The way this works is: Client: - Client creates a normal `net/http.Request{}` object using the `Request()` function in `utils.go`. The `http.Request{}` object is then signed using the Client's Ed25519 private key. - The HTTP Method and Path (_note this is important_) are hashed, as well as the request body (if any) using the FNV128a hashing algorithm. - This hash is then signed by the Client's's Ed25519 private key. - The resulting signature is then encoded to Base64 (_standard encoding_) and added to the HTTP headers as a `Signature:` header. - In addition the Client's Ed25519 public key is added to the HTTP headers as `Signer:` Server: - The server calculates the same FNV128a hash of the HTTP Request Method and Path and the body (if any) - The server decodes the HTTP header `Signature:` - The server then uses the Client's Ed25519 public key in the HTTP header `Signer:` to verify the signature of the `Signature:` HTTP header which gives us back the original FNV128a hash the Client calculated for the request. - The server then compares the Client's hash with the expected hash to see if they compare equally. Co-authored-by: James Mills <1290234+prologic@users.noreply.github.com> Co-authored-by: Jon Lundy <jon@xuu.cc> Reviewed-on: https://git.mills.io/saltyim/saltyim/pulls/178 Reviewed-by: xuu <xuu@noreply@mills.io>
This commit is contained in:
parent
96d8afcbef
commit
ddd16c202f
|
@ -23,6 +23,7 @@
|
|||
/data/*.json
|
||||
/data/logs
|
||||
/data/acme
|
||||
/data/blobs
|
||||
/data/avatars
|
||||
/data/.well-known
|
||||
|
||||
|
|
44
client.go
44
client.go
|
@ -7,6 +7,7 @@ import (
|
|||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"os"
|
||||
|
@ -95,9 +96,9 @@ func PackMessageTime(me *Addr, msg string, t *lextwt.DateTime) []byte {
|
|||
// Client is a Salty IM client that handles talking to a Salty IM Broker
|
||||
// and Sedngina and Receiving messages to/from Salty IM Users.
|
||||
type Client struct {
|
||||
me *Addr
|
||||
id *Identity
|
||||
key *keys.EdX25519Key
|
||||
me *Addr
|
||||
id *Identity
|
||||
|
||||
cache addrCache
|
||||
state *State
|
||||
|
||||
|
@ -111,9 +112,8 @@ type ClientOption func(cli *Client) error
|
|||
// NewClient reeturns a new Salty IM client for sending and receiving
|
||||
// encrypted messages to other Salty IM users as well as decrypting
|
||||
// and displaying messages of the user's own inbox.
|
||||
func NewClient(me *Addr, options ...ClientOption) (*Client, error) {
|
||||
func NewClient(options ...ClientOption) (*Client, error) {
|
||||
cli := &Client{
|
||||
me: me,
|
||||
cache: make(addrCache),
|
||||
lookup: &DirectLookup{},
|
||||
send: &DirectSend{},
|
||||
|
@ -190,7 +190,7 @@ func (cli *Client) processMessage(msg *msgbus.Message, extraenvs, prehook, posth
|
|||
log.Debugf("pre-hook: %q", out)
|
||||
}
|
||||
|
||||
unencrypted, senderKey, err := salty.Decrypt(cli.key, msg.Payload)
|
||||
unencrypted, senderKey, err := salty.Decrypt(cli.id.key, msg.Payload)
|
||||
if err != nil {
|
||||
return Message{}, fmt.Errorf("error decrypting message: %w", err)
|
||||
}
|
||||
|
@ -216,8 +216,8 @@ func (cli *Client) messageHandler(extraenvs, prehook, posthook string, msgs chan
|
|||
// Me returns our (self) address
|
||||
func (cli *Client) Me() *Addr { return cli.me }
|
||||
|
||||
// Key returns out (self) public key
|
||||
func (cli *Client) Key() *keys.EdX25519PublicKey { return cli.key.PublicKey() }
|
||||
// Key returns our (self) public key
|
||||
func (cli *Client) Key() *keys.EdX25519PublicKey { return cli.id.key.PublicKey() }
|
||||
|
||||
// State returns the current state of the client
|
||||
func (cli *Client) State() *State { return cli.state }
|
||||
|
@ -312,9 +312,8 @@ func (cli *Client) OutboxClient(to *Addr) *Client {
|
|||
capabilities: cli.me.capabilities,
|
||||
checkedAvatar: cli.me.checkedAvatar,
|
||||
},
|
||||
key: cli.key,
|
||||
key: cli.id.key,
|
||||
},
|
||||
key: cli.key,
|
||||
cache: cli.cache,
|
||||
state: cli.state,
|
||||
lookup: cli.lookup,
|
||||
|
@ -329,7 +328,7 @@ func (cli *Client) String() string {
|
|||
fmt.Fprintln(b, "Me: ", cli.me)
|
||||
fmt.Fprintln(b, "Endpoint: ", cli.me.Endpoint())
|
||||
fmt.Fprintln(b, "Outbox: ", cli.Outbox())
|
||||
fmt.Fprintln(b, "Key: ", cli.key)
|
||||
fmt.Fprintln(b, "Key: ", cli.id.key)
|
||||
return b.String()
|
||||
}
|
||||
|
||||
|
@ -422,14 +421,14 @@ func (cli *Client) SendToAddr(addr *Addr, msg string) error {
|
|||
return ErrNoSender
|
||||
}
|
||||
|
||||
b, err := salty.Encrypt(cli.key, PackMessage(cli.me, msg), []string{addr.key.ID().String()})
|
||||
b, err := salty.Encrypt(cli.id.key, PackMessage(cli.me, msg), []string{addr.key.ID().String()})
|
||||
if err != nil {
|
||||
return fmt.Errorf("error encrypting message to %s: %w", addr, err)
|
||||
}
|
||||
|
||||
endpoint := addr.Endpoint().String()
|
||||
log.Debugf("sending message to %s", endpoint)
|
||||
if err := cli.send.Send(cli.key, endpoint, string(b), addr.Cap()); err != nil {
|
||||
if err := cli.send.Send(cli.id.key, endpoint, string(b), addr.Cap()); err != nil {
|
||||
return fmt.Errorf("error sending message to %s: %w", addr, err)
|
||||
}
|
||||
|
||||
|
@ -457,7 +456,7 @@ func (cli *Client) Register(brokerURI string) error {
|
|||
if err != nil {
|
||||
return fmt.Errorf("error serializing register request: %w", err)
|
||||
}
|
||||
signed, err := salty.Sign(cli.key, data)
|
||||
signed, err := salty.Sign(cli.id.key, data)
|
||||
if err != nil {
|
||||
return fmt.Errorf("error signing register request: %w", err)
|
||||
}
|
||||
|
@ -479,7 +478,7 @@ func (cli *Client) SetAvatar(content []byte) error {
|
|||
if err != nil {
|
||||
return fmt.Errorf("error serializing avatar request: %w", err)
|
||||
}
|
||||
signed, err := salty.Sign(cli.key, data)
|
||||
signed, err := salty.Sign(cli.id.key, data)
|
||||
if err != nil {
|
||||
return fmt.Errorf("error signing avatar request: %w", err)
|
||||
}
|
||||
|
@ -495,3 +494,18 @@ func (cli *Client) SetAvatar(content []byte) error {
|
|||
|
||||
return nil
|
||||
}
|
||||
|
||||
// Request makes a signed request to a broker's API.
|
||||
func (cli *Client) Request(method, endpoint string, body []byte) ([]byte, error) {
|
||||
// TODO: Automatically work out the URI based on SRV lookups of the user's address
|
||||
u := cli.Me().Endpoint()
|
||||
u.Path = endpoint
|
||||
|
||||
res, err := SignedRequest(cli.id.key, method, u.String(), nil, bytes.NewBuffer(body))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("error making %s request to %s: %w", method, u, err)
|
||||
}
|
||||
defer res.Body.Close()
|
||||
|
||||
return io.ReadAll(res.Body)
|
||||
}
|
||||
|
|
|
@ -19,12 +19,13 @@ func TestClient_InvalidEndpoint(t *testing.T) {
|
|||
require.NoError(err)
|
||||
assert.NotNil(me)
|
||||
|
||||
_, err = NewClient(me)
|
||||
assert.Error(err)
|
||||
_, err = NewClient(WithAddr(me))
|
||||
require.Error(err)
|
||||
}
|
||||
|
||||
func TestClient_Outbox(t *testing.T) {
|
||||
test := require.New(t)
|
||||
assert := assert.New(t)
|
||||
require := require.New(t)
|
||||
|
||||
endpoint := &url.URL{Host: "example.com", Path: "/path", Scheme: "https"}
|
||||
key := keys.GenerateEdX25519Key()
|
||||
|
@ -32,14 +33,13 @@ func TestClient_Outbox(t *testing.T) {
|
|||
client := &Client{me: &Addr{endpoint: endpoint}, id: &Identity{key: key}}
|
||||
outbox := client.Outbox()
|
||||
|
||||
test.True(endpoint.Path == "/path",
|
||||
require.True(endpoint.Path == "/path",
|
||||
"endpoint.Path should not be modified after call to client.Outbox()")
|
||||
test.False(*endpoint == *outbox,
|
||||
require.False(*endpoint == *outbox,
|
||||
"endpoint and outbox should not point to the same *url.URL")
|
||||
|
||||
expected := fmt.Sprintf("/%x", sha256.Sum256(key.Private()))
|
||||
test.True(outbox.Path == expected, "expected %s but got %s", expected, outbox.Path)
|
||||
|
||||
assert.Equal(expected, outbox.Path, "expected %s but got %s", expected, outbox.Path)
|
||||
}
|
||||
|
||||
func TestClient_Outbox_State(t *testing.T) {
|
||||
|
|
|
@ -4,7 +4,6 @@ import (
|
|||
"fmt"
|
||||
"os"
|
||||
|
||||
log "github.com/sirupsen/logrus"
|
||||
"github.com/spf13/cobra"
|
||||
"github.com/spf13/viper"
|
||||
"go.mills.io/saltyim"
|
||||
|
@ -19,24 +18,24 @@ on a Salty Broker (an instance of saltyd).
|
|||
NOTE: This is only spported on a Salty Broker.`,
|
||||
Args: cobra.ExactArgs(1),
|
||||
Run: func(cmd *cobra.Command, args []string) {
|
||||
user := viper.GetString("user")
|
||||
identity := viper.GetString("identity")
|
||||
|
||||
var profiles []profile
|
||||
viper.UnmarshalKey("profiles", &profiles)
|
||||
for _, p := range profiles {
|
||||
if user == p.User {
|
||||
identity = p.Identity
|
||||
}
|
||||
}
|
||||
|
||||
me, err := saltyim.ParseAddr(user)
|
||||
id, err := saltyim.GetIdentity(
|
||||
saltyim.WithIdentityPath(viper.GetString("identity")),
|
||||
)
|
||||
if err != nil {
|
||||
log.Debugf("error parsing addr: %s\n", err)
|
||||
fmt.Fprintf(os.Stderr, "error loading identity: %s\n", err)
|
||||
os.Exit(2)
|
||||
}
|
||||
// XXX: What if me.IsZero()
|
||||
|
||||
setavatar(me, identity, args[0])
|
||||
cli, err := saltyim.NewClient(
|
||||
saltyim.WithIdentity(id),
|
||||
saltyim.WithUser(viper.GetString("user")),
|
||||
)
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, "error initializing client: %s\n", err)
|
||||
os.Exit(2)
|
||||
}
|
||||
|
||||
setavatar(cli, args[0])
|
||||
},
|
||||
}
|
||||
|
||||
|
@ -44,13 +43,7 @@ func init() {
|
|||
rootCmd.AddCommand(setavatarCmd)
|
||||
}
|
||||
|
||||
func setavatar(me *saltyim.Addr, identity, fn string) {
|
||||
cli, err := saltyim.NewClient(me, saltyim.WithClientIdentity(saltyim.WithIdentityPath(identity)))
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, "error initializing client: %s\n", err)
|
||||
os.Exit(2)
|
||||
}
|
||||
|
||||
func setavatar(cli *saltyim.Client, fn string) {
|
||||
data, err := os.ReadFile(fn)
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, "error reading avatar file %s: %s", fn, err)
|
||||
|
|
|
@ -21,28 +21,30 @@ and subscribing to your endpoint and prompts for input and sends encrypted
|
|||
messages to the user via their discovered endpoint.`,
|
||||
Args: cobra.MaximumNArgs(1),
|
||||
Run: func(cmd *cobra.Command, args []string) {
|
||||
user := viper.GetString("user")
|
||||
identity := viper.GetString("identity")
|
||||
id, err := saltyim.GetIdentity(
|
||||
saltyim.WithIdentityPath(viper.GetString("identity")),
|
||||
)
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, "error loading identity: %s\n", err)
|
||||
os.Exit(2)
|
||||
}
|
||||
|
||||
state := viper.GetString("state")
|
||||
|
||||
var profiles []profile
|
||||
viper.UnmarshalKey("profiles", &profiles)
|
||||
for _, p := range profiles {
|
||||
if user == p.User {
|
||||
identity = p.Identity
|
||||
}
|
||||
}
|
||||
|
||||
me, err := saltyim.ParseAddr(user)
|
||||
cli, err := saltyim.NewClient(
|
||||
saltyim.WithIdentity(id),
|
||||
saltyim.WithStateFromFile(state),
|
||||
saltyim.WithUser(viper.GetString("user")),
|
||||
)
|
||||
if err != nil {
|
||||
log.Debugf("error parsing addr: %s\n", err)
|
||||
fmt.Fprintf(os.Stderr, "error initializing client: %s\n", err)
|
||||
os.Exit(2)
|
||||
}
|
||||
// XXX: What if me.IsZero()
|
||||
|
||||
if len(args) == 1 {
|
||||
chat(me, identity, state, args[0])
|
||||
chat(cli, state, args[0])
|
||||
} else {
|
||||
chat(me, identity, state, "")
|
||||
chat(cli, state, "")
|
||||
}
|
||||
},
|
||||
}
|
||||
|
@ -52,15 +54,7 @@ func init() {
|
|||
rootCmd.AddCommand(chatCmd)
|
||||
}
|
||||
|
||||
func chat(me *saltyim.Addr, identity, state, user string) {
|
||||
cli, err := saltyim.NewClient(me,
|
||||
saltyim.WithStateFromFile(state),
|
||||
saltyim.WithClientIdentity(saltyim.WithIdentityPath(identity)),
|
||||
)
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, "error initializing client: %s\n", err)
|
||||
os.Exit(2)
|
||||
}
|
||||
func chat(cli *saltyim.Client, state, user string) {
|
||||
defer func() {
|
||||
if err := cli.State().Save(state); err != nil {
|
||||
log.WithError(err).Warnf("error saving state: %s", state)
|
||||
|
|
|
@ -21,23 +21,24 @@ var readCmd = &cobra.Command{
|
|||
Long: `This command subscribes to the provided inbox (optiona) which if
|
||||
not specified defaults to the local user ($USER)`,
|
||||
Run: func(cmd *cobra.Command, args []string) {
|
||||
user := viper.GetString("user")
|
||||
identity := viper.GetString("identity")
|
||||
state := viper.GetString("state")
|
||||
|
||||
var profiles []profile
|
||||
viper.UnmarshalKey("profiles", &profiles)
|
||||
for _, p := range profiles {
|
||||
if user == p.User {
|
||||
identity = p.Identity
|
||||
}
|
||||
}
|
||||
|
||||
me, err := saltyim.ParseAddr(user)
|
||||
id, err := saltyim.GetIdentity(
|
||||
saltyim.WithIdentityPath(viper.GetString("identity")),
|
||||
)
|
||||
if err != nil {
|
||||
log.Debugf("error parsing addr: %s\n", err)
|
||||
fmt.Fprintf(os.Stderr, "error loading identity: %s\n", err)
|
||||
os.Exit(2)
|
||||
}
|
||||
// XXX: What if me.IsZero()
|
||||
|
||||
cli, err := saltyim.NewClient(
|
||||
saltyim.WithIdentity(id),
|
||||
saltyim.WithUser(viper.GetString("user")),
|
||||
)
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, "error initializing client: %s\n", err)
|
||||
os.Exit(2)
|
||||
}
|
||||
|
||||
state := viper.GetString("state")
|
||||
|
||||
follow, err := cmd.Flags().GetBool("follow")
|
||||
if err != nil {
|
||||
|
@ -59,7 +60,7 @@ not specified defaults to the local user ($USER)`,
|
|||
log.Fatal("error getting --post-hook flag")
|
||||
}
|
||||
|
||||
read(me, identity, state, follow, extraenvs, prehook, posthook, args...)
|
||||
read(cli, state, follow, extraenvs, prehook, posthook, args...)
|
||||
},
|
||||
}
|
||||
|
||||
|
@ -92,15 +93,7 @@ func init() {
|
|||
)
|
||||
}
|
||||
|
||||
func read(me *saltyim.Addr, identity, state string, follow bool, extraenvs, prehook, posthook string, args ...string) {
|
||||
cli, err := saltyim.NewClient(me,
|
||||
saltyim.WithStateFromFile(state),
|
||||
saltyim.WithClientIdentity(saltyim.WithIdentityPath(identity)),
|
||||
)
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, "error initializing client: %s\n", err)
|
||||
os.Exit(2)
|
||||
}
|
||||
func read(cli *saltyim.Client, state string, follow bool, extraenvs, prehook, posthook string, args ...string) {
|
||||
defer func() {
|
||||
if err := cli.State().Save(state); err != nil {
|
||||
log.WithError(err).Warnf("error saving state: %s", state)
|
||||
|
|
|
@ -64,7 +64,7 @@ func register(me *saltyim.Addr, identity, broker string) {
|
|||
os.Exit(2)
|
||||
}
|
||||
|
||||
cli, err := saltyim.NewClient(me, saltyim.WithClientIdentity(saltyim.WithIdentity(id)))
|
||||
cli, err := saltyim.NewClient(saltyim.WithIdentity(id))
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, "error initializing client: %s\n", err)
|
||||
os.Exit(2)
|
||||
|
|
|
@ -0,0 +1,83 @@
|
|||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
"github.com/spf13/viper"
|
||||
"go.mills.io/saltyim"
|
||||
)
|
||||
|
||||
var requestCmd = &cobra.Command{
|
||||
Aliases: []string{"req"},
|
||||
Use: "request <method> <endpoint> [<file>|-]",
|
||||
Short: "Makes a signed request to an API endpoint on a Salty Broker",
|
||||
Long: `This command creates and makes a signed request to an API endpoint
|
||||
on a Salty Broker (an instance of saltyd). A valid HTTP method such as GET, PUT, POST, DELETE or HEAD
|
||||
is provided as the first argument and a valid API endpoint path such as /api/v1/blob -- The 3rd argument
|
||||
is optional and if supplifed must either be a path to a filename containing the payload of the request,
|
||||
- for reading from stdin, or if omitted will read from stdin.
|
||||
|
||||
This is mostly useful in debugging and development of the Salty Broker API and Client.
|
||||
|
||||
NOTE: This is only spported on a Salty Broker.`,
|
||||
Args: cobra.MinimumNArgs(2),
|
||||
Run: func(cmd *cobra.Command, args []string) {
|
||||
id, err := saltyim.GetIdentity(
|
||||
saltyim.WithIdentityPath(viper.GetString("identity")),
|
||||
)
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, "error loading identity: %s\n", err)
|
||||
os.Exit(2)
|
||||
}
|
||||
|
||||
cli, err := saltyim.NewClient(
|
||||
saltyim.WithIdentity(id),
|
||||
saltyim.WithUser(viper.GetString("user")),
|
||||
)
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, "error initializing client: %s\n", err)
|
||||
os.Exit(2)
|
||||
}
|
||||
|
||||
request(cli, args[:])
|
||||
},
|
||||
}
|
||||
|
||||
func init() {
|
||||
rootCmd.AddCommand(requestCmd)
|
||||
}
|
||||
|
||||
func request(cli *saltyim.Client, args []string) {
|
||||
var (
|
||||
body []byte
|
||||
err error
|
||||
)
|
||||
|
||||
method := args[0]
|
||||
endpoint := args[1]
|
||||
|
||||
if len(args) == 3 {
|
||||
if args[2] == "-" {
|
||||
body, err = io.ReadAll(os.Stdin)
|
||||
} else {
|
||||
body, err = os.ReadFile(args[2])
|
||||
}
|
||||
} else {
|
||||
body = nil
|
||||
}
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, "error reading request payload: %s", err)
|
||||
os.Exit(2)
|
||||
}
|
||||
|
||||
data, err := cli.Request(method, endpoint, body)
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, "error making request: %s\n", err)
|
||||
os.Exit(2)
|
||||
}
|
||||
|
||||
fmt.Println(string(data))
|
||||
}
|
|
@ -42,6 +42,29 @@ See https://salty.im for more details.`,
|
|||
// Disable deadlock detection in production mode
|
||||
sync.Opts.Disable = true
|
||||
}
|
||||
|
||||
identity := viper.GetString("identity")
|
||||
log.Debugf("identity: %s", identity)
|
||||
|
||||
user := viper.GetString("user")
|
||||
log.Debugf("user: %s", user)
|
||||
|
||||
var profiles []profile
|
||||
viper.UnmarshalKey("profiles", &profiles)
|
||||
log.Debugf("profiles: %#v", profiles)
|
||||
for _, p := range profiles {
|
||||
if user == p.User {
|
||||
identity = p.Identity
|
||||
}
|
||||
}
|
||||
log.Debugf("identity: %s", identity)
|
||||
|
||||
me, err := saltyim.ParseAddr(user)
|
||||
if err != nil {
|
||||
log.Debugf("error parsing addr: %s\n", err)
|
||||
}
|
||||
log.Debugf("me: %s", me)
|
||||
// XXX: What if me.IsZero()
|
||||
},
|
||||
}
|
||||
|
||||
|
|
|
@ -6,7 +6,6 @@ import (
|
|||
"os"
|
||||
"strings"
|
||||
|
||||
log "github.com/sirupsen/logrus"
|
||||
"github.com/spf13/cobra"
|
||||
"github.com/spf13/viper"
|
||||
|
||||
|
@ -35,24 +34,24 @@ For example:
|
|||
https://mills.io/.well-known/salty/prologic.json`,
|
||||
Args: cobra.MinimumNArgs(1),
|
||||
Run: func(cmd *cobra.Command, args []string) {
|
||||
user := viper.GetString("user")
|
||||
identity := viper.GetString("identity")
|
||||
|
||||
var profiles []profile
|
||||
viper.UnmarshalKey("profiles", &profiles)
|
||||
for _, p := range profiles {
|
||||
if user == p.User {
|
||||
identity = p.Identity
|
||||
}
|
||||
}
|
||||
|
||||
me, err := saltyim.ParseAddr(user)
|
||||
id, err := saltyim.GetIdentity(
|
||||
saltyim.WithIdentityPath(viper.GetString("identity")),
|
||||
)
|
||||
if err != nil {
|
||||
log.Debugf("error parsing addr: %s\n", err)
|
||||
fmt.Fprintf(os.Stderr, "error loading identity: %s\n", err)
|
||||
os.Exit(2)
|
||||
}
|
||||
// XXX: What if me.IsZero()
|
||||
|
||||
send(me, identity, args[0], args[1:]...)
|
||||
cli, err := saltyim.NewClient(
|
||||
saltyim.WithIdentity(id),
|
||||
saltyim.WithUser(viper.GetString("user")),
|
||||
)
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, "error initializing client: %s\n", err)
|
||||
os.Exit(2)
|
||||
}
|
||||
|
||||
send(cli, args[0], args[1:]...)
|
||||
},
|
||||
}
|
||||
|
||||
|
@ -60,19 +59,13 @@ func init() {
|
|||
rootCmd.AddCommand(sendCmd)
|
||||
}
|
||||
|
||||
func send(me *saltyim.Addr, identity, user string, args ...string) {
|
||||
func send(cli *saltyim.Client, user string, args ...string) {
|
||||
user = strings.TrimSpace(user)
|
||||
if user == "" {
|
||||
fmt.Fprintf(os.Stderr, "error: no user supplied\n")
|
||||
os.Exit(2)
|
||||
}
|
||||
|
||||
cli, err := saltyim.NewClient(me, saltyim.WithClientIdentity(saltyim.WithIdentityPath(identity)))
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, "error initializing client: %s\n", err)
|
||||
os.Exit(2)
|
||||
}
|
||||
|
||||
var msg string
|
||||
|
||||
if len(args) == 0 {
|
||||
|
|
|
@ -0,0 +1,140 @@
|
|||
---
|
||||
title: Salty.im Blob Storage v1
|
||||
description: A proposal for a secure blob store for the reference broker and client for Salty.im
|
||||
tags: blob, store, saltyim
|
||||
---
|
||||
|
||||
# Salty.im Blob Storage v1
|
||||
|
||||
> Design proposal for a blob storage v1 design for the reference broker and client https://git.mills.io/saltyim/saltyim
|
||||
|
||||
[toc]
|
||||
|
||||
## API
|
||||
|
||||
### Types
|
||||
|
||||
```go
|
||||
// Blob defines the type, filename and whether or not a blob is publicly accessible or not.
|
||||
// A Blob also holds zero r more properties as a map of key/value pairs of string interpreted
|
||||
// by the client.
|
||||
type Blob struct {
|
||||
Type string
|
||||
Public bool
|
||||
Filename string
|
||||
Properties map[string]string
|
||||
}
|
||||
|
||||
// BlobRequest is the request used by clients to update blob metadata for blobs stored on a broker.
|
||||
type BlobRequest struct {
|
||||
Blob
|
||||
}
|
||||
```
|
||||
|
||||
### Endpoints
|
||||
|
||||
- `PUT /api/v1/blob/:key`
|
||||
- Stores or updates a blob's contents.
|
||||
- `POST /api/v1/blob/:key`
|
||||
- Updates metadata of a blob
|
||||
- `DELETE /api/v1/blob/:key`
|
||||
- Deletes a blob
|
||||
- `HEAD /api/v1/blob/:key`
|
||||
- Returns metadata of a blob
|
||||
- `GET /api/v1/blob/:key`
|
||||
- Returns metadata and the blob's contents
|
||||
|
||||
General flow of requests and responses:
|
||||
|
||||
1. All requests are signed by the client using it's private key.
|
||||
2. Updates to metadata are performed using a `POST` requests and a body marshaled to JSON using the `NewBlobRequest()` object, all other requests either use their raw request body (`PUT`), headers (`HEAD`, and `GET`) or an empty body (`DELETE`).
|
||||
3. Verification of all signed requests are performed to ensure only the owner of the blob(s) can make modifications, delete or otherwise update a blob's metadata or its contents.
|
||||
4. All responses are standard HTTP response codes as appropriate to the success or failure of a given request:
|
||||
- `200 Ok`
|
||||
- `201 Created`
|
||||
- `400 Bad Request`
|
||||
- `401 Unauthorized`
|
||||
- `403 Forbidden`
|
||||
- `404 Not Found`
|
||||
- `500 Internal Server Error`
|
||||
|
||||
## Storage
|
||||
|
||||
Blobs are stored on-disk in a path structure:
|
||||
|
||||
```
|
||||
/path/to/data/blobs/<owner>/<key>
|
||||
```
|
||||
|
||||
Where:
|
||||
|
||||
- `<owner>` is owner's public key.
|
||||
- `<key>` is the key for the blob (_computed or specified by the client_) and is the content address of the stored blob.
|
||||
|
||||
## Security
|
||||
|
||||
It is up to the client to decide whether or not the stored blob and its contents are encrypted. before submitting it to the blob store service on the broker for storage.
|
||||
|
||||
### Threat Model
|
||||
|
||||
> [name=James Mills] Still working on this...
|
||||
|
||||
The following list of items are threat we simply do not care about or are considered "out of scope":
|
||||
|
||||
- **public key** -- This is public knowledge by design, it can be looked up via a user's Salty Address using for example `salty-chat lookup <address>`.
|
||||
- **law enforcement / state actor** -- We will consider state actors and law enforcement to be out of scope simply because given the resources of powerful actors, if they really wanted to access the contents of a blob, they probably will. However we will make it as hard as we can.
|
||||
|
||||
| Threat Actor | Affected Data | Vulnerability | Priority |
|
||||
| ------------ | ------------- | ------------- | -------- |
|
||||
| operators[^1]| blob[^2] | everything[^3]| P1 |
|
||||
| other users | blob[^2] | everything[^3]| P1 |
|
||||
|
||||
[^1]: all operators, including broker, server and network operators
|
||||
[^2]: all data about a blob, including its location, its contents and metadata
|
||||
[^3]: all or most of the data is vulnerable, including spoofing, modifying, deleting and preventing the other from access, modifying or deleting.
|
||||
|
||||
#### Threat Actors
|
||||
|
||||
1. another user
|
||||
2. broker operator
|
||||
3. server operator
|
||||
4. network operator
|
||||
5. casual eavesdropper
|
||||
6. law enforcement
|
||||
7. state actor
|
||||
|
||||
#### Affected Data
|
||||
|
||||
1. public key
|
||||
2. blob location
|
||||
3. blob contents
|
||||
4. blob metadata
|
||||
|
||||
#### Vulnerabilities
|
||||
|
||||
1. learn the data
|
||||
2. spoof the data
|
||||
3. delete the data
|
||||
4. prevent owner from reading the data
|
||||
5. prevent owner from modifying/deleting the data
|
||||
|
||||
## Properties
|
||||
|
||||
A blob can be accompanied by one or more "Properties" that are stored alongside the blob:
|
||||
|
||||
Stored on-disk as a simple `.json` file along-side the blob as `<key>.json`
|
||||
|
||||
## Sharing
|
||||
|
||||
If a blob's `.Public` flag is set to `true` then the blob is publicly accessible via a shared URL of the form:
|
||||
|
||||
- https://salty.yourdomain.tld/shared/:owner/:key
|
||||
|
||||
Where:
|
||||
|
||||
- `:owner` is the owner's public key.
|
||||
- `:key` is the blob's identifying key (_content-addressing_).
|
||||
|
||||
### Access Controls
|
||||
|
||||
> [name=James Mills] TBD
|
17
go.mod
17
go.mod
|
@ -10,18 +10,19 @@ require (
|
|||
github.com/cyphar/filepath-securejoin v0.2.3
|
||||
github.com/disintegration/gift v1.2.1
|
||||
github.com/disintegration/imageorient v0.0.0-20180920195336-8147d86e83ec
|
||||
github.com/golang-jwt/jwt/v4 v4.4.3
|
||||
github.com/h2non/filetype v1.1.3
|
||||
github.com/likexian/doh-go v0.6.4
|
||||
github.com/mattn/go-isatty v0.0.14
|
||||
github.com/mitchellh/go-homedir v1.1.0
|
||||
github.com/mlctrez/goapp-mdc v0.2.6
|
||||
github.com/nullrocks/identicon v0.0.0-20180626043057-7875f45b0022
|
||||
github.com/oklog/ulid/v2 v2.0.2
|
||||
github.com/oklog/ulid/v2 v2.1.0
|
||||
github.com/sasha-s/go-deadlock v0.3.1
|
||||
github.com/sirupsen/logrus v1.8.1
|
||||
github.com/spf13/cobra v1.4.0
|
||||
github.com/spf13/viper v1.10.1
|
||||
github.com/stretchr/testify v1.7.0
|
||||
github.com/stretchr/testify v1.8.0
|
||||
github.com/taigrr/go-colorhash v0.0.0-20220329080504-742db7f45eae
|
||||
github.com/timewasted/go-accept-headers v0.0.0-20130320203746-c78f304b1b09
|
||||
go.mills.io/salty v0.0.0-20220322161301-ce2b9f6573fa
|
||||
|
@ -54,15 +55,16 @@ require (
|
|||
github.com/lucasb-eyer/go-colorful v1.2.0 // indirect
|
||||
github.com/magiconair/properties v1.8.6 // indirect
|
||||
github.com/mattn/go-runewidth v0.0.13 // indirect
|
||||
github.com/matttproud/golang_protobuf_extensions v1.0.1 // indirect
|
||||
github.com/mitchellh/mapstructure v1.4.3 // indirect
|
||||
github.com/matttproud/golang_protobuf_extensions v1.0.2-0.20181231171920-c182affec369 // indirect
|
||||
github.com/mitchellh/mapstructure v1.5.0 // indirect
|
||||
github.com/oklog/ulid v1.3.1 // indirect
|
||||
github.com/patrickmn/go-cache v2.1.0+incompatible
|
||||
github.com/pelletier/go-toml v1.9.4 // indirect
|
||||
github.com/petermattis/goid v0.0.0-20220331194723-8ee3e6ded87a // indirect
|
||||
github.com/pkg/errors v0.9.1 // indirect
|
||||
github.com/plar/go-adaptive-radix-tree v1.0.4 // indirect
|
||||
github.com/pmezard/go-difflib v1.0.0 // indirect
|
||||
github.com/prometheus/client_golang v1.12.1 // indirect
|
||||
github.com/prometheus/client_golang v1.12.2 // indirect
|
||||
github.com/prometheus/client_model v0.2.0 // indirect
|
||||
github.com/prometheus/common v0.33.0 // indirect
|
||||
github.com/prometheus/procfs v0.7.3 // indirect
|
||||
|
@ -83,6 +85,7 @@ require (
|
|||
github.com/vmihailenco/tagparser/v2 v2.0.0 // indirect
|
||||
github.com/writeas/go-strip-markdown/v2 v2.1.1 // indirect
|
||||
golang.org/x/exp v0.0.0-20220328175248-053ad81199eb // indirect
|
||||
golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4 // indirect
|
||||
golang.org/x/sys v0.0.0-20220405052023-b1e9470b6e64 // indirect
|
||||
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211 // indirect
|
||||
golang.org/x/text v0.3.7 // indirect
|
||||
|
@ -90,7 +93,7 @@ require (
|
|||
google.golang.org/protobuf v1.28.0 // indirect
|
||||
gopkg.in/ini.v1 v1.66.4 // indirect
|
||||
gopkg.in/yaml.v2 v2.4.0 // indirect
|
||||
gopkg.in/yaml.v3 v3.0.0-20210107192922-496545a6307b // indirect
|
||||
gopkg.in/yaml.v3 v3.0.1 // indirect
|
||||
nhooyr.io/websocket v1.8.7 // indirect
|
||||
)
|
||||
|
||||
|
@ -101,7 +104,7 @@ require (
|
|||
git.mills.io/prologic/useragent v0.0.0-20210714100044-d249fe7921a0
|
||||
github.com/NYTimes/gziphandler v1.1.1
|
||||
github.com/gdamore/tcell/v2 v2.4.1-0.20210905002822-f057f0a857a1
|
||||
github.com/google/go-cmp v0.5.7 // indirect
|
||||
github.com/google/go-cmp v0.5.8 // indirect
|
||||
github.com/hashicorp/errwrap v1.1.0 // indirect
|
||||
github.com/julienschmidt/httprouter v1.3.0
|
||||
github.com/justinas/nosurf v1.1.1
|
||||
|
|
36
go.sum
36
go.sum
|
@ -175,6 +175,8 @@ github.com/gofrs/flock v0.8.1/go.mod h1:F1TvTiK9OcQqauNUHlbJvyl9Qa1QvF/gOUDKA14j
|
|||
github.com/gogo/protobuf v1.1.1/go.mod h1:r8qH/GZQm5c6nD/R0oafs1akxWv10x8SbQlK7atdtwQ=
|
||||
github.com/gogo/protobuf v1.2.1/go.mod h1:hp+jE20tsWTFYpLwKvXlhS1hjn+gTNwPg2I6zVXpSg4=
|
||||
github.com/gogo/protobuf v1.3.2/go.mod h1:P1XiOD3dCwIKUDQYPy72D8LYyHL2YPYrpS2s69NZV8Q=
|
||||
github.com/golang-jwt/jwt/v4 v4.4.3 h1:Hxl6lhQFj4AnOX6MLrsCb/+7tCj7DxP7VA+2rDIq5AU=
|
||||
github.com/golang-jwt/jwt/v4 v4.4.3/go.mod h1:m21LjoU+eqJr34lmDMbreY2eSTRJ1cv77w39/MY0Ch0=
|
||||
github.com/golang/glog v0.0.0-20160126235308-23def4e6c14b/go.mod h1:SBH7ygxi8pfUlaOkMMuAQtPIUF8ecWP5IEl/CR7VP2Q=
|
||||
github.com/golang/groupcache v0.0.0-20190129154638-5b532d6fd5ef/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc=
|
||||
github.com/golang/groupcache v0.0.0-20190702054246-869f871628b6/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc=
|
||||
|
@ -220,8 +222,8 @@ github.com/google/go-cmp v0.5.2/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/
|
|||
github.com/google/go-cmp v0.5.3/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
|
||||
github.com/google/go-cmp v0.5.4/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
|
||||
github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
|
||||
github.com/google/go-cmp v0.5.7 h1:81/ik6ipDQS2aGcBfIN5dHDB36BwrStyeAQquSYCV4o=
|
||||
github.com/google/go-cmp v0.5.7/go.mod h1:n+brtR0CgQNWTVd5ZUFpTBC8YFBDLK/h/bpaJ8/DtOE=
|
||||
github.com/google/go-cmp v0.5.8 h1:e6P7q2lk1O+qJJb4BtCQXlK8vWEO8V1ZeuEdJNOqZyg=
|
||||
github.com/google/go-cmp v0.5.8/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
|
||||
github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
|
||||
github.com/google/martian v2.1.0+incompatible/go.mod h1:9I4somxYTbIHy5NJKHRl3wXiIaQGbYVAs8BPL6v8lEs=
|
||||
github.com/google/martian/v3 v3.0.0/go.mod h1:y5Zk1BBys9G+gd6Jrk0W3cC1+ELVxBWuIGO+w/tUAp0=
|
||||
|
@ -357,8 +359,9 @@ github.com/mattn/go-isatty v0.0.14 h1:yVuAays6BHfxijgZPzw+3Zlu5yQgKGP2/hcQbHb7S9
|
|||
github.com/mattn/go-isatty v0.0.14/go.mod h1:7GGIvUiUoEMVVmxf/4nioHXj79iQHKdU27kJ6hsGG94=
|
||||
github.com/mattn/go-runewidth v0.0.13 h1:lTGmDsbAYt5DmK6OnoV7EuIF1wEIFAcxld6ypU4OSgU=
|
||||
github.com/mattn/go-runewidth v0.0.13/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w=
|
||||
github.com/matttproud/golang_protobuf_extensions v1.0.1 h1:4hp9jkHxhMHkqkrB3Ix0jegS5sx/RkqARlsWZ6pIwiU=
|
||||
github.com/matttproud/golang_protobuf_extensions v1.0.1/go.mod h1:D8He9yQNgCq6Z5Ld7szi9bcBfOoFv/3dc6xSMkL2PC0=
|
||||
github.com/matttproud/golang_protobuf_extensions v1.0.2-0.20181231171920-c182affec369 h1:I0XW9+e1XWDxdcEniV4rQAIOPUGDq67JSCiRCgGCZLI=
|
||||
github.com/matttproud/golang_protobuf_extensions v1.0.2-0.20181231171920-c182affec369/go.mod h1:BSXmuO+STAnVfrANrmjBb36TMTDstsz7MSK+HVaYKv4=
|
||||
github.com/maxence-charriere/go-app/v9 v9.4.1 h1:uDrMIvjzkXwBjw5594i7ZqD5LY5iN7j1KeMImjWAYiw=
|
||||
github.com/maxence-charriere/go-app/v9 v9.4.1/go.mod h1:zo0n1kh4OMKn7P+MrTUUi7QwUMU2HOfHsZ293TITtxI=
|
||||
github.com/miekg/dns v1.0.14/go.mod h1:W1PPwlIAgtquWBMBEV9nkV9Cazfe8ScdGz/Lj7v3Nrg=
|
||||
|
@ -372,8 +375,8 @@ github.com/mitchellh/iochan v1.0.0/go.mod h1:JwYml1nuB7xOzsp52dPpHFffvOCDupsG0Qu
|
|||
github.com/mitchellh/mapstructure v0.0.0-20160808181253-ca63d7c062ee/go.mod h1:FVVH3fgwuzCH5S8UJGiWEs2h04kUh9fWfEaFds41c1Y=
|
||||
github.com/mitchellh/mapstructure v1.1.2/go.mod h1:FVVH3fgwuzCH5S8UJGiWEs2h04kUh9fWfEaFds41c1Y=
|
||||
github.com/mitchellh/mapstructure v1.4.1/go.mod h1:bFUtVrKA4DC2yAKiSyO/QUcy7e+RRV2QTWOzhPopBRo=
|
||||
github.com/mitchellh/mapstructure v1.4.3 h1:OVowDSCllw/YjdLkam3/sm7wEtOy59d8ndGgCcyj8cs=
|
||||
github.com/mitchellh/mapstructure v1.4.3/go.mod h1:bFUtVrKA4DC2yAKiSyO/QUcy7e+RRV2QTWOzhPopBRo=
|
||||
github.com/mitchellh/mapstructure v1.5.0 h1:jeMsZIYE/09sWLaz43PL7Gy6RuMjD2eJVyuac5Z2hdY=
|
||||
github.com/mitchellh/mapstructure v1.5.0/go.mod h1:bFUtVrKA4DC2yAKiSyO/QUcy7e+RRV2QTWOzhPopBRo=
|
||||
github.com/mlctrez/goapp-mdc v0.2.6 h1:/nSRAqC3xz+GFCd3Zl3yI87bBeEpJVXi5FSMnRighXo=
|
||||
github.com/mlctrez/goapp-mdc v0.2.6/go.mod h1:hrbfhTSPD7jaaubJsUweirnVkbwtwQJcjJm5OrH+rVo=
|
||||
github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
|
||||
|
@ -389,9 +392,11 @@ github.com/nullrocks/identicon v0.0.0-20180626043057-7875f45b0022 h1:Ys0rDzh8s4U
|
|||
github.com/nullrocks/identicon v0.0.0-20180626043057-7875f45b0022/go.mod h1:x4NsS+uc7ecH/Cbm9xKQ6XzmJM57rWTkjywjfB2yQ18=
|
||||
github.com/oklog/ulid v1.3.1 h1:EGfNDEx6MqHz8B3uNV6QAib1UR2Lm97sHi3ocA6ESJ4=
|
||||
github.com/oklog/ulid v1.3.1/go.mod h1:CirwcVhetQ6Lv90oh/F+FBtV6XMibvdAFo93nm5qn4U=
|
||||
github.com/oklog/ulid/v2 v2.0.2 h1:r4fFzBm+bv0wNKNh5eXTwU7i85y5x+uwkxCUTNVQqLc=
|
||||
github.com/oklog/ulid/v2 v2.0.2/go.mod h1:mtBL0Qe/0HAx6/a4Z30qxVIAL1eQDweXq5lxOEiwQ68=
|
||||
github.com/oklog/ulid/v2 v2.1.0 h1:+9lhoxAP56we25tyYETBBY1YLA2SaoLvUFgrP2miPJU=
|
||||
github.com/oklog/ulid/v2 v2.1.0/go.mod h1:rcEKHmBBKfef9DhnvX7y1HZBYxjXb0cP5ExxNsTT1QQ=
|
||||
github.com/pascaldekloe/goe v0.0.0-20180627143212-57f6aae5913c/go.mod h1:lzWF7FIEvWOWxwDKqyGYQf6ZUaNfKdP144TG7ZOy1lc=
|
||||
github.com/patrickmn/go-cache v2.1.0+incompatible h1:HRMgzkcYKYpi3C8ajMPV8OFXaaRUnok+kx1WdO15EQc=
|
||||
github.com/patrickmn/go-cache v2.1.0+incompatible/go.mod h1:3Qf8kWWT7OJRJbdiICTKqZju1ZixQ/KpMGzzAfe6+WQ=
|
||||
github.com/pborman/getopt v0.0.0-20170112200414-7148bc3a4c30/go.mod h1:85jBQOZwpVEaDAr341tbn15RS4fCAsIst0qp7i8ex1o=
|
||||
github.com/pelletier/go-toml v1.2.0/go.mod h1:5z9KED0ma1S8pY6P1sdut58dfprrGBbd/94hg7ilaic=
|
||||
github.com/pelletier/go-toml v1.9.3/go.mod h1:u1nR/EPcESfeI/szUZKdtJ0xRNbUoANCkoOuaOx1Y+c=
|
||||
|
@ -420,8 +425,9 @@ github.com/prometheus/client_golang v0.9.3/go.mod h1:/TN21ttK/J9q6uSwhBd54HahCDf
|
|||
github.com/prometheus/client_golang v1.0.0/go.mod h1:db9x61etRT2tGnBNRi70OPL5FsnadC4Ky3P0J6CfImo=
|
||||
github.com/prometheus/client_golang v1.7.1/go.mod h1:PY5Wy2awLA44sXw4AOSfFBetzPP4j5+D6mVACh+pe2M=
|
||||
github.com/prometheus/client_golang v1.11.0/go.mod h1:Z6t4BnS23TR94PD6BsDNk8yVqroYurpAkEiz0P2BEV0=
|
||||
github.com/prometheus/client_golang v1.12.1 h1:ZiaPsmm9uiBeaSMRznKsCDNtPCS0T3JVDGF+06gjBzk=
|
||||
github.com/prometheus/client_golang v1.12.1/go.mod h1:3Z9XVyYiZYEO+YQWt3RD2R3jrbd179Rt297l4aS6nDY=
|
||||
github.com/prometheus/client_golang v1.12.2 h1:51L9cDoUHVrXx4zWYlcLQIZ+d+VXHgqnYKkIuq4g/34=
|
||||
github.com/prometheus/client_golang v1.12.2/go.mod h1:3Z9XVyYiZYEO+YQWt3RD2R3jrbd179Rt297l4aS6nDY=
|
||||
github.com/prometheus/client_model v0.0.0-20180712105110-5c3871d89910/go.mod h1:MbSGuTsp3dbXC40dX6PRTWyKYBIrTGTE9sqQNg2J8bo=
|
||||
github.com/prometheus/client_model v0.0.0-20190129233127-fd36f4220a90/go.mod h1:xMI15A0UPsDsEKsMN9yxemIoYk6Tm2C1GtYGdfGttqA=
|
||||
github.com/prometheus/client_model v0.0.0-20190812154241-14fe0d1b01d4/go.mod h1:xMI15A0UPsDsEKsMN9yxemIoYk6Tm2C1GtYGdfGttqA=
|
||||
|
@ -500,15 +506,18 @@ github.com/spf13/viper v1.10.1/go.mod h1:IGlFPqhNAPKRxohIzWpI5QEy4kuI7tcl5WvR+8q
|
|||
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
|
||||
github.com/stretchr/objx v0.1.1/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
|
||||
github.com/stretchr/objx v0.2.0/go.mod h1:qt09Ya8vawLte6SNmTgCsAVtYtaKzEcn8ATUoHMkEqE=
|
||||
github.com/stretchr/objx v0.3.0 h1:NGXK3lHquSN08v5vWalVI/L8XU9hdzE/G6xsrze47As=
|
||||
github.com/stretchr/objx v0.3.0/go.mod h1:qt09Ya8vawLte6SNmTgCsAVtYtaKzEcn8ATUoHMkEqE=
|
||||
github.com/stretchr/objx v0.4.0 h1:M2gUjqZET1qApGOWNSnZ49BAIMX4F/1plDv3+l31EJ4=
|
||||
github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
|
||||
github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs=
|
||||
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
|
||||
github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4=
|
||||
github.com/stretchr/testify v1.5.1/go.mod h1:5W2xD1RspED5o8YsWQXVCued0rvSQ+mT+I5cxcmMvtA=
|
||||
github.com/stretchr/testify v1.6.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
||||
github.com/stretchr/testify v1.7.0 h1:nwc3DEeHmmLAfoZucVR881uASk0Mfjw8xYJ99tb5CcY=
|
||||
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
||||
github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
||||
github.com/stretchr/testify v1.8.0 h1:pSgiaMZlXftHpm5L7V1+rVB+AZJydKsMxsQBIJw4PKk=
|
||||
github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
|
||||
github.com/subosito/gotenv v1.2.0 h1:Slr1R9HxAlEKefgq5jn9U+DnETlIUa6HfgEzj0g5d7s=
|
||||
github.com/subosito/gotenv v1.2.0/go.mod h1:N0PQaV/YGNqwC0u51sEeR/aUtSLEXKX9iv69rRypqCw=
|
||||
github.com/taigrr/go-colorhash v0.0.0-20220329080504-742db7f45eae h1:RXzKJmV0lGvBpY8/43bJShhPYIssF7X18UVMs9KIgIQ=
|
||||
|
@ -714,8 +723,9 @@ golang.org/x/sync v0.0.0-20200317015054-43a5402ce75a/go.mod h1:RxMgew5VJxzue5/jJ
|
|||
golang.org/x/sync v0.0.0-20200625203802-6e8e738ad208/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.0.0-20201207232520-09787c993a3a/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.0.0-20210220032951-036812b2e83c h1:5KslGYwFpkhGh+Q16bwMP3cOontH8FOep7tGV86Y7SQ=
|
||||
golang.org/x/sync v0.0.0-20210220032951-036812b2e83c/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4 h1:uVc8UZUe6tr40fFVnUP5Oj+veunVezqYl9z7DYw9xzw=
|
||||
golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sys v0.0.0-20180823144017-11551d06cbcc/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
||||
golang.org/x/sys v0.0.0-20180830151530-49385e6e1522/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
||||
golang.org/x/sys v0.0.0-20180905080454-ebe1bf3edb33/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
||||
|
@ -856,7 +866,6 @@ golang.org/x/tools v0.1.2/go.mod h1:o0xws9oXOQQZyjljx8fwUC0k7L1pTE6eaCbjGeHmOkk=
|
|||
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=
|
||||
golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1 h1:go1bK/D/BFZV2I8cIQd1NKEZ+0owSTG1fDTci4IqFcE=
|
||||
golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||
google.golang.org/api v0.4.0/go.mod h1:8k5glujaEP+g9n7WNsDg8QP6cUVNI86fCNMcbazEtwE=
|
||||
google.golang.org/api v0.7.0/go.mod h1:WtwebWUNSVBH/HAw79HIFXZNqEvBhG+Ra+ax0hx3E3M=
|
||||
|
@ -988,8 +997,9 @@ gopkg.in/yaml.v2 v2.3.0/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
|
|||
gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY=
|
||||
gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ=
|
||||
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
gopkg.in/yaml.v3 v3.0.0-20210107192922-496545a6307b h1:h8qDotaEPuJATrMmW04NCwg7v22aHH28wwpauUhK9Oo=
|
||||
gopkg.in/yaml.v3 v3.0.0-20210107192922-496545a6307b/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
honnef.co/go/tools v0.0.0-20190102054323-c2f93a96b099/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4=
|
||||
honnef.co/go/tools v0.0.0-20190106161140-3f1c8253044a/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4=
|
||||
honnef.co/go/tools v0.0.0-20190418001031-e561f6794a2a/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4=
|
||||
|
|
|
@ -175,14 +175,6 @@ func WithIdentityAddr(addr *Addr) IdentityOption {
|
|||
return func(i *Identity) { i.addr = addr }
|
||||
}
|
||||
|
||||
// WithIdentity indicates that an identity should be passed in
|
||||
func WithIdentity(ident *Identity) IdentityOption {
|
||||
return func(i *Identity) {
|
||||
i.key = ident.key
|
||||
i.addr = ident.addr
|
||||
}
|
||||
}
|
||||
|
||||
// WithIdentityPath indicates that an identity should be read / written from a file path
|
||||
func WithIdentityPath(path string) IdentityOption {
|
||||
return func(i *Identity) { i.path = path }
|
||||
|
|
|
@ -1,6 +1,8 @@
|
|||
package internal
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
|
||||
|
@ -9,6 +11,7 @@ import (
|
|||
"github.com/unrolled/render"
|
||||
|
||||
"go.mills.io/saltyim"
|
||||
"go.mills.io/saltyim/internal/authreq"
|
||||
)
|
||||
|
||||
// API ...
|
||||
|
@ -35,6 +38,13 @@ func (a *API) initRoutes() {
|
|||
router.GET("/ping", a.PingEndpoint())
|
||||
router.POST("/register", a.RegisterEndpoint())
|
||||
|
||||
// Blob Service
|
||||
router.DELETE("/blob/:key", authreq.VerifyMiddleware(a.BlobEndpoint()))
|
||||
router.HEAD("/blob/:key", authreq.VerifyMiddleware(a.BlobEndpoint()))
|
||||
router.GET("/blob/:key", authreq.VerifyMiddleware(a.BlobEndpoint()))
|
||||
router.PUT("/blob/:key", authreq.VerifyMiddleware(a.BlobEndpoint()))
|
||||
router.POST("/blob/:key", authreq.VerifyMiddleware(a.BlobEndpoint()))
|
||||
|
||||
// Lookup and Send support for Web / PWA clients
|
||||
router.GET("/lookup/:addr", a.LookupEndpoint())
|
||||
router.POST("/send", a.SendEndpoint())
|
||||
|
@ -147,3 +157,70 @@ func (a *API) AvatarEndpoint() httprouter.Handle {
|
|||
http.Error(w, "Avatar Created", http.StatusCreated)
|
||||
}
|
||||
}
|
||||
|
||||
// BlobEndpoint ...
|
||||
func (a *API) BlobEndpoint() httprouter.Handle {
|
||||
return func(w http.ResponseWriter, r *http.Request, p httprouter.Params) {
|
||||
claims := authreq.ClaimsFromRequest(r)
|
||||
if claims == nil {
|
||||
log.Warn("no claims")
|
||||
http.Error(w, "Unauthorised", http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
signer := claims.Issuer
|
||||
|
||||
key := p.ByName("key")
|
||||
|
||||
switch r.Method {
|
||||
case http.MethodDelete:
|
||||
if err := DeleteBlob(a.config, key, signer); err != nil {
|
||||
if errors.Is(err, ErrBlobNotFound) {
|
||||
http.Error(w, "Blob Not Found", http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
|
||||
log.WithError(err).Errorf("error getting blob %s for %s", key, signer)
|
||||
http.Error(w, "Internal Server Error", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
http.Error(w, "Blob Deleted", http.StatusOK)
|
||||
case http.MethodGet, http.MethodHead:
|
||||
blob, err := GetBlob(a.config, key, signer)
|
||||
if err != nil {
|
||||
if errors.Is(err, ErrBlobNotFound) {
|
||||
http.Error(w, "Blob Not Found", http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
|
||||
log.WithError(err).Errorf("error getting blob %s for %s", key, signer)
|
||||
http.Error(w, "Internal Server Error", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
defer blob.Close()
|
||||
|
||||
blob.SetHeaders(r)
|
||||
|
||||
if r.Method == http.MethodGet {
|
||||
_, _ = io.Copy(w, blob)
|
||||
}
|
||||
case http.MethodPut:
|
||||
data, err := io.ReadAll(r.Body)
|
||||
if err != nil {
|
||||
http.Error(w, "Internal Server Error", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
defer r.Body.Close()
|
||||
|
||||
if err := CreateOrUpdateBlob(a.config, key, data, signer); err != nil {
|
||||
log.WithError(err).Errorf("error creating/updating blob for %s for %s", key, signer)
|
||||
http.Error(w, "Avatar Error", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
http.Error(w, "Blob Created", http.StatusCreated)
|
||||
default:
|
||||
http.Error(w, "Method Not Allowed", http.StatusMethodNotAllowed)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -0,0 +1,139 @@
|
|||
package internal_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"os"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
"golang.org/x/sync/errgroup"
|
||||
|
||||
"go.mills.io/saltyim/internal"
|
||||
)
|
||||
|
||||
var (
|
||||
serverBind = ":61234"
|
||||
serverBaseURL = "http://localhost:61234"
|
||||
serverPrimaryDomain = "localhost"
|
||||
)
|
||||
|
||||
func TestMain(m *testing.M) {
|
||||
data, err := os.MkdirTemp("", "data*")
|
||||
if err != nil {
|
||||
fmt.Printf("error creating data dir: %s\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
defer os.RemoveAll(data)
|
||||
|
||||
svr, err := internal.NewServer(serverBind,
|
||||
// Debug mode
|
||||
internal.WithDebug(true),
|
||||
|
||||
// TLS options
|
||||
internal.WithTLS(false),
|
||||
internal.WithTLSKey(""),
|
||||
internal.WithTLSCert(""),
|
||||
|
||||
// Basic options
|
||||
internal.WithData(data),
|
||||
internal.WithStore("memory://"),
|
||||
internal.WithBaseURL(serverBaseURL),
|
||||
internal.WithPrimaryDomain(serverPrimaryDomain),
|
||||
|
||||
// Oeprator
|
||||
internal.WithAdminUser("admin@localhost"),
|
||||
internal.WithSupportEmail("support@localhost"),
|
||||
)
|
||||
if err != nil {
|
||||
fmt.Printf("error creating server: %s\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
stop := make(chan struct{})
|
||||
wg, ctx := errgroup.WithContext(context.Background())
|
||||
|
||||
wg.Go(svr.Run)
|
||||
wg.Go(func() error {
|
||||
<-stop
|
||||
ctx, cancel := context.WithTimeout(ctx, 2*time.Second)
|
||||
defer cancel()
|
||||
|
||||
return svr.Shutdown(ctx)
|
||||
})
|
||||
|
||||
i := m.Run()
|
||||
|
||||
close(stop)
|
||||
|
||||
go func() {
|
||||
<-time.After(3 * time.Second)
|
||||
fmt.Println("KILLING IT ALL!! Service failed to shutdown on time!!")
|
||||
os.Exit(0)
|
||||
}()
|
||||
|
||||
err = wg.Wait()
|
||||
fmt.Println(err)
|
||||
|
||||
os.Exit(i)
|
||||
}
|
||||
|
||||
func TestBlobPostNoAuthentication(t *testing.T) {
|
||||
assert := assert.New(t)
|
||||
|
||||
req, err := http.NewRequest(http.MethodGet, serverBaseURL+"/api/v1/blob/test", nil)
|
||||
assert.NoError(err)
|
||||
res, err := http.DefaultClient.Do(req)
|
||||
assert.NoError(err)
|
||||
|
||||
assert.Equal(http.StatusUnauthorized, res.StatusCode)
|
||||
}
|
||||
|
||||
func TestPing(t *testing.T) {
|
||||
assert := assert.New(t)
|
||||
|
||||
req, err := http.NewRequest(http.MethodGet, serverBaseURL+"/api/v1/ping", nil)
|
||||
assert.NoError(err)
|
||||
res, err := http.DefaultClient.Do(req)
|
||||
assert.NoError(err)
|
||||
|
||||
assert.Equal(http.StatusOK, res.StatusCode)
|
||||
b, err := io.ReadAll(res.Body)
|
||||
assert.NoError(err)
|
||||
assert.Equal("{}", string(b))
|
||||
}
|
||||
|
||||
func TestBlobPut(t *testing.T) {
|
||||
require := require.New(t)
|
||||
|
||||
cli := internal.NewTestUser("alice@localhost", serverBaseURL, t)
|
||||
|
||||
err := cli.Register(serverBaseURL)
|
||||
require.NoError(err)
|
||||
|
||||
body := []byte("Hello World!")
|
||||
_, err = cli.Request(http.MethodPut, serverBaseURL+"/api/v1/blob/test", body)
|
||||
require.NoError(err)
|
||||
}
|
||||
|
||||
func TestBlobGet(t *testing.T) {
|
||||
assert := assert.New(t)
|
||||
require := require.New(t)
|
||||
|
||||
cli := internal.NewTestUser("alice@localhost", serverBaseURL, t)
|
||||
|
||||
err := cli.Register(serverBaseURL)
|
||||
require.NoError(err)
|
||||
|
||||
body := []byte("Hello World!")
|
||||
_, err = cli.Request(http.MethodPut, serverBaseURL+"/api/v1/blob/test", body)
|
||||
require.NoError(err)
|
||||
|
||||
data, err := cli.Request(http.MethodGet, serverBaseURL+"/api/v1/blob/test", nil)
|
||||
require.NoError(err)
|
||||
assert.Equal(body, data, "expected returned blob to be identical to what we stored")
|
||||
}
|
|
@ -0,0 +1,157 @@
|
|||
// Package authreq signa and verifies HTTP requests using Ed25519 private/public keys
|
||||
package authreq
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"crypto/ed25519"
|
||||
"encoding/base64"
|
||||
"fmt"
|
||||
"hash/fnv"
|
||||
"io"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/golang-jwt/jwt/v4"
|
||||
"github.com/julienschmidt/httprouter"
|
||||
"github.com/oklog/ulid/v2"
|
||||
"github.com/patrickmn/go-cache"
|
||||
log "github.com/sirupsen/logrus"
|
||||
)
|
||||
|
||||
type contextKey int
|
||||
|
||||
const (
|
||||
claimsContextKey contextKey = iota
|
||||
authorizationHeader = "Authorization"
|
||||
signatureLifetime = 5 * time.Minute
|
||||
)
|
||||
|
||||
// ClaimsFromRequest returns the JWT claims object associated with the HTTP request (if any)
|
||||
func ClaimsFromRequest(r *http.Request) *jwt.RegisteredClaims {
|
||||
if claims := r.Context().Value(claimsContextKey); claims != nil {
|
||||
return claims.(*jwt.RegisteredClaims)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Sign signs the HTTP request with a Ed25519 private key
|
||||
func Sign(req *http.Request, key ed25519.PrivateKey) (*http.Request, error) {
|
||||
pub := enc([]byte(key.Public().(ed25519.PublicKey)))
|
||||
|
||||
h := fnv.New128a()
|
||||
fmt.Fprint(h, req.Method, req.URL.Path)
|
||||
|
||||
if req.Body != nil {
|
||||
b := &bytes.Buffer{}
|
||||
w := io.MultiWriter(h, b)
|
||||
_, err := io.Copy(w, req.Body)
|
||||
if err != nil {
|
||||
return req, err
|
||||
}
|
||||
req.Body = io.NopCloser(b)
|
||||
}
|
||||
|
||||
token := jwt.NewWithClaims(jwt.SigningMethodEdDSA, jwt.RegisteredClaims{
|
||||
ID: ulid.Make().String(),
|
||||
Subject: enc(h.Sum(nil)),
|
||||
ExpiresAt: jwt.NewNumericDate(time.Now().Add(signatureLifetime)),
|
||||
IssuedAt: jwt.NewNumericDate(time.Now()),
|
||||
Issuer: pub,
|
||||
})
|
||||
|
||||
sig, err := token.SignedString(key)
|
||||
if err != nil {
|
||||
return req, err
|
||||
}
|
||||
|
||||
req.Header.Set(authorizationHeader, sig)
|
||||
|
||||
return req, nil
|
||||
}
|
||||
|
||||
// VerifyMiddleware is a httprouter.handler middleware that verifies HTTP requests previously
|
||||
// signed with a Ed25519 private key
|
||||
func VerifyMiddleware(next httprouter.Handle) httprouter.Handle {
|
||||
usedIDs := cache.New(signatureLifetime, 2*signatureLifetime)
|
||||
|
||||
return func(rw http.ResponseWriter, req *http.Request, p httprouter.Params) {
|
||||
log.Debugf("XXX")
|
||||
auth := req.Header.Get(authorizationHeader)
|
||||
if auth == "" {
|
||||
log.Warn("no authorization found")
|
||||
rw.WriteHeader(http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
|
||||
hash := fnv.New128a()
|
||||
fmt.Fprint(hash, req.Method, req.URL.Path)
|
||||
|
||||
if req.Body != nil {
|
||||
buf := &bytes.Buffer{}
|
||||
mw := io.MultiWriter(hash, buf)
|
||||
_, err := io.Copy(mw, req.Body)
|
||||
if err != nil {
|
||||
log.WithError(err).Error("error hasning request body")
|
||||
rw.WriteHeader(http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
subject := enc(hash.Sum(nil))
|
||||
token, err := jwt.ParseWithClaims(
|
||||
string(auth),
|
||||
&jwt.RegisteredClaims{},
|
||||
func(token *jwt.Token) (any, error) {
|
||||
claims, ok := token.Claims.(*jwt.RegisteredClaims)
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("wrong type of claim")
|
||||
}
|
||||
|
||||
pub, err := dec(claims.Issuer)
|
||||
return ed25519.PublicKey(pub), err
|
||||
},
|
||||
jwt.WithValidMethods([]string{"EdDSA"}),
|
||||
jwt.WithJSONNumber(),
|
||||
)
|
||||
if err != nil {
|
||||
log.WithError(err).Error("error verifying token")
|
||||
rw.WriteHeader(http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
claims, ok := token.Claims.(*jwt.RegisteredClaims)
|
||||
if !ok {
|
||||
log.Warn("no claims found!")
|
||||
rw.WriteHeader(http.StatusUnprocessableEntity)
|
||||
return
|
||||
}
|
||||
|
||||
if claims.ID != "" {
|
||||
if _, ok := usedIDs.Get(claims.ID); ok {
|
||||
log.Warnf("ID has been seen before: %s", claims.ID)
|
||||
rw.WriteHeader(http.StatusForbidden)
|
||||
return
|
||||
}
|
||||
usedIDs.SetDefault(claims.ID, true)
|
||||
}
|
||||
|
||||
if claims.Subject != subject {
|
||||
log.Warnf("subjects do not match %s != %q", claims.Subject, subject)
|
||||
rw.WriteHeader(http.StatusForbidden)
|
||||
return
|
||||
}
|
||||
|
||||
ctx := context.WithValue(req.Context(), claimsContextKey, claims)
|
||||
next(rw, req.WithContext(ctx), p)
|
||||
}
|
||||
}
|
||||
|
||||
func enc(b []byte) string {
|
||||
return base64.RawURLEncoding.EncodeToString(b)
|
||||
}
|
||||
func dec(s string) ([]byte, error) {
|
||||
s = strings.TrimSpace(s)
|
||||
return base64.RawURLEncoding.DecodeString(s)
|
||||
}
|
|
@ -0,0 +1,111 @@
|
|||
package authreq_test
|
||||
|
||||
import (
|
||||
"crypto/ed25519"
|
||||
"encoding/base64"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/julienschmidt/httprouter"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
"go.mills.io/saltyim/internal/authreq"
|
||||
)
|
||||
|
||||
var authorizationHeader = "Authorization"
|
||||
|
||||
func TestGETRequest(t *testing.T) {
|
||||
assert := assert.New(t)
|
||||
require := require.New(t)
|
||||
|
||||
pub, priv, err := ed25519.GenerateKey(nil)
|
||||
require.NoError(err)
|
||||
|
||||
req, err := http.NewRequest(http.MethodGet, "http://example.com/"+enc(pub)+"/test?q=test", nil)
|
||||
require.NoError(err)
|
||||
|
||||
req, err = authreq.Sign(req, priv)
|
||||
require.NoError(err)
|
||||
|
||||
t.Log(enc(pub))
|
||||
t.Log(req.Header.Get(authorizationHeader))
|
||||
|
||||
var hdlr httprouter.Handle = func(w http.ResponseWriter, r *http.Request, p httprouter.Params) {
|
||||
c := authreq.ClaimsFromRequest(r)
|
||||
if c == nil {
|
||||
w.WriteHeader(http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
if !strings.Contains(req.URL.Path, c.Issuer) {
|
||||
w.WriteHeader(http.StatusForbidden)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
hdlr = authreq.VerifyMiddleware(hdlr)
|
||||
|
||||
rw := httptest.NewRecorder()
|
||||
|
||||
hdlr(rw, req, nil)
|
||||
|
||||
assert.Equal(rw.Code, http.StatusOK)
|
||||
}
|
||||
|
||||
func TestPOSTRequest(t *testing.T) {
|
||||
assert := assert.New(t)
|
||||
require := require.New(t)
|
||||
|
||||
content := "this is post!"
|
||||
|
||||
pub, priv, err := ed25519.GenerateKey(nil)
|
||||
require.NoError(err)
|
||||
|
||||
req, err := http.NewRequest(http.MethodPost, "http://example.com/"+enc(pub)+"/test?q=test", strings.NewReader(content))
|
||||
require.NoError(err)
|
||||
|
||||
req, err = authreq.Sign(req, priv)
|
||||
require.NoError(err)
|
||||
|
||||
t.Log(enc(pub))
|
||||
t.Log(req.Header.Get(authorizationHeader))
|
||||
|
||||
var hdlr httprouter.Handle = func(w http.ResponseWriter, r *http.Request, p httprouter.Params) {
|
||||
c := authreq.ClaimsFromRequest(r)
|
||||
if c == nil {
|
||||
w.WriteHeader(http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
contentCheck, err := io.ReadAll(r.Body)
|
||||
r.Body.Close()
|
||||
|
||||
if err != nil {
|
||||
w.WriteHeader(http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
t.Log(string(contentCheck))
|
||||
if !strings.Contains(req.URL.Path, c.Issuer) {
|
||||
w.WriteHeader(http.StatusForbidden)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
hdlr = authreq.VerifyMiddleware(hdlr)
|
||||
|
||||
rw := httptest.NewRecorder()
|
||||
|
||||
hdlr(rw, req, nil)
|
||||
|
||||
assert.Equal(rw.Code, http.StatusOK)
|
||||
|
||||
}
|
||||
|
||||
func enc(b []byte) string {
|
||||
return base64.RawURLEncoding.EncodeToString(b)
|
||||
}
|
|
@ -5,12 +5,11 @@ import (
|
|||
log "github.com/sirupsen/logrus"
|
||||
)
|
||||
|
||||
// BitcaskStore ...
|
||||
type BitcaskStore struct {
|
||||
type bitcaskStore struct {
|
||||
db *bitcask.Bitcask
|
||||
}
|
||||
|
||||
func newBitcaskStore(path string) (*BitcaskStore, error) {
|
||||
func newBitcaskStore(path string) (*bitcaskStore, error) {
|
||||
db, err := bitcask.Open(
|
||||
path,
|
||||
bitcask.WithMaxKeySize(256),
|
||||
|
@ -19,16 +18,16 @@ func newBitcaskStore(path string) (*BitcaskStore, error) {
|
|||
return nil, err
|
||||
}
|
||||
|
||||
return &BitcaskStore{db: db}, nil
|
||||
return &bitcaskStore{db: db}, nil
|
||||
}
|
||||
|
||||
// Sync ...
|
||||
func (bs *BitcaskStore) Sync() error {
|
||||
func (bs *bitcaskStore) Sync() error {
|
||||
return bs.db.Sync()
|
||||
}
|
||||
|
||||
// Close ...
|
||||
func (bs *BitcaskStore) Close() error {
|
||||
func (bs *bitcaskStore) Close() error {
|
||||
log.Info("syncing store ...")
|
||||
if err := bs.db.Sync(); err != nil {
|
||||
log.WithError(err).Error("error syncing store")
|
||||
|
@ -45,7 +44,7 @@ func (bs *BitcaskStore) Close() error {
|
|||
}
|
||||
|
||||
// Merge ...
|
||||
func (bs *BitcaskStore) Merge() error {
|
||||
func (bs *bitcaskStore) Merge() error {
|
||||
log.Info("merging store ...")
|
||||
if err := bs.db.Merge(); err != nil {
|
||||
log.WithError(err).Error("error merging store")
|
||||
|
|
|
@ -1,13 +1,11 @@
|
|||
package internal
|
||||
|
||||
// MemoryStore is an in-memory store mostly only used for tests
|
||||
type MemoryStore struct {
|
||||
type memoryStore struct{}
|
||||
|
||||
func newMemoryStore() *memoryStore {
|
||||
return &memoryStore{}
|
||||
}
|
||||
|
||||
func NewMemoryStore() *MemoryStore {
|
||||
return &MemoryStore{}
|
||||
}
|
||||
|
||||
func (s *MemoryStore) Sync() error { return nil }
|
||||
func (s *MemoryStore) Close() error { return nil }
|
||||
func (s *MemoryStore) Merge() error { return nil }
|
||||
func (s *memoryStore) Sync() error { return nil }
|
||||
func (s *memoryStore) Close() error { return nil }
|
||||
func (s *memoryStore) Merge() error { return nil }
|
||||
|
|
|
@ -116,7 +116,7 @@ func (c *Configuration) registerIdentity() func(button app.HTMLButton) {
|
|||
// not using client since that's not setup until we have an identity, might break the existing
|
||||
// flow
|
||||
log.Printf("identity;Addr(): %#v", identity.Addr())
|
||||
registerClient, err := saltyim.NewClient(identity.Addr(), saltyim.WithClientIdentity(saltyim.WithIdentityBytes(identity.Contents())))
|
||||
registerClient, err := saltyim.NewClient(saltyim.WithClientIdentity(saltyim.WithIdentityBytes(identity.Contents())))
|
||||
if err != nil { // TODO: pop dialog
|
||||
log.Println("error", err)
|
||||
return
|
||||
|
|
|
@ -108,7 +108,7 @@ func (h *SaltyChat) connect(ctx app.Context) {
|
|||
}
|
||||
|
||||
clientIdentity := saltyim.WithClientIdentity(saltyim.WithIdentityBytes(identity.Contents()))
|
||||
newClient, err := saltyim.NewClient(identity.Addr(), clientIdentity, saltyim.WithState(state))
|
||||
newClient, err := saltyim.NewClient(clientIdentity, saltyim.WithState(state))
|
||||
if err != nil {
|
||||
h.dialog.ShowError("error setting up client", err.Error())
|
||||
return
|
||||
|
|
|
@ -389,6 +389,7 @@ func NewServer(bind string, options ...Option) (*Server, error) {
|
|||
|
||||
csrfHandler := nosurf.New(router)
|
||||
csrfHandler.ExemptGlob("/api/v1/*")
|
||||
csrfHandler.ExemptGlob("/api/v1/blob/*")
|
||||
csrfHandler.ExemptGlob("/.well-known/*")
|
||||
csrfHandler.ExemptGlob("/inbox/*")
|
||||
|
||||
|
|
|
@ -47,6 +47,8 @@ func NewStore(store string) (Store, error) {
|
|||
switch u.Type {
|
||||
case "bitcask":
|
||||
return newBitcaskStore(u.Path)
|
||||
case "memory":
|
||||
return newMemoryStore(), nil
|
||||
default:
|
||||
return nil, ErrInvalidStore
|
||||
}
|
||||
|
|
|
@ -15,10 +15,12 @@ const (
|
|||
wellknownPath = ".well-known/salty"
|
||||
avatarsPath = "avatars"
|
||||
avatarResolution = 80 // 80x80 px
|
||||
blobsPath = "blobs"
|
||||
)
|
||||
|
||||
var (
|
||||
ErrAddressExists = errors.New("error: address already exists")
|
||||
ErrBlobNotFound = errors.New("error: blob not found")
|
||||
)
|
||||
|
||||
func CreateConfig(conf *Config, hash string, key string) error {
|
||||
|
@ -75,3 +77,56 @@ func CreateOrUpdateAvatar(conf *Config, addr *saltyim.Addr, contents []byte) err
|
|||
|
||||
return nil
|
||||
}
|
||||
|
||||
func CreateOrUpdateBlob(conf *Config, key string, data []byte, signer string) error {
|
||||
p := filepath.Join(conf.Data, blobsPath, signer)
|
||||
p = saltyim.FixUnixHome(p)
|
||||
|
||||
if err := os.MkdirAll(p, 0755); err != nil {
|
||||
return fmt.Errorf("error creating blobs paths %s: %w", p, err)
|
||||
}
|
||||
|
||||
fn := filepath.Join(p, key)
|
||||
|
||||
if err := os.WriteFile(fn, data, os.FileMode(0644)); err != nil {
|
||||
return fmt.Errorf("error writing blob %s: %w", fn, err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func GetBlob(conf *Config, key string, signer string) (*saltyim.Blob, error) {
|
||||
p := filepath.Join(conf.Data, blobsPath, signer)
|
||||
p = saltyim.FixUnixHome(p)
|
||||
|
||||
if err := os.MkdirAll(p, 0755); err != nil {
|
||||
return nil, fmt.Errorf("error creating blobs paths %s: %w", p, err)
|
||||
}
|
||||
|
||||
fn := filepath.Join(p, key)
|
||||
|
||||
if !FileExists(fn) {
|
||||
return nil, ErrBlobNotFound
|
||||
}
|
||||
|
||||
return saltyim.OpenBlob(fn)
|
||||
}
|
||||
|
||||
func DeleteBlob(conf *Config, key string, signer string) error {
|
||||
p := filepath.Join(conf.Data, blobsPath, signer)
|
||||
p = saltyim.FixUnixHome(p)
|
||||
|
||||
if err := os.MkdirAll(p, 0755); err != nil {
|
||||
return fmt.Errorf("error creating blobs paths %s: %w", p, err)
|
||||
}
|
||||
|
||||
fn := filepath.Join(p, key)
|
||||
|
||||
if !FileExists(fn) {
|
||||
return ErrBlobNotFound
|
||||
}
|
||||
|
||||
// TODO: Delete the .json properties metadata files too
|
||||
|
||||
return os.Remove(fn)
|
||||
}
|
||||
|
|
|
@ -0,0 +1,24 @@
|
|||
package internal
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
"go.mills.io/saltyim"
|
||||
)
|
||||
|
||||
func NewTestUser(addr, broker string, t *testing.T) *saltyim.Client {
|
||||
require := require.New(t)
|
||||
|
||||
me, err := saltyim.ParseAddr(addr)
|
||||
require.NoError(err)
|
||||
|
||||
id, err := saltyim.CreateIdentity()
|
||||
require.NoError(err)
|
||||
|
||||
cli, err := saltyim.NewClient(saltyim.WithAddr(me), saltyim.WithIdentity(id))
|
||||
require.NoError(err)
|
||||
|
||||
return cli
|
||||
}
|
|
@ -1,3 +1,3 @@
|
|||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:62776cf8369416668d4a77ff87adbd68b5bc45a0adbbdfda9a67ff08afe3c3a8
|
||||
size 29651160
|
||||
oid sha256:3133c2c49ec1c697518deb6f2ffb59e9902bc07f8c6ab9f6cb558d4711bcd357
|
||||
size 29757066
|
||||
|
|
36
options.go
36
options.go
|
@ -16,7 +16,6 @@ func WithClientIdentity(options ...IdentityOption) ClientOption {
|
|||
return fmt.Errorf("error loading identity: %w", err)
|
||||
}
|
||||
cli.id = id
|
||||
cli.key = id.key
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
@ -55,6 +54,14 @@ func WithStateFromBytes(data []byte) ClientOption {
|
|||
}
|
||||
}
|
||||
|
||||
// WithIdentity sets the client's identity from an identity object
|
||||
func WithIdentity(id *Identity) ClientOption {
|
||||
return func(cli *Client) error {
|
||||
cli.id = id
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
// WithState sets the client's state from a state object
|
||||
func WithState(state *State) ClientOption {
|
||||
return func(cli *Client) error {
|
||||
|
@ -62,3 +69,30 @@ func WithState(state *State) ClientOption {
|
|||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
// WithAddr sets the client's `me` Salty Address
|
||||
func WithAddr(me *Addr) ClientOption {
|
||||
return func(cli *Client) error {
|
||||
cli.me = me
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
// WithUser sets the client's `me` Salty Address if a non-nil or non-empty and valid
|
||||
// Salty Address for `user` is supplifed, otherwise the user in the client's identity
|
||||
// is used.
|
||||
func WithUser(user string) ClientOption {
|
||||
return func(cli *Client) error {
|
||||
if user == "" {
|
||||
return nil
|
||||
}
|
||||
|
||||
addr, err := ParseAddr(user)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
cli.me = addr
|
||||
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
|
|
@ -89,9 +89,9 @@ func (svc *Service) Run(ctx context.Context) error {
|
|||
// TODO: Should this timeout? Use a context?
|
||||
if err := retry.Do(func() error {
|
||||
cli, err := NewClient(
|
||||
svc.me,
|
||||
WithAddr(svc.me),
|
||||
WithIdentity(svc.id),
|
||||
WithStateFromFile(svc.state),
|
||||
WithClientIdentity(WithIdentity(svc.id)),
|
||||
)
|
||||
if err != nil {
|
||||
return err
|
||||
|
|
77
types.go
77
types.go
|
@ -2,7 +2,10 @@ package saltyim
|
|||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"os"
|
||||
|
||||
"go.mills.io/salty"
|
||||
)
|
||||
|
@ -54,7 +57,7 @@ func NewSendRequest(r io.Reader) (req SendRequest, signer string, err error) {
|
|||
return
|
||||
}
|
||||
|
||||
// AvatarRequest is the request used by clients to send messages via a broker
|
||||
// AvatarRequest is the request used by clients to update avatars stored on a broker's avatar service.
|
||||
type AvatarRequest struct {
|
||||
Addr *Addr
|
||||
Content []byte
|
||||
|
@ -76,3 +79,75 @@ func NewAvatarRequest(r io.Reader) (req AvatarRequest, signer string, err error)
|
|||
err = json.Unmarshal(out, &req)
|
||||
return
|
||||
}
|
||||
|
||||
// Blob defines the type, filename and whether or not a blob is publicly accessible or not.
|
||||
// A Blob also holds zero r more properties as a map of key/value pairs of string interpreted
|
||||
// by the client.
|
||||
type Blob struct {
|
||||
r io.ReadSeekCloser
|
||||
|
||||
Type string
|
||||
Public bool
|
||||
Filename string
|
||||
Properties map[string]string
|
||||
}
|
||||
|
||||
// Close closes the blob and the underlying io.ReadSeekCloser
|
||||
func (b *Blob) Close() error { return b.r.Close() }
|
||||
|
||||
// Read reads data from the blob from the underlying io.ReadSeekCloser
|
||||
func (b *Blob) Read(p []byte) (n int, err error) { return b.r.Read(p) }
|
||||
|
||||
// SetHeaders sets HTTP headers on the net/http.Request object based on the blob's type, filename
|
||||
// and various other properties (if any).
|
||||
func (b *Blob) SetHeaders(r *http.Request) {
|
||||
// TODO: Implement this...
|
||||
}
|
||||
|
||||
// OpenBlob opens a blob at the given path and returns a Blob object
|
||||
func OpenBlob(fn string) (*Blob, error) {
|
||||
f, err := os.Open(fn)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("error opening blob %s: %w", fn, err)
|
||||
}
|
||||
|
||||
return &Blob{r: f}, nil
|
||||
}
|
||||
|
||||
// BlobRequest is the request used by clients to update blob metadata for blobs stored on a broker.
|
||||
type BlobRequest struct {
|
||||
Blob
|
||||
}
|
||||
|
||||
// NewBlobRequest reads the signed request body from a client, verifies its signature
|
||||
// and returns the resulting `BlobRequest` and key used to sign the request on success
|
||||
// otherwise an empty object and en error on failure.
|
||||
func NewBlobRequest(r io.Reader) (req BlobRequest, signer string, err error) {
|
||||
body, err := io.ReadAll(r)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
out, key, err := salty.Verify(body)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
signer = key.ID().String()
|
||||
err = json.Unmarshal(out, &req)
|
||||
return
|
||||
}
|
||||
|
||||
// NewRawRequest reads the signed request body from a client, verifies its signature
|
||||
// and returns the resulting `[]byte` slice and key used to sign the request on success
|
||||
// otherwise an empty object and en error on failure.
|
||||
func NewRawRequest(r io.Reader) (out []byte, signer string, err error) {
|
||||
body, err := io.ReadAll(r)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
out, key, err := salty.Verify(body)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
signer = key.ID().String()
|
||||
return
|
||||
}
|
||||
|
|
59
utils.go
59
utils.go
|
@ -11,8 +11,10 @@ import (
|
|||
"time"
|
||||
|
||||
"github.com/andybalholm/brotli"
|
||||
"github.com/keys-pub/keys"
|
||||
"github.com/oklog/ulid/v2"
|
||||
log "github.com/sirupsen/logrus"
|
||||
"go.mills.io/saltyim/internal/authreq"
|
||||
)
|
||||
|
||||
const (
|
||||
|
@ -92,6 +94,63 @@ func Request(method, uri string, headers http.Header, body io.Reader) (*http.Res
|
|||
return res, nil
|
||||
}
|
||||
|
||||
// SignedRequest is a generic request handling function for making artbitrary HTPT
|
||||
// requests to a Salty broker's API endpoints that require authorization.
|
||||
func SignedRequest(key *keys.EdX25519Key, method, uri string, headers http.Header, body io.Reader) (*http.Response, error) {
|
||||
if headers == nil {
|
||||
headers = make(http.Header)
|
||||
}
|
||||
|
||||
if body != nil {
|
||||
switch headers.Get("Content-Encoding") {
|
||||
case "br":
|
||||
buf := &bytes.Buffer{}
|
||||
br := brotli.NewWriter(buf)
|
||||
io.Copy(br, body)
|
||||
br.Close()
|
||||
body = buf
|
||||
case "gzip":
|
||||
buf := &bytes.Buffer{}
|
||||
gz := gzip.NewWriter(buf)
|
||||
io.Copy(gz, body)
|
||||
gz.Close()
|
||||
body = buf
|
||||
}
|
||||
}
|
||||
|
||||
req, err := http.NewRequest(method, uri, body)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("%s: http.NewRequest fail: %s", uri, err)
|
||||
}
|
||||
|
||||
// Set a default User-Agent (if none set)
|
||||
if headers.Get("User-Agent") == "" {
|
||||
headers.Set("User-Agent", fmt.Sprintf("saltyim/%s", FullVersion()))
|
||||
}
|
||||
|
||||
req.Header = headers
|
||||
|
||||
client := http.Client{
|
||||
Timeout: defaultRequestTimeout,
|
||||
}
|
||||
|
||||
req, err = authreq.Sign(req, key.Private())
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("error signing request: %w", err)
|
||||
}
|
||||
|
||||
res, err := client.Do(req)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("%s: client.Do fail: %s", uri, err)
|
||||
}
|
||||
|
||||
if res.StatusCode/100 != 2 {
|
||||
return nil, fmt.Errorf("non-2xx response received: %s", res.Status)
|
||||
}
|
||||
|
||||
return res, nil
|
||||
}
|
||||
|
||||
// SplitInbox splits and endpoint into it's components (inbox, uri)
|
||||
// where inbox is a topic queue on the Salty broker uri
|
||||
func SplitInbox(endpoint string) (string, string) {
|
||||
|
|
Loading…
Reference in New Issue