Repository: twisted/twisted

Issue:
`twist conch --auth=sshkey`  doesn't work due to passing bytes to getpwnampwd.
Just saw this on 22.8.0.rc1, with the equivalent of `twist conch --auth=sshkey` .

```
2022-08-29T07:31:45+0000 [twisted.conch.ssh.userauth.SSHUserAuthServer#critical] Error checking auth for user b'user'
	Traceback (most recent call last):
	  File "/code/env/lib/pypy3.8/site-packages/twisted/conch/ssh/userauth.py", line 285, in auth_publickey
	    return self.portal.login(c, None, interfaces.IConchUser)
	  File "/code/env/lib/pypy3.8/site-packages/twisted/cred/portal.py", line 120, in login
	    ).addCallback(self.realm.requestAvatar, mind, *interfaces)
	  File "/code/env/lib/pypy3.8/site-packages/twisted/internet/defer.py", line 531, in addCallback
	    return self.addCallbacks(callback, callbackArgs=args, callbackKeywords=kwargs)
	  File "/code/env/lib/pypy3.8/site-packages/twisted/internet/defer.py", line 511, in addCallbacks
	    self._runCallbacks()
	--- <exception caught here> ---
	  File "/code/env/lib/pypy3.8/site-packages/twisted/internet/defer.py", line 892, in _runCallbacks
	    current.result, *args, **kwargs
	  File "/code/env/lib/pypy3.8/site-packages/twisted/conch/ssh/userauth.py", line 208, in _ebMaybeBadAuth
	    reason.trap(error.NotEnoughAuthentication)
	  File "/code/env/lib/pypy3.8/site-packages/twisted/python/failure.py", line 480, in trap
	    self.raiseException()
	  File "/code/env/lib/pypy3.8/site-packages/twisted/python/failure.py", line 504, in raiseException
	    raise self.value.with_traceback(self.tb)
	  File "/code/env/lib/pypy3.8/site-packages/twisted/internet/defer.py", line 892, in _runCallbacks
	    current.result, *args, **kwargs
	  File "/code/env/lib/pypy3.8/site-packages/twisted/conch/unix.py", line 49, in requestAvatar
	    user = UnixConchUser(username)
	  File "/code/env/lib/pypy3.8/site-packages/twisted/conch/unix.py", line 57, in __init__
	    self.pwdData = pwd.getpwnam(self.username)
	builtins.TypeError: expected str, got bytes object
```

_Originally posted by @glyph in https://github.com/twisted/twisted/issues/10286#issuecomment-1229896032_

Refer to the patch for #10286 for `UserDB` typing and mocks that will be helpful.

Discussion:

