Release: v0.1.0-alpha
Release Docker Image / Build & Push Docker Image (release) Failing after 1m30s

This commit is contained in:
2026-04-05 16:56:16 +02:00
parent 23ff731579
commit fd13e67aef
89 changed files with 18786 additions and 0 deletions
+281
View File
@@ -0,0 +1,281 @@
// Keywarden - Centralized SSH Key Management and Deployment
// Copyright (C) 2026 Patrick Asmus (scriptos)
// SPDX-License-Identifier: AGPL-3.0-or-later
package audit
import (
"database/sql"
"fmt"
"git.techniverse.net/scriptos/keywarden/internal/database"
"git.techniverse.net/scriptos/keywarden/internal/logging"
"git.techniverse.net/scriptos/keywarden/internal/models"
)
// Action constants for audit logging
const (
// Authentication
ActionLoginSuccess = "login_success"
ActionLoginFailed = "login_failed"
ActionLogout = "logout"
ActionMFASuccess = "mfa_verified"
ActionMFAFailed = "mfa_failed"
ActionMFAEnabled = "mfa_enabled"
ActionMFADisabled = "mfa_disabled"
// SSH Keys
ActionKeyGenerated = "key_generated"
ActionKeyImported = "key_imported"
ActionKeyDeleted = "key_deleted"
ActionKeyDownload = "key_downloaded"
// Servers
ActionServerAdded = "server_added"
ActionServerUpdated = "server_updated"
ActionServerDeleted = "server_deleted"
ActionServerTest = "server_test"
ActionServerAuth = "server_auth_test"
// Server Groups
ActionGroupCreated = "group_created"
ActionGroupUpdated = "group_updated"
ActionGroupDeleted = "group_deleted"
ActionGroupServerAdded = "group_server_added"
ActionGroupServerRemoved = "group_server_removed"
ActionGroupDeploy = "group_deploy"
// Deployments
ActionDeploySuccess = "deploy_success"
ActionDeployFailed = "deploy_failed"
// User Management (admin)
ActionUserCreated = "user_created"
ActionUserUpdated = "user_updated"
ActionUserDeleted = "user_deleted"
// Settings
ActionSettingsChanged = "settings_changed"
ActionPasswordChanged = "password_changed"
ActionMasterKeyRegen = "masterkey_regenerated"
ActionMasterKeyRegenerated = "masterkey_regenerated"
ActionMasterKeyRegenFailed = "masterkey_regen_failed"
ActionAvatarChanged = "avatar_changed"
// Email
ActionEmailNotifyChanged = "email_notify_changed"
ActionEmailTestSent = "email_test_sent"
ActionEmailTestFailed = "email_test_failed"
ActionEmailLoginSent = "email_login_sent"
ActionEmailLoginFailed = "email_login_failed"
// Access Assignments
ActionAssignmentCreated = "assignment_created"
ActionAssignmentUpdated = "assignment_updated"
ActionAssignmentDeleted = "assignment_deleted"
ActionAssignmentSynced = "assignment_synced"
ActionAssignmentSyncFailed = "assignment_sync_failed"
ActionAssignmentKeyRemoved = "assignment_key_removed"
ActionAssignmentUserDeleted = "assignment_user_deleted"
ActionAssignmentCleanFailed = "assignment_cleanup_failed"
// Cron Jobs
ActionCronJobCreated = "cron_job_created"
ActionCronJobUpdated = "cron_job_updated"
ActionCronJobDeleted = "cron_job_deleted"
ActionCronJobPaused = "cron_job_paused"
ActionCronJobResumed = "cron_job_resumed"
ActionCronJobExecuted = "cron_job_executed"
ActionCronJobFailed = "cron_job_failed"
ActionCronJobKeyRemoved = "cron_job_key_removed"
// Account Security
ActionAccountLocked = "account_locked"
ActionAccountUnlocked = "account_unlocked"
ActionForcePasswordChange = "force_password_change"
ActionMFAEnforced = "mfa_enforced"
ActionPasswordPolicyChanged = "password_policy_changed"
// Backup & Restore
ActionBackupExported = "backup_exported"
ActionBackupExportFailed = "backup_export_failed"
ActionBackupImported = "backup_imported"
ActionBackupImportFailed = "backup_import_failed"
// Invitations
ActionInvitationSent = "invitation_sent"
ActionInvitationSendFailed = "invitation_send_failed"
ActionInvitationAccepted = "invitation_accepted"
ActionInvitationFailed = "invitation_failed"
)
// AuditEntry extends AuditLog with the username for display
type AuditEntry struct {
models.AuditLog
Username string
}
// Service handles audit log operations
type Service struct {
db *database.DB
}
// NewService creates a new audit service
func NewService(db *database.DB) *Service {
return &Service{db: db}
}
// Log records an audit event. Errors are logged but never returned to avoid
// disrupting the main application flow.
func (s *Service) Log(userID int64, action, details, ipAddress string) {
var uid interface{}
if userID == 0 {
uid = sql.NullInt64{Valid: false}
} else {
uid = userID
}
logging.Debug("Audit: action=%s user_id=%d ip=%s details=%s", action, userID, ipAddress, details)
_, err := s.db.Exec(
`INSERT INTO audit_log (user_id, action, details, ip_address) VALUES (?, ?, ?, ?)`,
uid, action, details, ipAddress,
)
if err != nil {
logging.Warn("Failed to write audit log: %v", err)
}
}
// GetAll returns paginated audit entries for all users (admin view).
// Returns entries, total count, and error.
func (s *Service) GetAll(page, perPage int) ([]AuditEntry, int, error) {
if page < 1 {
page = 1
}
if perPage < 1 {
perPage = 50
}
var total int
if err := s.db.QueryRow(`SELECT COUNT(*) FROM audit_log`).Scan(&total); err != nil {
return nil, 0, fmt.Errorf("failed to count audit logs: %w", err)
}
offset := (page - 1) * perPage
rows, err := s.db.Query(
`SELECT a.id, COALESCE(a.user_id, 0), COALESCE(u.username, '(system)') AS username,
a.action, COALESCE(a.details, ''), COALESCE(a.ip_address, ''),
a.created_at
FROM audit_log a
LEFT JOIN users u ON u.id = a.user_id
ORDER BY a.created_at DESC
LIMIT ? OFFSET ?`,
perPage, offset,
)
if err != nil {
return nil, 0, fmt.Errorf("failed to query audit logs: %w", err)
}
defer rows.Close()
var entries []AuditEntry
for rows.Next() {
var e AuditEntry
if err := rows.Scan(&e.ID, &e.UserID, &e.Username, &e.Action, &e.Details, &e.IPAddress, &e.CreatedAt); err != nil {
return nil, 0, fmt.Errorf("failed to scan audit entry: %w", err)
}
entries = append(entries, e)
}
return entries, total, nil
}
// GetAllExceptOwners returns paginated audit entries excluding entries from
// users with the "owner" role. This is the admin view.
func (s *Service) GetAllExceptOwners(page, perPage int) ([]AuditEntry, int, error) {
if page < 1 {
page = 1
}
if perPage < 1 {
perPage = 50
}
var total int
if err := s.db.QueryRow(
`SELECT COUNT(*) FROM audit_log a
LEFT JOIN users u ON u.id = a.user_id
WHERE u.role IS NULL OR u.role != 'owner'`,
).Scan(&total); err != nil {
return nil, 0, fmt.Errorf("failed to count audit logs: %w", err)
}
offset := (page - 1) * perPage
rows, err := s.db.Query(
`SELECT a.id, COALESCE(a.user_id, 0), COALESCE(u.username, '(system)') AS username,
a.action, COALESCE(a.details, ''), COALESCE(a.ip_address, ''),
a.created_at
FROM audit_log a
LEFT JOIN users u ON u.id = a.user_id
WHERE u.role IS NULL OR u.role != 'owner'
ORDER BY a.created_at DESC
LIMIT ? OFFSET ?`,
perPage, offset,
)
if err != nil {
return nil, 0, fmt.Errorf("failed to query audit logs: %w", err)
}
defer rows.Close()
var entries []AuditEntry
for rows.Next() {
var e AuditEntry
if err := rows.Scan(&e.ID, &e.UserID, &e.Username, &e.Action, &e.Details, &e.IPAddress, &e.CreatedAt); err != nil {
return nil, 0, fmt.Errorf("failed to scan audit entry: %w", err)
}
entries = append(entries, e)
}
return entries, total, nil
}
// GetByUser returns paginated audit entries for a specific user.
func (s *Service) GetByUser(userID int64, page, perPage int) ([]AuditEntry, int, error) {
if page < 1 {
page = 1
}
if perPage < 1 {
perPage = 50
}
var total int
if err := s.db.QueryRow(`SELECT COUNT(*) FROM audit_log WHERE user_id = ?`, userID).Scan(&total); err != nil {
return nil, 0, fmt.Errorf("failed to count audit logs: %w", err)
}
offset := (page - 1) * perPage
rows, err := s.db.Query(
`SELECT a.id, COALESCE(a.user_id, 0), COALESCE(u.username, '(deleted)') AS username,
a.action, COALESCE(a.details, ''), COALESCE(a.ip_address, ''),
a.created_at
FROM audit_log a
LEFT JOIN users u ON u.id = a.user_id
WHERE a.user_id = ?
ORDER BY a.created_at DESC
LIMIT ? OFFSET ?`,
userID, perPage, offset,
)
if err != nil {
return nil, 0, fmt.Errorf("failed to query audit logs: %w", err)
}
defer rows.Close()
var entries []AuditEntry
for rows.Next() {
var e AuditEntry
if err := rows.Scan(&e.ID, &e.UserID, &e.Username, &e.Action, &e.Details, &e.IPAddress, &e.CreatedAt); err != nil {
return nil, 0, fmt.Errorf("failed to scan audit entry: %w", err)
}
entries = append(entries, e)
}
return entries, total, nil
}
+658
View File
@@ -0,0 +1,658 @@
// Keywarden - Centralized SSH Key Management and Deployment
// Copyright (C) 2026 Patrick Asmus (scriptos)
// SPDX-License-Identifier: AGPL-3.0-or-later
package auth
import (
"crypto/rand"
"database/sql"
"encoding/base32"
"errors"
"fmt"
"strconv"
"strings"
"time"
"unicode"
"git.techniverse.net/scriptos/keywarden/internal/database"
"git.techniverse.net/scriptos/keywarden/internal/models"
"golang.org/x/crypto/bcrypt"
)
var (
ErrInvalidCredentials = errors.New("invalid username or password")
ErrUserExists = errors.New("username or email already exists")
ErrUserNotFound = errors.New("user not found")
ErrMFARequired = errors.New("mfa verification required")
ErrInvalidMFACode = errors.New("invalid MFA code")
ErrAccountLocked = errors.New("account is temporarily locked")
)
// Service handles user authentication
type Service struct {
db *database.DB
}
// NewService creates a new auth service
func NewService(db *database.DB) *Service {
return &Service{db: db}
}
// Register creates a new user account. If mustChangePassword is true, the user
// will be forced to change their password on next login.
func (s *Service) Register(username, email, password, role string, mustChangePassword bool) (*models.User, error) {
hash, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost)
if err != nil {
return nil, fmt.Errorf("failed to hash password: %w", err)
}
if role == "" {
role = "user"
}
mcp := 0
if mustChangePassword {
mcp = 1
}
result, err := s.db.Exec(
`INSERT INTO users (username, email, password_hash, role, must_change_password) VALUES (?, ?, ?, ?, ?)`,
username, email, string(hash), role, mcp,
)
if err != nil {
return nil, ErrUserExists
}
id, _ := result.LastInsertId()
return &models.User{
ID: id,
Username: username,
Email: email,
Role: role,
MustChangePassword: mustChangePassword,
CreatedAt: time.Now(),
UpdatedAt: time.Now(),
}, nil
}
// Login authenticates a user and returns the user if successful
func (s *Service) Login(username, password string) (*models.User, error) {
user := &models.User{}
err := s.db.QueryRow(
`SELECT id, username, email, password_hash, role, mfa_enabled, mfa_secret, theme, email_notify_login, must_change_password, failed_login_attempts, locked_until, created_at, updated_at FROM users WHERE username = ?`,
username,
).Scan(&user.ID, &user.Username, &user.Email, &user.PasswordHash, &user.Role, &user.MFAEnabled, &user.MFASecret, &user.Theme, &user.EmailNotifyLogin, &user.MustChangePassword, &user.FailedLoginAttempts, &user.LockedUntil, &user.CreatedAt, &user.UpdatedAt)
if err == sql.ErrNoRows {
return nil, ErrInvalidCredentials
}
if err != nil {
return nil, fmt.Errorf("failed to query user: %w", err)
}
// Check account lockout
if user.LockedUntil != nil && time.Now().Before(*user.LockedUntil) {
return nil, ErrAccountLocked
}
if err := bcrypt.CompareHashAndPassword([]byte(user.PasswordHash), []byte(password)); err != nil {
return nil, ErrInvalidCredentials
}
return user, nil
}
// GetUserByID returns a user by their ID
func (s *Service) GetUserByID(id int64) (*models.User, error) {
user := &models.User{}
err := s.db.QueryRow(
`SELECT id, username, email, password_hash, role, mfa_enabled, mfa_secret, theme, email_notify_login, avatar_base64, must_change_password, failed_login_attempts, locked_until, last_login_at, created_at, updated_at FROM users WHERE id = ?`,
id,
).Scan(&user.ID, &user.Username, &user.Email, &user.PasswordHash, &user.Role, &user.MFAEnabled, &user.MFASecret, &user.Theme, &user.EmailNotifyLogin, &user.AvatarBase64, &user.MustChangePassword, &user.FailedLoginAttempts, &user.LockedUntil, &user.LastLoginAt, &user.CreatedAt, &user.UpdatedAt)
if err == sql.ErrNoRows {
return nil, ErrUserNotFound
}
if err != nil {
return nil, fmt.Errorf("failed to query user: %w", err)
}
return user, nil
}
// GetAllUsers returns all registered users (admin only)
func (s *Service) GetAllUsers() ([]models.User, error) {
rows, err := s.db.Query(
`SELECT id, username, email, role, mfa_enabled, must_change_password, failed_login_attempts, locked_until, last_login_at, created_at, updated_at FROM users ORDER BY created_at DESC`,
)
if err != nil {
return nil, fmt.Errorf("failed to query users: %w", err)
}
defer rows.Close()
var users []models.User
for rows.Next() {
var u models.User
if err := rows.Scan(&u.ID, &u.Username, &u.Email, &u.Role, &u.MFAEnabled, &u.MustChangePassword, &u.FailedLoginAttempts, &u.LockedUntil, &u.LastLoginAt, &u.CreatedAt, &u.UpdatedAt); err != nil {
return nil, fmt.Errorf("failed to scan user: %w", err)
}
users = append(users, u)
}
return users, nil
}
// HasUsers checks if any users exist in the database
func (s *Service) HasUsers() (bool, error) {
var count int
err := s.db.QueryRow(`SELECT COUNT(*) FROM users`).Scan(&count)
if err != nil {
return false, err
}
return count > 0, nil
}
// EnsureAdmin creates a default owner user if no users exist.
// It auto-generates a secure password and returns (created, generatedPassword, error).
func (s *Service) EnsureAdmin(username, email string) (bool, string, error) {
hasUsers, err := s.HasUsers()
if err != nil {
return false, "", err
}
if hasUsers {
return false, "", nil
}
// Generate a secure random password (20 chars, base62)
password, err := generateSecurePassword(20)
if err != nil {
return false, "", fmt.Errorf("failed to generate password: %w", err)
}
hash, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost)
if err != nil {
return false, "", fmt.Errorf("failed to hash password: %w", err)
}
_, err = s.db.Exec(
`INSERT INTO users (username, email, password_hash, role, must_change_password) VALUES (?, ?, ?, ?, 1)`,
username, email, string(hash), "owner",
)
if err != nil {
return false, "", err
}
return true, password, nil
}
// generateSecurePassword creates a cryptographically secure random password
func generateSecurePassword(length int) (string, error) {
const charset = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789"
b := make([]byte, length)
if _, err := rand.Read(b); err != nil {
return "", err
}
for i := range b {
b[i] = charset[b[i]%byte(len(charset))]
}
return string(b), nil
}
// UpdateUser updates user details (admin function)
func (s *Service) UpdateUser(id int64, username, email, role string) error {
_, err := s.db.Exec(
`UPDATE users SET username = ?, email = ?, role = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?`,
username, email, role, id,
)
if err != nil {
return fmt.Errorf("failed to update user: %w", err)
}
return nil
}
// UpdatePassword changes a user's password
func (s *Service) UpdatePassword(id int64, newPassword string) error {
hash, err := bcrypt.GenerateFromPassword([]byte(newPassword), bcrypt.DefaultCost)
if err != nil {
return fmt.Errorf("failed to hash password: %w", err)
}
_, err = s.db.Exec(
`UPDATE users SET password_hash = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?`,
string(hash), id,
)
if err != nil {
return fmt.Errorf("failed to update password: %w", err)
}
return nil
}
// DeleteUser removes a user
func (s *Service) DeleteUser(id int64) error {
result, err := s.db.Exec(`DELETE FROM users WHERE id = ?`, id)
if err != nil {
return fmt.Errorf("failed to delete user: %w", err)
}
rows, _ := result.RowsAffected()
if rows == 0 {
return ErrUserNotFound
}
return nil
}
// CountByRole counts how many users have the given role
func (s *Service) CountByRole(role string) (int, error) {
var count int
err := s.db.QueryRow(`SELECT COUNT(*) FROM users WHERE role = ?`, role).Scan(&count)
if err != nil {
return 0, fmt.Errorf("failed to count users by role: %w", err)
}
return count, nil
}
// GenerateMFASecret generates a random TOTP secret
func (s *Service) GenerateMFASecret() string {
secret := make([]byte, 20)
rand.Read(secret)
return base32.StdEncoding.WithPadding(base32.NoPadding).EncodeToString(secret)
}
// EnableMFA stores the MFA secret for a user
func (s *Service) EnableMFA(userID int64, secret string) error {
_, err := s.db.Exec(
`UPDATE users SET mfa_enabled = 1, mfa_secret = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?`,
secret, userID,
)
return err
}
// DisableMFA removes MFA for a user
func (s *Service) DisableMFA(userID int64) error {
_, err := s.db.Exec(
`UPDATE users SET mfa_enabled = 0, mfa_secret = '', updated_at = CURRENT_TIMESTAMP WHERE id = ?`,
userID,
)
return err
}
// UpdateTheme updates the user's theme preference (auto, light, dark)
func (s *Service) UpdateTheme(id int64, theme string) error {
if theme != "auto" && theme != "light" && theme != "dark" {
theme = "auto"
}
_, err := s.db.Exec(
`UPDATE users SET theme = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?`,
theme, id,
)
if err != nil {
return fmt.Errorf("failed to update theme: %w", err)
}
return nil
}
// UpdateEmailNotifyLogin updates the user's login email notification setting
func (s *Service) UpdateEmailNotifyLogin(id int64, enabled bool) error {
val := 0
if enabled {
val = 1
}
_, err := s.db.Exec(
`UPDATE users SET email_notify_login = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?`,
val, id,
)
if err != nil {
return fmt.Errorf("failed to update email notification setting: %w", err)
}
return nil
}
// UpdateAvatar updates the user's profile picture (base64-encoded data URI)
func (s *Service) UpdateAvatar(id int64, avatarBase64 string) error {
_, err := s.db.Exec(
`UPDATE users SET avatar_base64 = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?`,
avatarBase64, id,
)
if err != nil {
return fmt.Errorf("failed to update avatar: %w", err)
}
return nil
}
// LegacyAvatar holds the minimal info needed for avatar migration
type LegacyAvatar struct {
ID int64
AvatarBase64 string
}
// GetUsersWithLegacyAvatars returns users whose avatar_base64 contains a data URI (legacy format)
func (s *Service) GetUsersWithLegacyAvatars() ([]LegacyAvatar, error) {
rows, err := s.db.Query(`SELECT id, avatar_base64 FROM users WHERE avatar_base64 LIKE 'data:%'`)
if err != nil {
return nil, fmt.Errorf("failed to query legacy avatars: %w", err)
}
defer rows.Close()
var results []LegacyAvatar
for rows.Next() {
var la LegacyAvatar
if err := rows.Scan(&la.ID, &la.AvatarBase64); err != nil {
return nil, fmt.Errorf("failed to scan legacy avatar: %w", err)
}
results = append(results, la)
}
return results, nil
}
// GetSetting reads a setting value
func (s *Service) GetSetting(key string) (string, error) {
var value string
err := s.db.QueryRow(`SELECT value FROM settings WHERE key = ?`, key).Scan(&value)
if err == sql.ErrNoRows {
return "", nil
}
return value, err
}
// SetSetting writes a setting value
func (s *Service) SetSetting(key, value string) error {
_, err := s.db.Exec(
`INSERT INTO settings (key, value, updated_at) VALUES (?, ?, CURRENT_TIMESTAMP)
ON CONFLICT(key) DO UPDATE SET value = excluded.value, updated_at = CURRENT_TIMESTAMP`,
key, value,
)
return err
}
// SetSettingsBatch writes multiple settings in a single transaction
func (s *Service) SetSettingsBatch(settings map[string]string) error {
tx, err := s.db.Begin()
if err != nil {
return fmt.Errorf("failed to begin transaction: %w", err)
}
defer tx.Rollback()
stmt, err := tx.Prepare(
`INSERT INTO settings (key, value, updated_at) VALUES (?, ?, CURRENT_TIMESTAMP)
ON CONFLICT(key) DO UPDATE SET value = excluded.value, updated_at = CURRENT_TIMESTAMP`,
)
if err != nil {
return fmt.Errorf("failed to prepare statement: %w", err)
}
defer stmt.Close()
for k, v := range settings {
if _, err := stmt.Exec(k, v); err != nil {
return fmt.Errorf("failed to save setting %s: %w", k, err)
}
}
if err := tx.Commit(); err != nil {
return fmt.Errorf("failed to commit settings: %w", err)
}
return nil
}
// GetAllSettings returns all settings as a map
func (s *Service) GetAllSettings() (map[string]string, error) {
rows, err := s.db.Query(`SELECT key, value FROM settings`)
if err != nil {
return nil, err
}
defer rows.Close()
settings := make(map[string]string)
for rows.Next() {
var k, v string
if err := rows.Scan(&k, &v); err != nil {
return nil, err
}
settings[k] = v
}
return settings, nil
}
// --- Password Policy ---
// GetPasswordPolicy returns the current password policy from settings.
// Missing settings default to sensible values.
func (s *Service) GetPasswordPolicy() models.PasswordPolicy {
policy := models.PasswordPolicy{
MinLength: 8,
RequireUpper: true,
RequireLower: true,
RequireDigit: true,
RequireSpecial: false,
}
if v, _ := s.GetSetting("pw_min_length"); v != "" {
if n, err := strconv.Atoi(v); err == nil && n >= 4 {
policy.MinLength = n
}
}
if v, _ := s.GetSetting("pw_require_upper"); v == "false" {
policy.RequireUpper = false
}
if v, _ := s.GetSetting("pw_require_lower"); v == "false" {
policy.RequireLower = false
}
if v, _ := s.GetSetting("pw_require_digit"); v == "false" {
policy.RequireDigit = false
}
if v, _ := s.GetSetting("pw_require_special"); v == "true" {
policy.RequireSpecial = true
}
return policy
}
// ValidatePasswordPolicy checks a password against the configured policy.
// Returns nil if the password is compliant, otherwise a descriptive error.
func (s *Service) ValidatePasswordPolicy(password string) error {
policy := s.GetPasswordPolicy()
var violations []string
if len(password) < policy.MinLength {
violations = append(violations, fmt.Sprintf("at least %d characters", policy.MinLength))
}
if policy.RequireUpper {
hasUpper := false
for _, r := range password {
if unicode.IsUpper(r) {
hasUpper = true
break
}
}
if !hasUpper {
violations = append(violations, "at least one uppercase letter")
}
}
if policy.RequireLower {
hasLower := false
for _, r := range password {
if unicode.IsLower(r) {
hasLower = true
break
}
}
if !hasLower {
violations = append(violations, "at least one lowercase letter")
}
}
if policy.RequireDigit {
hasDigit := false
for _, r := range password {
if unicode.IsDigit(r) {
hasDigit = true
break
}
}
if !hasDigit {
violations = append(violations, "at least one digit")
}
}
if policy.RequireSpecial {
hasSpecial := false
for _, r := range password {
if !unicode.IsLetter(r) && !unicode.IsDigit(r) {
hasSpecial = true
break
}
}
if !hasSpecial {
violations = append(violations, "at least one special character")
}
}
if len(violations) > 0 {
return fmt.Errorf("Password must contain: %s.", strings.Join(violations, ", "))
}
return nil
}
// --- Account Lockout ---
// RecordFailedLogin increments the failed login counter for a username
// and locks the account if the threshold is reached.
func (s *Service) RecordFailedLogin(username string) {
maxAttempts := 5
lockDuration := 15 // minutes
if v, _ := s.GetSetting("lockout_attempts"); v != "" {
if n, err := strconv.Atoi(v); err == nil && n > 0 {
maxAttempts = n
}
}
if v, _ := s.GetSetting("lockout_duration"); v != "" {
if n, err := strconv.Atoi(v); err == nil && n > 0 {
lockDuration = n
}
}
// lockout_attempts == 0 means lockout is disabled
if maxAttempts == 0 {
return
}
// Increment counter
s.db.Exec(`UPDATE users SET failed_login_attempts = failed_login_attempts + 1 WHERE username = ?`, username)
// Check if threshold reached
var attempts int
err := s.db.QueryRow(`SELECT failed_login_attempts FROM users WHERE username = ?`, username).Scan(&attempts)
if err != nil {
return
}
if attempts >= maxAttempts {
lockUntil := time.Now().Add(time.Duration(lockDuration) * time.Minute)
s.db.Exec(`UPDATE users SET locked_until = ? WHERE username = ?`, lockUntil, username)
}
}
// ResetFailedLogins clears the failed login counter and lock for a user
func (s *Service) ResetFailedLogins(userID int64) {
s.db.Exec(`UPDATE users SET failed_login_attempts = 0, locked_until = NULL WHERE id = ?`, userID)
}
// UnlockAccount clears the lock for a user (admin action)
func (s *Service) UnlockAccount(userID int64) error {
_, err := s.db.Exec(`UPDATE users SET failed_login_attempts = 0, locked_until = NULL, updated_at = CURRENT_TIMESTAMP WHERE id = ?`, userID)
return err
}
// --- Force Password Change ---
// SetMustChangePassword sets or clears the must_change_password flag
func (s *Service) SetMustChangePassword(userID int64, must bool) error {
val := 0
if must {
val = 1
}
_, err := s.db.Exec(
`UPDATE users SET must_change_password = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?`,
val, userID,
)
return err
}
// --- Last Login Tracking ---
// UpdateLastLogin records the current time as the user's last login
func (s *Service) UpdateLastLogin(userID int64) {
s.db.Exec(`UPDATE users SET last_login_at = CURRENT_TIMESTAMP WHERE id = ?`, userID)
}
// --- Invitation Tokens ---
// CreateInvitationToken generates a secure random token for a user invitation.
// The token expires after the given duration.
func (s *Service) CreateInvitationToken(userID int64, expiry time.Duration) (string, error) {
// Generate a 32-byte random token
tokenBytes := make([]byte, 32)
if _, err := rand.Read(tokenBytes); err != nil {
return "", fmt.Errorf("failed to generate token: %w", err)
}
token := base32.StdEncoding.WithPadding(base32.NoPadding).EncodeToString(tokenBytes)
expiresAt := time.Now().Add(expiry)
_, err := s.db.Exec(
`INSERT INTO invitation_tokens (user_id, token, expires_at) VALUES (?, ?, ?)`,
userID, token, expiresAt,
)
if err != nil {
return "", fmt.Errorf("failed to store invitation token: %w", err)
}
return token, nil
}
// GetInvitationByToken retrieves a valid (unused, not expired) invitation.
func (s *Service) GetInvitationByToken(token string) (*models.InvitationToken, error) {
inv := &models.InvitationToken{}
err := s.db.QueryRow(
`SELECT id, user_id, token, expires_at, used, created_at FROM invitation_tokens WHERE token = ?`,
token,
).Scan(&inv.ID, &inv.UserID, &inv.Token, &inv.ExpiresAt, &inv.Used, &inv.CreatedAt)
if err == sql.ErrNoRows {
return nil, fmt.Errorf("invitation not found")
}
if err != nil {
return nil, fmt.Errorf("failed to query invitation: %w", err)
}
return inv, nil
}
// CompleteInvitation sets the user's password and marks the invitation as used.
func (s *Service) CompleteInvitation(token string, newPassword string) (*models.User, error) {
inv, err := s.GetInvitationByToken(token)
if err != nil {
return nil, err
}
if inv.Used {
return nil, fmt.Errorf("invitation has already been used")
}
if time.Now().After(inv.ExpiresAt) {
return nil, fmt.Errorf("invitation has expired")
}
// Hash the new password
hash, err := bcrypt.GenerateFromPassword([]byte(newPassword), bcrypt.DefaultCost)
if err != nil {
return nil, fmt.Errorf("failed to hash password: %w", err)
}
// Update the user's password and clear must_change_password flag
_, err = s.db.Exec(
`UPDATE users SET password_hash = ?, must_change_password = 0, updated_at = CURRENT_TIMESTAMP WHERE id = ?`,
string(hash), inv.UserID,
)
if err != nil {
return nil, fmt.Errorf("failed to update user password: %w", err)
}
// Mark the invitation as used
_, err = s.db.Exec(`UPDATE invitation_tokens SET used = 1 WHERE id = ?`, inv.ID)
if err != nil {
return nil, fmt.Errorf("failed to mark invitation as used: %w", err)
}
// Return the user
return s.GetUserByID(inv.UserID)
}
+401
View File
@@ -0,0 +1,401 @@
// Keywarden - Centralized SSH Key Management and Deployment
// Copyright (C) 2026 Patrick Asmus (scriptos)
// SPDX-License-Identifier: AGPL-3.0-or-later
//go:build integration
package auth
import (
"path/filepath"
"testing"
"git.techniverse.net/scriptos/keywarden/internal/database"
)
func setupTestDB(t *testing.T) *database.DB {
t.Helper()
tmpDir := t.TempDir()
db, err := database.New(filepath.Join(tmpDir, "test.db"))
if err != nil {
t.Fatalf("Failed to create test database: %v", err)
}
return db
}
func TestRegisterAndLogin(t *testing.T) {
db := setupTestDB(t)
defer db.Close()
svc := NewService(db)
user, err := svc.Register("testuser", "test@example.com", "password123", "user", false)
if err != nil {
t.Fatalf("Register failed: %v", err)
}
if user.Username != "testuser" {
t.Fatalf("Expected username 'testuser', got %q", user.Username)
}
if user.Role != "user" {
t.Fatalf("Expected role 'user', got %q", user.Role)
}
// Login with correct credentials
loggedIn, err := svc.Login("testuser", "password123")
if err != nil {
t.Fatalf("Login failed: %v", err)
}
if loggedIn.ID != user.ID {
t.Fatalf("Login returned different user ID")
}
}
func TestLoginWrongPassword(t *testing.T) {
db := setupTestDB(t)
defer db.Close()
svc := NewService(db)
svc.Register("testuser", "test@example.com", "password123", "user", false)
_, err := svc.Login("testuser", "wrongpassword")
if err != ErrInvalidCredentials {
t.Fatalf("Expected ErrInvalidCredentials, got %v", err)
}
}
func TestLoginNonexistentUser(t *testing.T) {
db := setupTestDB(t)
defer db.Close()
svc := NewService(db)
_, err := svc.Login("nonexistent", "password123")
if err != ErrInvalidCredentials {
t.Fatalf("Expected ErrInvalidCredentials, got %v", err)
}
}
func TestRegisterDuplicate(t *testing.T) {
db := setupTestDB(t)
defer db.Close()
svc := NewService(db)
_, err := svc.Register("testuser", "test@example.com", "pass1", "user", false)
if err != nil {
t.Fatalf("First register failed: %v", err)
}
_, err = svc.Register("testuser", "test2@example.com", "pass2", "user", false)
if err != ErrUserExists {
t.Fatalf("Expected ErrUserExists, got %v", err)
}
}
func TestRegisterDefaultRole(t *testing.T) {
db := setupTestDB(t)
defer db.Close()
svc := NewService(db)
user, err := svc.Register("testuser", "test@example.com", "pass", "", false)
if err != nil {
t.Fatalf("Register failed: %v", err)
}
if user.Role != "user" {
t.Fatalf("Expected default role 'user', got %q", user.Role)
}
}
func TestGetUserByID(t *testing.T) {
db := setupTestDB(t)
defer db.Close()
svc := NewService(db)
created, _ := svc.Register("testuser", "test@example.com", "pass", "admin", false)
user, err := svc.GetUserByID(created.ID)
if err != nil {
t.Fatalf("GetUserByID failed: %v", err)
}
if user.Username != "testuser" {
t.Fatalf("Expected username 'testuser', got %q", user.Username)
}
if user.Role != "admin" {
t.Fatalf("Expected role 'admin', got %q", user.Role)
}
}
func TestGetUserByIDNotFound(t *testing.T) {
db := setupTestDB(t)
defer db.Close()
svc := NewService(db)
_, err := svc.GetUserByID(999)
if err != ErrUserNotFound {
t.Fatalf("Expected ErrUserNotFound, got %v", err)
}
}
func TestUpdateUser(t *testing.T) {
db := setupTestDB(t)
defer db.Close()
svc := NewService(db)
created, _ := svc.Register("testuser", "test@example.com", "pass", "user", false)
err := svc.UpdateUser(created.ID, "newname", "new@example.com", "admin")
if err != nil {
t.Fatalf("UpdateUser failed: %v", err)
}
user, _ := svc.GetUserByID(created.ID)
if user.Username != "newname" {
t.Fatalf("Expected updated username 'newname', got %q", user.Username)
}
if user.Email != "new@example.com" {
t.Fatalf("Expected updated email, got %q", user.Email)
}
if user.Role != "admin" {
t.Fatalf("Expected updated role 'admin', got %q", user.Role)
}
}
func TestUpdatePassword(t *testing.T) {
db := setupTestDB(t)
defer db.Close()
svc := NewService(db)
created, _ := svc.Register("testuser", "test@example.com", "oldpass", "user", false)
err := svc.UpdatePassword(created.ID, "newpass")
if err != nil {
t.Fatalf("UpdatePassword failed: %v", err)
}
// Old password should fail
_, err = svc.Login("testuser", "oldpass")
if err != ErrInvalidCredentials {
t.Fatalf("Old password should no longer work")
}
// New password should work
_, err = svc.Login("testuser", "newpass")
if err != nil {
t.Fatalf("Login with new password failed: %v", err)
}
}
func TestDeleteUser(t *testing.T) {
db := setupTestDB(t)
defer db.Close()
svc := NewService(db)
created, _ := svc.Register("testuser", "test@example.com", "pass", "user", false)
err := svc.DeleteUser(created.ID)
if err != nil {
t.Fatalf("DeleteUser failed: %v", err)
}
_, err = svc.GetUserByID(created.ID)
if err != ErrUserNotFound {
t.Fatalf("Expected ErrUserNotFound after deletion, got %v", err)
}
}
func TestDeleteUserNotFound(t *testing.T) {
db := setupTestDB(t)
defer db.Close()
svc := NewService(db)
err := svc.DeleteUser(999)
if err != ErrUserNotFound {
t.Fatalf("Expected ErrUserNotFound, got %v", err)
}
}
func TestEnsureAdmin(t *testing.T) {
db := setupTestDB(t)
defer db.Close()
svc := NewService(db)
created, generatedPass, err := svc.EnsureAdmin("admin", "admin@test.com")
if err != nil {
t.Fatalf("EnsureAdmin failed: %v", err)
}
if !created {
t.Fatal("Expected user to be created")
}
if len(generatedPass) != 20 {
t.Fatalf("Expected 20-char generated password, got %d chars", len(generatedPass))
}
// Admin should be loginable with generated password
user, err := svc.Login("admin", generatedPass)
if err != nil {
t.Fatalf("Login as admin failed: %v", err)
}
if user.Role != "owner" {
t.Fatalf("Expected owner role, got %q", user.Role)
}
// Second call should be no-op
created2, _, err := svc.EnsureAdmin("admin2", "admin2@test.com")
if err != nil {
t.Fatalf("Second EnsureAdmin should not fail: %v", err)
}
if created2 {
t.Fatal("Second EnsureAdmin should not create a user")
}
// admin2 should NOT exist (was skipped)
_, err = svc.Login("admin2", "anypass")
if err != ErrInvalidCredentials {
t.Fatalf("admin2 should not have been created")
}
}
func TestGetAllUsers(t *testing.T) {
db := setupTestDB(t)
defer db.Close()
svc := NewService(db)
svc.Register("user1", "user1@test.com", "pass1", "user", false)
svc.Register("user2", "user2@test.com", "pass2", "admin", false)
users, err := svc.GetAllUsers()
if err != nil {
t.Fatalf("GetAllUsers failed: %v", err)
}
if len(users) != 2 {
t.Fatalf("Expected 2 users, got %d", len(users))
}
}
func TestHasUsers(t *testing.T) {
db := setupTestDB(t)
defer db.Close()
svc := NewService(db)
has, err := svc.HasUsers()
if err != nil {
t.Fatalf("HasUsers failed: %v", err)
}
if has {
t.Fatal("Expected no users initially")
}
svc.Register("user1", "user1@test.com", "pass1", "user", false)
has, err = svc.HasUsers()
if err != nil {
t.Fatalf("HasUsers failed: %v", err)
}
if !has {
t.Fatal("Expected users after registration")
}
}
func TestSettings(t *testing.T) {
db := setupTestDB(t)
defer db.Close()
svc := NewService(db)
// Get non-existent setting
val, err := svc.GetSetting("app_name")
if err != nil {
t.Fatalf("GetSetting failed: %v", err)
}
if val != "" {
t.Fatalf("Expected empty value, got %q", val)
}
// Set and get
err = svc.SetSetting("app_name", "Keywarden Test")
if err != nil {
t.Fatalf("SetSetting failed: %v", err)
}
val, err = svc.GetSetting("app_name")
if err != nil {
t.Fatalf("GetSetting failed: %v", err)
}
if val != "Keywarden Test" {
t.Fatalf("Expected 'Keywarden Test', got %q", val)
}
// Update existing
err = svc.SetSetting("app_name", "Updated")
if err != nil {
t.Fatalf("SetSetting update failed: %v", err)
}
val, _ = svc.GetSetting("app_name")
if val != "Updated" {
t.Fatalf("Expected 'Updated', got %q", val)
}
}
func TestGetAllSettings(t *testing.T) {
db := setupTestDB(t)
defer db.Close()
svc := NewService(db)
svc.SetSetting("key1", "val1")
svc.SetSetting("key2", "val2")
settings, err := svc.GetAllSettings()
if err != nil {
t.Fatalf("GetAllSettings failed: %v", err)
}
if len(settings) != 2 {
t.Fatalf("Expected 2 settings, got %d", len(settings))
}
if settings["key1"] != "val1" || settings["key2"] != "val2" {
t.Fatal("Settings values mismatch")
}
}
func TestMFASecret(t *testing.T) {
db := setupTestDB(t)
defer db.Close()
svc := NewService(db)
secret := svc.GenerateMFASecret()
if len(secret) == 0 {
t.Fatal("MFA secret should not be empty")
}
// Should be valid base32
if len(secret) < 16 {
t.Fatalf("MFA secret seems too short: %d chars", len(secret))
}
}
func TestEnableDisableMFA(t *testing.T) {
db := setupTestDB(t)
defer db.Close()
svc := NewService(db)
user, _ := svc.Register("testuser", "test@example.com", "pass", "user", false)
err := svc.EnableMFA(user.ID, "TESTSECRET")
if err != nil {
t.Fatalf("EnableMFA failed: %v", err)
}
updated, _ := svc.GetUserByID(user.ID)
if !updated.MFAEnabled {
t.Fatal("MFA should be enabled")
}
if updated.MFASecret != "TESTSECRET" {
t.Fatalf("MFA secret mismatch: got %q", updated.MFASecret)
}
err = svc.DisableMFA(user.ID)
if err != nil {
t.Fatalf("DisableMFA failed: %v", err)
}
updated, _ = svc.GetUserByID(user.ID)
if updated.MFAEnabled {
t.Fatal("MFA should be disabled")
}
}
+113
View File
@@ -0,0 +1,113 @@
// Keywarden - Centralized SSH Key Management and Deployment
// Copyright (C) 2026 Patrick Asmus (scriptos)
// SPDX-License-Identifier: AGPL-3.0-or-later
package config
import (
"os"
"strconv"
"strings"
)
// Config holds all application configuration
type Config struct {
Port string
DBPath string
DataDir string
KeysDir string
MasterDir string
SessionKey string
EncryptionKey string
LogLevel string // ERROR, WARN, INFO (default), DEBUG, TRACE
// SMTP / Email
SMTPHost string
SMTPPort string
SMTPUser string
SMTPPassword string
SMTPFrom string
SMTPTLS bool
SMTPEnabled bool
// Security / Hardening
BaseURL string // e.g. "https://keywarden.example.com" (used for emails, cookie config)
TrustedProxies string // comma-separated CIDRs, e.g. "10.0.0.0/8,172.16.0.0/12"
SecureCookies bool // set Secure flag on cookies (enable when behind HTTPS proxy)
RateLimitLogin int // max login POST attempts per IP per minute (0 = disabled)
MaxRequestSize int64 // max request body in bytes (0 = no limit)
}
// Load reads configuration from environment variables with sensible defaults
func Load() *Config {
smtpHost := getEnv("KEYWARDEN_SMTP_HOST", "")
// Parse BaseURL – auto-derive SecureCookies from scheme if not explicitly set
baseURL := strings.TrimRight(getEnv("KEYWARDEN_BASE_URL", ""), "/")
secureCookiesExplicit := getEnv("KEYWARDEN_SECURE_COOKIES", "")
secureCookies := false
if secureCookiesExplicit != "" {
secureCookies = secureCookiesExplicit == "true"
} else if strings.HasPrefix(baseURL, "https://") {
secureCookies = true
}
rateLimitLogin := getEnvInt("KEYWARDEN_RATE_LIMIT_LOGIN", 10)
maxRequestSize := getEnvInt64("KEYWARDEN_MAX_REQUEST_SIZE", 10*1024*1024) // 10 MB
return &Config{
Port: getEnv("KEYWARDEN_PORT", "8080"),
DBPath: getEnv("KEYWARDEN_DB_PATH", "./data/keywarden.db"),
DataDir: getEnv("KEYWARDEN_DATA_DIR", "./data"),
KeysDir: getEnv("KEYWARDEN_KEYS_DIR", "./data/keys"),
MasterDir: getEnv("KEYWARDEN_MASTER_DIR", "./data/master"),
SessionKey: getEnv("KEYWARDEN_SESSION_KEY", "change-me-in-production-please"),
EncryptionKey: getEnv("KEYWARDEN_ENCRYPTION_KEY", "change-me-encryption-key-32chars"),
LogLevel: getEnv("KEYWARDEN_LOG_LEVEL", "INFO"),
SMTPHost: smtpHost,
SMTPPort: getEnv("KEYWARDEN_SMTP_PORT", "587"),
SMTPUser: getEnv("KEYWARDEN_SMTP_USER", ""),
SMTPPassword: getEnv("KEYWARDEN_SMTP_PASSWORD", ""),
SMTPFrom: getEnv("KEYWARDEN_SMTP_FROM", "keywarden@localhost"),
SMTPTLS: getEnv("KEYWARDEN_SMTP_TLS", "true") == "true",
SMTPEnabled: smtpHost != "",
BaseURL: baseURL,
TrustedProxies: getEnv("KEYWARDEN_TRUSTED_PROXIES", ""),
SecureCookies: secureCookies,
RateLimitLogin: rateLimitLogin,
MaxRequestSize: maxRequestSize,
}
}
func getEnv(key, fallback string) string {
if val, ok := os.LookupEnv(key); ok {
return val
}
return fallback
}
func getEnvInt(key string, fallback int) int {
s := getEnv(key, "")
if s == "" {
return fallback
}
v, err := strconv.Atoi(s)
if err != nil {
return fallback
}
return v
}
func getEnvInt64(key string, fallback int64) int64 {
s := getEnv(key, "")
if s == "" {
return fallback
}
v, err := strconv.ParseInt(s, 10, 64)
if err != nil {
return fallback
}
return v
}
+68
View File
@@ -0,0 +1,68 @@
// Keywarden - Centralized SSH Key Management and Deployment
// Copyright (C) 2026 Patrick Asmus (scriptos)
// SPDX-License-Identifier: AGPL-3.0-or-later
package config
import (
"os"
"testing"
)
func TestLoadDefaults(t *testing.T) {
// Clear all KEYWARDEN_ env vars to ensure defaults
envs := []string{
"KEYWARDEN_PORT", "KEYWARDEN_DB_PATH", "KEYWARDEN_DATA_DIR",
"KEYWARDEN_KEYS_DIR", "KEYWARDEN_MASTER_DIR", "KEYWARDEN_SESSION_KEY",
"KEYWARDEN_ENCRYPTION_KEY",
}
for _, e := range envs {
os.Unsetenv(e)
}
cfg := Load()
if cfg.Port != "8080" {
t.Fatalf("Expected default port 8080, got %q", cfg.Port)
}
if cfg.DBPath != "./data/keywarden.db" {
t.Fatalf("Expected default DBPath, got %q", cfg.DBPath)
}
if cfg.DataDir != "./data" {
t.Fatalf("Expected default DataDir, got %q", cfg.DataDir)
}
if cfg.KeysDir != "./data/keys" {
t.Fatalf("Expected default KeysDir, got %q", cfg.KeysDir)
}
if cfg.MasterDir != "./data/master" {
t.Fatalf("Expected default MasterDir, got %q", cfg.MasterDir)
}
}
func TestLoadFromEnv(t *testing.T) {
os.Setenv("KEYWARDEN_PORT", "9090")
os.Setenv("KEYWARDEN_DB_PATH", "/custom/db.sqlite")
os.Setenv("KEYWARDEN_DATA_DIR", "/custom/data")
os.Setenv("KEYWARDEN_ENCRYPTION_KEY", "my-custom-key")
defer func() {
os.Unsetenv("KEYWARDEN_PORT")
os.Unsetenv("KEYWARDEN_DB_PATH")
os.Unsetenv("KEYWARDEN_DATA_DIR")
os.Unsetenv("KEYWARDEN_ENCRYPTION_KEY")
}()
cfg := Load()
if cfg.Port != "9090" {
t.Fatalf("Expected port 9090, got %q", cfg.Port)
}
if cfg.DBPath != "/custom/db.sqlite" {
t.Fatalf("Expected custom DBPath, got %q", cfg.DBPath)
}
if cfg.DataDir != "/custom/data" {
t.Fatalf("Expected custom DataDir, got %q", cfg.DataDir)
}
if cfg.EncryptionKey != "my-custom-key" {
t.Fatalf("Expected custom EncryptionKey, got %q", cfg.EncryptionKey)
}
}
+660
View File
@@ -0,0 +1,660 @@
// Keywarden - Centralized SSH Key Management and Deployment
// Copyright (C) 2026 Patrick Asmus (scriptos)
// SPDX-License-Identifier: AGPL-3.0-or-later
package cron
import (
"crypto/rand"
"fmt"
"sync"
"time"
"git.techniverse.net/scriptos/keywarden/internal/audit"
"git.techniverse.net/scriptos/keywarden/internal/database"
"git.techniverse.net/scriptos/keywarden/internal/deploy"
"git.techniverse.net/scriptos/keywarden/internal/keys"
"git.techniverse.net/scriptos/keywarden/internal/logging"
"git.techniverse.net/scriptos/keywarden/internal/models"
"git.techniverse.net/scriptos/keywarden/internal/servers"
)
// Service handles scheduled key deployments
type Service struct {
db *database.DB
deploy *deploy.Service
keys *keys.Service
servers *servers.Service
audit *audit.Service
stopCh chan struct{}
wg sync.WaitGroup
}
// NewService creates a new cron service
func NewService(db *database.DB, deploySvc *deploy.Service, keysSvc *keys.Service, serversSvc *servers.Service, auditSvc *audit.Service) *Service {
return &Service{
db: db,
deploy: deploySvc,
keys: keysSvc,
servers: serversSvc,
audit: auditSvc,
stopCh: make(chan struct{}),
}
}
// Start begins the cron scheduler loop (checks every 30 seconds)
func (s *Service) Start() {
s.wg.Add(1)
go func() {
defer s.wg.Done()
ticker := time.NewTicker(30 * time.Second)
defer ticker.Stop()
// Run once immediately at startup
s.tick()
for {
select {
case <-ticker.C:
s.tick()
case <-s.stopCh:
return
}
}
}()
logging.Info("Cron scheduler started")
}
// Stop gracefully stops the cron scheduler
func (s *Service) Stop() {
close(s.stopCh)
s.wg.Wait()
}
// tick checks for jobs that need to run
func (s *Service) tick() {
now := time.Now().UTC()
jobs, err := s.GetPendingJobs(now)
if err != nil {
logging.Error("Cron: failed to get pending jobs: %v", err)
return
}
for _, job := range jobs {
s.executeJob(job)
}
}
// executeJob runs a single cron job
func (s *Service) executeJob(job models.CronJob) {
logging.Info("Cron: executing job '%s' (ID %d)", job.Name, job.ID)
// Mark as running
s.db.Exec(`UPDATE cron_jobs SET status = 'running', last_run = ? WHERE id = ?`, time.Now().UTC(), job.ID)
// Use TargetUserID (the key owner) instead of UserID (the admin who created the job)
key, err := s.keys.GetKeyByID(job.SSHKeyID, job.TargetUserID)
if err != nil {
s.failJob(job, fmt.Sprintf("key not found: %v", err))
return
}
// Use system master key for SSH authentication
masterKeyPEM, err := s.keys.GetSystemMasterKeyPrivate()
if err != nil {
s.failJob(job, fmt.Sprintf("system master key not available: %v", err))
return
}
// Read access config directly from the job
systemUser := job.SystemUser
createUser := job.CreateUser
sudo := job.Sudo
initialPassword := job.InitialPassword
// Auto-generate initial password if createUser is enabled and no password is set
if createUser && initialPassword == "" {
initialPassword = generateInitialPassword(10)
logging.Debug("Cron job '%s': auto-generated initial password for system user '%s'", job.Name, systemUser)
}
var targetServers []models.Server
if job.ServerID > 0 {
// Single server — use Global lookup since cron jobs are admin-created
srv, err := s.servers.GetByIDGlobal(job.ServerID)
if err != nil {
s.failJob(job, fmt.Sprintf("server not found: %v", err))
return
}
targetServers = append(targetServers, *srv)
} else if job.GroupID > 0 {
// Server group — use Global lookup since cron jobs are admin-created
members, err := s.servers.GetGroupMembersGlobal(job.GroupID)
if err != nil || len(members) == 0 {
s.failJob(job, fmt.Sprintf("group members not found: %v", err))
return
}
targetServers = members
} else {
s.failJob(job, "no target server or group specified")
return
}
var successCount, failCount int
for _, srv := range targetServers {
server := srv
var deployErr error
if systemUser != "" {
// Deploy to specific system user (from assignment)
deployErr = s.deploy.DeployKeyToUser(key, &server, masterKeyPEM, systemUser, createUser, sudo, initialPassword)
} else {
// Legacy: deploy to server's default user (root)
deployErr = s.deploy.DeployKey(key, &server, masterKeyPEM)
}
if deployErr != nil {
failCount++
logging.Error("Cron job '%s': deploy to %s@%s:%d failed: %v", job.Name, server.Username, server.Hostname, server.Port, deployErr)
} else {
successCount++
}
}
// Log result
targetInfo := ""
if systemUser != "" {
targetInfo = fmt.Sprintf(" (system user: %s)", systemUser)
}
details := fmt.Sprintf("Cron job '%s': deployed key '%s'%s — %d success, %d failed", job.Name, key.Name, targetInfo, successCount, failCount)
if failCount > 0 && successCount == 0 {
s.audit.Log(job.UserID, audit.ActionCronJobFailed, details, "cron")
} else {
s.audit.Log(job.UserID, audit.ActionCronJobExecuted, details, "cron")
}
// Store auto-generated initial password (encrypted) if it was generated and deploy succeeded
if initialPassword != "" && job.InitialPassword == "" && successCount > 0 {
if encPW, encErr := s.keys.EncryptValue(initialPassword); encErr == nil {
s.db.Exec(`UPDATE cron_jobs SET initial_password = ? WHERE id = ?`, encPW, job.ID)
} else {
logging.Warn("Cron job '%s': failed to encrypt initial password: %v", job.Name, encErr)
}
}
// Update job status
if job.Schedule == "once" {
// One-time job: mark as done
s.db.Exec(`UPDATE cron_jobs SET status = 'done', last_run = ?, message = ? WHERE id = ?`,
time.Now().UTC(), details, job.ID)
} else {
// Recurring job: calculate next run and stay active
nextRun := CalculateNextRun(job)
s.db.Exec(`UPDATE cron_jobs SET status = 'active', last_run = ?, next_run = ?, message = ? WHERE id = ?`,
time.Now().UTC(), nextRun, details, job.ID)
}
// Handle auto-removal after expiry (for temporary deployments)
if job.RemoveAfterMin > 0 {
s.wg.Add(1)
go func(j models.CronJob, serverList []models.Server, sshKey *models.SSHKey, masterPEM []byte) {
defer s.wg.Done()
timer := time.NewTimer(time.Duration(j.RemoveAfterMin) * time.Minute)
select {
case <-timer.C:
s.handleExpiry(j, serverList, sshKey, masterPEM)
case <-s.stopCh:
timer.Stop()
return
}
}(job, targetServers, key, masterKeyPEM)
}
}
// handleExpiry handles the expiry action for a temporary access job
func (s *Service) handleExpiry(job models.CronJob, serverList []models.Server, key *models.SSHKey, masterKeyPEM []byte) {
expiryAction := job.ExpiryAction
if expiryAction == "" {
expiryAction = "remove_key"
}
systemUser := job.SystemUser
logging.Info("Cron: expiry action '%s' for key '%s' after %d minutes (job '%s')", expiryAction, key.Name, job.RemoveAfterMin, job.Name)
for _, srv := range serverList {
server := srv
var err error
switch expiryAction {
case "disable_user":
if systemUser != "" {
err = s.deploy.DisableSystemUser(key, &server, masterKeyPEM, systemUser)
} else {
err = s.deploy.RemoveKey(key, &server, masterKeyPEM)
}
case "delete_user":
if systemUser != "" {
err = s.deploy.RemoveSystemUser(key, &server, masterKeyPEM, systemUser)
} else {
err = s.deploy.RemoveKey(key, &server, masterKeyPEM)
}
default: // "remove_key"
if systemUser != "" {
err = s.deploy.RemoveKeyFromUser(key, &server, masterKeyPEM, systemUser)
} else {
err = s.deploy.RemoveKey(key, &server, masterKeyPEM)
}
}
if err != nil {
logging.Error("Cron job '%s': expiry action '%s' on %s@%s:%d failed: %v", job.Name, expiryAction, server.Username, server.Hostname, server.Port, err)
}
}
actionLabel := map[string]string{"remove_key": "removed key", "disable_user": "disabled user", "delete_user": "deleted user"}[expiryAction]
details := fmt.Sprintf("Cron job '%s': %s '%s' on %d server(s) after %d min", job.Name, actionLabel, key.Name, len(serverList), job.RemoveAfterMin)
s.audit.Log(job.UserID, audit.ActionCronJobKeyRemoved, details, "cron")
}
// failJob marks a job as failed
func (s *Service) failJob(job models.CronJob, msg string) {
logging.Error("Cron job '%s' failed: %s", job.Name, msg)
status := "failed"
if job.Schedule != "once" {
// Recurring jobs stay active but log the failure
status = "active"
nextRun := CalculateNextRun(job)
s.db.Exec(`UPDATE cron_jobs SET status = ?, last_run = ?, next_run = ?, message = ? WHERE id = ?`,
status, time.Now().UTC(), nextRun, msg, job.ID)
} else {
s.db.Exec(`UPDATE cron_jobs SET status = ?, last_run = ?, message = ? WHERE id = ?`,
status, time.Now().UTC(), msg, job.ID)
}
s.audit.Log(job.UserID, audit.ActionCronJobFailed, fmt.Sprintf("Cron job '%s' failed: %s", job.Name, msg), "cron")
}
// CalculateNextRun computes the next execution time for recurring jobs.
// It uses the job's timezone and schedule parameters to ensure the execution
// time stays aligned (no drift). All returned times are in UTC.
func CalculateNextRun(job models.CronJob) time.Time {
loc, err := time.LoadLocation(job.Timezone)
if err != nil {
loc = time.UTC
}
now := time.Now().In(loc)
switch job.Schedule {
case "hourly":
// Next occurrence of the specified minute
next := time.Date(now.Year(), now.Month(), now.Day(), now.Hour(), job.MinuteOfHour, 0, 0, loc)
if !next.After(now) {
next = next.Add(1 * time.Hour)
}
return next.UTC()
case "daily":
// Next occurrence of the specified time of day
hour, minute := parseTimeOfDay(job.TimeOfDay)
next := time.Date(now.Year(), now.Month(), now.Day(), hour, minute, 0, 0, loc)
if !next.After(now) {
next = next.AddDate(0, 0, 1)
}
return next.UTC()
case "weekly":
// Next occurrence of the specified weekday at the specified time
hour, minute := parseTimeOfDay(job.TimeOfDay)
targetDay := time.Weekday(job.DayOfWeek)
next := time.Date(now.Year(), now.Month(), now.Day(), hour, minute, 0, 0, loc)
daysUntil := int(targetDay) - int(now.Weekday())
if daysUntil < 0 {
daysUntil += 7
}
next = next.AddDate(0, 0, daysUntil)
if !next.After(now) {
next = next.AddDate(0, 0, 7)
}
return next.UTC()
case "monthly":
// Next occurrence of the specified day of month at the specified time
hour, minute := parseTimeOfDay(job.TimeOfDay)
day := job.DayOfMonth
if day < 1 {
day = 1
}
next := safeDate(now.Year(), now.Month(), day, hour, minute, loc)
if !next.After(now) {
next = safeDate(now.Year(), now.Month()+1, day, hour, minute, loc)
}
return next.UTC()
default:
return now.Add(24 * time.Hour).UTC()
}
}
// CalculateFirstRun computes the initial next_run time for a new job.
// For "once", it uses the scheduled_at time directly.
// For recurring schedules, it finds the next matching time from now.
func CalculateFirstRun(job models.CronJob) time.Time {
if job.Schedule == "once" {
return job.ScheduledAt.UTC()
}
return CalculateNextRun(job)
}
// parseTimeOfDay parses "HH:MM" format and returns hour, minute
func parseTimeOfDay(tod string) (int, int) {
var hour, minute int
fmt.Sscanf(tod, "%d:%d", &hour, &minute)
if hour < 0 || hour > 23 {
hour = 0
}
if minute < 0 || minute > 59 {
minute = 0
}
return hour, minute
}
// safeDate creates a date, clamping the day to the last day of the month
func safeDate(year int, month time.Month, day, hour, minute int, loc *time.Location) time.Time {
lastDay := time.Date(year, month+1, 0, 0, 0, 0, 0, loc).Day()
if day > lastDay {
day = lastDay
}
return time.Date(year, month, day, hour, minute, 0, 0, loc)
}
// --- Database Operations ---
// Create creates a new temporary access job
func (s *Service) Create(userID int64, name string, keyID, serverID, groupID int64, schedule string, scheduledAt time.Time, removeAfterMin int, tz, timeOfDay string, dayOfWeek, dayOfMonth, minuteOfHour int, targetUserID int64, systemUser string, sudo, createUser bool, initialPassword, expiryAction string) (*models.CronJob, error) {
if expiryAction == "" {
expiryAction = "remove_key"
}
job := models.CronJob{
UserID: userID,
Name: name,
SSHKeyID: keyID,
ServerID: serverID,
GroupID: groupID,
Schedule: schedule,
ScheduledAt: scheduledAt.UTC(),
RemoveAfterMin: removeAfterMin,
Timezone: tz,
TimeOfDay: timeOfDay,
DayOfWeek: dayOfWeek,
DayOfMonth: dayOfMonth,
MinuteOfHour: minuteOfHour,
TargetUserID: targetUserID,
SystemUser: systemUser,
Sudo: sudo,
CreateUser: createUser,
InitialPassword: initialPassword,
ExpiryAction: expiryAction,
Status: "active",
}
nextRun := CalculateFirstRun(job)
job.NextRun = nextRun
result, err := s.db.Exec(
`INSERT INTO cron_jobs (user_id, name, ssh_key_id, server_id, group_id, schedule, scheduled_at, next_run, remove_after_min, status, timezone, time_of_day, day_of_week, day_of_month, minute_of_hour, target_user_id, system_user, sudo, create_user, initial_password, expiry_action)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, 'active', ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
userID, name, keyID, serverID, groupID, schedule, scheduledAt.UTC(), nextRun, removeAfterMin,
tz, timeOfDay, dayOfWeek, dayOfMonth, minuteOfHour, targetUserID, systemUser, sudo, createUser, initialPassword, expiryAction,
)
if err != nil {
return nil, fmt.Errorf("failed to create cron job: %w", err)
}
id, _ := result.LastInsertId()
job.ID = id
return &job, nil
}
// GetByUser returns all cron jobs for a user
func (s *Service) GetByUser(userID int64) ([]models.CronJobDisplay, error) {
rows, err := s.db.Query(
`SELECT cj.id, cj.user_id, cj.name, cj.ssh_key_id, cj.server_id, cj.group_id,
cj.schedule, cj.scheduled_at, cj.next_run, cj.last_run, cj.remove_after_min,
cj.status, cj.message, cj.created_at,
cj.timezone, cj.time_of_day, cj.day_of_week, cj.day_of_month, cj.minute_of_hour,
cj.target_user_id, cj.system_user, cj.sudo, cj.create_user, cj.initial_password, cj.expiry_action,
COALESCE(sk.name, '(deleted)') as key_name,
COALESCE(srv.name, '') as server_name,
COALESCE(sg.name, '') as group_name,
COALESCE(tu.username, '') as target_username
FROM cron_jobs cj
LEFT JOIN ssh_keys sk ON cj.ssh_key_id = sk.id
LEFT JOIN servers srv ON cj.server_id = srv.id
LEFT JOIN server_groups sg ON cj.group_id = sg.id
LEFT JOIN users tu ON cj.target_user_id = tu.id
WHERE cj.user_id = ?
ORDER BY cj.created_at DESC`, userID,
)
if err != nil {
return nil, fmt.Errorf("failed to query cron jobs: %w", err)
}
defer rows.Close()
var jobs []models.CronJobDisplay
for rows.Next() {
var j models.CronJobDisplay
var lastRun, message *string
var serverName, groupName string
if err := rows.Scan(
&j.ID, &j.UserID, &j.Name, &j.SSHKeyID, &j.ServerID, &j.GroupID,
&j.Schedule, &j.ScheduledAt, &j.NextRun, &lastRun, &j.RemoveAfterMin,
&j.Status, &message, &j.CreatedAt,
&j.Timezone, &j.TimeOfDay, &j.DayOfWeek, &j.DayOfMonth, &j.MinuteOfHour,
&j.TargetUserID, &j.SystemUser, &j.Sudo, &j.CreateUser, &j.InitialPassword, &j.ExpiryAction,
&j.KeyName, &serverName, &groupName,
&j.TargetUsername,
); err != nil {
continue
}
if lastRun != nil {
if t, ok := parseTimeString(*lastRun); ok {
j.LastRun = &t
}
}
if message != nil {
j.Message = *message
}
if serverName != "" {
j.TargetName = serverName
j.TargetType = "host"
} else if groupName != "" {
j.TargetName = groupName
j.TargetType = "group"
}
jobs = append(jobs, j)
}
return jobs, nil
}
// GetByID returns a specific cron job
func (s *Service) GetByID(jobID, userID int64) (*models.CronJob, error) {
job := &models.CronJob{}
var lastRun, message *string
err := s.db.QueryRow(
`SELECT id, user_id, name, ssh_key_id, server_id, group_id, schedule, scheduled_at, next_run, last_run, remove_after_min, status, message, created_at,
timezone, time_of_day, day_of_week, day_of_month, minute_of_hour, target_user_id,
system_user, sudo, create_user, initial_password, expiry_action
FROM cron_jobs WHERE id = ? AND user_id = ?`, jobID, userID,
).Scan(&job.ID, &job.UserID, &job.Name, &job.SSHKeyID, &job.ServerID, &job.GroupID,
&job.Schedule, &job.ScheduledAt, &job.NextRun, &lastRun, &job.RemoveAfterMin,
&job.Status, &message, &job.CreatedAt,
&job.Timezone, &job.TimeOfDay, &job.DayOfWeek, &job.DayOfMonth, &job.MinuteOfHour,
&job.TargetUserID,
&job.SystemUser, &job.Sudo, &job.CreateUser, &job.InitialPassword, &job.ExpiryAction)
if err != nil {
return nil, fmt.Errorf("cron job not found: %w", err)
}
if lastRun != nil {
if t, ok := parseTimeString(*lastRun); ok {
job.LastRun = &t
}
}
if message != nil {
job.Message = *message
}
return job, nil
}
// Update updates a temporary access job
func (s *Service) Update(jobID, userID int64, name string, keyID, serverID, groupID int64, schedule string, scheduledAt time.Time, removeAfterMin int, tz, timeOfDay string, dayOfWeek, dayOfMonth, minuteOfHour int, targetUserID int64, systemUser string, sudo, createUser bool, initialPassword, expiryAction string) error {
if expiryAction == "" {
expiryAction = "remove_key"
}
job := models.CronJob{
UserID: userID,
Name: name,
SSHKeyID: keyID,
ServerID: serverID,
GroupID: groupID,
Schedule: schedule,
ScheduledAt: scheduledAt.UTC(),
RemoveAfterMin: removeAfterMin,
Timezone: tz,
TimeOfDay: timeOfDay,
DayOfWeek: dayOfWeek,
DayOfMonth: dayOfMonth,
MinuteOfHour: minuteOfHour,
TargetUserID: targetUserID,
SystemUser: systemUser,
Sudo: sudo,
CreateUser: createUser,
InitialPassword: initialPassword,
ExpiryAction: expiryAction,
}
nextRun := CalculateFirstRun(job)
result, err := s.db.Exec(
`UPDATE cron_jobs SET name=?, ssh_key_id=?, server_id=?, group_id=?, schedule=?, scheduled_at=?, next_run=?, remove_after_min=?, status='active',
timezone=?, time_of_day=?, day_of_week=?, day_of_month=?, minute_of_hour=?,
target_user_id=?, system_user=?, sudo=?, create_user=?, initial_password=?, expiry_action=?
WHERE id=? AND user_id=?`,
name, keyID, serverID, groupID, schedule, scheduledAt.UTC(), nextRun, removeAfterMin,
tz, timeOfDay, dayOfWeek, dayOfMonth, minuteOfHour,
targetUserID, systemUser, sudo, createUser, initialPassword, expiryAction,
jobID, userID,
)
if err != nil {
return fmt.Errorf("failed to update cron job: %w", err)
}
rows, _ := result.RowsAffected()
if rows == 0 {
return fmt.Errorf("cron job not found")
}
return nil
}
// Delete removes a cron job
func (s *Service) Delete(jobID, userID int64) error {
result, err := s.db.Exec(`DELETE FROM cron_jobs WHERE id = ? AND user_id = ?`, jobID, userID)
if err != nil {
return fmt.Errorf("failed to delete cron job: %w", err)
}
rows, _ := result.RowsAffected()
if rows == 0 {
return fmt.Errorf("cron job not found")
}
return nil
}
// TogglePause pauses or resumes a cron job
func (s *Service) TogglePause(jobID, userID int64) error {
job, err := s.GetByID(jobID, userID)
if err != nil {
return err
}
newStatus := "paused"
if job.Status == "paused" {
newStatus = "active"
// Recalculate next run when resuming
nextRun := CalculateNextRun(*job)
_, err = s.db.Exec(`UPDATE cron_jobs SET status = ?, next_run = ? WHERE id = ? AND user_id = ?`, newStatus, nextRun, jobID, userID)
return err
}
_, err = s.db.Exec(`UPDATE cron_jobs SET status = ? WHERE id = ? AND user_id = ?`, newStatus, jobID, userID)
return err
}
// GetPendingJobs returns jobs that are due for execution
func (s *Service) GetPendingJobs(now time.Time) ([]models.CronJob, error) {
rows, err := s.db.Query(
`SELECT id, user_id, name, ssh_key_id, server_id, group_id, schedule, scheduled_at, next_run, last_run, remove_after_min, status, message, created_at,
timezone, time_of_day, day_of_week, day_of_month, minute_of_hour, target_user_id,
system_user, sudo, create_user, initial_password, expiry_action
FROM cron_jobs
WHERE status = 'active' AND next_run <= ?
ORDER BY next_run ASC`, now.UTC(),
)
if err != nil {
return nil, fmt.Errorf("failed to query pending jobs: %w", err)
}
defer rows.Close()
var jobs []models.CronJob
for rows.Next() {
var j models.CronJob
var lastRun, message *string
if err := rows.Scan(&j.ID, &j.UserID, &j.Name, &j.SSHKeyID, &j.ServerID, &j.GroupID,
&j.Schedule, &j.ScheduledAt, &j.NextRun, &lastRun, &j.RemoveAfterMin,
&j.Status, &message, &j.CreatedAt,
&j.Timezone, &j.TimeOfDay, &j.DayOfWeek, &j.DayOfMonth, &j.MinuteOfHour,
&j.TargetUserID,
&j.SystemUser, &j.Sudo, &j.CreateUser, &j.InitialPassword, &j.ExpiryAction); err != nil {
continue
}
if lastRun != nil {
if t, ok := parseTimeString(*lastRun); ok {
j.LastRun = &t
}
}
if message != nil {
j.Message = *message
}
jobs = append(jobs, j)
}
return jobs, nil
}
// CountByUser returns total cron jobs for a user
func (s *Service) CountByUser(userID int64) int {
var count int
s.db.QueryRow(`SELECT COUNT(*) FROM cron_jobs WHERE user_id = ?`, userID).Scan(&count)
return count
}
// parseTimeString tries multiple time formats to parse a SQLite datetime string
func parseTimeString(s string) (time.Time, bool) {
for _, layout := range []string{
time.RFC3339Nano,
time.RFC3339,
"2006-01-02 15:04:05.999999999-07:00",
"2006-01-02 15:04:05-07:00",
"2006-01-02T15:04:05",
"2006-01-02 15:04:05",
} {
if t, err := time.Parse(layout, s); err == nil {
return t.UTC(), true
}
}
return time.Time{}, false
}
// generateInitialPassword generates a random alphanumeric password
func generateInitialPassword(length int) string {
const charset = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789"
b := make([]byte, length)
rand.Read(b)
for i := range b {
b[i] = charset[int(b[i])%len(charset)]
}
return string(b)
}
+293
View File
@@ -0,0 +1,293 @@
// Keywarden - Centralized SSH Key Management and Deployment
// Copyright (C) 2026 Patrick Asmus (scriptos)
// SPDX-License-Identifier: AGPL-3.0-or-later
package database
import (
"crypto/aes"
"crypto/cipher"
"crypto/rand"
"crypto/sha256"
"database/sql"
"encoding/json"
"fmt"
"io"
"time"
)
// BackupData contains all exportable data from the database
type BackupData struct {
Version string `json:"version"`
CreatedAt string `json:"created_at"`
Users []map[string]interface{} `json:"users"`
SSHKeys []map[string]interface{} `json:"ssh_keys"`
Servers []map[string]interface{} `json:"servers"`
ServerGroups []map[string]interface{} `json:"server_groups"`
GroupMembers []map[string]interface{} `json:"server_group_members"`
KeyDeployments []map[string]interface{} `json:"key_deployments"`
AuditLog []map[string]interface{} `json:"audit_log"`
Settings []map[string]interface{} `json:"settings"`
AccessAssign []map[string]interface{} `json:"access_assignments"`
CronJobs []map[string]interface{} `json:"cron_jobs"`
}
// ExportAll exports all database tables to a BackupData struct
func (d *DB) ExportAll() (*BackupData, error) {
backup := &BackupData{
Version: "1",
CreatedAt: time.Now().UTC().Format(time.RFC3339),
}
tables := []struct {
query string
dest *[]map[string]interface{}
}{
{`SELECT id, username, email, password_hash, role, mfa_enabled, mfa_secret, theme, email_notify_login, avatar_base64, must_change_password, failed_login_attempts, locked_until, last_login_at, created_at, updated_at FROM users ORDER BY id`, &backup.Users},
{`SELECT id, user_id, name, key_type, bits, fingerprint, public_key, private_key_enc, passphrase_enc, created_at FROM ssh_keys ORDER BY id`, &backup.SSHKeys},
{`SELECT id, user_id, name, hostname, port, username, description, created_at, updated_at FROM servers ORDER BY id`, &backup.Servers},
{`SELECT id, user_id, name, description, created_at, updated_at FROM server_groups ORDER BY id`, &backup.ServerGroups},
{`SELECT id, group_id, server_id FROM server_group_members ORDER BY id`, &backup.GroupMembers},
{`SELECT id, ssh_key_id, server_id, deployed_at, status, message FROM key_deployments ORDER BY id`, &backup.KeyDeployments},
{`SELECT id, user_id, action, details, ip_address, created_at FROM audit_log ORDER BY id`, &backup.AuditLog},
{`SELECT key, value, updated_at FROM settings ORDER BY key`, &backup.Settings},
{`SELECT id, user_id, ssh_key_id, server_id, group_id, system_user, desired_state, sudo, create_user, initial_password, status, last_sync_at, created_at, updated_at FROM access_assignments ORDER BY id`, &backup.AccessAssign},
{`SELECT id, user_id, name, ssh_key_id, server_id, group_id, schedule, scheduled_at, next_run, last_run, remove_after_min, status, message, timezone, time_of_day, day_of_week, day_of_month, minute_of_hour, target_user_id, system_user, sudo, create_user, initial_password, expiry_action, created_at FROM cron_jobs ORDER BY id`, &backup.CronJobs},
}
for _, t := range tables {
rows, err := d.Query(t.query)
if err != nil {
return nil, fmt.Errorf("failed to query table: %w", err)
}
data, err := rowsToMaps(rows)
rows.Close()
if err != nil {
return nil, fmt.Errorf("failed to read rows: %w", err)
}
*t.dest = data
}
return backup, nil
}
// ImportAll restores all database tables from a BackupData struct.
// It clears existing data and replaces it with the backup data.
func (d *DB) ImportAll(backup *BackupData) error {
tx, err := d.Begin()
if err != nil {
return fmt.Errorf("failed to begin transaction: %w", err)
}
defer tx.Rollback()
// Disable foreign key checks during import
if _, err := tx.Exec(`PRAGMA foreign_keys = OFF`); err != nil {
return fmt.Errorf("failed to disable foreign keys: %w", err)
}
// Clear all tables in dependency order
clearOrder := []string{
"cron_jobs",
"access_assignments",
"key_deployments",
"server_group_members",
"server_groups",
"ssh_keys",
"servers",
"audit_log",
"settings",
"users",
}
for _, table := range clearOrder {
if _, err := tx.Exec(fmt.Sprintf("DELETE FROM %s", table)); err != nil {
return fmt.Errorf("failed to clear table %s: %w", table, err)
}
}
// Import tables in reverse dependency order (parents first)
importOrder := []struct {
table string
columns []string
data []map[string]interface{}
}{
{"users", []string{"id", "username", "email", "password_hash", "role", "mfa_enabled", "mfa_secret", "theme", "email_notify_login", "avatar_base64", "must_change_password", "failed_login_attempts", "locked_until", "last_login_at", "created_at", "updated_at"}, backup.Users},
{"ssh_keys", []string{"id", "user_id", "name", "key_type", "bits", "fingerprint", "public_key", "private_key_enc", "passphrase_enc", "created_at"}, backup.SSHKeys},
{"servers", []string{"id", "user_id", "name", "hostname", "port", "username", "description", "created_at", "updated_at"}, backup.Servers},
{"server_groups", []string{"id", "user_id", "name", "description", "created_at", "updated_at"}, backup.ServerGroups},
{"server_group_members", []string{"id", "group_id", "server_id"}, backup.GroupMembers},
{"key_deployments", []string{"id", "ssh_key_id", "server_id", "deployed_at", "status", "message"}, backup.KeyDeployments},
{"audit_log", []string{"id", "user_id", "action", "details", "ip_address", "created_at"}, backup.AuditLog},
{"settings", []string{"key", "value", "updated_at"}, backup.Settings},
{"access_assignments", []string{"id", "user_id", "ssh_key_id", "server_id", "group_id", "system_user", "desired_state", "sudo", "create_user", "initial_password", "status", "last_sync_at", "created_at", "updated_at"}, backup.AccessAssign},
{"cron_jobs", []string{"id", "user_id", "name", "ssh_key_id", "server_id", "group_id", "schedule", "scheduled_at", "next_run", "last_run", "remove_after_min", "status", "message", "timezone", "time_of_day", "day_of_week", "day_of_month", "minute_of_hour", "target_user_id", "system_user", "sudo", "create_user", "initial_password", "expiry_action", "created_at"}, backup.CronJobs},
}
for _, imp := range importOrder {
if len(imp.data) == 0 {
continue
}
// Build INSERT statement
placeholders := ""
for i := range imp.columns {
if i > 0 {
placeholders += ", "
}
placeholders += "?"
}
query := fmt.Sprintf("INSERT INTO %s (%s) VALUES (%s)", imp.table, joinColumns(imp.columns), placeholders)
stmt, err := tx.Prepare(query)
if err != nil {
return fmt.Errorf("failed to prepare insert for %s: %w", imp.table, err)
}
for _, row := range imp.data {
args := make([]interface{}, len(imp.columns))
for i, col := range imp.columns {
args[i] = row[col]
}
if _, err := stmt.Exec(args...); err != nil {
stmt.Close()
return fmt.Errorf("failed to insert into %s: %w", imp.table, err)
}
}
stmt.Close()
}
// Re-enable foreign key checks
if _, err := tx.Exec(`PRAGMA foreign_keys = ON`); err != nil {
return fmt.Errorf("failed to re-enable foreign keys: %w", err)
}
if err := tx.Commit(); err != nil {
return fmt.Errorf("failed to commit transaction: %w", err)
}
return nil
}
// EncryptBackup encrypts JSON backup data with AES-256-GCM using the given password
func EncryptBackup(data []byte, password string) ([]byte, error) {
key := sha256.Sum256([]byte(password))
block, err := aes.NewCipher(key[:])
if err != nil {
return nil, fmt.Errorf("failed to create cipher: %w", err)
}
gcm, err := cipher.NewGCM(block)
if err != nil {
return nil, fmt.Errorf("failed to create GCM: %w", err)
}
nonce := make([]byte, gcm.NonceSize())
if _, err := io.ReadFull(rand.Reader, nonce); err != nil {
return nil, fmt.Errorf("failed to generate nonce: %w", err)
}
// Prepend a magic header so we can identify backup files
magic := []byte("KWBAK1") // Keywarden Backup v1
ciphertext := gcm.Seal(nonce, nonce, data, nil)
result := append(magic, ciphertext...)
return result, nil
}
// DecryptBackup decrypts an AES-256-GCM encrypted backup with the given password
func DecryptBackup(encrypted []byte, password string) ([]byte, error) {
// Check magic header
magic := []byte("KWBAK1")
if len(encrypted) < len(magic) {
return nil, fmt.Errorf("invalid backup file: too short")
}
if string(encrypted[:len(magic)]) != string(magic) {
return nil, fmt.Errorf("invalid backup file: wrong format")
}
encrypted = encrypted[len(magic):]
key := sha256.Sum256([]byte(password))
block, err := aes.NewCipher(key[:])
if err != nil {
return nil, fmt.Errorf("failed to create cipher: %w", err)
}
gcm, err := cipher.NewGCM(block)
if err != nil {
return nil, fmt.Errorf("failed to create GCM: %w", err)
}
nonceSize := gcm.NonceSize()
if len(encrypted) < nonceSize {
return nil, fmt.Errorf("invalid backup file: ciphertext too short")
}
nonce, ciphertext := encrypted[:nonceSize], encrypted[nonceSize:]
plaintext, err := gcm.Open(nil, nonce, ciphertext, nil)
if err != nil {
return nil, fmt.Errorf("decryption failed: wrong password or corrupted file")
}
return plaintext, nil
}
// rowsToMaps converts sql.Rows to a slice of maps
func rowsToMaps(rows *sql.Rows) ([]map[string]interface{}, error) {
columns, err := rows.Columns()
if err != nil {
return nil, err
}
var result []map[string]interface{}
for rows.Next() {
values := make([]interface{}, len(columns))
valuePtrs := make([]interface{}, len(columns))
for i := range values {
valuePtrs[i] = &values[i]
}
if err := rows.Scan(valuePtrs...); err != nil {
return nil, err
}
row := make(map[string]interface{})
for i, col := range columns {
val := values[i]
// Convert byte slices to strings for JSON serialization
if b, ok := val.([]byte); ok {
row[col] = string(b)
} else {
row[col] = val
}
}
result = append(result, row)
}
if result == nil {
result = []map[string]interface{}{}
}
return result, rows.Err()
}
// joinColumns joins column names with commas
func joinColumns(cols []string) string {
result := ""
for i, col := range cols {
if i > 0 {
result += ", "
}
result += col
}
return result
}
// ParseBackupJSON parses decrypted JSON data into a BackupData struct
func ParseBackupJSON(data []byte) (*BackupData, error) {
var backup BackupData
if err := json.Unmarshal(data, &backup); err != nil {
return nil, fmt.Errorf("failed to parse backup data: %w", err)
}
if backup.Version == "" {
return nil, fmt.Errorf("invalid backup: missing version")
}
return &backup, nil
}
+101
View File
@@ -0,0 +1,101 @@
// Keywarden - Centralized SSH Key Management and Deployment
// Copyright (C) 2026 Patrick Asmus (scriptos)
// SPDX-License-Identifier: AGPL-3.0-or-later
package database
import (
"encoding/json"
"testing"
)
func TestEncryptDecryptBackup(t *testing.T) {
password := "TestP@ssw0rd123!"
original := &BackupData{
Version: "1",
CreatedAt: "2026-04-05T12:00:00Z",
Users: []map[string]interface{}{
{"id": float64(1), "username": "admin", "email": "admin@test.local"},
},
SSHKeys: []map[string]interface{}{},
Servers: []map[string]interface{}{},
ServerGroups: []map[string]interface{}{},
GroupMembers: []map[string]interface{}{},
}
jsonData, err := json.Marshal(original)
if err != nil {
t.Fatalf("Failed to marshal: %v", err)
}
encrypted, err := EncryptBackup(jsonData, password)
if err != nil {
t.Fatalf("Failed to encrypt: %v", err)
}
// Check magic header
if string(encrypted[:6]) != "KWBAK1" {
t.Error("Missing KWBAK1 magic header")
}
// Decrypt with correct password
decrypted, err := DecryptBackup(encrypted, password)
if err != nil {
t.Fatalf("Failed to decrypt: %v", err)
}
// Verify content matches
var restored BackupData
if err := json.Unmarshal(decrypted, &restored); err != nil {
t.Fatalf("Failed to unmarshal decrypted data: %v", err)
}
if restored.Version != original.Version {
t.Errorf("Version mismatch: got %s, want %s", restored.Version, original.Version)
}
if len(restored.Users) != 1 {
t.Errorf("Expected 1 user, got %d", len(restored.Users))
}
// Wrong password should fail
_, err = DecryptBackup(encrypted, "wrong-password")
if err == nil {
t.Error("Expected error with wrong password, got nil")
}
}
func TestDecryptBackupInvalidFile(t *testing.T) {
_, err := DecryptBackup([]byte("invalid data"), "password")
if err == nil {
t.Error("Expected error for invalid file, got nil")
}
_, err = DecryptBackup([]byte("short"), "password")
if err == nil {
t.Error("Expected error for too-short data, got nil")
}
}
func TestParseBackupJSON(t *testing.T) {
valid := `{"version":"1","created_at":"2026-04-05T12:00:00Z","users":[],"ssh_keys":[],"servers":[],"server_groups":[],"server_group_members":[],"key_deployments":[],"audit_log":[],"settings":[],"access_assignments":[],"cron_jobs":[]}`
backup, err := ParseBackupJSON([]byte(valid))
if err != nil {
t.Fatalf("Failed to parse valid JSON: %v", err)
}
if backup.Version != "1" {
t.Errorf("Version mismatch: got %s, want 1", backup.Version)
}
// Missing version
invalid := `{"created_at":"2026-04-05T12:00:00Z"}`
_, err = ParseBackupJSON([]byte(invalid))
if err == nil {
t.Error("Expected error for missing version, got nil")
}
// Invalid JSON
_, err = ParseBackupJSON([]byte("not json"))
if err == nil {
t.Error("Expected error for invalid JSON, got nil")
}
}
+250
View File
@@ -0,0 +1,250 @@
// Keywarden - Centralized SSH Key Management and Deployment
// Copyright (C) 2026 Patrick Asmus (scriptos)
// SPDX-License-Identifier: AGPL-3.0-or-later
package database
import (
"database/sql"
"fmt"
"os"
"path/filepath"
_ "github.com/mattn/go-sqlite3"
)
// DB wraps the sql.DB connection
type DB struct {
*sql.DB
}
// New creates a new database connection and runs migrations
func New(dbPath string) (*DB, error) {
// Ensure directory exists
dir := filepath.Dir(dbPath)
if err := os.MkdirAll(dir, 0700); err != nil {
return nil, fmt.Errorf("failed to create database directory: %w", err)
}
db, err := sql.Open("sqlite3", dbPath+"?_journal_mode=WAL&_foreign_keys=on&_busy_timeout=5000")
if err != nil {
return nil, fmt.Errorf("failed to open database: %w", err)
}
if err := db.Ping(); err != nil {
return nil, fmt.Errorf("failed to ping database: %w", err)
}
d := &DB{db}
if err := d.migrate(); err != nil {
return nil, fmt.Errorf("failed to run migrations: %w", err)
}
return d, nil
}
// migrate creates all required tables
func (d *DB) migrate() error {
migrations := []string{
`CREATE TABLE IF NOT EXISTS users (
id INTEGER PRIMARY KEY AUTOINCREMENT,
username TEXT UNIQUE NOT NULL,
email TEXT UNIQUE NOT NULL,
password_hash TEXT NOT NULL,
role TEXT NOT NULL DEFAULT 'user',
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP
)`,
`CREATE TABLE IF NOT EXISTS ssh_keys (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER NOT NULL,
name TEXT NOT NULL,
key_type TEXT NOT NULL,
bits INTEGER,
fingerprint TEXT NOT NULL,
public_key TEXT NOT NULL,
private_key_enc TEXT NOT NULL,
passphrase_enc TEXT,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
)`,
`CREATE TABLE IF NOT EXISTS servers (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER NOT NULL,
name TEXT NOT NULL,
hostname TEXT NOT NULL,
port INTEGER NOT NULL DEFAULT 22,
username TEXT NOT NULL,
description TEXT,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
)`,
`CREATE TABLE IF NOT EXISTS key_deployments (
id INTEGER PRIMARY KEY AUTOINCREMENT,
ssh_key_id INTEGER NOT NULL,
server_id INTEGER NOT NULL,
deployed_at DATETIME DEFAULT CURRENT_TIMESTAMP,
status TEXT NOT NULL DEFAULT 'pending',
message TEXT,
FOREIGN KEY (ssh_key_id) REFERENCES ssh_keys(id) ON DELETE CASCADE,
FOREIGN KEY (server_id) REFERENCES servers(id) ON DELETE CASCADE
)`,
`CREATE TABLE IF NOT EXISTS audit_log (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER,
action TEXT NOT NULL,
details TEXT,
ip_address TEXT,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE SET NULL
)`,
`CREATE TABLE IF NOT EXISTS settings (
key TEXT PRIMARY KEY,
value TEXT NOT NULL,
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP
)`,
// Migration: add MFA columns to users if not present
`CREATE TABLE IF NOT EXISTS _migrations (id INTEGER PRIMARY KEY, name TEXT)`,
`CREATE TABLE IF NOT EXISTS server_groups (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER NOT NULL,
name TEXT NOT NULL,
description TEXT,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
)`,
`CREATE TABLE IF NOT EXISTS server_group_members (
id INTEGER PRIMARY KEY AUTOINCREMENT,
group_id INTEGER NOT NULL,
server_id INTEGER NOT NULL,
FOREIGN KEY (group_id) REFERENCES server_groups(id) ON DELETE CASCADE,
FOREIGN KEY (server_id) REFERENCES servers(id) ON DELETE CASCADE,
UNIQUE(group_id, server_id)
)`,
`CREATE TABLE IF NOT EXISTS access_assignments (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER NOT NULL,
ssh_key_id INTEGER NOT NULL,
server_id INTEGER DEFAULT 0,
group_id INTEGER DEFAULT 0,
system_user TEXT NOT NULL,
desired_state TEXT NOT NULL DEFAULT 'present',
sudo INTEGER NOT NULL DEFAULT 0,
create_user INTEGER NOT NULL DEFAULT 0,
status TEXT NOT NULL DEFAULT 'pending',
last_sync_at DATETIME,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE,
FOREIGN KEY (ssh_key_id) REFERENCES ssh_keys(id) ON DELETE CASCADE
)`,
`CREATE TABLE IF NOT EXISTS cron_jobs (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER NOT NULL,
name TEXT NOT NULL,
ssh_key_id INTEGER NOT NULL,
server_id INTEGER DEFAULT 0,
group_id INTEGER DEFAULT 0,
schedule TEXT NOT NULL DEFAULT 'once',
scheduled_at DATETIME NOT NULL,
next_run DATETIME NOT NULL,
last_run DATETIME,
remove_after_min INTEGER NOT NULL DEFAULT 0,
status TEXT NOT NULL DEFAULT 'active',
message TEXT,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE,
FOREIGN KEY (ssh_key_id) REFERENCES ssh_keys(id) ON DELETE CASCADE
)`,
}
for _, m := range migrations {
if _, err := d.Exec(m); err != nil {
return fmt.Errorf("migration failed: %w", err)
}
}
// Conditional migrations (ALTER TABLE)
alterMigrations := map[string]string{
"add_mfa_enabled": `ALTER TABLE users ADD COLUMN mfa_enabled INTEGER NOT NULL DEFAULT 0`,
"add_mfa_secret": `ALTER TABLE users ADD COLUMN mfa_secret TEXT DEFAULT ''`,
"add_is_master_key": `ALTER TABLE ssh_keys ADD COLUMN is_master INTEGER NOT NULL DEFAULT 0`,
"add_user_theme": `ALTER TABLE users ADD COLUMN theme TEXT NOT NULL DEFAULT 'auto'`,
"add_email_notify_login": `ALTER TABLE users ADD COLUMN email_notify_login INTEGER NOT NULL DEFAULT 0`,
"add_avatar_base64": `ALTER TABLE users ADD COLUMN avatar_base64 TEXT NOT NULL DEFAULT ''`,
"add_cron_auth_key_id": `ALTER TABLE cron_jobs ADD COLUMN auth_key_id INTEGER NOT NULL DEFAULT 0`,
"add_initial_password": `ALTER TABLE access_assignments ADD COLUMN initial_password TEXT NOT NULL DEFAULT ''`,
"add_cron_timezone": `ALTER TABLE cron_jobs ADD COLUMN timezone TEXT NOT NULL DEFAULT 'UTC'`,
"add_cron_time_of_day": `ALTER TABLE cron_jobs ADD COLUMN time_of_day TEXT NOT NULL DEFAULT '00:00'`,
"add_cron_day_of_week": `ALTER TABLE cron_jobs ADD COLUMN day_of_week INTEGER NOT NULL DEFAULT -1`,
"add_cron_day_of_month": `ALTER TABLE cron_jobs ADD COLUMN day_of_month INTEGER NOT NULL DEFAULT 0`,
"add_cron_minute_of_hour": `ALTER TABLE cron_jobs ADD COLUMN minute_of_hour INTEGER NOT NULL DEFAULT 0`,
"add_cron_target_user_id": `ALTER TABLE cron_jobs ADD COLUMN target_user_id INTEGER NOT NULL DEFAULT 0`,
"add_cron_assignment_id": `ALTER TABLE cron_jobs ADD COLUMN assignment_id INTEGER NOT NULL DEFAULT 0`,
"add_cron_system_user": `ALTER TABLE cron_jobs ADD COLUMN system_user TEXT NOT NULL DEFAULT ''`,
"add_cron_sudo": `ALTER TABLE cron_jobs ADD COLUMN sudo INTEGER NOT NULL DEFAULT 0`,
"add_cron_create_user": `ALTER TABLE cron_jobs ADD COLUMN create_user INTEGER NOT NULL DEFAULT 0`,
"add_cron_init_password": `ALTER TABLE cron_jobs ADD COLUMN initial_password TEXT NOT NULL DEFAULT ''`,
"add_cron_expiry_action": `ALTER TABLE cron_jobs ADD COLUMN expiry_action TEXT NOT NULL DEFAULT 'remove_key'`,
"add_must_change_password": `ALTER TABLE users ADD COLUMN must_change_password INTEGER NOT NULL DEFAULT 0`,
"add_failed_login_attempts": `ALTER TABLE users ADD COLUMN failed_login_attempts INTEGER NOT NULL DEFAULT 0`,
"add_locked_until": `ALTER TABLE users ADD COLUMN locked_until DATETIME`,
"add_last_login_at": `ALTER TABLE users ADD COLUMN last_login_at DATETIME`,
}
// Invitation tokens table (created via migration to avoid altering initial schema)
inviteTableMigration := map[string]string{
"create_invitation_tokens": `CREATE TABLE IF NOT EXISTS invitation_tokens (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER NOT NULL,
token TEXT UNIQUE NOT NULL,
expires_at DATETIME NOT NULL,
used INTEGER NOT NULL DEFAULT 0,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
)`,
}
for name, sql := range inviteTableMigration {
var count int
d.QueryRow(`SELECT COUNT(*) FROM _migrations WHERE name = ?`, name).Scan(&count)
if count == 0 {
if _, err := d.Exec(sql); err != nil {
return fmt.Errorf("migration %s failed: %w", name, err)
}
d.Exec(`INSERT INTO _migrations (name) VALUES (?)`, name)
}
}
for name, sql := range alterMigrations {
var count int
d.QueryRow(`SELECT COUNT(*) FROM _migrations WHERE name = ?`, name).Scan(&count)
if count == 0 {
d.Exec(sql) // ignore error if column already exists
d.Exec(`INSERT INTO _migrations (name) VALUES (?)`, name)
}
}
// Role model migration: promote first admin to owner if no owner exists yet
{
var migCount int
d.QueryRow(`SELECT COUNT(*) FROM _migrations WHERE name = 'promote_admin_to_owner'`).Scan(&migCount)
if migCount == 0 {
var ownerCount int
d.QueryRow(`SELECT COUNT(*) FROM users WHERE role = 'owner'`).Scan(&ownerCount)
if ownerCount == 0 {
// Find the first admin (by ID) and promote to owner
var firstAdminID int64
err := d.QueryRow(`SELECT id FROM users WHERE role = 'admin' ORDER BY id ASC LIMIT 1`).Scan(&firstAdminID)
if err == nil && firstAdminID > 0 {
d.Exec(`UPDATE users SET role = 'owner' WHERE id = ?`, firstAdminID)
}
}
d.Exec(`INSERT INTO _migrations (name) VALUES ('promote_admin_to_owner')`)
}
}
return nil
}
+178
View File
@@ -0,0 +1,178 @@
// Keywarden - Centralized SSH Key Management and Deployment
// Copyright (C) 2026 Patrick Asmus (scriptos)
// SPDX-License-Identifier: AGPL-3.0-or-later
//go:build integration
package database
import (
"os"
"path/filepath"
"testing"
)
func TestNewDatabase(t *testing.T) {
tmpDir := t.TempDir()
dbPath := filepath.Join(tmpDir, "test.db")
db, err := New(dbPath)
if err != nil {
t.Fatalf("New() failed: %v", err)
}
defer db.Close()
// Verify file was created
if _, err := os.Stat(dbPath); os.IsNotExist(err) {
t.Fatal("Database file was not created")
}
}
func TestMigrationsCreateTables(t *testing.T) {
tmpDir := t.TempDir()
db, err := New(filepath.Join(tmpDir, "test.db"))
if err != nil {
t.Fatalf("New() failed: %v", err)
}
defer db.Close()
tables := []string{"users", "ssh_keys", "servers", "key_deployments", "audit_log", "settings", "_migrations"}
for _, table := range tables {
var count int
err := db.QueryRow(`SELECT COUNT(*) FROM sqlite_master WHERE type='table' AND name=?`, table).Scan(&count)
if err != nil {
t.Fatalf("Failed to check table %s: %v", table, err)
}
if count == 0 {
t.Fatalf("Table %q was not created", table)
}
}
}
func TestMigrationsIdempotent(t *testing.T) {
tmpDir := t.TempDir()
dbPath := filepath.Join(tmpDir, "test.db")
// Run migrations twice (opening creates + migrates)
db1, err := New(dbPath)
if err != nil {
t.Fatalf("New() first call failed: %v", err)
}
db1.Close()
db2, err := New(dbPath)
if err != nil {
t.Fatalf("New() second call failed (migrations should be idempotent): %v", err)
}
defer db2.Close()
}
func TestAlterMigrationsTracked(t *testing.T) {
tmpDir := t.TempDir()
db, err := New(filepath.Join(tmpDir, "test.db"))
if err != nil {
t.Fatalf("New() failed: %v", err)
}
defer db.Close()
// Check that alter migrations are tracked
expectedMigrations := []string{"add_mfa_enabled", "add_mfa_secret", "add_is_master_key"}
for _, name := range expectedMigrations {
var count int
err := db.QueryRow(`SELECT COUNT(*) FROM _migrations WHERE name = ?`, name).Scan(&count)
if err != nil {
t.Fatalf("Failed to check migration %s: %v", name, err)
}
if count == 0 {
t.Fatalf("Migration %q was not tracked", name)
}
}
}
func TestInsertAndQueryUser(t *testing.T) {
tmpDir := t.TempDir()
db, err := New(filepath.Join(tmpDir, "test.db"))
if err != nil {
t.Fatalf("New() failed: %v", err)
}
defer db.Close()
result, err := db.Exec(
`INSERT INTO users (username, email, password_hash, role) VALUES (?, ?, ?, ?)`,
"testuser", "test@example.com", "hash123", "user",
)
if err != nil {
t.Fatalf("Insert user failed: %v", err)
}
id, _ := result.LastInsertId()
if id == 0 {
t.Fatal("Expected non-zero insert ID")
}
var username string
err = db.QueryRow(`SELECT username FROM users WHERE id = ?`, id).Scan(&username)
if err != nil {
t.Fatalf("Query user failed: %v", err)
}
if username != "testuser" {
t.Fatalf("Expected username 'testuser', got %q", username)
}
}
func TestSSHKeyWithMasterFlag(t *testing.T) {
tmpDir := t.TempDir()
db, err := New(filepath.Join(tmpDir, "test.db"))
if err != nil {
t.Fatalf("New() failed: %v", err)
}
defer db.Close()
// Create a user first
db.Exec(`INSERT INTO users (username, email, password_hash, role) VALUES (?, ?, ?, ?)`,
"testuser", "test@example.com", "hash", "user")
// Insert a master key
_, err = db.Exec(
`INSERT INTO ssh_keys (user_id, name, key_type, bits, fingerprint, public_key, private_key_enc, is_master)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)`,
1, "Master Key", "ed25519", 256, "SHA256:test", "pubkey", "encpriv", 1,
)
if err != nil {
t.Fatalf("Insert master key failed: %v", err)
}
// Verify is_master flag
var isMaster int
err = db.QueryRow(`SELECT is_master FROM ssh_keys WHERE user_id = 1 AND is_master = 1`).Scan(&isMaster)
if err != nil {
t.Fatalf("Query master key failed: %v", err)
}
if isMaster != 1 {
t.Fatalf("Expected is_master=1, got %d", isMaster)
}
}
func TestForeignKeyCascade(t *testing.T) {
tmpDir := t.TempDir()
db, err := New(filepath.Join(tmpDir, "test.db"))
if err != nil {
t.Fatalf("New() failed: %v", err)
}
defer db.Close()
// Insert user and key
db.Exec(`INSERT INTO users (username, email, password_hash, role) VALUES (?, ?, ?, ?)`,
"testuser", "test@example.com", "hash", "user")
db.Exec(`INSERT INTO ssh_keys (user_id, name, key_type, bits, fingerprint, public_key, private_key_enc)
VALUES (?, ?, ?, ?, ?, ?, ?)`, 1, "Test", "ed25519", 256, "fp", "pub", "priv")
// Delete user — key should cascade
db.Exec(`DELETE FROM users WHERE id = 1`)
var count int
db.QueryRow(`SELECT COUNT(*) FROM ssh_keys WHERE user_id = 1`).Scan(&count)
if count != 0 {
t.Fatalf("Expected 0 keys after user deletion (cascade), got %d", count)
}
}
+657
View File
@@ -0,0 +1,657 @@
// Keywarden - Centralized SSH Key Management and Deployment
// Copyright (C) 2026 Patrick Asmus (scriptos)
// SPDX-License-Identifier: AGPL-3.0-or-later
package deploy
import (
"fmt"
"net"
"strings"
"time"
"git.techniverse.net/scriptos/keywarden/internal/database"
"git.techniverse.net/scriptos/keywarden/internal/logging"
"git.techniverse.net/scriptos/keywarden/internal/models"
"golang.org/x/crypto/ssh"
)
// Service handles deploying SSH keys to remote servers
type Service struct {
db *database.DB
}
// NewService creates a new deploy service
func NewService(db *database.DB) *Service {
return &Service{db: db}
}
// DeployKey deploys a public key to a remote server's authorized_keys
func (s *Service) DeployKey(key *models.SSHKey, server *models.Server, authPrivateKey []byte) error {
logging.Debug("Deploy: connecting to %s@%s:%d with key auth for key '%s'", server.Username, server.Hostname, server.Port, key.Name)
// Parse the private key used for authentication
signer, err := ssh.ParsePrivateKey(authPrivateKey)
if err != nil {
return fmt.Errorf("failed to parse authentication key: %w", err)
}
config := &ssh.ClientConfig{
User: server.Username,
Auth: []ssh.AuthMethod{
ssh.PublicKeys(signer),
},
HostKeyCallback: ssh.InsecureIgnoreHostKey(), // TODO: implement known_hosts
Timeout: 10 * time.Second,
}
addr := fmt.Sprintf("%s:%d", server.Hostname, server.Port)
client, err := ssh.Dial("tcp", addr, config)
if err != nil {
s.logDeployment(key.ID, server.ID, "failed", fmt.Sprintf("connection failed: %v", err))
return fmt.Errorf("failed to connect to server: %w", err)
}
defer client.Close()
session, err := client.NewSession()
if err != nil {
s.logDeployment(key.ID, server.ID, "failed", fmt.Sprintf("session failed: %v", err))
return fmt.Errorf("failed to create session: %w", err)
}
defer session.Close()
// Clean the public key (remove trailing newline)
pubKey := strings.TrimSpace(key.PublicKey)
// Append to authorized_keys
cmd := fmt.Sprintf(
`mkdir -p ~/.ssh && chmod 700 ~/.ssh && echo '%s' >> ~/.ssh/authorized_keys && chmod 600 ~/.ssh/authorized_keys && sort -u -o ~/.ssh/authorized_keys ~/.ssh/authorized_keys`,
pubKey,
)
if err := session.Run(cmd); err != nil {
s.logDeployment(key.ID, server.ID, "failed", fmt.Sprintf("command failed: %v", err))
return fmt.Errorf("failed to deploy key: %w", err)
}
s.logDeployment(key.ID, server.ID, "success", "key deployed successfully")
return nil
}
// DeployKeyWithPassword deploys a public key using password authentication
func (s *Service) DeployKeyWithPassword(key *models.SSHKey, server *models.Server, password string) error {
logging.Debug("Deploy: connecting to %s@%s:%d with password auth for key '%s'", server.Username, server.Hostname, server.Port, key.Name)
config := &ssh.ClientConfig{
User: server.Username,
Auth: []ssh.AuthMethod{
ssh.Password(password),
},
HostKeyCallback: ssh.InsecureIgnoreHostKey(),
Timeout: 10 * time.Second,
}
addr := fmt.Sprintf("%s:%d", server.Hostname, server.Port)
client, err := ssh.Dial("tcp", addr, config)
if err != nil {
s.logDeployment(key.ID, server.ID, "failed", fmt.Sprintf("connection failed: %v", err))
return fmt.Errorf("failed to connect to server: %w", err)
}
defer client.Close()
session, err := client.NewSession()
if err != nil {
s.logDeployment(key.ID, server.ID, "failed", fmt.Sprintf("session failed: %v", err))
return fmt.Errorf("failed to create session: %w", err)
}
defer session.Close()
pubKey := strings.TrimSpace(key.PublicKey)
cmd := fmt.Sprintf(
`mkdir -p ~/.ssh && chmod 700 ~/.ssh && echo '%s' >> ~/.ssh/authorized_keys && chmod 600 ~/.ssh/authorized_keys && sort -u -o ~/.ssh/authorized_keys ~/.ssh/authorized_keys`,
pubKey,
)
if err := session.Run(cmd); err != nil {
s.logDeployment(key.ID, server.ID, "failed", fmt.Sprintf("command failed: %v", err))
return fmt.Errorf("failed to deploy key: %w", err)
}
s.logDeployment(key.ID, server.ID, "success", "key deployed successfully")
return nil
}
// RemoveKey removes a public key from a remote server's authorized_keys
func (s *Service) RemoveKey(key *models.SSHKey, server *models.Server, authPrivateKey []byte) error {
logging.Debug("Deploy: removing key '%s' from %s@%s:%d", key.Name, server.Username, server.Hostname, server.Port)
signer, err := ssh.ParsePrivateKey(authPrivateKey)
if err != nil {
return fmt.Errorf("failed to parse authentication key: %w", err)
}
config := &ssh.ClientConfig{
User: server.Username,
Auth: []ssh.AuthMethod{
ssh.PublicKeys(signer),
},
HostKeyCallback: ssh.InsecureIgnoreHostKey(),
Timeout: 10 * time.Second,
}
addr := fmt.Sprintf("%s:%d", server.Hostname, server.Port)
client, err := ssh.Dial("tcp", addr, config)
if err != nil {
return fmt.Errorf("failed to connect to server for key removal: %w", err)
}
defer client.Close()
session, err := client.NewSession()
if err != nil {
return fmt.Errorf("failed to create session for key removal: %w", err)
}
defer session.Close()
pubKey := strings.TrimSpace(key.PublicKey)
// Escape single quotes in the key for safe sed usage
escapedKey := strings.ReplaceAll(pubKey, "'", "'\\''")
cmd := fmt.Sprintf(
`grep -v '%s' ~/.ssh/authorized_keys > ~/.ssh/authorized_keys.tmp 2>/dev/null && mv ~/.ssh/authorized_keys.tmp ~/.ssh/authorized_keys && chmod 600 ~/.ssh/authorized_keys || true`,
escapedKey,
)
if err := session.Run(cmd); err != nil {
return fmt.Errorf("failed to remove key: %w", err)
}
s.logDeployment(key.ID, server.ID, "success", "key removed successfully")
return nil
}
// DeployKeyToUser deploys a public key to a specific system user's authorized_keys.
// It connects to the server as the server's admin user and manages the target systemUser.
// If createUser is true, the system user will be created if it doesn't exist.
// If sudo is true, a sudoers.d entry with NOPASSWD will be created.
// If initialPassword is set and createUser is true, the password will be set on the system user.
func (s *Service) DeployKeyToUser(key *models.SSHKey, server *models.Server, authPrivateKey []byte, systemUser string, createUser, sudo bool, initialPassword string) error {
logging.Debug("Deploy: connecting to %s@%s:%d to deploy key '%s' for system user '%s' (createUser=%v, sudo=%v)",
server.Username, server.Hostname, server.Port, key.Name, systemUser, createUser, sudo)
signer, err := ssh.ParsePrivateKey(authPrivateKey)
if err != nil {
return fmt.Errorf("failed to parse authentication key: %w", err)
}
config := &ssh.ClientConfig{
User: server.Username,
Auth: []ssh.AuthMethod{
ssh.PublicKeys(signer),
},
HostKeyCallback: ssh.InsecureIgnoreHostKey(),
Timeout: 10 * time.Second,
}
addr := fmt.Sprintf("%s:%d", server.Hostname, server.Port)
client, err := ssh.Dial("tcp", addr, config)
if err != nil {
s.logDeployment(key.ID, server.ID, "failed", fmt.Sprintf("connection failed: %v", err))
return fmt.Errorf("failed to connect to server: %w", err)
}
defer client.Close()
// If createUser is true, ensure the system user exists
if createUser {
session, err := client.NewSession()
if err != nil {
return fmt.Errorf("failed to create session for user creation: %w", err)
}
// Create user if not exists; use -m for home directory, -s for shell
createCmd := fmt.Sprintf(
`id '%s' >/dev/null 2>&1 || useradd -m -s /bin/bash '%s'`,
systemUser, systemUser,
)
if cerr := session.Run(createCmd); cerr != nil {
session.Close()
return fmt.Errorf("failed to create system user '%s': %w", systemUser, cerr)
}
session.Close()
logging.Info("Deploy: ensured system user '%s' exists on %s", systemUser, server.Hostname)
// Set initial password if provided
if initialPassword != "" {
pwSession, err := client.NewSession()
if err != nil {
return fmt.Errorf("failed to create session for password setup: %w", err)
}
pwCmd := fmt.Sprintf(`echo '%s:%s' | chpasswd`, systemUser, initialPassword)
if perr := pwSession.Run(pwCmd); perr != nil {
pwSession.Close()
logging.Warn("Deploy: failed to set initial password for user '%s' on %s: %v", systemUser, server.Hostname, perr)
} else {
pwSession.Close()
logging.Info("Deploy: set initial password for user '%s' on %s", systemUser, server.Hostname)
}
}
}
// If sudo is true, create a sudoers.d entry with NOPASSWD
if sudo {
session, err := client.NewSession()
if err != nil {
return fmt.Errorf("failed to create session for sudo setup: %w", err)
}
sudoCmd := fmt.Sprintf(
`echo '%s ALL=(ALL) NOPASSWD:ALL' > /etc/sudoers.d/%s && chmod 440 /etc/sudoers.d/%s`,
systemUser, systemUser, systemUser,
)
if serr := session.Run(sudoCmd); serr != nil {
session.Close()
logging.Warn("Deploy: failed to add sudo for user '%s' on %s: %v", systemUser, server.Hostname, serr)
} else {
session.Close()
logging.Info("Deploy: ensured NOPASSWD sudo for user '%s' on %s", systemUser, server.Hostname)
}
}
// Deploy the key to the system user's authorized_keys
session, err := client.NewSession()
if err != nil {
return fmt.Errorf("failed to create session for key deployment: %w", err)
}
defer session.Close()
pubKey := strings.TrimSpace(key.PublicKey)
homeDir := fmt.Sprintf("/home/%s", systemUser)
if systemUser == "root" {
homeDir = "/root"
}
cmd := fmt.Sprintf(
`mkdir -p %s/.ssh && chmod 700 %s/.ssh && echo '%s' >> %s/.ssh/authorized_keys && chmod 600 %s/.ssh/authorized_keys && chown -R '%s':'%s' %s/.ssh && sort -u -o %s/.ssh/authorized_keys %s/.ssh/authorized_keys`,
homeDir, homeDir, pubKey, homeDir, homeDir, systemUser, systemUser, homeDir, homeDir, homeDir,
)
if err := session.Run(cmd); err != nil {
s.logDeployment(key.ID, server.ID, "failed", fmt.Sprintf("command failed: %v", err))
return fmt.Errorf("failed to deploy key for user '%s': %w", systemUser, err)
}
s.logDeployment(key.ID, server.ID, "success", fmt.Sprintf("key deployed to user '%s'", systemUser))
return nil
}
// DeployKeyToUserWithPassword is like DeployKeyToUser but uses password authentication
func (s *Service) DeployKeyToUserWithPassword(key *models.SSHKey, server *models.Server, password string, systemUser string, createUser, sudo bool, initialPassword string) error {
logging.Debug("Deploy: connecting to %s@%s:%d with password to deploy key '%s' for system user '%s'",
server.Username, server.Hostname, server.Port, key.Name, systemUser)
config := &ssh.ClientConfig{
User: server.Username,
Auth: []ssh.AuthMethod{
ssh.Password(password),
},
HostKeyCallback: ssh.InsecureIgnoreHostKey(),
Timeout: 10 * time.Second,
}
addr := fmt.Sprintf("%s:%d", server.Hostname, server.Port)
client, err := ssh.Dial("tcp", addr, config)
if err != nil {
s.logDeployment(key.ID, server.ID, "failed", fmt.Sprintf("connection failed: %v", err))
return fmt.Errorf("failed to connect to server: %w", err)
}
defer client.Close()
if createUser {
session, err := client.NewSession()
if err != nil {
return fmt.Errorf("failed to create session for user creation: %w", err)
}
createCmd := fmt.Sprintf(
`id '%s' >/dev/null 2>&1 || useradd -m -s /bin/bash '%s'`,
systemUser, systemUser,
)
if cerr := session.Run(createCmd); cerr != nil {
session.Close()
return fmt.Errorf("failed to create system user '%s': %w", systemUser, cerr)
}
session.Close()
logging.Info("Deploy: ensured system user '%s' exists on %s", systemUser, server.Hostname)
// Set initial password if provided
if initialPassword != "" {
pwSession, err := client.NewSession()
if err != nil {
return fmt.Errorf("failed to create session for password setup: %w", err)
}
pwCmd := fmt.Sprintf(`echo '%s:%s' | chpasswd`, systemUser, initialPassword)
if perr := pwSession.Run(pwCmd); perr != nil {
pwSession.Close()
logging.Warn("Deploy: failed to set initial password for user '%s' on %s: %v", systemUser, server.Hostname, perr)
} else {
pwSession.Close()
logging.Info("Deploy: set initial password for user '%s' on %s", systemUser, server.Hostname)
}
}
}
if sudo {
session, err := client.NewSession()
if err != nil {
return fmt.Errorf("failed to create session for sudo setup: %w", err)
}
sudoCmd := fmt.Sprintf(
`echo '%s ALL=(ALL) NOPASSWD:ALL' > /etc/sudoers.d/%s && chmod 440 /etc/sudoers.d/%s`,
systemUser, systemUser, systemUser,
)
if serr := session.Run(sudoCmd); serr != nil {
session.Close()
logging.Warn("Deploy: failed to add sudo for user '%s' on %s: %v", systemUser, server.Hostname, serr)
} else {
session.Close()
logging.Info("Deploy: ensured NOPASSWD sudo for user '%s' on %s", systemUser, server.Hostname)
}
}
session, err := client.NewSession()
if err != nil {
return fmt.Errorf("failed to create session for key deployment: %w", err)
}
defer session.Close()
pubKey := strings.TrimSpace(key.PublicKey)
homeDir := fmt.Sprintf("/home/%s", systemUser)
if systemUser == "root" {
homeDir = "/root"
}
cmd := fmt.Sprintf(
`mkdir -p %s/.ssh && chmod 700 %s/.ssh && echo '%s' >> %s/.ssh/authorized_keys && chmod 600 %s/.ssh/authorized_keys && chown -R '%s':'%s' %s/.ssh && sort -u -o %s/.ssh/authorized_keys %s/.ssh/authorized_keys`,
homeDir, homeDir, pubKey, homeDir, homeDir, systemUser, systemUser, homeDir, homeDir, homeDir,
)
if err := session.Run(cmd); err != nil {
s.logDeployment(key.ID, server.ID, "failed", fmt.Sprintf("command failed: %v", err))
return fmt.Errorf("failed to deploy key for user '%s': %w", systemUser, err)
}
s.logDeployment(key.ID, server.ID, "success", fmt.Sprintf("key deployed to user '%s'", systemUser))
return nil
}
// RemoveKeyFromUser removes a public key from a specific system user's authorized_keys.
// It connects as the server's admin user and manages the target systemUser's keys.
func (s *Service) RemoveKeyFromUser(key *models.SSHKey, server *models.Server, authPrivateKey []byte, systemUser string) error {
logging.Debug("Deploy: connecting to %s@%s:%d to remove key '%s' from system user '%s'",
server.Username, server.Hostname, server.Port, key.Name, systemUser)
signer, err := ssh.ParsePrivateKey(authPrivateKey)
if err != nil {
return fmt.Errorf("failed to parse authentication key: %w", err)
}
config := &ssh.ClientConfig{
User: server.Username,
Auth: []ssh.AuthMethod{
ssh.PublicKeys(signer),
},
HostKeyCallback: ssh.InsecureIgnoreHostKey(),
Timeout: 10 * time.Second,
}
addr := fmt.Sprintf("%s:%d", server.Hostname, server.Port)
client, err := ssh.Dial("tcp", addr, config)
if err != nil {
return fmt.Errorf("failed to connect to server for key removal: %w", err)
}
defer client.Close()
session, err := client.NewSession()
if err != nil {
return fmt.Errorf("failed to create session for key removal: %w", err)
}
defer session.Close()
pubKey := strings.TrimSpace(key.PublicKey)
escapedKey := strings.ReplaceAll(pubKey, "'", "'\\''")
homeDir := fmt.Sprintf("/home/%s", systemUser)
if systemUser == "root" {
homeDir = "/root"
}
cmd := fmt.Sprintf(
`grep -v '%s' %s/.ssh/authorized_keys > %s/.ssh/authorized_keys.tmp 2>/dev/null && mv %s/.ssh/authorized_keys.tmp %s/.ssh/authorized_keys && chmod 600 %s/.ssh/authorized_keys && chown '%s':'%s' %s/.ssh/authorized_keys || true`,
escapedKey, homeDir, homeDir, homeDir, homeDir, homeDir, systemUser, systemUser, homeDir,
)
if err := session.Run(cmd); err != nil {
return fmt.Errorf("failed to remove key from user '%s': %w", systemUser, err)
}
s.logDeployment(key.ID, server.ID, "success", fmt.Sprintf("key removed from user '%s'", systemUser))
return nil
}
// RemoveSystemUser removes a Linux system user from a server, including:
// - Removing the SSH key from their authorized_keys
// - Removing sudo rights (/etc/sudoers.d/<user>)
// - Deleting the system user account (userdel -r)
func (s *Service) RemoveSystemUser(key *models.SSHKey, server *models.Server, authPrivateKey []byte, systemUser string) error {
logging.Info("Deploy: removing system user '%s' from %s@%s:%d", systemUser, server.Username, server.Hostname, server.Port)
signer, err := ssh.ParsePrivateKey(authPrivateKey)
if err != nil {
return fmt.Errorf("failed to parse authentication key: %w", err)
}
config := &ssh.ClientConfig{
User: server.Username,
Auth: []ssh.AuthMethod{
ssh.PublicKeys(signer),
},
HostKeyCallback: ssh.InsecureIgnoreHostKey(),
Timeout: 10 * time.Second,
}
addr := fmt.Sprintf("%s:%d", server.Hostname, server.Port)
client, err := ssh.Dial("tcp", addr, config)
if err != nil {
return fmt.Errorf("failed to connect to server for user removal: %w", err)
}
defer client.Close()
// Step 1: Remove sudoers entry
sudoSession, err := client.NewSession()
if err != nil {
return fmt.Errorf("failed to create session for sudo removal: %w", err)
}
sudoCmd := fmt.Sprintf(`rm -f /etc/sudoers.d/'%s'`, systemUser)
if serr := sudoSession.Run(sudoCmd); serr != nil {
logging.Warn("Deploy: failed to remove sudo for user '%s' on %s: %v", systemUser, server.Hostname, serr)
} else {
logging.Info("Deploy: removed sudoers entry for '%s' on %s", systemUser, server.Hostname)
}
sudoSession.Close()
// Step 2: Kill all processes of the user (so userdel doesn't fail)
killSession, err := client.NewSession()
if err != nil {
return fmt.Errorf("failed to create session for process kill: %w", err)
}
killCmd := fmt.Sprintf(`pkill -u '%s' 2>/dev/null || true`, systemUser)
killSession.Run(killCmd)
killSession.Close()
// Small delay to let processes terminate
time.Sleep(1 * time.Second)
// Step 3: Delete the system user with home directory
delSession, err := client.NewSession()
if err != nil {
return fmt.Errorf("failed to create session for user deletion: %w", err)
}
defer delSession.Close()
delCmd := fmt.Sprintf(`userdel -r '%s' 2>/dev/null || userdel '%s' 2>/dev/null`, systemUser, systemUser)
if derr := delSession.Run(delCmd); derr != nil {
return fmt.Errorf("failed to delete system user '%s': %w", systemUser, derr)
}
logging.Info("Deploy: successfully deleted system user '%s' from %s", systemUser, server.Hostname)
s.logDeployment(key.ID, server.ID, "success", fmt.Sprintf("system user '%s' deleted", systemUser))
return nil
}
// DisableSystemUser locks a system user account on a server by:
// - Removing the SSH key from authorized_keys
// - Locking the account (usermod --lock)
// - Setting the shell to /usr/sbin/nologin
func (s *Service) DisableSystemUser(key *models.SSHKey, server *models.Server, authPrivateKey []byte, systemUser string) error {
logging.Info("Deploy: disabling system user '%s' on %s@%s:%d", systemUser, server.Username, server.Hostname, server.Port)
signer, err := ssh.ParsePrivateKey(authPrivateKey)
if err != nil {
return fmt.Errorf("failed to parse authentication key: %w", err)
}
config := &ssh.ClientConfig{
User: server.Username,
Auth: []ssh.AuthMethod{
ssh.PublicKeys(signer),
},
HostKeyCallback: ssh.InsecureIgnoreHostKey(),
Timeout: 10 * time.Second,
}
addr := fmt.Sprintf("%s:%d", server.Hostname, server.Port)
client, err := ssh.Dial("tcp", addr, config)
if err != nil {
return fmt.Errorf("failed to connect to server for user disable: %w", err)
}
defer client.Close()
// Step 1: Remove SSH key from authorized_keys
removeSession, err := client.NewSession()
if err != nil {
return fmt.Errorf("failed to create session for key removal: %w", err)
}
pubKey := strings.TrimSpace(key.PublicKey)
escapedKey := strings.ReplaceAll(pubKey, "'", "'\\''")
homeDir := fmt.Sprintf("/home/%s", systemUser)
if systemUser == "root" {
homeDir = "/root"
}
removeCmd := fmt.Sprintf(
`grep -v '%s' %s/.ssh/authorized_keys > %s/.ssh/authorized_keys.tmp 2>/dev/null && mv %s/.ssh/authorized_keys.tmp %s/.ssh/authorized_keys && chmod 600 %s/.ssh/authorized_keys || true`,
escapedKey, homeDir, homeDir, homeDir, homeDir, homeDir,
)
removeSession.Run(removeCmd)
removeSession.Close()
// Step 2: Lock the account
lockSession, err := client.NewSession()
if err != nil {
return fmt.Errorf("failed to create session for user lock: %w", err)
}
lockCmd := fmt.Sprintf(`usermod --lock '%s' 2>/dev/null || true`, systemUser)
lockSession.Run(lockCmd)
lockSession.Close()
// Step 3: Set shell to nologin
shellSession, err := client.NewSession()
if err != nil {
return fmt.Errorf("failed to create session for shell change: %w", err)
}
defer shellSession.Close()
shellCmd := fmt.Sprintf(`usermod --shell /usr/sbin/nologin '%s' 2>/dev/null || chsh -s /usr/sbin/nologin '%s' 2>/dev/null || true`, systemUser, systemUser)
shellSession.Run(shellCmd)
logging.Info("Deploy: successfully disabled system user '%s' on %s", systemUser, server.Hostname)
s.logDeployment(key.ID, server.ID, "success", fmt.Sprintf("system user '%s' disabled (locked + nologin)", systemUser))
return nil
}
// TestConnection tests TCP connectivity to a server (port reachable)
func (s *Service) TestConnection(hostname string, port int) error {
logging.Debug("Testing TCP connection to %s:%d", hostname, port)
addr := fmt.Sprintf("%s:%d", hostname, port)
conn, err := net.DialTimeout("tcp", addr, 5*time.Second)
if err != nil {
return fmt.Errorf("cannot reach %s: %w", addr, err)
}
conn.Close()
return nil
}
// TestSSHAuth tests actual SSH authentication to a server using a private key
func (s *Service) TestSSHAuth(hostname string, port int, username string, privateKey []byte) error {
logging.Debug("Testing SSH auth for %s@%s:%d", username, hostname, port)
signer, err := ssh.ParsePrivateKey(privateKey)
if err != nil {
return fmt.Errorf("failed to parse master key: %w", err)
}
config := &ssh.ClientConfig{
User: username,
Auth: []ssh.AuthMethod{
ssh.PublicKeys(signer),
},
HostKeyCallback: ssh.InsecureIgnoreHostKey(),
Timeout: 10 * time.Second,
}
addr := fmt.Sprintf("%s:%d", hostname, port)
client, err := ssh.Dial("tcp", addr, config)
if err != nil {
return fmt.Errorf("SSH authentication failed: %w", err)
}
client.Close()
return nil
}
// logDeployment records a deployment attempt
func (s *Service) logDeployment(keyID, serverID int64, status, message string) {
s.db.Exec(
`INSERT INTO key_deployments (ssh_key_id, server_id, status, message) VALUES (?, ?, ?, ?)`,
keyID, serverID, status, message,
)
}
// GetDeployments returns deployment history for a user's keys
func (s *Service) GetDeployments(userID int64) ([]map[string]interface{}, error) {
rows, err := s.db.Query(
`SELECT kd.id, sk.name as key_name, srv.name as server_name, kd.status, kd.message, kd.deployed_at
FROM key_deployments kd
JOIN ssh_keys sk ON kd.ssh_key_id = sk.id
JOIN servers srv ON kd.server_id = srv.id
WHERE sk.user_id = ?
ORDER BY kd.deployed_at DESC LIMIT 50`, userID,
)
if err != nil {
return nil, fmt.Errorf("failed to query deployments: %w", err)
}
defer rows.Close()
var deployments []map[string]interface{}
for rows.Next() {
var id int64
var keyName, serverName, status, message string
var deployedAt time.Time
if err := rows.Scan(&id, &keyName, &serverName, &status, &message, &deployedAt); err != nil {
continue
}
deployments = append(deployments, map[string]interface{}{
"id": id,
"key_name": keyName,
"server_name": serverName,
"status": status,
"message": message,
"deployed_at": deployedAt,
})
}
return deployments, nil
}
+79
View File
@@ -0,0 +1,79 @@
// Keywarden - Centralized SSH Key Management and Deployment
// Copyright (C) 2026 Patrick Asmus (scriptos)
// SPDX-License-Identifier: AGPL-3.0-or-later
package encryption
import (
"crypto/aes"
"crypto/cipher"
"crypto/rand"
"crypto/sha256"
"encoding/base64"
"fmt"
"io"
)
// Service handles AES-256-GCM encryption/decryption
type Service struct {
key []byte // 32 bytes for AES-256
}
// NewService creates a new encryption service from a passphrase
func NewService(passphrase string) *Service {
// Derive a 32-byte key from the passphrase using SHA-256
hash := sha256.Sum256([]byte(passphrase))
return &Service{key: hash[:]}
}
// Encrypt encrypts plaintext using AES-256-GCM and returns base64-encoded ciphertext
func (s *Service) Encrypt(plaintext string) (string, error) {
block, err := aes.NewCipher(s.key)
if err != nil {
return "", fmt.Errorf("failed to create cipher: %w", err)
}
gcm, err := cipher.NewGCM(block)
if err != nil {
return "", fmt.Errorf("failed to create GCM: %w", err)
}
nonce := make([]byte, gcm.NonceSize())
if _, err := io.ReadFull(rand.Reader, nonce); err != nil {
return "", fmt.Errorf("failed to generate nonce: %w", err)
}
ciphertext := gcm.Seal(nonce, nonce, []byte(plaintext), nil)
return base64.StdEncoding.EncodeToString(ciphertext), nil
}
// Decrypt decrypts base64-encoded AES-256-GCM ciphertext
func (s *Service) Decrypt(encoded string) (string, error) {
ciphertext, err := base64.StdEncoding.DecodeString(encoded)
if err != nil {
return "", fmt.Errorf("failed to decode base64: %w", err)
}
block, err := aes.NewCipher(s.key)
if err != nil {
return "", fmt.Errorf("failed to create cipher: %w", err)
}
gcm, err := cipher.NewGCM(block)
if err != nil {
return "", fmt.Errorf("failed to create GCM: %w", err)
}
nonceSize := gcm.NonceSize()
if len(ciphertext) < nonceSize {
return "", fmt.Errorf("ciphertext too short")
}
nonce, ciphertext := ciphertext[:nonceSize], ciphertext[nonceSize:]
plaintext, err := gcm.Open(nil, nonce, ciphertext, nil)
if err != nil {
return "", fmt.Errorf("failed to decrypt: %w", err)
}
return string(plaintext), nil
}
+132
View File
@@ -0,0 +1,132 @@
// Keywarden - Centralized SSH Key Management and Deployment
// Copyright (C) 2026 Patrick Asmus (scriptos)
// SPDX-License-Identifier: AGPL-3.0-or-later
package encryption
import (
"strings"
"testing"
)
func TestNewService(t *testing.T) {
svc := NewService("test-passphrase")
if svc == nil {
t.Fatal("NewService returned nil")
}
if len(svc.key) != 32 {
t.Fatalf("expected 32-byte key, got %d", len(svc.key))
}
}
func TestEncryptDecryptRoundtrip(t *testing.T) {
svc := NewService("my-secret-passphrase-32-chars!!")
plaintext := "This is a private SSH key content"
encrypted, err := svc.Encrypt(plaintext)
if err != nil {
t.Fatalf("Encrypt failed: %v", err)
}
if encrypted == plaintext {
t.Fatal("Encrypted text should differ from plaintext")
}
decrypted, err := svc.Decrypt(encrypted)
if err != nil {
t.Fatalf("Decrypt failed: %v", err)
}
if decrypted != plaintext {
t.Fatalf("Decrypted text mismatch: got %q, want %q", decrypted, plaintext)
}
}
func TestEncryptProducesDifferentCiphertexts(t *testing.T) {
svc := NewService("test-passphrase")
plaintext := "same input"
enc1, err := svc.Encrypt(plaintext)
if err != nil {
t.Fatalf("Encrypt 1 failed: %v", err)
}
enc2, err := svc.Encrypt(plaintext)
if err != nil {
t.Fatalf("Encrypt 2 failed: %v", err)
}
if enc1 == enc2 {
t.Fatal("Two encryptions of the same plaintext should produce different ciphertexts (random nonce)")
}
}
func TestDecryptWithWrongKey(t *testing.T) {
svc1 := NewService("correct-passphrase")
svc2 := NewService("wrong-passphrase")
encrypted, err := svc1.Encrypt("secret data")
if err != nil {
t.Fatalf("Encrypt failed: %v", err)
}
_, err = svc2.Decrypt(encrypted)
if err == nil {
t.Fatal("Decrypt with wrong key should fail")
}
}
func TestDecryptInvalidBase64(t *testing.T) {
svc := NewService("test")
_, err := svc.Decrypt("not-valid-base64!!!")
if err == nil {
t.Fatal("Decrypt of invalid base64 should fail")
}
}
func TestDecryptTooShort(t *testing.T) {
svc := NewService("test")
// Valid base64 but too short for nonce + ciphertext
_, err := svc.Decrypt("AQID")
if err == nil {
t.Fatal("Decrypt of too-short ciphertext should fail")
}
}
func TestEncryptEmptyString(t *testing.T) {
svc := NewService("test")
encrypted, err := svc.Encrypt("")
if err != nil {
t.Fatalf("Encrypt empty string failed: %v", err)
}
decrypted, err := svc.Decrypt(encrypted)
if err != nil {
t.Fatalf("Decrypt empty string failed: %v", err)
}
if decrypted != "" {
t.Fatalf("Expected empty string, got %q", decrypted)
}
}
func TestEncryptLargePayload(t *testing.T) {
svc := NewService("test")
// Simulate a large SSH private key
large := strings.Repeat("A", 4096)
encrypted, err := svc.Encrypt(large)
if err != nil {
t.Fatalf("Encrypt large payload failed: %v", err)
}
decrypted, err := svc.Decrypt(encrypted)
if err != nil {
t.Fatalf("Decrypt large payload failed: %v", err)
}
if decrypted != large {
t.Fatal("Large payload roundtrip mismatch")
}
}
File diff suppressed because it is too large Load Diff
+375
View File
@@ -0,0 +1,375 @@
// Keywarden - Centralized SSH Key Management and Deployment
// Copyright (C) 2026 Patrick Asmus (scriptos)
// SPDX-License-Identifier: AGPL-3.0-or-later
package keys
import (
"fmt"
"git.techniverse.net/scriptos/keywarden/internal/database"
"git.techniverse.net/scriptos/keywarden/internal/encryption"
"git.techniverse.net/scriptos/keywarden/internal/models"
"git.techniverse.net/scriptos/keywarden/internal/sshutil"
)
// Service handles SSH key operations
type Service struct {
db *database.DB
enc *encryption.Service
}
// NewService creates a new key service with encryption
func NewService(db *database.DB, enc *encryption.Service) *Service {
return &Service{db: db, enc: enc}
}
// GenerateKey generates a new SSH key pair and stores it encrypted
func (s *Service) GenerateKey(userID int64, name, keyType string, bits int, comment string) (*models.SSHKey, error) {
return s.generateKey(userID, name, keyType, bits, comment)
}
// generateKey is the internal key generation function
func (s *Service) generateKey(userID int64, name, keyType string, bits int, comment string) (*models.SSHKey, error) {
var privPEM, pubKey []byte
var fingerprint string
var err error
switch keyType {
case "rsa":
privPEM, pubKey, fingerprint, err = sshutil.GenerateRSAKey(bits, comment)
case "ed25519":
privPEM, pubKey, fingerprint, err = sshutil.GenerateEd25519Key(comment)
bits = 256
case "ed448":
privPEM, pubKey, fingerprint, err = sshutil.GenerateEd448Key(comment)
bits = 456
default:
return nil, fmt.Errorf("unsupported key type: %s", keyType)
}
if err != nil {
return nil, fmt.Errorf("failed to generate key: %w", err)
}
// Encrypt private key before storage
encPrivKey, err := s.enc.Encrypt(string(privPEM))
if err != nil {
return nil, fmt.Errorf("failed to encrypt private key: %w", err)
}
result, err := s.db.Exec(
`INSERT INTO ssh_keys (user_id, name, key_type, bits, fingerprint, public_key, private_key_enc)
VALUES (?, ?, ?, ?, ?, ?, ?)`,
userID, name, keyType, bits, fingerprint, string(pubKey), encPrivKey,
)
if err != nil {
return nil, fmt.Errorf("failed to store key: %w", err)
}
id, _ := result.LastInsertId()
return &models.SSHKey{
ID: id,
UserID: userID,
Name: name,
KeyType: keyType,
Bits: bits,
Fingerprint: fingerprint,
PublicKey: string(pubKey),
}, nil
}
// ImportKey imports an existing key pair (encrypts the private key)
func (s *Service) ImportKey(userID int64, name string, privateKeyPEM []byte) (*models.SSHKey, error) {
pubKey, fingerprint, keyType, err := sshutil.ParsePrivateKey(privateKeyPEM)
if err != nil {
return nil, fmt.Errorf("failed to parse private key: %w", err)
}
// Map SSH key type names
kt := "rsa"
bits := 0
switch keyType {
case "ssh-rsa":
kt = "rsa"
bits = 2048 // approximate
case "ssh-ed25519":
kt = "ed25519"
bits = 256
case "ssh-ed448":
kt = "ed448"
bits = 456
}
// Encrypt private key before storage
encPrivKey, err := s.enc.Encrypt(string(privateKeyPEM))
if err != nil {
return nil, fmt.Errorf("failed to encrypt private key: %w", err)
}
result, err := s.db.Exec(
`INSERT INTO ssh_keys (user_id, name, key_type, bits, fingerprint, public_key, private_key_enc)
VALUES (?, ?, ?, ?, ?, ?, ?)`,
userID, name, kt, bits, fingerprint, string(pubKey), encPrivKey,
)
if err != nil {
return nil, fmt.Errorf("failed to store key: %w", err)
}
id, _ := result.LastInsertId()
return &models.SSHKey{
ID: id,
UserID: userID,
Name: name,
KeyType: kt,
Bits: bits,
Fingerprint: fingerprint,
PublicKey: string(pubKey),
}, nil
}
// GetKeysByUser returns all keys for a user
func (s *Service) GetKeysByUser(userID int64) ([]models.SSHKey, error) {
rows, err := s.db.Query(
`SELECT id, user_id, name, key_type, bits, fingerprint, public_key, created_at
FROM ssh_keys WHERE user_id = ? ORDER BY created_at DESC`, userID,
)
if err != nil {
return nil, fmt.Errorf("failed to query keys: %w", err)
}
defer rows.Close()
var keys []models.SSHKey
for rows.Next() {
var k models.SSHKey
if err := rows.Scan(&k.ID, &k.UserID, &k.Name, &k.KeyType, &k.Bits, &k.Fingerprint, &k.PublicKey, &k.CreatedAt); err != nil {
return nil, fmt.Errorf("failed to scan key: %w", err)
}
keys = append(keys, k)
}
return keys, nil
}
// GetAllKeys returns all SSH keys for all users (admin use)
func (s *Service) GetAllKeys() ([]models.SSHKey, error) {
rows, err := s.db.Query(
`SELECT id, user_id, name, key_type, bits, fingerprint, public_key, created_at
FROM ssh_keys ORDER BY user_id, name ASC`,
)
if err != nil {
return nil, fmt.Errorf("failed to query all keys: %w", err)
}
defer rows.Close()
var keys []models.SSHKey
for rows.Next() {
var k models.SSHKey
if err := rows.Scan(&k.ID, &k.UserID, &k.Name, &k.KeyType, &k.Bits, &k.Fingerprint, &k.PublicKey, &k.CreatedAt); err != nil {
return nil, fmt.Errorf("failed to scan key: %w", err)
}
keys = append(keys, k)
}
return keys, nil
}
// GetKeyByID returns a specific key with decrypted private key
func (s *Service) GetKeyByID(keyID, userID int64) (*models.SSHKey, error) {
key := &models.SSHKey{}
var encPrivKey string
err := s.db.QueryRow(
`SELECT id, user_id, name, key_type, bits, fingerprint, public_key, private_key_enc, created_at
FROM ssh_keys WHERE id = ? AND user_id = ?`, keyID, userID,
).Scan(&key.ID, &key.UserID, &key.Name, &key.KeyType, &key.Bits, &key.Fingerprint, &key.PublicKey, &encPrivKey, &key.CreatedAt)
if err != nil {
return nil, fmt.Errorf("key not found: %w", err)
}
// Decrypt private key
decrypted, err := s.enc.Decrypt(encPrivKey)
if err != nil {
// Fallback: might be an old unencrypted key
key.PrivateKeyEnc = encPrivKey
} else {
key.PrivateKeyEnc = decrypted
}
return key, nil
}
// DeleteKey deletes a key
func (s *Service) DeleteKey(keyID, userID int64) error {
result, err := s.db.Exec(`DELETE FROM ssh_keys WHERE id = ? AND user_id = ?`, keyID, userID)
if err != nil {
return fmt.Errorf("failed to delete key: %w", err)
}
rows, _ := result.RowsAffected()
if rows == 0 {
return fmt.Errorf("key not found")
}
return nil
}
// GetAllKeysWithOwner returns all SSH keys with their owner's username (for admin/owner views)
func (s *Service) GetAllKeysWithOwner() ([]models.SSHKeyWithOwner, error) {
rows, err := s.db.Query(
`SELECT k.id, k.user_id, k.name, k.key_type, k.bits, k.fingerprint, k.public_key, k.created_at,
COALESCE(u.username, '(deleted)')
FROM ssh_keys k
LEFT JOIN users u ON k.user_id = u.id
ORDER BY u.username ASC, k.name ASC`,
)
if err != nil {
return nil, fmt.Errorf("failed to query all keys with owner: %w", err)
}
defer rows.Close()
var keys []models.SSHKeyWithOwner
for rows.Next() {
var k models.SSHKeyWithOwner
if err := rows.Scan(&k.ID, &k.UserID, &k.Name, &k.KeyType, &k.Bits, &k.Fingerprint, &k.PublicKey, &k.CreatedAt, &k.OwnerUsername); err != nil {
return nil, fmt.Errorf("failed to scan key: %w", err)
}
keys = append(keys, k)
}
return keys, nil
}
// GetKeyByIDGlobal returns a specific key without user_id check (admin/owner access)
// Note: Private key is NOT returned decrypted — only metadata and public key
func (s *Service) GetKeyByIDGlobal(keyID int64) (*models.SSHKey, error) {
key := &models.SSHKey{}
err := s.db.QueryRow(
`SELECT id, user_id, name, key_type, bits, fingerprint, public_key, created_at
FROM ssh_keys WHERE id = ?`, keyID,
).Scan(&key.ID, &key.UserID, &key.Name, &key.KeyType, &key.Bits, &key.Fingerprint, &key.PublicKey, &key.CreatedAt)
if err != nil {
return nil, fmt.Errorf("key not found: %w", err)
}
return key, nil
}
// DeleteKeyGlobal deletes a key without user_id check (admin/owner access)
func (s *Service) DeleteKeyGlobal(keyID int64) error {
result, err := s.db.Exec(`DELETE FROM ssh_keys WHERE id = ?`, keyID)
if err != nil {
return fmt.Errorf("failed to delete key: %w", err)
}
rows, _ := result.RowsAffected()
if rows == 0 {
return fmt.Errorf("key not found")
}
return nil
}
// --- System Master Key ---
// The system master key is an Ed25519 key pair used by Keywarden to authenticate
// against remote servers for key deployments. It is generated once on first startup
// and stored encrypted in the settings table. It cannot be deleted, only regenerated.
// EnsureSystemMasterKey generates the system master key if it doesn't exist yet.
// Returns the public key string.
func (s *Service) EnsureSystemMasterKey() (string, error) {
pub, err := s.getSetting("system_master_key_public")
if err == nil && pub != "" {
return pub, nil
}
// Generate new master key
return s.generateSystemMasterKey()
}
// generateSystemMasterKey generates a new Ed25519 key pair and stores it in settings.
func (s *Service) generateSystemMasterKey() (string, error) {
privPEM, pubKey, fingerprint, err := sshutil.GenerateEd25519Key("keywarden-system-master")
if err != nil {
return "", fmt.Errorf("failed to generate system master key: %w", err)
}
// Encrypt private key
encPriv, err := s.enc.Encrypt(string(privPEM))
if err != nil {
return "", fmt.Errorf("failed to encrypt system master key: %w", err)
}
pubStr := string(pubKey)
// Store in settings table
if err := s.setSetting("system_master_key_private", encPriv); err != nil {
return "", fmt.Errorf("failed to store system master key private: %w", err)
}
if err := s.setSetting("system_master_key_public", pubStr); err != nil {
return "", fmt.Errorf("failed to store system master key public: %w", err)
}
if err := s.setSetting("system_master_key_fingerprint", fingerprint); err != nil {
return "", fmt.Errorf("failed to store system master key fingerprint: %w", err)
}
return pubStr, nil
}
// GetSystemMasterKeyPublic returns the public key of the system master key.
func (s *Service) GetSystemMasterKeyPublic() (string, error) {
pub, err := s.getSetting("system_master_key_public")
if err != nil || pub == "" {
return "", fmt.Errorf("system master key not found")
}
return pub, nil
}
// GetSystemMasterKeyFingerprint returns the fingerprint of the system master key.
func (s *Service) GetSystemMasterKeyFingerprint() (string, error) {
fp, err := s.getSetting("system_master_key_fingerprint")
if err != nil || fp == "" {
return "", fmt.Errorf("system master key fingerprint not found")
}
return fp, nil
}
// GetSystemMasterKeyPrivate returns the decrypted private key PEM of the system master key.
func (s *Service) GetSystemMasterKeyPrivate() ([]byte, error) {
encPriv, err := s.getSetting("system_master_key_private")
if err != nil || encPriv == "" {
return nil, fmt.Errorf("system master key not found")
}
decrypted, err := s.enc.Decrypt(encPriv)
if err != nil {
return nil, fmt.Errorf("failed to decrypt system master key: %w", err)
}
return []byte(decrypted), nil
}
// RegenerateSystemMasterKey generates a new system master key, replacing the old one.
// Returns the new public key string.
func (s *Service) RegenerateSystemMasterKey() (string, error) {
return s.generateSystemMasterKey()
}
// getSetting reads a value from the settings table.
func (s *Service) getSetting(key string) (string, error) {
var value string
err := s.db.QueryRow(`SELECT value FROM settings WHERE key = ?`, key).Scan(&value)
if err != nil {
return "", err
}
return value, nil
}
// setSetting writes a value to the settings table (upsert).
func (s *Service) setSetting(key, value string) error {
_, err := s.db.Exec(
`INSERT INTO settings (key, value, updated_at) VALUES (?, ?, CURRENT_TIMESTAMP)
ON CONFLICT(key) DO UPDATE SET value = excluded.value, updated_at = CURRENT_TIMESTAMP`,
key, value,
)
return err
}
// EncryptValue encrypts a plaintext string using the application encryption key
func (s *Service) EncryptValue(plaintext string) (string, error) {
return s.enc.Encrypt(plaintext)
}
// DecryptValue decrypts an encrypted string using the application encryption key
func (s *Service) DecryptValue(ciphertext string) (string, error) {
return s.enc.Decrypt(ciphertext)
}
+251
View File
@@ -0,0 +1,251 @@
// Keywarden - Centralized SSH Key Management and Deployment
// Copyright (C) 2026 Patrick Asmus (scriptos)
// SPDX-License-Identifier: AGPL-3.0-or-later
package logging
import (
"fmt"
"log"
"net/http"
"os"
"strings"
"time"
)
// Level represents the severity of a log message
type Level int
const (
LevelError Level = iota // only errors
LevelWarn // errors + warnings
LevelInfo // errors + warnings + info (default)
LevelDebug // errors + warnings + info + debug
LevelTrace // everything, including very verbose trace output
)
// String returns the human-readable name of the log level
func (l Level) String() string {
switch l {
case LevelError:
return "ERROR"
case LevelWarn:
return "WARN"
case LevelInfo:
return "INFO"
case LevelDebug:
return "DEBUG"
case LevelTrace:
return "TRACE"
default:
return "UNKNOWN"
}
}
// Logger is the application-wide structured logger
type Logger struct {
level Level
}
// global singleton – initialised via Init()
var global *Logger
func init() {
// Set log flags for consistent timestamp output (goes to stdout/stderr → Docker logs)
log.SetFlags(log.Ldate | log.Ltime | log.Lmicroseconds)
log.SetOutput(os.Stdout)
// Default until Init() is called
global = &Logger{level: LevelInfo}
}
// Init creates the global logger from the KEYWARDEN_LOG_LEVEL env var.
// Valid values: ERROR, WARN, INFO (default), DEBUG, TRACE
func Init(envValue string) {
global = &Logger{level: ParseLevel(envValue)}
global.Info("Log level set to %s", global.level.String())
}
// ParseLevel converts a string to a Level. Defaults to INFO on unknown input.
func ParseLevel(s string) Level {
switch strings.ToUpper(strings.TrimSpace(s)) {
case "ERROR":
return LevelError
case "WARN", "WARNING":
return LevelWarn
case "INFO", "":
return LevelInfo
case "DEBUG":
return LevelDebug
case "TRACE":
return LevelTrace
default:
return LevelInfo
}
}
// GetLevel returns the current global log level
func GetLevel() Level {
return global.level
}
// ---------------------------------------------------------------------------
// Core logging methods
// ---------------------------------------------------------------------------
func (l *Logger) log(lvl Level, format string, args ...interface{}) {
if lvl > l.level {
return
}
msg := fmt.Sprintf(format, args...)
log.Printf("[%-5s] %s", lvl.String(), msg)
}
// Error logs at ERROR level (always shown)
func Error(format string, args ...interface{}) { global.log(LevelError, format, args...) }
// Warn logs at WARN level
func Warn(format string, args ...interface{}) { global.log(LevelWarn, format, args...) }
// Info logs at INFO level
func Info(format string, args ...interface{}) { global.log(LevelInfo, format, args...) }
// Debug logs at DEBUG level
func Debug(format string, args ...interface{}) { global.log(LevelDebug, format, args...) }
// Trace logs at TRACE level (very verbose)
func Trace(format string, args ...interface{}) { global.log(LevelTrace, format, args...) }
// Fatal logs at ERROR level and exits the process (like log.Fatalf)
func Fatal(format string, args ...interface{}) {
msg := fmt.Sprintf(format, args...)
log.Fatalf("[ERROR] %s", msg)
}
// ---------------------------------------------------------------------------
// Convenience helpers
// ---------------------------------------------------------------------------
// Error-returning variant for wrapping + logging in one call
func (l *Logger) Info(format string, args ...interface{}) {
l.log(LevelInfo, format, args...)
}
// ---------------------------------------------------------------------------
// HTTP Request Logging Middleware
// ---------------------------------------------------------------------------
// responseWriter wraps http.ResponseWriter to capture the status code and bytes written
type responseWriter struct {
http.ResponseWriter
statusCode int
bytesWritten int
}
func newResponseWriter(w http.ResponseWriter) *responseWriter {
return &responseWriter{ResponseWriter: w, statusCode: http.StatusOK}
}
func (rw *responseWriter) WriteHeader(code int) {
rw.statusCode = code
rw.ResponseWriter.WriteHeader(code)
}
func (rw *responseWriter) Write(b []byte) (int, error) {
n, err := rw.ResponseWriter.Write(b)
rw.bytesWritten += n
return n, err
}
// RequestLogger returns middleware that logs every HTTP request.
// Output format:
//
// [INFO ] HTTP | 200 | 12.34ms | 192.168.1.1 | GET /dashboard | user=admin | Mozilla/5.0 ...
//
// At DEBUG level it additionally logs request headers.
// At TRACE level it logs everything including cookies (except values).
//
// An optional clientIPFunc can be provided to customise IP extraction
// (e.g. using trusted-proxy-aware logic). If omitted, the built-in
// extractClientIP is used.
func RequestLogger(getUserName func(r *http.Request) string, clientIPFunc ...func(r *http.Request) string) func(http.Handler) http.Handler {
getIP := extractClientIP
if len(clientIPFunc) > 0 && clientIPFunc[0] != nil {
getIP = clientIPFunc[0]
}
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
// Skip logging for static assets at INFO level to reduce noise
isStatic := strings.HasPrefix(r.URL.Path, "/static/") || strings.HasPrefix(r.URL.Path, "/avatar/")
start := time.Now()
wrapped := newResponseWriter(w)
// Process the request
next.ServeHTTP(wrapped, r)
duration := time.Since(start)
// Determine client IP
clientIP := getIP(r)
// Determine username (empty string if not authenticated)
username := ""
if getUserName != nil {
username = getUserName(r)
}
userAgent := r.UserAgent()
if len(userAgent) > 120 {
userAgent = userAgent[:120] + "…"
}
// Build the log line
userPart := ""
if username != "" {
userPart = fmt.Sprintf(" | user=%s", username)
}
// Static assets: only log at DEBUG or higher
if isStatic {
Debug("HTTP | %d | %10v | %-15s | %s %s%s | %s",
wrapped.statusCode, duration.Round(time.Microsecond),
clientIP, r.Method, r.URL.Path, userPart, userAgent)
} else {
Info("HTTP | %d | %10v | %-15s | %s %s%s | %s",
wrapped.statusCode, duration.Round(time.Microsecond),
clientIP, r.Method, r.URL.Path, userPart, userAgent)
}
// At TRACE level, log response size and all request headers
if GetLevel() >= LevelTrace {
Trace("HTTP response: %d bytes written for %s %s", wrapped.bytesWritten, r.Method, r.URL.Path)
Trace("HTTP request headers for %s %s:", r.Method, r.URL.Path)
for name, values := range r.Header {
// Redact sensitive headers
if strings.EqualFold(name, "Cookie") || strings.EqualFold(name, "Authorization") {
Trace(" %s: [REDACTED]", name)
} else {
Trace(" %s: %s", name, strings.Join(values, ", "))
}
}
}
})
}
}
// extractClientIP gets the real client IP, respecting reverse proxy headers
func extractClientIP(r *http.Request) string {
if fwd := r.Header.Get("X-Forwarded-For"); fwd != "" {
parts := strings.SplitN(fwd, ",", 2)
return strings.TrimSpace(parts[0])
}
if real := r.Header.Get("X-Real-Ip"); real != "" {
return real
}
// r.RemoteAddr is "ip:port"
if idx := strings.LastIndex(r.RemoteAddr, ":"); idx != -1 {
return r.RemoteAddr[:idx]
}
return r.RemoteAddr
}
+501
View File
@@ -0,0 +1,501 @@
// Keywarden - Centralized SSH Key Management and Deployment
// Copyright (C) 2026 Patrick Asmus (scriptos)
// SPDX-License-Identifier: AGPL-3.0-or-later
package mail
import (
"bytes"
"crypto/tls"
"fmt"
"mime"
"net"
"net/smtp"
"strings"
"text/template"
"time"
"git.techniverse.net/scriptos/keywarden/internal/config"
"git.techniverse.net/scriptos/keywarden/internal/logging"
)
// LoginNotificationData holds the template data for a login notification email
type LoginNotificationData struct {
Username string
IPAddress string
Timestamp string
UserAgent string
}
// InvitationData holds the template data for an invitation email
type InvitationData struct {
Username string
InviteURL string
ExpiresIn string
}
// Service handles email sending
type Service struct {
cfg *config.Config
enabled bool
}
// NewService creates a new mail service
func NewService(cfg *config.Config) *Service {
enabled := cfg.SMTPEnabled && cfg.SMTPHost != ""
if enabled {
logging.Info("Email notifications enabled (SMTP: %s:%s)", cfg.SMTPHost, cfg.SMTPPort)
} else {
logging.Info("Email notifications disabled (no SMTP host configured)")
}
return &Service{
cfg: cfg,
enabled: enabled,
}
}
// IsEnabled returns whether the mail service is configured and active
func (s *Service) IsEnabled() bool {
return s.enabled
}
// SendLoginNotification sends a login notification email to the user.
// This runs synchronously but callers should invoke it in a goroutine.
func (s *Service) SendLoginNotification(toEmail string, data LoginNotificationData) error {
if !s.enabled {
return nil
}
htmlBody, err := renderTemplate(loginNotificationHTML, data)
if err != nil {
return fmt.Errorf("failed to render HTML template: %w", err)
}
txtBody, err := renderTemplate(loginNotificationTXT, data)
if err != nil {
return fmt.Errorf("failed to render TXT template: %w", err)
}
subject := fmt.Sprintf("Keywarden: Login notification for %s", data.Username)
return s.sendMultipart(toEmail, subject, txtBody, htmlBody)
}
// SendTestEmail sends a test email to verify SMTP configuration
func (s *Service) SendTestEmail(toEmail string) error {
if !s.enabled {
return fmt.Errorf("email is not configured (KEYWARDEN_SMTP_HOST not set)")
}
subject := "Keywarden: SMTP Test Email"
txtBody := "This is a test email from Keywarden.\n\nIf you received this, your SMTP configuration is working correctly.\n"
htmlBody := `<!DOCTYPE html>
<html>
<body style="font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; margin: 0; padding: 20px; background-color: #f1f5f9;">
<div style="max-width: 500px; margin: 0 auto; background: #ffffff; border-radius: 8px; padding: 32px; box-shadow: 0 1px 3px rgba(0,0,0,0.1);">
<h2 style="color: #206bc4; margin-top: 0;">&#x1F511; Keywarden – SMTP Test</h2>
<p>This is a test email from Keywarden.</p>
<p style="color: #4caf50; font-weight: bold;">&#x2705; Your SMTP configuration is working correctly.</p>
</div>
</body>
</html>`
return s.sendMultipart(toEmail, subject, txtBody, htmlBody)
}
// SendInvitation sends an invitation email with a registration link to a new user.
func (s *Service) SendInvitation(toEmail string, data InvitationData) error {
if !s.enabled {
return fmt.Errorf("email is not configured (KEYWARDEN_SMTP_HOST not set)")
}
htmlBody, err := renderTemplate(invitationHTML, data)
if err != nil {
return fmt.Errorf("failed to render invitation HTML template: %w", err)
}
txtBody, err := renderTemplate(invitationTXT, data)
if err != nil {
return fmt.Errorf("failed to render invitation TXT template: %w", err)
}
subject := fmt.Sprintf("Keywarden: You have been invited – %s", data.Username)
return s.sendMultipart(toEmail, subject, txtBody, htmlBody)
}
// sendMultipart sends a multipart (text + HTML) email
func (s *Service) sendMultipart(to, subject, textBody, htmlBody string) error {
logging.Info("Sending email: to=%s subject='%s' smtp=%s:%s", to, subject, s.cfg.SMTPHost, s.cfg.SMTPPort)
logging.Debug("Email details: from=%s tls=%v", s.cfg.SMTPFrom, s.cfg.SMTPTLS)
boundary := fmt.Sprintf("keywarden-%d", time.Now().UnixNano())
// Encode Subject per RFC 2047 so non-ASCII characters (e.g. en-dash)
// are transmitted safely through all MTAs.
encodedSubject := mime.QEncoding.Encode("utf-8", subject)
headers := map[string]string{
"From": s.cfg.SMTPFrom,
"To": to,
"Subject": encodedSubject,
"MIME-Version": "1.0",
"Content-Type": fmt.Sprintf("multipart/alternative; boundary=\"%s\"", boundary),
"Date": time.Now().Format(time.RFC1123Z),
"X-Mailer": "Keywarden SSH Key Management",
}
var msg bytes.Buffer
for k, v := range headers {
msg.WriteString(fmt.Sprintf("%s: %s\r\n", k, v))
}
msg.WriteString("\r\n")
// Text part
msg.WriteString(fmt.Sprintf("--%s\r\n", boundary))
msg.WriteString("Content-Type: text/plain; charset=\"utf-8\"\r\n")
msg.WriteString("Content-Transfer-Encoding: 7bit\r\n\r\n")
msg.WriteString(textBody)
msg.WriteString("\r\n")
// HTML part
msg.WriteString(fmt.Sprintf("--%s\r\n", boundary))
msg.WriteString("Content-Type: text/html; charset=\"utf-8\"\r\n")
msg.WriteString("Content-Transfer-Encoding: 7bit\r\n\r\n")
msg.WriteString(htmlBody)
msg.WriteString("\r\n")
msg.WriteString(fmt.Sprintf("--%s--\r\n", boundary))
err := s.send(to, msg.Bytes())
if err != nil {
logging.Error("Email delivery failed: to=%s error=%v", to, err)
} else {
logging.Info("Email delivered successfully: to=%s", to)
}
return err
}
// send delivers a raw email message via SMTP
func (s *Service) send(to string, msg []byte) error {
addr := net.JoinHostPort(s.cfg.SMTPHost, s.cfg.SMTPPort)
var auth smtp.Auth
if s.cfg.SMTPUser != "" {
auth = smtp.PlainAuth("", s.cfg.SMTPUser, s.cfg.SMTPPassword, s.cfg.SMTPHost)
}
if s.cfg.SMTPTLS {
// STARTTLS or implicit TLS
tlsConfig := &tls.Config{
ServerName: s.cfg.SMTPHost,
MinVersion: tls.VersionTLS12,
}
// Try implicit TLS first (port 465), fall back to STARTTLS
if s.cfg.SMTPPort == "465" {
conn, err := tls.Dial("tcp", addr, tlsConfig)
if err != nil {
return fmt.Errorf("TLS dial failed: %w", err)
}
defer conn.Close()
client, err := smtp.NewClient(conn, s.cfg.SMTPHost)
if err != nil {
return fmt.Errorf("SMTP client creation failed: %w", err)
}
defer client.Close()
if auth != nil {
if err := client.Auth(auth); err != nil {
return fmt.Errorf("SMTP auth failed: %w", err)
}
}
if err := client.Mail(s.cfg.SMTPFrom); err != nil {
return fmt.Errorf("SMTP MAIL FROM failed: %w", err)
}
if err := client.Rcpt(to); err != nil {
return fmt.Errorf("SMTP RCPT TO failed: %w", err)
}
w, err := client.Data()
if err != nil {
return fmt.Errorf("SMTP DATA failed: %w", err)
}
if _, err := w.Write(msg); err != nil {
return fmt.Errorf("SMTP write failed: %w", err)
}
if err := w.Close(); err != nil {
return fmt.Errorf("SMTP close data failed: %w", err)
}
return client.Quit()
}
// STARTTLS (port 587 etc.)
conn, err := net.DialTimeout("tcp", addr, 10*time.Second)
if err != nil {
return fmt.Errorf("dial failed: %w", err)
}
defer conn.Close()
client, err := smtp.NewClient(conn, s.cfg.SMTPHost)
if err != nil {
return fmt.Errorf("SMTP client creation failed: %w", err)
}
defer client.Close()
if err := client.StartTLS(tlsConfig); err != nil {
return fmt.Errorf("STARTTLS failed: %w", err)
}
if auth != nil {
if err := client.Auth(auth); err != nil {
return fmt.Errorf("SMTP auth failed: %w", err)
}
}
if err := client.Mail(s.cfg.SMTPFrom); err != nil {
return fmt.Errorf("SMTP MAIL FROM failed: %w", err)
}
if err := client.Rcpt(to); err != nil {
return fmt.Errorf("SMTP RCPT TO failed: %w", err)
}
w, err := client.Data()
if err != nil {
return fmt.Errorf("SMTP DATA failed: %w", err)
}
if _, err := w.Write(msg); err != nil {
return fmt.Errorf("SMTP write failed: %w", err)
}
if err := w.Close(); err != nil {
return fmt.Errorf("SMTP close data failed: %w", err)
}
return client.Quit()
}
// Plain SMTP (no TLS) – use manual client to avoid Go's smtp.SendMail
// automatically attempting STARTTLS when the server advertises it.
conn, err := net.DialTimeout("tcp", addr, 10*time.Second)
if err != nil {
return fmt.Errorf("dial failed: %w", err)
}
defer conn.Close()
client, err := smtp.NewClient(conn, s.cfg.SMTPHost)
if err != nil {
return fmt.Errorf("SMTP client creation failed: %w", err)
}
defer client.Close()
if auth != nil {
if err := client.Auth(auth); err != nil {
return fmt.Errorf("SMTP auth failed: %w", err)
}
}
if err := client.Mail(s.cfg.SMTPFrom); err != nil {
return fmt.Errorf("SMTP MAIL FROM failed: %w", err)
}
if err := client.Rcpt(to); err != nil {
return fmt.Errorf("SMTP RCPT TO failed: %w", err)
}
w, err := client.Data()
if err != nil {
return fmt.Errorf("SMTP DATA failed: %w", err)
}
if _, err := w.Write(msg); err != nil {
return fmt.Errorf("SMTP write failed: %w", err)
}
if err := w.Close(); err != nil {
return fmt.Errorf("SMTP close data failed: %w", err)
}
return client.Quit()
}
func renderTemplate(tmplStr string, data interface{}) (string, error) {
tmpl, err := template.New("email").Parse(tmplStr)
if err != nil {
return "", err
}
var buf bytes.Buffer
if err := tmpl.Execute(&buf, data); err != nil {
return "", err
}
return buf.String(), nil
}
// --- Email Templates ---
var loginNotificationHTML = strings.TrimSpace(`
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
</head>
<body style="font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif; margin: 0; padding: 0; background-color: #f1f5f9; -webkit-font-smoothing: antialiased;">
<div style="max-width: 600px; margin: 0 auto; padding: 24px;">
<!-- Header -->
<div style="background: linear-gradient(135deg, #206bc4 0%, #1a56a0 100%); border-radius: 12px 12px 0 0; padding: 32px; text-align: center;">
<h1 style="color: #ffffff; margin: 0; font-size: 24px;">&#x1F511; Keywarden</h1>
<p style="color: rgba(255,255,255,0.8); margin: 8px 0 0; font-size: 14px;">Centralized SSH Key Management and Deployment</p>
</div>
<!-- Body -->
<div style="background: #ffffff; padding: 32px; border-radius: 0 0 12px 12px; box-shadow: 0 2px 8px rgba(0,0,0,0.08);">
<h2 style="color: #1e293b; margin-top: 0; font-size: 20px;">Login Notification</h2>
<p style="color: #475569; line-height: 1.6;">
A successful login to your Keywarden account was detected:
</p>
<table style="width: 100%; border-collapse: collapse; margin: 24px 0;">
<tr>
<td style="padding: 12px 16px; background: #f8fafc; border: 1px solid #e2e8f0; font-weight: 600; color: #334155; width: 140px;">User</td>
<td style="padding: 12px 16px; border: 1px solid #e2e8f0; color: #475569;">{{.Username}}</td>
</tr>
<tr>
<td style="padding: 12px 16px; background: #f8fafc; border: 1px solid #e2e8f0; font-weight: 600; color: #334155;">IP Address</td>
<td style="padding: 12px 16px; border: 1px solid #e2e8f0; color: #475569;">{{.IPAddress}}</td>
</tr>
<tr>
<td style="padding: 12px 16px; background: #f8fafc; border: 1px solid #e2e8f0; font-weight: 600; color: #334155;">Time</td>
<td style="padding: 12px 16px; border: 1px solid #e2e8f0; color: #475569;">{{.Timestamp}}</td>
</tr>
<tr>
<td style="padding: 12px 16px; background: #f8fafc; border: 1px solid #e2e8f0; font-weight: 600; color: #334155;">Browser</td>
<td style="padding: 12px 16px; border: 1px solid #e2e8f0; color: #475569;">{{.UserAgent}}</td>
</tr>
</table>
<div style="background: #fff3cd; border: 1px solid #ffc107; border-radius: 8px; padding: 16px; margin: 24px 0;">
<p style="margin: 0; color: #856404; font-size: 14px;">
&#x26A0;&#xFE0F; <strong>If this was not you</strong>, please change your password immediately and review your account security settings.
</p>
</div>
<p style="color: #94a3b8; font-size: 12px; margin-bottom: 0;">
You received this email because login notifications are enabled for your account. You can disable them in the Settings page.
</p>
</div>
<!-- Footer -->
<div style="text-align: center; padding: 16px; color: #94a3b8; font-size: 12px;">
&copy; 2026 Keywarden &ndash; Centralized SSH Key Management and Deployment
</div>
</div>
</body>
</html>
`)
var loginNotificationTXT = strings.TrimSpace(`
Keywarden - Login Notification
============================
A successful login to your Keywarden account was detected:
User: {{.Username}}
IP Address: {{.IPAddress}}
Time: {{.Timestamp}}
Browser: {{.UserAgent}}
If this was not you, please change your password immediately
and review your account security settings.
--
You received this email because login notifications are enabled
for your account. You can disable them in the Settings page.
Keywarden - Centralized SSH Key Management and Deployment
`)
var invitationHTML = strings.TrimSpace(`
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
</head>
<body style="font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif; margin: 0; padding: 0; background-color: #f1f5f9; -webkit-font-smoothing: antialiased;">
<div style="max-width: 600px; margin: 0 auto; padding: 24px;">
<!-- Header -->
<div style="background: linear-gradient(135deg, #206bc4 0%, #1a56a0 100%); border-radius: 12px 12px 0 0; padding: 32px; text-align: center;">
<h1 style="color: #ffffff; margin: 0; font-size: 24px;">&#x1F511; Keywarden</h1>
<p style="color: rgba(255,255,255,0.8); margin: 8px 0 0; font-size: 14px;">Centralized SSH Key Management and Deployment</p>
</div>
<!-- Body -->
<div style="background: #ffffff; padding: 32px; border-radius: 0 0 12px 12px; box-shadow: 0 2px 8px rgba(0,0,0,0.08);">
<h2 style="color: #1e293b; margin-top: 0; font-size: 20px;">You have been invited!</h2>
<p style="color: #475569; line-height: 1.6;">
An account has been created for you in Keywarden. Please complete your registration by setting a password.
</p>
<table style="width: 100%; border-collapse: collapse; margin: 24px 0;">
<tr>
<td style="padding: 12px 16px; background: #f8fafc; border: 1px solid #e2e8f0; font-weight: 600; color: #334155; width: 140px;">Username</td>
<td style="padding: 12px 16px; border: 1px solid #e2e8f0; color: #475569;">{{.Username}}</td>
</tr>
<tr>
<td style="padding: 12px 16px; background: #f8fafc; border: 1px solid #e2e8f0; font-weight: 600; color: #334155;">Valid for</td>
<td style="padding: 12px 16px; border: 1px solid #e2e8f0; color: #475569;">{{.ExpiresIn}}</td>
</tr>
</table>
<div style="text-align: center; margin: 32px 0;">
<a href="{{.InviteURL}}" style="display: inline-block; background: #206bc4; color: #ffffff; text-decoration: none; padding: 14px 32px; border-radius: 8px; font-weight: 600; font-size: 16px;">
&#x1F680; Complete Registration
</a>
</div>
<div style="background: #f0f9ff; border: 1px solid #bae6fd; border-radius: 8px; padding: 16px; margin: 24px 0;">
<p style="margin: 0; color: #0369a1; font-size: 14px;">
&#x1F6C8; If the button does not work, copy and paste this link into your browser:
</p>
<p style="margin: 8px 0 0; color: #0369a1; font-size: 12px; word-break: break-all;">{{.InviteURL}}</p>
</div>
<div style="background: #fff3cd; border: 1px solid #ffc107; border-radius: 8px; padding: 16px; margin: 24px 0;">
<p style="margin: 0; color: #856404; font-size: 14px;">
&#x26A0;&#xFE0F; <strong>This link is valid for {{.ExpiresIn}} and can only be used once.</strong> If you did not expect this invitation, you can safely ignore this email.
</p>
</div>
<p style="color: #94a3b8; font-size: 12px; margin-bottom: 0;">
This is an automated invitation from Keywarden.
</p>
</div>
<!-- Footer -->
<div style="text-align: center; padding: 16px; color: #94a3b8; font-size: 12px;">
&copy; 2026 Keywarden &ndash; Centralized SSH Key Management and Deployment
</div>
</div>
</body>
</html>
`)
var invitationTXT = strings.TrimSpace(`
Keywarden - Invitation
======================
You have been invited to Keywarden!
An account has been created for you. Please complete your registration
by setting a password.
Username: {{.Username}}
Valid for: {{.ExpiresIn}}
Complete your registration here:
{{.InviteURL}}
This link is valid for {{.ExpiresIn}} and can only be used once.
If you did not expect this invitation, you can safely ignore this email.
--
Keywarden - Centralized SSH Key Management and Deployment
`)
+180
View File
@@ -0,0 +1,180 @@
// Keywarden - Centralized SSH Key Management and Deployment
// Copyright (C) 2026 Patrick Asmus (scriptos)
// SPDX-License-Identifier: AGPL-3.0-or-later
package models
import "time"
// User represents a registered user
type User struct {
ID int64 `json:"id"`
Username string `json:"username"`
Email string `json:"email"`
PasswordHash string `json:"-"`
Role string `json:"role"`
MFAEnabled bool `json:"mfa_enabled"`
MFASecret string `json:"-"`
Theme string `json:"theme"` // "auto", "light", "dark"
EmailNotifyLogin bool `json:"email_notify_login"`
AvatarBase64 string `json:"avatar_base64"` // base64-encoded profile picture (data URI)
MustChangePassword bool `json:"must_change_password"` // force password change on next login
FailedLoginAttempts int `json:"failed_login_attempts"` // consecutive failed login attempts
LockedUntil *time.Time `json:"locked_until"` // account locked until this time
LastLoginAt *time.Time `json:"last_login_at"` // last successful login
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
}
// PasswordPolicy holds the password complexity requirements
type PasswordPolicy struct {
MinLength int `json:"min_length"`
RequireUpper bool `json:"require_upper"`
RequireLower bool `json:"require_lower"`
RequireDigit bool `json:"require_digit"`
RequireSpecial bool `json:"require_special"`
}
// SSHKey represents a stored SSH key pair
type SSHKey struct {
ID int64 `json:"id"`
UserID int64 `json:"user_id"`
Name string `json:"name"`
KeyType string `json:"key_type"` // "rsa", "ed25519", or "ed448"
Bits int `json:"bits"` // 2048, 4096 for RSA; 256 for Ed25519; 456 for Ed448
Fingerprint string `json:"fingerprint"`
PublicKey string `json:"public_key"`
PrivateKeyEnc string `json:"-"` // encrypted private key
PassphraseEnc string `json:"-"` // encrypted passphrase (optional)
CreatedAt time.Time `json:"created_at"`
}
// SSHKeyWithOwner extends SSHKey with the owner's username for admin views
type SSHKeyWithOwner struct {
SSHKey
OwnerUsername string `json:"owner_username"`
}
// Server represents a remote SSH server
type Server struct {
ID int64 `json:"id"`
UserID int64 `json:"user_id"`
Name string `json:"name"`
Hostname string `json:"hostname"`
Port int `json:"port"`
Username string `json:"username"`
Description string `json:"description"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
}
// KeyDeployment represents a key deployed to a server
type KeyDeployment struct {
ID int64 `json:"id"`
SSHKeyID int64 `json:"ssh_key_id"`
ServerID int64 `json:"server_id"`
DeployedAt time.Time `json:"deployed_at"`
Status string `json:"status"` // "pending", "success", "failed"
Message string `json:"message"`
}
// ServerGroup represents a group of servers
type ServerGroup struct {
ID int64 `json:"id"`
UserID int64 `json:"user_id"`
Name string `json:"name"`
Description string `json:"description"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
}
// ServerGroupWithCount extends ServerGroup with the number of member servers
type ServerGroupWithCount struct {
ServerGroup
ServerCount int `json:"server_count"`
}
// AuditLog represents an audit trail entry
type AuditLog struct {
ID int64 `json:"id"`
UserID int64 `json:"user_id"`
Action string `json:"action"`
Details string `json:"details"`
IPAddress string `json:"ip_address"`
CreatedAt time.Time `json:"created_at"`
}
// CronJob represents a scheduled temporary access job
type CronJob struct {
ID int64 `json:"id"`
UserID int64 `json:"user_id"`
Name string `json:"name"`
SSHKeyID int64 `json:"ssh_key_id"`
ServerID int64 `json:"server_id"` // 0 if targeting a group
GroupID int64 `json:"group_id"` // 0 if targeting a single server
Schedule string `json:"schedule"` // "once", "hourly", "daily", "weekly", "monthly"
ScheduledAt time.Time `json:"scheduled_at"`
NextRun time.Time `json:"next_run"`
LastRun *time.Time `json:"last_run"`
RemoveAfterMin int `json:"remove_after_min"` // 0 = permanent
Status string `json:"status"` // "active", "paused", "running", "done", "failed"
Message string `json:"message"`
Timezone string `json:"timezone"` // IANA timezone, e.g. "Europe/Berlin"
TimeOfDay string `json:"time_of_day"` // "HH:MM" for daily/weekly/monthly
DayOfWeek int `json:"day_of_week"` // 0=Sunday..6=Saturday (-1=unset)
DayOfMonth int `json:"day_of_month"` // 1-31 (0=unset)
MinuteOfHour int `json:"minute_of_hour"` // 0-59 for hourly schedule
TargetUserID int64 `json:"target_user_id"` // KeyWarden user to grant access
SystemUser string `json:"system_user"` // system user on target host
Sudo bool `json:"sudo"` // grant sudo rights
CreateUser bool `json:"create_user"` // create system user if missing
InitialPassword string `json:"initial_password"` // encrypted initial password for created user
ExpiryAction string `json:"expiry_action"` // "remove_key", "disable_user", "delete_user"
CreatedAt time.Time `json:"created_at"`
}
// CronJobDisplay extends CronJob with resolved names for UI display
type CronJobDisplay struct {
CronJob
KeyName string `json:"key_name"`
TargetName string `json:"target_name"`
TargetType string `json:"target_type"` // "host" or "group"
TargetUsername string `json:"target_username"` // KeyWarden username
}
// AccessAssignment represents an access assignment (user+key → host/group)
type AccessAssignment struct {
ID int64 `json:"id"`
UserID int64 `json:"user_id"` // Keywarden user
SSHKeyID int64 `json:"ssh_key_id"` // SSH key to deploy
ServerID int64 `json:"server_id"` // target host (0 if group)
GroupID int64 `json:"group_id"` // target group (0 if single host)
SystemUser string `json:"system_user"` // system user on target host
DesiredState string `json:"desired_state"` // "present" or "absent"
Sudo bool `json:"sudo"` // grant sudo rights
CreateUser bool `json:"create_user"` // create system user if missing
InitialPassword string `json:"initial_password"` // encrypted initial password for created user
Status string `json:"status"` // "pending", "synced", "failed"
LastSyncAt *time.Time `json:"last_sync_at"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
}
// AccessAssignmentDisplay extends AccessAssignment with resolved names for UI
type AccessAssignmentDisplay struct {
AccessAssignment
Username string `json:"username"`
KeyName string `json:"key_name"`
TargetName string `json:"target_name"`
TargetType string `json:"target_type"` // "host" or "group"
}
// InvitationToken represents a one-time invitation link for a new user
type InvitationToken struct {
ID int64 `json:"id"`
UserID int64 `json:"user_id"`
Token string `json:"token"`
ExpiresAt time.Time `json:"expires_at"`
Used bool `json:"used"`
CreatedAt time.Time `json:"created_at"`
}
+70
View File
@@ -0,0 +1,70 @@
// Keywarden - Centralized SSH Key Management and Deployment
// Copyright (C) 2026 Patrick Asmus (scriptos)
// SPDX-License-Identifier: AGPL-3.0-or-later
package security
import (
"crypto/rand"
"crypto/subtle"
"fmt"
"net/http"
)
// CSRFMiddleware returns middleware that implements the Double-Submit Cookie
// pattern for CSRF protection.
//
// On every request a _csrf cookie is ensured (generated if absent). On
// state-changing methods (POST, PUT, DELETE, PATCH) the middleware validates
// that the request carries a matching token either as:
// - a form field named "_csrf", or
// - an X-CSRF-Token request header (for AJAX / fetch calls).
//
// The cookie is NOT HttpOnly so that client-side JavaScript can read the
// value and inject it into forms / fetch headers automatically.
func CSRFMiddleware(secureCookies bool) func(http.Handler) http.Handler {
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
// --- Ensure CSRF token exists ---
token := ""
if c, err := r.Cookie("_csrf"); err == nil && len(c.Value) == 64 {
token = c.Value
}
if token == "" {
b := make([]byte, 32)
if _, err := rand.Read(b); err != nil {
http.Error(w, "Internal Server Error", http.StatusInternalServerError)
return
}
token = fmt.Sprintf("%x", b)
http.SetCookie(w, &http.Cookie{
Name: "_csrf",
Value: token,
Path: "/",
HttpOnly: false, // JS must be able to read it
Secure: secureCookies,
SameSite: http.SameSiteStrictMode,
MaxAge: 86400, // 24 hours
})
}
// --- Validate on state-changing methods ---
if r.Method == http.MethodPost || r.Method == http.MethodPut ||
r.Method == http.MethodDelete || r.Method == http.MethodPatch {
// Accept the token from either the form body or a request header
submitted := r.FormValue("_csrf")
if submitted == "" {
submitted = r.Header.Get("X-CSRF-Token")
}
if subtle.ConstantTimeCompare([]byte(submitted), []byte(token)) != 1 {
http.Error(w, "Forbidden – invalid or missing CSRF token", http.StatusForbidden)
return
}
}
next.ServeHTTP(w, r)
})
}
}
+65
View File
@@ -0,0 +1,65 @@
// Keywarden - Centralized SSH Key Management and Deployment
// Copyright (C) 2026 Patrick Asmus (scriptos)
// SPDX-License-Identifier: AGPL-3.0-or-later
package security
import (
"net/http"
)
// HeadersMiddleware returns middleware that sets security-relevant HTTP
// response headers on every response.
//
// These headers protect against clickjacking, MIME-sniffing, information
// leakage and other common web attacks. They are set regardless of
// whether TLS is in use.
func HeadersMiddleware() func(http.Handler) http.Handler {
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
h := w.Header()
// Prevent the page from being embedded in an iframe (clickjacking)
h.Set("X-Frame-Options", "DENY")
// Stop browsers from MIME-sniffing the content type
h.Set("X-Content-Type-Options", "nosniff")
// Control what information is sent in the Referer header
h.Set("Referrer-Policy", "strict-origin-when-cross-origin")
// Restrict browser features that are not needed
h.Set("Permissions-Policy", "camera=(), microphone=(), geolocation=(), payment=()")
// Content Security Policy – fairly strict but allows inline
// styles/scripts that Tabler and the app currently use.
h.Set("Content-Security-Policy",
"default-src 'self'; "+
"script-src 'self' 'unsafe-inline' https://cdn.jsdelivr.net; "+
"style-src 'self' 'unsafe-inline'; "+
"img-src 'self' data:; "+
"font-src 'self' data:; "+
"connect-src 'self'; "+
"frame-ancestors 'none'; "+
"form-action 'self'; "+
"base-uri 'self'")
// Opt out of Google FLoC / Topics
h.Set("X-Permitted-Cross-Domain-Policies", "none")
// Cache control for authenticated pages – prevent caching of
// sensitive data. Static assets set their own cache headers.
if r.URL.Path != "" && !isStaticAsset(r.URL.Path) {
h.Set("Cache-Control", "no-store, no-cache, must-revalidate, private")
h.Set("Pragma", "no-cache")
}
next.ServeHTTP(w, r)
})
}
}
// isStaticAsset returns true for paths that serve static files.
func isStaticAsset(path string) bool {
return len(path) > 8 && (path[:8] == "/static/" || path[:8] == "/avatar/")
}
+112
View File
@@ -0,0 +1,112 @@
// Keywarden - Centralized SSH Key Management and Deployment
// Copyright (C) 2026 Patrick Asmus (scriptos)
// SPDX-License-Identifier: AGPL-3.0-or-later
package security
import (
"net"
"net/http"
"strings"
)
// trustedNets holds the parsed trusted proxy CIDR ranges.
// Set once at startup via Init().
var trustedNets []*net.IPNet
// Init parses the trusted proxy configuration and prepares the package
// for use. Must be called once at startup before any middleware runs.
//
// trustedProxies is a comma-separated list of CIDRs or IPs, e.g.
// "10.0.0.0/8, 172.16.0.0/12, 192.168.0.0/16" or "10.0.1.92".
// An empty string means no trusted proxies are configured (legacy mode:
// proxy headers are trusted unconditionally for backward compatibility).
func Init(trustedProxies string) {
trustedNets = nil
if trustedProxies == "" {
return
}
for _, entry := range strings.Split(trustedProxies, ",") {
cidr := strings.TrimSpace(entry)
if cidr == "" {
continue
}
// Plain IP → convert to single-host CIDR
if !strings.Contains(cidr, "/") {
if strings.Contains(cidr, ":") {
cidr += "/128"
} else {
cidr += "/32"
}
}
_, ipNet, err := net.ParseCIDR(cidr)
if err != nil {
continue // skip invalid entries silently
}
trustedNets = append(trustedNets, ipNet)
}
}
// ClientIP extracts the real client IP address from the request.
//
// When trusted proxies are configured, X-Forwarded-For is walked from
// right to left and the first non-trusted IP is returned (secure approach).
// When no trusted proxies are configured, the legacy behavior is used
// (leftmost X-Forwarded-For entry, i.e. the value the first proxy saw).
func ClientIP(r *http.Request) string {
remoteIP := extractRemoteIP(r.RemoteAddr)
if len(trustedNets) > 0 {
// Strict mode: only honour proxy headers when the direct peer is trusted
if !isTrustedIP(remoteIP) {
return remoteIP
}
if fwd := r.Header.Get("X-Forwarded-For"); fwd != "" {
parts := strings.Split(fwd, ",")
// Walk right-to-left: rightmost untrusted IP is the real client
for i := len(parts) - 1; i >= 0; i-- {
ip := strings.TrimSpace(parts[i])
if ip != "" && !isTrustedIP(ip) {
return ip
}
}
}
if real := r.Header.Get("X-Real-Ip"); real != "" {
return real
}
return remoteIP
}
// Legacy mode (no trusted proxies configured): trust headers as before
if fwd := r.Header.Get("X-Forwarded-For"); fwd != "" {
parts := strings.SplitN(fwd, ",", 2)
return strings.TrimSpace(parts[0])
}
if real := r.Header.Get("X-Real-Ip"); real != "" {
return real
}
return remoteIP
}
// extractRemoteIP strips the port from r.RemoteAddr.
func extractRemoteIP(addr string) string {
host, _, err := net.SplitHostPort(addr)
if err == nil {
return host
}
return addr
}
// isTrustedIP checks if an IP is within any of the configured trusted networks.
func isTrustedIP(ip string) bool {
parsed := net.ParseIP(ip)
if parsed == nil {
return false
}
for _, n := range trustedNets {
if n.Contains(parsed) {
return true
}
}
return false
}
+96
View File
@@ -0,0 +1,96 @@
// Keywarden - Centralized SSH Key Management and Deployment
// Copyright (C) 2026 Patrick Asmus (scriptos)
// SPDX-License-Identifier: AGPL-3.0-or-later
package security
import (
"net/http"
"strings"
"sync"
"time"
)
// rateLimiter implements a fixed-window IP-based rate limiter.
type rateLimiter struct {
mu sync.Mutex
clients map[string]*window
limit int
period time.Duration
}
type window struct {
count int
resetAt time.Time
}
func newRateLimiter(limit int, period time.Duration) *rateLimiter {
rl := &rateLimiter{
clients: make(map[string]*window),
limit: limit,
period: period,
}
// Background goroutine to evict expired entries (prevent memory leak)
go func() {
ticker := time.NewTicker(5 * time.Minute)
defer ticker.Stop()
for range ticker.C {
now := time.Now()
rl.mu.Lock()
for ip, w := range rl.clients {
if now.After(w.resetAt) {
delete(rl.clients, ip)
}
}
rl.mu.Unlock()
}
}()
return rl
}
func (rl *rateLimiter) allow(ip string) bool {
rl.mu.Lock()
defer rl.mu.Unlock()
now := time.Now()
w, ok := rl.clients[ip]
if !ok || now.After(w.resetAt) {
rl.clients[ip] = &window{count: 1, resetAt: now.Add(rl.period)}
return true
}
w.count++
return w.count <= rl.limit
}
// RateLimitMiddleware returns middleware that rate-limits requests to the
// login endpoint by client IP address.
//
// loginLimit is the maximum number of login attempts per IP per minute.
// A value of 0 disables rate limiting entirely.
func RateLimitMiddleware(loginLimit int) func(http.Handler) http.Handler {
if loginLimit <= 0 {
// Disabled – pass through
return func(next http.Handler) http.Handler { return next }
}
loginRL := newRateLimiter(loginLimit, 1*time.Minute)
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
// Only rate-limit POST to login endpoints
if r.Method == http.MethodPost && isLoginPath(r.URL.Path) {
ip := ClientIP(r)
if !loginRL.allow(ip) {
http.Error(w, "Too Many Requests – please try again later", http.StatusTooManyRequests)
return
}
}
next.ServeHTTP(w, r)
})
}
}
// isLoginPath returns true for login-related POST paths.
func isLoginPath(path string) bool {
return path == "/login" || strings.HasPrefix(path, "/login/")
}
+30
View File
@@ -0,0 +1,30 @@
// Keywarden - Centralized SSH Key Management and Deployment
// Copyright (C) 2026 Patrick Asmus (scriptos)
// SPDX-License-Identifier: AGPL-3.0-or-later
package security
import (
"net/http"
)
// SizeLimitMiddleware returns middleware that limits the size of incoming
// request bodies to maxBytes. This prevents denial-of-service attacks via
// excessively large uploads. A value of 0 disables the limit.
//
// The limit is enforced with http.MaxBytesReader which causes the server
// to return 413 Request Entity Too Large if the body exceeds the limit.
func SizeLimitMiddleware(maxBytes int64) func(http.Handler) http.Handler {
if maxBytes <= 0 {
return func(next http.Handler) http.Handler { return next }
}
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Body != nil && r.ContentLength != 0 {
r.Body = http.MaxBytesReader(w, r.Body, maxBytes)
}
next.ServeHTTP(w, r)
})
}
}
+776
View File
@@ -0,0 +1,776 @@
// Keywarden - Centralized SSH Key Management and Deployment
// Copyright (C) 2026 Patrick Asmus (scriptos)
// SPDX-License-Identifier: AGPL-3.0-or-later
package servers
import (
"fmt"
"git.techniverse.net/scriptos/keywarden/internal/database"
"git.techniverse.net/scriptos/keywarden/internal/models"
)
// Service handles server management
type Service struct {
db *database.DB
}
// NewService creates a new server service
func NewService(db *database.DB) *Service {
return &Service{db: db}
}
// Create adds a new server
func (s *Service) Create(userID int64, name, hostname string, port int, username, description string) (*models.Server, error) {
if port == 0 {
port = 22
}
result, err := s.db.Exec(
`INSERT INTO servers (user_id, name, hostname, port, username, description) VALUES (?, ?, ?, ?, ?, ?)`,
userID, name, hostname, port, username, description,
)
if err != nil {
return nil, fmt.Errorf("failed to create server: %w", err)
}
id, _ := result.LastInsertId()
return &models.Server{
ID: id,
UserID: userID,
Name: name,
Hostname: hostname,
Port: port,
Username: username,
Description: description,
}, nil
}
// GetByUser returns all servers for a user
func (s *Service) GetByUser(userID int64) ([]models.Server, error) {
rows, err := s.db.Query(
`SELECT id, user_id, name, hostname, port, username, description, created_at, updated_at
FROM servers WHERE user_id = ? ORDER BY name ASC`, userID,
)
if err != nil {
return nil, fmt.Errorf("failed to query servers: %w", err)
}
defer rows.Close()
var servers []models.Server
for rows.Next() {
var srv models.Server
if err := rows.Scan(&srv.ID, &srv.UserID, &srv.Name, &srv.Hostname, &srv.Port, &srv.Username, &srv.Description, &srv.CreatedAt, &srv.UpdatedAt); err != nil {
return nil, fmt.Errorf("failed to scan server: %w", err)
}
servers = append(servers, srv)
}
return servers, nil
}
// GetByID returns a specific server
func (s *Service) GetByID(serverID, userID int64) (*models.Server, error) {
srv := &models.Server{}
err := s.db.QueryRow(
`SELECT id, user_id, name, hostname, port, username, description, created_at, updated_at
FROM servers WHERE id = ? AND user_id = ?`, serverID, userID,
).Scan(&srv.ID, &srv.UserID, &srv.Name, &srv.Hostname, &srv.Port, &srv.Username, &srv.Description, &srv.CreatedAt, &srv.UpdatedAt)
if err != nil {
return nil, fmt.Errorf("server not found: %w", err)
}
return srv, nil
}
// Update modifies a server
func (s *Service) Update(serverID, userID int64, name, hostname string, port int, username, description string) error {
result, err := s.db.Exec(
`UPDATE servers SET name=?, hostname=?, port=?, username=?, description=?, updated_at=CURRENT_TIMESTAMP
WHERE id=? AND user_id=?`,
name, hostname, port, username, description, serverID, userID,
)
if err != nil {
return fmt.Errorf("failed to update server: %w", err)
}
rows, _ := result.RowsAffected()
if rows == 0 {
return fmt.Errorf("server not found")
}
return nil
}
// Delete removes a server
func (s *Service) Delete(serverID, userID int64) error {
result, err := s.db.Exec(`DELETE FROM servers WHERE id = ? AND user_id = ?`, serverID, userID)
if err != nil {
return fmt.Errorf("failed to delete server: %w", err)
}
rows, _ := result.RowsAffected()
if rows == 0 {
return fmt.Errorf("server not found")
}
return nil
}
// --- Server Groups ---
// CreateGroup creates a new server group
func (s *Service) CreateGroup(userID int64, name, description string) (*models.ServerGroup, error) {
result, err := s.db.Exec(
`INSERT INTO server_groups (user_id, name, description) VALUES (?, ?, ?)`,
userID, name, description,
)
if err != nil {
return nil, fmt.Errorf("failed to create server group: %w", err)
}
id, _ := result.LastInsertId()
return &models.ServerGroup{
ID: id,
UserID: userID,
Name: name,
Description: description,
}, nil
}
// GetGroupsByUser returns all server groups for a user with server counts
func (s *Service) GetGroupsByUser(userID int64) ([]models.ServerGroupWithCount, error) {
rows, err := s.db.Query(
`SELECT sg.id, sg.user_id, sg.name, sg.description, sg.created_at, sg.updated_at,
COUNT(sgm.server_id) as server_count
FROM server_groups sg
LEFT JOIN server_group_members sgm ON sg.id = sgm.group_id
WHERE sg.user_id = ?
GROUP BY sg.id
ORDER BY sg.name ASC`, userID,
)
if err != nil {
return nil, fmt.Errorf("failed to query server groups: %w", err)
}
defer rows.Close()
var groups []models.ServerGroupWithCount
for rows.Next() {
var g models.ServerGroupWithCount
if err := rows.Scan(&g.ID, &g.UserID, &g.Name, &g.Description, &g.CreatedAt, &g.UpdatedAt, &g.ServerCount); err != nil {
return nil, fmt.Errorf("failed to scan server group: %w", err)
}
groups = append(groups, g)
}
return groups, nil
}
// GetGroupByID returns a specific server group
func (s *Service) GetGroupByID(groupID, userID int64) (*models.ServerGroup, error) {
g := &models.ServerGroup{}
err := s.db.QueryRow(
`SELECT id, user_id, name, description, created_at, updated_at
FROM server_groups WHERE id = ? AND user_id = ?`, groupID, userID,
).Scan(&g.ID, &g.UserID, &g.Name, &g.Description, &g.CreatedAt, &g.UpdatedAt)
if err != nil {
return nil, fmt.Errorf("server group not found: %w", err)
}
return g, nil
}
// UpdateGroup modifies a server group
func (s *Service) UpdateGroup(groupID, userID int64, name, description string) error {
result, err := s.db.Exec(
`UPDATE server_groups SET name=?, description=?, updated_at=CURRENT_TIMESTAMP
WHERE id=? AND user_id=?`,
name, description, groupID, userID,
)
if err != nil {
return fmt.Errorf("failed to update server group: %w", err)
}
rows, _ := result.RowsAffected()
if rows == 0 {
return fmt.Errorf("server group not found")
}
return nil
}
// DeleteGroup removes a server group
func (s *Service) DeleteGroup(groupID, userID int64) error {
result, err := s.db.Exec(`DELETE FROM server_groups WHERE id = ? AND user_id = ?`, groupID, userID)
if err != nil {
return fmt.Errorf("failed to delete server group: %w", err)
}
rows, _ := result.RowsAffected()
if rows == 0 {
return fmt.Errorf("server group not found")
}
return nil
}
// AddServerToGroup adds a server to a group
func (s *Service) AddServerToGroup(groupID, serverID, userID int64) error {
// Verify group belongs to user
_, err := s.GetGroupByID(groupID, userID)
if err != nil {
return fmt.Errorf("group not found: %w", err)
}
// Verify server belongs to user
_, err = s.GetByID(serverID, userID)
if err != nil {
return fmt.Errorf("server not found: %w", err)
}
_, err = s.db.Exec(
`INSERT OR IGNORE INTO server_group_members (group_id, server_id) VALUES (?, ?)`,
groupID, serverID,
)
if err != nil {
return fmt.Errorf("failed to add server to group: %w", err)
}
return nil
}
// RemoveServerFromGroup removes a server from a group
func (s *Service) RemoveServerFromGroup(groupID, serverID, userID int64) error {
// Verify group belongs to user
_, err := s.GetGroupByID(groupID, userID)
if err != nil {
return fmt.Errorf("group not found: %w", err)
}
_, err = s.db.Exec(
`DELETE FROM server_group_members WHERE group_id = ? AND server_id = ?`,
groupID, serverID,
)
if err != nil {
return fmt.Errorf("failed to remove server from group: %w", err)
}
return nil
}
// AddServerToGroupGlobal adds a server to a group without user_id check
func (s *Service) AddServerToGroupGlobal(groupID, serverID int64) error {
_, err := s.db.Exec(
`INSERT OR IGNORE INTO server_group_members (group_id, server_id) VALUES (?, ?)`,
groupID, serverID,
)
if err != nil {
return fmt.Errorf("failed to add server to group: %w", err)
}
return nil
}
// RemoveServerFromGroupGlobal removes a server from a group without user_id check
func (s *Service) RemoveServerFromGroupGlobal(groupID, serverID int64) error {
_, err := s.db.Exec(
`DELETE FROM server_group_members WHERE group_id = ? AND server_id = ?`,
groupID, serverID,
)
if err != nil {
return fmt.Errorf("failed to remove server from group: %w", err)
}
return nil
}
// GetGroupMembers returns all servers in a group
func (s *Service) GetGroupMembers(groupID, userID int64) ([]models.Server, error) {
rows, err := s.db.Query(
`SELECT s.id, s.user_id, s.name, s.hostname, s.port, s.username, s.description, s.created_at, s.updated_at
FROM servers s
JOIN server_group_members sgm ON s.id = sgm.server_id
WHERE sgm.group_id = ? AND s.user_id = ?
ORDER BY s.name ASC`, groupID, userID,
)
if err != nil {
return nil, fmt.Errorf("failed to query group members: %w", err)
}
defer rows.Close()
var servers []models.Server
for rows.Next() {
var srv models.Server
if err := rows.Scan(&srv.ID, &srv.UserID, &srv.Name, &srv.Hostname, &srv.Port, &srv.Username, &srv.Description, &srv.CreatedAt, &srv.UpdatedAt); err != nil {
return nil, fmt.Errorf("failed to scan server: %w", err)
}
servers = append(servers, srv)
}
return servers, nil
}
// GetGroupIDsForServer returns all group IDs that a server belongs to
func (s *Service) GetGroupIDsForServer(serverID, userID int64) ([]int64, error) {
rows, err := s.db.Query(
`SELECT sgm.group_id FROM server_group_members sgm
JOIN server_groups sg ON sg.id = sgm.group_id
WHERE sgm.server_id = ? AND sg.user_id = ?`, serverID, userID,
)
if err != nil {
return nil, fmt.Errorf("failed to query server groups: %w", err)
}
defer rows.Close()
var ids []int64
for rows.Next() {
var id int64
if err := rows.Scan(&id); err != nil {
continue
}
ids = append(ids, id)
}
return ids, nil
}
// SetServerGroups replaces all group memberships for a server
func (s *Service) SetServerGroups(serverID, userID int64, groupIDs []int64) error {
// Verify server belongs to user
_, err := s.GetByID(serverID, userID)
if err != nil {
return fmt.Errorf("server not found: %w", err)
}
// Remove all current group memberships for this server
_, err = s.db.Exec(`DELETE FROM server_group_members WHERE server_id = ?`, serverID)
if err != nil {
return fmt.Errorf("failed to clear group memberships: %w", err)
}
// Add new memberships
for _, gid := range groupIDs {
// Verify group belongs to user
_, err := s.GetGroupByID(gid, userID)
if err != nil {
continue
}
_, err = s.db.Exec(
`INSERT OR IGNORE INTO server_group_members (group_id, server_id) VALUES (?, ?)`,
gid, serverID,
)
if err != nil {
return fmt.Errorf("failed to add server to group: %w", err)
}
}
return nil
}
// GetGroupMemberIDs returns server IDs in a group
func (s *Service) GetGroupMemberIDs(groupID, userID int64) ([]int64, error) {
rows, err := s.db.Query(
`SELECT sgm.server_id FROM server_group_members sgm
JOIN servers s ON s.id = sgm.server_id
WHERE sgm.group_id = ? AND s.user_id = ?`, groupID, userID,
)
if err != nil {
return nil, fmt.Errorf("failed to query group member IDs: %w", err)
}
defer rows.Close()
var ids []int64
for rows.Next() {
var id int64
if err := rows.Scan(&id); err != nil {
continue
}
ids = append(ids, id)
}
return ids, nil
}
// --- Global queries (admin/owner) ---
// GetAllServers returns all servers regardless of owner
func (s *Service) GetAllServers() ([]models.Server, error) {
rows, err := s.db.Query(
`SELECT id, user_id, name, hostname, port, username, description, created_at, updated_at
FROM servers ORDER BY name ASC`,
)
if err != nil {
return nil, fmt.Errorf("failed to query all servers: %w", err)
}
defer rows.Close()
var servers []models.Server
for rows.Next() {
var srv models.Server
if err := rows.Scan(&srv.ID, &srv.UserID, &srv.Name, &srv.Hostname, &srv.Port, &srv.Username, &srv.Description, &srv.CreatedAt, &srv.UpdatedAt); err != nil {
return nil, fmt.Errorf("failed to scan server: %w", err)
}
servers = append(servers, srv)
}
return servers, nil
}
// GetAllGroups returns all server groups regardless of owner
func (s *Service) GetAllGroups() ([]models.ServerGroupWithCount, error) {
rows, err := s.db.Query(
`SELECT sg.id, sg.user_id, sg.name, sg.description, sg.created_at, sg.updated_at,
COUNT(sgm.server_id) as server_count
FROM server_groups sg
LEFT JOIN server_group_members sgm ON sg.id = sgm.group_id
GROUP BY sg.id
ORDER BY sg.name ASC`,
)
if err != nil {
return nil, fmt.Errorf("failed to query all server groups: %w", err)
}
defer rows.Close()
var groups []models.ServerGroupWithCount
for rows.Next() {
var g models.ServerGroupWithCount
if err := rows.Scan(&g.ID, &g.UserID, &g.Name, &g.Description, &g.CreatedAt, &g.UpdatedAt, &g.ServerCount); err != nil {
return nil, fmt.Errorf("failed to scan server group: %w", err)
}
groups = append(groups, g)
}
return groups, nil
}
// --- Global access functions (admin/owner) ---
// GetByIDGlobal returns a server without user_id check (admin/owner access)
func (s *Service) GetByIDGlobal(serverID int64) (*models.Server, error) {
srv := &models.Server{}
err := s.db.QueryRow(
`SELECT id, user_id, name, hostname, port, username, description, created_at, updated_at
FROM servers WHERE id = ?`, serverID,
).Scan(&srv.ID, &srv.UserID, &srv.Name, &srv.Hostname, &srv.Port, &srv.Username, &srv.Description, &srv.CreatedAt, &srv.UpdatedAt)
if err != nil {
return nil, fmt.Errorf("server not found: %w", err)
}
return srv, nil
}
// UpdateGlobal modifies a server without user_id check (admin/owner access)
func (s *Service) UpdateGlobal(serverID int64, name, hostname string, port int, username, description string) error {
result, err := s.db.Exec(
`UPDATE servers SET name=?, hostname=?, port=?, username=?, description=?, updated_at=CURRENT_TIMESTAMP
WHERE id=?`,
name, hostname, port, username, description, serverID,
)
if err != nil {
return fmt.Errorf("failed to update server: %w", err)
}
rows, _ := result.RowsAffected()
if rows == 0 {
return fmt.Errorf("server not found")
}
return nil
}
// DeleteGlobal removes a server without user_id check (admin/owner access)
func (s *Service) DeleteGlobal(serverID int64) error {
result, err := s.db.Exec(`DELETE FROM servers WHERE id = ?`, serverID)
if err != nil {
return fmt.Errorf("failed to delete server: %w", err)
}
rows, _ := result.RowsAffected()
if rows == 0 {
return fmt.Errorf("server not found")
}
return nil
}
// GetGroupByIDGlobal returns a server group without user_id check
func (s *Service) GetGroupByIDGlobal(groupID int64) (*models.ServerGroup, error) {
g := &models.ServerGroup{}
err := s.db.QueryRow(
`SELECT id, user_id, name, description, created_at, updated_at
FROM server_groups WHERE id = ?`, groupID,
).Scan(&g.ID, &g.UserID, &g.Name, &g.Description, &g.CreatedAt, &g.UpdatedAt)
if err != nil {
return nil, fmt.Errorf("server group not found: %w", err)
}
return g, nil
}
// UpdateGroupGlobal modifies a server group without user_id check
func (s *Service) UpdateGroupGlobal(groupID int64, name, description string) error {
result, err := s.db.Exec(
`UPDATE server_groups SET name=?, description=?, updated_at=CURRENT_TIMESTAMP WHERE id=?`,
name, description, groupID,
)
if err != nil {
return fmt.Errorf("failed to update server group: %w", err)
}
rows, _ := result.RowsAffected()
if rows == 0 {
return fmt.Errorf("server group not found")
}
return nil
}
// DeleteGroupGlobal removes a server group without user_id check
func (s *Service) DeleteGroupGlobal(groupID int64) error {
result, err := s.db.Exec(`DELETE FROM server_groups WHERE id = ?`, groupID)
if err != nil {
return fmt.Errorf("failed to delete server group: %w", err)
}
rows, _ := result.RowsAffected()
if rows == 0 {
return fmt.Errorf("server group not found")
}
return nil
}
// GetGroupMembersGlobal returns all servers in a group without user_id check
func (s *Service) GetGroupMembersGlobal(groupID int64) ([]models.Server, error) {
rows, err := s.db.Query(
`SELECT s.id, s.user_id, s.name, s.hostname, s.port, s.username, s.description, s.created_at, s.updated_at
FROM servers s
JOIN server_group_members sgm ON s.id = sgm.server_id
WHERE sgm.group_id = ?
ORDER BY s.name ASC`, groupID,
)
if err != nil {
return nil, fmt.Errorf("failed to query group members: %w", err)
}
defer rows.Close()
var servers []models.Server
for rows.Next() {
var srv models.Server
if err := rows.Scan(&srv.ID, &srv.UserID, &srv.Name, &srv.Hostname, &srv.Port, &srv.Username, &srv.Description, &srv.CreatedAt, &srv.UpdatedAt); err != nil {
return nil, fmt.Errorf("failed to scan server: %w", err)
}
servers = append(servers, srv)
}
return servers, nil
}
// GetGroupIDsForServerGlobal returns all group IDs that a server belongs to (without user_id check)
func (s *Service) GetGroupIDsForServerGlobal(serverID int64) ([]int64, error) {
rows, err := s.db.Query(
`SELECT sgm.group_id FROM server_group_members sgm WHERE sgm.server_id = ?`, serverID,
)
if err != nil {
return nil, fmt.Errorf("failed to query server groups: %w", err)
}
defer rows.Close()
var ids []int64
for rows.Next() {
var id int64
if err := rows.Scan(&id); err != nil {
continue
}
ids = append(ids, id)
}
return ids, nil
}
// SetServerGroupsGlobal replaces all group memberships for a server (without user_id check)
func (s *Service) SetServerGroupsGlobal(serverID int64, groupIDs []int64) error {
_, err := s.db.Exec(`DELETE FROM server_group_members WHERE server_id = ?`, serverID)
if err != nil {
return fmt.Errorf("failed to clear group memberships: %w", err)
}
for _, gid := range groupIDs {
_, err = s.db.Exec(
`INSERT OR IGNORE INTO server_group_members (group_id, server_id) VALUES (?, ?)`,
gid, serverID,
)
if err != nil {
return fmt.Errorf("failed to add server to group: %w", err)
}
}
return nil
}
// GetServersByAssignedUser returns hosts that a user has access to via assignments
func (s *Service) GetServersByAssignedUser(userID int64) ([]models.Server, error) {
rows, err := s.db.Query(
`SELECT DISTINCT s.id, s.user_id, s.name, s.hostname, s.port, s.username, s.description, s.created_at, s.updated_at
FROM servers s
WHERE s.id IN (
SELECT a.server_id FROM access_assignments a WHERE a.user_id = ? AND a.server_id > 0
UNION
SELECT sgm.server_id FROM access_assignments a
JOIN server_group_members sgm ON a.group_id = sgm.group_id
WHERE a.user_id = ? AND a.group_id > 0
)
ORDER BY s.name ASC`, userID, userID,
)
if err != nil {
return nil, fmt.Errorf("failed to query assigned servers: %w", err)
}
defer rows.Close()
var servers []models.Server
for rows.Next() {
var srv models.Server
if err := rows.Scan(&srv.ID, &srv.UserID, &srv.Name, &srv.Hostname, &srv.Port, &srv.Username, &srv.Description, &srv.CreatedAt, &srv.UpdatedAt); err != nil {
return nil, fmt.Errorf("failed to scan server: %w", err)
}
servers = append(servers, srv)
}
return servers, nil
}
// UpdateAssignmentStatus updates the sync status of an assignment
func (s *Service) UpdateAssignmentStatus(id int64, status, message string) error {
_, err := s.db.Exec(
`UPDATE access_assignments SET status=?, last_sync_at=CURRENT_TIMESTAMP, updated_at=CURRENT_TIMESTAMP WHERE id=?`,
status, id,
)
return err
}
// --- Access Assignments ---
// CreateAssignment creates a new access assignment
func (s *Service) CreateAssignment(userID, sshKeyID, serverID, groupID int64, systemUser, desiredState string, sudo, createUser bool) (*models.AccessAssignment, error) {
sudoInt := 0
if sudo {
sudoInt = 1
}
createUserInt := 0
if createUser {
createUserInt = 1
}
if desiredState == "" {
desiredState = "present"
}
result, err := s.db.Exec(
`INSERT INTO access_assignments (user_id, ssh_key_id, server_id, group_id, system_user, desired_state, sudo, create_user)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)`,
userID, sshKeyID, serverID, groupID, systemUser, desiredState, sudoInt, createUserInt,
)
if err != nil {
return nil, fmt.Errorf("failed to create assignment: %w", err)
}
id, _ := result.LastInsertId()
return &models.AccessAssignment{
ID: id,
UserID: userID,
SSHKeyID: sshKeyID,
ServerID: serverID,
GroupID: groupID,
SystemUser: systemUser,
DesiredState: desiredState,
Sudo: sudo,
CreateUser: createUser,
Status: "pending",
}, nil
}
// GetAllAssignments returns all access assignments with resolved display names
func (s *Service) GetAllAssignments() ([]models.AccessAssignmentDisplay, error) {
rows, err := s.db.Query(
`SELECT a.id, a.user_id, a.ssh_key_id, a.server_id, a.group_id,
a.system_user, a.desired_state, a.sudo, a.create_user,
a.status, a.initial_password, a.last_sync_at, a.created_at, a.updated_at,
u.username,
COALESCE(k.name, '(deleted)'),
CASE
WHEN a.server_id > 0 THEN COALESCE(s.name, '(deleted)')
WHEN a.group_id > 0 THEN COALESCE(sg.name, '(deleted)')
ELSE '(none)'
END,
CASE
WHEN a.server_id > 0 THEN 'host'
WHEN a.group_id > 0 THEN 'group'
ELSE 'none'
END
FROM access_assignments a
LEFT JOIN users u ON a.user_id = u.id
LEFT JOIN ssh_keys k ON a.ssh_key_id = k.id
LEFT JOIN servers s ON a.server_id = s.id AND a.server_id > 0
LEFT JOIN server_groups sg ON a.group_id = sg.id AND a.group_id > 0
ORDER BY a.created_at DESC`,
)
if err != nil {
return nil, fmt.Errorf("failed to query assignments: %w", err)
}
defer rows.Close()
var assignments []models.AccessAssignmentDisplay
for rows.Next() {
var a models.AccessAssignmentDisplay
var sudo, createUser int
if err := rows.Scan(&a.ID, &a.UserID, &a.SSHKeyID, &a.ServerID, &a.GroupID,
&a.SystemUser, &a.DesiredState, &sudo, &createUser,
&a.Status, &a.InitialPassword, &a.LastSyncAt, &a.CreatedAt, &a.UpdatedAt,
&a.Username, &a.KeyName, &a.TargetName, &a.TargetType); err != nil {
return nil, fmt.Errorf("failed to scan assignment: %w", err)
}
a.Sudo = sudo == 1
a.CreateUser = createUser == 1
assignments = append(assignments, a)
}
return assignments, nil
}
// GetAssignmentsByUser returns access assignments for a specific user
func (s *Service) GetAssignmentsByUser(userID int64) ([]models.AccessAssignmentDisplay, error) {
all, err := s.GetAllAssignments()
if err != nil {
return nil, err
}
var filtered []models.AccessAssignmentDisplay
for _, a := range all {
if a.UserID == userID {
filtered = append(filtered, a)
}
}
return filtered, nil
}
// GetAssignmentByID returns a single access assignment
func (s *Service) GetAssignmentByID(id int64) (*models.AccessAssignment, error) {
a := &models.AccessAssignment{}
var sudo, createUser int
err := s.db.QueryRow(
`SELECT id, user_id, ssh_key_id, server_id, group_id, system_user, desired_state,
sudo, create_user, initial_password, status, last_sync_at, created_at, updated_at
FROM access_assignments WHERE id = ?`, id,
).Scan(&a.ID, &a.UserID, &a.SSHKeyID, &a.ServerID, &a.GroupID, &a.SystemUser, &a.DesiredState,
&sudo, &createUser, &a.InitialPassword, &a.Status, &a.LastSyncAt, &a.CreatedAt, &a.UpdatedAt)
if err != nil {
return nil, fmt.Errorf("assignment not found: %w", err)
}
a.Sudo = sudo == 1
a.CreateUser = createUser == 1
return a, nil
}
// UpdateAssignment updates an existing access assignment
func (s *Service) UpdateAssignment(id, userID, sshKeyID, serverID, groupID int64, systemUser, desiredState string, sudo, createUser bool) error {
sudoInt := 0
if sudo {
sudoInt = 1
}
createUserInt := 0
if createUser {
createUserInt = 1
}
result, err := s.db.Exec(
`UPDATE access_assignments SET user_id=?, ssh_key_id=?, server_id=?, group_id=?,
system_user=?, desired_state=?, sudo=?, create_user=?, status='pending', updated_at=CURRENT_TIMESTAMP
WHERE id=?`,
userID, sshKeyID, serverID, groupID, systemUser, desiredState, sudoInt, createUserInt, id,
)
if err != nil {
return fmt.Errorf("failed to update assignment: %w", err)
}
rows, _ := result.RowsAffected()
if rows == 0 {
return fmt.Errorf("assignment not found")
}
return nil
}
// DeleteAssignment removes an access assignment
func (s *Service) DeleteAssignment(id int64) error {
result, err := s.db.Exec(`DELETE FROM access_assignments WHERE id = ?`, id)
if err != nil {
return fmt.Errorf("failed to delete assignment: %w", err)
}
rows, _ := result.RowsAffected()
if rows == 0 {
return fmt.Errorf("assignment not found")
}
return nil
}
// UpdateAssignmentInitialPassword stores the encrypted initial password for an assignment
func (s *Service) UpdateAssignmentInitialPassword(id int64, encryptedPassword string) error {
_, err := s.db.Exec(
`UPDATE access_assignments SET initial_password=?, updated_at=CURRENT_TIMESTAMP WHERE id=?`,
encryptedPassword, id,
)
return err
}
+251
View File
@@ -0,0 +1,251 @@
// Keywarden - Centralized SSH Key Management and Deployment
// Copyright (C) 2026 Patrick Asmus (scriptos)
// SPDX-License-Identifier: AGPL-3.0-or-later
package sshutil
import (
"bytes"
"crypto/ed25519"
"crypto/rand"
"crypto/rsa"
"crypto/sha256"
"crypto/x509"
"encoding/base64"
"encoding/binary"
"encoding/pem"
"fmt"
"strings"
"github.com/cloudflare/circl/sign/ed448"
"golang.org/x/crypto/ssh"
)
// GenerateRSAKey generates an RSA key pair with the given bit size (2048 or 4096)
func GenerateRSAKey(bits int, comment string) (privateKeyPEM []byte, publicKey []byte, fingerprint string, err error) {
if bits != 2048 && bits != 4096 {
return nil, nil, "", fmt.Errorf("unsupported RSA key size: %d (use 2048 or 4096)", bits)
}
privKey, err := rsa.GenerateKey(rand.Reader, bits)
if err != nil {
return nil, nil, "", fmt.Errorf("failed to generate RSA key: %w", err)
}
// Encode private key to PEM
privPEM := pem.EncodeToMemory(&pem.Block{
Type: "RSA PRIVATE KEY",
Bytes: marshalRSAPrivateKey(privKey),
})
// Generate SSH public key
pub, err := ssh.NewPublicKey(&privKey.PublicKey)
if err != nil {
return nil, nil, "", fmt.Errorf("failed to create SSH public key: %w", err)
}
pubBytes := appendComment(ssh.MarshalAuthorizedKey(pub), comment)
fp := fingerprintSHA256(pub)
return privPEM, pubBytes, fp, nil
}
// GenerateEd25519Key generates an Ed25519 key pair
func GenerateEd25519Key(comment string) (privateKeyPEM []byte, publicKey []byte, fingerprint string, err error) {
pubKey, privKey, err := ed25519.GenerateKey(rand.Reader)
if err != nil {
return nil, nil, "", fmt.Errorf("failed to generate Ed25519 key: %w", err)
}
// Encode private key to PEM using OpenSSH format
privPEM, err := ssh.MarshalPrivateKey(privKey, comment)
if err != nil {
return nil, nil, "", fmt.Errorf("failed to marshal Ed25519 private key: %w", err)
}
privPEMBytes := pem.EncodeToMemory(privPEM)
// Generate SSH public key
pub, err := ssh.NewPublicKey(pubKey)
if err != nil {
return nil, nil, "", fmt.Errorf("failed to create SSH public key: %w", err)
}
pubBytes := appendComment(ssh.MarshalAuthorizedKey(pub), comment)
fp := fingerprintSHA256(pub)
return privPEMBytes, pubBytes, fp, nil
}
// ed448PublicKey wraps an Ed448 public key to implement ssh.PublicKey
type ed448PublicKey []byte
func (k ed448PublicKey) Type() string {
return "ssh-ed448"
}
func (k ed448PublicKey) Marshal() []byte {
w := struct {
KeyType string
Key []byte
}{
KeyType: k.Type(),
Key: []byte(k),
}
return ssh.Marshal(&w)
}
func (k ed448PublicKey) Verify(data []byte, sig *ssh.Signature) error {
if sig.Format != k.Type() {
return fmt.Errorf("ssh: signature type %s for key type %s", sig.Format, k.Type())
}
if !ed448.Verify(ed448.PublicKey(k), data, sig.Blob, "") {
return fmt.Errorf("ssh: ed448 signature verification failed")
}
return nil
}
// GenerateEd448Key generates an Ed448 key pair
func GenerateEd448Key(comment string) (privateKeyPEM []byte, publicKey []byte, fingerprint string, err error) {
pubKey, privKey, err := ed448.GenerateKey(rand.Reader)
if err != nil {
return nil, nil, "", fmt.Errorf("failed to generate Ed448 key: %w", err)
}
sshPubKey := ed448PublicKey(pubKey)
privPEM, err := marshalOpenSSHEd448(privKey, pubKey, comment)
if err != nil {
return nil, nil, "", fmt.Errorf("failed to marshal Ed448 private key: %w", err)
}
pubBytes := appendComment(ssh.MarshalAuthorizedKey(sshPubKey), comment)
fp := fingerprintSHA256(sshPubKey)
return privPEM, pubBytes, fp, nil
}
// marshalOpenSSHEd448 encodes an Ed448 key pair in openssh-key-v1 private key format
func marshalOpenSSHEd448(privKey ed448.PrivateKey, pubKey ed448.PublicKey, comment string) ([]byte, error) {
// Public key wire format
pubWire := struct {
KeyType string
PubKey []byte
}{
KeyType: "ssh-ed448",
PubKey: []byte(pubKey),
}
pubWireBytes := ssh.Marshal(&pubWire)
// Random check value for integrity verification
var checkBuf [4]byte
if _, err := rand.Read(checkBuf[:]); err != nil {
return nil, fmt.Errorf("failed to generate random check: %w", err)
}
check := binary.BigEndian.Uint32(checkBuf[:])
// Build the private key blob (seed + public key, following OpenSSH convention)
var keyBlob []byte
if len(privKey) <= ed448.SeedSize {
keyBlob = make([]byte, 0, ed448.SeedSize+len(pubKey))
keyBlob = append(keyBlob, privKey...)
keyBlob = append(keyBlob, pubKey...)
} else {
keyBlob = []byte(privKey)
}
// Private key section (unencrypted)
privSection := struct {
Check1 uint32
Check2 uint32
KeyType string
PubKey []byte
PrivKey []byte
Comment string
}{
Check1: check,
Check2: check,
KeyType: "ssh-ed448",
PubKey: []byte(pubKey),
PrivKey: keyBlob,
Comment: comment,
}
privSectionBytes := ssh.Marshal(&privSection)
// Pad to block size of 8
padLen := (8 - len(privSectionBytes)%8) % 8
for i := 0; i < padLen; i++ {
privSectionBytes = append(privSectionBytes, byte(i+1))
}
// Assemble full openssh-key-v1 format
var buf bytes.Buffer
buf.WriteString("openssh-key-v1\x00")
outer := struct {
CipherName string
KdfName string
KdfOpts string
NumKeys uint32
PubKey []byte
PrivKey []byte
}{
CipherName: "none",
KdfName: "none",
KdfOpts: "",
NumKeys: 1,
PubKey: pubWireBytes,
PrivKey: privSectionBytes,
}
buf.Write(ssh.Marshal(&outer))
return pem.EncodeToMemory(&pem.Block{
Type: "OPENSSH PRIVATE KEY",
Bytes: buf.Bytes(),
}), nil
}
// ParsePublicKey parses an SSH public key and returns its fingerprint
func ParsePublicKey(pubKeyBytes []byte) (fingerprint string, keyType string, err error) {
pub, _, _, _, err := ssh.ParseAuthorizedKey(pubKeyBytes)
if err != nil {
return "", "", fmt.Errorf("failed to parse public key: %w", err)
}
return fingerprintSHA256(pub), pub.Type(), nil
}
// ParsePrivateKey parses a PEM-encoded private key and extracts the public key
func ParsePrivateKey(privKeyPEM []byte) (publicKey []byte, fingerprint string, keyType string, err error) {
signer, err := ssh.ParsePrivateKey(privKeyPEM)
if err != nil {
return nil, "", "", fmt.Errorf("failed to parse private key: %w", err)
}
pub := signer.PublicKey()
pubBytes := ssh.MarshalAuthorizedKey(pub)
fp := fingerprintSHA256(pub)
return pubBytes, fp, pub.Type(), nil
}
// appendComment appends a comment to an SSH authorized key line
func appendComment(pubBytes []byte, comment string) []byte {
if comment == "" {
return pubBytes
}
// MarshalAuthorizedKey returns "type base64\n", insert comment before newline
line := strings.TrimRight(string(pubBytes), "\n")
return []byte(line + " " + comment + "\n")
}
// fingerprintSHA256 returns the SHA256 fingerprint of an SSH public key
func fingerprintSHA256(pub ssh.PublicKey) string {
hash := sha256.Sum256(pub.Marshal())
return "SHA256:" + base64.RawStdEncoding.EncodeToString(hash[:])
}
// marshalRSAPrivateKey marshals an RSA private key to PKCS#1 DER bytes
func marshalRSAPrivateKey(key *rsa.PrivateKey) []byte {
return x509.MarshalPKCS1PrivateKey(key)
}
+243
View File
@@ -0,0 +1,243 @@
// Keywarden - Centralized SSH Key Management and Deployment
// Copyright (C) 2026 Patrick Asmus (scriptos)
// SPDX-License-Identifier: AGPL-3.0-or-later
package sshutil
import (
"strings"
"testing"
)
func TestGenerateEd25519Key(t *testing.T) {
privPEM, pubKey, fingerprint, err := GenerateEd25519Key("test@keywarden")
if err != nil {
t.Fatalf("GenerateEd25519Key failed: %v", err)
}
if len(privPEM) == 0 {
t.Fatal("Private key PEM is empty")
}
if len(pubKey) == 0 {
t.Fatal("Public key is empty")
}
if !strings.HasPrefix(fingerprint, "SHA256:") {
t.Fatalf("Fingerprint should start with SHA256:, got %q", fingerprint)
}
if !strings.Contains(string(pubKey), "ssh-ed25519") {
t.Fatal("Public key should contain ssh-ed25519")
}
if !strings.Contains(string(pubKey), "test@keywarden") {
t.Fatal("Public key should contain the comment")
}
if !strings.Contains(string(privPEM), "PRIVATE KEY") {
t.Fatal("Private key PEM should contain PRIVATE KEY header")
}
}
func TestGenerateRSAKey2048(t *testing.T) {
privPEM, pubKey, fingerprint, err := GenerateRSAKey(2048, "rsa-test")
if err != nil {
t.Fatalf("GenerateRSAKey(2048) failed: %v", err)
}
if len(privPEM) == 0 {
t.Fatal("Private key PEM is empty")
}
if len(pubKey) == 0 {
t.Fatal("Public key is empty")
}
if !strings.HasPrefix(fingerprint, "SHA256:") {
t.Fatalf("Fingerprint should start with SHA256:, got %q", fingerprint)
}
if !strings.Contains(string(pubKey), "ssh-rsa") {
t.Fatal("Public key should contain ssh-rsa")
}
if !strings.Contains(string(pubKey), "rsa-test") {
t.Fatal("Public key should contain the comment")
}
}
func TestGenerateRSAKey4096(t *testing.T) {
privPEM, pubKey, fingerprint, err := GenerateRSAKey(4096, "")
if err != nil {
t.Fatalf("GenerateRSAKey(4096) failed: %v", err)
}
if len(privPEM) == 0 {
t.Fatal("Private key PEM is empty")
}
if len(pubKey) == 0 {
t.Fatal("Public key is empty")
}
if !strings.HasPrefix(fingerprint, "SHA256:") {
t.Fatalf("Fingerprint should start with SHA256:, got %q", fingerprint)
}
}
func TestGenerateRSAKeyInvalidBits(t *testing.T) {
_, _, _, err := GenerateRSAKey(1024, "")
if err == nil {
t.Fatal("GenerateRSAKey(1024) should fail for unsupported key size")
}
_, _, _, err = GenerateRSAKey(3072, "")
if err == nil {
t.Fatal("GenerateRSAKey(3072) should fail for unsupported key size")
}
}
func TestParsePrivateKeyEd25519(t *testing.T) {
privPEM, expectedPub, _, err := GenerateEd25519Key("")
if err != nil {
t.Fatalf("GenerateEd25519Key failed: %v", err)
}
pubKey, fingerprint, keyType, err := ParsePrivateKey(privPEM)
if err != nil {
t.Fatalf("ParsePrivateKey failed: %v", err)
}
if keyType != "ssh-ed25519" {
t.Fatalf("Expected key type ssh-ed25519, got %q", keyType)
}
if !strings.HasPrefix(fingerprint, "SHA256:") {
t.Fatalf("Fingerprint should start with SHA256:, got %q", fingerprint)
}
if strings.TrimSpace(string(pubKey)) != strings.TrimSpace(string(expectedPub)) {
t.Fatal("Parsed public key does not match generated public key")
}
}
func TestParsePrivateKeyRSA(t *testing.T) {
privPEM, _, _, err := GenerateRSAKey(2048, "")
if err != nil {
t.Fatalf("GenerateRSAKey failed: %v", err)
}
_, fingerprint, keyType, err := ParsePrivateKey(privPEM)
if err != nil {
t.Fatalf("ParsePrivateKey failed: %v", err)
}
if keyType != "ssh-rsa" {
t.Fatalf("Expected key type ssh-rsa, got %q", keyType)
}
if !strings.HasPrefix(fingerprint, "SHA256:") {
t.Fatalf("Fingerprint should start with SHA256:, got %q", fingerprint)
}
}
func TestParsePublicKey(t *testing.T) {
_, pubKey, expectedFP, err := GenerateEd25519Key("")
if err != nil {
t.Fatalf("GenerateEd25519Key failed: %v", err)
}
fingerprint, keyType, err := ParsePublicKey(pubKey)
if err != nil {
t.Fatalf("ParsePublicKey failed: %v", err)
}
if keyType != "ssh-ed25519" {
t.Fatalf("Expected key type ssh-ed25519, got %q", keyType)
}
if fingerprint != expectedFP {
t.Fatalf("Fingerprint mismatch: got %q, want %q", fingerprint, expectedFP)
}
}
func TestGenerateEd25519KeyUniqueness(t *testing.T) {
_, pub1, fp1, err := GenerateEd25519Key("")
if err != nil {
t.Fatalf("GenerateEd25519Key 1 failed: %v", err)
}
_, pub2, fp2, err := GenerateEd25519Key("")
if err != nil {
t.Fatalf("GenerateEd25519Key 2 failed: %v", err)
}
if string(pub1) == string(pub2) {
t.Fatal("Two generated keys should have different public keys")
}
if fp1 == fp2 {
t.Fatal("Two generated keys should have different fingerprints")
}
}
func TestGenerateEd448Key(t *testing.T) {
privPEM, pubKey, fingerprint, err := GenerateEd448Key("test@keywarden")
if err != nil {
t.Fatalf("GenerateEd448Key failed: %v", err)
}
if len(privPEM) == 0 {
t.Fatal("Private key PEM is empty")
}
if len(pubKey) == 0 {
t.Fatal("Public key is empty")
}
if !strings.HasPrefix(fingerprint, "SHA256:") {
t.Fatalf("Fingerprint should start with SHA256:, got %q", fingerprint)
}
if !strings.Contains(string(pubKey), "ssh-ed448") {
t.Fatal("Public key should contain ssh-ed448")
}
if !strings.Contains(string(pubKey), "test@keywarden") {
t.Fatal("Public key should contain the comment")
}
if !strings.Contains(string(privPEM), "OPENSSH PRIVATE KEY") {
t.Fatal("Private key PEM should contain OPENSSH PRIVATE KEY header")
}
}
func TestGenerateEd448KeyUniqueness(t *testing.T) {
_, pub1, fp1, err := GenerateEd448Key("")
if err != nil {
t.Fatalf("GenerateEd448Key 1 failed: %v", err)
}
_, pub2, fp2, err := GenerateEd448Key("")
if err != nil {
t.Fatalf("GenerateEd448Key 2 failed: %v", err)
}
if string(pub1) == string(pub2) {
t.Fatal("Two generated Ed448 keys should have different public keys")
}
if fp1 == fp2 {
t.Fatal("Two generated Ed448 keys should have different fingerprints")
}
}
func TestGenerateEd448KeyNoComment(t *testing.T) {
privPEM, pubKey, fingerprint, err := GenerateEd448Key("")
if err != nil {
t.Fatalf("GenerateEd448Key failed: %v", err)
}
if len(privPEM) == 0 {
t.Fatal("Private key PEM is empty")
}
if len(pubKey) == 0 {
t.Fatal("Public key is empty")
}
if !strings.HasPrefix(fingerprint, "SHA256:") {
t.Fatalf("Fingerprint should start with SHA256:, got %q", fingerprint)
}
}
func TestParsePrivateKeyInvalid(t *testing.T) {
_, _, _, err := ParsePrivateKey([]byte("not a valid PEM"))
if err == nil {
t.Fatal("ParsePrivateKey should fail for invalid PEM")
}
}
func TestParsePublicKeyInvalid(t *testing.T) {
_, _, err := ParsePublicKey([]byte("not a valid public key"))
if err == nil {
t.Fatal("ParsePublicKey should fail for invalid key")
}
}