Marcus manages the day-to-day editorial workflow across all brands, coordinating coverage assignments and ensuring editorial standards are met. He previously served as city editor at the Pioneer Press and holds a degree in Political Science from Carleton College.
← Blog
~/blog/swift-nio-ssh-rsa-support
$
cat swift-nio-ssh-rsa-support.md
Date:
Mon, 21 Sep 2026
From:
Thomas Gumz
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:
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.