8000 #12184 Add support in conch.ssh to ETM HMACs. by adiroiban · Pull Request #12186 · twisted/twisted · GitHub
[go: up one dir, main page]
More Web Proxy on the site http://driver.im/
Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

#12184 Add support in conch.ssh to ETM HMACs. #12186

Draft
wants to merge 3 commits into
base: trunk
Choose a base branch
from
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions src/twisted/conch/newsfragments/12184.feature
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
twisted.conch.ssh.transport.SSHTransportBase now supports the
`hmac-sha2-256-etm@openssh.com` and `hmac-sha2-512-etm@openssh.com` HMAC
algorithms.
217 changes: 171 additions & 46 deletions src/twisted/conch/ssh/transport.py
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,7 @@
from twisted.internet import defer, protocol
from twisted.logger import Logger
from twisted.python import randbytes
from twisted.python.compat import iterbytes, networkString
from twisted.python.compat import iterbytes

# This import is needed if SHA256 hashing is used.
# from twisted.python.compat import nativeString
Expand Down Expand Up @@ -117,6 +117,8 @@
b"none": (None, 0, modes.CBC),
}
macMap = {
b"hmac-sha2-256-etm@openssh.com": sha256,
b"hmac-sha2-512-etm@openssh.com": sha512,
b"hmac-sha2-512": sha512,
b"hmac-sha2-384": sha384,
b"hmac-sha2-256": sha256,
Expand Down Expand Up @@ -279,6 +281,24 @@
outer = hmac.HMAC(self.inMAC.key, data, self.inMAC[0]).digest()
return hmac.compare_digest(mac, outer)

def isOutMACETM(self) -> bool:
"""
@return True if outgoing MAC is ETM.
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
@return True if outgoing MAC is ETM.
@return: True if outgoing MAC is Encrypt-Then-MAC, otherwise False.

"""
return self.outMACType.endswith(b"-etm@openssh.com")
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Feedback from @glyph

Get this value cached.

We can pre-compute them when we set the key.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is outMACType intended to be a mutable attribute? Perhaps this could be computed in __init__?

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

True. Not at __init__ , but at setKeys(). I will do that. Thanks


def isInMACETM(self) -> bool:
"""
@return True if incoming MAC is ETM.
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
@return True if incoming MAC is ETM.
@return: True if incoming MAC is Encrypt-Then-MAC, otherwise False

"""
return self.inMACType.endswith(b"-etm@openssh.com")


class _InvalidPacket(Exception):
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This exception is used to share more code between ETM and MTE code.

"""
Used internally when an invalid packet was received.
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
Used internally when an invalid packet was received.
An invalid packet was received.

"""


def _getSupportedCiphers():
"""
Expand Down Expand Up @@ -454,9 +474,11 @@
# List ordered by preference.
supportedCiphers = _getSupportedCiphers()
supportedMACs = [
b"hmac-sha2-512",
b"hmac-sha2-384",
b"hmac-sha2-256-etm@openssh.com",
b"hmac-sha2-512-etm@openssh.com",
b"hmac-sha2-256",
b"hmac-sha2-384",
b"hmac-sha2-512",
b"hmac-sha1",
b"hmac-md5",
# `none`,
Expand Down Expand Up @@ -637,83 +659,186 @@
payload = self.outgoingCompression.compress(
payload
) + self.outgoingCompression.flush(2)

wirePacket = self._createPacket(payload)

self.transport.write(wirePacket)
self.outgoingPacketSequence += 1

def _createPacket(self, payload: bytes) -> bytes:
"""
Return the SSH packet at it should be sent to the peer over the wire.

`payload` is the actual data.
"""
# Start by computing the required padding.
#
# Extra 1 byte for the padding length
payloadSize = len(payload) + 1
isETM = self.currentEncryptions.isOutMACETM()

if not isETM:
# For not ETM, the packet length is also included.
payloadSize += 4

bs = self.currentEncryptions.encBlockSize
# 4 for the packet length and 1 for the padding length
totalSize = 5 + len(payload)
lenPad = bs - (totalSize % bs)
lenPad = bs - (payloadSize % bs)
if lenPad < 4:
lenPad = lenPad + bs
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
lenPad = lenPad + bs
lenPad += bs

packet = (
struct.pack("!LB", totalSize + lenPad - 4, lenPad)
+ payload
+ randbytes.secureRandom(lenPad)

# Create the unencrypted packet.
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Shouldn't this packet construction live in the currentEncryptions attribute? The only reference here to self other than self.currentEncryptions is to self.outgoingPacketSequence, so maybe the thing to do here is return self.currentEncryptions.makePacket(payload, self.outgoingPacketSequence) ?

(This is definitely a big improvement to at least get stuff into a functional shape, but pushing it a little further would put the responsibility in the right place.)

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Shouldn't this packet construction live in the currentEncryptions attribute?

Good question. This was my first refactoring.

And this is why I wanted an initial review for this draft PR.

Take a look at the first commit from the PR and let me know if you think that
return self.currentEncryptions.makePacket(payload, self.outgoingPacketSequence) looks better

My initial refactoring was to move everything into SSHCiphers... but then, this means that SSHCiphers, beside actual cipher stuff it also need to know thinkgs about SSH transport and binary packet format.

This is why in the second version I moved the code to transport.


In the end, I think that is best to keep this createPacket code into SSHTransport and not into SSHCipher

payloadPacket = (
struct.pack("!B", lenPad) + payload + randbytes.secureRandom(lenPad)
)

if isETM:
# For Encrypt-then-MAC the total size is not encrypted.
encryptedPayload = self.currentEncryptions.encrypt(payloadPacket)
totalSizePacket = struct.pack("!L", len(encryptedPayload))
macTarget = totalSizePacket + encryptedPayload
macPacket = self.currentEncryptions.makeMAC(
self.outgoingPacketSequence, macTarget
)
return macTarget + macPacket

# For non ETM, we subtract the 4 bytes added to compute the
# padding size.
# But we also encrypt the package length.
totalSizePacket = struct.pack("!L", payloadSize + lenPad - 4)
encryptedPacket = self.currentEncryptions.encrypt(
totalSizePacket + payloadPacket
)
macTarget = totalSizePacket + payloadPacket

macPacket = self.currentEncryptions.makeMAC(
self.outgoingPacketSequence, macTarget
)
encPacket = self.currentEncryptions.encrypt(
packet
) + self.currentEncryptions.makeMAC(self.outgoingPacketSequence, packet)
self.transport.write(encPacket)
self.outgoingPacketSequence += 1

def getPacket(self):
return encryptedPacket + macPacket

def getPacket(self) -> bytes | None:
"""
Try to return a decrypted, authenticated, and decompressed packet
out of the buffer. If there is not enough data, return None.

@rtype: L{str} or L{None}
@return: The decoded packet, if any.
@return: The decoded packet, if any. C{None} is returned if no packet was received yet.
"""
bs = self.currentEncryptions.decBlockSize
ms = self.currentEncryptions.verifyDigestSize
if len(self.buf) < bs:
if len(self.buf) < self.currentEncryptions.decBlockSize:
# Not enough data for a block
return

try:
if self.currentEncryptions.isInMACETM():
payload = self._getPacketETM()
else:
payload = self._getPacketMTE()
Comment on lines +731 to +734
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think it would be a lot easier to read if these ...MTE / ...ETM methods were encapsulated within their own object, so there were a protocol for the operation, then a MTE implementation and an ETM implementation, then a call to self._packetGetter.getPacket() (or something) rather than switching to one of two mostly-similar methods.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If you are going to make this a conditional though, there should still only be one assignment:

Suggested change
if self.currentEncryptions.isInMACETM():
payload = self._getPacketETM()
else:
payload = self._getPacketMTE()
payload = self._getPacketETM() if self.currentEncryptions.isInMACETM() else self._getPacketMTE()
F438

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks.

I will see what we can do... in the future, we will also have an AES-GCM implementation.

so inline if / else is not ok for the long term.

I went with these simple methods inside the SSHTranport as I don't feel that is that much code to move into an object.


except _InvalidPacket as error:
self.sendDisconnect(
DISCONNECT_PROTOCOL_ERROR,
error.args[0].encode("ascii"),
)
return
Comment on lines +736 to +741
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If we are always discarding the traceback from this new exception, it might be better expressed as an addition to the union; less stack-walking that way, more explicit that it's a possibility. i.e. payload: bytes | None | Literal[Invalid.packet] and then if packet is Invalid.packet: ?

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We don't keep the traceback, but we extract the error message.

I think that what is missing here, is self._log.failure(error.args[0]).

This should make it similar to the other error exception handling.


I prefer to use the exception here as I would like to have the error message, and I feel that raising an exceptoin is the "standard" way of signaling an invalid packet while also having an error details.


From my experience, the whole log calls from the SSH transport are not well designed.

For my production code, I ignore them and I have various custom hooks to add logs.

Improving the logs would be a different tasks for conch.ssh


if payload is None:
# No packet received yet.
return

if self.incomingCompression:
try:
payload = self.incomingCompression.decompress(payload)
except Exception:
self._log.failure("Error decompressing payload")
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

love to see the new logging system used as intended :)

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

As far as I know, this code is already in trunk.

I don't have much exprience with Twisted log system.

I remember that at some point I tried to document the new logging system, how to use and how to write test for it, but it wasn't a succesfull PR.

self.sendDisconnect(DISCONNECT_COMPRESSION_ERROR, b"compression error")
return

Check warning on line 753 in src/twisted/conch/ssh/transport.py

View check run for this annotation

Codecov / codecov/patch

src/twisted/conch/ssh/transport.py#L750-L753

Added lines #L750 - L753 were not covered by tests

self.incomingPacketSequence += 1
return payload

def _getPacketMTE(self) -> bytes | None:
"""
Handle decoding a packet when mac then encrypt.

@return: None if no packet was yet received.
"""
bs = self.currentEncryptions.decBlockSize
ms = self.currentEncryptions.verifyDigestSize

if not hasattr(self, "first"):
first = self.currentEncryptions.decrypt(self.buf[:bs])
else:
first = self.first
del self.first
packetLen, paddingLen = struct.unpack("!LB", first[:5])
if packetLen > 1048576: # 1024 ** 2
self.sendDisconnect(
DISCONNECT_PROTOCOL_ERROR,
networkString(f"bad packet length {packetLen}"),
)
return
raise _InvalidPacket(f"bad packet length {packetLen}")

if len(self.buf) < packetLen + 4 + ms:
# Not enough data for a packet
self.first = first
return
if (packetLen + 4) % bs != 0:
self.sendDisconnect(
DISCONNECT_PROTOCOL_ERROR,
networkString(
"bad packet mod (%i%%%i == %i)"
% (packetLen + 4, bs, (packetLen + 4) % bs)
),
raise _InvalidPacket(
"bad packet mod ({}%{} == {})".format(
packetLen + 4, bs, (packetLen + 4) % bs
)
)
return

encData, self.buf = self.buf[: 4 + packetLen], self.buf[4 + packetLen :]
packet = first + self.currentEncryptions.decrypt(encData[bs:])
if len(packet) != 4 + packetLen:
self.sendDisconnect(DISCONNECT_PROTOCOL_ERROR, b"bad decryption")
return
raise _InvalidPacket("bad decryption")

Check warning on line 790 in src/twisted/conch/ssh/transport.py

View check run for this annotation

Codecov / codecov/patch

src/twisted/conch/ssh/transport.py#L790

Added line #L790 was not covered by tests

if ms:
macData, self.buf = self.buf[:ms], self.buf[ms:]
if not self.currentEncryptions.verify(
self.incomingPacketSequence, packet, macData
):
self.sendDisconnect(DISCONNECT_MAC_ERROR, b"bad MAC")
return
raise _InvalidPacket("bad MAC for MTE")

Check warning on line 797 in src/twisted/conch/ssh/transport.py

View check run for this annotation

Codecov / codecov/patch

src/twisted/conch/ssh/transport.py#L797

Added line #L797 was not covered by tests

payload = packet[5:-paddingLen]
if self.incomingCompression:
try:
payload = self.incomingCompression.decompress(payload)
except Exception:
# Tolerate any errors in decompression
self._log.failure("Error decompressing payload")
self.sendDisconnect(DISCONNECT_COMPRESSION_ERROR, b"compression error")
return
self.incomingPacketSequence += 1
return payload

def _getPacketETM(self) -> bytes | None:
"""
Handle decoding a packet when encrypt then MAC is used.

@return: None if no packet was yet received.
"""
bs = self.currentEncryptions.decBlockSize
ms = self.currentEncryptions.verifyDigestSize

packetLen = struct.unpack("!L", self.buf[:4])[0]

if packetLen > 1048576: # 1024 ** 2
raise _InvalidPacket(f"bad packet length {packetLen}")

Check warning on line 814 in src/twisted/conch/ssh/transport.py

View check run for this annotation

Codecov / codecov/patch

src/twisted/conch/ssh/transport.py#L814

Added line #L814 was not covered by tests

if packetLen % bs != 0:
raise _InvalidPacket(

Check warning on line 817 in src/twisted/conch/ssh/transport.py

View check run for this annotation

Codecov / codecov/patch

src/twisted/conch/ssh/transport.py#L817

Added line #L817 was not covered by tests
"bad packet mod ({}%{} == {})".format(packetLen, bs, packetLen % bs)
)

if len(self.buf) < packetLen + ms:
# Not enough data for a packet
return

# Consume the encrypted data from the buffer.
#
# The first 4 bytes are the package lenght field.
encData, self.buf = self.buf[: 4 + packetLen], self.buf[4 + packetLen :]
packet = self.currentEncryptions.decrypt(encData[4:])
if len(packet) != packetLen:
raise _InvalidPacket("bad decryption")

Check warning on line 831 in src/twisted/conch/ssh/transport.py

View check run for this annotation

Codecov / codecov/patch

src/twisted/conch/ssh/transport.py#L831

Added line #L831 was not covered by tests

# Consume the MAC data from the buffer.
macData, self.buf = self.buf[:ms], self.buf[ms:]
if not self.currentEncryptions.verify(
self.incomingPacketSequence, encData, macData
):
raise _InvalidPacket("bad MAC for ETM")

Check warning on line 838 in src/twisted/conch/ssh/transport.py

View check run for this annotation

Codecov / codecov/patch

src/twisted/conch/ssh/transport.py#L838

Added line #L838 was not covered by tests

paddingLen = struct.unpack("!B", packet[:1])[0]
payload = packet[1:-paddingLen]
return payload

def _unsupportedVersionReceived(self, remoteVersion):
Expand Down
23 changes: 9 additions & 14 deletions src/twisted/conch/test/test_transport.py
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@
from twisted import __version__ as twisted_version
from twisted.conch.error import ConchError
from twisted.conch.ssh import _kex, address, service
from twisted.conch.ssh.transport import SSHCiphers
from twisted.internet import defer
from twisted.protocols import loopback
from twisted.python import randbytes
Expand Down Expand Up @@ -148,24 +149,18 @@ def ssh_IGNORE(self, packet):
self.ignoreds.append(packet)


class MockCipher:
class MockCipher(SSHCiphers):
"""
A mocked-up version of twisted.conch.ssh.transport.SSHCiphers.
"""

outCipType = b"test"
encBlockSize = 6
inCipType = b"test"
decBlockSize = 6
inMACType = b"test"
outMACType = b"test"
verifyDigestSize = 1
usedEncrypt = False
usedDecrypt = False
outMAC = (None, b"", b"", 1)
inMAC = (None, b"", b"", 1)
keys = ()

def __init__(self, outCip=b"test", inCip=b"test", outMac=b"test", inMac=b"test"):
super().__init__(outCip, inCip, outMac, inMac)
self.encBlockSize += 6
self.decBlockSize += 6

def encrypt(self, x):
"""
Called to encrypt the packet. Simply record that encryption was used
Expand Down Expand Up @@ -656,11 +651,11 @@ def test_getPacketEncrypted(self):
proto.currentEncryptions = testCipher = MockCipher()
proto.sendPacket(ord("A"), b"BCD")
value = self.transport.value()
proto.buf = value[: MockCipher.decBlockSize]
proto.buf = value[: testCipher.decBlockSize]
self.assertIsNone(proto.getPacket())
self.assertTrue(testCipher.usedDecrypt)
self.assertEqual(proto.first, b"\x00\x00\x00\x0e\x09A")
proto.buf += value[MockCipher.decBlockSize :]
proto.buf += value[testCipher.decBlockSize :]
self.assertEqual(proto.getPacket(), b"ABCD")
self.assertEqual(proto.buf, b"")

Expand Down
Loading
0