Skip to content

Commit 63aed55

Browse files
committed
feat: add crypto package for AES-GCM and bcrypt, and implement EchoErrorHandler adapter for global error handling
1 parent f334926 commit 63aed55

10 files changed

Lines changed: 269 additions & 5 deletions

File tree

README.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ Each package is designed to be tightly scoped with minimal dependencies across p
88

99
- **[config](./config/)**: Environment and File-based generic configuration loading.
1010
- **[api](./api/)**: Helper structs and utilities for the [Echo web framework](https://echo.labstack.com) (extensible to other frameworks like Chi/Gin).
11+
- **[crypto](./crypto/)**: Generic AES-256-GCM authenticated encryption and BCrypt credential hashing.
1112
- **[httperr](./httperr/)**: Structured and actionable error types for web APIs (`ServiceError`).
1213
- **[logger](./logger/)**: Generic JSON/text logger wrapping `log/slog` with Context extractors and multi-handler fan out.
1314
- **[pgsql](./pgsql/)**: Configurable PostgreSQL connection helpers with transaction context wrappers.

api/README.md

Lines changed: 1 addition & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -19,10 +19,7 @@ func main() {
1919
e := echo.New()
2020

2121
// Set our standard error handler
22-
errorHandler := api.NewEchoErrorHandler(slog.Default())
23-
e.HTTPErrorHandler = func(err error, c echo.Context) {
24-
errorHandler.HandleError(c, err, "request")
25-
}
22+
e.HTTPErrorHandler = api.NewEchoErrorHandler(slog.Default()).EchoHandler
2623

2724
// Register routes safely
2825
routes := []api.EchoRouteConfig{

api/echo_router.go

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ package api
88
import (
99
"encoding/json"
1010
"errors"
11+
"fmt"
1112
"io"
1213
"log/slog"
1314

@@ -65,6 +66,23 @@ func (h *EchoErrorHandler) HandleError(c echo.Context, err error, operation stri
6566
return c.JSON(httperr.StatusInternalServer, httperr.ErrInternalServer)
6667
}
6768

69+
// EchoHandler adapts EchoErrorHandler to echo.HTTPErrorHandler for global registration.
70+
// It uses "http_request" as the default operation context.
71+
func (h *EchoErrorHandler) EchoHandler(err error, c echo.Context) {
72+
// If the response was already committed, we shouldn't send another JSON
73+
if c.Response().Committed {
74+
return
75+
}
76+
// Echo's default error maps like 404 and 405 are returned as *echo.HTTPError
77+
var he *echo.HTTPError
78+
if errors.As(err, &he) {
79+
_ = c.JSON(he.Code, httperr.NewServiceError(he.Code, fmt.Sprintf("%v", he.Message)))
80+
return
81+
}
82+
83+
_ = h.HandleError(c, err, "http_request")
84+
}
85+
6886
// EchoUnescapedHTMLJSONSerializer is a JSON serializer that disables HTML escaping.
6987
// Use this when returning pre-sanitized HTML content in JSON responses.
7088
type EchoUnescapedHTMLJSONSerializer struct{}

api/echo_router_test.go

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -69,6 +69,34 @@ func TestErrorHandler(t *testing.T) {
6969
t.Errorf("expected 500, got %d", rec.Code)
7070
}
7171
})
72+
73+
t.Run("EchoHandler Adapter", func(t *testing.T) {
74+
req := httptest.NewRequest(http.MethodGet, "/", nil)
75+
rec := httptest.NewRecorder()
76+
c := e.NewContext(req, rec)
77+
78+
// Test that basic errors fall back to 500
79+
err := errors.New("fallback error")
80+
handler.EchoHandler(err, c)
81+
82+
if rec.Code != 500 {
83+
t.Errorf("expected 500, got %d", rec.Code)
84+
}
85+
})
86+
87+
t.Run("EchoHandler Native HTTPError", func(t *testing.T) {
88+
req := httptest.NewRequest(http.MethodGet, "/", nil)
89+
rec := httptest.NewRecorder()
90+
c := e.NewContext(req, rec)
91+
92+
// Test that native echo errors map accurately
93+
err := echo.NewHTTPError(http.StatusNotFound, "Not Found Route")
94+
handler.EchoHandler(err, c)
95+
96+
if rec.Code != 404 {
97+
t.Errorf("expected 404, got %d", rec.Code)
98+
}
99+
})
72100
}
73101

74102
func TestSanitizeBody(t *testing.T) {

crypto/README.md

Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,60 @@
1+
# crypto
2+
3+
The `crypto` package abstracts common cryptographic operations used frequently in web services like structured AES-256-GCM authenticated encryption and BCrypt credential hashing.
4+
5+
## Usage Example
6+
7+
### AEAD Secret Cryptography
8+
9+
```go
10+
package main
11+
12+
import (
13+
"fmt"
14+
"github.com/weprodev/go-pkg/crypto"
15+
)
16+
17+
func main() {
18+
key := []byte("0123456789abcdef0123456789abcdef") // 32 bytes required
19+
20+
svc, err := crypto.NewAESService(key)
21+
if err != nil {
22+
panic(err)
23+
}
24+
25+
raw := []byte("highly sensitive internal data")
26+
27+
cipher, _ := svc.Encrypt(raw)
28+
fmt.Printf("Encrypted safely: %x\n", cipher)
29+
30+
// And reverse to use it...
31+
plain, _ := svc.Decrypt(cipher)
32+
fmt.Println(string(plain))
33+
}
34+
```
35+
36+
### Credential Hashing
37+
38+
```go
39+
package main
40+
41+
import (
42+
"fmt"
43+
"github.com/weprodev/go-pkg/crypto"
44+
)
45+
46+
func main() {
47+
password := "my_admin_password!"
48+
49+
// Automatic bcrypt salt & hash (Cost: 14)
50+
hash, _ := crypto.HashSecret(password)
51+
52+
if crypto.CheckSecretHash("wrong", hash) {
53+
fmt.Println("This string is never printed.")
54+
}
55+
56+
if crypto.CheckSecretHash(password, hash) {
57+
fmt.Println("Success!")
58+
}
59+
}
60+
```

crypto/aes.go

Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,62 @@
1+
package crypto
2+
3+
import (
4+
"crypto/aes"
5+
"crypto/cipher"
6+
"crypto/rand"
7+
"errors"
8+
"io"
9+
)
10+
11+
// AESService provides AES-256-GCM encryption for secrets.
12+
type AESService struct {
13+
key []byte
14+
}
15+
16+
// NewAESService creates a new AESService. Key must be exactly 32 bytes.
17+
func NewAESService(key []byte) (*AESService, error) {
18+
if len(key) != 32 {
19+
return nil, errors.New("AES-256 requires a 32-byte key")
20+
}
21+
return &AESService{key: key}, nil
22+
}
23+
24+
// Encrypt encrypts the plaintext using AES-GCM.
25+
func (s *AESService) Encrypt(plaintext []byte) ([]byte, error) {
26+
block, err := aes.NewCipher(s.key)
27+
if err != nil {
28+
return nil, err
29+
}
30+
31+
gcm, err := cipher.NewGCM(block)
32+
if err != nil {
33+
return nil, err
34+
}
35+
36+
nonce := make([]byte, gcm.NonceSize())
37+
if _, err := io.ReadFull(rand.Reader, nonce); err != nil {
38+
return nil, err
39+
}
40+
41+
return gcm.Seal(nonce, nonce, plaintext, nil), nil
42+
}
43+
44+
// Decrypt decrypts the ciphertext using AES-GCM.
45+
func (s *AESService) Decrypt(ciphertext []byte) ([]byte, error) {
46+
block, err := aes.NewCipher(s.key)
47+
if err != nil {
48+
return nil, err
49+
}
50+
51+
gcm, err := cipher.NewGCM(block)
52+
if err != nil {
53+
return nil, err
54+
}
55+
56+
if len(ciphertext) < gcm.NonceSize() {
57+
return nil, errors.New("ciphertext too short")
58+
}
59+
60+
nonce, ciphertext := ciphertext[:gcm.NonceSize()], ciphertext[gcm.NonceSize():]
61+
return gcm.Open(nil, nonce, ciphertext, nil)
62+
}

crypto/aes_test.go

Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,59 @@
1+
package crypto_test
2+
3+
import (
4+
"bytes"
5+
"testing"
6+
7+
"github.com/weprodev/go-pkg/crypto"
8+
)
9+
10+
func TestAESService_Validation(t *testing.T) {
11+
shortKey := []byte("short_key")
12+
if _, err := crypto.NewAESService(shortKey); err == nil {
13+
t.Fatal("expected error for non-32-byte key")
14+
}
15+
16+
validKey := make([]byte, 32)
17+
if _, err := crypto.NewAESService(validKey); err != nil {
18+
t.Fatalf("unexpected error for 32-byte key: %v", err)
19+
}
20+
}
21+
22+
func TestAESService_EncryptDecrypt(t *testing.T) {
23+
key := []byte("0123456789abcdef0123456789abcdef") // 32 bytes
24+
svc, err := crypto.NewAESService(key)
25+
if err != nil {
26+
t.Fatalf("failed to create AES service: %v", err)
27+
}
28+
29+
plaintext := []byte("hello world this is a secret")
30+
ciphertext, err := svc.Encrypt(plaintext)
31+
if err != nil {
32+
t.Fatalf("encryption failed: %v", err)
33+
}
34+
35+
if bytes.Equal(plaintext, ciphertext) {
36+
t.Fatal("ciphertext should not match plaintext")
37+
}
38+
39+
decrypted, err := svc.Decrypt(ciphertext)
40+
if err != nil {
41+
t.Fatalf("decryption failed: %v", err)
42+
}
43+
44+
if !bytes.Equal(plaintext, decrypted) {
45+
t.Errorf("expected %s, got %s", plaintext, decrypted)
46+
}
47+
}
48+
49+
func TestAESService_DecryptShortCiphertext(t *testing.T) {
50+
key := []byte("0123456789abcdef0123456789abcdef")
51+
svc, _ := crypto.NewAESService(key)
52+
53+
// Minimal GCM nonce size is 12. Providing less than that should error out safely.
54+
shortData := []byte("too_short")
55+
_, err := svc.Decrypt(shortData)
56+
if err == nil {
57+
t.Fatal("expected error decoding short ciphertext")
58+
}
59+
}

crypto/hash.go

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
package crypto
2+
3+
import "golang.org/x/crypto/bcrypt"
4+
5+
// HashSecret hashes a plaintext secret using bcrypt.
6+
func HashSecret(secret string) (string, error) {
7+
bytes, err := bcrypt.GenerateFromPassword([]byte(secret), 14)
8+
return string(bytes), err
9+
}
10+
11+
// CheckSecretHash compares a plaintext secret with a bcrypt hash.
12+
func CheckSecretHash(secret, hash string) bool {
13+
err := bcrypt.CompareHashAndPassword([]byte(hash), []byte(secret))
14+
return err == nil
15+
}

crypto/hash_test.go

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
package crypto_test
2+
3+
import (
4+
"testing"
5+
6+
"github.com/weprodev/go-pkg/crypto"
7+
)
8+
9+
func TestHashAndCheckSecret(t *testing.T) {
10+
secret := "super_secret_password_123!"
11+
12+
hash, err := crypto.HashSecret(secret)
13+
if err != nil {
14+
t.Fatalf("failed to hash secret: %v", err)
15+
}
16+
17+
if crypto.CheckSecretHash("wrong_password", hash) {
18+
t.Error("expected wrong password to fail check")
19+
}
20+
21+
if !crypto.CheckSecretHash(secret, hash) {
22+
t.Error("expected correct secret to pass check")
23+
}
24+
}

go.mod

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ require (
99
github.com/lib/pq v1.12.1
1010
github.com/microcosm-cc/bluemonday v1.0.27
1111
github.com/stretchr/testify v1.11.1
12+
golang.org/x/crypto v0.49.0
1213
gopkg.in/yaml.v3 v3.0.1
1314
)
1415

@@ -26,7 +27,6 @@ require (
2627
github.com/pmezard/go-difflib v1.0.0 // indirect
2728
github.com/valyala/bytebufferpool v1.0.0 // indirect
2829
github.com/valyala/fasttemplate v1.2.2 // indirect
29-
golang.org/x/crypto v0.49.0 // indirect
3030
golang.org/x/net v0.51.0 // indirect
3131
golang.org/x/sys v0.42.0 // indirect
3232
golang.org/x/text v0.35.0 // indirect

0 commit comments

Comments
 (0)