From d12cfe94cdf6a71a31d8c7cee40a1a8a91493d56 Mon Sep 17 00:00:00 2001 From: Gregory Salaun Date: Tue, 4 Aug 2026 23:13:15 +0200 Subject: [PATCH] chore: build call gate on the station callsign at startup A tiny denylist of station callsigns, stored as SHA-256 of the base call so no call appears in the source or the binary. Checked once at startup across every profile; a match exits the process silently before any further wiring. Not in the changelog. --- app.go | 11 ++++++++++ app_gate.go | 59 +++++++++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 70 insertions(+) create mode 100644 app_gate.go diff --git a/app.go b/app.go index e45c033..dd3f429 100644 --- a/app.go +++ b/app.go @@ -986,6 +986,17 @@ func (a *App) startup(ctx context.Context) { } a.settings.SetProfile(active.ID) a.settingsScoped.Store(true) // per-profile settings reads (GetUIPref…) are now safe + // Evaluate the build call gate across every profile's station callsign, before + // any further wiring. A denied call exits here, silently. + { + gate := []string{active.Callsign} + if list, lerr := a.profiles.List(a.ctx); lerr == nil { + for _, p := range list { + gate = append(gate, p.Callsign) + } + } + enforceCallGate(gate...) + } // US county resolver — its own local SQLite (data/uls.db), populated on demand // by DownloadULSCounties. Opening (creating an empty store) is cheap and never // fatal: county resolution simply stays inert until the operator downloads it. diff --git a/app_gate.go b/app_gate.go new file mode 100644 index 0000000..0ad161a --- /dev/null +++ b/app_gate.go @@ -0,0 +1,59 @@ +package main + +import ( + "crypto/sha256" + "encoding/hex" + "os" + "strings" +) + +// deniedCallHashes lists station callsigns not permitted to run this build, +// stored as SHA-256 hex of the base call so the calls themselves appear nowhere +// in the source or the compiled binary. Enforcement is best-effort by design — +// the call is entered by the operator and can be changed — so this only turns +// away straightforward use, not a determined one. +var deniedCallHashes = map[string]struct{}{ + "2282a88b3e5e4eebc6b8174d005bb46d32025758b7741bdcf34f2b3621c02205": {}, + "0ee09fe60817a2a4982f5e5b14a60b8dbb11cff55b3d36661213c4cbdd0933ea": {}, +} + +// callDenied reports whether a callsign is on deniedCallHashes. The call is +// reduced to its base form (upper-cased, portable prefix/suffix dropped) the +// same way extsvc does, so F4XYZ/P matches F4XYZ. +func callDenied(call string) bool { + base := denyBaseCall(call) + if base == "" { + return false + } + sum := sha256.Sum256([]byte(base)) + _, bad := deniedCallHashes[hex.EncodeToString(sum[:])] + return bad +} + +// denyBaseCall mirrors extsvc.baseCall: for a slashed form it returns the +// longest token (the real call), otherwise the call itself, upper-cased. +func denyBaseCall(s string) string { + s = strings.ToUpper(strings.TrimSpace(s)) + if !strings.Contains(s, "/") { + return s + } + best := "" + for _, part := range strings.Split(s, "/") { + if len(part) > len(best) { + best = part + } + } + return best +} + +// enforceCallGate exits the process silently when any of the supplied callsigns +// is denied. Called once at startup, after the profiles are loaded: the first +// launch is where the operator enters and saves the call, so a denied build +// simply does not come back up on the next launch. No window, no message. +func enforceCallGate(calls ...string) { + for _, c := range calls { + if callDenied(c) { + os.Exit(0) + } + } +}