Skip to content
Merged
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
11 changes: 10 additions & 1 deletion Sources/NIOSSH/Docs.docc/index.md
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@ SwiftNIO SSH supports SSHv2 with the following feature set:
- All session channel features, including shell and exec channel requests
- Direct and reverse TCP port forwarding
- Modern cryptographic primitives only: Ed25519 and ECDSA over the major NIST curves (P256, P384, P521) for asymmetric cryptography, AES-GCM for symmetric cryptography, x25519 for key exchange
- Password and public key user authentication
- Password and public key user authentication (including SSH certificate support)
- Supports all platforms supported by SwiftNIO and Swift Crypto

## How do I use SwiftNIO SSH?
Expand Down Expand Up @@ -116,6 +116,15 @@ The client protocol is straightforward: SwiftNIO SSH will invoke the method ``NI

The server protocol is more complex. The delegate must provide a ``NIOSSHServerUserAuthenticationDelegate/supportedAuthenticationMethods`` property that communicates which authentication methods are supported by the delegate. Then, each time the client sends a user auth request, the ``NIOSSHServerUserAuthenticationDelegate/requestReceived(request:responsePromise:)`` method will be invoked. This may be invoked multiple times in parallel, as clients are allowed to issue auth requests in parallel. The `responsePromise` should be succeeded with the result of the authentication. There are three results: ``NIOSSHUserAuthenticationOutcome/success`` and ``NIOSSHUserAuthenticationOutcome/failure`` are straightforward, but in principle the server can require multiple challenges using ``NIOSSHUserAuthenticationOutcome/partialSuccess(remainingMethods:)``.

#### SSH Certificate Authentication

SwiftNIO SSH supports SSH certificate authentication through the public key authentication method. When using certificates:

- Clients can offer a ``NIOSSHCertifiedPublicKey`` as part of their public key authentication by using the ``NIOSSHUserAuthenticationOffer/Offer/PrivateKey/init(privateKey:certifiedKey:)`` initializer.
- Servers will automatically validate certificates against configured trusted certificate authorities (CAs) when ``SSHServerConfiguration/trustedUserCAKeys`` is set.
- The ``NIOSSHUserAuthenticationRequest/Request/PublicKey/certifiedKey`` property will contain the parsed certificate information after successful validation.
- Certificate validation includes checking the certificate type, principal, validity period, signature, and critical options.

### Direct Port Forwarding

