Help with `chacha20poly1305@openssh` transport protection in swift-nio-ssh

I'm trying to build an app to be able to connect to OpenWRT routers using ssh.

I decided to use swift-no-ssh to accomplish that but on the router server the encryption algorithms are chacha20-poly1305@openssh.com, aes128-ctr and aes256-ctr while on the client swift-nio-ssh implements aes128-gcm@openssh.com and aes256-gcm@openssh.com. So following the comment of @lukasa on another thread I tried to implement my custom CHACHA20Poly1305OpenSSHTransportProtection to inject in the SSHClientConfiguration. The problem is that I just can't make sense of it, I'm not able to properly decrypt and encrypt messages. Could some kind soul lend me a hand and help me or address me towards a valid alternative? I have also read that this implementation would have be released in different packages, but I didn't find anything.

Here my surely wrong version of the implementation. I just slightly changed the default aesxxx-gcm@openssh and I was hoping the errors would point me towards a correct implementation but they were very non-descriptive so this didn't help. Also, in another thread that I don't find anymore @lukasa said swift-crypto ChaChaPoly is not suitable for this.

final class CHACHA20Poly1305OpenSSHTransportProtection: NIOSSHTransportProtection {

    private var outboundEncryptionKey: SymmetricKey
    private var inboundEncryptionKey: SymmetricKey
    private var outboundNonce: ChaChaPoly.Nonce
    private var inboundNonce: ChaChaPoly.Nonce

    static var cipherName: String {
        "chacha20-poly1305@openssh.com"
    }

    static var macName: String? {
        nil
    }

    static var cipherBlockSize: Int {
        64
    }

    static var keySizes: NIOSSH.ExpectedKeySizes {
        .init(ivSize: 12, encryptionKeySize: 32, macKeySize: 32)
    }

    var macBytes: Int {
        16
    }

    var lengthEncrypted: Bool {
        true
    }

    init(initialKeys: NIOSSH.NIOSSHSessionKeys) throws {
        guard initialKeys.outboundEncryptionKey.bitCount == Self.keySizes.encryptionKeySize * 8,
            initialKeys.inboundEncryptionKey.bitCount == Self.keySizes.encryptionKeySize * 8 else {
            struct InvalidKeySize: Error {}
            throw InvalidKeySize()
        }

        self.outboundEncryptionKey = initialKeys.outboundEncryptionKey
        self.inboundEncryptionKey = initialKeys.inboundEncryptionKey
        self.outboundNonce = try ChaChaPoly.Nonce(data: initialKeys.initialOutboundIV)
        self.inboundNonce = try ChaChaPoly.Nonce(data: initialKeys.initialOutboundIV)
    }

    func updateKeys(_ newKeys: NIOSSH.NIOSSHSessionKeys) throws {
        guard newKeys.outboundEncryptionKey.bitCount == Self.keySizes.encryptionKeySize * 8,
              newKeys.inboundEncryptionKey.bitCount == Self.keySizes.encryptionKeySize * 8 else {
            struct InvalidKeySize: Error {}
            throw InvalidKeySize()
        }

        self.outboundEncryptionKey = newKeys.outboundEncryptionKey
        self.inboundEncryptionKey = newKeys.inboundEncryptionKey
        self.outboundNonce = try ChaChaPoly.Nonce(data: newKeys.initialOutboundIV)
        self.inboundNonce = try ChaChaPoly.Nonce(data: newKeys.initialInboundIV)
    }
    
    func decryptFirstBlock(_ source: inout NIOCore.ByteBuffer) throws {
        // For ChaCha20-Poly1305, the length is encrypted along with the data, so there is no need to decrypt the first block separately.
    }
    
