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
20 changes: 20 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -128,6 +128,26 @@ The client protocol is straightforward: SwiftNIO SSH will invoke the method `nex

The server protocol is more complex. The delegate must provide a `supportedAuthenticationMethods` property that communicates which authentication methods are supported by the delegate. Then, each time the client sends a user auth request, the `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: `.success` and `.failure` are straightforward, but in principle the server can require multiple challenges using `.partialSuccess(remainingMethods:)`.

#### External signer (advanced)

SwiftNIO SSH also supports external signing for public‑key authentication (e.g., ssh‑agent or hardware keys) via `NIOSSHExternalSigner`.
This example is intentionally OS‑agnostic and does **not** use ssh‑agent directly.

```swift
struct FakeSigner: NIOSSHExternalSigner {
let publicKey: NIOSSHPublicKey

func sign(payload: ByteBuffer) throws -> ByteBuffer {
var buffer = ByteBufferAllocator().buffer(capacity: 3)
buffer.writeBytes([0x01, 0x02, 0x03]) // example signature bytes
return buffer
}
}

let signer = FakeSigner(publicKey: somePublicKey)
let key = NIOSSHPrivateKey(externalSigner: signer)
```

### 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
20 changes: 20 additions & 0 deletions Sources/NIOSSH/Docs.docc/index.md
Original file line number Diff line number Diff line change
Expand Up @@ -127,6 +127,26 @@ 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:)``.

##### External signer (advanced)

SwiftNIO SSH also supports external signing for public‑key authentication via ``NIOSSHExternalSigner``.
This example is intentionally OS‑agnostic and does **not** use ssh‑agent directly.

```swift
struct FakeSigner: NIOSSHExternalSigner {
let publicKey: NIOSSHPublicKey

func sign(payload: ByteBuffer) throws -> ByteBuffer {
var buffer = ByteBufferAllocator().buffer(capacity: 3)
buffer.writeBytes([0x01, 0x02, 0x03]) // example signature bytes
return buffer
}
}

let signer = FakeSigner(publicKey: somePublicKey)
let key = NIOSSHPrivateKey(externalSigner: signer)
```

#### 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
67 changes: 67 additions & 0 deletions Sources/NIOSSH/Keys And Signatures/NIOSSHPrivateKey.swift
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,19 @@ public struct NIOSSHPrivateKey: Sendable {
self.backingKey = .ecdsaP521(key)
}

/// Create a private key backed by an external signer (e.g. ssh-agent).
///
/// Use this when private-key material is held outside NIOSSH. The signer receives raw
/// `UserAuthSignablePayload` bytes and returns algorithm-specific signature bytes.
///
/// - Parameter signer: External signer implementation that provides signatures for `publicKey` using raw SSH user-auth payload bytes.
/// - SeeAlso: ``NIOSSHExternalSigner`` for payload/signature format and threading requirements.
/// - Important: `sign(payload:)` is called synchronously on NIO event-loop threads. Do not block or perform
/// long-running work inside the signer.
public init(externalSigner signer: any NIOSSHExternalSigner) {
self.backingKey = .external(signer)
}

