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
16 changes: 16 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -129,6 +129,22 @@ 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:)`.

#### Delegated signing (SSH agents and hardware keys)

For public-key user authentication, SwiftNIO SSH can delegate the signing operation to an external system, such as an SSH agent, a hardware security module, or a smart card, so that private key material never has to be loaded into the process. Construct a `NIOSSHPrivateKey` from the corresponding public key plus a signing callback. SwiftNIO SSH invokes the callback with the raw signable payload bytes and expects a fully formed `NIOSSHSignature` in return.

```swift
let publicKey: NIOSSHPublicKey = // ... the public key whose private half lives in the agent

let key = NIOSSHPrivateKey(publicKey: publicKey) { payload in
// Forward `payload` to the agent and convert its reply into a NIOSSHSignature.
let rawSignature = try agent.sign(Data(payload))
return .ed25519(signature: rawSignature)
}
```

`NIOSSHSignature` provides constructors for each supported algorithm (`.ed25519(signature:)`, `.ecdsaP256(signature:)`, `.ecdsaP384(signature:)`, and `.ecdsaP521(signature:)`) so the callback can wrap the raw signature bytes returned by the external signer. The callback is `@Sendable` and is invoked on a NIO event-loop thread, so it must not block.

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

##### Delegated signing (SSH agents and hardware keys)

For public-key user authentication, SwiftNIO SSH can delegate the signing operation to an external system, such as an SSH agent, a hardware security module, or a smart card, so that private key material never has to be loaded into the process. Construct a ``NIOSSHPrivateKey`` from the corresponding public key plus a signing callback using ``NIOSSHPrivateKey/init(publicKey:signingCallback:)``. SwiftNIO SSH invokes the callback with the raw signable payload bytes and expects a fully formed ``NIOSSHSignature`` in return.

```swift
let publicKey: NIOSSHPublicKey = // ... the public key whose private half lives in the agent

