Files
OpsLog/app_secret.go
T
rouggy fb78b1c052 feat(webpublish): publish the log as a web page or CSV, with FTP upload
Settings -> Web publishing. Renders the last N QSOs with the columns the
operator picks, writes the file locally, and optionally uploads it by FTP or
explicit FTPS.

Local write FIRST, upload second, always. A network failure then leaves a good
file on disk that can be published another way, instead of a truncated one on
the server. The local write itself goes to a temp file and renames over the
target, so a reader — or a syncing client — never sees a half-written page.

The HTML page is fully self-contained: inline CSS, inline sort script, no font,
no CDN, no external request at all. It has to work on hosting that blocks
third-party requests, and a page about someone's hobby should not report its
readers to anyone.

Columns are a curated set, not "every ADIF field". This is published to the
public: RST and QSL status belong on it, the operator's home address does not.

Two triggers, both debounced through one path: a QSO is logged, or the optional
timer fires. Fifteen seconds of coalescing means a run of contacts produces one
upload rather than one per QSO, and nobody reading a web page can tell the
difference.

The config is one JSON blob under a single settings key, and that key is marked
sensitive: the FTP password lives inside it, so the whole blob is encrypted at
rest with the others. A locked vault reads back empty, which correctly reads as
"not configured" — publishing must not run with a password it cannot decrypt.
2026-08-10 09:07:08 +02:00

178 lines
5.6 KiB
Go

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,
keyExtEQSLPassword: true,
keyExtCloudlogAPIKey: true,
// The web-publish config is one JSON blob and the FTP password lives inside
// it, so the whole blob is encrypted. That costs nothing — it is read and
// written as a unit anyway — and beats storing a server password in clear
// next to the rest.
keyWebPublish: 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
}