Changeset 0.9.4 (#54)

This commit is contained in:
2026-02-24 10:44:12 +00:00
parent 90021157e6
commit 5e416d3726
26 changed files with 1333 additions and 108 deletions

View File

@@ -15,6 +15,8 @@ import (
"golang.org/x/crypto/bcrypt"
"git.gobha.me/xcaliber/chat-switchboard/config"
"git.gobha.me/xcaliber/chat-switchboard/crypto"
"git.gobha.me/xcaliber/chat-switchboard/database"
"git.gobha.me/xcaliber/chat-switchboard/models"
"git.gobha.me/xcaliber/chat-switchboard/store"
)
@@ -32,12 +34,13 @@ type Claims struct {
}
type AuthHandler struct {
cfg *config.Config
stores store.Stores
cfg *config.Config
stores store.Stores
uekCache *crypto.UEKCache
}
func NewAuthHandler(cfg *config.Config, s store.Stores) *AuthHandler {
return &AuthHandler{cfg: cfg, stores: s}
func NewAuthHandler(cfg *config.Config, s store.Stores, uekCache *crypto.UEKCache) *AuthHandler {
return &AuthHandler{cfg: cfg, stores: s, uekCache: uekCache}
}
func (h *AuthHandler) Register(c *gin.Context) {
@@ -90,6 +93,12 @@ func (h *AuthHandler) Register(c *gin.Context) {
return
}
// Generate and store the User Encryption Key (vault)
if err := h.initVault(c.Request.Context(), user.ID, req.Password); err != nil {
log.Printf("⚠ Failed to init vault for user %s: %v", user.ID, err)
// Non-fatal: user can still use the app, vault will init on next login
}
if !user.IsActive {
c.JSON(http.StatusCreated, gin.H{
"message": "Account created but requires admin approval",
@@ -133,6 +142,9 @@ func (h *AuthHandler) Login(c *gin.Context) {
return
}
// Vault: unwrap UEK into session cache (or init if first login post-migration)
h.unlockVault(c.Request.Context(), user, req.Password)
h.stores.Users.UpdateLastLogin(c.Request.Context(), user.ID)
tokens, err := h.generateTokens(user)
@@ -189,6 +201,11 @@ func (h *AuthHandler) Logout(c *gin.Context) {
h.stores.Users.RevokeRefreshToken(c.Request.Context(), tokenHash)
}
// Evict UEK from session cache
if userID, exists := c.Get("user_id"); exists {
h.uekCache.Evict(userID.(string))
}
c.JSON(http.StatusOK, gin.H{"message": "logged out"})
}
@@ -239,6 +256,85 @@ func hashToken(token string) string {
return hex.EncodeToString(h[:])
}
// ── Vault Lifecycle ─────────────────────────
// initVault generates a UEK, wraps it with the user's password, and stores
// the encrypted UEK + salt + nonce on the user record. Called on registration
// and on first login for pre-migration users.
func (h *AuthHandler) initVault(ctx context.Context, userID, password string) error {
if h.uekCache == nil {
return nil // Vault not configured (e.g. tests)
}
uek, err := crypto.GenerateUEK()
if err != nil {
return err
}
salt, err := crypto.GenerateSalt()
if err != nil {
return err
}
pdk := crypto.DeriveKeyFromPassword(password, salt)
encryptedUEK, nonce, err := crypto.WrapUEK(uek, pdk)
if err != nil {
return err
}
_, err = database.DB.ExecContext(ctx, `
UPDATE users
SET encrypted_uek = $1, uek_salt = $2, uek_nonce = $3, vault_set = true
WHERE id = $4
`, encryptedUEK, salt, nonce, userID)
if err != nil {
return err
}
// Cache the UEK for the session
h.uekCache.Store(userID, uek)
log.Printf(" 🔐 Vault initialized for user %s", userID)
return nil
}
// unlockVault unwraps the UEK on login and caches it. If the user doesn't
// have a vault yet (pre-migration account), initializes one.
func (h *AuthHandler) unlockVault(ctx context.Context, user *models.User, password string) {
if h.uekCache == nil {
return
}
var vaultSet bool
var encryptedUEK, salt, nonce []byte
err := database.DB.QueryRowContext(ctx, `
SELECT vault_set, encrypted_uek, uek_salt, uek_nonce
FROM users WHERE id = $1
`, user.ID).Scan(&vaultSet, &encryptedUEK, &salt, &nonce)
if err != nil {
log.Printf("⚠ Vault query failed for user %s: %v", user.ID, err)
return
}
if !vaultSet {
// Pre-migration user: initialize vault on first login
if err := h.initVault(ctx, user.ID, password); err != nil {
log.Printf("⚠ Vault init on login failed for user %s: %v", user.ID, err)
}
return
}
// Derive PDK from password and unwrap UEK
pdk := crypto.DeriveKeyFromPassword(password, salt)
uek, err := crypto.UnwrapUEK(encryptedUEK, nonce, pdk)
if err != nil {
log.Printf("⚠ Vault unlock failed for user %s: %v", user.ID, err)
return
}
h.uekCache.Store(user.ID, uek)
}
// BootstrapAdmin creates/updates the admin user from env vars (K8s secret).
func BootstrapAdmin(cfg *config.Config, s store.Stores) {
if cfg.AdminUsername == "" || cfg.AdminPassword == "" {