Patch:
diff --git a/src/twisted/conch/unix.py b/src/twisted/conch/unix.py
index 046223e572..32f4a9c943 100644
--- a/src/twisted/conch/unix.py
+++ b/src/twisted/conch/unix.py
@@ -5,6 +5,8 @@
 A UNIX SSH server.
 """
 
+from __future__ import annotations
+
 import fcntl
 import grp
 import os
@@ -14,6 +16,7 @@ import socket
 import struct
 import time
 import tty
+from typing import Callable, Dict, Tuple
 
 from zope.interface import implementer
 
@@ -33,6 +36,7 @@ from twisted.conch.ssh.filetransfer import (
 )
 from twisted.cred import portal
 from twisted.internet.error import ProcessExitedAlready
+from twisted.internet.interfaces import IListeningPort
 from twisted.logger import Logger
 from twisted.python import components
 from twisted.python.compat import nativeString
@@ -45,13 +49,18 @@ except ImportError:
 
 @implementer(portal.IRealm)
 class UnixSSHRealm:
-    def requestAvatar(self, username, mind, *interfaces):
-        user = UnixConchUser(username)
+    def requestAvatar(
+        self,
+        username: bytes,
+        mind: object,
+        *interfaces: portal._InterfaceItself,
+    ) -> Tuple[portal._InterfaceItself, UnixConchUser, Callable[[], None]]:
+        user = UnixConchUser(username.decode())
         return interfaces[0], user, user.logout
 
 
 class UnixConchUser(ConchUser):
-    def __init__(self, username):
+    def __init__(self, username: str) -> None:
         ConchUser.__init__(self)
         self.username = username
         self.pwdData = pwd.getpwnam(self.username)
@@ -60,7 +69,9 @@ class UnixConchUser(ConchUser):
             if username in userlist:
                 l.append(gid)
         self.otherGroups = l
-        self.listeners = {}  # Dict mapping (interface, port) -> listener
+        self.listeners: Dict[
+            str, IListeningPort
+        ] = {}  # Dict mapping (interface, port) -> listener
         self.channelLookup.update(
             {
                 b"session": session.SSHSession,
@@ -116,7 +127,7 @@ class UnixConchUser(ConchUser):
         self._runAsUser(listener.stopListening)
         return 1
 
-    def logout(self):
+    def logout(self) -> None:
         # Remove all listeners.
         for listener in self.listeners.values():
             self._runAsUser(listener.stopListening)
diff --git a/src/twisted/cred/checkers.py b/src/twisted/cred/checkers.py
index 85b0b9f101..24dd40ff17 100644
--- a/src/twisted/cred/checkers.py
+++ b/src/twisted/cred/checkers.py
@@ -10,14 +10,33 @@ Basic credential checkers
 
 
 import os
+from typing import Any, Dict, Optional, Tuple, Union
 
 from zope.interface import Attribute, Interface, implementer
 
-from twisted.cred import credentials, error
+from twisted.cred import error
+from twisted.cred.credentials import (
+    IAnonymous,
+    IUsernameHashedPassword,
+    IUsernamePassword,
+)
 from twisted.internet import defer
+from twisted.internet.defer import Deferred
 from twisted.logger import Logger
 from twisted.python import failure
 
+# A note on anonymity - We do not want None as the value for anonymous
+# because it is too easy to accidentally return it.  We do not want the
+# empty string, because it is too easy to mistype a password file.  For
+# example, an .htpasswd file may contain the lines: ['hello:asdf',
+# 'world:asdf', 'goodbye', ':world'].  This misconfiguration will have an
+# ill effect in any case, but accidentally granting anonymous access is a
+# worse failure mode than simply granting access to an untypeable
+# username.  We do not want an instance of 'object', because that would
+# create potential problems with persistence.
+
+ANONYMOUS: Tuple[()] = ()
+
 
 class ICredentialsChecker(Interface):
     """
@@ -29,35 +48,22 @@ class ICredentialsChecker(Interface):
         "may check."
     )
 
-    def requestAvatarId(credentials):
+    def requestAvatarId(credentials: Any) -> Deferred[Union[bytes, Tuple[()]]]:
         """
         Validate credentials and produce an avatar ID.
 
         @param credentials: something which implements one of the interfaces in
-        C{credentialInterfaces}.
+            C{credentialInterfaces}.
 
         @return: a L{Deferred} which will fire with a L{bytes} that identifies
-        an avatar, an empty tuple to specify an authenticated anonymous user
-        (provided as L{twisted.cred.checkers.ANONYMOUS}) or fail with
-        L{UnauthorizedLogin}. Alternatively, return the result itself.
+            an avatar, an empty tuple to specify an authenticated anonymous
+            user (provided as L{twisted.cred.checkers.ANONYMOUS}) or fail with
+            L{UnauthorizedLogin}.  Alternatively, return the result itself.
 
         @see: L{twisted.cred.credentials}
         """
 
 
