diff --git a/README.md b/README.md index 9f40cfd0..cbd7c09f 100644 --- a/README.md +++ b/README.md @@ -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. diff --git a/Sources/NIOSSH/Docs.docc/index.md b/Sources/NIOSSH/Docs.docc/index.md index 3f03bdc1..6af210e0 100644 --- a/Sources/NIOSSH/Docs.docc/index.md +++ b/Sources/NIOSSH/Docs.docc/index.md @@ -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. diff --git a/Sources/NIOSSH/Keys And Signatures/NIOSSHPrivateKey.swift b/Sources/NIOSSH/Keys And Signatures/NIOSSHPrivateKey.swift index 501d84d5..79d85890 100644 --- a/Sources/NIOSSH/Keys And Signatures/NIOSSHPrivateKey.swift +++ b/Sources/NIOSSH/Keys And Signatures/NIOSSHPrivateKey.swift @@ -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) @@ -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"] @@ -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) @@ -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): @@ -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) @@ -160,6 +205,8 @@ 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)) @@ -167,3 +214,23 @@ extension NIOSSHPrivateKey { } } } + +/// 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 +} diff --git a/Sources/NIOSSH/NIOSSHError.swift b/Sources/NIOSSH/NIOSSHError.swift index c2b8a542..de65bf38 100644 --- a/Sources/NIOSSH/NIOSSHError.swift +++ b/Sources/NIOSSH/NIOSSHError.swift @@ -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. @@ -207,6 +212,7 @@ extension NIOSSHError { case invalidHostKeyForKeyExchange case invalidOpenSSHPublicKey case invalidCertificate + case externalSignerDigestUnsupported } private var base: Base @@ -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) } } diff --git a/Tests/NIOSSHTests/NIOSSHExternalSignerTests.swift b/Tests/NIOSSHTests/NIOSSHExternalSignerTests.swift new file mode 100644 index 00000000..a56b6667 --- /dev/null +++ b/Tests/NIOSSHTests/NIOSSHExternalSignerTests.swift @@ -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(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 + } +}