package main import ( "encoding/base64" "fmt" "hamlog/internal/applog" "hamlog/internal/secret" ) // Secret-vault settings keys (NOT themselves sensitive — they hold the salt and // an encrypted verifier token, never a password). const ( keySecretSalt = "secret.salt" // base64 PBKDF2 salt keySecretVerifier = "secret.verifier" // enc:v1: token, validates the passphrase ) // sensitiveSettingKeys are the password fields encrypted at rest when the user // sets a passphrase. Everything else stays plaintext. var sensitiveSettingKeys = map[string]bool{ keyQRZPassword: true, keyHQPassword: true, keyEmailPassword: true, keyExtClublogPassword: true, keyExtLoTWKeyPassword: true, keyExtLoTWWebPassword: true, keyExtHRDLogCode: true, } func isSensitiveSetting(key string) bool { return sensitiveSettingKeys[key] } // SecretStatus tells the UI whether a passphrase is configured and unlocked. type SecretStatus struct { HasPassphrase bool `json:"has_passphrase"` Unlocked bool `json:"unlocked"` } // GetSecretStatus is polled at launch: if HasPassphrase && !Unlocked the UI // shows the unlock prompt. func (a *App) GetSecretStatus() SecretStatus { if a.settings == nil { return SecretStatus{} } salt, _ := a.settings.GetRaw(a.ctx, keySecretSalt) return SecretStatus{HasPassphrase: salt != "", Unlocked: a.settings.Unlocked()} } // reloadAfterSecretChange rebuilds anything that read passwords at startup while // they were still locked (lookup providers + external-service config). func (a *App) reloadAfterSecretChange() { a.reloadLookupProviders() if a.extsvc != nil { a.extsvc.SetConfig(a.loadExternalServices()) } } // UnlockSecrets validates the passphrase against the stored verifier and, on // success, installs the cipher so passwords decrypt for the rest of the session. func (a *App) UnlockSecrets(passphrase string) error { if a.settings == nil { return fmt.Errorf("not initialized") } saltB64, _ := a.settings.GetRaw(a.ctx, keySecretSalt) verifier, _ := a.settings.GetRaw(a.ctx, keySecretVerifier) if saltB64 == "" || verifier == "" { return fmt.Errorf("no passphrase configured") } salt, err := base64.StdEncoding.DecodeString(saltB64) if err != nil { return fmt.Errorf("corrupt salt") } key, err := secret.DeriveKey(passphrase, salt) if err != nil { return fmt.Errorf("derive key: %w", err) } c, err := secret.New(key) if err != nil { return fmt.Errorf("cipher: %w", err) } if !c.CheckVerifier(verifier) { return fmt.Errorf("wrong passphrase") } a.settings.Unlock(c) a.reloadAfterSecretChange() return nil } // SetPassphrase sets a new passphrase or changes an existing one, then // re-encrypts every stored secret under the new key. Changing an existing // passphrase requires the vault to be unlocked first (so current secrets are // readable as plaintext). func (a *App) SetPassphrase(passphrase string) error { if a.settings == nil { return fmt.Errorf("not initialized") } if len(passphrase) < 4 { return fmt.Errorf("passphrase too short (min 4 characters)") } saltB64, _ := a.settings.GetRaw(a.ctx, keySecretSalt) if saltB64 != "" && !a.settings.Unlocked() { return fmt.Errorf("unlock with the current passphrase first") } // Snapshot current plaintext (Get decrypts when unlocked, passes through // when no passphrase yet). current := map[string]string{} for k := range sensitiveSettingKeys { if v, _ := a.settings.Get(a.ctx, k); v != "" { current[k] = v } } salt, err := secret.NewSalt() if err != nil { return fmt.Errorf("salt: %w", err) } key, err := secret.DeriveKey(passphrase, salt) if err != nil { return fmt.Errorf("derive key: %w", err) } c, err := secret.New(key) if err != nil { return fmt.Errorf("cipher: %w", err) } // Persist salt + verifier first so an interrupted migration is still // recoverable (the prompt appears next launch; plaintext secrets read // through transparently until re-encrypted). if err := a.settings.SetRaw(a.ctx, keySecretSalt, base64.StdEncoding.EncodeToString(salt)); err != nil { return err } if err := a.settings.SetRaw(a.ctx, keySecretVerifier, c.MakeVerifier()); err != nil { return err } a.settings.Unlock(c) for k, v := range current { if err := a.settings.Set(a.ctx, k, v); err != nil { // now encrypts applog.Printf("secret: re-encrypt %q failed: %v", k, err) } } a.reloadAfterSecretChange() return nil } // RemovePassphrase decrypts all secrets back to plaintext and clears the vault. // Requires the current passphrase (validated) so it can't be cleared blindly. func (a *App) RemovePassphrase(passphrase string) error { if a.settings == nil { return fmt.Errorf("not initialized") } if !a.settings.Unlocked() { if err := a.UnlockSecrets(passphrase); err != nil { return err } } // Read decrypted, then store back as plaintext and drop salt/verifier. plain := map[string]string{} for k := range sensitiveSettingKeys { if v, _ := a.settings.Get(a.ctx, k); v != "" { plain[k] = v } } a.settings.Lock() for k, v := range plain { if err := a.settings.SetRaw(a.ctx, k, v); err != nil { applog.Printf("secret: decrypt-on-remove %q failed: %v", k, err) } } _ = a.settings.SetRaw(a.ctx, keySecretSalt, "") _ = a.settings.SetRaw(a.ctx, keySecretVerifier, "") a.reloadAfterSecretChange() return nil }