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
387 lines
13 KiB
Go
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
|
|
}
|