Skip to content

Commit 8b510d0

Browse files
committed
crypter: make it safer, add more documentation, clarify limitations, add more tests
1 parent d5789aa commit 8b510d0

2 files changed

Lines changed: 394 additions & 213 deletions

File tree

crypter/crypter.go

Lines changed: 207 additions & 56 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,68 @@
1+
// Package crypter provides AES-GCM authenticated encryption and decryption for string values.
2+
//
3+
// # Format
4+
//
5+
// Every encrypted value is stored as a tagged string:
6+
//
7+
// ENCv1[<base64url-raw(nonce || ciphertext || tag)>]
8+
//
9+
// Where:
10+
// - "ENCv1[" is the literal start tag (StartTag) identifying the format version.
11+
// - The payload is standard base64url without padding (RFC 4648 §5).
12+
// - The payload decodes to: 12-byte random nonce, followed by the AES-GCM ciphertext, followed by the 16-byte GCM authentication tag.
13+
// - "]" is the literal end tag (EndTag).
14+
//
15+
// # Key requirements
16+
//
17+
// The secret passed to New must be exactly 16, 24, or 32 bytes of raw key material
18+
// (corresponding to AES-128, AES-192, or AES-256). It is used directly as the AES key
19+
// with no stretching. Use a cryptographically random key (e.g. from pwgen -s or crypto/rand),
20+
// not a human-memorable passphrase.
21+
//
22+
// # Mixed-plaintext configs
23+
//
24+
// Both Encrypt and Decrypt are safe to call on values that may or may not already be
25+
// encrypted. Encrypt is idempotent (tagged values pass through unchanged); Decrypt
26+
// returns untagged values as-is. This makes the package suitable as a transparent
27+
// encrypt/decrypt layer over config maps that mix plaintext and encrypted fields.
28+
//
29+
// # Thread safety
30+
//
31+
// A single Crypter may be used concurrently from multiple goroutines without external
32+
// synchronization. The internal nonce pool is goroutine-safe.
33+
//
34+
// # Usage
35+
//
36+
// Initialize once with a 16/24/32-byte key, then reuse the Crypter for all operations:
37+
//
38+
// c, err := crypter.New(os.Getenv("SECRET_KEY")) // e.g. SECRET_KEY=$(pwgen -s 32 1)
39+
// if err != nil {
40+
// log.Fatal(err)
41+
// }
42+
//
43+
// Encrypt a plaintext value:
44+
//
45+
// enc, err := c.Encrypt("my-database-password")
46+
// // enc == "ENCv1[<base64url>]"
47+
//
48+
// Decrypt it back:
49+
//
50+
// plain, err := c.Decrypt(enc)
51+
// // plain == "my-database-password"
52+
//
53+
// Decrypt is safe to call on values that may or may not be encrypted — plaintext
54+
// passes through unchanged, which makes it suitable as a transparent layer over
55+
// config maps:
56+
//
57+
// // both calls succeed; second returns "already-plain" as-is
58+
// c.Decrypt("ENCv1[...]") // → decrypted plaintext, nil
59+
// c.Decrypt("already-plain") // → "already-plain", nil
60+
//
61+
// Check whether a value is encrypted without performing any cryptographic work:
62+
//
63+
// if c.IsEncrypted(value) {
64+
// // value came from an encrypted config field
65+
// }
166
package crypter
267

