Files
felhom-controller/controller/internal/appexport/crypto.go
T
admin 2487681396 style: gofmt normalization — no logic changes
gofmt -w across the controller tree (46 files) so gofmt -l is empty — disarms the
formatting landmine where a targeted edit + accidental gofmt -w swept ~46 unrelated
files. Pure formatting: whitespace + gofmt's optional-semicolon removal in reflowed
inline closures. One doc comment reworded ('' -> 'the empty string') to avoid gofmt's
Go-1.19 doc-comment typographic substitition ('' -> curly quote) muddying its meaning.
No build/vet/test behavior change.
2026-07-25 07:37:02 +02:00

229 lines
5.2 KiB
Go

package appexport
import (
"crypto/aes"
"crypto/cipher"
"crypto/hmac"
"crypto/rand"
"crypto/sha256"
"errors"
"fmt"
"io"
"os"
"golang.org/x/crypto/scrypt"
)
const (
magicHeader = "FABE" // Felhom App Bundle Encrypted
scryptN = 1 << 15 // 32768
scryptR = 8
scryptP = 1
saltSize = 32
aesKeySize = 32
hmacKeySize = 32
ivSize = aes.BlockSize // 16
)
// deriveKeys derives an AES-256 key and HMAC-SHA256 key from password + salt.
func deriveKeys(password string, salt []byte) (aesKey, hmacKey []byte, err error) {
derived, err := scrypt.Key([]byte(password), salt, scryptN, scryptR, scryptP, aesKeySize+hmacKeySize)
if err != nil {
return nil, nil, err
}
return derived[:aesKeySize], derived[aesKeySize:], nil
}
// IsEncryptedFAB checks if a file starts with the "FABE" magic header.
func IsEncryptedFAB(path string) (bool, error) {
f, err := os.Open(path)
if err != nil {
return false, err
}
defer f.Close()
magic := make([]byte, 4)
n, err := f.Read(magic)
if err != nil || n < 4 {
return false, nil
}
return string(magic) == magicHeader, nil
}
// EncryptFile encrypts a plaintext file with a password.
// Uses AES-256-CTR + HMAC-SHA256 with scrypt key derivation.
// Format: "FABE" (4) || salt (32) || IV (16) || encrypted_data || HMAC-SHA256 (32)
func EncryptFile(inputPath, outputPath, password string) error {
in, err := os.Open(inputPath)
if err != nil {
return fmt.Errorf("open input: %w", err)
}
defer in.Close()
out, err := os.Create(outputPath)
if err != nil {
return fmt.Errorf("create output: %w", err)
}
defer out.Close()
salt := make([]byte, saltSize)
if _, err := rand.Read(salt); err != nil {
return fmt.Errorf("generating salt: %w", err)
}
iv := make([]byte, ivSize)
if _, err := rand.Read(iv); err != nil {
return fmt.Errorf("generating IV: %w", err)
}
aesKey, hmKey, err := deriveKeys(password, salt)
if err != nil {
return fmt.Errorf("deriving keys: %w", err)
}
block, err := aes.NewCipher(aesKey)
if err != nil {
return err
}
stream := cipher.NewCTR(block, iv)
mac := hmac.New(sha256.New, hmKey)
// Write header (magic is NOT in HMAC; salt + IV are)
if _, err := out.Write([]byte(magicHeader)); err != nil {
return err
}
mac.Write(salt)
if _, err := out.Write(salt); err != nil {
return err
}
mac.Write(iv)
if _, err := out.Write(iv); err != nil {
return err
}
// Encrypt and stream data
buf := make([]byte, 64*1024)
for {
n, readErr := in.Read(buf)
if n > 0 {
encrypted := make([]byte, n)
stream.XORKeyStream(encrypted, buf[:n])
mac.Write(encrypted)
if _, err := out.Write(encrypted); err != nil {
return err
}
}
if readErr == io.EOF {
break
}
if readErr != nil {
return fmt.Errorf("read: %w", readErr)
}
}
// Append HMAC tag
if _, err := out.Write(mac.Sum(nil)); err != nil {
return err
}
return out.Sync()
}
// DecryptFile decrypts an encrypted .fab file with a password.
// Returns a clear error if the password is wrong or the file is corrupted.
func DecryptFile(inputPath, outputPath, password string) error {
in, err := os.Open(inputPath)
if err != nil {
return fmt.Errorf("open input: %w", err)
}
defer in.Close()
// Verify magic header
magic := make([]byte, 4)
if _, err := io.ReadFull(in, magic); err != nil {
return fmt.Errorf("reading header: %w", err)
}
if string(magic) != magicHeader {
return errors.New("not an encrypted FAB file")
}
// Read salt and IV
salt := make([]byte, saltSize)
if _, err := io.ReadFull(in, salt); err != nil {
return fmt.Errorf("reading salt: %w", err)
}
iv := make([]byte, ivSize)
if _, err := io.ReadFull(in, iv); err != nil {
return fmt.Errorf("reading IV: %w", err)
}
// Calculate data size (total - header - HMAC tag)
stat, err := in.Stat()
if err != nil {
return err
}
headerSize := int64(4 + saltSize + ivSize)
tagSize := int64(sha256.Size)
dataSize := stat.Size() - headerSize - tagSize
if dataSize < 0 {
return errors.New("file too small to be valid")
}
aesKey, hmKey, err := deriveKeys(password, salt)
if err != nil {
return fmt.Errorf("deriving keys: %w", err)
}
mac := hmac.New(sha256.New, hmKey)
mac.Write(salt)
mac.Write(iv)
block, err := aes.NewCipher(aesKey)
if err != nil {
return err
}
stream := cipher.NewCTR(block, iv)
out, err := os.Create(outputPath)
if err != nil {
return fmt.Errorf("create output: %w", err)
}
defer out.Close()
// Decrypt data section
buf := make([]byte, 64*1024)
remaining := dataSize
for remaining > 0 {
toRead := int64(len(buf))
if toRead > remaining {
toRead = remaining
}
n, readErr := in.Read(buf[:toRead])
if n > 0 {
mac.Write(buf[:n])
decrypted := make([]byte, n)
stream.XORKeyStream(decrypted, buf[:n])
if _, err := out.Write(decrypted); err != nil {
return err
}
remaining -= int64(n)
}
if readErr == io.EOF {
break
}
if readErr != nil {
return fmt.Errorf("read: %w", readErr)
}
}
// Verify HMAC tag
storedMAC := make([]byte, sha256.Size)
if _, err := io.ReadFull(in, storedMAC); err != nil {
return fmt.Errorf("reading HMAC: %w", err)
}
if !hmac.Equal(mac.Sum(nil), storedMAC) {
os.Remove(outputPath)
return errors.New("jelszó hibás vagy a fájl sérült")
}
return out.Sync()
}