WIP: Add blob storage service #177

Closed
prologic wants to merge 3 commits from blob into master

@ -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,7 +19,7 @@ func TestClient_InvalidEndpoint(t *testing.T) {
require.NoError(err)
assert.NotNil(me)
_, err = NewClient(me)
_, err = NewClient(WithAddr(me))
assert.Error(err)
}
@ -29,7 +29,7 @@ func TestClient_Outbox(t *testing.T) {
endpoint := &url.URL{Host: "example.com", Path: "/path", Scheme: "https"}
key := keys.GenerateEdX25519Key()
client := &Client{me: &Addr{endpoint: endpoint}, id: &Identity{key: key}}
client := &Client{id: &Identity{key: key}}
outbox := client.Outbox()
test.True(endpoint.Path == "/path",

@ -0,0 +1,31 @@
package main
import (
"bytes"
"fmt"
"log"
"time"
"github.com/keys-pub/keys"
"github.com/keys-pub/keys/http"
)
func main() {
key := keys.GenerateEdX25519Key()
// Vault POST
content := []byte(`[{"data":"dGVzdGluZzE="},{"data":"dGVzdGluZzI="}]`)
contentHash := http.ContentHash(content)
req, err := http.NewAuthRequest("POST", "https://keys.pub/vault/"+key.ID().String(), bytes.NewReader(content), contentHash, time.Now(), key)
if err != nil {
log.Fatal(err)
}
fmt.Printf("curl -H \"Authorization: %s\" -d %q %q\n", req.Header["Authorization"][0], string(content), req.URL.String())
// Vault GET
req, err = http.NewAuthRequest("GET", "https://keys.pub/vault/"+key.ID().String(), nil, "", time.Now(), key)
if err != nil {
log.Fatal(err)
}
fmt.Printf("curl -H \"Authorization: %s\" %q\n", req.Header["Authorization"][0], req.URL.String())
}

@ -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,29 @@ 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)
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")
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 +53,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, err = io.ReadAll(os.Stdin)
}
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,6 +10,7 @@ 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

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

@ -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,7 @@
package internal
import (
"io"
"net/http"
"net/url"
@ -9,6 +10,7 @@ import (
"github.com/unrolled/render"
"go.mills.io/saltyim"
"go.mills.io/saltyim/internal/authreq"
)
// API ...
@ -35,6 +37,13 @@ func (a *API) initRoutes() {
router.GET("/ping", a.PingEndpoint())
router.POST("/register", a.RegisterEndpoint())
// Blob Service
router.DELETE("/blob/:key", authreq.VerifySignature(a.BlobEndpoint()))
router.HEAD("/blob/:key", authreq.VerifySignature(a.BlobEndpoint()))
router.GET("/blob/:key", authreq.VerifySignature(a.BlobEndpoint()))
router.PUT("/blob/:key", authreq.VerifySignature(a.BlobEndpoint()))
router.POST("/blob/:key", authreq.VerifySignature(a.BlobEndpoint()))
// Lookup and Send support for Web / PWA clients
router.GET("/lookup/:addr", a.LookupEndpoint())
router.POST("/send", a.SendEndpoint())
@ -147,3 +156,39 @@ 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) {
log.Debugf("YYY")
claims := authreq.ClaimsFromRequest(r)
if claims == nil {
log.Warn("no claims")
http.Error(w, "Unauthorised", http.StatusUnauthorized)
return
}
signer := claims.Issuer
switch r.Method {
case http.MethodPut:
key := p.ByName("key")
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 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"
log "github.com/sirupsen/logrus"
)
type contextKey int
const (
claimsContextKey contextKey = iota
authorizationHeader = "Authorization"
)
var SignatureLifetime = 90 * time.Minute
func ClaimsFromRequest(r *http.Request) *jwt.RegisteredClaims {
if claims := r.Context().Value(claimsContextKey); claims != nil {
return claims.(*jwt.RegisteredClaims)
}
return nil
}
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.String())
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{
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
}
func VerifySignature(next httprouter.Handle) httprouter.Handle {
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.String())
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.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)
}

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

@ -15,6 +15,7 @@ const (
wellknownPath = ".well-known/salty"
avatarsPath = "avatars"
avatarResolution = 80 // 80x80 px
blobsPath = "blobs"
)
var (
@ -75,3 +76,20 @@ 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)
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, signer, key)
if err := os.WriteFile(fn, data, os.FileMode(0644)); err != nil {
return fmt.Errorf("error writing blob %s: %w", fn, err)
}
return nil
}

BIN
internal/web/app.wasm (Stored with Git LFS)

Binary file not shown.

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

@ -54,7 +54,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 +76,51 @@ 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 {
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
}
// 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)
xuu commented 2 months ago
Review

Ah verifies the request here.

Ah verifies the request here.
Review

Yup, the basic idea I had in mind is "okay" (I think?) but yeah I have a bit of a problem when there is no request body 😅

Yup, the basic idea I had in mind is "okay" (I think?) but yeah I have a bit of a problem when there is no request body 😅
if err != nil {
return
}
signer = key.ID().String()
return
}

@ -11,8 +11,11 @@ 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 +95,64 @@ 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)
}
log.Debugf("req.Headers: #%v", req.Header)
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…
Cancel
Save