368
import (
@@ -12,60 +77,108 @@ import (
1277
)
1378

1479
const (
15-
// StartTag marks the beginning of the encrypted value.
16-
// Format: ENCv1[<base64url-raw(nonce||ciphertext||tag)>]
80+
// StartTag is the prefix that identifies a string as encrypted by this package.
81+
// It encodes the format version ("v1") so future format changes can use a different prefix.
82+
// Any string passed to Encrypt that already begins with StartTag is assumed to be
83+
// encrypted and returned unchanged — callers must ensure plaintext values never start
84+
// with this prefix.
85+
//
86+
// Full format: ENCv1[<base64url-raw(nonce||ciphertext||tag)>]
1787
StartTag = "ENCv1["
1888

19-
// EndTag marks the end of the encrypted value.
89+
// EndTag is the suffix that closes the encrypted value.
90+
// It is a single "]" character, which is unambiguous within the base64url alphabet
91+
// (base64url uses A-Za-z0-9_-, none of which are "]").
92+
// Note: when embedding encrypted values in YAML or JSON, quote the value to prevent
93+
// parsers from interpreting the surrounding brackets as a flow sequence or array.
2094
EndTag = "]"
2195
)
2296

2397
var (
24-
// ErrInvalidCipherText is returned when the provided ciphertext is malformed,
25-
// not produced by this package, or authentication fails.
98+
// ErrInvalidCipherText is returned when the ciphertext is structurally malformed:
99+
// missing the end tag, the decoded payload is shorter than a GCM nonce, or the
100+
// ciphertext was not produced by this package (wrong format version, truncated, etc.).
101+
// Authentication failures from a wrong key are reported as ErrOpen, not this error.
26102
ErrInvalidCipherText = errors.New("crypter: invalid ciphertext")
27103

28-
// ErrInvalidKeyLength is returned when the provided key length is not valid for AES.
29-
// AES keys must be 16, 24, or 32 bytes.
104+
// ErrInvalidKeyLength is returned by New when the provided secret is not 16, 24, or 32 bytes.
105+
// AES requires one of these exact key lengths (AES-128, AES-192, AES-256 respectively).
106+
// The length is measured in bytes, not Unicode code points — a 32-rune string with
107+
// multi-byte characters may be more than 32 bytes and will be rejected.
30108
ErrInvalidKeyLength = errors.New("crypter: invalid key length")
31109

32-
// ErrEmptyPayload is returned when the encrypted value has no payload between tags.
110+
// ErrEmptyPayload is returned when the tagged string contains no base64 payload,
111+
// i.e. the input is exactly "ENCv1[]". This is distinct from ErrInvalidCipherText
112+
// to make the failure mode explicit when debugging configuration values.
33113
ErrEmptyPayload = errors.New("crypter: empty payload")
34114

35-
// ErrNewCipher is returned when aes.NewCipher fails.
115+
// ErrNewCipher is returned (wrapping the underlying error) when aes.NewCipher fails.
116+
// In practice this only happens if the key length is invalid, which New already
117+
// validates — so this error should never be seen in normal usage.
36118
ErrNewCipher = errors.New("crypter: aes.NewCipher failed")
37119

38-
// ErrNewGCM is returned when cipher.NewGCM fails.
120+
// ErrNewGCM is returned (wrapping the underlying error) when cipher.NewGCM fails.
121+
// This should never occur in practice; it is included for completeness.
39122
ErrNewGCM = errors.New("crypter: cipher.NewGCM failed")
40123

41-
// ErrReadNonce is returned when reading cryptographic nonce from rand.Reader fails.
124+
// ErrReadNonce is returned when reading random bytes from crypto/rand fails during
125+
// encryption. This is an OS-level error (e.g. /dev/urandom exhausted or unavailable)
126+
// and is unrecoverable. The encrypted value is not produced.
42127
ErrReadNonce = errors.New("crypter: read nonce failed")
43128

44-
// ErrBase64Decode is returned when base64 decoding of the payload fails.
129+
// ErrBase64Decode is returned when the payload between the tags is not valid base64url.
130+
// This means the ciphertext string was corrupted or hand-edited after encryption.
45131
ErrBase64Decode = errors.New("crypter: base64 decode failed")
46132

47-
// ErrOpen is returned when AEAD authentication/decryption fails.
133+
// ErrOpen is returned (wrapping the underlying cipher error) when AES-GCM
134+
// authentication or decryption fails. This happens when:
135+
// - the ciphertext was encrypted with a different key,
136+
// - the ciphertext or its authentication tag was tampered with, or
137+
// - the nonce was corrupted.
138+
// The underlying error is wrapped so errors.Is(err, ErrOpen) works, and the
139+
// original cipher message is preserved for debugging via errors.Unwrap.
48140
ErrOpen = errors.New("crypter: aead open failed")
49141
)
50142

143+
// startLen is the byte length of StartTag, cached to avoid repeated len() calls
144+
// in the hot path of IsEncrypted.
51145
const startLen = len(StartTag)
52146

53-
// Crypter provides methods to encrypt and decrypt strings using AES-GCM.
54-
// It uses a simple tagging format to identify encrypted values and avoid double encryption.
55-
// The encrypted format is: ENCv1[<base64url-raw(nonce||ciphertext||tag)>]
56-
// The nonce is generated randomly for each encryption and is included in the payload for decryption.
57-
// The IsEncrypted method provides a fast heuristic to check if a string is encrypted by this package,
58-
// without performing any cryptographic operations.
59-
// The Encrypt method encrypts the input string if it is not already tagged, and returns the tagged encrypted string.
60-
// The Decrypt method decrypts the input string if it is tagged, and returns the plaintext; otherwise it returns the input unchanged.
147+
// Crypter encrypts and decrypts string values using AES-GCM authenticated encryption.
148+
//
149+
// A Crypter is initialized once with a fixed AES key and then reused for any number of
150+
// Encrypt/Decrypt calls. The internal cipher and nonce pool are immutable after construction,
151+
// so concurrent use is safe without external locking.
152+
//
153+
// The zero value is not usable; always construct with New.
61154
type Crypter struct {
62-
aead cipher.AEAD
155+
// aead is the AES-GCM AEAD instance initialized from the caller's key.
156+
// It is safe for concurrent use; Go's cipher.AEAD implementations do not mutate
157+
// internal state during Seal/Open.
158+
aead cipher.AEAD
159+
160+
// nonceSize is aead.NonceSize(), cached here to avoid repeated interface calls
161+
// in the hot path. For standard AES-GCM this is always 12 (96 bits).
63162
nonceSize int
163+
164+
// noncePool is a pool of *[]byte nonce buffers, one per concurrent Encrypt call.
165+
// Using a pool avoids allocating a fresh 12-byte slice on every encryption.
166+
// The pool stores *[]byte (pointer) rather than []byte (slice header) so that
167+
// pool.Put never heap-allocates the interface box: a 24-byte slice header does not
168+
// fit in the pointer word of an interface value, but a pointer does.
64169
noncePool sync.Pool
65170
}
66171

67-
// New initializes a new Crypter with the provided secret key.
68-
// The secret must be 16, 24, or 32 bytes (AES-128/192/256).
172+
// New initializes a Crypter with the provided AES key.
173+
//
174+
// The secret must be exactly 16, 24, or 32 bytes, selecting AES-128, AES-192, or AES-256
175+
// respectively. The bytes are used directly as the AES key — no hashing or stretching is
176+
// applied. Provide a cryptographically random key (e.g. from pwgen -s 32 or crypto/rand),
177+
// not a human-memorable passphrase.
178+
//
179+
// Returns ErrInvalidKeyLength if the secret is not one of the three valid lengths.
180+
// Other errors (ErrNewCipher, ErrNewGCM) indicate internal cipher initialization failure
181+
// and should not occur with a valid key.
69182
func New(secret string) (*Crypter, error) {
70183
key := []byte(secret)
71184
switch len(key) {
@@ -94,35 +207,64 @@ func New(secret string) (*Crypter, error) {
94207
}, nil
95208
}
96209

97-
// IsEncrypted is the hot-path heuristic check. For maximum performance it only
98-
// checks the opening tag.
210+
// IsEncrypted reports whether s looks like a value encrypted by this package.
211+
//
212+
// It is a fast heuristic: it checks only that s begins with StartTag and has at least
213+
// one byte after it. No base64 decoding or cryptographic verification is performed.
214+
// A false positive (a plaintext value that happens to start with "ENCv1[") will cause
215+
// Encrypt to skip encryption and Decrypt to attempt — and fail — decryption.
99216
//
100-
// Contract: plaintext values will never start with StartTag.
217+
// Contract: plaintext values must never begin with StartTag. This is the caller's
218+
// responsibility; the package does not enforce it.
101219
func (c *Crypter) IsEncrypted(s string) bool {
102220
return len(s) > startLen && s[:startLen] == StartTag
103221
}
104222

105-
// Encrypt returns data wrapped in ENCv1[...] using AES-GCM.
106-
// If data is already tagged, it returns it unchanged.
223+
// Encrypt encrypts data using AES-GCM and returns the result wrapped in ENCv1[...].
224+
//
225+
// If data is already tagged (IsEncrypted returns true), it is returned unchanged —
226+
// Encrypt is idempotent. Otherwise a fresh 12-byte random nonce is drawn from
227+
// crypto/rand, the plaintext is sealed with AES-GCM, and the result is encoded as:
228+
//
229+
// ENCv1[<base64url-raw(nonce || ciphertext || GCM-tag)>]
230+
//
231+
// The nonce is unique per call, so encrypting the same plaintext twice produces
232+
// different ciphertext each time.
233+
//
234+
// Warning: if data begins with StartTag but is not a valid encrypted value (a violation
235+
// of the caller contract documented on IsEncrypted), Encrypt returns it unchanged without
236+
// error. The caller would silently store plaintext while believing it to be encrypted.
237+
// Always ensure plaintext values cannot begin with StartTag.
238+
//
239+
// Returns ErrReadNonce if crypto/rand is unavailable (OS-level failure).
107240
func (c *Crypter) Encrypt(data string) (string, error) {
108241
if c.IsEncrypted(data) {
109242
return data, nil
110243
}
111244

112245
noncep, ok := c.noncePool.Get().(*[]byte)
113246
if !ok {
114-
fmt.Println("crypter: nonce pool returned unexpected type, allocating fresh nonce")
247+
// should never happen; pool.New always returns *[]byte
115248
b := make([]byte, c.nonceSize)
116249
noncep = &b
117250
}
118-
nonce := *noncep
119-
defer c.noncePool.Put(noncep)
120-
121-
if _, err := io.ReadFull(rand.Reader, nonce); err != nil {
251+
if _, err := io.ReadFull(rand.Reader, *noncep); err != nil {
252+
c.noncePool.Put(noncep)
122253
return "", fmt.Errorf("%w: %w", ErrReadNonce, err)
123254
}
255+
// Copy random bytes into an owned slice, then return the pool buffer immediately.
256+
// Seal and all code below only touch this owned slice — the pool buffer is
257+
// unreachable from this point, eliminating any concern about pool lifetime or
258+
// concurrent reuse of its backing array.
259+
nonce := make([]byte, c.nonceSize)
260+
copy(nonce, *noncep)
261+
c.noncePool.Put(noncep)
124262

125-
raw := c.aead.Seal(nonce, nonce, []byte(data), nil)
263+
// Pre-allocate raw with nonce prefix, then let Seal append ciphertext+tag in place.
264+
// dst (raw) and nonce are distinct allocations — no aliasing.
265+
raw := make([]byte, 0, c.nonceSize+len(data)+c.aead.Overhead())
266+
raw = append(raw, nonce...)
267+
raw = c.aead.Seal(raw, nonce, []byte(data), nil)
126268

127269
encodedLen := base64.RawURLEncoding.EncodedLen(len(raw))
128270
buf := make([]byte, startLen, startLen+encodedLen+len(EndTag))
@@ -132,43 +274,52 @@ func (c *Crypter) Encrypt(data string) (string, error) {
132274
return string(buf), nil
133275
}
134276

135-
// Decrypt returns decrypted plaintext if data is tagged; otherwise it returns data unchanged.
277+
// Decrypt decrypts a tagged value and returns the original plaintext.
278+
//
279+
// If data is not tagged (IsEncrypted returns false), it is returned unchanged without
280+
// error — this is intentional for configs that mix plaintext and encrypted values.
281+
// If you need to enforce that a value is always encrypted, check IsEncrypted before calling.
282+
//
283+
// For tagged values, the base64 payload is decoded, the 12-byte nonce is extracted from
284+
// the front, and AES-GCM Open is called to authenticate and decrypt the ciphertext.
285+
//
286+
// Error cases for tagged input:
287+
// - ErrInvalidCipherText: missing end tag, decoded payload shorter than a GCM nonce,
288+
// or payload too short to contain the GCM authentication tag.
289+
// - ErrEmptyPayload: the tag is present but contains no base64 content ("ENCv1[]").
290+
// - ErrBase64Decode: the payload is not valid base64url.
291+
// - ErrOpen: AES-GCM authentication failed — wrong key, corrupted ciphertext, or tampered tag.
136292
func (c *Crypter) Decrypt(data string) (string, error) {
137293
if !c.IsEncrypted(data) {
138294
return data, nil
139295
}
140296

141-
raw, err := unwrapAfterStartTag(data)
297+
// Validate end tag and extract the base64url payload.
298+
if data[len(data)-1] != EndTag[0] {
299+
return "", ErrInvalidCipherText
300+
}
301+
payload := data[startLen : len(data)-1] // slice only, no alloc
302+
if payload == "" {
303+
return "", ErrEmptyPayload
304+
}
305+
raw, err := base64.RawURLEncoding.DecodeString(payload)
142306
if err != nil {
143-
return "", err
307+
return "", fmt.Errorf("%w: %w", ErrBase64Decode, err)
144308
}
309+
145310
if len(raw) < c.nonceSize {
146311
return "", ErrInvalidCipherText
147312
}
148313

149314
nonce, ct := raw[:c.nonceSize], raw[c.nonceSize:]
315+
if len(ct) < c.aead.Overhead() {
316+
// Payload has a valid nonce but no room for the GCM authentication tag.
317+
return "", ErrInvalidCipherText
318+
}
150319
pt, err := c.aead.Open(nil, nonce, ct, nil)
151320
if err != nil {
152321
// Map all auth/decrypt failures to a stable sentinel, but keep original for debugging via wrapping.
153322
return "", fmt.Errorf("%w: %w", ErrOpen, err)
154323
}
155324
return string(pt), nil
156325
}
157-
158-
func unwrapAfterStartTag(s string) ([]byte, error) {
159-
// Caller already checked IsEncrypted, which guarantees len(s) > startLen.
160-
if s[len(s)-1] != EndTag[0] {
161-
return nil, ErrInvalidCipherText
162-
}
163-
164-
payload := s[startLen : len(s)-1] // slice only, no alloc
165-
if payload == "" {
166-
return nil, ErrEmptyPayload
167-
}
168-
169-
raw, err := base64.RawURLEncoding.DecodeString(payload)
170-
if err != nil {
171-
return nil, fmt.Errorf("%w: %w", ErrBase64Decode, err)
172-
}
173-
return raw, nil
174-
}

0 commit comments

Comments
 (0)