Criptografia at rest com a stdlib do Go
Quero guardar um dado no disco de forma que, mesmo que o arquivo vaze, ele só seja legível com a senha e que qualquer adulteração seja detectada.
Para isso vamos proteger os dados criptografando ele “at rest” o que basicamente significa que os dados estão protegidos quando são salvos no disco, e só podem ser acessados por quem tem a senha correta.
Dá para fazer isso só com a biblioteca padrão:
- PBKDF2 para derivar a chave a partir da senha
- AES-256-GCM para cifrar e autenticar.
Desde o Go 1.24 o crypto/pbkdf2 está na stdlib, então sem dependência externa.
A senha não vira chave direto. Ela passa pelo PBKDF2 com um salt aleatório e muitas iterações, isso encarece o ataque de força bruta. O AES-GCM é um modo autenticado: cifra e gera uma tag de integridade. Se alguém editar o arquivo, a verificação falha.
O arquivo final tem este layout, onde o salt vai junto (ele não é segredo, só precisa ser único) e o magic entra como dado adicional (AAD), sendo também autenticado:
magic | salt(16) | nonce(12) | ciphertext+tag
A chave sai da senha e vira um AES-256-GCM:
const (
saltLen = 16
keyLen = 32 // AES-256
iter = 600_000 // PBKDF2 iterations
)
func newGCM(pass string, salt []byte) (cipher.AEAD, error) {
key, err := pbkdf2.Key(
sha256.New,
pass,
salt,
iter,
keyLen)
if err != nil {
return nil, err
}
block, err := aes.NewCipher(key)
if err != nil {
return nil, err
}
return cipher.NewGCM(block)
}
Cifrar é gerar salt e nonce aleatórios e montar o blob:
func encrypt(pass string, plain []byte) ([]byte, error) {
salt := make([]byte, saltLen)
if _, err := rand.Read(salt); err != nil {
return nil, err
}
gcm, err := newGCM(pass, salt)
if err != nil {
return nil, err
}
nonce := make([]byte, gcm.NonceSize())
if _, err := rand.Read(nonce); err != nil {
return nil, err
}
ct := gcm.Seal(nil, nonce, plain, []byte(magic))
out := append([]byte(magic), salt...)
out = append(out, nonce...)
return append(out, ct...), nil
}
O gcm.Open falha se a senha estiver errada ou se o arquivo foi alterado.
As duas coisas dão o mesmo erro, e isso é proposital:
func decrypt(pass string, blob []byte) ([]byte, error) {
if len(blob) < len(magic)+saltLen+12 || string(blob[:len(magic)]) != magic {
return nil, fmt.Errorf("invalid format")
}
p := blob[len(magic):]
salt, rest := p[:saltLen], p[saltLen:]
gcm, err := newGCM(pass, salt)
if err != nil {
return nil, err
}
ns := gcm.NonceSize()
if len(rest) < ns {
return nil, fmt.Errorf("invalid format")
}
nonce, ct := rest[:ns], rest[ns:]
pt, err := gcm.Open(nil, nonce, ct, []byte(magic))
if err != nil {
return nil, fmt.Errorf("wrong password (or tampered file)")
}
return pt, nil
}
Para testar:
export PASSWORD="my secret password"
go run . encrypt "sensitive data" > vault.bin
hexdump -C vault.bin
go run . decrypt < vault.bin
O hexdump -C mostra só ruído depois do magic.
Com crypto/pbkdf2 + crypto/aes + crypto/cipher você cifra dados “at rest” com zero dependências.