← Blog

Date:Mon, 21 Sep 2026 From:Thomas Gumz <thomas@lograker.app> To:You Subject:

Adding RSA Support to swift-nio-ssh

LogRaker talks SSH through swift-nio-ssh, which is a great library with one gap: it has no RSA support. That is deliberate. Upstream supports modern primitives only, and for new deployments the newer ed25519 format is the better default.

It is also not a choice you always get to make. Amazon EC2 hands out PKCS#1 RSA .pem key pairs, and only added ed25519 key pairs in 2021. Any key pair older than that is RSA, the instances it opens are still running, and nothing about the situation is negotiable from the client side. A log viewer that cannot use the key sitting in ~/.ssh/ is not useful.

So LogRaker vendored a fork. Here is what that actually took.

There is no extension point

The obvious move would be to add the key type from outside the module. Well, you can’t. NIOSSHPrivateKey stores its key in an internal enum:

extension NIOSSHPrivateKey {
    internal enum BackingKey {
        case ed25519(Curve25519.Signing.PrivateKey)
        case ecdsaP256(P256.Signing.PrivateKey)
        // ...
    }
}

backingKey is internal, the memberwise init(backingKey:) is private, and the only public initializers are one per supported algorithm. There is no protocol to conform to and no case to add. Adding an algorithm means editing the module, which means a new fork.

Eight files, in the end: Package.swift to pull in _RSA.Signing from swift-crypto’s CryptoExtras product (it is not in Crypto), five sources under Sources/NIOSSH/, and one upstream test that had to change, because it used ssh-rsa as its example of an algorithm nobody recognizes.

The trap: two different strings

This is the part worth reading even if you never touch nio-ssh.

A publickey userauth request carries a public key blob and an algorithm name. For ed25519 and the ECDSA curves those two strings are identical, so upstream writes the key’s own tag into the algorithm-name field and never has to think about it:

writtenBytes += self.writeSSHString(key.keyPrefix)

RSA is the one algorithm where they differ. The key blob stays tagged ssh-rsa, but the algorithm name has to name the signature algorithm: rsa-sha2-256 or rsa-sha2-512, from RFC 8332. The bare ssh-rsa name means RSA with SHA-1, which OpenSSH 8.8 disabled by default in 2021. Offer that name to any current server and it is rejected outright, no matter how good your key is.

So the fork adds signatureAlgorithmName next to keyPrefix:

internal var signatureAlgorithmName: String.UTF8View {
    switch self.backingKey {
    case .rsa:
        return Self.rsaSHA512SignatureAlgorithm
    case .ed25519, .ecdsaP256, .ecdsaP384, .ecdsaP521, .certified:
        return self.keyPrefix
    }
}

The subtlety is not the property. It is that the algorithm name gets written in two places, and they have to agree byte for byte:

One is what you claim, the other is what you sign. If they diverge, every byte you send is individually well-formed, the key is correct, the signature is a valid RSA signature, and yet the server rejects it anyway. There is no error message that points at the mismatch, because from the server’s side it is indistinguishable from a wrong key. This is the kind of bug you find by writing out both buffers and diffing them.

A test now pins the two together, so the next person to touch either write site gets a failure instead of a mystery.

Which name to offer

The fork always offers rsa-sha2-512 and does not negotiate. That sounds lazy and is actually forced: nio-ssh does not parse SSH_MSG_EXT_INFO, so the server-sig-algs list never reaches any code that could act on it. There is nothing to negotiate against.

It is a safe default anyway. rsa-sha2-512 has been accepted since OpenSSH 7.2, in 2016. Anything a log viewer realistically connects to is newer than that.

One related detail, easy to miss: the fork’s list of known algorithms has to carry the key tag and both signature algorithm names, because that list is checked against the algorithm-name field of incoming userauth requests and PK_OK replies. One key tag, several legal names.

Keeping the fork honest

A vendored fork rots quietly. Three things keep this one readable:

Every edit is marked. Each hunk carries a LOGRAKER FORK: comment, so the whole diff against upstream is one grep:

grep -rn "LOGRAKER FORK" Sources/ Tests/ Package.swift

That is also the rebase checklist: fetch upstream, rebase onto the new tag, and the markers show you which conflicts are yours.

One test is load-bearing. The serialization test compares the generated public-key blob byte for byte against what ssh-keygen -y prints for the same key. Hand-rolled SSH wire format is exactly where a plausible-looking mistake survives review, and OpenSSH is the only opinion that counts.

Security advisories will not find you. This is the real cost of vendoring, and it has nothing to do with RSA. A local SwiftPM package never reports a new version, so no tool will ever tell you that upstream shipped a fix. You have to go and look:

gh api repos/apple/swift-nio-ssh/security-advisories \
  --jq '.[] | "\(.severity) \(.ghsa_id) \(.summary)"'

Put that somewhere you will actually run it.

Should you do this?

Probably not, if you have a choice. Forking a security-relevant library means you own its upstream fixes forever, and the honest summary of this fork is “we added an algorithm its authors chose to leave out”.

But the constraint was real: the keys exist, they are RSA, and they open machines people need to watch. The fork touches eight files against upstream 0.15.0, every edit marked, and it can be deleted the day nio-ssh adds RSA support of its own.

The fork is public, at lograker/swift-nio-ssh, Apache 2.0 like upstream. Read its README before depending on it: it exists to serve one app, it is pinned to 0.15.0, and nobody has audited it.