diff --git a/cypress/e2e/auth.cy.js b/cypress/e2e/auth.cy.js index 0543ab3..c4a42fc 100644 --- a/cypress/e2e/auth.cy.js +++ b/cypress/e2e/auth.cy.js @@ -56,6 +56,6 @@ describe('auth spec', () => { cy.get('#password').type('joe') cy.get('#password2').type('joe') cy.get('.btn-primary').click() - cy.contains('username is not available').should('be.visible') + cy.contains('failed to create account').should('be.visible') }) }) \ No newline at end of file diff --git a/internal/crypto/aes.go b/internal/crypto/aes.go new file mode 100644 index 0000000..a7fa8e6 --- /dev/null +++ b/internal/crypto/aes.go @@ -0,0 +1,92 @@ +package crypto + +import ( + "crypto/aes" + "crypto/cipher" + "crypto/rand" + "encoding/hex" + "fmt" + "io" +) + +type EntityEncryptHelper struct{} + +func (e EntityEncryptHelper) Encrypt(text, pass, salt string) (string, error) { + // derive a key from the pass + key, err := StrongKey(pass, salt) + if err != nil { + return "", err + } + ciphertext, err := EncryptAES(key, text) + if err != nil { + return "", err + } + return ciphertext, nil +} + +// Decrypt cipher text with a given PIN which will be used to derive a key +func (e EntityEncryptHelper) Decrypt(ciphertext, pass, salt string) (string, error) { + // derive a key from the pass + key, err := StrongKey(pass, salt) + if err != nil { + return "", err + } + plaintext, err := DecryptAES(key, ciphertext) + if err != nil { + return "", err + } + return plaintext, nil +} + +// Encrypts the plain text with a given key +// see StrongKey() to create one +func EncryptAES(key []byte, plaintext string) (string, error) { + cipherBlock, err := aes.NewCipher(key) + if err != nil { + return "", fmt.Errorf("failed to create cipher: %w", err) + } + + gcm, err := cipher.NewGCM(cipherBlock) + if err != nil { + return "", fmt.Errorf("failed to wrap cipher: %w", err) + } + + // Never use more than 2^32 random nonces with a given key because of the risk of a repeat. + nonce := make([]byte, gcm.NonceSize()) + if _, err := io.ReadFull(rand.Reader, nonce); err != nil { + return "", fmt.Errorf("failed create nonce: %w", err) + } + + // ciphertext here is actually nonce+ciphertext + // So that when we decrypt, just knowing the nonce size + // is enough to separate it from the ciphertext. + ciphertext := gcm.Seal(nonce, nonce, []byte(plaintext), nil) + + return hex.EncodeToString(ciphertext), nil +} + +// Decrypt the cipher text using the key provided +func DecryptAES(key []byte, ct string) (string, error) { + ciphertext, _ := hex.DecodeString(ct) + cipherBlock, err := aes.NewCipher(key) + if err != nil { + return "", fmt.Errorf("failed to create cipher: %w", err) + } + + gcm, err := cipher.NewGCM(cipherBlock) + if err != nil { + return "", fmt.Errorf("failed to wrap cipher: %w", err) + } + + // Since we know the ciphertext is actually nonce+ciphertext + // And len(nonce) == NonceSize(). We can separate the two. + nonceSize := gcm.NonceSize() + nonce, ciphertext := ciphertext[:nonceSize], ciphertext[nonceSize:] + + plaintext, err := gcm.Open(nil, []byte(nonce), []byte(ciphertext), nil) + if err != nil { + return "", fmt.Errorf("failed to decrypt: %w", err) + } + + return string(plaintext), nil +} diff --git a/internal/storage/hash.go b/internal/crypto/argon.go similarity index 52% rename from internal/storage/hash.go rename to internal/crypto/argon.go index 4e42ca6..479b1cf 100644 --- a/internal/storage/hash.go +++ b/internal/crypto/argon.go @@ -1,51 +1,15 @@ -package storage +package crypto import ( - "crypto/aes" - "crypto/cipher" - "crypto/rand" - "crypto/sha256" "crypto/subtle" "encoding/base64" - "encoding/binary" - "encoding/hex" "errors" "fmt" - "io" "strings" "golang.org/x/crypto/argon2" - "golang.org/x/crypto/hkdf" ) -const keySizeBytes = 16 // 128-bit key - -// simple pin number generator -// returns 4-5 digits -func MakePin() (string, error) { - b, err := generateRandomBytes(16) - if err != nil { - return "", err - } - pin := binary.BigEndian.Uint16(b) - return fmt.Sprintf("%d", pin), nil -} - -// simple random token generator (url encoded) -func MakeToken() (string, error) { - b, err := generateRandomBytes(32) - if err != nil { - return "", err - } - return base64.URLEncoding.EncodeToString(b), nil -} - -// simple text hashing -func HashText(text string) string { - textHash := sha256.Sum256([]byte(text)) - return hex.EncodeToString(textHash[:]) -} - // Uses argon2id to hash and salt the given clear text value // returns the encoded string to be used for storage func HashPass(password string) (string, error) { @@ -76,77 +40,6 @@ func CompareHashToPass(hash, password string) error { return nil } -// Use HMAC Key Derivation Function (HKDF) to derive a strong key (RFC5869) -// This function is useful to derive a key from some small password text the user knows -func StrongKey(passText string, saltText string) ([]byte, error) { - hashFn := sha256.New - hashSize := hashFn().Size() - passBytes := []byte(passText) - if len(saltText) != hashSize { - return nil, fmt.Errorf("invalid salt length, must be %d", hashSize) - } - saltBytes := []byte(saltText) - hkdf := hkdf.New(hashFn, passBytes, saltBytes, nil) - key := make([]byte, keySizeBytes) - if _, err := io.ReadFull(hkdf, key); err != nil { - return nil, err - } - return key, nil -} - -// Encrypts the plain text with a given key -// see StrongKey() to create one -func EncryptAES(key []byte, plaintext string) (string, error) { - cipherBlock, err := aes.NewCipher(key) - if err != nil { - return "", fmt.Errorf("failed to create cipher: %w", err) - } - - gcm, err := cipher.NewGCM(cipherBlock) - if err != nil { - return "", fmt.Errorf("failed to wrap cipher: %w", err) - } - - // Never use more than 2^32 random nonces with a given key because of the risk of a repeat. - nonce := make([]byte, gcm.NonceSize()) - if _, err := io.ReadFull(rand.Reader, nonce); err != nil { - return "", fmt.Errorf("failed create nonce: %w", err) - } - - // ciphertext here is actually nonce+ciphertext - // So that when we decrypt, just knowing the nonce size - // is enough to separate it from the ciphertext. - ciphertext := gcm.Seal(nonce, nonce, []byte(plaintext), nil) - - return hex.EncodeToString(ciphertext), nil -} - -// Decrypt the cipher text using the key provided -func DecryptAES(key []byte, ct string) (string, error) { - ciphertext, _ := hex.DecodeString(ct) - cipherBlock, err := aes.NewCipher(key) - if err != nil { - return "", fmt.Errorf("failed to create cipher: %w", err) - } - - gcm, err := cipher.NewGCM(cipherBlock) - if err != nil { - return "", fmt.Errorf("failed to wrap cipher: %w", err) - } - - // Since we know the ciphertext is actually nonce+ciphertext - // And len(nonce) == NonceSize(). We can separate the two. - nonceSize := gcm.NonceSize() - nonce, ciphertext := ciphertext[:nonceSize], ciphertext[nonceSize:] - - plaintext, err := gcm.Open(nil, []byte(nonce), []byte(ciphertext), nil) - if err != nil { - return "", fmt.Errorf("failed to decrypt: %w", err) - } - - return string(plaintext), nil -} - const argonHashVariantName = "argon2id" // The parameters to be used in the secure hash generation @@ -218,12 +111,3 @@ func decodeArgon2Hash(hash string) (params *argon2Params, salt, key []byte, err params.keyLength = uint32(len(key)) return params, salt, key, nil } - -// Generate cryptographically secure random of a given length -func generateRandomBytes(size uint32) ([]byte, error) { - bucket := make([]byte, size) - if _, err := rand.Read(bucket); err != nil { - return nil, err - } - return bucket, nil -} diff --git a/internal/crypto/crypto.go b/internal/crypto/crypto.go new file mode 100644 index 0000000..98c6d9a --- /dev/null +++ b/internal/crypto/crypto.go @@ -0,0 +1,68 @@ +package crypto + +import ( + "crypto/rand" + "crypto/sha256" + "encoding/base64" + "encoding/binary" + "encoding/hex" + "fmt" + "io" + + "golang.org/x/crypto/hkdf" +) + +// simple pin number generator +// returns 4-5 digits +func MakePin() (string, error) { + b, err := generateRandomBytes(16) + if err != nil { + return "", err + } + pin := binary.BigEndian.Uint16(b) + return fmt.Sprintf("%d", pin), nil +} + +// simple random token generator (url encoded) +func MakeToken() (string, error) { + b, err := generateRandomBytes(32) + if err != nil { + return "", err + } + return base64.URLEncoding.EncodeToString(b), nil +} + +// simple text hashing +func HashText(text string) string { + textHash := sha256.Sum256([]byte(text)) + return hex.EncodeToString(textHash[:]) +} + +const keySizeBytes = 16 // 128-bit key + +// Use HMAC Key Derivation Function (HKDF) to derive a strong key (RFC5869) +// This function is useful to derive a key from some small password text the user knows +func StrongKey(passText string, saltText string) ([]byte, error) { + hashFn := sha256.New + hashSize := hashFn().Size() + passBytes := []byte(passText) + if len(saltText) != hashSize { + return nil, fmt.Errorf("invalid salt length, must be %d", hashSize) + } + saltBytes := []byte(saltText) + hkdf := hkdf.New(hashFn, passBytes, saltBytes, nil) + key := make([]byte, keySizeBytes) + if _, err := io.ReadFull(hkdf, key); err != nil { + return nil, err + } + return key, nil +} + +// Generate cryptographically secure random of a given length +func generateRandomBytes(size uint32) ([]byte, error) { + bucket := make([]byte, size) + if _, err := rand.Read(bucket); err != nil { + return nil, err + } + return bucket, nil +} diff --git a/internal/storage/hash_test.go b/internal/crypto/crypto_test.go similarity index 77% rename from internal/storage/hash_test.go rename to internal/crypto/crypto_test.go index d5cc06a..fc3ce42 100644 --- a/internal/storage/hash_test.go +++ b/internal/crypto/crypto_test.go @@ -1,4 +1,4 @@ -package storage_test +package crypto_test import ( "regexp" @@ -6,11 +6,11 @@ import ( "strings" "testing" - "github.com/ivarprudnikov/secretshare/internal/storage" + "github.com/ivarprudnikov/secretshare/internal/crypto" ) func TestHash_MakePin(t *testing.T) { - pin, err := storage.MakePin() + pin, err := crypto.MakePin() if err != nil { t.Fatalf("Expected no error, got %v", err) } @@ -25,12 +25,12 @@ func TestHash_MakePin(t *testing.T) { func TestHash_StrongKey(t *testing.T) { salt := "12345678901234567890123456789012" - key1, err := storage.StrongKey("1234", salt) + key1, err := crypto.StrongKey("1234", salt) if err != nil { t.Fatalf("Expected no error, got %v", err) } - key2, err := storage.StrongKey("1234", salt) + key2, err := crypto.StrongKey("1234", salt) if err != nil { t.Fatalf("Expected no error, got %v", err) } @@ -42,14 +42,14 @@ func TestHash_StrongKey(t *testing.T) { func TestHash_EncryptDecrypt(t *testing.T) { key := []byte("1234567890123456") - cipher, err := storage.EncryptAES(key, "abc") + cipher, err := crypto.EncryptAES(key, "abc") if err != nil { t.Fatalf("Expected no error, got %v", err) } if cipher == "abc" { t.Fatal("ciphertext should not be the same as input") } - plaintext, err := storage.DecryptAES(key, cipher) + plaintext, err := crypto.DecryptAES(key, cipher) if err != nil { t.Fatalf("Expected no error, got %v", err) } @@ -59,14 +59,14 @@ func TestHash_EncryptDecrypt(t *testing.T) { } func TestHash_HashText(t *testing.T) { - digest := storage.HashText("foobar") + digest := crypto.HashText("foobar") if digest != "c3ab8ff13720e8ad9047dd39466b3c8974e592c2fa383d4a3960714caef0c4f2" { t.Fatalf("unexpected digest %s", digest) } } func TestHash_HashPass_ThenCompare(t *testing.T) { - hashed, err := storage.HashPass("foobar") + hashed, err := crypto.HashPass("foobar") if err != nil { t.Fatalf("Expected no error, got %v", err) } @@ -74,7 +74,7 @@ func TestHash_HashPass_ThenCompare(t *testing.T) { t.Fatalf("unexpected value %s", hashed) } - err = storage.CompareHashToPass(hashed, "foobar") + err = crypto.CompareHashToPass(hashed, "foobar") if err != nil { t.Fatalf("Expected no error, got %v", err) } diff --git a/internal/storage/aztablestore/messages.go b/internal/storage/aztablestore/messages.go index 5188dc5..e69b0e6 100644 --- a/internal/storage/aztablestore/messages.go +++ b/internal/storage/aztablestore/messages.go @@ -7,10 +7,12 @@ import ( "log/slog" "github.com/Azure/azure-sdk-for-go/sdk/data/aztables" + "github.com/ivarprudnikov/secretshare/internal/crypto" "github.com/ivarprudnikov/secretshare/internal/storage" ) type azMessageStore struct { + crypto.EntityEncryptHelper accountName string tableName string salt string @@ -46,34 +48,6 @@ func (s *azMessageStore) CountMessages() (int64, error) { return count, nil } -// TODO move to storage -func (s *azMessageStore) Encrypt(text string, pass string) (string, error) { - // derive a key from the pass - key, err := storage.StrongKey(pass, s.salt) - if err != nil { - return "", err - } - ciphertext, err := storage.EncryptAES(key, text) - if err != nil { - return "", err - } - return ciphertext, nil -} - -// Decrypt cipher text with a given PIN which will be used to derive a key -func (s *azMessageStore) Decrypt(ciphertext string, pass string) (string, error) { - // derive a key from the pass - key, err := storage.StrongKey(pass, s.salt) - if err != nil { - return "", err - } - plaintext, err := storage.DecryptAES(key, ciphertext) - if err != nil { - return "", err - } - return plaintext, nil -} - func (s *azMessageStore) ListMessages(username string) ([]*storage.Message, error) { var msgs []*storage.Message client, err := s.getClient() @@ -104,11 +78,11 @@ func (s *azMessageStore) ListMessages(username string) ([]*storage.Message, erro // TODO: allow to reset the pin for the owner func (s *azMessageStore) AddMessage(text string, username string) (*storage.Message, error) { // an easy to enter pin - pin, err := storage.MakePin() + pin, err := crypto.MakePin() if err != nil { return nil, err } - ciphertext, err := s.Encrypt(text, pin) + ciphertext, err := s.Encrypt(text, pin, s.salt) if err != nil { return nil, err } @@ -117,6 +91,9 @@ func (s *azMessageStore) AddMessage(text string, username string) (*storage.Mess return nil, err } err = s.saveMessage(&msg) + if err != nil { + return nil, err + } msg.Pin = pin return &msg, nil } @@ -137,8 +114,8 @@ func (s *azMessageStore) GetFullMessage(id string, pin string) (*storage.Message return nil, err } - if err := storage.CompareHashToPass(msg.Pin, pin); err == nil { - text, err := s.Decrypt(msg.Content, pin) + if err := crypto.CompareHashToPass(msg.Pin, pin); err == nil { + text, err := s.Decrypt(msg.Content, pin, s.salt) if err != nil { return nil, err } diff --git a/internal/storage/aztablestore/users.go b/internal/storage/aztablestore/users.go index 4134fc7..f910af4 100644 --- a/internal/storage/aztablestore/users.go +++ b/internal/storage/aztablestore/users.go @@ -9,6 +9,7 @@ import ( "github.com/Azure/azure-sdk-for-go/sdk/azcore" "github.com/Azure/azure-sdk-for-go/sdk/data/aztables" + "github.com/ivarprudnikov/secretshare/internal/crypto" "github.com/ivarprudnikov/secretshare/internal/storage" ) @@ -118,7 +119,7 @@ func (u *azUserStore) GetUserWithPass(username string, password string) (*storag // even if user is not found evaluate the password // this will reduce the effect on time difference // at the time of the login check - if err := storage.CompareHashToPass(hashedPass, password); err == nil { + if err := crypto.CompareHashToPass(hashedPass, password); err == nil { return user, nil } return nil, nil diff --git a/internal/storage/memstore/messages.go b/internal/storage/memstore/messages.go index b2286dc..8b0c0d0 100644 --- a/internal/storage/memstore/messages.go +++ b/internal/storage/memstore/messages.go @@ -4,10 +4,12 @@ import ( "fmt" "sync" + "github.com/ivarprudnikov/secretshare/internal/crypto" "github.com/ivarprudnikov/secretshare/internal/storage" ) type memMessageStore struct { + crypto.EntityEncryptHelper messages sync.Map salt string } @@ -25,33 +27,6 @@ func (s *memMessageStore) CountMessages() (int64, error) { return count, nil } -func (s *memMessageStore) Encrypt(text string, pass string) (string, error) { - // derive a key from the pass - key, err := storage.StrongKey(pass, s.salt) - if err != nil { - return "", err - } - ciphertext, err := storage.EncryptAES(key, text) - if err != nil { - return "", err - } - return ciphertext, nil -} - -// Decrypt cipher text with a given PIN which will be used to derive a key -func (s *memMessageStore) Decrypt(ciphertext string, pass string) (string, error) { - // derive a key from the pass - key, err := storage.StrongKey(pass, s.salt) - if err != nil { - return "", err - } - plaintext, err := storage.DecryptAES(key, ciphertext) - if err != nil { - return "", err - } - return plaintext, nil -} - func (s *memMessageStore) ListMessages(username string) ([]*storage.Message, error) { var msgs []*storage.Message s.messages.Range(func(k, v any) bool { @@ -66,11 +41,11 @@ func (s *memMessageStore) ListMessages(username string) ([]*storage.Message, err // TODO: allow to reset the pin for the owner func (s *memMessageStore) AddMessage(text string, username string) (*storage.Message, error) { // an easy to enter pin - pin, err := storage.MakePin() + pin, err := crypto.MakePin() if err != nil { return nil, err } - ciphertext, err := s.Encrypt(text, pin) + ciphertext, err := s.Encrypt(text, pin, s.salt) if err != nil { return nil, err } @@ -102,9 +77,9 @@ func (s *memMessageStore) GetFullMessage(id string, pin string) (*storage.Messag if v, ok := s.messages.Load(id); ok { if msg, ok := v.(storage.Message); ok { - if err := storage.CompareHashToPass(msg.Pin, pin); err == nil { + if err := crypto.CompareHashToPass(msg.Pin, pin); err == nil { - text, err := s.Decrypt(msg.Content, pin) + text, err := s.Decrypt(msg.Content, pin, s.salt) if err != nil { return nil, err } diff --git a/internal/storage/memstore/messages_test.go b/internal/storage/memstore/messages_test.go index 1e81efa..c1958be 100644 --- a/internal/storage/memstore/messages_test.go +++ b/internal/storage/memstore/messages_test.go @@ -115,15 +115,16 @@ func TestMessageStore_DeletedAfterFailedAttempts(t *testing.T) { func TestMessageStore_EncryptDecrypt(t *testing.T) { // Create a new MessageStore instance - store := memstore.NewMemMessageStore("12345678123456781234567812345678") + salt := "12345678123456781234567812345678" + store := memstore.NewMemMessageStore(salt) message := "abc" key := "pass" - ciphertext, err := store.Encrypt(message, key) + ciphertext, err := store.Encrypt(message, key, salt) if err != nil { t.Fatalf("Expected no error, got %v", err) } - plaintext, err := store.Decrypt(ciphertext, key) + plaintext, err := store.Decrypt(ciphertext, key, salt) if err != nil { t.Fatalf("Expected no error, got %v", err) } diff --git a/internal/storage/memstore/users.go b/internal/storage/memstore/users.go index 940bafb..764ede2 100644 --- a/internal/storage/memstore/users.go +++ b/internal/storage/memstore/users.go @@ -4,6 +4,7 @@ import ( "errors" "sync" + "github.com/ivarprudnikov/secretshare/internal/crypto" "github.com/ivarprudnikov/secretshare/internal/storage" ) @@ -49,7 +50,7 @@ func (u *memUserStore) GetUser(username string) (*storage.User, error) { func (u *memUserStore) GetUserWithPass(username string, password string) (*storage.User, error) { if v, ok := u.users.Load(username); ok { if usr, ok := v.(storage.User); ok { - if err := storage.CompareHashToPass(usr.Password, password); err == nil { + if err := crypto.CompareHashToPass(usr.Password, password); err == nil { return &usr, nil } } diff --git a/internal/storage/messages.go b/internal/storage/messages.go index e1c0677..511c34e 100644 --- a/internal/storage/messages.go +++ b/internal/storage/messages.go @@ -4,6 +4,7 @@ import ( "time" "github.com/Azure/azure-sdk-for-go/sdk/data/aztables" + "github.com/ivarprudnikov/secretshare/internal/crypto" ) const MAX_PIN_ATTEMPTS = 5 @@ -14,8 +15,8 @@ type MessageStore interface { AddMessage(text string, username string) (*Message, error) GetMessage(id string) (*Message, error) GetFullMessage(id string, pin string) (*Message, error) - Encrypt(text string, pass string) (string, error) - Decrypt(ciphertext string, pass string) (string, error) + Encrypt(text, pass, salt string) (string, error) + Decrypt(ciphertext, pass, salt string) (string, error) } type Message struct { @@ -31,14 +32,14 @@ func (m *Message) FormattedDate() string { } func NewMessage(username string, ciphertext string, pin string) (Message, error) { - pinHash, err := HashPass(pin) + pinHash, err := crypto.HashPass(pin) if err != nil { return Message{}, err } t := time.Now() return Message{ Entity: aztables.Entity{ - PartitionKey: HashText(ciphertext), + PartitionKey: crypto.HashText(ciphertext), RowKey: username, Timestamp: aztables.EDMDateTime(t), }, diff --git a/internal/storage/users.go b/internal/storage/users.go index 9b60dd7..6adbe6a 100644 --- a/internal/storage/users.go +++ b/internal/storage/users.go @@ -5,6 +5,7 @@ import ( "time" "github.com/Azure/azure-sdk-for-go/sdk/data/aztables" + "github.com/ivarprudnikov/secretshare/internal/crypto" ) const PERMISSION_READ_STATS = "read:stats" @@ -37,7 +38,7 @@ func (u *User) HasPermission(permission string) bool { } func NewUser(username string, password string, permissions []string) (User, error) { - hashedPass, err := HashPass(password) + hashedPass, err := crypto.HashPass(password) if err != nil { return User{}, err } diff --git a/internal/storage/users_test.go b/internal/storage/users_test.go index b0df45c..a918b67 100644 --- a/internal/storage/users_test.go +++ b/internal/storage/users_test.go @@ -4,6 +4,7 @@ import ( "encoding/json" "testing" + "github.com/ivarprudnikov/secretshare/internal/crypto" "github.com/ivarprudnikov/secretshare/internal/storage" ) @@ -30,7 +31,7 @@ func TestUser(t *testing.T) { if user.Permissions != "baz,bau" { t.Fatalf("Unexpected output %v", user.Permissions) } - err = storage.CompareHashToPass(user.Password, "bar") + err = crypto.CompareHashToPass(user.Password, "bar") if err != nil { t.Fatalf("Unexpected error %v", err) } diff --git a/routes.go b/routes.go index 4f0805c..2ef45c5 100644 --- a/routes.go +++ b/routes.go @@ -11,6 +11,7 @@ import ( "net/http" "github.com/gorilla/sessions" + "github.com/ivarprudnikov/secretshare/internal/crypto" "github.com/ivarprudnikov/secretshare/internal/storage" ) @@ -342,7 +343,7 @@ func newAppMiddleware(sessions *sessions.CookieStore, users storage.UserStore) f if r.Method == "GET" { // setup CSRF token for pages - t, err := storage.MakeToken() + t, err := crypto.MakeToken() if err != nil { sendError(r.Context(), sess, w, "failed to setup csrf", err) return