Skip to content

Commit d3f730b

Browse files
committed
fix: totp security;
1 parent b674b7e commit d3f730b

7 files changed

Lines changed: 227 additions & 7 deletions

File tree

internal/auth/auth.go

Lines changed: 17 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -48,6 +48,7 @@ type LoginResult struct {
4848
SessionID string
4949
TOTPRequired bool
5050
UserID int64
51+
TOTPKey []byte
5152
}
5253

5354
// isSecure reports whether the request arrived over a secure connection,
@@ -106,10 +107,22 @@ func Login(database *sql.DB, uname, password string) (*LoginResult, error) {
106107
return nil, ErrInvalidCredentials
107108
}
108109

110+
// Derive the TOTP encryption key from the password while it is still in hand
111+
salt := user.TOTPSalt
112+
if salt == "" {
113+
if s, sErr := GenerateTOTPSalt(); sErr == nil && db.SetTOTPSalt(database, user.ID, s) == nil {
114+
salt = s
115+
}
116+
}
117+
var totpKey []byte
118+
if salt != "" {
119+
totpKey = DeriveTOTPKey(password, salt)
120+
}
121+
109122
// If TOTP is enabled, signal the caller to handle the second step
110123
if user.TOTPEnabled {
111124
logger.Debug("TOTP required for user: %s", uname)
112-
return &LoginResult{TOTPRequired: true, UserID: user.ID}, nil
125+
return &LoginResult{TOTPRequired: true, UserID: user.ID, TOTPKey: totpKey}, nil
113126
}
114127

115128
// Generate a new session ID
@@ -142,6 +155,9 @@ func Login(database *sql.DB, uname, password string) (*LoginResult, error) {
142155

143156
logger.Debug("User logged in: %s", uname)
144157

158+
// Keep the derived key in memory for the session so enrollment can encrypt immediately
159+
StashTOTPKey(sessionID, totpKey, SessionDuration)
160+
145161
// Return the session ID to be set in the cookie
146162
return &LoginResult{SessionID: sessionID}, nil
147163
}

internal/auth/totpcrypt.go

