// Package offsitekeys is the hub's off-site KEY REGISTRAR (decision 69, R-820): the hub — never the box — // writes the box's public key into the customer's Storage Box sub-account `.ssh/authorized_keys`, pinned // to an append-only rclone server, and audits that file. // // WHY THE HUB AND NOT THE BOX (measured 2026-10-03, audits/offsite-append-only-2026-10-03/): a key pinned // to `command="rclone serve restic --stdio --append-only ",restrict` can back up and restore and is // refused every delete (403). But the sub-account PASSWORD logs in on ports 22 and 23 and can rewrite // `authorized_keys` — removing the pin. So the protection holds only if no box ever holds the password. // The box sends its PUBLIC key; the hub, which keeps the password sealed (R-821), does the write. // // THE TRANSPORT IS THE PROVIDER'S RESTRICTED SHELL (port 23), not SFTP: measured on the provider, its // `dd of=` takes stdin, `mv` overwrites, `cat` of a missing file exits 1, and `test` does not exist. // That needs only golang.org/x/crypto/ssh, which the hub already uses — no new dependency. // // THE WINDOW (decision 68, Part E): OpenSSH uses the FIRST line whose key matches. Measured on the // provider: the same key on an append-only line first and a deleting line second → refused (403); // deleting line first → deletes. So opening a window = PREPENDING an unpinned-delete line for the box's // own key; closing = removing it. One key on the box. package offsitekeys import ( "context" "errors" "fmt" "sort" "strings" "golang.org/x/crypto/ssh" ) // Shell runs one command in the sub-account's restricted shell, feeding stdin, returning stdout. A // non-zero exit is an error (*ssh.ExitError underneath). type Shell interface { Run(ctx context.Context, cmd string, stdin []byte) ([]byte, error) Close() error } // Target is one sub-account, from the customer's offsite descriptor. type Target struct { Host string User string Port int RepoPath string // e.g. /home/felhom-repo — measured: an absolute path works in the forced command Fingerprint string // the host-key fingerprint captured at provisioning (SHA256:…); REQUIRED } // Dialer opens a Shell to a Target with the sub-account password, verifying the host key against // Target.Fingerprint (never TOFU). type Dialer interface { Dial(ctx context.Context, t Target, password string) (Shell, error) } // PinnedPrefix is the authorized_keys option set every box key must carry outside a window. func PinnedPrefix(repoPath string) string { return fmt.Sprintf(`command="rclone serve restic --stdio --append-only %s",restrict `, repoPath) } // WindowPrefix is the DELETING line written only while a clean-up window is open (decision 68). func WindowPrefix(repoPath string) string { return fmt.Sprintf(`command="rclone serve restic --stdio %s",restrict `, repoPath) } const authorizedKeys = ".ssh/authorized_keys" const tmpKeys = ".ssh/authorized_keys.felhom-new" // Line is one parsed authorized_keys line. type Line struct { Raw string Fingerprint string // SHA256:… of the key, "" when the line holds no parseable key Pinned bool // carries exactly PinnedPrefix(repo) Window bool // carries exactly WindowPrefix(repo) } // ParseLines classifies every non-empty, non-comment line of an authorized_keys file. func ParseLines(content, repoPath string) []Line { var out []Line pin, win := PinnedPrefix(repoPath), WindowPrefix(repoPath) for _, raw := range strings.Split(content, "\n") { raw = strings.TrimRight(raw, "\r") t := strings.TrimSpace(raw) if t == "" || strings.HasPrefix(t, "#") { continue } l := Line{Raw: t, Pinned: strings.HasPrefix(t, pin), Window: strings.HasPrefix(t, win)} if pk, _, _, _, err := ssh.ParseAuthorizedKey([]byte(t)); err == nil { l.Fingerprint = ssh.FingerprintSHA256(pk) } out = append(out, l) } return out } // KeyFingerprint validates a single PUBLIC key line as a box would send it (no options) and returns its // fingerprint and its canonical "type base64" form (the comment is dropped — it is box-supplied text). func KeyFingerprint(pub string) (fp, canonical string, err error) { pub = strings.TrimSpace(pub) if pub == "" || strings.ContainsAny(pub, "\n\r\x00") { return "", "", errors.New("offsitekeys: public key must be one line") } pk, _, options, _, err := ssh.ParseAuthorizedKey([]byte(pub)) if err != nil { return "", "", fmt.Errorf("offsitekeys: not a public key: %w", err) } if len(options) > 0 { return "", "", errors.New("offsitekeys: a box key must carry no options — the hub writes them") } switch pk.Type() { case ssh.KeyAlgoED25519, ssh.KeyAlgoRSA, ssh.KeyAlgoECDSA256, ssh.KeyAlgoECDSA384, ssh.KeyAlgoECDSA521: default: return "", "", fmt.Errorf("offsitekeys: key type %s not accepted", pk.Type()) } canon := strings.TrimSpace(string(ssh.MarshalAuthorizedKey(pk))) return ssh.FingerprintSHA256(pk), canon, nil } // Registrar performs the key-file operations. Every method opens its own Shell and closes it. type Registrar struct { Dialer Dialer } func (r *Registrar) open(ctx context.Context, t Target, password string) (Shell, error) { if t.Fingerprint == "" { return nil, errors.New("offsitekeys: target has no host fingerprint — refusing (no blind TOFU)") } if t.Host == "" || t.User == "" || t.RepoPath == "" { return nil, errors.New("offsitekeys: target missing host/user/repo_path") } return r.Dialer.Dial(ctx, t, password) } // read returns the current authorized_keys content; a missing file is "" (cat exits 1 there). func read(ctx context.Context, sh Shell) (string, error) { // READ-ONLY (R-827, v0.128.0): a missing .ssh or file reads as "" — the directory is created only by // write(). Until v0.127.0 the daily check created .ssh on a sub-account that had none. if _, err := sh.Run(ctx, "ls -d .ssh", nil); err != nil { return "", nil } if _, err := sh.Run(ctx, "ls "+authorizedKeys, nil); err != nil { return "", nil // no file yet } out, err := sh.Run(ctx, "cat "+authorizedKeys, nil) if err != nil { return "", fmt.Errorf("offsitekeys: read authorized_keys: %w", err) } return string(out), nil } // write replaces authorized_keys atomically (dd to a temp file, chmod, mv) and reads it back. func write(ctx context.Context, sh Shell, content string) error { if _, err := sh.Run(ctx, "ls -d .ssh", nil); err != nil { if _, merr := sh.Run(ctx, "mkdir .ssh", nil); merr != nil { return fmt.Errorf("offsitekeys: create .ssh: %w", merr) } _, _ = sh.Run(ctx, "chmod 700 .ssh", nil) } if _, err := sh.Run(ctx, "dd of="+tmpKeys, []byte(content)); err != nil { return fmt.Errorf("offsitekeys: write temp file: %w", err) } if _, err := sh.Run(ctx, "chmod 600 "+tmpKeys, nil); err != nil { return fmt.Errorf("offsitekeys: chmod temp file: %w", err) } if _, err := sh.Run(ctx, "mv "+tmpKeys+" "+authorizedKeys, nil); err != nil { return fmt.Errorf("offsitekeys: move into place: %w", err) } back, err := sh.Run(ctx, "cat "+authorizedKeys, nil) if err != nil { return fmt.Errorf("offsitekeys: read back: %w", err) } if strings.TrimSpace(string(back)) != strings.TrimSpace(content) { return errors.New("offsitekeys: read-back differs from what was written") } return nil } func join(lines []string) string { if len(lines) == 0 { return "" } return strings.Join(lines, "\n") + "\n" } // InstallResult says what Install changed. type InstallResult struct { Fingerprint string RemovedUnpinned int // unpinned lines dropped (any line without the pin is a deletion route) } // Install adds the box's key pinned append-only. Every UNPINNED line is dropped in the same write — an // unpinned line is a route to deletion (this is also the migration of a box whose key predates the pin: // the same key comes back pinned). Other PINNED lines are kept until Confirm, so a box that has not yet // switched keeps working (rotation: write new → box confirms → remove old). Idempotent. func (r *Registrar) Install(ctx context.Context, t Target, password, pub string) (InstallResult, error) { fp, canon, err := KeyFingerprint(pub) if err != nil { return InstallResult{}, err } sh, err := r.open(ctx, t, password) if err != nil { return InstallResult{}, err } defer sh.Close() cur, err := read(ctx, sh) if err != nil { return InstallResult{}, err } res := InstallResult{Fingerprint: fp} var keep []string for _, l := range ParseLines(cur, t.RepoPath) { switch { case !l.Pinned: res.RemovedUnpinned++ case l.Fingerprint == fp: // re-added below, once default: keep = append(keep, l.Raw) } } keep = append(keep, PinnedPrefix(t.RepoPath)+canon+" felhom-box") if err := write(ctx, sh, join(keep)); err != nil { return InstallResult{}, err } return res, nil } // Confirm keeps ONLY the pinned line of the confirmed key — the rotation's last step. Returns how many // lines it removed. Refuses when the confirmed key is not present pinned (nothing is written). func (r *Registrar) Confirm(ctx context.Context, t Target, password, fp string) (int, error) { sh, err := r.open(ctx, t, password) if err != nil { return 0, err } defer sh.Close() cur, err := read(ctx, sh) if err != nil { return 0, err } var keep []string removed := 0 for _, l := range ParseLines(cur, t.RepoPath) { if l.Pinned && l.Fingerprint == fp { if len(keep) == 0 { keep = append(keep, l.Raw) } else { removed++ } continue } removed++ } if len(keep) == 0 { return 0, fmt.Errorf("offsitekeys: key %s is not installed pinned — nothing confirmed", fp) } if removed == 0 { return 0, nil } if err := write(ctx, sh, join(keep)); err != nil { return 0, err } return removed, nil } // Finding is one problem the audit saw. It names the line by fingerprint, NEVER by key material. type Finding struct { Fingerprint string Kind string // "unpinned" | "window" | "unparseable" } // AuditResult is the daily check's view of one sub-account. type AuditResult struct { Lines int Pinned int Findings []Finding } // Audit reads authorized_keys and reports every line that is not pinned append-only. A window line is // reported only when no window is supposed to be open. Read-only. func (r *Registrar) Audit(ctx context.Context, t Target, password string, windowOpen bool) (AuditResult, error) { sh, err := r.open(ctx, t, password) if err != nil { return AuditResult{}, err } defer sh.Close() cur, err := read(ctx, sh) if err != nil { return AuditResult{}, err } return audit(cur, t.RepoPath, windowOpen), nil } func audit(content, repo string, windowOpen bool) AuditResult { var res AuditResult for _, l := range ParseLines(content, repo) { res.Lines++ switch { case l.Pinned: res.Pinned++ case l.Window && windowOpen: // expected while the window is open case l.Window: res.Findings = append(res.Findings, Finding{Fingerprint: l.Fingerprint, Kind: "window"}) case l.Fingerprint == "": res.Findings = append(res.Findings, Finding{Kind: "unparseable"}) default: res.Findings = append(res.Findings, Finding{Fingerprint: l.Fingerprint, Kind: "unpinned"}) } } sort.Slice(res.Findings, func(i, j int) bool { return res.Findings[i].Fingerprint < res.Findings[j].Fingerprint }) return res } // OpenWindow PREPENDS a deleting line for the (pinned, installed) key fp — first match wins (measured). // Refuses when fp is not installed pinned. Idempotent. func (r *Registrar) OpenWindow(ctx context.Context, t Target, password, fp string) error { sh, err := r.open(ctx, t, password) if err != nil { return err } defer sh.Close() cur, err := read(ctx, sh) if err != nil { return err } var body string var rest []string for _, l := range ParseLines(cur, t.RepoPath) { if l.Window { continue // re-written below exactly once } if l.Pinned && l.Fingerprint == fp && body == "" { body = strings.TrimPrefix(l.Raw, PinnedPrefix(t.RepoPath)) } rest = append(rest, l.Raw) } if body == "" { return fmt.Errorf("offsitekeys: key %s is not installed pinned — no window opened", fp) } return write(ctx, sh, join(append([]string{WindowPrefix(t.RepoPath) + body}, rest...))) } // CloseWindow removes every window line. Idempotent; a file with no window line is not rewritten. func (r *Registrar) CloseWindow(ctx context.Context, t Target, password string) error { sh, err := r.open(ctx, t, password) if err != nil { return err } defer sh.Close() cur, err := read(ctx, sh) if err != nil { return err } var rest []string found := false for _, l := range ParseLines(cur, t.RepoPath) { if l.Window { found = true continue } rest = append(rest, l.Raw) } if !found { return nil } return write(ctx, sh, join(rest)) } // MoveAside renames the repository directory to `.orphaned-` (then -2, -3 …), NEVER // deletes — the R-26/R-32 move-aside the box can no longer do itself (its key reaches only the pinned // rclone server). Returns the new path. A missing repository is an error (nothing to set aside). func (r *Registrar) MoveAside(ctx context.Context, t Target, password, date string) (string, error) { sh, err := r.open(ctx, t, password) if err != nil { return "", err } defer sh.Close() if _, err := sh.Run(ctx, "ls -d "+t.RepoPath, nil); err != nil { return "", fmt.Errorf("offsitekeys: repository %s not found: %w", t.RepoPath, err) } base := t.RepoPath + ".orphaned-" + date name := base for i := 2; i <= 50; i++ { if _, err := sh.Run(ctx, "ls -d "+name, nil); err != nil { break // absent → free } name = fmt.Sprintf("%s-%d", base, i) } if _, err := sh.Run(ctx, "mv "+t.RepoPath+" "+name, nil); err != nil { return "", fmt.Errorf("offsitekeys: move aside: %w", err) } return name, nil } // RemoveUnpinned rewrites authorized_keys keeping only the PINNED lines (decision 72, R-826): every // unpinned line — a route to deletion — and any window line (when no window is open) goes. An empty // result is allowed (no box). Returns how many lines were removed; a file with nothing to remove is not // rewritten. func (r *Registrar) RemoveUnpinned(ctx context.Context, t Target, password string, windowOpen bool) (int, error) { sh, err := r.open(ctx, t, password) if err != nil { return 0, err } defer sh.Close() cur, err := read(ctx, sh) if err != nil { return 0, err } var keep []string removed := 0 for _, l := range ParseLines(cur, t.RepoPath) { if l.Pinned || (l.Window && windowOpen) { keep = append(keep, l.Raw) continue } removed++ } if removed == 0 { return 0, nil } return removed, write(ctx, sh, join(keep)) } // DeleteSetAside removes one SET-ASIDE repository directory (decision 74, R-823) — the only deletion the // registrar performs. It refuses anything that is not `.orphaned-<…>` (never the live // repository, never a path with a slash or "..") and anything that does not exist. func (r *Registrar) DeleteSetAside(ctx context.Context, t Target, password, path string) error { if !IsSetAsidePath(t.RepoPath, path) { return fmt.Errorf("offsitekeys: %q is not a set-aside copy of %s — refusing", path, t.RepoPath) } sh, err := r.open(ctx, t, password) if err != nil { return err } defer sh.Close() if _, err := sh.Run(ctx, "ls -d "+path, nil); err != nil { return fmt.Errorf("offsitekeys: set-aside copy %s not found: %w", path, err) } if _, err := sh.Run(ctx, "rm -rf "+path, nil); err != nil { return fmt.Errorf("offsitekeys: delete %s: %w", path, err) } if _, err := sh.Run(ctx, "ls -d "+path, nil); err == nil { return fmt.Errorf("offsitekeys: %s still exists after the delete", path) } return nil } // IsSetAsidePath: `.orphaned-` with a suffix of [A-Za-z0-9-] only. func IsSetAsidePath(repo, path string) bool { pre := repo + ".orphaned-" if repo == "" || !strings.HasPrefix(path, pre) || len(path) == len(pre) { return false } for _, c := range path[len(pre):] { if !(c == '-' || (c >= '0' && c <= '9') || (c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z')) { return false } } return true }