Files
felhom.eu/hub/internal/offsitekeys/offsitekeys.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

387 lines
13 KiB
Go

// 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 <repo>",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=<file>` 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) {
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)
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, "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 `<repo>.orphaned-<date>` (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
}