mirror of
https://github.com/pocket-id/pocket-id.git
synced 2026-02-15 02:55:14 +00:00
feat: encrypt private keys saved on disk and in database (#682)
Co-authored-by: Kyle Mendell <kmendell@ofkm.us>
This commit is contained in:
committed by
GitHub
parent
9872608d61
commit
5550729120
69
backend/internal/utils/crypto/crypto.go
Normal file
69
backend/internal/utils/crypto/crypto.go
Normal file
@@ -0,0 +1,69 @@
|
||||
package crypto
|
||||
|
||||
import (
|
||||
"crypto/aes"
|
||||
"crypto/cipher"
|
||||
"crypto/rand"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
)
|
||||
|
||||
// ErrDecrypt is returned by Decrypt when the operation failed for any reason
|
||||
var ErrDecrypt = errors.New("failed to decrypt data")
|
||||
|
||||
// Encrypt a byte slice using AES-GCM and a random nonce
|
||||
// Important: do not encrypt more than ~4 billion messages with the same key!
|
||||
func Encrypt(key []byte, plaintext []byte, associatedData []byte) (ciphertext []byte, err error) {
|
||||
block, err := aes.NewCipher(key)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to create block cipher: %w", err)
|
||||
}
|
||||
aead, err := cipher.NewGCM(block)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to create AEAD cipher: %w", err)
|
||||
}
|
||||
|
||||
// Generate a random nonce
|
||||
nonce := make([]byte, aead.NonceSize())
|
||||
_, err = io.ReadFull(rand.Reader, nonce)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to generate random nonce: %w", err)
|
||||
}
|
||||
|
||||
// Allocate the slice for the result, with additional space for the nonce and overhead
|
||||
ciphertext = make([]byte, 0, len(plaintext)+aead.NonceSize()+aead.Overhead())
|
||||
ciphertext = append(ciphertext, nonce...)
|
||||
|
||||
// Encrypt the plaintext
|
||||
// Tag is automatically added at the end
|
||||
ciphertext = aead.Seal(ciphertext, nonce, plaintext, associatedData)
|
||||
|
||||
return ciphertext, nil
|
||||
}
|
||||
|
||||
// Decrypt a byte slice using AES-GCM
|
||||
func Decrypt(key []byte, ciphertext []byte, associatedData []byte) (plaintext []byte, err error) {
|
||||
block, err := aes.NewCipher(key)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to create block cipher: %w", err)
|
||||
}
|
||||
aead, err := cipher.NewGCM(block)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to create AEAD cipher: %w", err)
|
||||
}
|
||||
|
||||
// Extract the nonce
|
||||
if len(ciphertext) < (aead.NonceSize() + aead.Overhead()) {
|
||||
return nil, ErrDecrypt
|
||||
}
|
||||
|
||||
// Decrypt the data
|
||||
plaintext, err = aead.Open(nil, ciphertext[:aead.NonceSize()], ciphertext[aead.NonceSize():], associatedData)
|
||||
if err != nil {
|
||||
// Note: we do not return the exact error here, to avoid disclosing information
|
||||
return nil, ErrDecrypt
|
||||
}
|
||||
|
||||
return plaintext, nil
|
||||
}
|
||||
208
backend/internal/utils/crypto/crypto_test.go
Normal file
208
backend/internal/utils/crypto/crypto_test.go
Normal file
@@ -0,0 +1,208 @@
|
||||
package crypto
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"fmt"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestEncryptDecrypt(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
keySize int
|
||||
plaintext string
|
||||
associatedData []byte
|
||||
}{
|
||||
{
|
||||
name: "AES-128 with short plaintext",
|
||||
keySize: 16,
|
||||
plaintext: "Hello, World!",
|
||||
associatedData: []byte("test-aad"),
|
||||
},
|
||||
{
|
||||
name: "AES-192 with medium plaintext",
|
||||
keySize: 24,
|
||||
plaintext: "This is a longer message to test encryption and decryption",
|
||||
associatedData: []byte("associated-data-192"),
|
||||
},
|
||||
{
|
||||
name: "AES-256 with unicode",
|
||||
keySize: 32,
|
||||
plaintext: "Hello 世界! 🌍 Testing unicode characters", //nolint:gosmopolitan
|
||||
associatedData: []byte("unicode-test"),
|
||||
},
|
||||
{
|
||||
name: "No associated data",
|
||||
keySize: 32,
|
||||
plaintext: "Testing without associated data",
|
||||
associatedData: nil,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
// Generate random key
|
||||
key := make([]byte, tt.keySize)
|
||||
_, err := rand.Read(key)
|
||||
require.NoError(t, err, "Failed to generate random key")
|
||||
|
||||
plaintext := []byte(tt.plaintext)
|
||||
|
||||
// Test encryption
|
||||
ciphertext, err := Encrypt(key, plaintext, tt.associatedData)
|
||||
require.NoError(t, err, "Encrypt should succeed")
|
||||
|
||||
// Verify ciphertext is different from plaintext (unless empty)
|
||||
if len(plaintext) > 0 {
|
||||
assert.NotEqual(t, plaintext, ciphertext)
|
||||
}
|
||||
|
||||
// Test decryption
|
||||
decrypted, err := Decrypt(key, ciphertext, tt.associatedData)
|
||||
require.NoError(t, err, "Decrypt should succeed")
|
||||
|
||||
// Verify decrypted text matches original
|
||||
assert.Equal(t, plaintext, decrypted, "Decrypted text should match original")
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestEncryptWithInvalidKeySize(t *testing.T) {
|
||||
invalidKeySizes := []int{8, 12, 33, 47, 55, 128}
|
||||
|
||||
for _, keySize := range invalidKeySizes {
|
||||
t.Run(fmt.Sprintf("Key size %d", keySize), func(t *testing.T) {
|
||||
key := make([]byte, keySize)
|
||||
plaintext := []byte("test message")
|
||||
|
||||
_, err := Encrypt(key, plaintext, nil)
|
||||
require.Error(t, err)
|
||||
assert.ErrorContains(t, err, "invalid key size")
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestDecryptWithInvalidKeySize(t *testing.T) {
|
||||
invalidKeySizes := []int{8, 12, 33, 47, 55, 128}
|
||||
|
||||
for _, keySize := range invalidKeySizes {
|
||||
t.Run(fmt.Sprintf("Key size %d", keySize), func(t *testing.T) {
|
||||
key := make([]byte, keySize)
|
||||
ciphertext := []byte("fake ciphertext")
|
||||
|
||||
_, err := Decrypt(key, ciphertext, nil)
|
||||
require.Error(t, err)
|
||||
assert.ErrorContains(t, err, "invalid key size")
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestDecryptWithInvalidCiphertext(t *testing.T) {
|
||||
key := make([]byte, 32)
|
||||
_, err := rand.Read(key)
|
||||
require.NoError(t, err, "Failed to generate random key")
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
ciphertext []byte
|
||||
}{
|
||||
{
|
||||
name: "empty ciphertext",
|
||||
ciphertext: []byte{},
|
||||
},
|
||||
{
|
||||
name: "too short ciphertext",
|
||||
ciphertext: []byte("short"),
|
||||
},
|
||||
{
|
||||
name: "random invalid data",
|
||||
ciphertext: []byte("this is not valid encrypted data"),
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
_, err := Decrypt(key, tt.ciphertext, nil)
|
||||
require.Error(t, err)
|
||||
require.ErrorIs(t, err, ErrDecrypt)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestDecryptWithWrongKey(t *testing.T) {
|
||||
// Generate two different keys
|
||||
key1 := make([]byte, 32)
|
||||
key2 := make([]byte, 32)
|
||||
_, err := rand.Read(key1)
|
||||
require.NoError(t, err)
|
||||
_, err = rand.Read(key2)
|
||||
require.NoError(t, err)
|
||||
|
||||
plaintext := []byte("secret message")
|
||||
|
||||
// Encrypt with key1
|
||||
ciphertext, err := Encrypt(key1, plaintext, nil)
|
||||
require.NoError(t, err)
|
||||
|
||||
// Try to decrypt with key2
|
||||
_, err = Decrypt(key2, ciphertext, nil)
|
||||
require.Error(t, err)
|
||||
require.ErrorIs(t, err, ErrDecrypt)
|
||||
}
|
||||
|
||||
func TestDecryptWithWrongAssociatedData(t *testing.T) {
|
||||
key := make([]byte, 32)
|
||||
_, err := rand.Read(key)
|
||||
require.NoError(t, err, "Failed to generate random key")
|
||||
|
||||
plaintext := []byte("secret message")
|
||||
correctAAD := []byte("correct-aad")
|
||||
wrongAAD := []byte("wrong-aad")
|
||||
|
||||
// Encrypt with correct AAD
|
||||
ciphertext, err := Encrypt(key, plaintext, correctAAD)
|
||||
require.NoError(t, err)
|
||||
|
||||
// Try to decrypt with wrong AAD
|
||||
_, err = Decrypt(key, ciphertext, wrongAAD)
|
||||
require.Error(t, err)
|
||||
require.ErrorIs(t, err, ErrDecrypt)
|
||||
|
||||
// Verify correct AAD works
|
||||
decrypted, err := Decrypt(key, ciphertext, correctAAD)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, plaintext, decrypted, "Decrypted text should match original when using correct AAD")
|
||||
}
|
||||
|
||||
func TestEncryptDecryptConsistency(t *testing.T) {
|
||||
key := make([]byte, 32)
|
||||
_, err := rand.Read(key)
|
||||
require.NoError(t, err)
|
||||
|
||||
plaintext := []byte("consistency test message")
|
||||
associatedData := []byte("test-aad")
|
||||
|
||||
// Encrypt multiple times and verify we get different ciphertexts (due to random IV)
|
||||
ciphertext1, err := Encrypt(key, plaintext, associatedData)
|
||||
require.NoError(t, err)
|
||||
|
||||
ciphertext2, err := Encrypt(key, plaintext, associatedData)
|
||||
require.NoError(t, err)
|
||||
|
||||
// Ciphertexts should be different (due to random IV)
|
||||
assert.NotEqual(t, ciphertext1, ciphertext2, "Multiple encryptions of same plaintext should produce different ciphertexts")
|
||||
|
||||
// Both should decrypt to the same plaintext
|
||||
decrypted1, err := Decrypt(key, ciphertext1, associatedData)
|
||||
require.NoError(t, err)
|
||||
|
||||
decrypted2, err := Decrypt(key, ciphertext2, associatedData)
|
||||
require.NoError(t, err)
|
||||
|
||||
assert.Equal(t, plaintext, decrypted1, "First decrypted text should match original")
|
||||
assert.Equal(t, plaintext, decrypted2, "Second decrypted text should match original")
|
||||
assert.Equal(t, decrypted1, decrypted2, "Both decrypted texts should be identical")
|
||||
}
|
||||
Reference in New Issue
Block a user