Files
felhom.eu/hub/internal/offsitekeys/service.go
T
admin f417cdede1
gates / gates (push) Successful in 29s
hub v0.127.0: off-site key registrar (box never gets the storage password), password sealed at rest, daily key check, clean-up window (shipped off) — decisions 68-69, R-820/R-821/R-822
Part A evidence (migration spike, sftp-written repo through the pinned rclone key) and the hub
red-proofs under documentation/audits/offsite-lock-build-2026-10-03/. Manifest bump follows after
the image is built and Secret/offsite-secret-key exists.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0159rPz1ZhFKsS53msqPYxtS
2026-10-03 16:57:04 +02:00

336 lines
14 KiB
Go

package offsitekeys
import (
"context"
"encoding/json"
"errors"
"fmt"
"log"
"strings"
"time"
"gitea.dooplex.hu/admin/felhom-hub/internal/offsite"
"gitea.dooplex.hu/admin/felhom-hub/internal/store"
)
// Event types this package raises — all OPERATOR-ONLY (registered in notify.operatorOnlyEvents).
const (
EventKeyInstalled = "offsite_key_installed" // info: the registrar pinned a box key
EventKeyUnlocked = "offsite_key_unlocked" // error: the daily check saw a line that can delete
EventAuditFailed = "offsite_key_audit_failed" // warning: the daily check could not read the file
EventMovedAside = "offsite_repo_moved_aside" // info: the hub set an orphaned repository aside
)
// Service binds the Registrar to the hub's store: the descriptor (where), the sealed password (how),
// the key record, and the operator events.
type Service struct {
Store *store.Store
Reg *Registrar
Logger *log.Logger
// Emit routes an event to the dispatcher (operator mail). nil → events are only saved.
Emit func(customerID, eventType, severity, message, detailsJSON, source string)
Now func() time.Time
}
// ErrNotProvisioned — the customer has no provisioned off-site target (nothing to register against).
var ErrNotProvisioned = errors.New("offsitekeys: customer has no provisioned off-site target")
func (s *Service) logf(f string, a ...any) {
if s.Logger != nil {
s.Logger.Printf(f, a...)
}
}
func (s *Service) now() time.Time {
if s.Now != nil {
return s.Now()
}
return time.Now()
}
func (s *Service) event(customerID, typ, sev, msg string, details any) {
dj := ""
if details != nil {
if b, err := json.Marshal(details); err == nil {
dj = string(b)
}
}
if _, err := s.Store.SaveEvent(customerID, typ, sev, msg, dj, "hub"); err != nil {
s.logf("[WARN] offsitekeys: save event %s for %s: %v", typ, customerID, err)
}
if s.Emit != nil {
s.Emit(customerID, typ, sev, msg, dj, "hub")
}
}
// TargetFor resolves the customer's sub-account and the hub's (decrypted) password for it.
func (s *Service) TargetFor(customerID string) (Target, string, error) {
cfg, err := s.Store.GetCustomerConfig(customerID)
if err != nil || cfg == nil {
return Target{}, "", fmt.Errorf("offsitekeys: customer %s: %v", customerID, err)
}
d, err := offsite.ReadDescriptor(cfg.ConfigJSON)
if err != nil {
return Target{}, "", err
}
if d == nil || !d.Enabled || d.Host == "" || d.User == "" || d.RepoPath == "" {
return Target{}, "", ErrNotProvisioned
}
pw, err := s.Store.OffsitePassword(customerID)
if err != nil {
return Target{}, "", fmt.Errorf("offsitekeys: no usable stored credential for %s: %w", customerID, err)
}
return Target{Host: d.Host, User: d.User, Port: d.Port, RepoPath: d.RepoPath, Fingerprint: d.HostFingerprint}, pw, nil
}
// RegisterKey installs the box's public key pinned append-only (the ONLY way a box gets off-site access
// from hub v0.127.0 on — no box ever receives the password).
func (s *Service) RegisterKey(ctx context.Context, customerID, pub string) (InstallResult, error) {
t, pw, err := s.TargetFor(customerID)
if err != nil {
return InstallResult{}, err
}
start := s.now()
res, err := s.Reg.Install(ctx, t, pw, pub)
if err != nil {
s.logf("[ERROR] offsitekeys: install key for %s (%s@%s): %v", customerID, t.User, t.Host, err)
return InstallResult{}, err
}
if err := s.Store.RecordOffsiteKeyInstalled(customerID, res.Fingerprint); err != nil {
s.logf("[WARN] offsitekeys: record key for %s: %v", customerID, err)
}
if err := s.Store.MarkOffsiteSecretDelivered(customerID); err != nil {
s.logf("[WARN] offsitekeys: mark delivered for %s: %v", customerID, err)
}
s.logf("[INFO] offsitekeys: installed box key %s for %s pinned append-only (%s@%s, dropped %d unpinned line(s)) in %s",
res.Fingerprint, customerID, t.User, t.Host, res.RemovedUnpinned, s.now().Sub(start).Round(time.Millisecond))
s.event(customerID, EventKeyInstalled, "info",
fmt.Sprintf("Off-site: the box's key %s was installed append-only on %s (%d unpinned line(s) removed).", res.Fingerprint, t.User, res.RemovedUnpinned),
map[string]any{"fingerprint": res.Fingerprint, "removed_unpinned": res.RemovedUnpinned})
return res, nil
}
// ConfirmKey is the rotation's last step: only the confirmed key's pinned line stays.
func (s *Service) ConfirmKey(ctx context.Context, customerID, fp string) (int, error) {
t, pw, err := s.TargetFor(customerID)
if err != nil {
return 0, err
}
removed, err := s.Reg.Confirm(ctx, t, pw, fp)
if err != nil {
return 0, err
}
if ok, err := s.Store.RecordOffsiteKeyConfirmed(customerID, fp); err != nil || !ok {
s.logf("[WARN] offsitekeys: confirm record for %s (fp %s): matched=%v err=%v", customerID, fp, ok, err)
}
s.logf("[INFO] offsitekeys: box confirmed key %s for %s; %d other line(s) removed", fp, customerID, removed)
return removed, nil
}
// MoveAside sets the repository aside (never deletes) on the box's request.
func (s *Service) MoveAside(ctx context.Context, customerID string) (string, error) {
t, pw, err := s.TargetFor(customerID)
if err != nil {
return "", err
}
name, err := s.Reg.MoveAside(ctx, t, pw, s.now().UTC().Format("20060102"))
if err != nil {
return "", err
}
s.logf("[WARN] offsitekeys: moved %s's repository aside: %s -> %s (nothing deleted)", customerID, t.RepoPath, name)
s.event(customerID, EventMovedAside, "info",
fmt.Sprintf("Off-site: the box asked to set its orphaned repository aside; %s was moved to %s. Nothing was deleted.", t.RepoPath, name),
map[string]any{"from": t.RepoPath, "to": name})
return name, nil
}
// AuditOutcome is one customer's daily-check result.
type AuditOutcome struct {
CustomerID string
Result AuditResult
Err error
}
// WindowOpenFunc reports whether a clean-up window is open for the customer (Part E). nil → never.
type WindowOpenFunc func(customerID string) bool
// AuditAll is the DAILY CHECK (decision 69): every provisioned customer's authorized_keys is read, and
// any line that can delete outside an open window raises `offsite_key_unlocked` (error, operator-only),
// naming the line by fingerprint only. An unreadable file raises `offsite_key_audit_failed`.
func (s *Service) AuditAll(ctx context.Context, windowOpen WindowOpenFunc) []AuditOutcome {
cfgs, err := s.Store.ListCustomerConfigs()
if err != nil {
s.logf("[ERROR] offsitekeys: audit: list configs: %v", err)
return nil
}
var out []AuditOutcome
for _, c := range cfgs {
d, derr := offsite.ReadDescriptor(c.ConfigJSON)
if derr != nil || d == nil || !d.Enabled || d.Host == "" {
continue
}
o := AuditOutcome{CustomerID: c.CustomerID}
t, pw, terr := s.TargetFor(c.CustomerID)
if terr != nil {
o.Err = terr
} else {
open := windowOpen != nil && windowOpen(c.CustomerID)
o.Result, o.Err = s.Reg.Audit(ctx, t, pw, open)
}
out = append(out, o)
switch {
case o.Err != nil:
s.logf("[WARN] offsitekeys: audit %s: %v", c.CustomerID, o.Err)
s.event(c.CustomerID, EventAuditFailed, "warning",
fmt.Sprintf("Off-site key check: could not read the key file of %s: %v", c.CustomerID, o.Err), nil)
case len(o.Result.Findings) > 0:
var parts []string
for _, f := range o.Result.Findings {
parts = append(parts, f.Kind+" "+f.Fingerprint)
}
s.logf("[ERROR] offsitekeys: audit %s: %d line(s) can delete off-site history: %s", c.CustomerID, len(o.Result.Findings), strings.Join(parts, "; "))
s.event(c.CustomerID, EventKeyUnlocked, "error",
fmt.Sprintf("Off-site key check: %d key line(s) on %s are NOT append-only and can delete this household's off-site history: %s",
len(o.Result.Findings), t.User, strings.Join(parts, "; ")),
map[string]any{"findings": o.Result.Findings, "lines": o.Result.Lines, "pinned": o.Result.Pinned})
default:
s.logf("[INFO] offsitekeys: audit %s: %d line(s), all pinned append-only", c.CustomerID, o.Result.Lines)
}
}
return out
}
// ── Decision 68: the clean-up window ──────────────────────────────────────────────────────────────
const (
EventWindowDrop = "offsite_window_drop" // error: more snapshots went than a window may remove
EventWindowFailed = "offsite_window_failed" // warning: a window errored or was left open
EventGuardRefused = "offsite_prune_guard_refused" // error: the box's fake-snapshot guard refused (R-822)
windowLength = 20 * time.Minute
windowCadence = 6*24*time.Hour + 12*time.Hour // "weekly", with slack for the night chain's drift
)
// WindowGrant is the hub's answer to the box.
type WindowGrant struct {
Granted bool `json:"granted"`
WindowID int64 `json:"window_id,omitempty"`
NewestAllowed time.Time `json:"newest_allowed,omitempty"`
MaxRemove int `json:"max_remove,omitempty"`
Reason string `json:"reason,omitempty"`
}
// WindowResult is the box's report when it is done.
type WindowResult struct {
WindowID int64 `json:"window_id"`
CountBefore int `json:"count_before"`
CountAfter int `json:"count_after"`
Removed int `json:"removed"`
Outcome string `json:"outcome"`
Reason string `json:"reason"`
}
// MaxRemove is the most snapshots one window may remove: 40 % of what was there, at least 5. The ruled
// policy (7 daily + 4 weekly + 6 monthly per app) removes ~7 of ~17 per app per week (~41 %); the box
// takes the OLDEST first within this bound, so a backlog drains over several windows.
func MaxRemove(countBefore int) int {
n := countBefore * 40 / 100
if n < 5 {
n = 5
}
return n
}
// OpenWindowFor decides and, if due, opens the window: a deleting line for the box's CONFIRMED key is
// prepended (first match wins), and a ledger row bounds it to 20 minutes.
func (s *Service) OpenWindowFor(ctx context.Context, customerID string, countBefore int) (WindowGrant, error) {
oneShot := s.Store.TakeOffsiteWindowGrant(customerID)
last := s.Store.LastOffsiteWindowOpened(customerID)
due := last.IsZero() || s.now().Sub(last) >= windowCadence
if !oneShot && !(s.Store.OffsiteWindowsEnabled() && due) {
reason := "weekly windows are off"
if s.Store.OffsiteWindowsEnabled() {
reason = "not due (last window " + last.UTC().Format(time.RFC3339) + ")"
}
return WindowGrant{Reason: reason}, nil
}
k, err := s.Store.GetOffsiteKey(customerID)
if err != nil || k == nil || k.ConfirmedAt.IsZero() {
return WindowGrant{Reason: "no confirmed append-only key on record"}, nil
}
t, pw, err := s.TargetFor(customerID)
if err != nil {
return WindowGrant{}, err
}
if err := s.Reg.OpenWindow(ctx, t, pw, k.Fingerprint); err != nil {
return WindowGrant{}, err
}
now := s.now()
id, err := s.Store.OpenOffsiteWindowRow(customerID, now.Add(windowLength), countBefore)
if err != nil {
// The line is written; close it rather than leave a deleting line without a ledger row.
_ = s.Reg.CloseWindow(ctx, t, pw)
return WindowGrant{}, err
}
g := WindowGrant{Granted: true, WindowID: id, NewestAllowed: now.UTC(), MaxRemove: MaxRemove(countBefore)}
s.logf("[WARN] offsitekeys: clean-up window %d OPENED for %s (key %s, %d snapshot(s), max %d removed, closes by %s, one-shot=%v)",
id, customerID, k.Fingerprint, countBefore, g.MaxRemove, now.Add(windowLength).UTC().Format(time.RFC3339), oneShot)
return g, nil
}
// CloseWindowFor closes the window on the box's report and checks the count.
func (s *Service) CloseWindowFor(ctx context.Context, customerID string, r WindowResult) error {
w, err := s.Store.GetOffsiteWindow(r.WindowID)
if err != nil || w == nil || w.CustomerID != customerID {
return fmt.Errorf("offsitekeys: window %d is not this customer's", r.WindowID)
}
t, pw, err := s.TargetFor(customerID)
if err != nil {
return err
}
if err := s.Reg.CloseWindow(ctx, t, pw); err != nil {
return err // the sweep retries at closes_by
}
if _, err := s.Store.CloseOffsiteWindowRow(w.ID, r.CountAfter, r.Outcome, "box"); err != nil {
s.logf("[WARN] offsitekeys: ledger close %d: %v", w.ID, err)
}
drop := w.CountBefore - r.CountAfter
s.logf("[INFO] offsitekeys: clean-up window %d CLOSED for %s: outcome=%s, %d -> %d (drop %d, allowed %d)",
w.ID, customerID, r.Outcome, w.CountBefore, r.CountAfter, drop, MaxRemove(w.CountBefore))
details := map[string]any{"window_id": w.ID, "count_before": w.CountBefore, "count_after": r.CountAfter, "outcome": r.Outcome, "reason": r.Reason}
switch {
case drop > MaxRemove(w.CountBefore):
s.event(customerID, EventWindowDrop, "error",
fmt.Sprintf("Off-site clean-up window %d: the snapshot count fell %d -> %d, more than a window may remove (%d).", w.ID, w.CountBefore, r.CountAfter, MaxRemove(w.CountBefore)), details)
case r.Outcome == "guard-refused":
s.event(customerID, EventGuardRefused, "error",
fmt.Sprintf("Off-site clean-up window %d: the box's fake-snapshot guard refused to prune — nothing was deleted: %s", w.ID, r.Reason), details)
case r.Outcome == "error":
s.event(customerID, EventWindowFailed, "warning",
fmt.Sprintf("Off-site clean-up window %d: the prune failed on the box: %s", w.ID, r.Reason), details)
}
return nil
}
// SweepExpiredWindows closes every window left open past its 20 minutes (a box that crashed or lied).
func (s *Service) SweepExpiredWindows(ctx context.Context) {
ws, err := s.Store.ExpiredOffsiteWindows()
if err != nil {
s.logf("[WARN] offsitekeys: window sweep: %v", err)
return
}
for _, w := range ws {
t, pw, err := s.TargetFor(w.CustomerID)
if err == nil {
err = s.Reg.CloseWindow(ctx, t, pw)
}
if err != nil {
s.logf("[ERROR] offsitekeys: window %d for %s is past its time and could NOT be closed: %v (retrying next sweep)", w.ID, w.CustomerID, err)
continue
}
_, _ = s.Store.CloseOffsiteWindowRow(w.ID, -1, "", "timeout")
s.logf("[WARN] offsitekeys: clean-up window %d for %s was left open — closed by the hub", w.ID, w.CustomerID)
s.event(w.CustomerID, EventWindowFailed, "warning",
fmt.Sprintf("Off-site clean-up window %d was not closed by the box within %s; the hub closed it (the deleting key line is removed).", w.ID, windowLength), nil)
}
}