Changeset 0.15.0 (#71)
This commit is contained in:
@@ -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
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user