@ -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())
|
||||
}
|
@ -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))
|
||||
}
|
@ -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
|
@ -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)
|
||||
}
|
Binary file not shown.
Loading…
Reference in new issue
Ah verifies the request here.
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 😅