Lines changed: 141 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,141 @@
1+
// PodNest - Self-hosted site management platform
2+
// Copyright (c) 2026 Kevin Pirnie <iam@kevinpirnie.com>
3+
// Licensed under the MIT License. See LICENSE file in the project root for full license text.
4+
5+
package auth
6+
7+
import (
8+
"crypto/aes"
9+
"crypto/cipher"
10+
"crypto/rand"
11+
"encoding/base64"
12+
"errors"
13+
"strings"
14+
"sync"
15+
"time"
16+
17+
"golang.org/x/crypto/argon2"
18+
)
19+
20+
// totpEncPrefix marks a TOTP secret encrypted with a password-derived key
21+
const totpEncPrefix = "enc:pw1:"
22+
23+
// ErrTOTPKeyUnavailable is returned when an encrypted secret cannot be decrypted
24+
// because the password-derived key is not in the keystore (e.g. after a restart)
25+
var ErrTOTPKeyUnavailable = errors.New("totp key unavailable")
26+
27+
// totpKeyEntry holds a derived key and its expiry in the in-memory keystore
28+
type totpKeyEntry struct {
29+
key []byte
30+
exp time.Time
31+
}
32+
33+
// totpKeys holds password-derived keys in memory only, keyed by pending token
34+
// or session ID — never persisted, so a DB read alone cannot recover secrets
35+
var (
36+
totpKeysMu sync.Mutex
37+
totpKeys = map[string]totpKeyEntry{}
38+
)
39+
40+
// StashTOTPKey stores a derived key under the given ID for the given TTL
41+
func StashTOTPKey(id string, key []byte, ttl time.Duration) {
42+
if id == "" || key == nil {
43+
return
44+
}
45+
totpKeysMu.Lock()
46+
defer totpKeysMu.Unlock()
47+
48+
// prune anything expired while we hold the lock — keeps the map tiny
49+
now := time.Now()
50+
for k, e := range totpKeys {
51+
if now.After(e.exp) {
52+
delete(totpKeys, k)
53+
}
54+
}
55+
totpKeys[id] = totpKeyEntry{key: key, exp: now.Add(ttl)}
56+
}
57+
58+
// GetTOTPKey returns the derived key stashed under the given ID, or nil
59+
func GetTOTPKey(id string) []byte {
60+
totpKeysMu.Lock()
61+
defer totpKeysMu.Unlock()
62+
e, ok := totpKeys[id]
63+
if !ok || time.Now().After(e.exp) {
64+
return nil
65+
}
66+
return e.key
67+
}
68+
69+
// DropTOTPKey removes the derived key stashed under the given ID
70+
func DropTOTPKey(id string) {
71+
totpKeysMu.Lock()
72+
defer totpKeysMu.Unlock()
73+
delete(totpKeys, id)
74+
}
75+
76+
// GenerateTOTPSalt returns a random base64 salt for key derivation
77+
func GenerateTOTPSalt() (string, error) {
78+
b := make([]byte, 16)
79+
if _, err := rand.Read(b); err != nil {
80+
return "", err
81+
}
82+
return base64.StdEncoding.EncodeToString(b), nil
83+
}
84+
85+
// DeriveTOTPKey derives a 32-byte AES key from the user's password and salt
86+
func DeriveTOTPKey(password, salt string) []byte {
87+
return argon2.IDKey([]byte(password), []byte(salt), 1, 64*1024, 4, 32)
88+
}
89+
90+
// IsEncryptedTOTPSecret reports whether a stored secret is encrypted at rest
91+
func IsEncryptedTOTPSecret(s string) bool {
92+
return strings.HasPrefix(s, totpEncPrefix)
93+
}
94+
95+
// EncryptTOTPSecret encrypts a plaintext secret with the derived key using AES-256-GCM
96+
func EncryptTOTPSecret(key []byte, secret string) (string, error) {
97+
block, err := aes.NewCipher(key)
98+
if err != nil {
99+
return "", err
100+
}
101+
gcm, err := cipher.NewGCM(block)
102+
if err != nil {
103+
return "", err
104+
}
105+
nonce := make([]byte, gcm.NonceSize())
106+
if _, err := rand.Read(nonce); err != nil {
107+
return "", err
108+
}
109+
ct := gcm.Seal(nonce, nonce, []byte(secret), nil)
110+
return totpEncPrefix + base64.StdEncoding.EncodeToString(ct), nil
111+
}
112+
113+
// DecryptTOTPSecret returns the plaintext secret; unencrypted values pass through
114+
func DecryptTOTPSecret(key []byte, stored string) (string, error) {
115+
if !IsEncryptedTOTPSecret(stored) {
116+
return stored, nil
117+
}
118+
if key == nil {
119+
return "", ErrTOTPKeyUnavailable
120+
}
121+
raw, err := base64.StdEncoding.DecodeString(strings.TrimPrefix(stored, totpEncPrefix))
122+
if err != nil {
123+
return "", err
124+
}
125+
block, err := aes.NewCipher(key)
126+
if err != nil {
127+
return "", err
128+
}
129+
gcm, err := cipher.NewGCM(block)
130+
if err != nil {
131+
return "", err
132+
}
133+
if len(raw) < gcm.NonceSize() {
134+
return "", errors.New("invalid encrypted totp secret")
135+
}
136+
pt, err := gcm.Open(nil, raw[:gcm.NonceSize()], raw[gcm.NonceSize():], nil)
137+
if err != nil {
138+
return "", err
139+
}
140+
return string(pt), nil
141+
}

