diff --git a/cmd/ntech/main.go b/cmd/ntech/main.go index 1366a33..77c51cd 100644 --- a/cmd/ntech/main.go +++ b/cmd/ntech/main.go @@ -2,7 +2,9 @@ package main import ( "context" + "crypto/rand" "database/sql" + "encoding/base64" "fmt" "io/fs" "log" @@ -92,6 +94,19 @@ func main() { log.Printf("Dozvole: uklonjeno %d zastarelih redova", br) } + // ključ za šifrovanje TOTP tajni u mirovanju (AES-256-GCM) + totpKljuc, err := ucitajTotpKljuc() + if err != nil { + log.Fatalf("Greška pri učitavanju ključa za TOTP: %v", err) + } + + // jednokratno šifruj eventualne stare TOTP tajne koje su ostale kao čist tekst + if br, err := sqlite.ZasifrujPostojeceTotp(context.Background(), db, totpKljuc); err != nil { + log.Printf("Upozorenje: greška pri šifrovanju postojećih TOTP tajni: %v", err) + } else if br > 0 { + log.Printf("TOTP: šifrovano %d postojećih tajni", br) + } + napraviBackup(db, putanjaBaze) // periodični automatski backup — interval se čita iz podešavanja u svakom ciklusu, @@ -118,7 +133,7 @@ func main() { os.MkdirAll("web/static/uploads", 0755) - h := handler.Novi(db) + h := handler.Novi(db, totpKljuc) h.Verzija = Verzija // verzija statičkih fajlova za cache-busting — menja se pri svakom pokretanju, // pa novi build/restart natera brauzer da povuče sveži CSS/JS (umesto starog iz keša) @@ -172,7 +187,7 @@ func main() { // zaštićene rute — zahtevaju prijavljenog korisnika r.Group(func(r chi.Router) { - r.Use(ntechmw.RequireAuth(db)) + r.Use(ntechmw.RequireAuth(db, totpKljuc)) r.Get("/", func(w http.ResponseWriter, r *http.Request) { http.Redirect(w, r, "/dashboard", http.StatusFound) @@ -283,6 +298,44 @@ func main() { } } +// ucitajTotpKljuc vraća 32-bajtni ključ za šifrovanje TOTP tajni. Čita ga iz +// NTECH_TOTP_KEY (base64). Ako env nije postavljen, generiše nov nasumičan ključ +// i dopisuje ga u ntech.env (perm 0600), tako da postojeće instalacije dobiju +// ključ automatski pri prvom pokretanju nove verzije. +// +// VAŽNO: ako se ovaj ključ izgubi ili promeni, postojeće šifrovane TOTP tajne se +// više ne mogu dešifrovati (korisnici moraju ponovo aktivirati 2FA). +func ucitajTotpKljuc() ([]byte, error) { + if v := os.Getenv("NTECH_TOTP_KEY"); v != "" { + kljuc, err := base64.StdEncoding.DecodeString(v) + if err != nil { + return nil, fmt.Errorf("NTECH_TOTP_KEY nije ispravan base64: %w", err) + } + if len(kljuc) != auth.DuzinaTotpKljuca { + return nil, fmt.Errorf("NTECH_TOTP_KEY mora imati %d bajta (trenutno %d)", auth.DuzinaTotpKljuca, len(kljuc)) + } + return kljuc, nil + } + + // nema ključa — generiši nov i upiši ga u ntech.env + kljuc := make([]byte, auth.DuzinaTotpKljuca) + if _, err := rand.Read(kljuc); err != nil { + return nil, fmt.Errorf("generisanje ključa: %w", err) + } + enkodiran := base64.StdEncoding.EncodeToString(kljuc) + f, err := os.OpenFile("ntech.env", os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0600) + if err != nil { + return nil, fmt.Errorf("otvaranje ntech.env: %w", err) + } + defer f.Close() + if _, err := f.WriteString("\nNTECH_TOTP_KEY=" + enkodiran + "\n"); err != nil { + return nil, fmt.Errorf("upis ključa u ntech.env: %w", err) + } + os.Setenv("NTECH_TOTP_KEY", enkodiran) + log.Println("Generisan nov NTECH_TOTP_KEY i upisan u ntech.env") + return kljuc, nil +} + // napraviBackup kreira konzistentnu kopiju baze i briše najstarije preko zadatog broja kopija. // Koristi već otvorenu vezu ka bazi (VACUUM INTO je bezbedan na pooled konekciji). func napraviBackup(db *sql.DB, putanjaBaze string) { diff --git a/internal/auth/kripto.go b/internal/auth/kripto.go new file mode 100644 index 0000000..45edfdc --- /dev/null +++ b/internal/auth/kripto.go @@ -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 +} diff --git a/internal/auth/kripto_test.go b/internal/auth/kripto_test.go new file mode 100644 index 0000000..5ee51ee --- /dev/null +++ b/internal/auth/kripto_test.go @@ -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") + } +} diff --git a/internal/db/sqlite/korisnici.go b/internal/db/sqlite/korisnici.go index 1944041..23e591d 100644 --- a/internal/db/sqlite/korisnici.go +++ b/internal/db/sqlite/korisnici.go @@ -6,10 +6,16 @@ import ( "fmt" "time" + "ntech/internal/auth" "ntech/internal/model" ) -type sqliteKorisniciRepo struct{ db *sql.DB } +// sqliteKorisniciRepo drži ključ za šifrovanje TOTP tajni (AES-256-GCM) jer se +// tajna šifruje pri upisu (SacuvajTotpTajnu) i dešifruje pri čitanju. +type sqliteKorisniciRepo struct { + db *sql.DB + kljuc []byte +} // dodeliOpcijeKorisnika popunjava bool i opciona polja korisnika iz skeniranih // NULL vrednosti — deljeno između skeniraiKorisnika (jedan red) i Lista (više redova) @@ -49,9 +55,23 @@ func skeniraiKorisnika(row interface{ Scan(...any) error }) (*model.Korisnik, er return k, nil } -// NoviKorisniciRepo kreira SQLite implementaciju KorisniciRepository -func NoviKorisniciRepo(db *sql.DB) *sqliteKorisniciRepo { - return &sqliteKorisniciRepo{db: db} +// NoviKorisniciRepo kreira SQLite implementaciju KorisniciRepository. +// kljuc je 32-bajtni ključ za šifrovanje TOTP tajni u mirovanju. +func NoviKorisniciRepo(db *sql.DB, kljuc []byte) *sqliteKorisniciRepo { + return &sqliteKorisniciRepo{db: db, kljuc: kljuc} +} + +// desifrujTotpTajnu pretvara šifrovanu TOTP tajnu (kako stoji u bazi) u čist +// tekst u memoriji. Tolerantno je na stare nešifrovane tajne: ako dešifrovanje +// ne uspe (greška GCM provere), vrednost se ostavlja kakva jeste — to je plain +// text iz verzija pre uvođenja enkripcije. +func (r *sqliteKorisniciRepo) desifrujTotpTajnu(k *model.Korisnik) { + if k.TotpTajna == "" { + return + } + if cisto, err := auth.Desifruj(k.TotpTajna, r.kljuc); err == nil { + k.TotpTajna = cisto + } } func (r *sqliteKorisniciRepo) Kreiraj(ctx context.Context, korisnickoIme, lozinkaHash, uloga string) (*model.Korisnik, error) { @@ -77,6 +97,7 @@ func (r *sqliteKorisniciRepo) DohvatiPoImenu(ctx context.Context, korisnickoIme if err != nil { return nil, fmt.Errorf("ntech: korisnici.DohvatiPoImenu: %w", err) } + r.desifrujTotpTajnu(k) return k, nil } @@ -92,6 +113,7 @@ func (r *sqliteKorisniciRepo) DohvatiPoID(ctx context.Context, id int64) (*model if err != nil { return nil, fmt.Errorf("ntech: korisnici.DohvatiPoID: %w", err) } + r.desifrujTotpTajnu(k) return k, nil } @@ -123,6 +145,7 @@ func (r *sqliteKorisniciRepo) Lista(ctx context.Context) ([]model.Korisnik, erro dodeliOpcijeKorisnika(&k, aktivan, koristiLokalnuTemu, lokalnaTema, lokalnaPozadina, lokalnaPozadinaOpacity, lokalnaPozadinaBlur, lokalnaPozadinaBlurPozadine, lokalnaPozadinaGlassOpacity, datumKreiranja) + r.desifrujTotpTajnu(&k) lista = append(lista, k) } return lista, nil @@ -191,7 +214,12 @@ func (r *sqliteKorisniciRepo) SacuvajTotpTajnu(ctx context.Context, id int64, ta if tajna == "" { _, err = r.db.ExecContext(ctx, `UPDATE korisnici SET totp_tajna = NULL WHERE id = ?`, id) } else { - _, err = r.db.ExecContext(ctx, `UPDATE korisnici SET totp_tajna = ? WHERE id = ?`, tajna, id) + // tajna se čuva šifrovana (AES-256-GCM) — nikad kao čist tekst + sifrovana, errSifra := auth.Sifruj(tajna, r.kljuc) + if errSifra != nil { + return fmt.Errorf("ntech: korisnici.SacuvajTotpTajnu: %w", errSifra) + } + _, err = r.db.ExecContext(ctx, `UPDATE korisnici SET totp_tajna = ? WHERE id = ?`, sifrovana, id) } if err != nil { return fmt.Errorf("ntech: korisnici.SacuvajTotpTajnu: %w", err) @@ -199,6 +227,55 @@ func (r *sqliteKorisniciRepo) SacuvajTotpTajnu(ctx context.Context, id int64, ta return nil } +// ZasifrujPostojeceTotp jednokratno šifruje sve TOTP tajne koje su u bazi ostale +// kao čist tekst (iz verzija pre uvođenja enkripcije). Idempotentno je: tajne koje +// se već dešifruju datim ključem preskače. Vraća broj ažuriranih redova. Poziva se +// pri pokretanju programa, posle migracija. +func ZasifrujPostojeceTotp(ctx context.Context, db *sql.DB, kljuc []byte) (int, error) { + rows, err := db.QueryContext(ctx, + `SELECT id, totp_tajna FROM korisnici WHERE totp_tajna IS NOT NULL AND totp_tajna != ''`) + if err != nil { + return 0, fmt.Errorf("ntech: korisnici.ZasifrujPostojeceTotp: %w", err) + } + // prvo skupljamo redove, pa tek onda upisujemo — da ne čitamo i pišemo + // istovremeno preko iste konekcije + type red struct { + id int64 + tajna string + } + var zaSifrovanje []red + for rows.Next() { + var rd red + if err := rows.Scan(&rd.id, &rd.tajna); err != nil { + rows.Close() + return 0, fmt.Errorf("ntech: korisnici.ZasifrujPostojeceTotp: %w", err) + } + // ako se već dešifruje, znači da je šifrovana — preskoči je + if _, err := auth.Desifruj(rd.tajna, kljuc); err == nil { + continue + } + zaSifrovanje = append(zaSifrovanje, rd) + } + if err := rows.Err(); err != nil { + rows.Close() + return 0, fmt.Errorf("ntech: korisnici.ZasifrujPostojeceTotp: %w", err) + } + rows.Close() + + br := 0 + for _, rd := range zaSifrovanje { + sifrovana, err := auth.Sifruj(rd.tajna, kljuc) + if err != nil { + return br, fmt.Errorf("ntech: korisnici.ZasifrujPostojeceTotp: %w", err) + } + if _, err := db.ExecContext(ctx, `UPDATE korisnici SET totp_tajna = ? WHERE id = ?`, sifrovana, rd.id); err != nil { + return br, fmt.Errorf("ntech: korisnici.ZasifrujPostojeceTotp: %w", err) + } + br++ + } + return br, nil +} + func (r *sqliteKorisniciRepo) Obrisi(ctx context.Context, id int64) error { _, err := r.db.ExecContext(ctx, `DELETE FROM korisnici WHERE id = ?`, id) if err != nil { diff --git a/internal/handler/handler.go b/internal/handler/handler.go index 5f5e112..f74c0f9 100644 --- a/internal/handler/handler.go +++ b/internal/handler/handler.go @@ -35,12 +35,15 @@ type Handler struct { AssetV string // verzija statičkih fajlova za cache-busting (postavlja se pri pokretanju) Templates map[string]*template.Template TemplatesFS fs.FS + totpKljuc []byte // ključ za šifrovanje TOTP tajni (prosleđuje se KorisniciRepo) } -// Novi kreira novi Handler sa datom bazom -func Novi(baza *sql.DB) *Handler { +// Novi kreira novi Handler sa datom bazom. +// totpKljuc je 32-bajtni ključ za šifrovanje TOTP tajni u mirovanju. +func Novi(baza *sql.DB, totpKljuc []byte) *Handler { return &Handler{ DB: baza, + totpKljuc: totpKljuc, Artikli: sqlite.NoviArtikalRepo(baza), KategorijeRepo: sqlite.NovaKategorijaRepo(baza), DobavljaciRepo: sqlite.NoviDobavljacRepo(baza), @@ -50,7 +53,7 @@ func Novi(baza *sql.DB) *Handler { ServisniDeloviRepo: sqlite.NoviServisniDeloviRepo(baza), MagacinskePromeneRepo: sqlite.NoviMagacinskePromeneRepo(baza), ProdajaRepo: sqlite.NoviProdajaRepo(baza), - KorisniciRepo: sqlite.NoviKorisniciRepo(baza), + KorisniciRepo: sqlite.NoviKorisniciRepo(baza, totpKljuc), SesijeRepo: sqlite.NoviSesijeRepo(baza), PodsetnikRepo: sqlite.NoviPodsetnikRepo(baza), PokusajiRepo: sqlite.NoviPokusajiPrijaveRepo(baza), @@ -71,7 +74,7 @@ func (h *Handler) reinicijalizujRepozitorijume(novaDB *sql.DB) { h.ServisniDeloviRepo = sqlite.NoviServisniDeloviRepo(novaDB) h.MagacinskePromeneRepo = sqlite.NoviMagacinskePromeneRepo(novaDB) h.ProdajaRepo = sqlite.NoviProdajaRepo(novaDB) - h.KorisniciRepo = sqlite.NoviKorisniciRepo(novaDB) + h.KorisniciRepo = sqlite.NoviKorisniciRepo(novaDB, h.totpKljuc) h.SesijeRepo = sqlite.NoviSesijeRepo(novaDB) h.PodsetnikRepo = sqlite.NoviPodsetnikRepo(novaDB) h.PokusajiRepo = sqlite.NoviPokusajiPrijaveRepo(novaDB) diff --git a/internal/middleware/auth.go b/internal/middleware/auth.go index 0675f97..416ae03 100644 --- a/internal/middleware/auth.go +++ b/internal/middleware/auth.go @@ -16,10 +16,11 @@ type kontekstKljuc string // KljucKorisnika je ključ za korisnika u request contextu const KljucKorisnika kontekstKljuc = "korisnik" -// RequireAuth je chi middleware koji proverava sesiju i injektuje korisnika u context -func RequireAuth(db *sql.DB) func(http.Handler) http.Handler { +// RequireAuth je chi middleware koji proverava sesiju i injektuje korisnika u context. +// totpKljuc je ključ za dešifrovanje TOTP tajne (korisRepo ga koristi pri čitanju). +func RequireAuth(db *sql.DB, totpKljuc []byte) func(http.Handler) http.Handler { sesijeRepo := sqlite.NoviSesijeRepo(db) - korisRepo := sqlite.NoviKorisniciRepo(db) + korisRepo := sqlite.NoviKorisniciRepo(db, totpKljuc) return func(next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {