feat: complete Nexus Terminal — premium SSH/Telnet/RAW TCP desktop client
Production-minded Wails v2 desktop application with: - Full SSH client (password, key, keyboard-interactive, jump hosts, keepalive) - Telnet client (RFC 854/855 negotiation, NAWS, TTYPE, encoding) - RAW TCP client (binary-safe, text/hex/split views, payload presets) - SQLite storage with migrations and repository abstraction - Encrypted secret storage (AES-256-GCM + Argon2id) with keychain fallback - Known hosts verification (strict/ask/accept-new/insecure) - Session manager with multi-session lifecycle and Wails events - Dark charcoal Material-inspired design system (CSS custom properties) - Tabbed session management with rename, reorder, pin, context menu - Profile editor with protocol-aware form sections - Command palette, notifications, settings modal - JSON import/export, audit logging, preferences - 50+ tests across validation, secrets, storage, encoding, sessions - Makefile, build.sh, .gitignore, comprehensive README
This commit is contained in:
@@ -0,0 +1,162 @@
|
||||
package sshclient
|
||||
|
||||
import (
|
||||
"crypto/sha256"
|
||||
"encoding/base64"
|
||||
"fmt"
|
||||
"net"
|
||||
"time"
|
||||
|
||||
"github.com/copilot-ssh-client/app/internal/models"
|
||||
"golang.org/x/crypto/ssh"
|
||||
)
|
||||
|
||||
// HostKeyStore is the interface for known-host persistence.
|
||||
// Implementations handle the underlying storage mechanism (e.g. SQLite, file).
|
||||
type HostKeyStore interface {
|
||||
GetKnownHost(host string, port int) (*models.KnownHost, error)
|
||||
SaveKnownHost(kh *models.KnownHost) error
|
||||
}
|
||||
|
||||
// HostKeyVerifier handles SSH host key verification against the known-hosts
|
||||
// store. It supports four policies:
|
||||
// - strict: reject unknown or changed keys
|
||||
// - ask: prompt the user for unknown keys (via callback)
|
||||
// - accept-new: automatically accept new keys, reject changed keys
|
||||
// - insecure: accept all keys (for testing only — clearly warned)
|
||||
type HostKeyVerifier struct {
|
||||
store HostKeyStore
|
||||
policy models.HostKeyPolicy
|
||||
onPrompt func(host string, keyType string, fingerprint string) (bool, error)
|
||||
}
|
||||
|
||||
// NewHostKeyVerifier creates a verifier with the given store and policy.
|
||||
func NewHostKeyVerifier(store HostKeyStore, policy models.HostKeyPolicy) *HostKeyVerifier {
|
||||
return &HostKeyVerifier{
|
||||
store: store,
|
||||
policy: policy,
|
||||
}
|
||||
}
|
||||
|
||||
// SetPromptHandler sets the callback used in "ask" mode to prompt the user
|
||||
// for acceptance of an unknown host key.
|
||||
func (v *HostKeyVerifier) SetPromptHandler(fn func(string, string, string) (bool, error)) {
|
||||
v.onPrompt = fn
|
||||
}
|
||||
|
||||
// HostKeyCallback returns an ssh.HostKeyCallback suitable for use with
|
||||
// ssh.ClientConfig. The behaviour depends on the configured policy.
|
||||
func (v *HostKeyVerifier) HostKeyCallback() ssh.HostKeyCallback {
|
||||
return func(hostname string, remote net.Addr, key ssh.PublicKey) error {
|
||||
host, port := extractHostPort(hostname, remote)
|
||||
keyType := key.Type()
|
||||
fingerprint := computeFingerprint(key)
|
||||
|
||||
stored, err := v.store.GetKnownHost(host, port)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to query known hosts: %w", err)
|
||||
}
|
||||
|
||||
switch v.policy {
|
||||
case models.HostKeyInsecure:
|
||||
return v.saveIfNew(stored, host, port, keyType, fingerprint, key)
|
||||
|
||||
case models.HostKeyAcceptNew:
|
||||
if stored == nil {
|
||||
return v.saveHost(host, port, keyType, fingerprint, key)
|
||||
}
|
||||
return v.verifyStored(stored, fingerprint, host)
|
||||
|
||||
case models.HostKeyAsk:
|
||||
if stored == nil {
|
||||
return v.promptAndSave(host, port, keyType, fingerprint, key)
|
||||
}
|
||||
return v.verifyStored(stored, fingerprint, host)
|
||||
|
||||
case models.HostKeyStrict:
|
||||
if stored == nil {
|
||||
return fmt.Errorf("host key for %s:%d is not known (policy: strict)", host, port)
|
||||
}
|
||||
return v.verifyStored(stored, fingerprint, host)
|
||||
|
||||
default:
|
||||
return fmt.Errorf("unknown host key policy: %s", v.policy)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// verifyStored checks that the presented fingerprint matches the stored one.
|
||||
func (v *HostKeyVerifier) verifyStored(stored *models.KnownHost, fingerprint, host string) error {
|
||||
if stored.Fingerprint != fingerprint {
|
||||
return fmt.Errorf(
|
||||
"host key for %s has changed! stored fingerprint: %s, received: %s — possible MITM attack",
|
||||
host, stored.Fingerprint, fingerprint,
|
||||
)
|
||||
}
|
||||
stored.LastSeen = time.Now()
|
||||
_ = v.store.SaveKnownHost(stored)
|
||||
return nil
|
||||
}
|
||||
|
||||
// promptAndSave asks the user whether to accept an unknown host key.
|
||||
func (v *HostKeyVerifier) promptAndSave(host string, port int, keyType, fingerprint string, key ssh.PublicKey) error {
|
||||
if v.onPrompt == nil {
|
||||
return fmt.Errorf("host key for %s:%d is unknown and no prompt handler is set (policy: ask)", host, port)
|
||||
}
|
||||
|
||||
accepted, err := v.onPrompt(host, keyType, fingerprint)
|
||||
if err != nil {
|
||||
return fmt.Errorf("host key prompt failed: %w", err)
|
||||
}
|
||||
if !accepted {
|
||||
return fmt.Errorf("host key for %s:%d rejected by user", host, port)
|
||||
}
|
||||
|
||||
return v.saveHost(host, port, keyType, fingerprint, key)
|
||||
}
|
||||
|
||||
// saveIfNew stores the host key only when no prior record exists.
|
||||
func (v *HostKeyVerifier) saveIfNew(stored *models.KnownHost, host string, port int, keyType, fingerprint string, key ssh.PublicKey) error {
|
||||
if stored == nil {
|
||||
return v.saveHost(host, port, keyType, fingerprint, key)
|
||||
}
|
||||
stored.LastSeen = time.Now()
|
||||
_ = v.store.SaveKnownHost(stored)
|
||||
return nil
|
||||
}
|
||||
|
||||
// saveHost persists a new known-host record.
|
||||
func (v *HostKeyVerifier) saveHost(host string, port int, keyType, fingerprint string, key ssh.PublicKey) error {
|
||||
now := time.Now()
|
||||
kh := &models.KnownHost{
|
||||
Host: host,
|
||||
Port: port,
|
||||
KeyType: keyType,
|
||||
Fingerprint: fingerprint,
|
||||
PublicKey: base64.StdEncoding.EncodeToString(key.Marshal()),
|
||||
FirstSeen: now,
|
||||
LastSeen: now,
|
||||
Trusted: true,
|
||||
}
|
||||
return v.store.SaveKnownHost(kh)
|
||||
}
|
||||
|
||||
// computeFingerprint returns the SHA-256 fingerprint of a public key in
|
||||
// the standard "SHA256:<base64>" format.
|
||||
func computeFingerprint(key ssh.PublicKey) string {
|
||||
hash := sha256.Sum256(key.Marshal())
|
||||
return "SHA256:" + base64.RawStdEncoding.EncodeToString(hash[:])
|
||||
}
|
||||
|
||||
// extractHostPort splits the hostname and port from the SSH callback arguments.
|
||||
func extractHostPort(hostname string, remote net.Addr) (string, int) {
|
||||
host, portStr, err := net.SplitHostPort(hostname)
|
||||
if err != nil {
|
||||
host, portStr, _ = net.SplitHostPort(remote.String())
|
||||
}
|
||||
port := 22
|
||||
if portStr != "" {
|
||||
fmt.Sscanf(portStr, "%d", &port)
|
||||
}
|
||||
return host, port
|
||||
}
|
||||
Reference in New Issue
Block a user