internal/db/db.go

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -90,6 +90,8 @@ func migrateColumns(db *sql.DB) error {
9090
// TOTP support
9191
`ALTER TABLE kppn_users ADD COLUMN totp_secret TEXT NOT NULL DEFAULT ''`,
9292
`ALTER TABLE kppn_users ADD COLUMN totp_enabled INTEGER NOT NULL DEFAULT 0`,
93+
// per-user salt for password-derived totp secret encryption
94+
`ALTER TABLE kppn_users ADD COLUMN totp_salt TEXT NOT NULL DEFAULT ''`,
9395
// Notifiy support
9496
`ALTER TABLE kppn_users ADD COLUMN notify_email INTEGER NOT NULL DEFAULT 0`,
9597
`ALTER TABLE kppn_users ADD COLUMN notify_sms INTEGER NOT NULL DEFAULT 0`,

internal/db/users.go

Lines changed: 22 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -57,11 +57,11 @@ func GetUserByUsername(db *sql.DB, uname string) (*models.User, error) {
5757

5858
// query the database for a user matching the provided username and scan the result into the user struct
5959
err := db.QueryRow(`
60-
SELECT id, uname, pword, uhash, fname, lname, email, phone, role, totp_secret, totp_enabled, notify_email, notify_sms, created, updated
60+
SELECT id, uname, pword, uhash, fname, lname, email, phone, role, totp_secret, totp_salt, totp_enabled, notify_email, notify_sms, created, updated
6161
FROM kppn_users WHERE uname = ?`, uname,
6262
).Scan(
6363
&u.ID, &u.UName, &u.PWord, &u.UHash, &u.FName, &u.LName,
64-
&u.Email, &u.Phone, &u.Role, &u.TOTPSecret, &u.TOTPEnabled,
64+
&u.Email, &u.Phone, &u.Role, &u.TOTPSecret, &u.TOTPSalt, &u.TOTPEnabled,
6565
&u.NotifyEmail, &u.NotifySMS, &u.Created, &u.Updated,
6666
)
6767
if err == sql.ErrNoRows {
@@ -84,11 +84,11 @@ func GetUserByID(db *sql.DB, id int64) (*models.User, error) {
8484

8585
// query the database for a user matching the provided ID and scan the result into the user struct
8686
err := db.QueryRow(`
87-
SELECT id, uname, pword, uhash, fname, lname, email, phone, role, totp_secret, totp_enabled, notify_email, notify_sms, created, updated
87+
SELECT id, uname, pword, uhash, fname, lname, email, phone, role, totp_secret, totp_salt, totp_enabled, notify_email, notify_sms, created, updated
8888
FROM kppn_users WHERE id = ?`, id,
8989
).Scan(
9090
&u.ID, &u.UName, &u.PWord, &u.UHash, &u.FName, &u.LName,
91-
&u.Email, &u.Phone, &u.Role, &u.TOTPSecret, &u.TOTPEnabled,
91+
&u.Email, &u.Phone, &u.Role, &u.TOTPSecret, &u.TOTPSalt, &u.TOTPEnabled,
9292
&u.NotifyEmail, &u.NotifySMS, &u.Created, &u.Updated,
9393
)
9494
if err == sql.ErrNoRows {
@@ -202,6 +202,24 @@ func SetTOTPSecret(db *sql.DB, id int64, secret string) error {
202202
return err
203203
}
204204

205+
// SetTOTPSalt stores the per-user salt used for TOTP secret key derivation.
206+
func SetTOTPSalt(db *sql.DB, id int64, salt string) error {
207+
_, err := db.Exec(`UPDATE kppn_users SET totp_salt=?, updated=datetime('now') WHERE id=?`, salt, id)
208+
if err != nil {
209+
logger.Error("SetTOTPSalt: failed for user %d: %v", id, err)
210+
}
211+
return err
212+
}
213+
214+
// UpdateTOTPSecret replaces the stored TOTP secret without touching the enabled flag.
215+
func UpdateTOTPSecret(db *sql.DB, id int64, secret string) error {
216+
_, err := db.Exec(`UPDATE kppn_users SET totp_secret=?, updated=datetime('now') WHERE id=?`, secret, id)
217+
if err != nil {
218+
logger.Error("UpdateTOTPSecret: failed for user %d: %v", id, err)
219+
}
220+
return err
221+
}
222+
205223
// EnableTOTP activates TOTP for the user (secret must already be stored).
206224
func EnableTOTP(db *sql.DB, id int64) error {
207225
_, err := db.Exec(`UPDATE kppn_users SET totp_enabled=1, updated=datetime('now') WHERE id=?`, id)

internal/handlers/users/handler.go

Lines changed: 22 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -267,6 +267,12 @@ func (h *Handler) apiUpdateUser(w http.ResponseWriter, r *http.Request) {
267267
}
268268
logger.Debug("invalidating all sessions for user %d after password change", target.ID)
269269
_ = db.DeleteUserSessions(h.DB, target.ID)
270+
271+
// the TOTP secret is encrypted with a key derived from the old password — force re-enrollment
272+
if target.TOTPEnabled {
273+
_ = db.DisableTOTP(h.DB, target.ID)
274+
_ = db.DeleteBackupCodes(h.DB, target.ID)
275+
}
270276
}
271277

272278
logger.Debug("updated user %d: %s", target.ID, target.UName)
@@ -356,11 +362,26 @@ func (h *Handler) apiTOTPConfirm(w http.ResponseWriter, r *http.Request) {
356362
return
357363
}
358364

359-
if !auth.VerifyTOTP(fresh.TOTPSecret, req.Code) {
365+
// decrypt with the caller's password-derived key; pending secrets are stored plaintext
366+
totpKey := auth.GetTOTPKey(auth.SessionFromRequest(r))
367+
secret, decErr := auth.DecryptTOTPSecret(totpKey, fresh.TOTPSecret)
368+
if decErr != nil {
369+
apiutil.ErrorMsg(w, http.StatusBadRequest, "please log out and back in, then retry TOTP setup")
370+
return
371+
}
372+
373+
if !auth.VerifyTOTP(secret, req.Code) {
360374
apiutil.ErrorMsg(w, http.StatusUnprocessableEntity, "invalid TOTP code")
361375
return
362376
}
363377

378+
// encrypt the confirmed secret with the owner's key — only when confirming our own
379+
if caller.ID == target.ID && totpKey != nil && !auth.IsEncryptedTOTPSecret(fresh.TOTPSecret) {
380+
if enc, encErr := auth.EncryptTOTPSecret(totpKey, fresh.TOTPSecret); encErr == nil {
381+
_ = db.UpdateTOTPSecret(h.DB, target.ID, enc)
382+
}
383+
}
384+
364385
if err := db.EnableTOTP(h.DB, target.ID); err != nil {
365386
logger.Error("apiTOTPConfirm: failed to enable TOTP for user %d: %v", target.ID, err)
366387
apiutil.Error(w, http.StatusInternalServerError, err)

internal/models/models.go

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -138,6 +138,7 @@ type User struct {
138138
Phone string
139139
Role int
140140
TOTPSecret string
141+
TOTPSalt string
141142
TOTPEnabled bool
142143
NotifyEmail bool
143144
NotifySMS bool

internal/server/handler_ui.go

Lines changed: 22 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -236,7 +236,17 @@ func (s *Server) handleLoginTOTP(w http.ResponseWriter, r *http.Request) {
236236
return
237237
}
238238

239-
if !auth.VerifyTOTP(user.TOTPSecret, code) {
239+
// decrypt the stored secret with the password-derived key from the login step
240+
totpKey := auth.GetTOTPKey(pendingToken)
241+
secret, decErr := auth.DecryptTOTPSecret(totpKey, user.TOTPSecret)
242+
if decErr != nil {
243+
logger.Warn("unable to decrypt TOTP secret for user %d: %v", user.ID, decErr)
244+
auth.ClearTOTPPendingCookie(w)
245+
http.Redirect(w, r, "/login", http.StatusSeeOther)
246+
return
247+
}
248+
249+
if !auth.VerifyTOTP(secret, code) {
240250

241251
// fall back to backup codes
242252
used, _ := db.UseBackupCode(s.cfg.DB, user.ID, code)
@@ -271,13 +281,24 @@ func (s *Server) handleLoginTOTP(w http.ResponseWriter, r *http.Request) {
271281
auth.ClearTOTPPendingCookie(w)
272282
auth.RecordSuccessfulLogin(ip)
273283

284+
// lazily encrypt a plaintext secret now that the password-derived key is in hand
285+
if totpKey != nil && !auth.IsEncryptedTOTPSecret(user.TOTPSecret) {
286+
if enc, encErr := auth.EncryptTOTPSecret(totpKey, user.TOTPSecret); encErr == nil {
287+
_ = db.UpdateTOTPSecret(s.cfg.DB, user.ID, enc)
288+
}
289+
}
290+
274291
sessionID, _, err := auth.CreateSession(s.cfg.DB, user.ID)
275292
if err != nil {
276293
logger.Error("failed to create session after TOTP for user %d: %v", user.ID, err)
277294
http.Error(w, "internal error", http.StatusInternalServerError)
278295
return
279296
}
280297

298+
// hand the derived key from the pending token to the new session
299+
auth.DropTOTPKey(pendingToken)
300+
auth.StashTOTPKey(sessionID, totpKey, auth.SessionDuration)
301+
281302
logger.Debug("user '%s' completed TOTP login", user.UName)
282303
// record successful TOTP login
283304
audit.Record(models.AuditEntry{

0 commit comments

Comments
 (0)