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+ // }
166package crypter
267
368import (
@@ -12,60 +77,108 @@ import (
1277)
1378
1479const (
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
2397var (
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.
51145const 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.
61154type 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.
69182func 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.
101219func (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).
107240func (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.
136292func (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