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

102 lines
2.6 KiB
Go

package offsitekeys
import (
"bytes"
"context"
"fmt"
"net"
"strconv"
"time"
"golang.org/x/crypto/ssh"
)
// SSHDialer is the production Dialer: password (and keyboard-interactive) auth to the sub-account's
// restricted shell, the host key checked against the provisioning-time fingerprint. The password is
// never logged and never leaves this process.
type SSHDialer struct {
Timeout time.Duration // connect + per-command bound; 0 → 30 s
}
type sshShell struct {
c *ssh.Client
timeout time.Duration
}
func (d SSHDialer) Dial(ctx context.Context, t Target, password string) (Shell, error) {
to := d.Timeout
if to == 0 {
to = 30 * time.Second
}
port := t.Port
if port == 0 {
port = 23
}
want := t.Fingerprint
cfg := &ssh.ClientConfig{
User: t.User,
Auth: []ssh.AuthMethod{
ssh.Password(password),
ssh.KeyboardInteractive(func(_, _ string, qs []string, _ []bool) ([]string, error) {
ans := make([]string, len(qs))
for i := range ans {
ans[i] = password
}
return ans, nil
}),
},
HostKeyCallback: func(_ string, _ net.Addr, key ssh.PublicKey) error {
if got := ssh.FingerprintSHA256(key); got != want {
return fmt.Errorf("offsitekeys: host key MISMATCH for %s (got %s, want %s)", t.Host, got, want)
}
return nil
},
Timeout: to,
}
addr := net.JoinHostPort(t.Host, strconv.Itoa(port))
var nd net.Dialer
dctx, cancel := context.WithTimeout(ctx, to)
defer cancel()
conn, err := nd.DialContext(dctx, "tcp", addr)
if err != nil {
return nil, fmt.Errorf("offsitekeys: dial %s: %w", addr, err)
}
_ = conn.SetDeadline(time.Now().Add(to))
cc, chans, reqs, err := ssh.NewClientConn(conn, addr, cfg)
if err != nil {
conn.Close()
return nil, fmt.Errorf("offsitekeys: ssh handshake %s: %w", addr, err)
}
_ = conn.SetDeadline(time.Time{})
return &sshShell{c: ssh.NewClient(cc, chans, reqs), timeout: to}, nil
}
func (s *sshShell) Run(ctx context.Context, cmd string, stdin []byte) ([]byte, error) {
sess, err := s.c.NewSession()
if err != nil {
return nil, err
}
defer sess.Close()
if stdin != nil {
sess.Stdin = bytes.NewReader(stdin)
}
var out, errb bytes.Buffer
sess.Stdout, sess.Stderr = &out, &errb
done := make(chan error, 1)
go func() { done <- sess.Run(cmd) }()
rctx, cancel := context.WithTimeout(ctx, s.timeout)
defer cancel()
select {
case err := <-done:
if err != nil {
return out.Bytes(), fmt.Errorf("%q: %w: %s", cmd, err, bytes.TrimSpace(errb.Bytes()))
}
return out.Bytes(), nil
case <-rctx.Done():
_ = sess.Close()
return nil, fmt.Errorf("%q: %w", cmd, rctx.Err())
}
}
func (s *sshShell) Close() error { return s.c.Close() }