    func decryptAndVerifyRemainingPacket(_ source: inout NIOCore.ByteBuffer, sequenceNumber: UInt32) throws -> NIOCore.ByteBuffer {
        var plaintext: Data

        // Establish a nested scope here to avoid the byte buffer views causing an accidental CoW.
        do {
            // The first 4 bytes are the length. The last 16 are the tag. Everything else is ciphertext. We expect
            // that the ciphertext is a clean multiple of the block size, and to be non-zero.
            guard let lengthView = source.readSlice(length: 4)?.readableBytesView,
                let ciphertextView = source.readSlice(length: source.readableBytes - 16)?.readableBytesView,
                let tagView = source.readSlice(length: 16)?.readableBytesView,
                ciphertextView.count > 0, ciphertextView.count % Self.cipherBlockSize == 0 else {
                // The only way this fails is if the payload doesn't match this encryption scheme.
                struct InvalidEncryptedPacketLength: Error {}
                throw InvalidEncryptedPacketLength()
            }

            // Ok, let's try to decrypt this data.
            let sealedBox = try ChaChaPoly.SealedBox(nonce: self.inboundNonce, ciphertext: ciphertextView, tag: tagView)
            plaintext = try ChaChaPoly.open(sealedBox, using: self.inboundEncryptionKey, authenticating: lengthView)

            // All good! A quick soundness check to verify that the length of the plaintext is ok.
            guard plaintext.count % Self.cipherBlockSize == 0, plaintext.count == ciphertextView.count else {
                struct InvalidDecryptedPlaintextLength: Error {}
                throw InvalidDecryptedPlaintextLength()
            }
        }

        // Don't forget to increment the inbound nonce.
        //self.inboundNonce.increment()

        // Ok, we want to write the plaintext back into the buffer. This contains the padding length byte and the padding
        // bytes, so we want to strip those. We write back into the buffer and then slice the return value out because
        // it's highly likely that the source buffer is held uniquely, which means we can avoid an allocation.
        try plaintext.removePaddingBytes()
        source.prependData(plaintext)

        // This slice read must succeed, as we just wrote in that many bytes.
        return source.readSlice(length: plaintext.count)!
    }
    
    func encryptPacket(_ destination: inout NIOCore.ByteBuffer, sequenceNumber: UInt32) throws {
        // Keep track of where the length is going to be written.
        let packetLengthIndex = destination.readerIndex
        let packetLengthLength = MemoryLayout<UInt32>.size
        let packetPaddingIndex = destination.readerIndex

        // We encrypte everything, except the length
        let encryptedBufferSize = destination.readableBytes

        // Now we need to encrypt the data. We pass the length field as additional authenticated data, and the encrypted
        // payload portion as the data to encrypt. We know these views will be valid, so we forcibly unwrap them: if they're invalid,
        // our math was wrong and we cannot recover.
        let sealedBox = try ChaChaPoly.seal(destination.viewBytes(at: packetPaddingIndex, length: encryptedBufferSize)!,
                                         using: self.outboundEncryptionKey,
                                         nonce: self.outboundNonce,
                                         authenticating: destination.viewBytes(at: packetLengthIndex, length: packetLengthLength)!)

        assert(sealedBox.ciphertext.count == encryptedBufferSize)

        // We now want to overwrite the portion of the bytebuffer that contains the plaintext with the ciphertext,
        // and then append the tag.
        destination.setContiguousBytes(sealedBox.ciphertext, at: packetPaddingIndex)
        let tagLength = destination.writeContiguousBytes(sealedBox.tag)
        precondition(tagLength == self.macBytes, "Unexpected short tag")

        // Now we increment the Nonce for the next use, and then we're done!
        //self.outboundNonce.increment()
    }
}

So the unfortunate truth of the matter is that Swift Crypto does not give you the tools you need to properly implement chacha20-poly1305@openssh.com.

The spec for that cipher suite is stored in the OpenSSH repository. The reason that Swift Crypto's implementation is not suitable is not immediately obvious, but this sentence provides a clue:

The construction used is based on that proposed for TLS by Adam Langley in [3], but differs in the layout of data passed to the MAC and in the addition of encryption of the packet lengths.

You also have a comment here that points to the core issue:

// For ChaCha20-Poly1305, the length is encrypted along with the data, so there is no need to decrypt the first block separately.

So let's try to nail down the problem.

Swift Crypto provides ChaCha20-Poly1305 as an AEAD construction. An AEAD has two operations: seal and open. Seal takes a key, a nonce, a plaintext, and any additional authenticated data (AAD), and computes as its output a ciphertext and a tag. Open reverses the process: it takes a key, a nonce, a ciphertext, a tag, and the AAD, and will validate the tag and, assuming validation passes, return the plaintext.

Importantly, the whole point of an AEAD is that it is not possible to encrypt without integrity checking. In general, encryption without authentication is very bad and opens you up to a wide range of attacks, so AEAD constructions are designed to make it impossible to forget that authentication step. Putting this more simply: the ChaCha20-Poly1305 AEAD requires that you perform an integrity check over the ciphertext.

With that, let's look at the protocol. The first two paragraphs of the "Detailed Construction" section look like this:

