Changeset 0.15.0 (#71)

This commit is contained in:
2026-02-26 21:19:55 +00:00
parent e2149e249d
commit 2d79ff593b
35 changed files with 3218 additions and 504 deletions

View File

@@ -258,6 +258,68 @@ func hashToken(token string) string {
// ── Vault Lifecycle ─────────────────────────
// DestroyVaultDB nullifies a user's vault columns and deletes personal
// provider configs. This is the shared DB-level operation used by:
// - unlockVault (stale seal recovery)
// - BootstrapAdmin / SeedUsers (password rotation at startup)
// - AdminHandler.destroyVault (admin-initiated reset)
//
// Does NOT evict from UEK cache or write audit logs — callers handle that.
func DestroyVaultDB(ctx context.Context, userID string) (providersDeleted int64) {
_, err := database.DB.ExecContext(ctx, `
UPDATE users
SET encrypted_uek = NULL, uek_salt = NULL, uek_nonce = NULL, vault_set = false
WHERE id = $1
`, userID)
if err != nil {
log.Printf("⚠ DestroyVaultDB: failed to clear vault columns for user %s: %v", userID, err)
}
result, err := database.DB.ExecContext(ctx, `
DELETE FROM provider_configs WHERE scope = 'personal' AND owner_id = $1
`, userID)
if err != nil {
log.Printf("⚠ DestroyVaultDB: failed to delete personal providers for user %s: %v", userID, err)
return 0
}
rows, _ := result.RowsAffected()
return rows
}
// ProbeAndRepairVault checks whether a user's vault can be unlocked with
// the given password. If the vault doesn't exist (vault_set=false), this is
// a no-op. If the vault exists but the seal is stale (encrypted_uek was
// wrapped with a different password), the vault and personal providers are
// destroyed so initVault fires cleanly on next login.
//
// Used by BootstrapAdmin and SeedUsers where the password is known at
// startup but the UEK cache is not available.
func ProbeAndRepairVault(ctx context.Context, userID, password string) {
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
`, userID).Scan(&vaultSet, &encryptedUEK, &salt, &nonce)
if err != nil || !vaultSet {
return // no vault to probe
}
pdk := crypto.DeriveKeyFromPassword(password, salt)
if _, err := crypto.UnwrapUEK(encryptedUEK, nonce, pdk); err == nil {
return // vault seal matches current password — all good
}
// Stale seal: password has actually changed since the vault was sealed
deleted := DestroyVaultDB(ctx, userID)
if deleted > 0 {
log.Printf(" 🔐 Vault stale-seal repair for user %s: cleared %d personal provider(s)", userID, deleted)
} else {
log.Printf(" 🔐 Vault stale-seal repair for user %s: vault columns cleared", userID)
}
}
// 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.
@@ -328,7 +390,20 @@ func (h *AuthHandler) unlockVault(ctx context.Context, user *models.User, passwo
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)
// Stale seal: encrypted_uek was wrapped with a different password
// (e.g. admin reset, BootstrapAdmin rotation, or pre-UEK migration).
// The old UEK is irrecoverable. Destroy the vault and re-initialize
// with the current password so the user isn't permanently locked out.
deleted := DestroyVaultDB(ctx, user.ID)
if deleted > 0 {
log.Printf("⚠ Vault stale-seal recovery for user %s: cleared %d personal provider(s)", user.ID, deleted)
} else {
log.Printf("⚠ Vault stale-seal recovery for user %s: no personal providers to clear", user.ID)
}
if err := h.initVault(ctx, user.ID, password); err != nil {
log.Printf("⚠ Vault re-init failed for user %s: %v", user.ID, err)
}
return
}
@@ -356,6 +431,9 @@ func BootstrapAdmin(cfg *config.Config, s store.Stores) {
"role": models.UserRoleAdmin,
"is_active": true,
})
// If the actual password changed, the vault seal is stale. Probe it
// with the current password and destroy only if unwrap fails.
ProbeAndRepairVault(ctx, existing.ID, cfg.AdminPassword)
log.Printf(" ✅ Admin user '%s' updated", cfg.AdminUsername)
return
}
@@ -437,6 +515,8 @@ func SeedUsers(cfg *config.Config, s store.Stores) {
"role": role,
"is_active": true,
})
// Probe vault with current password — only destroys if seal is stale
ProbeAndRepairVault(ctx, existing.ID, password)
log.Printf(" 🌱 Seed user '%s' updated (role=%s)", username, role)
continue
}