-# A note on anonymity - We do not want None as the value for anonymous
-# because it is too easy to accidentally return it.  We do not want the
-# empty string, because it is too easy to mistype a password file.  For
-# example, an .htpasswd file may contain the lines: ['hello:asdf',
-# 'world:asdf', 'goodbye', ':world'].  This misconfiguration will have an
-# ill effect in any case, but accidentally granting anonymous access is a
-# worse failure mode than simply granting access to an untypeable
-# username.  We do not want an instance of 'object', because that would
-# create potential problems with persistence.
-
-ANONYMOUS = ()
-
-
 @implementer(ICredentialsChecker)
 class AllowAnonymousAccess:
     """
@@ -66,7 +72,7 @@ class AllowAnonymousAccess:
     @cvar credentialInterfaces: Tuple containing L{IAnonymous}.
     """
 
-    credentialInterfaces = (credentials.IAnonymous,)
+    credentialInterfaces = (IAnonymous,)
 
     def requestAvatarId(self, credentials):
         """
@@ -97,11 +103,11 @@ class InMemoryUsernamePasswordDatabaseDontUse:
     """
 
     credentialInterfaces = (
-        credentials.IUsernamePassword,
-        credentials.IUsernameHashedPassword,
+        IUsernamePassword,
+        IUsernameHashedPassword,
     )
 
-    def __init__(self, **users):
+    def __init__(self, **users: bytes) -> None:
         """
         Initialize the in-memory database.
 
@@ -119,7 +125,7 @@ class InMemoryUsernamePasswordDatabaseDontUse:
         """
         self.users = {x.encode("ascii"): y for x, y in users.items()}
 
-    def addUser(self, username, password):
+    def addUser(self, username: bytes, password: bytes) -> None:
         """
         Set a user's password.
 
@@ -162,8 +168,8 @@ class FilePasswordDB:
     """
 
     cache = False
-    _credCache = None
-    _cacheTimestamp = 0
+    _credCache: Optional[Dict[bytes, bytes]] = None
+    _cacheTimestamp: float = 0
     _log = Logger()
 
     def __init__(
@@ -222,13 +228,13 @@ class FilePasswordDB:
             # The passwords are stored plaintext.  We can support both
             # plaintext and hashed passwords received over the network.
             self.credentialInterfaces = (
-                credentials.IUsernamePassword,
-                credentials.IUsernameHashedPassword,
+                IUsernamePassword,
+                IUsernameHashedPassword,
             )
         else:
             # The passwords are hashed on disk.  We can support only
             # plaintext passwords received over the network.
-            self.credentialInterfaces = (credentials.IUsernamePassword,)
+            self.credentialInterfaces = (IUsernamePassword,)
 
     def __getstate__(self):
         d = dict(vars(self))
@@ -271,7 +277,7 @@ class FilePasswordDB:
             self._log.error("Unable to load credentials db: {e!r}", e=e)
             raise error.UnauthorizedLogin()
 
-    def getUser(self, username):
+    def getUser(self, username: bytes) -> Tuple[bytes, bytes]:
         """
         Look up the credentials for a username.
 
@@ -302,13 +308,15 @@ class FilePasswordDB:
                     return u, p
             raise KeyError(username)
 
-    def requestAvatarId(self, c):
+    def requestAvatarId(
+        self, credentials: IUsernamePassword
+    ) -> Deferred[Union[bytes, Tuple[()]]]:
         try:
-            u, p = self.getUser(c.username)
+            u, p = self.getUser(credentials.username)
         except KeyError:
             return defer.fail(error.UnauthorizedLogin())
         else:
-            up = credentials.IUsernamePassword(c, None)
+            up = IUsernamePassword(credentials, None)
             if self.hash:
                 if up is not None:
                     h = self.hash(up.username, up.password, p)
@@ -316,7 +324,7 @@ class FilePasswordDB:
                         return defer.succeed(u)
                 return defer.fail(error.UnauthorizedLogin())
             else:
-                return defer.maybeDeferred(c.checkPassword, p).addCallback(
+                return defer.maybeDeferred(credentials.checkPassword, p).addCallback(
                     self._cbPasswordMatch, u
                 )
 
diff --git a/src/twisted/cred/credentials.py b/src/twisted/cred/credentials.py
index 37a21de4d3..30f9be2c29 100644
--- a/src/twisted/cred/credentials.py
+++ b/src/twisted/cred/credentials.py
@@ -98,7 +98,10 @@ class IUsernamePassword(ICredentials):
     @ivar password: The password associated with these credentials.
     """
 
-    def checkPassword(password):
+    username: bytes
+    password: bytes
+
+    def checkPassword(password: bytes) -> bool:
         """
         Validate these credentials against the correct password.
 
@@ -461,11 +464,11 @@ class UsernameHashedPassword:
 
 @implementer(IUsernamePassword)
 class UsernamePassword:
-    def __init__(self, username, password):
+    def __init__(self, username: bytes, password: bytes) -> None:
         self.username = username
         self.password = password
 
-    def checkPassword(self, password):
+    def checkPassword(self, password: bytes) -> bool:
         return self.password == password
 
 
diff --git a/src/twisted/cred/portal.py b/src/twisted/cred/portal.py
index 0af869dd99..f9c1e2bc72 100644
--- a/src/twisted/cred/portal.py
+++ b/src/twisted/cred/portal.py
@@ -8,13 +8,29 @@ The point of integration of application and authentication.
 """
 
 
+from typing import Callable, Dict, Iterable, List, Tuple, Type, Union
+
 from zope.interface import Interface, providedBy
 
 from twisted.cred import error
+from twisted.cred.checkers import ICredentialsChecker
+from twisted.cred.credentials import ICredentials
 from twisted.internet import defer
-from twisted.internet.defer import maybeDeferred
+from twisted.internet.defer import Deferred, maybeDeferred
 from twisted.python import failure, reflect
 
+# To say 'we need an Interface object', we have to say Type[Interface];
+# although zope.interface has no type/instance distinctions within the
+# implementation of Interface itself (subclassing it actually instantiates it),
+# since mypy-zope treats Interface objects *as* types, this is how you have to
+# treat it.
+_InterfaceItself = Type[Interface]
+
+# This is the result shape for both IRealm.requestAvatar and Portal.login,
+# although the former is optionally allowed to return synchronously and the
+# latter must be Deferred.
+_requestResult = Tuple[_InterfaceItself, object, Callable[[], None]]
+
 
 class IRealm(Interface):
     """
@@ -22,7 +38,9 @@ class IRealm(Interface):
     authentication system.
     """
 
-    def requestAvatar(avatarId, mind, *interfaces):
+    def requestAvatar(
+        avatarId: bytes, mind: object, *interfaces: _InterfaceItself
+    ) -> Union[Deferred[_requestResult], _requestResult]:
         """
         Return avatar which provides one of the given interfaces.
 
@@ -57,7 +75,11 @@ class Portal:
     in the realm object and in the credentials checker objects.
     """
 
-    def __init__(self, realm, checkers=()):
+    checkers: Dict[Type[Interface], ICredentialsChecker]
+
+    def __init__(
+        self, realm: IRealm, checkers: Iterable[ICredentialsChecker] = ()
+    ) -> None:
         """
         Create a Portal to a L{IRealm}.
         """
@@ -66,19 +88,23 @@ class Portal:
         for checker in checkers:
             self.registerChecker(checker)
 
-    def listCredentialsInterfaces(self):
+    def listCredentialsInterfaces(self) -> List[Type[Interface]]:
         """
         Return list of credentials interfaces that can be used to login.
         """
         return list(self.checkers.keys())
 
-    def registerChecker(self, checker, *credentialInterfaces):
+    def registerChecker(
+        self, checker: ICredentialsChecker, *credentialInterfaces: Type[Interface]
+    ) -> None:
         if not credentialInterfaces:
             credentialInterfaces = checker.credentialInterfaces
         for credentialInterface in credentialInterfaces:
             self.checkers[credentialInterface] = checker
 
-    def login(self, credentials, mind, *interfaces):
+    def login(
+        self, credentials: ICredentials, mind: object, *interfaces: Type[Interface]
+    ) -> Deferred[_requestResult]:
         """
         @param credentials: an implementor of
             L{twisted.cred.credentials.ICredentials}
diff --git a/src/twisted/newsfragments/11626.bugfix b/src/twisted/newsfragments/11626.bugfix
new file mode 100644
index 0000000000..cd75493d55
--- /dev/null
+++ b/src/twisted/newsfragments/11626.bugfix
@@ -0,0 +1,1 @@
+`twist conch --auth=sshkey` can now authenticate users without a traceback again, thanks to twisted.conch.unix.UnixConchUser no longer being incorrectly instantiated with `bytes`.  In the course of this fix, some type hinting has also been applied to `twisted.cred.portal`.
\ No newline at end of file
diff --git a/src/twisted/python/fakepwd.py b/src/twisted/python/fakepwd.py
index de607e9b3c..39c1100821 100644
--- a/src/twisted/python/fakepwd.py
+++ b/src/twisted/python/fakepwd.py
@@ -6,7 +6,7 @@
 L{twisted.python.fakepwd} provides a fake implementation of the L{pwd} API.
 """
 
-from typing import List
+from typing import List, Optional
 
 __all__ = ["UserDatabase", "ShadowDatabase"]
 
@@ -61,6 +61,8 @@ class UserDatabase:
     """
 
     _users: List[_UserRecord]
+    _lastUID: int = 10101
+    _lastGID: int = 20202
 
     def __init__(self) -> None:
         self._users = []
@@ -68,12 +70,12 @@ class UserDatabase:
     def addUser(
         self,
         username: str,
-        password: str,
-        uid: int,
-        gid: int,
-        gecos: str,
-        home: str,
-        shell: str,
+        password: str = "password",
+        uid: Optional[int] = None,
+        gid: Optional[int] = None,
+        gecos: str = "",
+        home: str = "",
+        shell: str = "/bin/sh",
     ) -> None:
         """
         Add a new user record to this database.
@@ -99,9 +101,14 @@ class UserDatabase:
         @param shell: The value for the C{pw_shell} field of the user record to
             add.
         """
-        self._users.append(
-            _UserRecord(username, password, uid, gid, gecos, home, shell)
-        )
+        if uid is None:
+            uid = self._lastUID
+            self._lastUID += 1
+        if gid is None:
+            gid = self._lastGID
+            self._lastGID += 1
+        newUser = _UserRecord(username, password, uid, gid, gecos, home, shell)
+        self._users.append(newUser)
 
     def getpwuid(self, uid: int) -> _UserRecord:
         """


Test patch:
diff --git a/src/twisted/conch/test/test_tap.py b/src/twisted/conch/test/test_tap.py
index 37937aef6e..e08b47c153 100644
--- a/src/twisted/conch/test/test_tap.py
+++ b/src/twisted/conch/test/test_tap.py
@@ -5,9 +5,13 @@
 Tests for L{twisted.conch.tap}.
 """
 
+from typing import Any
+
 from twisted.application.internet import StreamServerEndpointService
 from twisted.cred import error
+from twisted.cred.checkers import FilePasswordDB, ICredentialsChecker
 from twisted.cred.credentials import ISSHPrivateKey, IUsernamePassword, UsernamePassword
+from twisted.internet.defer import Deferred
 from twisted.python.reflect import requireModule
 from twisted.trial.unittest import TestCase
 
@@ -37,7 +41,7 @@ class MakeServiceTests(TestCase):
 
     usernamePassword = (b"iamuser", b"thisispassword")
 
-    def setUp(self):
+    def setUp(self) -> None:
         """
         Create a file with two users.
         """
@@ -46,7 +50,7 @@ class MakeServiceTests(TestCase):
             f.write(b":".join(self.usernamePassword))
         self.options = tap.Options()
 
-    def test_basic(self):
+    def test_basic(self) -> None:
         """
         L{tap.makeService} returns a L{StreamServerEndpointService} instance
         running on TCP port 22, and the linked protocol factory is an instance
@@ -58,7 +62,7 @@ class MakeServiceTests(TestCase):
         self.assertEqual(service.endpoint._port, 22)
         self.assertIsInstance(service.factory, OpenSSHFactory)
 
-    def test_defaultAuths(self):
+    def test_defaultAuths(self) -> None:
         """
         Make sure that if the C{--auth} command-line option is not passed,
         the default checkers are (for backwards compatibility): SSH and UNIX
@@ -81,7 +85,7 @@ class MakeServiceTests(TestCase):
             "There should be %d checkers by default" % (numCheckers,),
         )
 
-    def test_authAdded(self):
+    def test_authAdded(self) -> None:
         """
         The C{--auth} command-line option will add a checker to the list of
         checkers, and it should be the only auth checker
@@ -89,7 +93,7 @@ class MakeServiceTests(TestCase):
         self.options.parseOptions(["--auth", "file:" + self.filename])
         self.assertEqual(len(self.options["credCheckers"]), 1)
 
-    def test_multipleAuthAdded(self):
+    def test_multipleAuthAdded(self) -> None:
         """
         Multiple C{--auth} command-line options will add all checkers specified
         to the list ofcheckers, and there should only be the specified auth
@@ -105,32 +109,33 @@ class MakeServiceTests(TestCase):
         )
         self.assertEqual(len(self.options["credCheckers"]), 2)
 
-    def test_authFailure(self):
+    def test_authFailure(self) -> Any:
         """
         The checker created by the C{--auth} command-line option returns a
         L{Deferred} that fails with L{UnauthorizedLogin} when
         presented with credentials that are unknown to that checker.
         """
         self.options.parseOptions(["--auth", "file:" + self.filename])
-        checker = self.options["credCheckers"][-1]
-        invalid = UsernamePassword(self.usernamePassword[0], "fake")
+        checker: FilePasswordDB = self.options["credCheckers"][-1]
+        self.assertIsInstance(checker, FilePasswordDB)
+        invalid = UsernamePassword(self.usernamePassword[0], b"fake")
         # Wrong password should raise error
         return self.assertFailure(
             checker.requestAvatarId(invalid), error.UnauthorizedLogin
         )
 
-    def test_authSuccess(self):
+    def test_authSuccess(self) -> Deferred[None]:
         """
         The checker created by the C{--auth} command-line option returns a
         L{Deferred} that returns the avatar id when presented with credentials
         that are known to that checker.
         """
         self.options.parseOptions(["--auth", "file:" + self.filename])
-        checker = self.options["credCheckers"][-1]
+        checker: ICredentialsChecker = self.options["credCheckers"][-1]
         correct = UsernamePassword(*self.usernamePassword)
         d = checker.requestAvatarId(correct)
 
-        def checkSuccess(username):
+        def checkSuccess(username: bytes) -> None:
             self.assertEqual(username, correct.username)
 
         return d.addCallback(checkSuccess)
diff --git a/src/twisted/conch/test/test_unix.py b/src/twisted/conch/test/test_unix.py
index 6c55a36cd9..211389f399 100644
--- a/src/twisted/conch/test/test_unix.py
+++ b/src/twisted/conch/test/test_unix.py
@@ -5,13 +5,22 @@
 
 from zope.interface import implementer
 
+from twisted.conch.interfaces import IConchUser
+from twisted.cred.checkers import InMemoryUsernamePasswordDatabaseDontUse
+from twisted.cred.credentials import IUsernamePassword, UsernamePassword
+from twisted.cred.portal import Portal
 from twisted.internet.interfaces import IReactorProcess
+from twisted.python.fakepwd import UserDatabase
 from twisted.python.reflect import requireModule
 from twisted.trial import unittest
+from .test_session import StubClient, StubConnection
 
 cryptography = requireModule("cryptography")
 unix = requireModule("twisted.conch.unix")
 
+if unix is not None:
+    from twisted.conch.unix import UnixConchUser, UnixSSHRealm
+
 
 @implementer(IReactorProcess)
 class MockProcessSpawner:
@@ -52,46 +61,65 @@ class MockProcessSpawner:
         )
 
 
-class StubUnixConchUser:
-    """
-    Enough of UnixConchUser to exercise SSHSessionForUnixConchUser in the
-    tests below.
-    """
-
-    def __init__(self, homeDirectory):
-        from .test_session import StubClient, StubConnection
-
-        self._homeDirectory = homeDirectory
-        self.conn = StubConnection(transport=StubClient())
-
-    def getUserGroupId(self):
-        return (None, None)
-
-    def getHomeDir(self):
-        return self._homeDirectory
-
-    def getShell(self):
-        pass
+shouldSkip = (
+    "Cannot run without cryptography"
+    if cryptography is None
+    else "Unix system required"
+    if unix is None
+    else None
+)
 
 
 class TestSSHSessionForUnixConchUser(unittest.TestCase):
 
-    if cryptography is None:
-        skip = "Cannot run without cryptography"
-    elif unix is None:
-        skip = "Unix system required"
+    skip = shouldSkip
 
-    def testExecCommandEnvironment(self):
+    def testExecCommandEnvironment(self) -> None:
         """
         C{execCommand} sets the C{HOME} environment variable to the avatar's home
         directory.
         """
-        mockReactor = MockProcessSpawner()
+        userdb = UserDatabase()
         homeDirectory = "/made/up/path/"
-        avatar = StubUnixConchUser(homeDirectory)
+        userName = "user"
+        userdb.addUser(userName, home=homeDirectory)
+        self.patch(unix, "pwd", userdb)
+        mockReactor = MockProcessSpawner()
+        avatar = UnixConchUser(userName)
+        avatar.conn = StubConnection(transport=StubClient())
         session = unix.SSHSessionForUnixConchUser(avatar, reactor=mockReactor)
         protocol = None
         command = ["not-actually-executed"]
         session.execCommand(protocol, command)
         [call] = mockReactor._spawnProcessCalls
         self.assertEqual(homeDirectory, call["env"]["HOME"])
+
+
+class TestUnixSSHRealm(unittest.TestCase):
+    """
+    Tests for L{UnixSSHRealm}.
+    """
+
+    skip = shouldSkip
+
+    def test_unixSSHRealm(self) -> None:
+        """
+        L{UnixSSHRealm} is an L{IRealm} whose C{.requestAvatar} method returns
+        a L{UnixConchUser}.
+        """
+        userdb = UserDatabase()
+        home = "/testing/home/value"
+        userdb.addUser("user", home=home)
+        self.patch(unix, "pwd", userdb)
+        pwdb = InMemoryUsernamePasswordDatabaseDontUse(user=b"password")
+        p = Portal(UnixSSHRealm(), [pwdb])
+
+        # there seems to be a bug in mypy-zope where sometimes things don't
+        # implement their superinterfaces; 0.3.11, when we upgrade to 0.9.0
+        # this type declaration will be extraneous
+        creds: IUsernamePassword = UsernamePassword(b"user", b"password")
+        result = p.login(creds, None, IConchUser)
+        resultInterface, avatar, logout = self.successResultOf(result)
+        self.assertIsInstance(avatar, UnixConchUser)
+        assert isinstance(avatar, UnixConchUser)  # legibility for mypy
+        self.assertEqual(avatar.getHomeDir(), home)