Add SSH-managed node registry with connection testing and reauth.
Register hosts under Containers/VMs/Docker with encrypted key storage, and require re-authentication for sensitive account changes.
This commit is contained in:
@@ -66,11 +66,12 @@ func (manager *SessionManager) CreateSession(
|
||||
}
|
||||
|
||||
record := settings.SessionRecord{
|
||||
ID: sessionID,
|
||||
UserID: userID,
|
||||
CreatedAt: now,
|
||||
LastSeenAt: now,
|
||||
ExpiresAt: now.Add(lifetime),
|
||||
ID: sessionID,
|
||||
UserID: userID,
|
||||
CreatedAt: now,
|
||||
LastSeenAt: now,
|
||||
LastReauthAt: now,
|
||||
ExpiresAt: now.Add(lifetime),
|
||||
}
|
||||
filtered = append(filtered, record)
|
||||
store.Sessions = filtered
|
||||
@@ -142,6 +143,7 @@ func (manager *SessionManager) LookupValidSession(
|
||||
}
|
||||
found.ID = newID
|
||||
found.CreatedAt = now
|
||||
// LastReauthAt is preserved across rotation.
|
||||
setSessionCookie(writer, newID, int(found.ExpiresAt.Sub(now).Seconds()))
|
||||
}
|
||||
|
||||
@@ -153,6 +155,32 @@ func (manager *SessionManager) LookupValidSession(
|
||||
return *found, nil
|
||||
}
|
||||
|
||||
// MarkReauth updates LastReauthAt for the session identified by sessionID.
|
||||
func (manager *SessionManager) MarkReauth(sessionID string) error {
|
||||
manager.mu.Lock()
|
||||
defer manager.mu.Unlock()
|
||||
|
||||
store, err := settings.LoadSessionsOrEmpty(manager.configDir, manager.key)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
now := time.Now().UTC()
|
||||
found := false
|
||||
for index := range store.Sessions {
|
||||
if store.Sessions[index].ID != sessionID {
|
||||
continue
|
||||
}
|
||||
store.Sessions[index].LastReauthAt = now
|
||||
found = true
|
||||
break
|
||||
}
|
||||
if !found {
|
||||
return ErrSessionNotFound
|
||||
}
|
||||
return settings.SaveSessions(manager.configDir, store, manager.key)
|
||||
}
|
||||
|
||||
// InvalidateUserSessions removes all persisted sessions for the given user ID.
|
||||
func (manager *SessionManager) InvalidateUserSessions(userID string) error {
|
||||
manager.mu.Lock()
|
||||
|
||||
@@ -0,0 +1,73 @@
|
||||
package auth
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"golang.org/x/crypto/ssh"
|
||||
)
|
||||
|
||||
const defaultSSHPort = "22"
|
||||
const sshDialTimeout = 10 * time.Second
|
||||
|
||||
// TestSSHConnection dials host over SSH using the given private key and username.
|
||||
// Host key verification is intentionally skipped for this connectivity check;
|
||||
// trust-on-first-use / known_hosts can be added later.
|
||||
func TestSSHConnection(hostIP string, username string, privateKeyPEM string, passphrase string) error {
|
||||
hostIP = strings.TrimSpace(hostIP)
|
||||
username = strings.TrimSpace(username)
|
||||
if hostIP == "" {
|
||||
return fmt.Errorf("host IP is required")
|
||||
}
|
||||
if username == "" {
|
||||
return fmt.Errorf("username is required")
|
||||
}
|
||||
|
||||
signer, err := parseSigner(privateKeyPEM, passphrase)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
config := &ssh.ClientConfig{
|
||||
User: username,
|
||||
Auth: []ssh.AuthMethod{
|
||||
ssh.PublicKeys(signer),
|
||||
},
|
||||
HostKeyCallback: ssh.InsecureIgnoreHostKey(),
|
||||
Timeout: sshDialTimeout,
|
||||
}
|
||||
|
||||
address := net.JoinHostPort(hostIP, defaultSSHPort)
|
||||
client, err := ssh.Dial("tcp", address, config)
|
||||
if err != nil {
|
||||
return fmt.Errorf("ssh dial %s: %w", address, err)
|
||||
}
|
||||
defer client.Close()
|
||||
return nil
|
||||
}
|
||||
|
||||
func parseSigner(privateKeyPEM string, passphrase string) (ssh.Signer, error) {
|
||||
trimmed := strings.TrimSpace(privateKeyPEM)
|
||||
if trimmed == "" {
|
||||
return nil, fmt.Errorf("private key is required")
|
||||
}
|
||||
|
||||
var (
|
||||
signer ssh.Signer
|
||||
err error
|
||||
)
|
||||
if strings.TrimSpace(passphrase) == "" {
|
||||
signer, err = ssh.ParsePrivateKey([]byte(trimmed))
|
||||
} else {
|
||||
signer, err = ssh.ParsePrivateKeyWithPassphrase(
|
||||
[]byte(trimmed),
|
||||
[]byte(passphrase),
|
||||
)
|
||||
}
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("parse private key: %w", err)
|
||||
}
|
||||
return signer, nil
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
package auth
|
||||
|
||||
import (
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestTestSSHConnectionRejectsEmptyHost(t *testing.T) {
|
||||
err := TestSSHConnection("", "clustercanvas", "not-a-key", "")
|
||||
if err == nil {
|
||||
t.Fatal("expected error")
|
||||
}
|
||||
}
|
||||
|
||||
func TestTestSSHConnectionRejectsInvalidKey(t *testing.T) {
|
||||
err := TestSSHConnection("127.0.0.1", "clustercanvas", "not-a-key", "")
|
||||
if err == nil {
|
||||
t.Fatal("expected error")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,145 @@
|
||||
package auth
|
||||
|
||||
import (
|
||||
"crypto"
|
||||
"crypto/ed25519"
|
||||
"crypto/rand"
|
||||
"crypto/rsa"
|
||||
"encoding/pem"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"golang.org/x/crypto/ssh"
|
||||
)
|
||||
|
||||
const (
|
||||
SSHKeyAlgoEd25519 = "ed25519"
|
||||
SSHKeyAlgoRSA = "rsa"
|
||||
|
||||
DefaultSSHUsername = "clustercanvas"
|
||||
DefaultRSABits = 4096
|
||||
DefaultEd25519Rounds = 100
|
||||
)
|
||||
|
||||
// GeneratedSSHKey is the result of generating or importing an SSH key pair.
|
||||
type GeneratedSSHKey struct {
|
||||
PrivateKeyPEM string
|
||||
PublicKey string
|
||||
Algorithm string
|
||||
RSABits int
|
||||
KDFRounds int
|
||||
}
|
||||
|
||||
// GenerateSSHKey creates an OpenSSH private key and authorized_keys public line.
|
||||
//
|
||||
// Private keys are written without an OpenSSH passphrase. At-rest protection is
|
||||
// provided by ClusterCanvas AES-GCM (node-keys.enc). KDFRounds is retained as
|
||||
// generation metadata (ssh-keygen -a) for future passphrase-protected exports;
|
||||
// golang.org/x/crypto/ssh hardcodes bcrypt rounds when encrypting.
|
||||
func GenerateSSHKey(algorithm string, rsaBits int, kdfRounds int) (GeneratedSSHKey, error) {
|
||||
algorithm = strings.ToLower(strings.TrimSpace(algorithm))
|
||||
switch algorithm {
|
||||
case SSHKeyAlgoEd25519:
|
||||
if kdfRounds <= 0 {
|
||||
kdfRounds = DefaultEd25519Rounds
|
||||
}
|
||||
_, privateKey, err := ed25519.GenerateKey(rand.Reader)
|
||||
if err != nil {
|
||||
return GeneratedSSHKey{}, fmt.Errorf("generate ed25519 key: %w", err)
|
||||
}
|
||||
return marshalGeneratedKey(privateKey, SSHKeyAlgoEd25519, 0, kdfRounds)
|
||||
case SSHKeyAlgoRSA:
|
||||
if rsaBits == 0 {
|
||||
rsaBits = DefaultRSABits
|
||||
}
|
||||
if !ValidRSABits(rsaBits) {
|
||||
return GeneratedSSHKey{}, fmt.Errorf("rsa bits must be one of 2048, 3072, or 4096")
|
||||
}
|
||||
privateKey, err := rsa.GenerateKey(rand.Reader, rsaBits)
|
||||
if err != nil {
|
||||
return GeneratedSSHKey{}, fmt.Errorf("generate rsa key: %w", err)
|
||||
}
|
||||
return marshalGeneratedKey(privateKey, SSHKeyAlgoRSA, rsaBits, 0)
|
||||
default:
|
||||
return GeneratedSSHKey{}, fmt.Errorf("algorithm must be %q or %q", SSHKeyAlgoEd25519, SSHKeyAlgoRSA)
|
||||
}
|
||||
}
|
||||
|
||||
// ParseSSHPrivateKey accepts an OpenSSH/PEM private key and derives the public key.
|
||||
func ParseSSHPrivateKey(privateKeyPEM string) (GeneratedSSHKey, error) {
|
||||
trimmed := strings.TrimSpace(privateKeyPEM)
|
||||
if trimmed == "" {
|
||||
return GeneratedSSHKey{}, errors.New("private_key is required")
|
||||
}
|
||||
|
||||
rawKey, err := ssh.ParseRawPrivateKey([]byte(trimmed))
|
||||
if err != nil {
|
||||
return GeneratedSSHKey{}, fmt.Errorf("invalid private key: %w", err)
|
||||
}
|
||||
|
||||
signer, ok := rawKey.(crypto.Signer)
|
||||
if !ok {
|
||||
return GeneratedSSHKey{}, errors.New("private key does not support signing")
|
||||
}
|
||||
|
||||
sshPublicKey, err := ssh.NewPublicKey(signer.Public())
|
||||
if err != nil {
|
||||
return GeneratedSSHKey{}, fmt.Errorf("derive public key: %w", err)
|
||||
}
|
||||
|
||||
algorithm := SSHKeyAlgoEd25519
|
||||
rsaBits := 0
|
||||
switch key := rawKey.(type) {
|
||||
case *ed25519.PrivateKey, ed25519.PrivateKey:
|
||||
algorithm = SSHKeyAlgoEd25519
|
||||
case *rsa.PrivateKey:
|
||||
algorithm = SSHKeyAlgoRSA
|
||||
rsaBits = key.N.BitLen()
|
||||
default:
|
||||
// Keep OpenSSH type string for unusual keys.
|
||||
algorithm = sshPublicKey.Type()
|
||||
}
|
||||
|
||||
return GeneratedSSHKey{
|
||||
PrivateKeyPEM: trimmed + "\n",
|
||||
PublicKey: strings.TrimSpace(string(ssh.MarshalAuthorizedKey(sshPublicKey))),
|
||||
Algorithm: algorithm,
|
||||
RSABits: rsaBits,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// ValidRSABits reports whether bits is an allowed RSA size.
|
||||
func ValidRSABits(bits int) bool {
|
||||
switch bits {
|
||||
case 2048, 3072, 4096:
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
func marshalGeneratedKey(privateKey crypto.PrivateKey, algorithm string, rsaBits int, kdfRounds int) (GeneratedSSHKey, error) {
|
||||
block, err := ssh.MarshalPrivateKey(privateKey, "")
|
||||
if err != nil {
|
||||
return GeneratedSSHKey{}, fmt.Errorf("marshal private key: %w", err)
|
||||
}
|
||||
|
||||
signer, ok := privateKey.(crypto.Signer)
|
||||
if !ok {
|
||||
return GeneratedSSHKey{}, errors.New("private key does not support signing")
|
||||
}
|
||||
sshPublicKey, err := ssh.NewPublicKey(signer.Public())
|
||||
if err != nil {
|
||||
return GeneratedSSHKey{}, fmt.Errorf("derive public key: %w", err)
|
||||
}
|
||||
|
||||
privatePEM := string(pem.EncodeToMemory(block))
|
||||
return GeneratedSSHKey{
|
||||
PrivateKeyPEM: privatePEM,
|
||||
PublicKey: strings.TrimSpace(string(ssh.MarshalAuthorizedKey(sshPublicKey))),
|
||||
Algorithm: algorithm,
|
||||
RSABits: rsaBits,
|
||||
KDFRounds: kdfRounds,
|
||||
}, nil
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
package auth
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestGenerateSSHKeyEd25519(t *testing.T) {
|
||||
generated, err := GenerateSSHKey(SSHKeyAlgoEd25519, 0, 100)
|
||||
if err != nil {
|
||||
t.Fatalf("GenerateSSHKey: %v", err)
|
||||
}
|
||||
if generated.Algorithm != SSHKeyAlgoEd25519 {
|
||||
t.Fatalf("algorithm = %q", generated.Algorithm)
|
||||
}
|
||||
if generated.KDFRounds != 100 {
|
||||
t.Fatalf("kdf rounds = %d", generated.KDFRounds)
|
||||
}
|
||||
if !strings.Contains(generated.PrivateKeyPEM, "BEGIN OPENSSH PRIVATE KEY") {
|
||||
t.Fatal("expected openssh private key")
|
||||
}
|
||||
if !strings.HasPrefix(generated.PublicKey, "ssh-ed25519 ") {
|
||||
t.Fatalf("public key = %q", generated.PublicKey)
|
||||
}
|
||||
|
||||
parsed, err := ParseSSHPrivateKey(generated.PrivateKeyPEM)
|
||||
if err != nil {
|
||||
t.Fatalf("ParseSSHPrivateKey: %v", err)
|
||||
}
|
||||
if parsed.PublicKey != generated.PublicKey {
|
||||
t.Fatalf("parsed public key mismatch")
|
||||
}
|
||||
}
|
||||
|
||||
func TestGenerateSSHKeyRSABits(t *testing.T) {
|
||||
generated, err := GenerateSSHKey(SSHKeyAlgoRSA, 2048, 0)
|
||||
if err != nil {
|
||||
t.Fatalf("GenerateSSHKey: %v", err)
|
||||
}
|
||||
if generated.RSABits != 2048 {
|
||||
t.Fatalf("rsa bits = %d", generated.RSABits)
|
||||
}
|
||||
if !strings.HasPrefix(generated.PublicKey, "ssh-rsa ") {
|
||||
t.Fatalf("public key = %q", generated.PublicKey)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGenerateSSHKeyRejectsInvalidRSABits(t *testing.T) {
|
||||
_, err := GenerateSSHKey(SSHKeyAlgoRSA, 1024, 0)
|
||||
if err == nil {
|
||||
t.Fatal("expected error")
|
||||
}
|
||||
}
|
||||
|
||||
func TestNewUUIDFormat(t *testing.T) {
|
||||
value, err := NewUUID()
|
||||
if err != nil {
|
||||
t.Fatalf("NewUUID: %v", err)
|
||||
}
|
||||
if len(value) != 36 {
|
||||
t.Fatalf("uuid length = %d", len(value))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
package auth
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"fmt"
|
||||
)
|
||||
|
||||
// NewUUID returns a random RFC 4122 version-4 UUID string.
|
||||
func NewUUID() (string, error) {
|
||||
buffer := make([]byte, 16)
|
||||
if _, err := rand.Read(buffer); err != nil {
|
||||
return "", fmt.Errorf("uuid: %w", err)
|
||||
}
|
||||
buffer[6] = (buffer[6] & 0x0f) | 0x40
|
||||
buffer[8] = (buffer[8] & 0x3f) | 0x80
|
||||
return fmt.Sprintf(
|
||||
"%x-%x-%x-%x-%x",
|
||||
buffer[0:4],
|
||||
buffer[4:6],
|
||||
buffer[6:8],
|
||||
buffer[8:10],
|
||||
buffer[10:16],
|
||||
), nil
|
||||
}
|
||||
Reference in New Issue
Block a user