feat(auth): šifrovanje TOTP tajni u mirovanju (AES-256-GCM)
TOTP tajne se više ne čuvaju kao čist tekst u koloni korisnici.totp_tajna. Uvedene auth.Sifruj/auth.Desifruj (AES-256-GCM) u internal/auth/kripto.go. sqliteKorisniciRepo šifruje pri SacuvajTotpTajnu i dešifruje pri čitanju (DohvatiPoImenu/PoID/Lista), pa ostatak programa i dalje vidi čistu tajnu. Ključ je 32-bajtni NTECH_TOTP_KEY (base64), učitava se ili generiše pri pokretanju (ucitajTotpKljuc) i upisuje u ntech.env van baze. Stare nešifrovane tajne se tolerišu pri čitanju (fallback) i jednokratno šifruju pri startu (ZasifrujPostojeceTotp). RequireAuth i Handler provode ključ do repo-a. Dodati prvi testovi u repozitorijumu (internal/auth/kripto_test.go).
This commit is contained in:
@@ -0,0 +1,70 @@
|
||||
package auth
|
||||
|
||||
import (
|
||||
"crypto/aes"
|
||||
"crypto/cipher"
|
||||
"crypto/rand"
|
||||
"encoding/base64"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
)
|
||||
|
||||
// DuzinaTotpKljuca je obavezna dužina ključa za AES-256 (32 bajta).
|
||||
const DuzinaTotpKljuca = 32
|
||||
|
||||
// Sifruj šifruje tekst pomoću AES-256-GCM i vraća base64 zapis u kome je nonce
|
||||
// zalepljen ispred šifrata i autentifikacionog taga. Ključ mora biti tačno 32
|
||||
// bajta. Koristi se za TOTP tajne u mirovanju (šifrovane u bazi).
|
||||
func Sifruj(tekst string, kljuc []byte) (string, error) {
|
||||
if len(kljuc) != DuzinaTotpKljuca {
|
||||
return "", fmt.Errorf("ntech: auth.Sifruj: ključ mora imati %d bajta", DuzinaTotpKljuca)
|
||||
}
|
||||
blok, err := aes.NewCipher(kljuc)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("ntech: auth.Sifruj: %w", err)
|
||||
}
|
||||
gcm, err := cipher.NewGCM(blok)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("ntech: auth.Sifruj: %w", err)
|
||||
}
|
||||
// svaka enkripcija dobija nasumičan nonce; on se čuva uz šifrat radi dešifrovanja
|
||||
nonce := make([]byte, gcm.NonceSize())
|
||||
if _, err := io.ReadFull(rand.Reader, nonce); err != nil {
|
||||
return "", fmt.Errorf("ntech: auth.Sifruj: %w", err)
|
||||
}
|
||||
// Seal dodaje šifrat i tag iza nonce-a (prvi argument je „prefiks" rezultata)
|
||||
zapis := gcm.Seal(nonce, nonce, []byte(tekst), nil)
|
||||
return base64.StdEncoding.EncodeToString(zapis), nil
|
||||
}
|
||||
|
||||
// Desifruj dešifruje zapis koji je napravio Sifruj. Vraća grešku ako zapis nije
|
||||
// ispravan base64, ako je prekratak, ili ako provera integriteta (GCM tag) ne
|
||||
// prođe. Ta greška se namerno koristi i za prepoznavanje starih, nešifrovanih
|
||||
// tajni — pozivalac u tom slučaju tretira vrednost kao već čist tekst.
|
||||
func Desifruj(zapis string, kljuc []byte) (string, error) {
|
||||
if len(kljuc) != DuzinaTotpKljuca {
|
||||
return "", fmt.Errorf("ntech: auth.Desifruj: ključ mora imati %d bajta", DuzinaTotpKljuca)
|
||||
}
|
||||
podaci, err := base64.StdEncoding.DecodeString(zapis)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("ntech: auth.Desifruj: %w", err)
|
||||
}
|
||||
blok, err := aes.NewCipher(kljuc)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("ntech: auth.Desifruj: %w", err)
|
||||
}
|
||||
gcm, err := cipher.NewGCM(blok)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("ntech: auth.Desifruj: %w", err)
|
||||
}
|
||||
if len(podaci) < gcm.NonceSize() {
|
||||
return "", errors.New("ntech: auth.Desifruj: zapis je prekratak")
|
||||
}
|
||||
nonce, sifrat := podaci[:gcm.NonceSize()], podaci[gcm.NonceSize():]
|
||||
otvoren, err := gcm.Open(nil, nonce, sifrat, nil)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("ntech: auth.Desifruj: %w", err)
|
||||
}
|
||||
return string(otvoren), nil
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
package auth
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// napraviKljuc vraća nasumičan 32-bajtni ključ za test
|
||||
func napraviKljuc(t *testing.T) []byte {
|
||||
t.Helper()
|
||||
k := make([]byte, DuzinaTotpKljuca)
|
||||
if _, err := rand.Read(k); err != nil {
|
||||
t.Fatalf("rand: %v", err)
|
||||
}
|
||||
return k
|
||||
}
|
||||
|
||||
// šifrovanje pa dešifrovanje vraća original
|
||||
func TestSifrujDesifrujRoundTrip(t *testing.T) {
|
||||
kljuc := napraviKljuc(t)
|
||||
tajna := "JBSWY3DPEHPK3PXP"
|
||||
|
||||
zapis, err := Sifruj(tajna, kljuc)
|
||||
if err != nil {
|
||||
t.Fatalf("Sifruj: %v", err)
|
||||
}
|
||||
if zapis == tajna {
|
||||
t.Fatal("šifrat je jednak originalu — nije šifrovano")
|
||||
}
|
||||
|
||||
nazad, err := Desifruj(zapis, kljuc)
|
||||
if err != nil {
|
||||
t.Fatalf("Desifruj: %v", err)
|
||||
}
|
||||
if nazad != tajna {
|
||||
t.Fatalf("očekivano %q, dobijeno %q", tajna, nazad)
|
||||
}
|
||||
}
|
||||
|
||||
// isti tekst dva puta daje različite šifrate (nasumičan nonce)
|
||||
func TestSifrujNasumicanNonce(t *testing.T) {
|
||||
kljuc := napraviKljuc(t)
|
||||
a, _ := Sifruj("isti tekst", kljuc)
|
||||
b, _ := Sifruj("isti tekst", kljuc)
|
||||
if a == b {
|
||||
t.Fatal("dva šifrovanja istog teksta dala isti rezultat — nonce nije nasumičan")
|
||||
}
|
||||
}
|
||||
|
||||
// dešifrovanje pogrešnim ključem mora da padne (integritet)
|
||||
func TestDesifrujPogresanKljuc(t *testing.T) {
|
||||
zapis, _ := Sifruj("tajna", napraviKljuc(t))
|
||||
if _, err := Desifruj(zapis, napraviKljuc(t)); err == nil {
|
||||
t.Fatal("dešifrovanje pogrešnim ključem je uspelo — GCM provera ne radi")
|
||||
}
|
||||
}
|
||||
|
||||
// stari, nešifrovan plain text mora da padne na Desifruj (osnova fallback logike)
|
||||
func TestDesifrujPrepoznajePlainText(t *testing.T) {
|
||||
kljuc := napraviKljuc(t)
|
||||
if _, err := Desifruj("JBSWY3DPEHPK3PXP", kljuc); err == nil {
|
||||
t.Fatal("plain text je prošao kao šifrat - fallback ne bi prepoznao stare tajne")
|
||||
}
|
||||
}
|
||||
|
||||
// pogrešna dužina ključa je greška, ne panika
|
||||
func TestSifrujKratakKljuc(t *testing.T) {
|
||||
if _, err := Sifruj("x", []byte("prekratak")); err == nil {
|
||||
t.Fatal("kratak ključ nije odbijen")
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user