The chacha20-poly1305@openssh.com cipher requires 512 bits of key material as output from the SSH key exchange. This forms two 256 bit keys (K_1 and K_2), used by two separate instances of chacha20. The first 256 bits constitute K_2 and the second 256 bits become K_1.

The instance keyed by K_1 is a stream cipher that is used only to encrypt the 4 byte packet length field. The second instance, keyed by K_2, is used in conjunction with poly1305 to build an AEAD (Authenticated Encryption with Associated Data) that is used to encrypt and authenticate the entire packet.

This reveals the crux of our issue. chacha20-poly1305@openssh.com performs ChaCha20 encryption/decryption twice, with two different keys. One instance is used without Poly1305 to encrypt the packet length field. The other is used to encrypt the payload, and also in conjunction with Poly1305 to authenticate the entire payload.

But Swift Crypto does not provide ChaCha20 as a standalone stream cipher, only as an AEAD mode. There is no API for doing encryption/decryption without integrity checking. However, the OpenSSH API requires this for the length: the integrity check of the packet length is done later.

Fundamentally, implementing this requires access to ChaCha20 and Poly1305 as standalone implementations, rather than as a combined AEAD, so that they can be combined in this unusual way. This is not a use-case that Swift Crypto is likely to support. We could consider adding it to Crypto Extras, but even that isn't a slam dunk.

In the short term, you could look for alternative implementations, or file a ticket against Swift Crypto. CryptoExtras also offers an AES block function construction, which you could use to implement AES counter mode.

8 Likes

Thanks for your response @lukasa. I saw that in Crypto Extras there is ChaCha20Ctr, could this be used to encrypt the packet length, or be a starting point to make a cipher that uses CCryptoBoringSSL_CRYPTO_chacha_20?

1 Like

Yes, you could use that. However, you will still need something for the novel Poly1305 construction.

2 Likes

I decide to opt for the aesxxx-ctr road. I was able to implement the encryption:

// Test values from https://www.rfc-editor.org/rfc/rfc3686
let aesKey = "7E 24 06 78 17 FA E0 D7 43 D6 CE 1F 32 53 91 63"
    .components(separatedBy: " ").compactMap { UInt8($0, radix: 16) }
let aesCTRIV = "C0 54 3B 59 DA 48 D9 0B"
    .components(separatedBy: " ").compactMap { UInt8($0, radix: 16) }
let nonce = "00 6C B6 DB"
    .components(separatedBy: " ").compactMap { UInt8($0, radix: 16) }
let plainText = "00 01 02 03 04 05 06 07 08 09 0A 0B 0C 0D 0E 0F 10 11 12 13 14 15 16 17 18 19 1A 1B 1C 1D 1E 1F"
    .components(separatedBy: " ").compactMap { UInt8($0, radix: 16) }
let expectedCipherText = "51 04 A1 06 16 8A 72 D9 79 0D 41 EE 8E DA D3 88 EB 2E 1E FC 46 DA 57 C8 FC E6 30 DF 91 41 BE 28"
    .components(separatedBy: " ").compactMap { UInt8($0, radix: 16) }

// Start encryption
let cipherBlockSize = 128/8
var counter: UInt32 = 1
var cypherText = [UInt8]()
let plainTextChunks = stride(from: 0, to: plainText.count, by: cipherBlockSize).map { plainText[$0 ..< min($0 + cipherBlockSize, plainText.count)] }
for plainTextChunk in plainTextChunks {
    let blockCounter: [UInt8] = [
        UInt8((counter >> 24) & 0xFF),
        UInt8((counter >> 16) & 0xFF),
        UInt8((counter >> 8) & 0xFF),
        UInt8(counter & 0xFF)
    ]
    let counterBlock = nonce + aesCTRIV + blockCounter
    var aesBlockCounter = counterBlock
    try AES.permute(&aesBlockCounter, key: SymmetricKey(data: aesKey))
    for (a, b) in zip(plainTextChunk, aesBlockCounter) {
        cypherText.append(a ^ b)
    }
    counter += 1
}

// Test

if cypherText == expectedCipherText {
    print("ALL OK!!!")
} else {
    print("NOPE.")
}
print("current result:\t\t", cypherText)
print("expecred result:\t", expectedCipherText)

