v0.289.0: off-site key cannot delete — append-only rclone transport, box sends only its public key (hub registrar), retention only inside a hub window behind the fake-snapshot guard (decisions 68-69, R-820, R-822)
gates / gates (push) Successful in 27s

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0159rPz1ZhFKsS53msqPYxtS
This commit is contained in:
2026-10-03 17:01:36 +02:00
parent 09453325d1
commit 55bb6c3d32
15 changed files with 1039 additions and 386 deletions
+144 -98
View File
@@ -17,16 +17,13 @@ import (
"strings"
"time"
"gitea.dooplex.hu/admin/felhom-controller/internal/backup"
"golang.org/x/crypto/ssh"
"golang.org/x/crypto/ssh/knownhosts"
)
// --- func adapters (convenient wiring in main.go) ---
type ConsumerFunc func(ctx context.Context) (string, error)
func (f ConsumerFunc) Consume(ctx context.Context) (string, error) { return f(ctx) }
type EnablerFunc func(ctx context.Context, host, user string, port int, repoPath, privPEM, knownHosts string, quotaGB int) error
func (f EnablerFunc) ConfigureOffbox(ctx context.Context, host, user string, port int, repoPath, privPEM, knownHosts string, quotaGB int) error {
@@ -40,48 +37,125 @@ type SettleFunc func() (version, floor string, updateRunning, floorKnown bool)
func (f SettleFunc) SettleState() (string, string, bool, bool) { return f() }
// --- HTTPConsumer: POST the hub consume-password endpoint with the per-customer API key ---
type HTTPConsumer struct {
// --- HubRegistrar: the box's half of the hub key registrar (decision 69, hub >= v0.127.0) ---
//
// The box sends ONLY its public key; the hub writes it into the Storage Box sub-account's
// authorized_keys pinned to `rclone serve restic --stdio --append-only <repo>`. Until controller
// v0.289.0 the box fetched the sub-account PASSWORD here (consume-password) — and that password can
// rewrite authorized_keys, i.e. remove the pin (measured 2026-10-03, R-820). No response this client
// reads can carry a password; TestHubRegistrar_NeverAsksForAPassword pins the paths it calls.
type HubRegistrar struct {
HubURL string
CustomerID string
APIKey string
HC *http.Client
}
func (c HTTPConsumer) Consume(ctx context.Context) (string, error) {
func (c HubRegistrar) post(ctx context.Context, path string, body any) ([]byte, error) {
if c.HubURL == "" || c.CustomerID == "" || c.APIKey == "" {
return "", fmt.Errorf("offsite-apply: consume: hub url/customer/apikey not configured")
return nil, fmt.Errorf("offsite-apply: hub url/customer/apikey not configured")
}
hc := c.HC
if hc == nil {
hc = &http.Client{Timeout: 20 * time.Second}
hc = &http.Client{Timeout: 3 * time.Minute} // the hub does an SSH round trip to the provider
}
url := strings.TrimRight(c.HubURL, "/") + "/api/v1/offsite/consume-password/" + c.CustomerID
req, err := http.NewRequestWithContext(ctx, http.MethodPost, url, nil)
var rd io.Reader
if body != nil {
b, err := json.Marshal(body)
if err != nil {
return nil, err
}
rd = strings.NewReader(string(b))
}
url := strings.TrimRight(c.HubURL, "/") + "/api/v1/offsite/" + path + "/" + c.CustomerID
req, err := http.NewRequestWithContext(ctx, http.MethodPost, url, rd)
if err != nil {
return "", err
return nil, err
}
req.Header.Set("Authorization", "Bearer "+c.APIKey)
req.Header.Set("Content-Type", "application/json")
resp, err := hc.Do(req)
if err != nil {
return "", err
return nil, err
}
defer resp.Body.Close()
raw, _ := io.ReadAll(io.LimitReader(resp.Body, 1<<16))
if resp.StatusCode == http.StatusNotFound {
return "", fmt.Errorf("no unconsumed offsite password (already consumed or none provisioned)")
}
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
return "", fmt.Errorf("consume: HTTP %d", resp.StatusCode)
return nil, fmt.Errorf("hub %s: HTTP %d: %s", path, resp.StatusCode, truncate(raw))
}
var body struct {
Password string `json:"password"`
return raw, nil
}
// Register asks the hub to install pub pinned append-only; returns the key's fingerprint as the hub saw it.
func (c HubRegistrar) Register(ctx context.Context, pub string) (string, error) {
raw, err := c.post(ctx, "register-key", map[string]string{"public_key": strings.TrimSpace(pub)})
if err != nil {
return "", err
}
if err := json.Unmarshal(raw, &body); err != nil || body.Password == "" {
return "", fmt.Errorf("consume: malformed response")
var r struct {
Installed bool `json:"installed"`
Fingerprint string `json:"fingerprint"`
}
return body.Password, nil // NEVER logged
if err := json.Unmarshal(raw, &r); err != nil || !r.Installed || r.Fingerprint == "" {
return "", fmt.Errorf("hub register-key: malformed response")
}
return r.Fingerprint, nil
}
// Confirm tells the hub the box now uses fp; the hub removes every other key line.
func (c HubRegistrar) Confirm(ctx context.Context, fp string) error {
_, err := c.post(ctx, "confirm-key", map[string]string{"fingerprint": fp})
return err
}
// MoveAside asks the hub to set the repository aside (never deletes) — the box's pinned key cannot.
func (c HubRegistrar) MoveAside(ctx context.Context) (string, error) {
raw, err := c.post(ctx, "move-aside", nil)
if err != nil {
return "", err
}
var r struct {
MovedTo string `json:"moved_to"`
}
if err := json.Unmarshal(raw, &r); err != nil || r.MovedTo == "" {
return "", fmt.Errorf("hub move-aside: malformed response")
}
return r.MovedTo, nil
}
// --- HubWindowClient: the box's half of the clean-up window (decision 68) ---
type HubWindowClient struct{ Registrar HubRegistrar }
func (c HubWindowClient) Open(ctx context.Context, countBefore int) (backup.OffsiteWindow, error) {
raw, err := c.Registrar.post(ctx, "window-open", map[string]int{"count_before": countBefore})
if err != nil {
return backup.OffsiteWindow{}, err
}
var r struct {
Granted bool `json:"granted"`
WindowID int64 `json:"window_id"`
NewestAllowed string `json:"newest_allowed"`
MaxRemove int `json:"max_remove"`
Reason string `json:"reason"`
}
if err := json.Unmarshal(raw, &r); err != nil {
return backup.OffsiteWindow{}, fmt.Errorf("hub window-open: malformed response")
}
w := backup.OffsiteWindow{Granted: r.Granted, ID: r.WindowID, MaxRemove: r.MaxRemove, Reason: r.Reason}
if r.Granted {
t, perr := time.Parse(time.RFC3339, r.NewestAllowed)
if perr != nil {
return backup.OffsiteWindow{}, fmt.Errorf("hub window-open: bad newest_allowed")
}
w.NewestAllowed = t
}
return w, nil
}
func (c HubWindowClient) Close(ctx context.Context, res backup.OffsiteWindowResult) error {
_, err := c.Registrar.post(ctx, "window-close", res)
return err
}
// --- KeyscanScanner: capture the box host key (x/crypto/ssh, no binary) → fingerprint + known_hosts line ---
@@ -145,72 +219,21 @@ func (ED25519KeyGen) Generate() (string, string, error) {
return privPEM, pubLine, nil
}
// --- SSHCopyIDInstaller: install the pubkey via the proven `sshpass -e ssh-copy-id -p N -s -f`, verify ---
type SSHCopyIDInstaller struct{}
func (SSHCopyIDInstaller) Install(ctx context.Context, host, user string, port int, password, privPEM, pubAuthorized, knownHosts string) error {
if strings.TrimSpace(knownHosts) == "" {
return fmt.Errorf("ssh-copy-id: empty known_hosts — refusing to install without a pinned host key")
}
// ssh-copy-id -s (SFTP mode) mktemp's its batch file under ~/.ssh and dies LOCALLY if the directory
// doesn't exist — the container image ships without /root/.ssh (live finding: the one-time password was
// consumed, then the install failed before ever connecting).
if home, err := os.UserHomeDir(); err == nil {
if err := os.MkdirAll(filepath.Join(home, ".ssh"), 0o700); err != nil {
return fmt.Errorf("ssh-copy-id: ensure ~/.ssh (needed by -s mode): %w", err)
}
}
work, err := os.MkdirTemp("", "felhom-keyinstall-")
if err != nil {
return err
}
defer os.RemoveAll(work)
pubPath := filepath.Join(work, "id.pub")
privPath := filepath.Join(work, "id")
khPath := filepath.Join(work, "known_hosts")
if err := os.WriteFile(pubPath, []byte(pubAuthorized), 0o600); err != nil {
return err
}
if err := os.WriteFile(privPath, []byte(privPEM), 0o600); err != nil {
return err
}
// Pin the scanner-VERIFIED host key: StrictHostKeyChecking=yes against this known_hosts refuses any
// other key (no accept-new/TOFU) — the ssh-copy-id + verify sessions connect ONLY to the box whose
// fingerprint the bridge already matched against the hub descriptor.
if err := os.WriteFile(khPath, []byte(knownHosts+"\n"), 0o600); err != nil {
return err
}
// Install (SSHPASS env is read by `sshpass -e`; the password never appears on argv).
install := exec.CommandContext(ctx, "sshpass", "-e", "ssh-copy-id", "-p", strconv.Itoa(port), "-s", "-f",
"-i", pubPath, "-o", "StrictHostKeyChecking=yes", "-o", "UserKnownHostsFile="+khPath, user+"@"+host)
install.Env = append(os.Environ(), "SSHPASS="+password)
if out, err := install.CombinedOutput(); err != nil {
return fmt.Errorf("ssh-copy-id: %w: %s", err, truncate(out))
}
// Verify passwordless key auth (an SFTP no-op; the box's restricted shell only offers SFTP).
verify := exec.CommandContext(ctx, "sftp", "-b", "-", "-P", strconv.Itoa(port),
"-i", privPath, "-oBatchMode=yes", "-oStrictHostKeyChecking=yes", "-oUserKnownHostsFile="+khPath, user+"@"+host)
verify.Stdin = strings.NewReader("pwd\n")
if out, err := verify.CombinedOutput(); err != nil {
return fmt.Errorf("key-auth verify failed after install: %w: %s", err, truncate(out))
}
return nil
// --- PinnedProber: does this key reach the PINNED append-only server? ---
//
// Measured on the provider 2026-10-03 (audits/offsite-lock-build-2026-10-03/partA/A5): `ssh -i <key>
// host probe` with stdin closed exits 0 and prints rclone's NOTICE when the key is pinned (the forced
// rclone starts, sees EOF, exits); an UNPINNED key gets the restricted shell → "Command not found",
// exit 8. A refused key exits 255. So ok = exit 0 AND "rclone" in the output — a POSITIVE observable.
type PinnedProber struct {
Timeout time.Duration // 0 → 20 s
// Run is the exec seam (tests); nil → real ssh.
Run func(ctx context.Context, args []string) ([]byte, error)
}
// --- SFTPKeyAuthProber: does the ALREADY-INSTALLED key still authenticate? (key-auth-first) ---
// SFTPKeyAuthProber probes passwordless auth with the existing installed key (KeyPath), pinned to the
// freshly-verified knownHosts line. No key file → ok=false (fresh guest). The probe never logs secrets.
type SFTPKeyAuthProber struct {
KeyPath string // the installed key, e.g. <dataDir>/offbox/ssh_key
Timeout time.Duration // per-probe budget; 0 → 20s
}
func (p SFTPKeyAuthProber) Probe(ctx context.Context, host, user string, port int, knownHosts string) (string, bool) {
pem, err := os.ReadFile(p.KeyPath)
if err != nil {
return "", false // no existing key — a fresh guest; take the full path
func (p PinnedProber) Probe(ctx context.Context, host, user string, port int, knownHosts, privPEM string) bool {
if strings.TrimSpace(privPEM) == "" {
return false
}
timeout := p.Timeout
if timeout == 0 {
@@ -220,21 +243,44 @@ func (p SFTPKeyAuthProber) Probe(ctx context.Context, host, user string, port in
defer cancel()
work, err := os.MkdirTemp("", "felhom-keyprobe-")
if err != nil {
return "", false
return false
}
defer os.RemoveAll(work)
khPath := filepath.Join(work, "known_hosts")
if err := os.WriteFile(khPath, []byte(knownHosts+"\n"), 0o600); err != nil {
return "", false
khPath, keyPath := filepath.Join(work, "known_hosts"), filepath.Join(work, "id")
if os.WriteFile(khPath, []byte(knownHosts+"\n"), 0o600) != nil || os.WriteFile(keyPath, []byte(privPEM), 0o600) != nil {
return false
}
probe := exec.CommandContext(pctx, "sftp", "-b", "-", "-P", strconv.Itoa(port),
"-i", p.KeyPath, "-oBatchMode=yes", "-oConnectTimeout=10",
"-oStrictHostKeyChecking=yes", "-oUserKnownHostsFile="+khPath, user+"@"+host)
probe.Stdin = strings.NewReader("pwd\n")
if err := probe.Run(); err != nil {
return "", false // auth refused / unreachable — fall through to the full path
args := []string{"-p", strconv.Itoa(port), "-i", keyPath, "-oBatchMode=yes", "-oConnectTimeout=10", "-oIdentitiesOnly=yes",
"-oStrictHostKeyChecking=yes", "-oUserKnownHostsFile=" + khPath, user + "@" + host, "probe"}
run := p.Run
if run == nil {
run = func(ctx context.Context, args []string) ([]byte, error) {
cmd := exec.CommandContext(ctx, "ssh", args...)
cmd.Stdin = strings.NewReader("")
return cmd.CombinedOutput()
}
}
return string(pem), true
out, err := run(pctx, args)
return err == nil && strings.Contains(string(out), "rclone")
}
// PublicKeyOf derives the authorized_keys line of an OpenSSH private key (the migration path: a box
// whose key predates the pin registers the SAME key, so the hub re-writes it pinned).
func PublicKeyOf(privPEM string) (string, error) {
signer, err := ssh.ParsePrivateKey([]byte(privPEM))
if err != nil {
return "", err
}
return string(ssh.MarshalAuthorizedKey(signer.PublicKey())), nil
}
// FingerprintOf returns the SHA256 fingerprint of an authorized_keys line.
func FingerprintOf(pub string) (string, error) {
pk, _, _, _, err := ssh.ParseAuthorizedKey([]byte(pub))
if err != nil {
return "", err
}
return ssh.FingerprintSHA256(pk), nil
}
func truncate(b []byte) string {