#if canImport(Darwin)
public init(secureEnclaveP256Key key: SecureEnclave.P256.Signing.PrivateKey) {
self.backingKey = .secureEnclaveP256(key)
Expand All @@ -69,6 +82,8 @@ public struct NIOSSHPrivateKey: Sendable {
return ["ecdsa-sha2-nistp384"]
case .ecdsaP521:
return ["ecdsa-sha2-nistp521"]
case .external(let signer):
return [Substring(String(decoding: signer.publicKey.keyPrefix, as: UTF8.self))]
#if canImport(Darwin)
case .secureEnclaveP256:
return ["ecdsa-sha2-nistp256"]
Expand All @@ -84,6 +99,7 @@ extension NIOSSHPrivateKey {
case ecdsaP256(P256.Signing.PrivateKey)
case ecdsaP384(P384.Signing.PrivateKey)
case ecdsaP521(P521.Signing.PrivateKey)
case external(any NIOSSHExternalSigner)

#if canImport(Darwin)
case secureEnclaveP256(SecureEnclave.P256.Signing.PrivateKey)
Expand Down Expand Up @@ -114,6 +130,8 @@ extension NIOSSHPrivateKey {
try key.signature(for: ptr)
}
return NIOSSHSignature(backingSignature: .ecdsaP521(signature))
case .external:
throw NIOSSHError.externalSignerDigestUnsupported

#if canImport(Darwin)
case .secureEnclaveP256(let key):
Expand All @@ -139,6 +157,33 @@ extension NIOSSHPrivateKey {
case .ecdsaP521(let key):
let signature = try key.signature(for: payload.bytes.readableBytesView)
return NIOSSHSignature(backingSignature: .ecdsaP521(signature))
case .external(let signer):
let signatureBytes = try signer.sign(payload: payload.bytes)
let keyPrefix = signer.publicKey.keyPrefix

if keyPrefix.elementsEqual(NIOSSHPublicKey.ed25519PublicKeyPrefix) {
// Ed25519 remains wrapped as a ByteBuffer
return NIOSSHSignature(backingSignature: .ed25519(.byteBuffer(signatureBytes)))
}
if keyPrefix.elementsEqual(NIOSSHPublicKey.ecdsaP256PublicKeyPrefix) {
// ECDSA initializers use readableBytesView (DataProtocol)
return try NIOSSHSignature(
backingSignature: .ecdsaP256(.init(rawRepresentation: signatureBytes.readableBytesView))
)
}
if keyPrefix.elementsEqual(NIOSSHPublicKey.ecdsaP384PublicKeyPrefix) {
return try NIOSSHSignature(
backingSignature: .ecdsaP384(.init(rawRepresentation: signatureBytes.readableBytesView))
)
}
if keyPrefix.elementsEqual(NIOSSHPublicKey.ecdsaP521PublicKeyPrefix) {
return try NIOSSHSignature(
backingSignature: .ecdsaP521(.init(rawRepresentation: signatureBytes.readableBytesView))
)
}
throw NIOSSHError.unknownSignature(
algorithm: String(decoding: keyPrefix, as: UTF8.self)
)
#if canImport(Darwin)
case .secureEnclaveP256(let key):
let signature = try key.signature(for: payload.bytes.readableBytesView)
Expand All @@ -160,10 +205,32 @@ extension NIOSSHPrivateKey {
return NIOSSHPublicKey(backingKey: .ecdsaP384(privateKey.publicKey))
case .ecdsaP521(let privateKey):
return NIOSSHPublicKey(backingKey: .ecdsaP521(privateKey.publicKey))
case .external(let signer):
return signer.publicKey
#if canImport(Darwin)
case .secureEnclaveP256(let privateKey):
return NIOSSHPublicKey(backingKey: .ecdsaP256(privateKey.publicKey))
#endif
}
}
}

/// External signer interface for SSH public-key authentication.
public protocol NIOSSHExternalSigner: Sendable {
var publicKey: NIOSSHPublicKey { get }

/// Signs the raw SSH user-auth payload.
///
/// - Parameter payload: Raw `UserAuthSignablePayload` bytes
/// (session identifier + `SSH_MSG_USERAUTH_REQUEST` fields). The payload is **not** pre-hashed.
/// - Returns: Signature bytes compatible with `publicKey`.
///
/// Supported external signature encodings:
/// - `ssh-ed25519`: raw Ed25519 signature bytes.
/// - `ecdsa-sha2-nistp256`: CryptoKit P-256 `rawRepresentation` (`r || s`, fixed-width, 64 bytes).
/// - `ecdsa-sha2-nistp384`: CryptoKit P-384 `rawRepresentation` (`r || s`, fixed-width, 96 bytes).
/// - `ecdsa-sha2-nistp521`: CryptoKit P-521 `rawRepresentation` (`r || s`, fixed-width, 132 bytes).
///
/// - Important: This is called synchronously on NIO event-loop threads. Implementations must not block.
func sign(payload: ByteBuffer) throws -> ByteBuffer
}
9 changes: 9 additions & 0 deletions Sources/NIOSSH/NIOSSHError.swift
Original file line number Diff line number Diff line change
Expand Up @@ -161,6 +161,11 @@ extension NIOSSHError {
internal static func invalidCertificate(diagnostics: String) -> NIOSSHError {
NIOSSHError(type: .invalidCertificate, diagnostics: diagnostics)
}

internal static let externalSignerDigestUnsupported = NIOSSHError(
type: .externalSignerDigestUnsupported,
diagnostics: nil
)
}

// MARK: - NIOSSHError CustomStringConvertible conformance.
Expand Down Expand Up @@ -207,6 +212,7 @@ extension NIOSSHError {
case invalidHostKeyForKeyExchange
case invalidOpenSSHPublicKey
case invalidCertificate
case externalSignerDigestUnsupported
}

private var base: Base
Expand Down Expand Up @@ -304,6 +310,9 @@ extension NIOSSHError {

/// A certificate failed validation.
public static let invalidCertificate: ErrorType = .init(.invalidCertificate)

/// An external signer does not support digest-based signing.
public static let externalSignerDigestUnsupported: ErrorType = .init(.externalSignerDigestUnsupported)
}
}

Expand Down
158 changes: 158 additions & 0 deletions Tests/NIOSSHTests/NIOSSHExternalSignerTests.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,158 @@
//===----------------------------------------------------------------------===//
//
// This source file is part of the SwiftNIO open source project
//
// Copyright (c) 2026 Apple Inc. and the SwiftNIO project authors
// Licensed under Apache License v2.0
//
// See LICENSE.txt for license information
// See CONTRIBUTORS.txt for the list of SwiftNIO project authors
//
// SPDX-License-Identifier: Apache-2.0
//
//===----------------------------------------------------------------------===//

import Crypto
import NIOConcurrencyHelpers
import NIOCore
@testable import NIOSSH
import XCTest

final class NIOSSHExternalSignerTests: XCTestCase {
func testExternalSignerUsedAndSignaturePlumbed() throws {
let ed25519Key = Curve25519.Signing.PrivateKey()
let publicKey = NIOSSHPrivateKey(ed25519Key: ed25519Key).publicKey
let signatureBytes: [UInt8] = [0xAA, 0xBB, 0xCC]
let signer = RecordingSigner(publicKey: publicKey, signatureBytes: signatureBytes)

let privateKey = NIOSSHPrivateKey(externalSigner: signer)
let payload = makePayload(publicKey: publicKey)
let signature = try privateKey.sign(payload)

XCTAssertEqual(signer.callCount, 1)
XCTAssertEqual(signer.lastPayloadBytes, Array(payload.bytes.readableBytesView))

var expectedSignatureBuffer = ByteBufferAllocator().buffer(capacity: signatureBytes.count)
expectedSignatureBuffer.writeBytes(signatureBytes)
let expected = NIOSSHSignature(backingSignature: .ed25519(.byteBuffer(expectedSignatureBuffer)))
XCTAssertEqual(signature, expected)
}

func testExternalSignerP256UsedAndSignaturePlumbed() throws {
let p256Key = P256.Signing.PrivateKey()
let publicKey = NIOSSHPrivateKey(p256Key: p256Key).publicKey
let signature = try p256Key.signature(for: Data([0x01, 0x02, 0x03, 0x04]))
let signer = RecordingSigner(
publicKey: publicKey,
signatureBytes: Array(signature.rawRepresentation)
)

let privateKey = NIOSSHPrivateKey(externalSigner: signer)
let payload = makePayload(publicKey: publicKey)
let producedSignature = try privateKey.sign(payload)

XCTAssertEqual(signer.callCount, 1)
let expected = NIOSSHSignature(backingSignature: .ecdsaP256(signature))
XCTAssertEqual(producedSignature, expected)
}

func testExternalSignerP256InvalidRawSignatureThrows() throws {
let p256Key = P256.Signing.PrivateKey()
let publicKey = NIOSSHPrivateKey(p256Key: p256Key).publicKey
let signer = RecordingSigner(publicKey: publicKey, signatureBytes: [0x01, 0x02, 0x03])
let privateKey = NIOSSHPrivateKey(externalSigner: signer)
let payload = makePayload(publicKey: publicKey)

XCTAssertThrowsError(try privateKey.sign(payload))
}

func testExternalSignerP384UsedAndSignaturePlumbed() throws {
let p384Key = P384.Signing.PrivateKey()
let publicKey = NIOSSHPrivateKey(p384Key: p384Key).publicKey
let signature = try p384Key.signature(for: Data([0x05, 0x06, 0x07, 0x08]))
let signer = RecordingSigner(
publicKey: publicKey,
signatureBytes: Array(signature.rawRepresentation)
)

let privateKey = NIOSSHPrivateKey(externalSigner: signer)
let payload = makePayload(publicKey: publicKey)
let producedSignature = try privateKey.sign(payload)

XCTAssertEqual(signer.callCount, 1)
let expected = NIOSSHSignature(backingSignature: .ecdsaP384(signature))
XCTAssertEqual(producedSignature, expected)
}

func testExternalSignerP384InvalidRawSignatureThrows() throws {
let p384Key = P384.Signing.PrivateKey()
let publicKey = NIOSSHPrivateKey(p384Key: p384Key).publicKey
let signer = RecordingSigner(publicKey: publicKey, signatureBytes: [0x01, 0x02, 0x03])
let privateKey = NIOSSHPrivateKey(externalSigner: signer)
let payload = makePayload(publicKey: publicKey)

XCTAssertThrowsError(try privateKey.sign(payload))
}

func testExternalSignerP521UsedAndSignaturePlumbed() throws {
let p521Key = P521.Signing.PrivateKey()
let publicKey = NIOSSHPrivateKey(p521Key: p521Key).publicKey
let signature = try p521Key.signature(for: Data([0x09, 0x0A, 0x0B, 0x0C]))
let signer = RecordingSigner(
publicKey: publicKey,
signatureBytes: Array(signature.rawRepresentation)
)

let privateKey = NIOSSHPrivateKey(externalSigner: signer)
let payload = makePayload(publicKey: publicKey)
let producedSignature = try privateKey.sign(payload)

XCTAssertEqual(signer.callCount, 1)
let expected = NIOSSHSignature(backingSignature: .ecdsaP521(signature))
XCTAssertEqual(producedSignature, expected)
}

func testExternalSignerP521InvalidRawSignatureThrows() throws {
let p521Key = P521.Signing.PrivateKey()
let publicKey = NIOSSHPrivateKey(p521Key: p521Key).publicKey
let signer = RecordingSigner(publicKey: publicKey, signatureBytes: [0x01, 0x02, 0x03])
let privateKey = NIOSSHPrivateKey(externalSigner: signer)
let payload = makePayload(publicKey: publicKey)

XCTAssertThrowsError(try privateKey.sign(payload))
}

private func makePayload(publicKey: NIOSSHPublicKey) -> UserAuthSignablePayload {
var sessionID = ByteBufferAllocator().buffer(capacity: 16)
sessionID.writeBytes([0x01, 0x02, 0x03, 0x04])
return UserAuthSignablePayload(
sessionIdentifier: sessionID,
userName: "user",
serviceName: "ssh-connection",
publicKey: publicKey
)
}
}

private struct RecordingSigner: NIOSSHExternalSigner {
let publicKey: NIOSSHPublicKey
let signatureBytes: [UInt8]
private let payloadBytes = NIOLockedValueBox<[UInt8]?>(nil)
private let count = NIOLockedValueBox<Int>(0)

var callCount: Int {
count.withLockedValue { $0 }
}

var lastPayloadBytes: [UInt8]? {
payloadBytes.withLockedValue { $0 }
}

func sign(payload: ByteBuffer) throws -> ByteBuffer {
payloadBytes.withLockedValue { $0 = Array(payload.readableBytesView) }
count.withLockedValue { $0 += 1 }
var buffer = ByteBufferAllocator().buffer(capacity: signatureBytes.count)
buffer.writeBytes(signatureBytes)
return buffer
}
}