I don't know if this is the best way to implement the algorithms... Anyway I'm finding problems into implement this for the NIOSSHTransportProtection implementation. Could someone explain me where I can get the nonce? NIOSSHTransportProtection let me specify only ivKeys, encryptionKeys and macKey. Surely I'm missing something, maybe I have to incorporate the nonce in the ivKeys or in the encryptionKeys. Sorry if these things may seem trivial to you, but it's the first time I'm dealing with swift-nio and encryption in general.

The relevant specification is RFC 4344, and §4 in particular:

The label -ctr indicates that the block cipher is to be used in "stateful-decryption counter" (SDCTR) mode. Let L be the block length of in bits. In stateful-decryption counter mode, both the sender and the receiver maintain an internal L-bit counter X. The initial value of X should be the initial IV (as computed in Section 7.2 of [RFC4253]) interpreted as an L-bit unsigned integer in network-byte-order. If X=(2**L)-1, then "increment X" has the traditional semantics of "set X to 0." We use the notation to mean "convert X to an L-bit string in network-byte-order." Naturally, implementations may differ in how the internal value X is stored. For example, implementations may store X as multiple unsigned 32-bit counters.

To encrypt a packet P=P1||P2||...||Pn (where P1, P2, ..., Pn are each blocks of length L), the encryptor first encrypts with to obtain a block B1. The block B1 is then XORed with P1 to generate the ciphertext block C1. The counter X is then incremented, and the process is repeated for each subsequent block in order to generate the entire ciphertext C=C1||C2||...||Cn corresponding to the packet P. Note that the counter X is not included in the ciphertext. Also note that the keystream can be pre-computed and that encryption is parallelizable.

To decrypt a ciphertext C=C1||C2||...||Cn, the decryptor (who also maintains its own copy of X) first encrypts its copy of with to generate a block B1 and then XORs B1 to C1 to get P1. The decryptor then increments its copy of the counter X and repeats the above process for each block to obtain the plaintext packet P=P1||P2||...||Pn. As before, the keystream can be pre-computed, and decryption is parallelizable.

The result here is that the IV provided to you in the session keys is the initial IV for AES CTR mode (X in the above description), and you should ask for the IV to be the same as the block size in your keySizes.

Thanks very much @lukasa for your help... I'm trying how you suggested but still, when I send my first encrypted message I receive: Error in pipeline: read(descriptor:pointer:size:): Connection reset by peer (errno: 54)
I don't understand what's going on, this is my code:

final class AES128CTRTransportProtection: NIOSSHTransportProtection {
    
    private var outboundEncryptionKey: SymmetricKey
    private var inboundEncryptionKey: SymmetricKey

    private var outboundNonce: SSHAESCTRNonce
    private var inboundNonce: SSHAESCTRNonce

    static var cipherName: String {
        "aes128-ctr"
    }

    static var macName: String? {
        nil
    }

    static var keySizes: ExpectedKeySizes {
        .init(ivSize: 16, encryptionKeySize: 16, macKeySize: 0)
    }

    required init(initialKeys: NIOSSHSessionKeys) throws {
        guard initialKeys.outboundEncryptionKey.bitCount == Self.keySizes.encryptionKeySize * 8,
            initialKeys.inboundEncryptionKey.bitCount == Self.keySizes.encryptionKeySize * 8 else {
            struct InvalidKeySize: Error {}
            throw InvalidKeySize()
        }

        self.outboundEncryptionKey = initialKeys.outboundEncryptionKey
        self.inboundEncryptionKey = initialKeys.inboundEncryptionKey
        self.outboundNonce = SSHAESCTRNonce(keyExchangeResult: initialKeys.initialOutboundIV)
        self.inboundNonce = SSHAESCTRNonce(keyExchangeResult: initialKeys.initialInboundIV)
    }

    static var cipherBlockSize: Int {
        16
    }

    var macBytes: Int {
        0
    }

    var lengthEncrypted: Bool {
        true
    }

    func updateKeys(_ newKeys: NIOSSHSessionKeys) throws {
        guard newKeys.outboundEncryptionKey.bitCount == Self.keySizes.encryptionKeySize * 8,
            newKeys.inboundEncryptionKey.bitCount == Self.keySizes.encryptionKeySize * 8 else {
            struct InvalidKeySize: Error {}
            throw InvalidKeySize()
        }

        self.outboundEncryptionKey = newKeys.outboundEncryptionKey
        self.inboundEncryptionKey = newKeys.inboundEncryptionKey
        self.outboundNonce = SSHAESCTRNonce(keyExchangeResult: newKeys.initialOutboundIV)
        self.inboundNonce = SSHAESCTRNonce(keyExchangeResult: newKeys.initialInboundIV)
    }

