Skip to content

Commit

Permalink
Allow to use multiple keys for a single secret (#2)
Browse files Browse the repository at this point in the history
The entry key concept enables to have multiple keys for a single secret.
That supposed to replace the previous key concept, which was a single key for a
single secret.
The legacy key concept is still supported, but it is not recommended to use it
and it will be removed in the future.

The multiple keys concept is useful to share the same secret with multiple
users, and to be able to revoke access to a single user without affecting the
others.

At the moment the anyone who has access to the secret can add a new key to it.

- Introduced an entrykeymanager service.
- entrykeymanager now returns the Key Encryption Key (KEK) for Data Encryption
  Key (DEK) on creation.
- Added an entry key generator endpoint.
- Shared parser codes have been implemented.
- Introduced a common interface for views.
- Implemented the view interface for entry creation.
- Updated entry delete to implement the views.View interface.
- Updated getentry to implement the views.View interface.
- Fixed an issue with finding keys.
- Refactored services to improve code organization and structure.
- Moved mocks to their respective packages.
- Added support for legacy encryption.
- Updated remaining reads of entry keys.
- Simplified test database and transaction initialization.
- Renamed entrymodel.UpdateAccessed to Use.
- Added a command to generate coverage.
- Improved EntryKeyManager and EntryManager tests.
- Added a coverage clean-up command to the Makefile.
- Handled database operation errors in entrykey tests.
  • Loading branch information
Ajnasz authored Apr 11, 2024
1 parent 8876912 commit fb2ea97
Show file tree
Hide file tree
Showing 48 changed files with 3,006 additions and 380 deletions.
15 changes: 14 additions & 1 deletion Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -36,4 +36,17 @@ curl-bad:

.PHONY: hurl
hurl:
@hurl --variable api_host='http://localhost:8080' hurl/*.hurl
@hurl --verbose --error-format=long --variable api_host='http://localhost:8080' hurl/*.hurl

.PHONY: coverage clean-cover
clean-cover:
rm -f cover.out cover.html

coverage: cover.out cover.html

cover.out:
go test ./... -coverprofile cover.out

cover.html: cover.out
go tool cover -html=cover.out -o cover.html

89 changes: 67 additions & 22 deletions api/api.go
Original file line number Diff line number Diff line change
Expand Up @@ -11,12 +11,17 @@ import (

"github.com/Ajnasz/sekret.link/api/middlewares"
"github.com/Ajnasz/sekret.link/internal/api"
"github.com/Ajnasz/sekret.link/internal/hasher"
"github.com/Ajnasz/sekret.link/internal/models"
"github.com/Ajnasz/sekret.link/internal/parsers"
"github.com/Ajnasz/sekret.link/internal/services"
"github.com/Ajnasz/sekret.link/internal/views"
)

func newAESEncrypter(b []byte) services.Encrypter {
return services.NewAESEncrypter(b)
}

// HandlerConfig configuration for http handlers
type HandlerConfig struct {
ExpireSeconds int
Expand All @@ -26,31 +31,39 @@ type HandlerConfig struct {
DB *sql.DB
}

// NewSecretHandler creates a SecretHandler instance
func NewSecretHandler(config HandlerConfig) SecretHandler {
return SecretHandler{config}
}

// SecretHandler is an http.Handler implementation which handles requests to
// encode or decode the post body
type SecretHandler struct {
config HandlerConfig
}

// NewSecretHandler creates a SecretHandler instance
func NewSecretHandler(config HandlerConfig) SecretHandler {
return SecretHandler{config: config}
}

// POST method handler
// This method is responsible for creating a new entry
// url: /
// query:
// - expire: the expiration time of the entry
// - maxReads: the maximum number of reads for the entry
//
// method: POST
// response: 200 OK
// response: 400 Bad Request
// response: 500 Internal Server Error
// response: 413 Payload Too Large
func (s SecretHandler) Post(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != "/" && r.URL.Path != "" {
http.Error(w, "Not found", http.StatusNotFound)
log.Println("Not found", r.URL.Path)
return
}

encrypter := func(b []byte) services.Encrypter {
return services.NewAESEncrypter(b)
}

parser := parsers.NewCreateEntryParser(s.config.MaxExpireSeconds)
entryManager := services.NewEntryManager(s.config.DB, &models.EntryModel{}, encrypter)
keyManager := services.NewEntryKeyManager(s.config.DB, &models.EntryKeyModel{}, hasher.NewSHA256Hasher(), newAESEncrypter)
entryManager := services.NewEntryManager(s.config.DB, &models.EntryModel{}, newAESEncrypter, keyManager)
view := views.NewEntryCreateView(s.config.WebExternalURL)

createHandler := api.NewCreateHandler(
Expand All @@ -64,13 +77,10 @@ func (s SecretHandler) Post(w http.ResponseWriter, r *http.Request) {

// GET method handler
func (s SecretHandler) Get(w http.ResponseWriter, r *http.Request) {
encrypter := func(b []byte) services.Encrypter {
return services.NewAESEncrypter(b)
}

view := views.NewEntryReadView()
parser := parsers.NewGetEntryParser()
entryManager := services.NewEntryManager(s.config.DB, &models.EntryModel{}, encrypter)
keyManager := services.NewEntryKeyManager(s.config.DB, &models.EntryKeyModel{}, hasher.NewSHA256Hasher(), newAESEncrypter)
entryManager := services.NewEntryManager(s.config.DB, &models.EntryModel{}, newAESEncrypter, keyManager)
getHandler := api.NewGetHandler(
parser,
entryManager,
Expand All @@ -81,11 +91,8 @@ func (s SecretHandler) Get(w http.ResponseWriter, r *http.Request) {

// DELETE method handler
func (s SecretHandler) Delete(w http.ResponseWriter, r *http.Request) {
encrypter := func(b []byte) services.Encrypter {
return services.NewAESEncrypter(b)
}

entryManager := services.NewEntryManager(s.config.DB, &models.EntryModel{}, encrypter)
keyManager := services.NewEntryKeyManager(s.config.DB, &models.EntryKeyModel{}, hasher.NewSHA256Hasher(), newAESEncrypter)
entryManager := services.NewEntryManager(s.config.DB, &models.EntryModel{}, newAESEncrypter, keyManager)
view := views.NewEntryDeleteView()
deleteHandler := api.NewDeleteHandler(entryManager, view)
deleteHandler.Handle(w, r)
Expand All @@ -97,6 +104,32 @@ func (s SecretHandler) Options(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusOK)
}

// GenerateEncryptionKey provides a way to generate a new encryption key for an existing entry
// This allows to share the same entry with multiple users without sharing the encryption key
// url: /key/{uuid}/{key}
// - uuid: the uuid of the entry
// - key: the key of the entry
// query:
// - expire: the expiration time of the new key
// - maxReads: the maximum number of reads for the new key
//
// method: GET
// response: 200 OK
func (s SecretHandler) GenerateEncryptionKey(w http.ResponseWriter, r *http.Request) {
keyManager := services.NewEntryKeyManager(s.config.DB, &models.EntryKeyModel{}, hasher.NewSHA256Hasher(), newAESEncrypter)
entryManager := services.NewEntryManager(s.config.DB, &models.EntryModel{}, newAESEncrypter, keyManager)
view := views.NewGenerateEntryKeyView(s.config.WebExternalURL)
parser := parsers.NewGenerateEntryKeyParser(s.config.MaxExpireSeconds)
getHandler := api.NewGenerateEntryKeyHandler(
parser,
entryManager,
view,
)

getHandler.Handle(w, r)

}

// NotFound handler
func (s SecretHandler) NotFound(w http.ResponseWriter, r *http.Request) {
http.Error(w, "Not found", http.StatusNotFound)
Expand Down Expand Up @@ -128,6 +161,7 @@ func clearApiRoot(apiRoot string) string {
}

func (s SecretHandler) RegisterHandlers(mux *http.ServeMux, apiRoot string) {
apiRoot = clearApiRoot(apiRoot)
mux.Handle(
fmt.Sprintf("GET %s", path.Join("/", apiRoot, "{uuid}", "{key}")),
http.StripPrefix(
Expand All @@ -138,7 +172,7 @@ func (s SecretHandler) RegisterHandlers(mux *http.ServeMux, apiRoot string) {
),
)
mux.Handle(
fmt.Sprintf("POST %s", clearApiRoot(apiRoot)),
fmt.Sprintf("POST %s", apiRoot),
http.StripPrefix(
path.Join("/", apiRoot),
middlewares.SetupLogging(
Expand All @@ -158,7 +192,7 @@ func (s SecretHandler) RegisterHandlers(mux *http.ServeMux, apiRoot string) {
)

mux.Handle(
fmt.Sprintf("OPTIONS %s", clearApiRoot(apiRoot)),
fmt.Sprintf("OPTIONS %s", apiRoot),
http.StripPrefix(
apiRoot,
middlewares.SetupLogging(
Expand All @@ -167,6 +201,17 @@ func (s SecretHandler) RegisterHandlers(mux *http.ServeMux, apiRoot string) {
),
)

// TODO
mux.Handle(
fmt.Sprintf("GET %s", path.Join(apiRoot, "key", "{uuid}", "{key}")),
http.StripPrefix(
apiRoot,
middlewares.SetupLogging(
middlewares.SetupHeaders(http.HandlerFunc(s.GenerateEncryptionKey)),
),
),
)

mux.Handle("/", middlewares.SetupLogging(middlewares.SetupHeaders(http.HandlerFunc(s.NotFound))))

}
23 changes: 16 additions & 7 deletions api/api_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ import (
"testing"
"time"

"github.com/Ajnasz/sekret.link/internal/hasher"
"github.com/Ajnasz/sekret.link/internal/models"
"github.com/Ajnasz/sekret.link/internal/services"
"github.com/Ajnasz/sekret.link/internal/test/durable"
Expand Down Expand Up @@ -91,7 +92,9 @@ func TestCreateEntry(t *testing.T) {
encrypter := func(b []byte) services.Encrypter {
return services.NewAESEncrypter(b)
}
entryManager := services.NewEntryManager(db, &models.EntryModel{}, encrypter)
keyManager := services.NewEntryKeyManager(db, &models.EntryKeyModel{}, hasher.NewSHA256Hasher(), encrypter)

entryManager := services.NewEntryManager(db, &models.EntryModel{}, encrypter, keyManager)
entry, err := entryManager.ReadEntry(ctx, savedUUID, key)

if err != nil {
Expand Down Expand Up @@ -209,7 +212,8 @@ func TestCreateEntryJSON(t *testing.T) {
encrypter := func(b []byte) services.Encrypter {
return services.NewAESEncrypter(b)
}
entryManager := services.NewEntryManager(db, &models.EntryModel{}, encrypter)
keyManager := services.NewEntryKeyManager(db, &models.EntryKeyModel{}, hasher.NewSHA256Hasher(), encrypter)
entryManager := services.NewEntryManager(db, &models.EntryModel{}, encrypter, keyManager)
entry, err := entryManager.ReadEntry(ctx, encode.UUID, key)

if err != nil {
Expand Down Expand Up @@ -299,7 +303,8 @@ func TestCreateEntryForm(t *testing.T) {
encrypter := func(b []byte) services.Encrypter {
return services.NewAESEncrypter(b)
}
entryManager := services.NewEntryManager(db, &models.EntryModel{}, encrypter)
keyManager := services.NewEntryKeyManager(db, &models.EntryKeyModel{}, hasher.NewSHA256Hasher(), encrypter)
entryManager := services.NewEntryManager(db, &models.EntryModel{}, encrypter, keyManager)
entry, err := entryManager.ReadEntry(ctx, savedUUID, key)

if err != nil {
Expand Down Expand Up @@ -387,7 +392,9 @@ func TestGetEntry(t *testing.T) {
encrypter := func(b []byte) services.Encrypter {
return services.NewAESEncrypter(b)
}
entryManager := services.NewEntryManager(db, &models.EntryModel{}, encrypter)

keyManager := services.NewEntryKeyManager(db, &models.EntryKeyModel{}, hasher.NewSHA256Hasher(), encrypter)
entryManager := services.NewEntryManager(db, &models.EntryModel{}, encrypter, keyManager)
meta, encKey, err := entryManager.CreateEntry(ctx, []byte(testCase.Value), 1, time.Second*10)

if err != nil {
Expand Down Expand Up @@ -442,7 +449,9 @@ func TestGetEntryJSON(t *testing.T) {
encrypter := func(b []byte) services.Encrypter {
return services.NewAESEncrypter(b)
}
entryManager := services.NewEntryManager(db, &models.EntryModel{}, encrypter)

keyManager := services.NewEntryKeyManager(db, &models.EntryKeyModel{}, hasher.NewSHA256Hasher(), encrypter)
entryManager := services.NewEntryManager(db, &models.EntryModel{}, encrypter, keyManager)
meta, encKey, err := entryManager.CreateEntry(ctx, []byte(testCase.Value), 1, time.Second*10)
if err != nil {
t.Error(err)
Expand Down Expand Up @@ -577,7 +586,8 @@ func TestCreateEntryWithExpiration(t *testing.T) {
encrypter := func(b []byte) services.Encrypter {
return services.NewAESEncrypter(b)
}
entryManager := services.NewEntryManager(db, &models.EntryModel{}, encrypter)
keyManager := services.NewEntryKeyManager(db, &models.EntryKeyModel{}, hasher.NewSHA256Hasher(), encrypter)
entryManager := services.NewEntryManager(db, &models.EntryModel{}, encrypter, keyManager)
entry, err := entryManager.ReadEntry(ctx, savedUUID, decodedKey)

if err != nil {
Expand Down Expand Up @@ -641,7 +651,6 @@ func TestCreateEntryWithMaxReads(t *testing.T) {
model := &models.EntryModel{}

savedUUID := resp.Header.Get("x-entry-uuid")
fmt.Println("savedUUID", savedUUID)

if err != nil {
t.Fatal(err)
Expand Down
1 change: 0 additions & 1 deletion cmd/prepare/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,6 @@ func prepareDatabase(ctx context.Context) error {
flag.StringVar(&postgresDB, "postgresDB", "", "Connection string for postgresql database backend")
flag.Parse()

fmt.Println(postgresDB)
db, err := durable.OpenDatabaseClient(context.Background(), config.GetConnectionString(postgresDB))
if err != nil {
return err
Expand Down
55 changes: 55 additions & 0 deletions hurl/createnewkey.hurl
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
# Create a new entry
POST {{api_host}}/api/?maxReads=3
content-type: application/json
{
"name": "John Doe",
"email": "[email protected]"
}

HTTP 200
[Captures]
entry_uuid: header "x-entry-uuid"
entry_key: header "x-entry-key"
entry_expire: header "x-entry-expire"
entry_delete_key: header "x-entry-delete-key"


# Generate a new key for the entry
GET {{api_host}}/api/key/{{entry_uuid}}/{{entry_key}}

HTTP 200
[Captures]
entry_key2: header "x-entry-key"
entry_expire2: header "x-entry-expire"

GET {{api_host}}/api/key/{{entry_uuid}}/{{entry_key}}

HTTP 200
[Captures]
entry_key3: header "x-entry-key"
entry_expire3: header "x-entry-expire"

# Retrieve the entry with key 2
GET {{api_host}}/api/{{entry_uuid}}/{{entry_key2}}

HTTP 200
[Asserts]
{
"name": "John Doe",
"email": "[email protected]"
}

# Retrieve the entry with key 3
GET {{api_host}}/api/{{entry_uuid}}/{{entry_key3}}

HTTP 200
[Asserts]
{
"name": "John Doe",
"email": "[email protected]"
}

# # Should not be able to retrieve the entry again
# GET {{api_host}}/api/{{entry_uuid}}/{{entry_key2}}
#
# HTTP 404
6 changes: 6 additions & 0 deletions hurl/createread.hurl
Original file line number Diff line number Diff line change
Expand Up @@ -23,3 +23,9 @@ HTTP 200
"name": "John Doe",
"email": "[email protected]"
}


# Should not be able to retrieve the entry again
GET {{api_host}}/api/{{entry_uuid}}/{{entry_key}}

HTTP 404
11 changes: 7 additions & 4 deletions internal/api/createentry.go
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import (

"github.com/Ajnasz/sekret.link/internal/parsers"
"github.com/Ajnasz/sekret.link/internal/services"
"github.com/Ajnasz/sekret.link/internal/views"
)

// CreateEntryParser is an interface for parsing the create entry request
Expand All @@ -32,15 +33,15 @@ type CreateHandler struct {
maxDataSize int64
parser CreateEntryParser
entryManager CreateEntryManager
view CreateEntryView
view views.View[views.EntryCreatedResponse]
}

// NewCreateHandler creates a new CreateHandler
func NewCreateHandler(
maxDataSize int64,
parser CreateEntryParser,
entryManager CreateEntryManager,
view CreateEntryView,
view views.View[views.EntryCreatedResponse],
) CreateHandler {
return CreateHandler{
maxDataSize: maxDataSize,
Expand All @@ -67,7 +68,9 @@ func (c CreateHandler) handle(w http.ResponseWriter, r *http.Request) error {
return err
}

c.view.RenderEntryCreated(w, r, entry, hex.EncodeToString(key))
viewData := views.BuildCreatedResponse(entry, hex.EncodeToString(key))

c.view.Render(w, r, viewData)
return nil
}

Expand All @@ -76,6 +79,6 @@ func (c CreateHandler) Handle(w http.ResponseWriter, r *http.Request) {
err := c.handle(w, r)

if err != nil {
c.view.RenderCreateEntryErrorResponse(w, r, err)
c.view.RenderError(w, r, err)
}
}
Loading

0 comments on commit fb2ea97

Please sign in to comment.