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: #178
Reviewed-by: xuu <xuu@noreply@mills.io>
pull/181/head
James Mills 3 days ago
parent 96d8afcbef
commit ddd16c202f
  1. 1
      .gitignore
  2. 44
      client.go
  3. 14
      client_test.go
  4. 35
      cmd/salty-chat/avatar.go
  5. 42
      cmd/salty-chat/chat.go
  6. 39
      cmd/salty-chat/read.go
  7. 2
      cmd/salty-chat/register.go
  8. 83
      cmd/salty-chat/request.go
  9. 23
      cmd/salty-chat/root.go
  10. 35
      cmd/salty-chat/send.go
  11. 0
      data/blobs/.gitkeep
  12. 140
      docs/BlogStorage.md
  13. 17
      go.mod
  14. 36
      go.sum
  15. 8
      identity.go
  16. 77
      internal/api.go
  17. 139
      internal/api_e2e_test.go
  18. 157
      internal/authreq/authreq.go
  19. 111
      internal/authreq/authreq_test.go
  20. 13
      internal/bitcask_store.go
  21. 14
      internal/memory_store.go
  22. 2
      internal/pwa/components/configuration.go
  23. 2
      internal/pwa/components/saltychat.go
  24. 1
      internal/server.go
  25. 2
      internal/store.go
  26. 55
      internal/tasks.go
  27. 24
      internal/test_helpers.go
  28. BIN
      internal/web/app.wasm
  29. 36
      options.go
  30. 4
      service.go
  31. 77
      types.go
  32. 59
      utils.go

1
.gitignore vendored

@ -23,6 +23,7 @@
/data/*.json
/data/logs
/data/acme
/data/blobs
/data/avatars
/data/.well-known

@ -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
}
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)
}
me, err := saltyim.ParseAddr(user)
cli, err := saltyim.NewClient(
saltyim.WithIdentity(id),
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()
setavatar(me, identity, args[0])
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")
state := viper.GetString("state")
var profiles []profile
viper.UnmarshalKey("profiles", &profiles)
for _, p := range profiles {
if user == p.User {
identity = p.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)
}
me, err := saltyim.ParseAddr(user)
state := viper.GetString("state")
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
}
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)
}
me, err := saltyim.ParseAddr(user)
cli, err := saltyim.NewClient(
saltyim.WithIdentity(id),
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()
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
}
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)
}
me, err := saltyim.ParseAddr(user)
cli, err := saltyim.NewClient(
saltyim.WithIdentity(id),
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()
send(me, identity, args[0], args[1:]...)
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

@ -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

@ -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"}),