let key = NIOSSHPrivateKey(publicKey: publicKey) { payload in
// Forward `payload` to the agent and convert its reply into a NIOSSHSignature.
let rawSignature = try agent.sign(Data(payload))
return .ed25519(signature: rawSignature)
}
```

``NIOSSHSignature`` provides constructors for each supported algorithm (``NIOSSHSignature/ed25519(signature:)``, ``NIOSSHSignature/ecdsaP256(signature:)``, ``NIOSSHSignature/ecdsaP384(signature:)``, and ``NIOSSHSignature/ecdsaP521(signature:)``) so the callback can wrap the raw signature bytes returned by the external signer. The callback is `@Sendable` and is invoked on a NIO event-loop thread, so it must not block.

#### 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
28 changes: 27 additions & 1 deletion Sources/NIOSSH/Keys And Signatures/NIOSSHPrivateKey.swift
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,19 @@ public struct NIOSSHPrivateKey: Sendable {
}
#endif

/// Creates a private key backed by a signing delegate.
///
/// This initializer enables integration with SSH agents, hardware security modules,
/// or other secure key management systems by delegating signing operations to an
/// external system.
///
/// - Parameter publicKey: The public key corresponding to the private key managed by the delegate
/// - Parameter signingCallback: The delegated signing operation callback
public init(publicKey: NIOSSHPublicKey,
signingCallback: @escaping @Sendable (ByteBufferView) throws -> NIOSSHSignature) {
self.backingKey = .signingDelegate(signingCallback, publicKey)
}

// The algorithms that apply to this host key.
internal var hostKeyAlgorithms: [Substring] {
switch self.backingKey {
Expand All @@ -69,6 +82,8 @@ public struct NIOSSHPrivateKey: Sendable {
return ["ecdsa-sha2-nistp384"]
case .ecdsaP521:
return ["ecdsa-sha2-nistp521"]
case .signingDelegate(_, let publicKey):
return [String(publicKey.keyPrefix)[...]]
#if canImport(Darwin)
case .secureEnclaveP256:
return ["ecdsa-sha2-nistp256"]
Expand All @@ -79,11 +94,12 @@ public struct NIOSSHPrivateKey: Sendable {

extension NIOSSHPrivateKey {
/// The various key types that can be used with NIOSSH.
internal enum BackingKey {
internal enum BackingKey: Sendable {
case ed25519(Curve25519.Signing.PrivateKey)
case ecdsaP256(P256.Signing.PrivateKey)
case ecdsaP384(P384.Signing.PrivateKey)
case ecdsaP521(P521.Signing.PrivateKey)
case signingDelegate(@Sendable (ByteBufferView) throws -> NIOSSHSignature, NIOSSHPublicKey)

#if canImport(Darwin)
case secureEnclaveP256(SecureEnclave.P256.Signing.PrivateKey)
Expand Down Expand Up @@ -115,6 +131,11 @@ extension NIOSSHPrivateKey {
}
return NIOSSHSignature(backingSignature: .ecdsaP521(signature))

case .signingDelegate:
// Signing delegates are not supported for digest-based signing
// This method is used for host key authentication, not user auth
throw NIOSSHError.unknownPublicKey(algorithm: "signingDelegate")

#if canImport(Darwin)
case .secureEnclaveP256(let key):
let signature = try digest.withUnsafeBytes { ptr in
Expand All @@ -139,6 +160,9 @@ extension NIOSSHPrivateKey {
case .ecdsaP521(let key):
let signature = try key.signature(for: payload.bytes.readableBytesView)
return NIOSSHSignature(backingSignature: .ecdsaP521(signature))
case .signingDelegate(let sign, _):
let sshSignature = try sign(payload.bytes.readableBytesView)
return sshSignature
#if canImport(Darwin)
case .secureEnclaveP256(let key):
let signature = try key.signature(for: payload.bytes.readableBytesView)
Expand All @@ -160,6 +184,8 @@ extension NIOSSHPrivateKey {
return NIOSSHPublicKey(backingKey: .ecdsaP384(privateKey.publicKey))
case .ecdsaP521(let privateKey):
return NIOSSHPublicKey(backingKey: .ecdsaP521(privateKey.publicKey))
case .signingDelegate(_, let publicKey):
return publicKey
#if canImport(Darwin)
case .secureEnclaveP256(let privateKey):
return NIOSSHPublicKey(backingKey: .ecdsaP256(privateKey.publicKey))
Expand Down
47 changes: 47 additions & 0 deletions Sources/NIOSSH/Keys And Signatures/NIOSSHSignature.swift
Original file line number Diff line number Diff line change
Expand Up @@ -395,3 +395,50 @@ extension P384.Signing.ECDSASignature: ECDSASignatureProtocol {
extension P521.Signing.ECDSASignature: ECDSASignatureProtocol {
static var pointSize: Int { 66 }
}

// MARK: - Public constructors for signing delegates

extension NIOSSHSignature {
/// Create an ed25519 signature from raw signature data.
///
/// This is used by signing delegates, such as SSH agents,
/// to create an an ed25519 signature from raw signature data.
/// - Parameter signature: The raw ed25519 signature data.
/// - Returns: The created NIOSSH signature.
public static func ed25519(signature: Data) -> NIOSSHSignature {
return NIOSSHSignature(backingSignature: .ed25519(.data(signature)))
}

/// Create an ECDSA P-256 signature from raw signature data.
///
/// This is used by signing delegates, such as SSH agents,
/// to create an ECDSA P-256 signature from raw signature data.
/// - Parameter signature: The raw ECDSA P-256 signature data.
/// - Returns: The created NIOSSH signature.
public static func ecdsaP256(signature: Data) throws -> NIOSSHSignature {
let ecdsaSignature = try P256.Signing.ECDSASignature(rawRepresentation: signature)
return NIOSSHSignature(backingSignature: .ecdsaP256(ecdsaSignature))
}

/// Create an ECDSA P-384 signature from raw signature data.
///
/// This is used by signing delegates, such as SSH agents,
/// to create an ECDSA P-384 signature from raw signature data.
/// - Parameter signature: The raw ECDSA P-384 signature data.
/// - Returns: The created NIOSSH signature.
public static func ecdsaP384(signature: Data) throws -> NIOSSHSignature {
let ecdsaSignature = try P384.Signing.ECDSASignature(rawRepresentation: signature)
return NIOSSHSignature(backingSignature: .ecdsaP384(ecdsaSignature))
}

/// Create an ECDSA P-521 signature from raw signature data.
///
/// This is used by signing delegates, such as SSH agents,
/// to create an ECDSA P-521 signature from raw signature data.
/// - Parameter signature: The raw ECDSA P-521 signature data.
/// - Returns: The created NIOSSH signature.
public static func ecdsaP521(signature: Data) throws -> NIOSSHSignature {
let ecdsaSignature = try P521.Signing.ECDSASignature(rawRepresentation: signature)
return NIOSSHSignature(backingSignature: .ecdsaP521(ecdsaSignature))
}
}
194 changes: 194 additions & 0 deletions Tests/NIOSSHTests/SigningDelegateTests.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,194 @@
//===----------------------------------------------------------------------===//
//
// 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 NIOCore
import NIOFoundationCompat
import XCTest

@testable import NIOSSH

final class SigningDelegateTests: XCTestCase {
/// Build a representative user-authentication signable payload for a public key.
private func makePayload(for publicKey: NIOSSHPublicKey) -> UserAuthSignablePayload {
var sessionIdentifier = ByteBufferAllocator().buffer(capacity: 32)
sessionIdentifier.writeBytes(Array(repeating: UInt8(0x2A), count: 32))
return UserAuthSignablePayload(
sessionIdentifier: sessionIdentifier,
userName: "user",
serviceName: "ssh-connection",
publicKey: publicKey
)
}

func testBasicEd25519SigningDelegateFlow() throws {
let edKey = Curve25519.Signing.PrivateKey()
let publicKey = NIOSSHPrivateKey(ed25519Key: edKey).publicKey

let payload = self.makePayload(for: publicKey)
let rawSignature = try edKey.signature(for: payload.bytes.readableBytesView)
let delegateSignature = NIOSSHSignature.ed25519(signature: Data(rawSignature))
let sshKey = NIOSSHPrivateKey(publicKey: publicKey) { _ in delegateSignature }

let signature = try assertNoThrowWithValue(sshKey.sign(payload))

// Naturally, this should verify.
XCTAssertNoThrow(XCTAssertTrue(publicKey.isValidSignature(signature, for: payload)))

// Now let's try round-tripping through bytebuffer.
var buffer = ByteBufferAllocator().buffer(capacity: 1024)
buffer.writeSSHSignature(signature)

let newSignature = try assertNoThrowWithValue(buffer.readSSHSignature()!)
XCTAssertNoThrow(XCTAssertTrue(publicKey.isValidSignature(newSignature, for: payload)))
}

func testBasicECDSAP256SigningDelegateFlow() throws {
let ecdsaKey = P256.Signing.PrivateKey()
let publicKey = NIOSSHPrivateKey(p256Key: ecdsaKey).publicKey

let payload = self.makePayload(for: publicKey)
let rawSignature = try ecdsaKey.signature(for: Data(payload.bytes.readableBytesView))
let delegateSignature = try NIOSSHSignature.ecdsaP256(signature: Data(rawSignature.rawRepresentation))
let sshKey = NIOSSHPrivateKey(publicKey: publicKey) { _ in delegateSignature }

let signature = try assertNoThrowWithValue(sshKey.sign(payload))

// Naturally, this should verify.
XCTAssertNoThrow(XCTAssertTrue(publicKey.isValidSignature(signature, for: payload)))

// Now let's try round-tripping through bytebuffer.
var buffer = ByteBufferAllocator().buffer(capacity: 1024)
buffer.writeSSHSignature(signature)

let newSignature = try assertNoThrowWithValue(buffer.readSSHSignature()!)
XCTAssertNoThrow(XCTAssertTrue(publicKey.isValidSignature(newSignature, for: payload)))
}

func testBasicECDSAP384SigningDelegateFlow() throws {
let ecdsaKey = P384.Signing.PrivateKey()
let publicKey = NIOSSHPrivateKey(p384Key: ecdsaKey).publicKey

let payload = self.makePayload(for: publicKey)
let rawSignature = try ecdsaKey.signature(for: Data(payload.bytes.readableBytesView))
let delegateSignature = try NIOSSHSignature.ecdsaP384(signature: Data(rawSignature.rawRepresentation))
let sshKey = NIOSSHPrivateKey(publicKey: publicKey) { _ in delegateSignature }

let signature = try assertNoThrowWithValue(sshKey.sign(payload))

// Naturally, this should verify.
XCTAssertNoThrow(XCTAssertTrue(publicKey.isValidSignature(signature, for: payload)))

// Now let's try round-tripping through bytebuffer.
var buffer = ByteBufferAllocator().buffer(capacity: 1024)
buffer.writeSSHSignature(signature)

let newSignature = try assertNoThrowWithValue(buffer.readSSHSignature()!)
XCTAssertNoThrow(XCTAssertTrue(publicKey.isValidSignature(newSignature, for: payload)))
}

func testBasicECDSAP521SigningDelegateFlow() throws {
let ecdsaKey = P521.Signing.PrivateKey()
let publicKey = NIOSSHPrivateKey(p521Key: ecdsaKey).publicKey

let payload = self.makePayload(for: publicKey)
let rawSignature = try ecdsaKey.signature(for: Data(payload.bytes.readableBytesView))
let delegateSignature = try NIOSSHSignature.ecdsaP521(signature: Data(rawSignature.rawRepresentation))
let sshKey = NIOSSHPrivateKey(publicKey: publicKey) { _ in delegateSignature }

let signature = try assertNoThrowWithValue(sshKey.sign(payload))

// Naturally, this should verify.
XCTAssertNoThrow(XCTAssertTrue(publicKey.isValidSignature(signature, for: payload)))

// Now let's try round-tripping through bytebuffer.
var buffer = ByteBufferAllocator().buffer(capacity: 1024)
buffer.writeSSHSignature(signature)

let newSignature = try assertNoThrowWithValue(buffer.readSSHSignature()!)
XCTAssertNoThrow(XCTAssertTrue(publicKey.isValidSignature(newSignature, for: payload)))
}

func testSigningDelegateFailsVerificationWithDifferentKey() throws {
let edKey = Curve25519.Signing.PrivateKey()
let publicKey = NIOSSHPrivateKey(ed25519Key: edKey).publicKey

let payload = self.makePayload(for: publicKey)
let rawSignature = try edKey.signature(for: payload.bytes.readableBytesView)
let delegateSignature = NIOSSHSignature.ed25519(signature: Data(rawSignature))
let sshKey = NIOSSHPrivateKey(publicKey: publicKey) { _ in delegateSignature }

let signature = try assertNoThrowWithValue(sshKey.sign(payload))

let otherPublicKey = NIOSSHPrivateKey(ed25519Key: .init()).publicKey

// Naturally, this should not verify.
XCTAssertNoThrow(XCTAssertFalse(otherPublicKey.isValidSignature(signature, for: payload)))

// Now let's try round-tripping through bytebuffer.
var buffer = ByteBufferAllocator().buffer(capacity: 1024)
buffer.writeSSHSignature(signature)

let newSignature = try assertNoThrowWithValue(buffer.readSSHSignature()!)
XCTAssertNoThrow(XCTAssertFalse(otherPublicKey.isValidSignature(newSignature, for: payload)))
}

func testSigningDelegatePassesSignablePayload() throws {
let edKey = Curve25519.Signing.PrivateKey()
let publicKey = NIOSSHPrivateKey(ed25519Key: edKey).publicKey

let payload = self.makePayload(for: publicKey)
let rawSignature = try edKey.signature(for: payload.bytes.readableBytesView)
let delegateSignature = NIOSSHSignature.ed25519(signature: Data(rawSignature))

let recorder = PayloadRecorder()
let sshKey = NIOSSHPrivateKey(publicKey: publicKey) { received in
recorder.bytes = Array(received)
return delegateSignature
}

_ = try assertNoThrowWithValue(sshKey.sign(payload))

XCTAssertEqual(recorder.bytes, Array(payload.bytes.readableBytesView))
}

func testSigningDelegateExposesPublicKey() throws {
let publicKey = NIOSSHPrivateKey(ed25519Key: .init()).publicKey
let sshKey = NIOSSHPrivateKey(publicKey: publicKey) { _ in
.ed25519(signature: Data())
}

XCTAssertEqual(sshKey.publicKey, publicKey)
}

func testSigningDelegatePropagatesErrors() throws {
struct SigningFailure: Error {}

let publicKey = NIOSSHPrivateKey(ed25519Key: .init()).publicKey
let sshKey = NIOSSHPrivateKey(publicKey: publicKey) { _ in
throw SigningFailure()
}

let payload = self.makePayload(for: publicKey)
XCTAssertThrowsError(try sshKey.sign(payload)) { error in
XCTAssertTrue(error is SigningFailure)
}
}
}

/// Records the payload bytes handed to a signing delegate so a test can assert
/// that the callback received the expected ``UserAuthSignablePayload`` content.
private final class PayloadRecorder: @unchecked Sendable {
var bytes: [UInt8]?
}