Direct port forwarding is port forwarding from client to server. In this mode traditionally the client will listen on a local port, and will forward inbound connections to the server. It will ask that the server forward these connections as outbound connections to a specific host and port.
Expand Down
39 changes: 35 additions & 4 deletions Sources/NIOSSH/Key Exchange/SSHKeyExchangeStateMachine.swift
Original file line number Diff line number Diff line change
Expand Up @@ -274,10 +274,41 @@ struct SSHKeyExchangeStateMachine {
guard case .client(let clientConfig) = self.role else {
preconditionFailure("Should not be in .keyExchangeInitSent as server")
}
let promise = self.loop.makePromise(of: Void.self)
clientConfig.serverAuthDelegate.validateHostKey(hostKey: message.hostKey, validationCompletePromise: promise)
return promise.futureResult.map {
SSHMultiMessage(SSHMessage.newKeys)

// Check if this is a certificate and validate it if we have trusted CAs
if let certifiedKey = NIOSSHCertifiedPublicKey(message.hostKey),
!clientConfig.trustedHostCAKeys.isEmpty {
// This is a certificate and we have trusted CAs configured
do {
// Use the configured hostname for validation, or empty string to accept any
let principal = clientConfig.hostname ?? ""
let _ = try certifiedKey.validate(
principal: principal,
type: .host,
allowedAuthoritySigningKeys: clientConfig.trustedHostCAKeys,
acceptableCriticalOptions: [] // Host certificates typically don't have critical options
)
// Certificate is valid, now let the delegate do additional validation
let promise = self.loop.makePromise(of: Void.self)
clientConfig.serverAuthDelegate.validateHostCertificate(
hostKey: message.hostKey,
certifiedKey: certifiedKey,
validationCompletePromise: promise
)
return promise.futureResult.map {
SSHMultiMessage(SSHMessage.newKeys)
}
} catch {
// Certificate validation failed
return self.loop.makeFailedFuture(error)
}
} else {
// Regular key validation or no trusted CAs configured
let promise = self.loop.makePromise(of: Void.self)
clientConfig.serverAuthDelegate.validateHostKey(hostKey: message.hostKey, validationCompletePromise: promise)
return promise.futureResult.map {
SSHMultiMessage(SSHMessage.newKeys)
}
}
case .server:
preconditionFailure("Servers cannot enter key exchange init sent.")
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -28,4 +28,23 @@ public protocol NIOSSHClientServerAuthenticationDelegate {
/// - hostKey: The host key presented by the server
/// - validationCompletePromise: A promise that must be succeeded or failed based on whether the host key is trusted.
func validateHostKey(hostKey: NIOSSHPublicKey, validationCompletePromise: EventLoopPromise<Void>)

/// Invoked to validate a host certificate. This method is called when the server presents a certificate
/// instead of a plain host key, and the client has trusted CA keys configured.
///
/// The default implementation calls `validateHostKey` for backward compatibility.
///
/// - parameters:
/// - hostKey: The host key presented by the server (which contains a certificate)
/// - certifiedKey: The parsed certificate information
/// - validationCompletePromise: A promise that must be succeeded or failed based on whether the certificate is trusted.
func validateHostCertificate(hostKey: NIOSSHPublicKey, certifiedKey: NIOSSHCertifiedPublicKey, validationCompletePromise: EventLoopPromise<Void>)
}

// Provide default implementation for backward compatibility
public extension NIOSSHClientServerAuthenticationDelegate {
func validateHostCertificate(hostKey: NIOSSHPublicKey, certifiedKey: NIOSSHCertifiedPublicKey, validationCompletePromise: EventLoopPromise<Void>) {
// By default, just call the regular host key validation
self.validateHostKey(hostKey: hostKey, validationCompletePromise: validationCompletePromise)
}
}
10 changes: 10 additions & 0 deletions Sources/NIOSSH/SSHClientConfiguration.swift
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,16 @@ public struct SSHClientConfiguration {

/// The maximum packet size that this NIOSSH client will accept
public var maximumPacketSize = SSHPacketParser.defaultMaximumPacketSize

/// The trusted certificate authority public keys for host authentication.
/// When set, hosts presenting certificates signed by these CAs will be authenticated
/// if the certificate is valid and the principal matches the hostname.
public var trustedHostCAKeys: [NIOSSHPublicKey] = []

/// The hostname that this client is connecting to.
/// This is used for validating host certificates when `trustedHostCAKeys` is configured.
/// If not set, host certificate validation will accept any hostname.
public var hostname: String?

public init(userAuthDelegate: NIOSSHClientUserAuthenticationDelegate,
serverAuthDelegate: NIOSSHClientServerAuthenticationDelegate,
Expand Down
10 changes: 10 additions & 0 deletions Sources/NIOSSH/SSHServerConfiguration.swift
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,16 @@ public struct SSHServerConfiguration {

/// The maximum packet size that this NIOSSH server will accept
public var maximumPacketSize = SSHPacketParser.defaultMaximumPacketSize

/// The trusted certificate authority public keys for user authentication.
/// When set, users presenting certificates signed by these CAs will be authenticated
/// if the certificate is valid and the principal matches.
public var trustedUserCAKeys: [NIOSSHPublicKey] = []

/// The acceptable critical options for user certificate validation.
/// When validating user certificates, only these critical options will be accepted.
/// Default includes "force-command" and "source-address" per OpenSSH standards.
public var acceptableCriticalOptions: [String] = ["force-command", "source-address"]

public init(hostKeys: [NIOSSHPrivateKey], userAuthDelegate: NIOSSHServerUserAuthenticationDelegate, globalRequestDelegate: GlobalRequestDelegate? = nil, banner: UserAuthBanner? = nil) {
self.hostKeys = hostKeys
Expand Down
22 changes: 20 additions & 2 deletions Sources/NIOSSH/User Authentication/UserAuthenticationMethod.swift
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,10 @@
import NIOCore

/// The user authentication modes available at this point in time.
///
/// Note: SSH certificate authentication is supported through the publicKey method,
/// not as a separate authentication method. When using certificates, the publicKey
/// method is used with a certified key.
public struct NIOSSHAvailableUserAuthenticationMethods: OptionSet {
public var rawValue: UInt8

Expand Down Expand Up @@ -97,9 +101,21 @@ public extension NIOSSHUserAuthenticationRequest {
public extension NIOSSHUserAuthenticationRequest.Request {
struct PublicKey {
public var publicKey: NIOSSHPublicKey

/// If the public key is a certificate, this contains the parsed certificate information.
/// This includes critical options, extensions, and other certificate metadata.
/// Certificate authentication in SSH uses the publicKey authentication method with
/// a certified key, not a separate authentication method.
public var certifiedKey: NIOSSHCertifiedPublicKey?

public init(publicKey: NIOSSHPublicKey) {
self.publicKey = publicKey
self.certifiedKey = NIOSSHCertifiedPublicKey(publicKey)
}

public init(publicKey: NIOSSHPublicKey, certifiedKey: NIOSSHCertifiedPublicKey?) {
self.publicKey = publicKey
self.certifiedKey = certifiedKey
}
}

Expand All @@ -113,7 +129,7 @@ public extension NIOSSHUserAuthenticationRequest.Request {

struct HostBased {
Copy link

Copilot AI Jul 31, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The error message was updated but the init() method comment above still references 'PublicKeyRequest' which appears to be a copy-paste error from the original code.

Suggested change
struct HostBased {
struct HostBased {
/// HostBased authentication is currently unimplemented.
/// This method will trigger a runtime error if called.

Copilot uses AI. Check for mistakes.

init() {
fatalError("PublicKeyRequest is currently unimplemented")
fatalError("HostBased authentication is currently unimplemented")
}
}
}
Expand Down Expand Up @@ -160,6 +176,8 @@ public extension NIOSSHUserAuthenticationOffer.Offer {
self.publicKey = privateKey.publicKey
}

/// Creates a private key offer with a certified public key.
/// Certificate authentication uses the publicKey authentication method.
public init(privateKey: NIOSSHPrivateKey, certifiedKey: NIOSSHCertifiedPublicKey) {
self.privateKey = privateKey
self.publicKey = NIOSSHPublicKey(certifiedKey)
Expand All @@ -176,7 +194,7 @@ public extension NIOSSHUserAuthenticationOffer.Offer {

struct HostBased {
init() {
fatalError("PublicKeyRequest is currently unimplemented")
fatalError("HostBased authentication is currently unimplemented")
}
}
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -19,13 +19,15 @@ struct UserAuthenticationStateMachine {
private var delegate: UserAuthDelegate
private let loop: EventLoop
private var sessionID: ByteBuffer
private let role: SSHConnectionRole

// TODO: The server SHOULD limit the number of authentication attempts the client may make.
init(role: SSHConnectionRole, loop: EventLoop, sessionID: ByteBuffer) {
self.state = .idle
self.delegate = UserAuthDelegate(role: role)
self.loop = loop
self.sessionID = sessionID
self.role = role
}

fileprivate static let serviceName: String = "ssh-userauth"
Expand Down Expand Up @@ -414,8 +416,34 @@ private extension UserAuthenticationStateMachine {
return self.loop.makeSucceededFuture(.failure(.init(authentications: supportedMethods.strings, partialSuccess: false)))
}

// Check if this is a certificate and validate it
var validatedCertificate: NIOSSHCertifiedPublicKey? = nil
Copy link

Copilot AI Jul 31, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Using explicit 'nil' initialization is unnecessary in Swift. Consider using 'var validatedCertificate: NIOSSHCertifiedPublicKey?' instead.

Suggested change
var validatedCertificate: NIOSSHCertifiedPublicKey? = nil
var validatedCertificate: NIOSSHCertifiedPublicKey?

Copilot uses AI. Check for mistakes.

if let certifiedKey = NIOSSHCertifiedPublicKey(key),
case .server(let config) = self.role,
!config.trustedUserCAKeys.isEmpty {
// This is a certificate and we have trusted CAs configured
do {
let criticalOptions = try certifiedKey.validate(
principal: request.username,
type: .user,
allowedAuthoritySigningKeys: config.trustedUserCAKeys,
acceptableCriticalOptions: config.acceptableCriticalOptions
)

// Certificate is valid, store it to pass to the delegate
validatedCertificate = certifiedKey
} catch {
// Certificate validation failed
return self.loop.makeSucceededFuture(.failure(.init(authentications: supportedMethods.strings, partialSuccess: false)))
}
}

// Signature is valid, ask if the delegate is happy.
let request = NIOSSHUserAuthenticationRequest(username: request.username, serviceName: request.service, request: .publicKey(.init(publicKey: key)))
let request = NIOSSHUserAuthenticationRequest(
username: request.username,
serviceName: request.service,
request: .publicKey(.init(publicKey: key, certifiedKey: validatedCertificate))
)
let promise = self.loop.makePromise(of: NIOSSHUserAuthenticationOutcome.self)
delegate.requestReceived(request: request, responsePromise: promise)

Expand All @@ -425,7 +453,26 @@ private extension UserAuthenticationStateMachine {

case .publicKey(.known(key: let key, signature: .none)):
// This is a weird wrinkle in public key auth: it's a request to ask whether a given key is valid, but not to validate that key itself.
// For now we do a shortcut: we just say that all keys are acceptable, rather than ask the delegate.
// For certificates, we should validate them before saying they're OK
if let certifiedKey = NIOSSHCertifiedPublicKey(key),
case .server(let config) = self.role,
!config.trustedUserCAKeys.isEmpty {
// This is a certificate and we have trusted CAs configured
do {
_ = try certifiedKey.validate(
principal: request.username,
type: .user,
allowedAuthoritySigningKeys: config.trustedUserCAKeys,
acceptableCriticalOptions: config.acceptableCriticalOptions
)
// Certificate is valid
return self.loop.makeSucceededFuture(.publicKeyOK(.init(key: key)))
} catch {
// Certificate validation failed, reject it
return self.loop.makeSucceededFuture(.failure(.init(authentications: delegate.supportedAuthenticationMethods.strings, partialSuccess: false)))
}
}
// For now we do a shortcut: we just say that all non-certificate keys are acceptable, rather than ask the delegate.
return self.loop.makeSucceededFuture(.publicKeyOK(.init(key: key)))

case .publicKey(.unknown):
Expand Down
Loading