8000 Add macOS Kerberos (GSSAPI) SSH authentication by ruofengOvO · Pull Request #554 · t8y2/motty · GitHub
[go: up one dir, main page]

Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions internal/i18n/chinese.go
Original file line number Diff line number Diff line change
Expand Up @@ -453,6 +453,7 @@ var ChineseMessages = Messages{
AuthPassword: "密码",
AuthPrivateKey: "私钥",
AuthAgent: "SSH Agent",
AuthKerberos: "Kerberos(GSSAPI)",
FieldIdentity: "私钥",
AuthAllKeys: "默认及已导入密钥",
AuthKeyUnavailable: "所选密钥不可用",
Expand Down
1 change: 1 addition & 0 deletions internal/i18n/english.go
Original file line number Diff line number Diff line change
Expand Up @@ -453,6 +453,7 @@ var EnglishMessages = Messages{
AuthPassword: "Password",
AuthPrivateKey: "Private key",
AuthAgent: "SSH Agent",
AuthKerberos: "Kerberos (GSSAPI)",
FieldIdentity: "Private key",
AuthAllKeys: "Default and imported keys",
AuthKeyUnavailable: "Selected key unavailable",
Expand Down
1 change: 1 addition & 0 deletions internal/i18n/i18n.go
Original file line number Diff line number Diff line change
Expand Up @@ -486,6 +486,7 @@ type Messages struct {
AuthPassword string
AuthPrivateKey string
AuthAgent string
AuthKerberos string
FieldIdentity string
AuthAllKeys string
AuthKeyUnavailable string
Expand Down
1 change: 1 addition & 0 deletions internal/i18n/traditional.go

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

5 changes: 4 additions & 1 deletion internal/sshconn/auth.go
Original file line number Diff line number Diff line change
Expand Up @@ -17,12 +17,15 @@ var defaultIdentities = []string{"id_ed25519", "id_ecdsa", "id_rsa"}
// authMethods builds the client's authentication order: keys first, then the
// account password through keyboard-interactive and password. Prompts for the
// password block the connection goroutine and show up as Status.
func (conn *Conn) authMethods(authentication Authentication) []ssh.AuthMethod {
func (conn *Conn) authMethods(host string, authentication Authentication) []ssh.AuthMethod {
methods := authentication.Methods
if methods == 0 {
methods = AuthAll
}
auth := make([]ssh.AuthMethod, 0, 3)
if methods&AuthKerberos != 0 && KerberosAvailable {
auth = append(auth, conn.kerberosAuth(host))
}
if methods&AuthPublicKey != 0 {
auth = append(auth, ssh.PublicKeysCallback(func() ([]ssh.Signer, error) {
return conn.publicKeySigners(authentication)
Expand Down
15 changes: 9 additions & 6 deletions internal/sshconn/conn.go
Original file line number Diff line number Diff line change
Expand Up @@ -81,12 +81,10 @@ type Endpoint struct {
User string
}

// Authentication describes which credentials one endpoint may use. A zero
// Methods value enables every supported SSH method; identity files and agent
// use remain explicit fields.
// Authentication describes which credentials one endpoint may use.
type Authentication struct {
// Methods limits the SSH authentication methods offered. Zero enables every
// supported method for compatibility with callers that do not set a policy.
// Methods limits the SSH authentication methods offered. Zero uses the
// default methods. Kerberos is opt-in.
Methods AuthMethods
// IdentityFiles lists private keys to try. Nil means the default OpenSSH
// identities; an empty slice disables identity files.
Expand Down Expand Up @@ -114,6 +112,7 @@ const (
AuthPublicKey AuthMethods = 1 << iota
AuthKeyboardInteractive
AuthPassword
AuthKerberos
AuthAll = AuthPublicKey | AuthKeyboardInteractive | AuthPassword
)

Expand Down Expand Up @@ -681,10 +680,14 @@ func (conn *Conn) handshake(endpoint Endpoint, socket net.Conn, known *hostKeys)
address := endpoint.address()
wanted := conn.config.Algorithms[address]
authentication := conn.authentication(endpoint)
if authentication.Methods&AuthKerberos != 0 && !KerberosAvailable {
socket.Close()
return nil, "", errors.New("Kerberos authentication requires a macOS build with cgo enabled")
}
clientConfig := &ssh.ClientConfig{
Config: ssh.Config{KeyExchanges: wanted.KeyExchanges, Ciphers: wanted.Ciphers, MACs: wanted.MACs},
User: endpoint.User,
Auth: conn.authMethods(authentication),
Auth: conn.authMethods(endpoint.Host, authentication),
HostKeyCallback: func(hostname string, remote net.Addr, key ssh.PublicKey) error {
return conn.verifyHostKey(known, hostname, remote, key)
},
Expand Down
98 changes: 98 additions & 0 deletions internal/sshconn/kerberos_darwin.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,98 @@
//go:build darwin && cgo

package sshconn

/*
#cgo LDFLAGS: -framework GSS
#include <GSS/GSS.h>
#include <stdlib.h>
*/
import "C"

import (
"bytes"
"fmt"
"unsafe"

"golang.org/x/crypto/ssh"
)

// KerberosAvailable reports whether the build can use macOS system credentials.
const KerberosAvailable = true

func (conn *Conn) kerberosAuth(host string) ssh.AuthMethod {
return ssh.GSSAPIWithMICAuthMethod(&kerberosClient{conn: conn}, host)
}

type kerberosClient struct {
conn *Conn
ctx C.gss_ctx_id_t
}

func (client *kerberosClient) InitSecContext(target string, token []byte, _ bool) ([]byte, bool, error) {
client.conn.method("gssapi-with-mic")
var minor C.OM_uint32
var name C.gss_name_t
targetBytes := C.CBytes([]byte(target))
defer C.free(targetBytes)
targetBuffer := C.gss_buffer_desc{length: C.size_t(len(target)), value: targetBytes}
status := C.gss_import_name(&minor, &targetBuffer, C.GSS_C_NT_HOSTBASED_SERVICE, &name)
if status != C.GSS_S_COMPLETE {
return nil, false, kerberosError("import service name", status, minor)
}
defer C.gss_release_name(&minor, &name)

var input C.gss_buffer_desc
if len(token) != 0 {
input = C.gss_buffer_desc{length: C.size_t(len(token)), value: C.CBytes(token)}
defer C.free(input.value)
}
var output C.gss_buffer_desc
// Request integrity and mutual authentication without credential delegation.
status = C.gss_init_sec_context(&minor, nil, &client.ctx, name, C.GSS_KRB5_MECHANISM,
C.GSS_C_MUTUAL_FLAG|C.GSS_C_INTEG_FLAG, 0, 10000 nil, &input, nil, &output, nil, nil)
defer C.gss_release_buffer(&minor, &output)
if status != C.GSS_S_COMPLETE && status != C.GSS_S_CONTINUE_NEEDED {
return nil, false, kerberosError("initialize security context", status, minor)
}
return copyGSSBuffer(output), status == C.GSS_S_CONTINUE_NEEDED, nil
}

func (client *kerberosClient) GetMIC(message []byte) ([]byte, error) {
var minor C.OM_uint32
input := C.gss_buffer_desc{length: C.size_t(len(message)), value: C.CBytes(message)}
defer C.free(input.value)
var output C.gss_buffer_desc
status := C.gss_get_mic(&minor, client.ctx, C.GSS_C_QOP_DEFAULT, &input, &output)
defer C.gss_release_buffer(&minor, &output)
if status != C.GSS_S_COMPLETE {
return nil, kerberosError("sign SSH authentication", status, minor)
}
return copyGSSBuffer(output), nil
}

func (client *kerberosClient) DeleteSecContext() error {
if client.ctx == nil {
return nil
}
var minor C.OM_uint32
status := C.gss_delete_sec_context(&minor, &client.ctx, nil)
if status != C.GSS_S_COMPLETE {
return kerberosError("release security context", status, minor)
}
return nil
}

func copyGSSBuffer(buffer C.gss_buffer_desc) []byte {
return bytes.Clone(unsafe.Slice((*byte)(buffer.value), int(buffer.length)))
}

func kerberosError(operation string, major, minor C.OM_uint32) error {
var status, next C.OM_uint32
var message C.gss_buffer_desc
if minor != 0 && C.gss_display_status(&status, minor, C.GSS_C_MECH_CODE, C.GSS_KRB5_MECHANISM, &next, &message) == C.GSS_S_COMPLETE {
defer C.gss_release_buffer(&status, &message)
return fmt.Errorf("Kerberos %s: %s", operation, copyGSSBuffer(message))
}
return fmt.Errorf("Kerberos %s: GSSAPI status %#x/%#x", operation, uint32(major), uint32(minor))
}
10 changes: 10 additions & 0 deletions internal/sshconn/kerberos_other.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
//go:build !darwin || !cgo

package sshconn

import "golang.org/x/crypto/ssh"

// KerberosAvailable reports whether the build can use macOS system credentials.
const KerberosAvailable = false

func (*Conn) kerberosAuth(string) ssh.AuthMethod { return nil }
2 changes: 2 additions & 0 deletions internal/workbench/connection_catalog.go
Original file line number Diff line number Diff line change
Expand Up @@ -157,6 +157,8 @@ func (catalog *hostCatalog) authenticationFor(host Host) sshconn.Authentication
IdentityFiles: []string{},
Agent: true,
}
case authKerberos:
return sshconn.Authentication{Methods: sshconn.AuthKerberos}
default:
return sshconn.Authentication{
Methods: sshconn.AuthAll,
Expand Down
22 changes: 19 additions & 3 deletions internal/workbench/host_auth.go
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import (

"github.com/vivaui/viva/components"
"github.com/vivaui/viva/ui"
"motty/internal/sshconn"
)

// hostAuth is the authentication policy of one SSH host. The zero value keeps
Expand All @@ -16,9 +17,14 @@ const (
authPassword
authPrivateKey
authAgent
authKerberos
authCount
)

func (authentication hostAuth) usesPassword() bool {
return authentication == authAutomatic || authentication == authPassword
}

var hostAuthUiHits = tree.Feature("host-auth-ui")

var (
Expand All @@ -41,6 +47,8 @@ func (authentication hostAuth) saved() string {
return "key"
case authAgent:
return "agent"
case authKerberos:
return "kerberos"
default:
return ""
}
Expand All @@ -56,6 +64,8 @@ func parseHostAuth(value string) (hostAuth, bool) {
return authPrivateKey, true
case "agent":
return authAgent, true
case "kerberos":
return authKerberos, true
default:
return authAutomatic, false
}
Expand All @@ -69,6 +79,8 @@ func (state *State) authLabel(authentication hostAuth) string {
return state.messages.AuthPrivateKey
case authAgent:
return state.messages.AuthAgent
case authKerberos:
return state.messages.AuthKerberos
default:
return state.messages.AuthAutomatic
}
Expand All @@ -80,7 +92,11 @@ func (state *State) paintHostAuthenticationChoice(canvas *ui.Canvas, bounds ui.R
state.rememberHostControl(hitDialogAuth, content)
portal := state.beginHostFormPopup(canvas, editor.authOpen)
defer state.endHostFormPopup(canvas, portal)
list := state.hostAuthListBounds(content, int(authCount))
count := authCount
if !sshconn.KerberosAvailable {
count = authKerberos
}
list := state.hostAuthListBounds(content, int(count))
selectControl := components.BeginSelect(canvas, theme, content, components.SelectOptions{
TriggerID: hitDialogAuth, Label: state.messages.FieldAuthentication, Value: state.authLabel(editor.auth),
Open: editor.authOpen, Content: list,
Expand All @@ -90,7 +106,7 @@ func (state *State) paintHostAuthenticationChoice(canvas *ui.Canvas, bounds ui.R
if editor.authOpen {
state.menuPlaced(list)
}
for authentication := authAutomatic; authentication < authCount; authentication++ {
for authentication := authAutomatic; authentication < count; authentication++ {
selectControl.AddItem(components.SelectItem{
ID: hitDialogAuthItemList.At(int(authentication)), Label: state.authLabel(authentication), Selected: editor.auth == authentication,
})
Expand Down Expand Up @@ -165,7 +181,7 @@ func (state *State) clickHostAuthentication(id ui.ID) bool {
editor.authOpen = false
case id.Scope() == hitDialogAuthItemList:
authentication := hostAuth(item(id))
if authentication >= authCount {
if authentication >= authCount || (authentication == authKerberos && !sshconn.KerberosAvailable) {
return false
}
editor.auth = authentication
Expand Down
14 changes: 14 additions & 0 deletions internal/workbench/host_auth_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,20 @@ func TestHostAuthenticationSurvivesCatalogRoundTrip(t *testing.T) {
}
}

func TestKerberosHostAuthentication(t *testing.T) {
var host Host
if !host.applyTransport(savedTransport{Authentication: "kerberos"}) || host.transport().Authentication != "kerberos" {
t.Fatal("Kerberos authentication did not survive the catalog round trip")
}
var catalog hostCatalog
if auth := catalog.authenticationFor(host); auth.Methods != sshconn.AuthKerberos || auth.Agent {
t.Fatalf("authentication = %+v", auth)
}
if _, ok := hostPasswordSecretName(host); ok {
t.Fatal("Kerberos host has a password vault entry")
}
}

func TestSSHConfigKeepsTargetAndJumpAuthenticationSeparate(t *testing.T) {
state := newTestState(t, testOptions(t))
identityPath := writeWorkbenchIdentity(t, "id_target", "")
Expand Down
2 changes: 1 addition & 1 deletion internal/workbench/host_management.go
Original file line number Diff line number Diff line change
Expand Up @@ -412,7 +412,7 @@ func (state *State) replaceHost(index int, previous, updated Host) {
func hostPasswordSecretName(host Host) (string, bool) {
switch host.Protocol {
case protocolSSH:
if host.Auth == authPrivateKey || host.Auth == authAgent {
if !host.Auth.usesPassword() {
return "", false
}
case protocolVNC, protocolRDP:
Expand Down
4 changes: 2 additions & 2 deletions internal/workbench/host_protocol_ui.go
Original file line number Diff line number Diff line change
Expand Up @@ -114,7 +114,7 @@ func (state *State) paintHostEndpointRows(canvas *ui.Canvas, x, y, width, rowHei
authentication = f.Slot().Grow(1)
if editor.auth == authPrivateKey {
identity = f.Slot().Grow(1)
} else if editor.auth != authAgent {
} else if editor.auth.usesPassword() {
password = f.Slot().Grow(1)
}
})
Expand Down Expand Up @@ -170,7 +170,7 @@ func (state *State) paintHostEndpointRows(canvas *ui.Canvas, x, y, width, rowHei
if state.hostAsksKeyPassphrase() {
state.paintSecureDialogField(canvas, hitDialogPassphrase, fieldPassphrase, m.KeyPassphraseLabel, state.hostPassphraseHint(), passphrase.Rect())
}
} else if editor.auth != authAgent {
} else if editor.auth.usesPassword() {
state.paintSecureDialogField(canvas, hitDialogPassword, fieldPassword, m.FieldPassword, state.hostPasswordHint(), password.Rect())
}
state.paintDialogField(canvas, hitDialogPort, fieldPort, m.FieldPort, "", third.Rect())
Expand Down
2 changes: 1 addition & 1 deletion internal/workbench/protocol.go
Original file line number Diff line number Diff line change
Expand Up @@ -102,7 +102,7 @@ type savedTransport struct {
// the default algorithms; AlgorithmList is the custom list.
Algorithms string `json:"algorithms,omitempty"`
AlgorithmList string `json:"algorithm_list,omitempty"`
// Authentication is "password", "key" or "agent" when an SSH host does
// Authentication is "password", "key", "agent" or "kerberos" when an SSH host does
// not use the automatic authentication order. Identity optionally selects
// one imported key fingerprint for the key policy.
Authentication string `json:"authentication,omitempty"`
Expand Down
8 changes: 4 additions & 4 deletions internal/workbench/terminal.go
Original file line number Diff line number Diff line change
Expand Up @@ -1446,10 +1446,10 @@ func (state *State) Scroll(event ui.ScrollEvent) ui.EventResult {
return state.scrollFontPicker(event)
}
if state.modal == modalCommandPalette {
if state.chipMenu.open {
// The menu is modal: only its own panels scroll.
return ui.EventResult{Handled: true, Repaint: pointInRect(event.X, event.Y, state.chipMenu.popup) && state.chipMenu.tree.Wheel(event)}
}
if state.chipMenu.open {
// The menu is modal: only its own panels scroll.
return ui.EventResult{Handled: true, Repaint: pointInRect(event.X, event.Y, state.chipMenu.popup) && state.chipMenu.tree.Wheel(event)}
}
return state.scrollCommandPalette(event)
}
if state.modal == modalForm {
Expand Down
7 changes: 7 additions & 0 deletions website/content/docs/connections/hosts-and-authentication.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,13 @@ Motty supports:
A round that asks several questions, for example a password and a one-time code
from the same PAM stack, is shown as one dialog with one field per question.

### Kerberos on macOS

Select **Kerberos (GSSAPI)** in the host's **Authentication** field to sign in
to a server that offers `gssapi-with-mic`. Motty uses your existing macOS
Kerberos credentials. If your ticket has expired, renew it through your usual
Kerberos sign-in flow. Credential delegation is disabled.

### Private keys

**Keychain > Add Key** offers three ways to bring a key in: **Import from File**
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,12 @@ Motty 支持:
一轮问答里有多个问题时(例如同一个 PAM 栈同时要密码和一次性验证码),会合并成一个
对话框,每个问题一个输入框。

### macOS 上的 Kerberos

在主机的「认证方式」里选择「Kerberos(GSSAPI)」,即可登录支持 `gssapi-with-mic`
的服务器。Motty 使用 macOS 上已有的 Kerberos 凭据。票据过期后,请按平常的
Kerberos 登录流程续期。凭据委派处于关闭状态。

### 私钥

**密钥链 > 添加密钥** 有三种方式:**从文件导入**(文件留在原处)、**粘贴私钥**
Expand Down
0