    func decryptFirstBlock(_: inout ByteBuffer) throws {
        // No additional processing is needed for the first block in CTR mode.
    }

    func decryptAndVerifyRemainingPacket(_ source: inout ByteBuffer, sequenceNumber _: UInt32) throws -> ByteBuffer {
        fatalError()
    }

    func encryptPacket(_ destination: inout ByteBuffer, sequenceNumber: UInt32) throws {
        var cypherText = [UInt8]()
        let plainText = destination.readableBytesView.map { $0 }
        let plainTextChunks = stride(from: 0, to: plainText.count, by: Self.cipherBlockSize).map { plainText[$0 ..< min($0 + Self.cipherBlockSize, plainText.count)] }
        for plainTextChunk in plainTextChunks {
            var aesBlockCounter = outboundNonce.blockCounter
            try AES.permute(&aesBlockCounter, key: inboundEncryptionKey)
            for (a, b) in zip(plainTextChunk, aesBlockCounter) {
                cypherText.append(a ^ b)
            }
            outboundNonce.increment()
        }
        destination.setContiguousBytes(cypherText, at: destination.readerIndex)
    }
}


// MARK: - SSHAESCTRNonce

struct SSHAESCTRNonce {
    private var baseNonceRepresentation: [UInt8]

    var blockCounter: [UInt8] {
        baseNonceRepresentation
    }

    init(keyExchangeResult: [UInt8]) {
        self.baseNonceRepresentation = keyExchangeResult
    }
}

extension SSHAESCTRNonce {
    mutating func increment() {
        withExtendedLifetime(baseNonceRepresentation) {
            baseNonceRepresentation.reverse()
            baseNonceRepresentation.withUnsafeMutableBytes { bytes in
                bytes.bindMemory(to: UInt32.self)[0] += 1
            }
            baseNonceRepresentation.reverse()
        }
    }
}

Is the peer actually supporting no MAC?

The peer supports hmac-sha2-256 and another one

Ok great, so you'll need to implement that as well. A NIOSSLTransportProtection represents the combination of a MAC and an encryption scheme, so you need to add that MAC to your code as well and advertise it.

Thanks for the infos :)
I’ll pass the cyphertext into the HMAC class, in the way is done here for the TestTransportProtection:

I’ll update you, thanks again!

@lukasa Still same error, also with new code:

final class AES128CTRTransportProtection: NIOSSHTransportProtection {

    private var outboundNonce: SSHAESCTRNonce
    private var inboundNonce: SSHAESCTRNonce

    private var outboundEncryptionKey: SymmetricKey
    private var inboundEncryptionKey: SymmetricKey

    private var outboundMACKey: SymmetricKey
    private var inboundMACKey: SymmetricKey

    static var cipherName: String {
        "aes128-ctr"
    }

    static var macName: String? {
        "hmac-sha2-256"
    }

    static var keySizes: ExpectedKeySizes {
        .init(ivSize: 16, encryptionKeySize: 16, macKeySize: 16)
    }

    required init(initialKeys: NIOSSHSessionKeys) throws {
        guard initialKeys.outboundEncryptionKey.bitCount == Self.keySizes.encryptionKeySize * 8,
            initialKeys.inboundEncryptionKey.bitCount == Self.keySizes.encryptionKeySize * 8 else {
            struct InvalidKeySize: Error {}
            throw InvalidKeySize()
        }
        self.outboundNonce = SSHAESCTRNonce(keyExchangeResult: initialKeys.initialOutboundIV)
        self.inboundNonce = SSHAESCTRNonce(keyExchangeResult: initialKeys.initialInboundIV)
        self.outboundEncryptionKey = initialKeys.outboundEncryptionKey
        self.inboundEncryptionKey = initialKeys.inboundEncryptionKey
        self.outboundMACKey = initialKeys.outboundMACKey
        self.inboundMACKey = initialKeys.inboundMACKey
    }

    static var cipherBlockSize: Int {
        16
    }

    var macBytes: Int {
        32
    }

    var lengthEncrypted: Bool {
        true
    }

