f417cdede1
gates / gates (push) Successful in 29s
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
336 lines
14 KiB
Go
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)
|
|
}
|
|
}
|