This commit is contained in:
@@ -0,0 +1,155 @@
|
||||
package crypto
|
||||
|
||||
import (
|
||||
"crypto/aes"
|
||||
"crypto/cipher"
|
||||
"crypto/hkdf"
|
||||
"crypto/rand"
|
||||
"crypto/sha256"
|
||||
"encoding/base64"
|
||||
"errors"
|
||||
)
|
||||
|
||||
// Encryption of IMAP passwords. Porting guide 3.5.
|
||||
//
|
||||
// Ciphertext format (review SEC-9, layout corrected per review CR-20 —
|
||||
// gcm.Seal emits ciphertext followed by tag):
|
||||
// v1: base64( version(1) || IV(12) || ciphertext || tag(16) )
|
||||
// legacy (pre-HKDF): base64( IV(12) || ciphertext || tag(16) ) — no version
|
||||
// byte; derived with raw SHA-256 for backward compatibility during rotation.
|
||||
//
|
||||
// The key is now derived via HKDF-SHA256 with a fixed context string.
|
||||
// Changing the secret makes saved IMAP passwords unreadable unless the
|
||||
// legacy fallback is used (handled by Decrypt).
|
||||
//
|
||||
// Review CR-20: a legacy record starts with a RANDOM IV byte, which can
|
||||
// coincidentally equal versionByte — the format cannot be sniffed from the
|
||||
// first byte alone. Decrypt therefore treats an apparent version byte as a
|
||||
// hint only: the v1 decode is attempted first and, when GCM authentication
|
||||
// does not succeed, the WHOLE record is retried as legacy. Only successful
|
||||
// authenticated decryption ever selects a format/key.
|
||||
|
||||
const (
|
||||
algorithm = "aes-256-gcm"
|
||||
ivLength = 12
|
||||
tagLength = 16
|
||||
versionByte = 1
|
||||
legacyVersion = 0
|
||||
hkdfContext = "computer/imap-password/v1"
|
||||
)
|
||||
|
||||
// Key derives the 32-byte AES key from the secret using HKDF-SHA256.
|
||||
// An empty secret is a programming error: Encrypt/Decrypt guard it, and
|
||||
// main fails startup without a configured secret.
|
||||
func Key(secret string) []byte {
|
||||
if secret == "" {
|
||||
panic("crypto: empty secret")
|
||||
}
|
||||
k, err := hkdf.Key(sha256.New, []byte(secret), nil, hkdfContext, 32)
|
||||
if err != nil {
|
||||
panic("hkdf: " + err.Error())
|
||||
}
|
||||
return k
|
||||
}
|
||||
|
||||
// LegacyKey derives the 32-byte AES key via raw SHA-256 (pre-HKDF).
|
||||
// Used by Decrypt when encountering a legacy ciphertext (no version byte).
|
||||
func LegacyKey(secret string) []byte {
|
||||
if secret == "" {
|
||||
panic("crypto: empty secret")
|
||||
}
|
||||
h := sha256.Sum256([]byte(secret))
|
||||
return h[:]
|
||||
}
|
||||
|
||||
// Encrypt returns base64( version(1) || IV(12) || ciphertext || tag(16) ).
|
||||
// Never returns an error path that loses data; the caller must ensure the
|
||||
// secret is set.
|
||||
func Encrypt(text, secret string) (string, error) {
|
||||
if secret == "" {
|
||||
return "", errors.New("missing encryption secret")
|
||||
}
|
||||
key := Key(secret)
|
||||
block, err := aes.NewCipher(key)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
gcm, err := cipher.NewGCM(block)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
iv := make([]byte, ivLength)
|
||||
if _, err := rand.Read(iv); err != nil {
|
||||
return "", err
|
||||
}
|
||||
seal := gcm.Seal(nil, iv, []byte(text), nil)
|
||||
// gcm output is ciphertext || tag
|
||||
ciphertext := seal[:len(seal)-tagLength]
|
||||
tag := seal[len(seal)-tagLength:]
|
||||
out := make([]byte, 0, 1+ivLength+len(ciphertext)+tagLength)
|
||||
out = append(out, versionByte)
|
||||
out = append(out, iv...)
|
||||
out = append(out, ciphertext...)
|
||||
out = append(out, tag...)
|
||||
return base64.StdEncoding.EncodeToString(out), nil
|
||||
}
|
||||
|
||||
// Decrypt reverses Encrypt. Returns an error on malformed input.
|
||||
// Supports both v1 (HKDF) and legacy (raw SHA-256) ciphertexts for
|
||||
// transparent rotation. Review CR-20: the leading-byte check is only a hint —
|
||||
// a legacy IV byte can collide with versionByte, so a failed v1 decode falls
|
||||
// back to an authenticated legacy decode of the whole record instead of
|
||||
// declaring the password unreadable.
|
||||
func Decrypt(data, secret string) (string, error) {
|
||||
if secret == "" {
|
||||
return "", errors.New("missing encryption secret")
|
||||
}
|
||||
raw, err := base64.StdEncoding.DecodeString(data)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if len(raw) >= 1 && raw[0] == versionByte {
|
||||
if plain, e := openGCM(raw[1:], Key(secret)); e == nil {
|
||||
return plain, nil
|
||||
}
|
||||
// Fall through: either a tampered/miskeyed v1 record (the legacy
|
||||
// retry will fail authentication too) or a legacy record whose random
|
||||
// IV starts with the version byte.
|
||||
}
|
||||
return openGCM(raw, LegacyKey(secret))
|
||||
}
|
||||
|
||||
// openGCM decodes one record body: IV(12) || ciphertext || tag(16).
|
||||
func openGCM(raw, key []byte) (string, error) {
|
||||
if len(raw) < ivLength+tagLength {
|
||||
return "", errShortInputError
|
||||
}
|
||||
iv := raw[:ivLength]
|
||||
// gcm.Seal returns ciphertext || tag, so the tag is the LAST 16 bytes
|
||||
tag := raw[len(raw)-tagLength:]
|
||||
ciphertext := raw[ivLength : len(raw)-tagLength]
|
||||
block, err := aes.NewCipher(key)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
gcm, err := cipher.NewGCM(block)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
// gcm expects ciphertext || tag
|
||||
sealed := append([]byte(nil), ciphertext...)
|
||||
sealed = append(sealed, tag...)
|
||||
plain, err := gcm.Open(nil, iv, sealed, nil)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return string(plain), nil
|
||||
}
|
||||
|
||||
// errShortInputError is returned when the decoded input is too short to contain
|
||||
// the IV and auth tag.
|
||||
var errShortInputError = errShortInput{}
|
||||
|
||||
type errShortInput struct{}
|
||||
|
||||
func (errShortInput) Error() string { return "crypto: input too short" }
|
||||
@@ -0,0 +1,137 @@
|
||||
package crypto
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"crypto/aes"
|
||||
"crypto/cipher"
|
||||
"crypto/rand"
|
||||
"crypto/sha256"
|
||||
"encoding/base64"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestPasswordEncryption(t *testing.T) {
|
||||
cipher, e := Encrypt("mailbox-password", "stable-secret")
|
||||
if e != nil {
|
||||
t.Fatal(e)
|
||||
}
|
||||
p, e := Decrypt(cipher, "stable-secret")
|
||||
if e != nil || p != "mailbox-password" {
|
||||
t.Fatal(p, e)
|
||||
}
|
||||
raw, _ := base64.StdEncoding.DecodeString(cipher)
|
||||
// v1 format: version(1) + IV(12) + ciphertext + tag(16) (review CR-20
|
||||
// corrected the layout comment: gcm.Seal emits ciphertext || tag).
|
||||
if len(raw) != 1+12+len(p)+16 {
|
||||
t.Fatalf("incompatible v1 IV/ciphertext/tag layout: got %d", len(raw))
|
||||
}
|
||||
if raw[0] != versionByte {
|
||||
t.Fatal("ciphertext missing version byte")
|
||||
}
|
||||
raw[len(raw)-1] ^= 1
|
||||
if _, e = Decrypt(base64.StdEncoding.EncodeToString(raw), "stable-secret"); e == nil {
|
||||
t.Fatal("accepted tampered ciphertext")
|
||||
}
|
||||
if _, e = Decrypt(cipher, "changed-secret"); e == nil {
|
||||
t.Fatal("accepted wrong secret")
|
||||
}
|
||||
if _, e = Encrypt("value", ""); e == nil {
|
||||
t.Fatal("accepted empty secret")
|
||||
}
|
||||
if _, e = Decrypt("", "stable-secret"); e == nil {
|
||||
t.Fatal("accepted empty input")
|
||||
}
|
||||
// Too-short bodies are rejected on both format paths.
|
||||
short := base64.StdEncoding.EncodeToString([]byte{versionByte, 1, 2, 3})
|
||||
if _, e = Decrypt(short, "stable-secret"); e == nil {
|
||||
t.Fatal("accepted short versioned input")
|
||||
}
|
||||
shortLegacy := base64.StdEncoding.EncodeToString([]byte{9, 9, 9})
|
||||
if _, e = Decrypt(shortLegacy, "stable-secret"); e == nil {
|
||||
t.Fatal("accepted short legacy input")
|
||||
}
|
||||
}
|
||||
|
||||
// legacyEncrypt builds a pre-HKDF ciphertext: base64( IV || ciphertext || tag )
|
||||
// under the raw SHA-256 key, with full control over the IV (review CR-20 asks
|
||||
// for deterministic fixtures around the version-byte collision).
|
||||
func legacyEncrypt(t *testing.T, plain, secret string, iv []byte) string {
|
||||
t.Helper()
|
||||
key := sha256.Sum256([]byte(secret))
|
||||
block, e := aes.NewCipher(key[:])
|
||||
if e != nil {
|
||||
t.Fatal(e)
|
||||
}
|
||||
gcm, e := cipher.NewGCM(block)
|
||||
if e != nil {
|
||||
t.Fatal(e)
|
||||
}
|
||||
seal := gcm.Seal(nil, iv, []byte(plain), nil)
|
||||
return base64.StdEncoding.EncodeToString(append(append([]byte(nil), iv...), seal...))
|
||||
}
|
||||
|
||||
// TestLegacyCiphertextCompatibility verifies Decrypt can read ciphertexts
|
||||
// created before HKDF/versioning (review SEC-9).
|
||||
func TestLegacyCiphertextCompatibility(t *testing.T) {
|
||||
secret := "legacy-secret"
|
||||
iv := make([]byte, ivLength)
|
||||
rand.Read(iv)
|
||||
legacyCipher := legacyEncrypt(t, "legacy-password", secret, iv)
|
||||
|
||||
// Decrypt with the new Decrypt (legacy format via LegacyKey/raw SHA-256).
|
||||
p, e := Decrypt(legacyCipher, secret)
|
||||
if e != nil || p != "legacy-password" {
|
||||
t.Fatalf("failed to decrypt legacy ciphertext: %v %v", p, e)
|
||||
}
|
||||
|
||||
// Re-encrypting with the same secret produces v1 format (HKDF + version)
|
||||
v1Cipher, e := Encrypt(p, secret)
|
||||
if e != nil {
|
||||
t.Fatal(e)
|
||||
}
|
||||
raw, _ := base64.StdEncoding.DecodeString(v1Cipher)
|
||||
if raw[0] != versionByte {
|
||||
t.Fatal("re-encrypted ciphertext missing version byte")
|
||||
}
|
||||
}
|
||||
|
||||
// TestLegacyVersionByteCollision pins review CR-20: a legacy record whose
|
||||
// random IV starts with the version byte must still decrypt. The old
|
||||
// first-byte sniff stripped the byte and selected the v1 key, rendering such
|
||||
// stored credentials permanently unreadable.
|
||||
func TestLegacyVersionByteCollision(t *testing.T) {
|
||||
secret := "legacy-secret"
|
||||
iv := make([]byte, ivLength)
|
||||
rand.Read(iv)
|
||||
iv[0] = versionByte // force the collision, deterministically
|
||||
legacyCipher := legacyEncrypt(t, "collision-password", secret, iv)
|
||||
|
||||
p, e := Decrypt(legacyCipher, secret)
|
||||
if e != nil || p != "collision-password" {
|
||||
t.Fatalf("legacy ciphertext colliding with the version byte failed to decrypt: %v %v", p, e)
|
||||
}
|
||||
|
||||
// Tampering must still fail authentication on the fallback path.
|
||||
raw, _ := base64.StdEncoding.DecodeString(legacyCipher)
|
||||
raw[len(raw)-1] ^= 1
|
||||
if _, e = Decrypt(base64.StdEncoding.EncodeToString(raw), secret); e == nil {
|
||||
t.Fatal("accepted tampered colliding legacy ciphertext")
|
||||
}
|
||||
// A wrong secret never authenticates, collision or not.
|
||||
if _, e = Decrypt(legacyCipher, "other-secret"); e == nil {
|
||||
t.Fatal("accepted wrong secret for legacy ciphertext")
|
||||
}
|
||||
// Sanity: the fixture really starts with the version byte.
|
||||
if !bytes.HasPrefix(mustDecode(t, legacyCipher), []byte{versionByte}) {
|
||||
t.Fatal("fixture does not exercise the collision")
|
||||
}
|
||||
}
|
||||
|
||||
func mustDecode(t *testing.T, s string) []byte {
|
||||
t.Helper()
|
||||
raw, e := base64.StdEncoding.DecodeString(s)
|
||||
if e != nil {
|
||||
t.Fatal(e)
|
||||
}
|
||||
return raw
|
||||
}
|
||||
Reference in New Issue
Block a user