From 96d8ebc153218291f0d5257aabaeb71ce961afc6 Mon Sep 17 00:00:00 2001 From: daiimus Date: Sat, 27 Dec 2025 16:52:23 -0800 Subject: [PATCH] Add RSA key support (ssh-rsa, rsa-sha2-256, rsa-sha2-512) Motivation: RSA keys are still widely used in SSH deployments, especially on older servers and enterprise environments. The current SwiftNIO-SSH implementation only supports Ed25519 and ECDSA keys, which limits compatibility. RFC 8332 deprecates the original ssh-rsa signature scheme (which uses SHA-1) in favor of rsa-sha2-256 and rsa-sha2-512, but many servers still require fallback to ssh-rsa for compatibility. Modifications: - Add NIOSSHPrivateKey(rsaKey:) initializer using _CryptoExtras - Add RSASignatureAlgorithm enum with sha512/sha256/sha1 cases and fallback chain - Implement rsa-sha2-512, rsa-sha2-256, and ssh-rsa signature algorithms per RFC 8332 - Add RSA public key wire format read/write support (exponent + modulus) - Add RSA signature wire format with proper algorithm prefixes - Support RSA user authentication with algorithm negotiation - Add BackingKey.rsa case to NIOSSHPrivateKey and NIOSSHPublicKey - Add .rsaSHA512, .rsaSHA256, .rsaSHA1 cases to NIOSSHSignature - Update SSHMessages to parse rsaSignatureAlgorithm field Tests: - Add RSAKeyTests.swift with 21 tests covering signing, verification, round-trip, cross-algorithm failures, key sizes (2048/3072/4096), and wire format - Update HostKeyTests to use ssh-dss for 'unrecognised' tests (RSA now supported) - Update UserAuthenticationStateMachineTests pattern matching for new signature field Result: SwiftNIO-SSH now supports RSA keys for both host key verification and user authentication. The implementation follows RFC 8332 by preferring rsa-sha2-512, falling back to rsa-sha2-256, and finally ssh-rsa for maximum compatibility. --- Package.swift | 5 +- .../NIOSSHCertifiedPublicKey.swift | 29 ++ .../NIOSSHPrivateKey.swift | 83 ++- .../Keys And Signatures/NIOSSHPublicKey.swift | 123 ++++- .../Keys And Signatures/NIOSSHSignature.swift | 82 ++- Sources/NIOSSH/RSASignatureAlgorithm.swift | 54 ++ Sources/NIOSSH/SSHMessages.swift | 13 +- .../UserAuthSignablePayload.swift | 7 +- .../UserAuthenticationMethod.swift | 18 +- .../UserAuthenticationStateMachine.swift | 7 +- Tests/NIOSSHTests/HostKeyTests.swift | 4 +- Tests/NIOSSHTests/RSAKeyTests.swift | 491 ++++++++++++++++++ Tests/NIOSSHTests/SSHMessagesTests.swift | 4 +- .../UserAuthenticationStateMachineTests.swift | 20 +- 14 files changed, 901 insertions(+), 39 deletions(-) create mode 100644 Sources/NIOSSH/RSASignatureAlgorithm.swift create mode 100644 Tests/NIOSSHTests/RSAKeyTests.swift diff --git a/Package.swift b/Package.swift index 121043f2..b3e756c8 100644 --- a/Package.swift +++ b/Package.swift @@ -38,7 +38,9 @@ let package = Package( ], dependencies: [ .package(url: "https://github.com/apple/swift-nio.git", from: "2.81.0"), - .package(url: "https://github.com/apple/swift-crypto.git", "1.0.0"..<"4.0.0"), + // NOTE: Minimum swift-crypto raised to 3.x for _CryptoExtras (RSA) support. + // This is a breaking change for users on swift-crypto 1.x or 2.x. + .package(url: "https://github.com/apple/swift-crypto.git", "3.0.0"..<"4.0.0"), .package(url: "https://github.com/apple/swift-atomics.git", from: "1.0.2"), ], targets: [ @@ -49,6 +51,7 @@ let package = Package( .product(name: "NIOConcurrencyHelpers", package: "swift-nio"), .product(name: "NIOFoundationCompat", package: "swift-nio"), .product(name: "Crypto", package: "swift-crypto"), + .product(name: "_CryptoExtras", package: "swift-crypto"), .product(name: "Atomics", package: "swift-atomics"), ], swiftSettings: swiftSettings diff --git a/Sources/NIOSSH/Keys And Signatures/NIOSSHCertifiedPublicKey.swift b/Sources/NIOSSH/Keys And Signatures/NIOSSHCertifiedPublicKey.swift index f850df85..e9b27af0 100644 --- a/Sources/NIOSSH/Keys And Signatures/NIOSSHCertifiedPublicKey.swift +++ b/Sources/NIOSSH/Keys And Signatures/NIOSSHCertifiedPublicKey.swift @@ -359,11 +359,40 @@ extension NIOSSHCertifiedPublicKey { return Self.p384KeyPrefix case .ecdsaP521: return Self.p521KeyPrefix + case .rsa: + preconditionFailure("RSA certificates are not currently supported") case .certified: preconditionFailure("base key cannot be certified") } } + internal var signatureAlgorithmPrefix: String.UTF8View { + switch self.key.backingKey { + case .ed25519: + return Self.ed25519KeyPrefix + case .ecdsaP256: + return Self.p256KeyPrefix + case .ecdsaP384: + return Self.p384KeyPrefix + case .ecdsaP521: + return Self.p521KeyPrefix + case .rsa: + preconditionFailure("RSA certificates are not currently supported") + case .certified: + preconditionFailure("base key cannot be certified") + } + } + + /// Returns the algorithm name to use for authentication, supporting RSA algorithm selection. + internal func algorithmName(forRSA rsaAlgorithm: RSASignatureAlgorithm) -> String.UTF8View { + switch self.key.backingKey { + case .rsa: + return rsaAlgorithm.wireBytes + default: + return self.signatureAlgorithmPrefix + } + } + internal func isValidSignature(_ signature: NIOSSHSignature, for digest: DigestBytes) -> Bool { self.key.isValidSignature(signature, for: digest) } diff --git a/Sources/NIOSSH/Keys And Signatures/NIOSSHPrivateKey.swift b/Sources/NIOSSH/Keys And Signatures/NIOSSHPrivateKey.swift index 501d84d5..4de9c1a9 100644 --- a/Sources/NIOSSH/Keys And Signatures/NIOSSHPrivateKey.swift +++ b/Sources/NIOSSH/Keys And Signatures/NIOSSHPrivateKey.swift @@ -13,7 +13,9 @@ //===----------------------------------------------------------------------===// @preconcurrency import Crypto +import _CryptoExtras import NIOCore +import NIOFoundationCompat #if canImport(FoundationEssentials) import FoundationEssentials @@ -52,6 +54,14 @@ public struct NIOSSHPrivateKey: Sendable { self.backingKey = .ecdsaP521(key) } + /// Create a private key from an RSA key. + /// + /// RSA support is provided for interoperability with legacy systems. For new deployments, + /// consider using Ed25519 or ECDSA keys instead. + public init(rsaKey key: _RSA.Signing.PrivateKey) { + self.backingKey = .rsa(key) + } + #if canImport(Darwin) public init(secureEnclaveP256Key key: SecureEnclave.P256.Signing.PrivateKey) { self.backingKey = .secureEnclaveP256(key) @@ -69,6 +79,10 @@ public struct NIOSSHPrivateKey: Sendable { return ["ecdsa-sha2-nistp384"] case .ecdsaP521: return ["ecdsa-sha2-nistp521"] + case .rsa: + // Modern servers prefer rsa-sha2-512 > rsa-sha2-256 > ssh-rsa + // ssh-rsa uses SHA-1 which is deprecated but still needed for compatibility + return ["rsa-sha2-512", "rsa-sha2-256", "ssh-rsa"] #if canImport(Darwin) case .secureEnclaveP256: return ["ecdsa-sha2-nistp256"] @@ -84,6 +98,7 @@ extension NIOSSHPrivateKey { case ecdsaP256(P256.Signing.PrivateKey) case ecdsaP384(P384.Signing.PrivateKey) case ecdsaP521(P521.Signing.PrivateKey) + case rsa(_RSA.Signing.PrivateKey) #if canImport(Darwin) case secureEnclaveP256(SecureEnclave.P256.Signing.PrivateKey) @@ -114,6 +129,24 @@ extension NIOSSHPrivateKey { try key.signature(for: ptr) } return NIOSSHSignature(backingSignature: .ecdsaP521(signature)) + case .rsa(let key): + // For RSA, the signature algorithm identifier should match the digest type. + // Per RFC 8332, we support rsa-sha2-256 and rsa-sha2-512. + // SHA-384 is not defined in SSH, so we use rsa-sha2-512 as the strongest available. + let signature = try key.signature(for: digest, padding: .insecurePKCS1v1_5) + + switch DigestBytes.byteCount { + case SHA256.byteCount: + return NIOSSHSignature(backingSignature: .rsaSHA256(signature)) + case SHA384.byteCount: + // SHA-384 has no SSH algorithm; use strongest available (rsa-sha2-512) + return NIOSSHSignature(backingSignature: .rsaSHA512(signature)) + case SHA512.byteCount: + return NIOSSHSignature(backingSignature: .rsaSHA512(signature)) + default: + // For any other digest size (including SHA-1's 20 bytes), use SHA-512 + return NIOSSHSignature(backingSignature: .rsaSHA512(signature)) + } #if canImport(Darwin) case .secureEnclaveP256(let key): @@ -125,7 +158,7 @@ extension NIOSSHPrivateKey { } } - func sign(_ payload: UserAuthSignablePayload) throws -> NIOSSHSignature { + func sign(_ payload: UserAuthSignablePayload, rsaSignatureAlgorithm: RSASignatureAlgorithm = .sha512) throws -> NIOSSHSignature { switch self.backingKey { case .ed25519(let key): let signature = try key.signature(for: payload.bytes.readableBytesView) @@ -139,6 +172,23 @@ extension NIOSSHPrivateKey { case .ecdsaP521(let key): let signature = try key.signature(for: payload.bytes.readableBytesView) return NIOSSHSignature(backingSignature: .ecdsaP521(signature)) + case .rsa(let key): + // Sign using the specified RSA algorithm (RFC 8332) + let bytesView = payload.bytes.readableBytesView + switch rsaSignatureAlgorithm { + case .sha512: + let digest = SHA512.hash(data: bytesView) + let signature = try key.signature(for: digest, padding: .insecurePKCS1v1_5) + return NIOSSHSignature(backingSignature: .rsaSHA512(signature)) + case .sha256: + let digest = SHA256.hash(data: bytesView) + let signature = try key.signature(for: digest, padding: .insecurePKCS1v1_5) + return NIOSSHSignature(backingSignature: .rsaSHA256(signature)) + case .sha1: + let digest = Insecure.SHA1.hash(data: bytesView) + let signature = try key.signature(for: digest, padding: .insecurePKCS1v1_5) + return NIOSSHSignature(backingSignature: .rsaSHA1(signature)) + } #if canImport(Darwin) case .secureEnclaveP256(let key): let signature = try key.signature(for: payload.bytes.readableBytesView) @@ -146,6 +196,35 @@ extension NIOSSHPrivateKey { #endif } } + + /// Sign a payload with a specific RSA signature algorithm. + /// + /// This is used when the server negotiates a specific RSA algorithm (ssh-rsa, rsa-sha2-256, rsa-sha2-512). + func signRSA(_ payload: UserAuthSignablePayload, algorithm: Substring) throws -> NIOSSHSignature { + guard case .rsa(let key) = self.backingKey else { + preconditionFailure("signRSA called on non-RSA key") + } + + let bytesView = payload.bytes.readableBytesView + + switch algorithm { + case "ssh-rsa": + // ssh-rsa uses SHA-1 (deprecated but still used for compatibility) + let digest = Insecure.SHA1.hash(data: bytesView) + let signature = try key.signature(for: digest, padding: .insecurePKCS1v1_5) + return NIOSSHSignature(backingSignature: .rsaSHA1(signature)) + case "rsa-sha2-256": + let digest = SHA256.hash(data: bytesView) + let signature = try key.signature(for: digest, padding: .insecurePKCS1v1_5) + return NIOSSHSignature(backingSignature: .rsaSHA256(signature)) + case "rsa-sha2-512": + let digest = SHA512.hash(data: bytesView) + let signature = try key.signature(for: digest, padding: .insecurePKCS1v1_5) + return NIOSSHSignature(backingSignature: .rsaSHA512(signature)) + default: + preconditionFailure("Unknown RSA algorithm: \(algorithm)") + } + } } extension NIOSSHPrivateKey { @@ -160,6 +239,8 @@ extension NIOSSHPrivateKey { return NIOSSHPublicKey(backingKey: .ecdsaP384(privateKey.publicKey)) case .ecdsaP521(let privateKey): return NIOSSHPublicKey(backingKey: .ecdsaP521(privateKey.publicKey)) + case .rsa(let privateKey): + return NIOSSHPublicKey(backingKey: .rsa(privateKey.publicKey)) #if canImport(Darwin) case .secureEnclaveP256(let privateKey): return NIOSSHPublicKey(backingKey: .ecdsaP256(privateKey.publicKey)) diff --git a/Sources/NIOSSH/Keys And Signatures/NIOSSHPublicKey.swift b/Sources/NIOSSH/Keys And Signatures/NIOSSHPublicKey.swift index 20fa63e1..efc9b474 100644 --- a/Sources/NIOSSH/Keys And Signatures/NIOSSHPublicKey.swift +++ b/Sources/NIOSSH/Keys And Signatures/NIOSSHPublicKey.swift @@ -13,6 +13,7 @@ //===----------------------------------------------------------------------===// @preconcurrency import Crypto +import _CryptoExtras import Foundation import NIOCore import NIOFoundationCompat @@ -94,12 +95,19 @@ extension NIOSSHPublicKey { return digest.withUnsafeBytes { digestPtr in key.isValidSignature(sig, for: digestPtr) } + case (.rsa(let key), .rsaSHA1(let sig)): + return key.isValidSignature(sig, for: digest, padding: .insecurePKCS1v1_5) + case (.rsa(let key), .rsaSHA256(let sig)): + return key.isValidSignature(sig, for: digest, padding: .insecurePKCS1v1_5) + case (.rsa(let key), .rsaSHA512(let sig)): + return key.isValidSignature(sig, for: digest, padding: .insecurePKCS1v1_5) case (.certified(let key), _): return key.isValidSignature(signature, for: digest) case (.ed25519, _), (.ecdsaP256, _), (.ecdsaP384, _), - (.ecdsaP521, _): + (.ecdsaP521, _), + (.rsa, _): return false } } @@ -116,12 +124,22 @@ extension NIOSSHPublicKey { return key.isValidSignature(sig, for: bytes.readableBytesView) case (.ecdsaP521(let key), .ecdsaP521(let sig)): return key.isValidSignature(sig, for: bytes.readableBytesView) + case (.rsa(let key), .rsaSHA1(let sig)): + let digest = Insecure.SHA1.hash(data: bytes.readableBytesView) + return key.isValidSignature(sig, for: digest, padding: .insecurePKCS1v1_5) + case (.rsa(let key), .rsaSHA256(let sig)): + let digest = SHA256.hash(data: bytes.readableBytesView) + return key.isValidSignature(sig, for: digest, padding: .insecurePKCS1v1_5) + case (.rsa(let key), .rsaSHA512(let sig)): + let digest = SHA512.hash(data: bytes.readableBytesView) + return key.isValidSignature(sig, for: digest, padding: .insecurePKCS1v1_5) case (.certified(let key), _): return key.isValidSignature(signature, for: bytes) case (.ed25519, _), (.ecdsaP256, _), (.ecdsaP384, _), - (.ecdsaP521, _): + (.ecdsaP521, _), + (.rsa, _): return false } } @@ -138,12 +156,22 @@ extension NIOSSHPublicKey { return key.isValidSignature(sig, for: payload.bytes.readableBytesView) case (.ecdsaP521(let key), .ecdsaP521(let sig)): return key.isValidSignature(sig, for: payload.bytes.readableBytesView) + case (.rsa(let key), .rsaSHA1(let sig)): + let digest = Insecure.SHA1.hash(data: payload.bytes.readableBytesView) + return key.isValidSignature(sig, for: digest, padding: .insecurePKCS1v1_5) + case (.rsa(let key), .rsaSHA256(let sig)): + let digest = SHA256.hash(data: payload.bytes.readableBytesView) + return key.isValidSignature(sig, for: digest, padding: .insecurePKCS1v1_5) + case (.rsa(let key), .rsaSHA512(let sig)): + let digest = SHA512.hash(data: payload.bytes.readableBytesView) + return key.isValidSignature(sig, for: digest, padding: .insecurePKCS1v1_5) case (.certified(let key), _): return key.isValidSignature(signature, for: payload) case (.ed25519, _), (.ecdsaP256, _), (.ecdsaP384, _), - (.ecdsaP521, _): + (.ecdsaP521, _), + (.rsa, _): return false } } @@ -157,6 +185,7 @@ extension NIOSSHPublicKey { case ecdsaP256(P256.Signing.PublicKey) case ecdsaP384(P384.Signing.PublicKey) case ecdsaP521(P521.Signing.PublicKey) + case rsa(_RSA.Signing.PublicKey) case certified(NIOSSHCertifiedPublicKey) // This case recursively contains `NIOSSHPublicKey`. } @@ -172,6 +201,9 @@ extension NIOSSHPublicKey { /// The prefix of a P521 ECDSA public key. internal static let ecdsaP521PublicKeyPrefix = "ecdsa-sha2-nistp521".utf8 + /// The prefix of an RSA public key. + internal static let rsaPublicKeyPrefix = "ssh-rsa".utf8 + internal var keyPrefix: String.UTF8View { switch self.backingKey { case .ed25519: @@ -182,15 +214,51 @@ extension NIOSSHPublicKey { return Self.ecdsaP384PublicKeyPrefix case .ecdsaP521: return Self.ecdsaP521PublicKeyPrefix + case .rsa: + return Self.rsaPublicKeyPrefix case .certified(let base): return base.keyPrefix } } + /// The algorithm prefix to use for user authentication signatures. + /// For most keys this matches keyPrefix, but RSA uses rsa-sha2-512 for modern auth. + internal var signatureAlgorithmPrefix: String.UTF8View { + switch self.backingKey { + case .ed25519: + return Self.ed25519PublicKeyPrefix + case .ecdsaP256: + return Self.ecdsaP256PublicKeyPrefix + case .ecdsaP384: + return Self.ecdsaP384PublicKeyPrefix + case .ecdsaP521: + return Self.ecdsaP521PublicKeyPrefix + case .rsa: + // Use rsa-sha2-512 for user auth (RFC 8332) + return "rsa-sha2-512".utf8 + case .certified(let base): + return base.signatureAlgorithmPrefix + } + } + + /// Returns the algorithm name to use for authentication, supporting RSA algorithm selection. + /// For RSA keys, the caller can specify which algorithm to use (RFC 8332). + /// For other key types, the standard algorithm prefix is returned. + internal func algorithmName(forRSA rsaAlgorithm: RSASignatureAlgorithm) -> String.UTF8View { + switch self.backingKey { + case .rsa: + return rsaAlgorithm.wireBytes + case .certified(let base): + return base.algorithmName(forRSA: rsaAlgorithm) + default: + return self.signatureAlgorithmPrefix + } + } + internal static var knownAlgorithms: [String.UTF8View] { [ Self.ed25519PublicKeyPrefix, Self.ecdsaP384PublicKeyPrefix, Self.ecdsaP256PublicKeyPrefix, - Self.ecdsaP521PublicKeyPrefix, + Self.ecdsaP521PublicKeyPrefix, Self.rsaPublicKeyPrefix, ] } } @@ -207,12 +275,15 @@ extension NIOSSHPublicKey.BackingKey: Equatable { return lhs.rawRepresentation == rhs.rawRepresentation case (.ecdsaP521(let lhs), .ecdsaP521(let rhs)): return lhs.rawRepresentation == rhs.rawRepresentation + case (.rsa(let lhs), .rsa(let rhs)): + return lhs.derRepresentation == rhs.derRepresentation case (.certified(let lhs), .certified(let rhs)): return lhs == rhs case (.ed25519, _), (.ecdsaP256, _), (.ecdsaP384, _), (.ecdsaP521, _), + (.rsa, _), (.certified, _): return false } @@ -234,8 +305,11 @@ extension NIOSSHPublicKey.BackingKey: Hashable { case .ecdsaP521(let pkey): hasher.combine(4) hasher.combine(pkey.rawRepresentation) - case .certified(let pkey): + case .rsa(let pkey): hasher.combine(5) + hasher.combine(pkey.derRepresentation) + case .certified(let pkey): + hasher.combine(6) hasher.combine(pkey) } } @@ -260,6 +334,9 @@ extension ByteBuffer { case .ecdsaP521(let key): writtenBytes += self.writeSSHString(NIOSSHPublicKey.ecdsaP521PublicKeyPrefix) writtenBytes += self.writeECDSAP521PublicKey(baseKey: key) + case .rsa(let key): + writtenBytes += self.writeSSHString(NIOSSHPublicKey.rsaPublicKeyPrefix) + writtenBytes += self.writeRSAPublicKey(baseKey: key) case .certified(let key): return self.writeCertifiedKey(key) } @@ -281,6 +358,8 @@ extension ByteBuffer { return self.writeECDSAP384PublicKey(baseKey: key) case .ecdsaP521(let key): return self.writeECDSAP521PublicKey(baseKey: key) + case .rsa(let key): + return self.writeRSAPublicKey(baseKey: key) case .certified: preconditionFailure("Certified keys are the only callers of this method, and cannot contain themselves") } @@ -310,6 +389,8 @@ extension ByteBuffer { return try buffer.readECDSAP384PublicKey() } else if keyIdentifierBytes.elementsEqual(NIOSSHPublicKey.ecdsaP521PublicKeyPrefix) { return try buffer.readECDSAP521PublicKey() + } else if keyIdentifierBytes.elementsEqual(NIOSSHPublicKey.rsaPublicKeyPrefix) { + return try buffer.readRSAPublicKey() } else { // We don't know this public key type. Maybe the certified keys do. return try buffer.readCertifiedKeyWithoutKeyPrefix(keyIdentifierBytes).map(NIOSSHPublicKey.init) @@ -349,6 +430,20 @@ extension ByteBuffer { return writtenBytes } + private mutating func writeRSAPublicKey(baseKey: _RSA.Signing.PublicKey) -> Int { + // For RSA, the key format is: mpint e (public exponent), mpint n (modulus) + var writtenBytes = 0 + do { + let primitives = try baseKey.getKeyPrimitives() + writtenBytes += self.writePositiveMPInt(primitives.publicExponent) + writtenBytes += self.writePositiveMPInt(primitives.modulus) + } catch { + // This should never happen with a valid RSA key + preconditionFailure("Failed to get RSA key primitives: \(error)") + } + return writtenBytes + } + /// A helper function that reads an Ed25519 public key. /// /// Not safe to call from arbitrary code as this does not return the reader index on failure: it relies on the caller performing @@ -435,6 +530,24 @@ extension ByteBuffer { return NIOSSHPublicKey(backingKey: .ecdsaP521(key)) } + /// A helper function that reads an RSA public key. + /// + /// Not safe to call from arbitrary code as this does not return the reader index on failure: it relies on the caller performing + /// the rewind. + private mutating func readRSAPublicKey() throws -> NIOSSHPublicKey? { + // For RSA, the key format is: mpint e (public exponent), mpint n (modulus) + guard let eBytes = self.readSSHString(), + let nBytes = self.readSSHString() else { + return nil + } + + let key = try _RSA.Signing.PublicKey( + n: Data(nBytes.mpIntView), + e: Data(eBytes.mpIntView) + ) + return NIOSSHPublicKey(backingKey: .rsa(key)) + } + /// A helper function for complex readers that will reset a buffer on nil or on error, as though the read /// never occurred. internal mutating func rewindOnNilOrError(_ body: (inout ByteBuffer) throws -> T?) rethrows -> T? { diff --git a/Sources/NIOSSH/Keys And Signatures/NIOSSHSignature.swift b/Sources/NIOSSH/Keys And Signatures/NIOSSHSignature.swift index be30e692..17be975a 100644 --- a/Sources/NIOSSH/Keys And Signatures/NIOSSHSignature.swift +++ b/Sources/NIOSSH/Keys And Signatures/NIOSSHSignature.swift @@ -13,6 +13,7 @@ //===----------------------------------------------------------------------===// @preconcurrency import Crypto +import _CryptoExtras import Foundation import NIOCore import NIOFoundationCompat @@ -42,6 +43,11 @@ extension NIOSSHSignature { case ecdsaP521(P521.Signing.ECDSASignature) + // RSA signatures with different hash algorithms + case rsaSHA1(_RSA.Signing.RSASignature) // ssh-rsa (deprecated, SHA-1) + case rsaSHA256(_RSA.Signing.RSASignature) // rsa-sha2-256 + case rsaSHA512(_RSA.Signing.RSASignature) // rsa-sha2-512 + internal enum RawBytes { case byteBuffer(ByteBuffer) case data(Data) @@ -59,6 +65,15 @@ extension NIOSSHSignature { /// The prefix of a P521 ECDSA public key. fileprivate static let ecdsaP521SignaturePrefix = "ecdsa-sha2-nistp521".utf8 + + /// The prefix of an RSA signature using SHA-1 (ssh-rsa, deprecated). + internal static let rsaSHA1SignaturePrefix = "ssh-rsa".utf8 + + /// The prefix of an RSA signature using SHA-256. + internal static let rsaSHA256SignaturePrefix = "rsa-sha2-256".utf8 + + /// The prefix of an RSA signature using SHA-512. + internal static let rsaSHA512SignaturePrefix = "rsa-sha2-512".utf8 } extension NIOSSHSignature.BackingSignature.RawBytes: Equatable { @@ -93,10 +108,19 @@ extension NIOSSHSignature.BackingSignature: Equatable { return lhs.rawRepresentation == rhs.rawRepresentation case (.ecdsaP521(let lhs), .ecdsaP521(let rhs)): return lhs.rawRepresentation == rhs.rawRepresentation + case (.rsaSHA1(let lhs), .rsaSHA1(let rhs)): + return lhs.rawRepresentation == rhs.rawRepresentation + case (.rsaSHA256(let lhs), .rsaSHA256(let rhs)): + return lhs.rawRepresentation == rhs.rawRepresentation + case (.rsaSHA512(let lhs), .rsaSHA512(let rhs)): + return lhs.rawRepresentation == rhs.rawRepresentation case (.ed25519, _), (.ecdsaP256, _), (.ecdsaP384, _), - (.ecdsaP521, _): + (.ecdsaP521, _), + (.rsaSHA1, _), + (.rsaSHA256, _), + (.rsaSHA512, _): return false } } @@ -117,6 +141,15 @@ extension NIOSSHSignature.BackingSignature: Hashable { case .ecdsaP521(let sig): hasher.combine(3) hasher.combine(sig.rawRepresentation) + case .rsaSHA1(let sig): + hasher.combine(4) + hasher.combine(sig.rawRepresentation) + case .rsaSHA256(let sig): + hasher.combine(5) + hasher.combine(sig.rawRepresentation) + case .rsaSHA512(let sig): + hasher.combine(6) + hasher.combine(sig.rawRepresentation) } } } @@ -134,6 +167,12 @@ extension ByteBuffer { return self.writeECDSAP384Signature(baseSignature: sig) case .ecdsaP521(let sig): return self.writeECDSAP521Signature(baseSignature: sig) + case .rsaSHA1(let sig): + return self.writeRSASignature(baseSignature: sig, prefix: NIOSSHSignature.rsaSHA1SignaturePrefix) + case .rsaSHA256(let sig): + return self.writeRSASignature(baseSignature: sig, prefix: NIOSSHSignature.rsaSHA256SignaturePrefix) + case .rsaSHA512(let sig): + return self.writeRSASignature(baseSignature: sig, prefix: NIOSSHSignature.rsaSHA512SignaturePrefix) } } @@ -212,6 +251,13 @@ extension ByteBuffer { return writtenLength } + private mutating func writeRSASignature(baseSignature: _RSA.Signing.RSASignature, prefix: String.UTF8View) -> Int { + // RSA signature format: algorithm prefix followed by the raw signature bytes + var writtenLength = self.writeSSHString(prefix) + writtenLength += self.writeSSHString(baseSignature.rawRepresentation) + return writtenLength + } + mutating func readSSHSignature() throws -> NIOSSHSignature? { try self.rewindOnNilOrError { buffer in // The wire format always begins with an SSH string containing the signature format identifier. Let's grab that. @@ -229,11 +275,17 @@ extension ByteBuffer { return try buffer.readECDSAP384Signature() } else if bytesView.elementsEqual(NIOSSHSignature.ecdsaP521SignaturePrefix) { return try buffer.readECDSAP521Signature() + } else if bytesView.elementsEqual(NIOSSHSignature.rsaSHA1SignaturePrefix) { + return try buffer.readRSASignature(variant: .sha1) + } else if bytesView.elementsEqual(NIOSSHSignature.rsaSHA256SignaturePrefix) { + return try buffer.readRSASignature(variant: .sha256) + } else if bytesView.elementsEqual(NIOSSHSignature.rsaSHA512SignaturePrefix) { + return try buffer.readRSASignature(variant: .sha512) } else { // We don't know this signature type. let signature = signatureIdentifierBytes.readString(length: signatureIdentifierBytes.readableBytes) - ?? "" + ?? "" throw NIOSSHError.unknownSignature(algorithm: signature) } } @@ -314,6 +366,30 @@ extension ByteBuffer { backingSignature: .ecdsaP521(ECDSASignatureHelper.toECDSASignature(r: rBytes, s: sBytes)) ) } + + private enum RSASignatureVariant { + case sha1 + case sha256 + case sha512 + } + + /// A helper function that reads an RSA signature. + private mutating func readRSASignature(variant: RSASignatureVariant) throws -> NIOSSHSignature? { + guard let sigBytes = self.readSSHString() else { + return nil + } + + let signature = _RSA.Signing.RSASignature(rawRepresentation: Data(sigBytes.readableBytesView)) + + switch variant { + case .sha1: + return NIOSSHSignature(backingSignature: .rsaSHA1(signature)) + case .sha256: + return NIOSSHSignature(backingSignature: .rsaSHA256(signature)) + case .sha512: + return NIOSSHSignature(backingSignature: .rsaSHA512(signature)) + } + } } /// A structure that helps store ECDSA signatures on the stack temporarily to avoid unnecessary memory allocation. @@ -369,7 +445,7 @@ private struct ECDSASignatureHelper { extension ByteBuffer { // A view onto the mpInt bytes. Strips off a leading 0 if it is present for // size reasons. - fileprivate var mpIntView: ByteBufferView { + internal var mpIntView: ByteBufferView { var baseView = self.readableBytesView if baseView.first == 0 { baseView = baseView.dropFirst() diff --git a/Sources/NIOSSH/RSASignatureAlgorithm.swift b/Sources/NIOSSH/RSASignatureAlgorithm.swift new file mode 100644 index 00000000..105c873a --- /dev/null +++ b/Sources/NIOSSH/RSASignatureAlgorithm.swift @@ -0,0 +1,54 @@ +//===----------------------------------------------------------------------===// +// +// This source file is part of the SwiftNIO open source project +// +// Copyright (c) 2025 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 +// +//===----------------------------------------------------------------------===// + +/// The RSA signature algorithm to use for user authentication. +/// +/// Per RFC 8332, servers may support different RSA signature algorithms. +/// Modern servers prefer rsa-sha2-512, but older servers may only support ssh-rsa. +public enum RSASignatureAlgorithm: Hashable, Sendable { + /// RSA signature using SHA-512 (recommended, RFC 8332) + case sha512 + + /// RSA signature using SHA-256 (RFC 8332) + case sha256 + + /// RSA signature using SHA-1 (deprecated, legacy compatibility only) + case sha1 + + /// The algorithm name as used in SSH wire protocol + public var algorithmName: String { + switch self { + case .sha512: return "rsa-sha2-512" + case .sha256: return "rsa-sha2-256" + case .sha1: return "ssh-rsa" + } + } + + /// The algorithm name as UTF8 bytes for wire protocol + internal var wireBytes: String.UTF8View { self.algorithmName.utf8 } + + /// Initialize from wire protocol algorithm name. + /// Returns nil for unrecognized algorithm names. + public init?(algorithmName bytes: Bytes) where Bytes.Element == UInt8 { + if bytes.elementsEqual("rsa-sha2-512".utf8) { + self = .sha512 + } else if bytes.elementsEqual("rsa-sha2-256".utf8) { + self = .sha256 + } else if bytes.elementsEqual("ssh-rsa".utf8) { + self = .sha1 + } else { + return nil + } + } +} diff --git a/Sources/NIOSSH/SSHMessages.swift b/Sources/NIOSSH/SSHMessages.swift index a127d336..29233fef 100644 --- a/Sources/NIOSSH/SSHMessages.swift +++ b/Sources/NIOSSH/SSHMessages.swift @@ -149,7 +149,7 @@ extension SSHMessage { } enum PublicKeyAuthType: Equatable { - case known(key: NIOSSHPublicKey, signature: NIOSSHSignature?) + case known(key: NIOSSHPublicKey, signature: NIOSSHSignature?, rsaSignatureAlgorithm: RSASignatureAlgorithm) case unknown } @@ -698,6 +698,9 @@ extension ByteBuffer { throw NIOSSHError.invalidSSHMessage(reason: "algorithm and key mismatch in user auth request") } + // Determine RSA signature algorithm from wire algorithm name + let rsaAlgorithm = RSASignatureAlgorithm(algorithmName: algorithmName.readableBytesView) ?? .sha512 + if expectSignature { guard var signatureBytes = self.readSSHString(), let signature = try signatureBytes.readSSHSignature() @@ -705,9 +708,9 @@ extension ByteBuffer { return nil } - method = .publicKey(.known(key: publicKey, signature: signature)) + method = .publicKey(.known(key: publicKey, signature: signature, rsaSignatureAlgorithm: rsaAlgorithm)) } else { - method = .publicKey(.known(key: publicKey, signature: nil)) + method = .publicKey(.known(key: publicKey, signature: nil, rsaSignatureAlgorithm: rsaAlgorithm)) } } else { // This is not an algorithm we know. Consume the signature if we're expecting it. @@ -1370,10 +1373,10 @@ extension ByteBuffer { writtenBytes += self.writeSSHString("password".utf8) writtenBytes += self.writeSSHBoolean(false) writtenBytes += self.writeSSHString(password.utf8) - case .publicKey(.known(key: let key, signature: let signature)): + case .publicKey(.known(key: let key, signature: let signature, rsaSignatureAlgorithm: let rsaAlgorithm)): writtenBytes += self.writeSSHString("publickey".utf8) writtenBytes += self.writeSSHBoolean(signature != nil) - writtenBytes += self.writeSSHString(key.keyPrefix) + writtenBytes += self.writeSSHString(key.algorithmName(forRSA: rsaAlgorithm)) writtenBytes += self.writeCompositeSSHString { buffer in buffer.writeSSHHostKey(key) } diff --git a/Sources/NIOSSH/User Authentication/UserAuthSignablePayload.swift b/Sources/NIOSSH/User Authentication/UserAuthSignablePayload.swift index fce88345..c75fe00f 100644 --- a/Sources/NIOSSH/User Authentication/UserAuthSignablePayload.swift +++ b/Sources/NIOSSH/User Authentication/UserAuthSignablePayload.swift @@ -32,7 +32,7 @@ import NIOCore internal struct UserAuthSignablePayload { private(set) var bytes: ByteBuffer - init(sessionIdentifier: ByteBuffer, userName: String, serviceName: String, publicKey: NIOSSHPublicKey) { + init(sessionIdentifier: ByteBuffer, userName: String, serviceName: String, publicKey: NIOSSHPublicKey, rsaSignatureAlgorithm: RSASignatureAlgorithm = .sha512) { // We use the session identifier as the base buffer and just append to it. We ask for 1kB because it's likely // enough for this data. var sessionIdentifier = sessionIdentifier @@ -45,7 +45,10 @@ internal struct UserAuthSignablePayload { newBuffer.writeSSHString(serviceName.utf8) newBuffer.writeSSHString("publickey".utf8) newBuffer.writeSSHBoolean(true) - newBuffer.writeSSHString(publicKey.keyPrefix) + // For RSA keys, use the specified algorithm (RFC 8332) + // For other key types, use the standard algorithm prefix + let algorithmName = publicKey.algorithmName(forRSA: rsaSignatureAlgorithm) + newBuffer.writeSSHString(algorithmName) newBuffer.writeCompositeSSHString { buffer in buffer.writeSSHHostKey(publicKey) } diff --git a/Sources/NIOSSH/User Authentication/UserAuthenticationMethod.swift b/Sources/NIOSSH/User Authentication/UserAuthenticationMethod.swift index d66df2aa..d508a0b1 100644 --- a/Sources/NIOSSH/User Authentication/UserAuthenticationMethod.swift +++ b/Sources/NIOSSH/User Authentication/UserAuthenticationMethod.swift @@ -202,15 +202,21 @@ extension NIOSSHUserAuthenticationOffer.Offer { /// /// This is sent to the server. public var publicKey: NIOSSHPublicKey + + /// The RSA signature algorithm to use. Only applicable for RSA keys. + /// For non-RSA keys, this value is ignored. + public var rsaSignatureAlgorithm: RSASignatureAlgorithm - public init(privateKey: NIOSSHPrivateKey) { + public init(privateKey: NIOSSHPrivateKey, rsaSignatureAlgorithm: RSASignatureAlgorithm = .sha512) { self.privateKey = privateKey self.publicKey = privateKey.publicKey + self.rsaSignatureAlgorithm = rsaSignatureAlgorithm } - public init(privateKey: NIOSSHPrivateKey, certifiedKey: NIOSSHCertifiedPublicKey) { + public init(privateKey: NIOSSHPrivateKey, certifiedKey: NIOSSHCertifiedPublicKey, rsaSignatureAlgorithm: RSASignatureAlgorithm = .sha512) { self.privateKey = privateKey self.publicKey = NIOSSHPublicKey(certifiedKey) + self.rsaSignatureAlgorithm = rsaSignatureAlgorithm } } @@ -242,14 +248,16 @@ extension SSHMessage.UserAuthRequestMessage { switch request.offer { case .privateKey(let privateKeyRequest): + let rsaAlgorithm = privateKeyRequest.rsaSignatureAlgorithm let dataToSign = UserAuthSignablePayload( sessionIdentifier: sessionID, userName: self.username, serviceName: self.service, - publicKey: privateKeyRequest.publicKey + publicKey: privateKeyRequest.publicKey, + rsaSignatureAlgorithm: rsaAlgorithm ) - let signature = try privateKeyRequest.privateKey.sign(dataToSign) - self.method = .publicKey(.known(key: privateKeyRequest.publicKey, signature: signature)) + let signature = try privateKeyRequest.privateKey.sign(dataToSign, rsaSignatureAlgorithm: rsaAlgorithm) + self.method = .publicKey(.known(key: privateKeyRequest.publicKey, signature: signature, rsaSignatureAlgorithm: rsaAlgorithm)) case .password(let passwordRequest): self.method = .password(passwordRequest.password) case .hostBased: diff --git a/Sources/NIOSSH/User Authentication/UserAuthenticationStateMachine.swift b/Sources/NIOSSH/User Authentication/UserAuthenticationStateMachine.swift index 3cda5113..3cc08e8b 100644 --- a/Sources/NIOSSH/User Authentication/UserAuthenticationStateMachine.swift +++ b/Sources/NIOSSH/User Authentication/UserAuthenticationStateMachine.swift @@ -476,13 +476,14 @@ extension UserAuthenticationStateMachine { .init(outcome, supportedMethods: supportedMethods) } - case .publicKey(.known(key: let key, signature: .some(let signature))): + case .publicKey(.known(key: let key, signature: .some(let signature), rsaSignatureAlgorithm: let rsaAlgorithm)): // This is a direct request to auth, just pass it through. let dataToSign = UserAuthSignablePayload( sessionIdentifier: sessionID, userName: request.username, serviceName: request.service, - publicKey: key + publicKey: key, + rsaSignatureAlgorithm: rsaAlgorithm ) let supportedMethods = delegate.supportedAuthenticationMethods @@ -506,7 +507,7 @@ extension UserAuthenticationStateMachine { .init(outcome, supportedMethods: supportedMethods) } - case .publicKey(.known(key: let key, signature: .none)): + case .publicKey(.known(key: let key, signature: .none, rsaSignatureAlgorithm: _)): // 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. return self.loop.makeSucceededFuture(.publicKeyOK(.init(key: key))) diff --git a/Tests/NIOSSHTests/HostKeyTests.swift b/Tests/NIOSSHTests/HostKeyTests.swift index 15260a85..ada461f9 100644 --- a/Tests/NIOSSHTests/HostKeyTests.swift +++ b/Tests/NIOSSHTests/HostKeyTests.swift @@ -254,7 +254,7 @@ final class HostKeyTests: XCTestCase { func testUnrecognisedKey() throws { var buffer = ByteBufferAllocator().buffer(capacity: 1024) - buffer.writeSSHString("ssh-rsa".utf8) + buffer.writeSSHString("ssh-dss".utf8) XCTAssertThrowsError(try buffer.readSSHHostKey()) { error in XCTAssertEqual((error as? NIOSSHError).map { $0.type }, .unknownPublicKey) @@ -293,7 +293,7 @@ final class HostKeyTests: XCTestCase { func testUnrecognisedSignature() throws { var buffer = ByteBufferAllocator().buffer(capacity: 1024) - buffer.writeSSHString("ssh-rsa".utf8) + buffer.writeSSHString("ssh-dss".utf8) XCTAssertThrowsError(try buffer.readSSHSignature()) { error in XCTAssertEqual((error as? NIOSSHError).map { $0.type }, .unknownSignature) diff --git a/Tests/NIOSSHTests/RSAKeyTests.swift b/Tests/NIOSSHTests/RSAKeyTests.swift new file mode 100644 index 00000000..e4ed9d1d --- /dev/null +++ b/Tests/NIOSSHTests/RSAKeyTests.swift @@ -0,0 +1,491 @@ +//===----------------------------------------------------------------------===// +// +// This source file is part of the SwiftNIO open source project +// +// Copyright (c) 2019-2025 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 +@_spi(CryptoExtras) import _CryptoExtras +import NIOCore +import NIOFoundationCompat +import XCTest + +@testable import NIOSSH + +final class RSAKeyTests: XCTestCase { + // MARK: - Basic Signing Flow Tests (matching pattern from HostKeyTests) + + func testBasicRSASHA512SigningFlow() throws { + let rsaKey = try _RSA.Signing.PrivateKey(keySize: .bits2048) + let sshKey = NIOSSHPrivateKey(rsaKey: rsaKey) + + let digest = SHA512.hash(data: Array("hello, world!".utf8)) + let signature = try assertNoThrowWithValue(sshKey.sign(digest: digest)) + + // Naturally, this should verify. + XCTAssertNoThrow(XCTAssertTrue(sshKey.publicKey.isValidSignature(signature, for: digest))) + + // 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(sshKey.publicKey.isValidSignature(newSignature, for: digest))) + } + + func testBasicRSASHA256SigningFlow() throws { + let rsaKey = try _RSA.Signing.PrivateKey(keySize: .bits2048) + let sshKey = NIOSSHPrivateKey(rsaKey: rsaKey) + + let digest = SHA256.hash(data: Array("hello, world!".utf8)) + let signature = try assertNoThrowWithValue(sshKey.sign(digest: digest)) + + // Verify that a signature over a SHA256 digest can be validated (algorithm selection is tested below) + XCTAssertNoThrow(XCTAssertTrue(sshKey.publicKey.isValidSignature(signature, for: digest))) + } + + // MARK: - RSA Host Key Signing with Different Digest Types + // + // These tests verify that RSA keys used as host keys during key exchange produce + // signatures with the correct algorithm tag based on the digest type. + // Per RFC 8332, the signature algorithm identifier should match the hash used. + + func testRSAHostKeySignsWithSHA256Digest() throws { + // When RSA is used as a host key with P-256 or Curve25519 key exchange, + // the exchange hash is SHA-256. The signature should be tagged as rsa-sha2-256. + let rsaKey = try _RSA.Signing.PrivateKey(keySize: .bits2048) + let sshKey = NIOSSHPrivateKey(rsaKey: rsaKey) + + let digest = SHA256.hash(data: Array("simulated exchange hash".utf8)) + let signature = try sshKey.sign(digest: digest) + + // Verify the signature type is rsaSHA256, not rsaSHA512 + switch signature.backingSignature { + case .rsaSHA256: + break // Expected + case .rsaSHA512: + XCTFail("SHA-256 digest should produce rsaSHA256 signature, not rsaSHA512") + case .rsaSHA1: + XCTFail("SHA-256 digest should produce rsaSHA256 signature, not rsaSHA1") + default: + XCTFail("Expected RSA signature type") + } + + // Verify round-trip through wire format preserves the correct algorithm + var buffer = ByteBufferAllocator().buffer(capacity: 1024) + buffer.writeSSHSignature(signature) + + // Check the wire format has the correct algorithm prefix + guard let prefixLength = buffer.getInteger(at: buffer.readerIndex, as: UInt32.self), + let prefixBytes = buffer.getBytes(at: buffer.readerIndex + 4, length: Int(prefixLength)) else { + XCTFail("Failed to read signature prefix") + return + } + XCTAssertEqual(String(bytes: prefixBytes, encoding: .utf8), "rsa-sha2-256", + "Wire format should use rsa-sha2-256 for SHA-256 digest") + } + + func testRSAHostKeySignsWithSHA384Digest() throws { + // When RSA is used as a host key with P-384 key exchange, + // the exchange hash is SHA-384. Since SSH has no rsa-sha2-384, + // we should use rsa-sha2-512 (the strongest available). + let rsaKey = try _RSA.Signing.PrivateKey(keySize: .bits2048) + let sshKey = NIOSSHPrivateKey(rsaKey: rsaKey) + + let digest = SHA384.hash(data: Array("simulated exchange hash".utf8)) + let signature = try sshKey.sign(digest: digest) + + // SHA-384 should map to rsaSHA512 (closest stronger algorithm) + switch signature.backingSignature { + case .rsaSHA512: + break // Expected - no SHA-384 in SSH, use strongest available + case .rsaSHA256: + XCTFail("SHA-384 digest should produce rsaSHA512 signature (strongest available)") + case .rsaSHA1: + XCTFail("SHA-384 digest should not produce rsaSHA1 signature") + default: + XCTFail("Expected RSA signature type") + } + } + + func testRSAHostKeySignsWithSHA512Digest() throws { + // When RSA is used as a host key with P-521 key exchange, + // the exchange hash is SHA-512. The signature should be tagged as rsa-sha2-512. + let rsaKey = try _RSA.Signing.PrivateKey(keySize: .bits2048) + let sshKey = NIOSSHPrivateKey(rsaKey: rsaKey) + + let digest = SHA512.hash(data: Array("simulated exchange hash".utf8)) + let signature = try sshKey.sign(digest: digest) + + // Verify the signature type is rsaSHA512 + switch signature.backingSignature { + case .rsaSHA512: + break // Expected + case .rsaSHA256: + XCTFail("SHA-512 digest should produce rsaSHA512 signature, not rsaSHA256") + case .rsaSHA1: + XCTFail("SHA-512 digest should produce rsaSHA512 signature, not rsaSHA1") + default: + XCTFail("Expected RSA signature type") + } + + // Verify round-trip through wire format preserves the correct algorithm + var buffer = ByteBufferAllocator().buffer(capacity: 1024) + buffer.writeSSHSignature(signature) + + // Check the wire format has the correct algorithm prefix + guard let prefixLength = buffer.getInteger(at: buffer.readerIndex, as: UInt32.self), + let prefixBytes = buffer.getBytes(at: buffer.readerIndex + 4, length: Int(prefixLength)) else { + XCTFail("Failed to read signature prefix") + return + } + XCTAssertEqual(String(bytes: prefixBytes, encoding: .utf8), "rsa-sha2-512", + "Wire format should use rsa-sha2-512 for SHA-512 digest") + } + + // MARK: - RSA Signature Algorithm Selection Tests + + func testRSASignatureAlgorithmSHA512() throws { + let rsaKey = try _RSA.Signing.PrivateKey(keySize: .bits2048) + let sshKey = NIOSSHPrivateKey(rsaKey: rsaKey) + + var sessionID = ByteBufferAllocator().buffer(capacity: 32) + sessionID.writeBytes(0..<32) + + let payload = UserAuthSignablePayload( + sessionIdentifier: sessionID, + userName: "testuser", + serviceName: "ssh-connection", + publicKey: sshKey.publicKey, + rsaSignatureAlgorithm: .sha512 + ) + + let signature = try assertNoThrowWithValue(sshKey.sign(payload, rsaSignatureAlgorithm: .sha512)) + XCTAssertNoThrow(XCTAssertTrue(sshKey.publicKey.isValidSignature(signature, for: payload))) + + // Verify round-trip + var buffer = ByteBufferAllocator().buffer(capacity: 1024) + buffer.writeSSHSignature(signature) + let roundTripped = try assertNoThrowWithValue(buffer.readSSHSignature()!) + XCTAssertNoThrow(XCTAssertTrue(sshKey.publicKey.isValidSignature(roundTripped, for: payload))) + } + + func testRSASignatureAlgorithmSHA256() throws { + let rsaKey = try _RSA.Signing.PrivateKey(keySize: .bits2048) + let sshKey = NIOSSHPrivateKey(rsaKey: rsaKey) + + var sessionID = ByteBufferAllocator().buffer(capacity: 32) + sessionID.writeBytes(0..<32) + + let payload = UserAuthSignablePayload( + sessionIdentifier: sessionID, + userName: "testuser", + serviceName: "ssh-connection", + publicKey: sshKey.publicKey, + rsaSignatureAlgorithm: .sha256 + ) + + let signature = try assertNoThrowWithValue(sshKey.sign(payload, rsaSignatureAlgorithm: .sha256)) + XCTAssertNoThrow(XCTAssertTrue(sshKey.publicKey.isValidSignature(signature, for: payload))) + + // Verify round-trip + var buffer = ByteBufferAllocator().buffer(capacity: 1024) + buffer.writeSSHSignature(signature) + let roundTripped = try assertNoThrowWithValue(buffer.readSSHSignature()!) + XCTAssertNoThrow(XCTAssertTrue(sshKey.publicKey.isValidSignature(roundTripped, for: payload))) + } + + func testRSASignatureAlgorithmSHA1() throws { + let rsaKey = try _RSA.Signing.PrivateKey(keySize: .bits2048) + let sshKey = NIOSSHPrivateKey(rsaKey: rsaKey) + + var sessionID = ByteBufferAllocator().buffer(capacity: 32) + sessionID.writeBytes(0..<32) + + let payload = UserAuthSignablePayload( + sessionIdentifier: sessionID, + userName: "testuser", + serviceName: "ssh-connection", + publicKey: sshKey.publicKey, + rsaSignatureAlgorithm: .sha1 + ) + + let signature = try assertNoThrowWithValue(sshKey.sign(payload, rsaSignatureAlgorithm: .sha1)) + XCTAssertNoThrow(XCTAssertTrue(sshKey.publicKey.isValidSignature(signature, for: payload))) + + // Verify round-trip + var buffer = ByteBufferAllocator().buffer(capacity: 1024) + buffer.writeSSHSignature(signature) + let roundTripped = try assertNoThrowWithValue(buffer.readSSHSignature()!) + XCTAssertNoThrow(XCTAssertTrue(sshKey.publicKey.isValidSignature(roundTripped, for: payload))) + } + + // MARK: - Verification Failure Tests + + func testRSAFailsVerificationWithDifferentKeys() throws { + let rsaKey = try _RSA.Signing.PrivateKey(keySize: .bits2048) + let sshKey = NIOSSHPrivateKey(rsaKey: rsaKey) + + let digest = SHA512.hash(data: Array("hello, world!".utf8)) + let signature = try assertNoThrowWithValue(sshKey.sign(digest: digest)) + + let otherRSAKey = try _RSA.Signing.PrivateKey(keySize: .bits2048) + let otherSSHKey = NIOSSHPrivateKey(rsaKey: otherRSAKey) + + // Naturally, this should not verify. + XCTAssertNoThrow(XCTAssertFalse(otherSSHKey.publicKey.isValidSignature(signature, for: digest))) + + // 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(otherSSHKey.publicKey.isValidSignature(newSignature, for: digest))) + } + + func testRSAFailsVerificationWithWrongAlgorithmKeys() throws { + let rsaKey = try _RSA.Signing.PrivateKey(keySize: .bits2048) + let sshKey = NIOSSHPrivateKey(rsaKey: rsaKey) + + let digest = SHA512.hash(data: Array("hello, world!".utf8)) + let signature = try assertNoThrowWithValue(sshKey.sign(digest: digest)) + + // Try verifying with an Ed25519 key + let otherSSHKey = NIOSSHPrivateKey(ed25519Key: .init()) + + XCTAssertNoThrow(XCTAssertFalse(otherSSHKey.publicKey.isValidSignature(signature, for: digest))) + } + + func testEd25519FailsVerificationWithRSASignature() throws { + let edKey = Curve25519.Signing.PrivateKey() + let sshKey = NIOSSHPrivateKey(ed25519Key: edKey) + + let digest = SHA256.hash(data: Array("hello, world!".utf8)) + let signature = try assertNoThrowWithValue(sshKey.sign(digest: digest)) + + let rsaKey = try _RSA.Signing.PrivateKey(keySize: .bits2048) + let rsaSSHKey = NIOSSHPrivateKey(rsaKey: rsaKey) + + // RSA key should not verify Ed25519 signature + XCTAssertNoThrow(XCTAssertFalse(rsaSSHKey.publicKey.isValidSignature(signature, for: digest))) + } + + // MARK: - Public Key Wire Format Tests + + func testRSAPublicKeyRoundTrip() throws { + let rsaKey = try _RSA.Signing.PrivateKey(keySize: .bits2048) + let sshKey = NIOSSHPrivateKey(rsaKey: rsaKey) + let publicKey = sshKey.publicKey + + // Write to buffer + var buffer = ByteBufferAllocator().buffer(capacity: 1024) + buffer.writeSSHHostKey(publicKey) + + // Read back + let readKey = try assertNoThrowWithValue(buffer.readSSHHostKey()!) + + // Keys should be equal + XCTAssertEqual(publicKey, readKey) + } + + func testRSAPublicKeyPrefix() throws { + let rsaKey = try _RSA.Signing.PrivateKey(keySize: .bits2048) + let sshKey = NIOSSHPrivateKey(rsaKey: rsaKey) + + XCTAssertTrue(sshKey.publicKey.keyPrefix.elementsEqual("ssh-rsa".utf8)) + } + + // MARK: - Host Key Algorithm Tests + + func testRSAHostKeyAlgorithms() throws { + let rsaKey = try _RSA.Signing.PrivateKey(keySize: .bits2048) + let sshKey = NIOSSHPrivateKey(rsaKey: rsaKey) + + let algorithms = sshKey.hostKeyAlgorithms + XCTAssertEqual(algorithms.count, 3) + XCTAssertTrue(algorithms.contains("rsa-sha2-512")) + XCTAssertTrue(algorithms.contains("rsa-sha2-256")) + XCTAssertTrue(algorithms.contains("ssh-rsa")) + } + + // MARK: - RSASignatureAlgorithm Enum Tests + + func testRSASignatureAlgorithmInitFromWireName() { + // Valid algorithm names + XCTAssertEqual(RSASignatureAlgorithm(algorithmName: "rsa-sha2-512".utf8), .sha512) + XCTAssertEqual(RSASignatureAlgorithm(algorithmName: "rsa-sha2-256".utf8), .sha256) + XCTAssertEqual(RSASignatureAlgorithm(algorithmName: "ssh-rsa".utf8), .sha1) + + // Invalid algorithm names + XCTAssertNil(RSASignatureAlgorithm(algorithmName: "unknown".utf8)) + XCTAssertNil(RSASignatureAlgorithm(algorithmName: "".utf8)) + XCTAssertNil(RSASignatureAlgorithm(algorithmName: "RSA-SHA2-512".utf8)) // Case-sensitive + } + + func testRSASignatureAlgorithmWireNames() { + XCTAssertEqual(RSASignatureAlgorithm.sha512.algorithmName, "rsa-sha2-512") + XCTAssertEqual(RSASignatureAlgorithm.sha256.algorithmName, "rsa-sha2-256") + XCTAssertEqual(RSASignatureAlgorithm.sha1.algorithmName, "ssh-rsa") + } + + // MARK: - Different Key Sizes + + func testRSA2048KeyWorks() throws { + let rsaKey = try _RSA.Signing.PrivateKey(keySize: .bits2048) + let sshKey = NIOSSHPrivateKey(rsaKey: rsaKey) + + let digest = SHA512.hash(data: Array("test".utf8)) + let signature = try sshKey.sign(digest: digest) + + XCTAssertTrue(sshKey.publicKey.isValidSignature(signature, for: digest)) + } + + func testRSA3072KeyWorks() throws { + let rsaKey = try _RSA.Signing.PrivateKey(keySize: .bits3072) + let sshKey = NIOSSHPrivateKey(rsaKey: rsaKey) + + let digest = SHA512.hash(data: Array("test".utf8)) + let signature = try sshKey.sign(digest: digest) + + XCTAssertTrue(sshKey.publicKey.isValidSignature(signature, for: digest)) + } + + func testRSA4096KeyWorks() throws { + let rsaKey = try _RSA.Signing.PrivateKey(keySize: .bits4096) + let sshKey = NIOSSHPrivateKey(rsaKey: rsaKey) + + let digest = SHA512.hash(data: Array("test".utf8)) + let signature = try sshKey.sign(digest: digest) + + XCTAssertTrue(sshKey.publicKey.isValidSignature(signature, for: digest)) + } + + // MARK: - Signature Wire Format Tests + + func testRSASHA512SignaturePrefix() throws { + let rsaKey = try _RSA.Signing.PrivateKey(keySize: .bits2048) + let sshKey = NIOSSHPrivateKey(rsaKey: rsaKey) + + var sessionID = ByteBufferAllocator().buffer(capacity: 32) + sessionID.writeBytes(0..<32) + + let payload = UserAuthSignablePayload( + sessionIdentifier: sessionID, + userName: "testuser", + serviceName: "ssh-connection", + publicKey: sshKey.publicKey, + rsaSignatureAlgorithm: .sha512 + ) + + let signature = try sshKey.sign(payload, rsaSignatureAlgorithm: .sha512) + + var buffer = ByteBufferAllocator().buffer(capacity: 1024) + buffer.writeSSHSignature(signature) + + // Read back the algorithm prefix + guard let prefixLength = buffer.readInteger(as: UInt32.self), + let prefixBytes = buffer.readBytes(length: Int(prefixLength)) else { + XCTFail("Failed to read signature prefix") + return + } + + XCTAssertEqual(String(bytes: prefixBytes, encoding: .utf8), "rsa-sha2-512") + } + + func testRSASHA256SignaturePrefix() throws { + let rsaKey = try _RSA.Signing.PrivateKey(keySize: .bits2048) + let sshKey = NIOSSHPrivateKey(rsaKey: rsaKey) + + var sessionID = ByteBufferAllocator().buffer(capacity: 32) + sessionID.writeBytes(0..<32) + + let payload = UserAuthSignablePayload( + sessionIdentifier: sessionID, + userName: "testuser", + serviceName: "ssh-connection", + publicKey: sshKey.publicKey, + rsaSignatureAlgorithm: .sha256 + ) + + let signature = try sshKey.sign(payload, rsaSignatureAlgorithm: .sha256) + + var buffer = ByteBufferAllocator().buffer(capacity: 1024) + buffer.writeSSHSignature(signature) + + // Read back the algorithm prefix + guard let prefixLength = buffer.readInteger(as: UInt32.self), + let prefixBytes = buffer.readBytes(length: Int(prefixLength)) else { + XCTFail("Failed to read signature prefix") + return + } + + XCTAssertEqual(String(bytes: prefixBytes, encoding: .utf8), "rsa-sha2-256") + } + + func testRSASHA1SignaturePrefix() throws { + let rsaKey = try _RSA.Signing.PrivateKey(keySize: .bits2048) + let sshKey = NIOSSHPrivateKey(rsaKey: rsaKey) + + var sessionID = ByteBufferAllocator().buffer(capacity: 32) + sessionID.writeBytes(0..<32) + + let payload = UserAuthSignablePayload( + sessionIdentifier: sessionID, + userName: "testuser", + serviceName: "ssh-connection", + publicKey: sshKey.publicKey, + rsaSignatureAlgorithm: .sha1 + ) + + let signature = try sshKey.sign(payload, rsaSignatureAlgorithm: .sha1) + + var buffer = ByteBufferAllocator().buffer(capacity: 1024) + buffer.writeSSHSignature(signature) + + // Read back the algorithm prefix + guard let prefixLength = buffer.readInteger(as: UInt32.self), + let prefixBytes = buffer.readBytes(length: Int(prefixLength)) else { + XCTFail("Failed to read signature prefix") + return + } + + XCTAssertEqual(String(bytes: prefixBytes, encoding: .utf8), "ssh-rsa") + } + + // MARK: - Hashable/Equatable Tests + + func testRSAPublicKeyEquatable() throws { + let rsaKey = try _RSA.Signing.PrivateKey(keySize: .bits2048) + let sshKey = NIOSSHPrivateKey(rsaKey: rsaKey) + + // Same key should be equal + XCTAssertEqual(sshKey.publicKey, sshKey.publicKey) + + // Different key should not be equal + let otherRSAKey = try _RSA.Signing.PrivateKey(keySize: .bits2048) + let otherSSHKey = NIOSSHPrivateKey(rsaKey: otherRSAKey) + XCTAssertNotEqual(sshKey.publicKey, otherSSHKey.publicKey) + } + + func testRSAPublicKeyHashable() throws { + let rsaKey = try _RSA.Signing.PrivateKey(keySize: .bits2048) + let sshKey = NIOSSHPrivateKey(rsaKey: rsaKey) + + var set = Set() + set.insert(sshKey.publicKey) + + XCTAssertTrue(set.contains(sshKey.publicKey)) + + let otherRSAKey = try _RSA.Signing.PrivateKey(keySize: .bits2048) + let otherSSHKey = NIOSSHPrivateKey(rsaKey: otherRSAKey) + XCTAssertFalse(set.contains(otherSSHKey.publicKey)) + } +} diff --git a/Tests/NIOSSHTests/SSHMessagesTests.swift b/Tests/NIOSSHTests/SSHMessagesTests.swift index 442ab36a..d988b293 100644 --- a/Tests/NIOSSHTests/SSHMessagesTests.swift +++ b/Tests/NIOSSHTests/SSHMessagesTests.swift @@ -220,7 +220,7 @@ final class SSHMessagesTests: XCTestCase { .init( username: "test", service: "ssh-connection", - method: .publicKey(.known(key: key.publicKey, signature: nil)) + method: .publicKey(.known(key: key.publicKey, signature: nil, rsaSignatureAlgorithm: .sha512)) ) ) buffer.writeSSHMessage(message) @@ -238,7 +238,7 @@ final class SSHMessagesTests: XCTestCase { .init( username: "test", service: "ssh-connection", - method: .publicKey(.known(key: key.publicKey, signature: signature)) + method: .publicKey(.known(key: key.publicKey, signature: signature, rsaSignatureAlgorithm: .sha512)) ) ) buffer.writeSSHMessage(message) diff --git a/Tests/NIOSSHTests/UserAuthenticationStateMachineTests.swift b/Tests/NIOSSHTests/UserAuthenticationStateMachineTests.swift index 990d8f1a..072a671b 100644 --- a/Tests/NIOSSHTests/UserAuthenticationStateMachineTests.swift +++ b/Tests/NIOSSHTests/UserAuthenticationStateMachineTests.swift @@ -185,8 +185,8 @@ final class UserAuthenticationStateMachineTests: XCTestCase { // For signed methods we need to be a bit careful: we can't assume that the signature will have a bitwise match, so we have to validate it // instead. - if case .some(.publicKey(.known(let expectedKey, _))) = expectedMessage.map({ $0.method }), - case .some(.publicKey(.known(let actualKey, let actualSignature))) = request.value.map({ $0.method }), + if case .some(.publicKey(.known(let expectedKey, _, _))) = expectedMessage.map({ $0.method }), + case .some(.publicKey(.known(let actualKey, let actualSignature, _))) = request.value.map({ $0.method }), let userAuthPayload = userAuthPayload { XCTAssertEqual(expectedMessage!.username, request.value!.username) @@ -224,8 +224,8 @@ final class UserAuthenticationStateMachineTests: XCTestCase { // For signed methods we need to be a bit careful: we can't assume that the signature will have a bitwise match, so we have to validate it // instead. - if case .some(.publicKey(.known(let expectedKey, _))) = expectedMessage.map({ $0.method }), - case .some(.publicKey(.known(let actualKey, let actualSignature))) = request.value.map({ $0.method }), + if case .some(.publicKey(.known(let expectedKey, _, _))) = expectedMessage.map({ $0.method }), + case .some(.publicKey(.known(let actualKey, let actualSignature, _))) = request.value.map({ $0.method }), let userAuthPayload = userAuthPayload { XCTAssertEqual(expectedMessage!.username, request.value!.username) @@ -991,7 +991,7 @@ final class UserAuthenticationStateMachineTests: XCTestCase { let query = SSHMessage.UserAuthRequestMessage( username: "foo", service: "ssh-connection", - method: .publicKey(.known(key: self.hostKey.publicKey, signature: nil)) + method: .publicKey(.known(key: self.hostKey.publicKey, signature: nil, rsaSignatureAlgorithm: .sha512)) ) let response = SSHMessage.UserAuthPKOKMessage(key: self.hostKey.publicKey) try self.expectAuthRequestToReturnPKOKSynchronously( @@ -1012,7 +1012,7 @@ final class UserAuthenticationStateMachineTests: XCTestCase { let request = SSHMessage.UserAuthRequestMessage( username: "foo", service: "ssh-connection", - method: .publicKey(.known(key: self.hostKey.publicKey, signature: signature)) + method: .publicKey(.known(key: self.hostKey.publicKey, signature: signature, rsaSignatureAlgorithm: .sha512)) ) try self.expectAuthRequestToFailSynchronously( request: request, @@ -1038,7 +1038,7 @@ final class UserAuthenticationStateMachineTests: XCTestCase { let request2 = SSHMessage.UserAuthRequestMessage( username: "foo", service: "ssh-connection", - method: .publicKey(.known(key: newKey.publicKey, signature: newSignature)) + method: .publicKey(.known(key: newKey.publicKey, signature: newSignature, rsaSignatureAlgorithm: .sha512)) ) try self.expectAuthRequestToSucceedSynchronously(request: request2, stateMachine: &stateMachine) stateMachine.sendUserAuthSuccess() @@ -1065,7 +1065,7 @@ final class UserAuthenticationStateMachineTests: XCTestCase { let request = SSHMessage.UserAuthRequestMessage( username: "foo", service: "ssh-connection", - method: .publicKey(.known(key: self.hostKey.publicKey, signature: signature)) + method: .publicKey(.known(key: self.hostKey.publicKey, signature: signature, rsaSignatureAlgorithm: .sha512)) ) try self.expectAuthRequestToFailSynchronously( request: request, @@ -1101,7 +1101,7 @@ final class UserAuthenticationStateMachineTests: XCTestCase { let firstMessage = SSHMessage.UserAuthRequestMessage( username: "foo", service: "ssh-connection", - method: .publicKey(.known(key: delegate.key.publicKey, signature: signature)) + method: .publicKey(.known(key: delegate.key.publicKey, signature: signature, rsaSignatureAlgorithm: .sha512)) ) XCTAssertNoThrow( try self.serviceAccepted( @@ -1149,7 +1149,7 @@ final class UserAuthenticationStateMachineTests: XCTestCase { let firstMessage = SSHMessage.UserAuthRequestMessage( username: "foo", service: "ssh-connection", - method: .publicKey(.known(key: NIOSSHPublicKey(delegate.certifiedKey), signature: signature)) + method: .publicKey(.known(key: NIOSSHPublicKey(delegate.certifiedKey), signature: signature, rsaSignatureAlgorithm: .sha512)) ) XCTAssertNoThrow( try self.serviceAccepted(