d3c50b50f6
gates / gates (push) Successful in 29s
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0159rPz1ZhFKsS53msqPYxtS
220 lines
7.2 KiB
Go
220 lines
7.2 KiB
Go
package offsitekeys
|
|
|
|
import (
|
|
"context"
|
|
"crypto/ed25519"
|
|
"crypto/rand"
|
|
"errors"
|
|
"strings"
|
|
"testing"
|
|
|
|
"golang.org/x/crypto/ssh"
|
|
)
|
|
|
|
// fakeFS emulates the provider's restricted shell as MEASURED 2026-10-03: `dd of=` takes stdin, `mv`
|
|
// overwrites, `cat`/`ls` of a missing path exit non-zero, `test` does not exist.
|
|
type fakeFS struct {
|
|
files map[string]string
|
|
dirs map[string]bool
|
|
cmds []string
|
|
}
|
|
|
|
func newFS() *fakeFS { return &fakeFS{files: map[string]string{}, dirs: map[string]bool{".ssh": true}} }
|
|
|
|
func (f *fakeFS) Run(_ context.Context, cmd string, stdin []byte) ([]byte, error) {
|
|
f.cmds = append(f.cmds, cmd)
|
|
a := strings.Fields(cmd)
|
|
miss := errors.New("exit status 1")
|
|
switch {
|
|
case a[0] == "ls" && a[1] == "-d":
|
|
if f.dirs[a[2]] {
|
|
return []byte(a[2] + "\n"), nil
|
|
}
|
|
return nil, miss
|
|
case a[0] == "ls":
|
|
if _, ok := f.files[a[1]]; ok {
|
|
return []byte(a[1]), nil
|
|
}
|
|
return nil, miss
|
|
case a[0] == "cat":
|
|
if v, ok := f.files[a[1]]; ok {
|
|
return []byte(v), nil
|
|
}
|
|
return nil, miss
|
|
case a[0] == "mkdir":
|
|
f.dirs[a[1]] = true
|
|
return nil, nil
|
|
case a[0] == "chmod":
|
|
return nil, nil
|
|
case strings.HasPrefix(a[0], "dd") && strings.HasPrefix(a[1], "of="):
|
|
f.files[strings.TrimPrefix(a[1], "of=")] = string(stdin)
|
|
return nil, nil
|
|
case a[0] == "mv":
|
|
if v, ok := f.files[a[1]]; ok {
|
|
f.files[a[2]] = v
|
|
delete(f.files, a[1])
|
|
return nil, nil
|
|
}
|
|
if f.dirs[a[1]] {
|
|
f.dirs[a[2]] = true
|
|
delete(f.dirs, a[1])
|
|
return nil, nil
|
|
}
|
|
return nil, miss
|
|
case a[0] == "rm" && a[1] == "-rf" && strings.Contains(a[2], ".orphaned-"):
|
|
delete(f.dirs, a[2]) // decision 74: the ONLY delete, of a set-aside copy
|
|
return nil, nil
|
|
case a[0] == "rm":
|
|
return nil, errors.New("the registrar must never delete anything else")
|
|
}
|
|
return nil, errors.New("Command not found")
|
|
}
|
|
func (f *fakeFS) Close() error { return nil }
|
|
|
|
type fakeDialer struct{ fs *fakeFS }
|
|
|
|
func (d fakeDialer) Dial(context.Context, Target, string) (Shell, error) { return d.fs, nil }
|
|
|
|
var tgt = Target{Host: "u1-sub4.example", User: "u1-sub4", Port: 23, RepoPath: "/home/felhom-repo", Fingerprint: "SHA256:host"}
|
|
|
|
func newKey(t *testing.T) (pub, fp string) {
|
|
t.Helper()
|
|
k, _, err := ed25519.GenerateKey(rand.Reader)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
pk, _ := ssh.NewPublicKey(k)
|
|
return strings.TrimSpace(string(ssh.MarshalAuthorizedKey(pk))) + " box", ssh.FingerprintSHA256(pk)
|
|
}
|
|
|
|
// The MIGRATION shape: a box whose key predates the pin (an unpinned line, exactly as ssh-copy-id left
|
|
// it) registers the SAME key → it comes back pinned and the unpinned line is gone. Then the audit is clean.
|
|
func TestInstall_MigratesUnpinnedKeyAndAuditGoesClean(t *testing.T) {
|
|
fs := newFS()
|
|
pub, fp := newKey(t)
|
|
other, _ := newKey(t)
|
|
fs.files[".ssh/authorized_keys"] = pub + "\n" + other + "\n"
|
|
r := &Registrar{Dialer: fakeDialer{fs}}
|
|
|
|
before, err := r.Audit(context.Background(), tgt, "pw", false)
|
|
if err != nil || len(before.Findings) != 2 {
|
|
t.Fatalf("before: %+v %v — want 2 unpinned findings (the decoy must be seen)", before, err)
|
|
}
|
|
res, err := r.Install(context.Background(), tgt, "pw", pub)
|
|
if err != nil || res.Fingerprint != fp || res.RemovedUnpinned != 2 {
|
|
t.Fatalf("install = %+v, %v", res, err)
|
|
}
|
|
got := fs.files[".ssh/authorized_keys"]
|
|
if !strings.HasPrefix(got, PinnedPrefix(tgt.RepoPath)) || strings.Count(got, "\n") != 1 {
|
|
t.Fatalf("file after install:\n%s", got)
|
|
}
|
|
after, _ := r.Audit(context.Background(), tgt, "pw", false)
|
|
if len(after.Findings) != 0 || after.Pinned != 1 {
|
|
t.Fatalf("after: %+v", after)
|
|
}
|
|
for _, c := range fs.cmds {
|
|
if strings.HasPrefix(c, "rm") {
|
|
t.Fatalf("registrar issued a delete: %q", c)
|
|
}
|
|
}
|
|
}
|
|
|
|
// Rotation: new key installed beside the old pinned one; Confirm leaves only the new one.
|
|
func TestInstallThenConfirm_Rotation(t *testing.T) {
|
|
fs := newFS()
|
|
r := &Registrar{Dialer: fakeDialer{fs}}
|
|
oldPub, oldFP := newKey(t)
|
|
newPub, newFP := newKey(t)
|
|
if _, err := r.Install(context.Background(), tgt, "pw", oldPub); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if _, err := r.Install(context.Background(), tgt, "pw", newPub); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
lines := ParseLines(fs.files[".ssh/authorized_keys"], tgt.RepoPath)
|
|
if len(lines) != 2 || !lines[0].Pinned || !lines[1].Pinned {
|
|
t.Fatalf("both keys must be pinned until confirm: %+v", lines)
|
|
}
|
|
if _, err := r.Confirm(context.Background(), tgt, "pw", "SHA256:not-installed"); err == nil {
|
|
t.Fatal("confirming an absent key must refuse")
|
|
}
|
|
n, err := r.Confirm(context.Background(), tgt, "pw", newFP)
|
|
if err != nil || n != 1 {
|
|
t.Fatalf("confirm = %d, %v", n, err)
|
|
}
|
|
lines = ParseLines(fs.files[".ssh/authorized_keys"], tgt.RepoPath)
|
|
if len(lines) != 1 || lines[0].Fingerprint != newFP || lines[0].Fingerprint == oldFP {
|
|
t.Fatalf("after confirm: %+v", lines)
|
|
}
|
|
}
|
|
|
|
// The window (decision 68): the deleting line goes FIRST (first match wins — measured), the audit
|
|
// tolerates it only while a window is open, and closing removes it.
|
|
func TestWindow_PrependAuditClose(t *testing.T) {
|
|
fs := newFS()
|
|
r := &Registrar{Dialer: fakeDialer{fs}}
|
|
pub, fp := newKey(t)
|
|
if err := r.OpenWindow(context.Background(), tgt, "pw", fp); err == nil {
|
|
t.Fatal("a window for a key that is not installed must refuse")
|
|
}
|
|
if _, err := r.Install(context.Background(), tgt, "pw", pub); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := r.OpenWindow(context.Background(), tgt, "pw", fp); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
lines := ParseLines(fs.files[".ssh/authorized_keys"], tgt.RepoPath)
|
|
if len(lines) != 2 || !lines[0].Window || !lines[1].Pinned || lines[0].Fingerprint != fp {
|
|
t.Fatalf("window line must be first: %+v", lines)
|
|
}
|
|
if a, _ := r.Audit(context.Background(), tgt, "pw", true); len(a.Findings) != 0 {
|
|
t.Fatalf("open window flagged: %+v", a)
|
|
}
|
|
if a, _ := r.Audit(context.Background(), tgt, "pw", false); len(a.Findings) != 1 || a.Findings[0].Kind != "window" {
|
|
t.Fatalf("a window line with no open window must alarm: %+v", a)
|
|
}
|
|
if err := r.CloseWindow(context.Background(), tgt, "pw"); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if a, _ := r.Audit(context.Background(), tgt, "pw", false); len(a.Findings) != 0 || a.Pinned != 1 {
|
|
t.Fatalf("after close: %+v", a)
|
|
}
|
|
}
|
|
|
|
// Move-aside renames, never deletes, and never reuses a name.
|
|
func TestMoveAside_RenamesNeverDeletes(t *testing.T) {
|
|
fs := newFS()
|
|
fs.dirs["/home/felhom-repo"] = true
|
|
fs.dirs["/home/felhom-repo.orphaned-20261003"] = true
|
|
r := &Registrar{Dialer: fakeDialer{fs}}
|
|
to, err := r.MoveAside(context.Background(), tgt, "pw", "20261003")
|
|
if err != nil || to != "/home/felhom-repo.orphaned-20261003-2" {
|
|
t.Fatalf("move-aside = %q, %v", to, err)
|
|
}
|
|
if fs.dirs["/home/felhom-repo"] || !fs.dirs["/home/felhom-repo.orphaned-20261003"] {
|
|
t.Fatalf("dirs after: %v", fs.dirs)
|
|
}
|
|
}
|
|
|
|
func TestTargetWithoutFingerprint_Refused(t *testing.T) {
|
|
r := &Registrar{Dialer: fakeDialer{newFS()}}
|
|
pub, _ := newKey(t)
|
|
nt := tgt
|
|
nt.Fingerprint = ""
|
|
if _, err := r.Install(context.Background(), nt, "pw", pub); err == nil {
|
|
t.Fatal("no host fingerprint must refuse (no blind TOFU)")
|
|
}
|
|
}
|
|
|
|
func TestKeyFingerprint_RefusesOptionsAndJunk(t *testing.T) {
|
|
pub, _ := newKey(t)
|
|
for _, bad := range []string{"", "not a key", `command="sh" ` + pub, pub + "\n" + pub} {
|
|
if _, _, err := KeyFingerprint(bad); err == nil {
|
|
t.Fatalf("accepted %q", bad)
|
|
}
|
|
}
|
|
if _, _, err := KeyFingerprint(pub); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
}
|