    func updateKeys(_ newKeys: NIOSSHSessionKeys) throws {
        guard newKeys.outboundEncryptionKey.bitCount == Self.keySizes.encryptionKeySize * 8,
            newKeys.inboundEncryptionKey.bitCount == Self.keySizes.encryptionKeySize * 8 else {
            struct InvalidKeySize: Error {}
            throw InvalidKeySize()
        }

        self.outboundNonce = SSHAESCTRNonce(keyExchangeResult: newKeys.initialOutboundIV)
        self.inboundNonce = SSHAESCTRNonce(keyExchangeResult: newKeys.initialInboundIV)
        self.outboundEncryptionKey = newKeys.outboundEncryptionKey
        self.inboundEncryptionKey = newKeys.inboundEncryptionKey
        self.outboundMACKey = newKeys.outboundMACKey
        self.inboundMACKey = newKeys.inboundMACKey
    }

    func decryptFirstBlock(_: inout ByteBuffer) throws {
        // No additional processing is needed for the first block in CTR mode.
    }

    func decryptAndVerifyRemainingPacket(_ source: inout ByteBuffer, sequenceNumber _: UInt32) throws -> ByteBuffer {
        fatalError()
    }

    func encryptPacket(_ destination: inout ByteBuffer, sequenceNumber: UInt32) throws {
        var cypherText = [UInt8]()
        let plainText = destination.readableBytesView.map { $0 }
        let plainTextChunks = stride(from: 0, to: plainText.count, by: Self.cipherBlockSize).map { plainText[$0 ..< min($0 + Self.cipherBlockSize, plainText.count)] }
        for plainTextChunk in plainTextChunks {
            var aesBlockCounter = outboundNonce.blockCounter
            try AES.permute(&aesBlockCounter, key: inboundEncryptionKey)
            for (a, b) in zip(plainTextChunk, aesBlockCounter) {
                cypherText.append(a ^ b)
            }
            outboundNonce.increment()
        }

        var hmac = HMAC<SHA256>(key: self.outboundMACKey)
        hmac.update(data: cypherText) // also tried passing plainText (Encrypt-and-MAC)
        destination.setBytes(cypherText, at: destination.readerIndex)
        let tagLength = destination.writeBytes(hmac.finalize())
        precondition(tagLength == self.macBytes, "Unexpected short tag")
    }
}


// MARK: - SSHAESCTRNonce

struct SSHAESCTRNonce {
    private var baseNonceRepresentation: ContiguousArray<UInt8>

    var blockCounter: [UInt8] {
        Array(baseNonceRepresentation)
    }

    init(keyExchangeResult: [UInt8]) {
        self.baseNonceRepresentation = ContiguousArray(keyExchangeResult)
    }
}

extension SSHAESCTRNonce {
    mutating func increment() {
        withExtendedLifetime(baseNonceRepresentation) {
            baseNonceRepresentation.reverse()
            baseNonceRepresentation.withUnsafeMutableBytes { bytes in
                bytes.bindMemory(to: UInt32.self)[0] += 1
            }
            baseNonceRepresentation.reverse()
        }
    }
}

Can you confirm whether encryptPacket or decryptPacket are being called? Can you also add a channel handler to your pipeline that prints errorCaught?

@lukasa Yes, "encryptPacket" is called and then the error Error in pipeline: read(descriptor:pointer:size:): Connection reset by peer (errno: 54) is thrown. "decryptPacket" is never called.

This is my main:

public static func start() async throws {

        let group = MultiThreadedEventLoopGroup(numberOfThreads: 1)
        defer {
            try! group.syncShutdownGracefully()
        }


        let sshClientConfiguration = SSHClientConfiguration(
            userAuthDelegate: InteractivePasswordPromptDelegate(username: "root", password: "Passw0rd"),
            serverAuthDelegate: AcceptAllHostKeysDelegate(),
            transportProtectionSchemes: [AES128CTRTransportProtection.self]
        )
        let bootstrap = ClientBootstrap(group: group)
            .channelInitializer { channel in
                channel.pipeline.addHandlers([
                    NIOSSHHandler(role: .client(sshClientConfiguration), allocator: channel.allocator, inboundChildChannelInitializer: nil),
                    ErrorHandler()
                ])
            }
            //.channelOption(ChannelOptions.socket(SocketOptionLevel(SOL_SOCKET), SO_REUSEADDR), value: 1)
            //.channelOption(ChannelOptions.socket(SocketOptionLevel(IPPROTO_TCP), TCP_NODELAY), value: 1)

        let channel = try await bootstrap.connect(host: "192.168.1.1", port: 22).get()

        let promise = channel.eventLoop.makePromise(of: Any.self)
        await print(try promise.futureResult.get())
    }

