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:
SSHMessages.swift, into the wire message.UserAuthSignablePayload.swift, into the buffer that gets signed.
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.