This is my error handler:

final class ErrorHandler: ChannelInboundHandler {
    typealias InboundIn = Any

    func errorCaught(context: ChannelHandlerContext, error: Error) {
        print("Error in pipeline: \(error)")
        context.close(promise: nil)
    }
}

I call my code in this way:

struct ContentView: View {
    var body: some View {
        Text("Hello, world!")
        .task {
            print("START")
            do {
                try await UCISSHSession.start()
            } catch {
                print(error)
            }
            print("END")
        }
    }
}

and this is my complete output:

START
Thread Performance Checker: Thread running at User-initiated quality-of-service class waiting on a lower QoS thread running at Default quality-of-service class. Investigate ways to avoid priority inversions
PID: 84161, TID: 15434354
Backtrace
=================================================================
3   OpenWRT                             0x0000000102563410 $s8NIOPosix27MultiThreadedEventLoopGroupC014setupThreadAnddE033_C2B1528F4FBA68A3DBFA89DBAEBE9D4DLL4name06parentF015selectorFactory11initializerAA010SelectabledE0CSS_AcA8SelectorCyAA15NIORegistrationVGyKcyAA9NIOThreadCctFZ + 448
4   OpenWRT                             0x00000001025646e0 $s8NIOPosix27MultiThreadedEventLoopGroupC18threadInitializers13canBeShutDown0G10NamePrefix15selectorFactoryACSayyAA9NIOThreadCcG_SbSSAA8SelectorCyAA15NIORegistrationVGyKctcfcAA010SelectabledE0CyAIcXEfU_ + 672
5   OpenWRT                             0x00000001025676ac $s8NIOPosix27MultiThreadedEventLoopGroupC18threadInitializers13canBeShutDown0G10NamePrefix15selectorFactoryACSayyAA9NIOThreadCcG_SbSSAA8SelectorCyAA15NIORegistrationVGyKctcfcAA010SelectabledE0CyAIcXEfU_TA + 48
6   libswiftCore.dylib                  0x000000019326bf24 $sSlsE3mapySayqd__Gqd__7ElementQzKXEKlF + 680
7   OpenWRT                             0x000000010256439c $s8NIOPosix27MultiThreadedEventLoopGroupC18threadInitializers13canBeShutDown0G10NamePrefix15selectorFactoryACSayyAA9NIOThreadCcG_SbSSAA8SelectorCyAA15NIORegistrationVGyKctcfc + 584
8   OpenWRT                             0x0000000102564144 $s8NIOPosix27MultiThreadedEventLoopGroupC18threadInitializers13canBeShutDown0G10NamePrefix15selectorFactoryACSayyAA9NIOThreadCcG_SbSSAA8SelectorCyAA15NIORegistrationVGyKctcfC + 100
9   OpenWRT                             0x0000000102563a48 $s8NIOPosix27MultiThreadedEventLoopGroupC15numberOfThreads13canBeShutDown15selectorFactoryACSi_SbAA8SelectorCyAA15NIORegistrationVGyKctcfC + 352
10  OpenWRT                             0x0000000102563848 $s8NIOPosix27MultiThreadedEventLoopGroupC15numberOfThreadsACSi_tcfC + 48
11  OpenWRT                             0x000000010209df54 $s12UCISSHClient13UCISSHSessionV5startyyYaKFZTY0_ + 136
12  libswift_Concurrency.dylib          0x0000000222b04fd8 _ZN5swift34runJobInEstablishedExecutorContextEPNS_3JobE + 416
13  libswift_Concurrency.dylib          0x0000000222b0619c _ZL17swift_job_runImplPN5swift3JobENS_11ExecutorRefE + 72
14  libdispatch.dylib                   0x00000001035328e4 _dispatch_root_queue_drain + 404
15  libdispatch.dylib                   0x00000001035334f4 _dispatch_worker_thread2 + 188
16  libsystem_pthread.dylib             0x0000000103277d60 _pthread_wqthread + 228
17  libsystem_pthread.dylib             0x000000010327fab4 start_wqthread + 8
Error in pipeline: read(descriptor:pointer:size:): Connection reset by peer (errno: 54)

You appear to be using the wrong key to encrypt: you want the outbound encryption key, not the inbound one.