~lazr-developers/lazr.sshserver/trunk

« back to all changes in this revision

Viewing changes to src/lazr/sshserver/auth.py

  • Committer: Jürgen Gmach
  • Date: 2021-10-31 16:38:55 UTC
  • Revision ID: juergen.gmach@canonical.com-20211031163855-b2brmahmbih8ho37
Moved to git

Show diffs side-by-side

added added

removed removed

Lines of Context:
1
 
# Copyright 2009-2018 Canonical Ltd.  This software is licensed under the
2
 
# GNU Lesser General Public License version 3 (see the file LICENSE).
3
 
 
4
 
"""Custom authentication for the SSH server.
5
 
 
6
 
Launchpad's SSH server authenticates users against a XML-RPC service (see
7
 
`lp.services.authserver.interfaces.IAuthServer` and
8
 
`PublicKeyFromLaunchpadChecker`) and provides richer error messages in the
9
 
case of failed authentication (see `SSHUserAuthServer`).
10
 
"""
11
 
 
12
 
from __future__ import absolute_import, print_function
13
 
 
14
 
__metaclass__ = type
15
 
__all__ = [
16
 
    'LaunchpadAvatar',
17
 
    'PublicKeyFromLaunchpadChecker',
18
 
    'SSHUserAuthServer',
19
 
    ]
20
 
 
21
 
import base64
22
 
import binascii
23
 
import sys
24
 
 
25
 
from twisted.conch import avatar
26
 
from twisted.conch.error import (
27
 
    ConchError,
28
 
    ValidPublicKey,
29
 
    )
30
 
from twisted.conch.interfaces import IConchUser
31
 
from twisted.conch.ssh import (
32
 
    keys,
33
 
    userauth,
34
 
    )
35
 
from twisted.conch.ssh.common import (
36
 
    getNS,
37
 
    NS,
38
 
    )
39
 
from twisted.cred import credentials
40
 
from twisted.cred.checkers import ICredentialsChecker
41
 
from twisted.cred.error import UnauthorizedLogin
42
 
from twisted.internet import defer
43
 
from twisted.logger import Logger
44
 
from twisted.python import failure
45
 
from twisted.web import xmlrpc
46
 
from zope.event import notify
47
 
from zope.interface import implementer
48
 
 
49
 
from lazr.sshserver import events
50
 
from lazr.sshserver.session import PatchedSSHSession
51
 
from lazr.sshserver.sftp import FileTransferServer
52
 
 
53
 
 
54
 
log = Logger()
55
 
 
56
 
 
57
 
# Private helper copied from twisted.python.compat.
58
 
def _bytesChr(i):
59
 
    """
60
 
    Like L{chr} but always works on ASCII, returning L{bytes}.
61
 
 
62
 
    @param i: The ASCII code point to return.
63
 
    @type i: L{int}
64
 
 
65
 
    @rtype: L{bytes}
66
 
    """
67
 
    if sys.version_info[0] >= 3:
68
 
        return bytes([i])
69
 
    else:
70
 
        return chr(i)
71
 
 
72
 
 
73
 
# The error_code value must be kept in sync with
74
 
# launchpad/lib/lp/xmlrpc/faults.py.  A test in the Launchpad tree ensures
75
 
# this.
76
 
class NoSuchPersonWithName(xmlrpc.Fault):
77
 
    """There's no Person with the specified name registered in Launchpad."""
78
 
 
79
 
    error_code = 200
80
 
    msg_template = 'No such person or team: %(person_name)s'
81
 
 
82
 
    def __init__(self, person_name):
83
 
        super(NoSuchPersonWithName, self).__init__(
84
 
            self.error_code, self.msg_template % {"person_name": person_name})
85
 
 
86
 
 
87
 
class LaunchpadAvatar(avatar.ConchUser):
88
 
    """An account on the SSH server, corresponding to a Launchpad person.
89
 
 
90
 
    :ivar channelLookup: See `avatar.ConchUser`.
91
 
    :ivar subsystemLookup: See `avatar.ConchUser`.
92
 
    :ivar user_id: The Launchpad database ID of the Person for this account.
93
 
    :ivar username: The Launchpad username for this account.
94
 
    """
95
 
 
96
 
    def __init__(self, user_dict):
97
 
        """Construct a `LaunchpadAvatar`.
98
 
 
99
 
        :param user_dict: The result of a call to
100
 
            `IAuthServer.getUserAndSSHKeys`.
101
 
        """
102
 
        avatar.ConchUser.__init__(self)
103
 
        self.user_id = user_dict['id']
104
 
        self.username = user_dict['name']
105
 
 
106
 
        # Set the only channel as a standard SSH session (with a couple of bug
107
 
        # fixes).
108
 
        self.channelLookup = {b'session': PatchedSSHSession}
109
 
        # ...and set the only subsystem to be SFTP.
110
 
        self.subsystemLookup = {b'sftp': FileTransferServer}
111
 
 
112
 
    def logout(self):
113
 
        notify(events.UserLoggedOut(self))
114
 
 
115
 
 
116
 
class UserDisplayedUnauthorizedLogin(UnauthorizedLogin):
117
 
    """UnauthorizedLogin which should be reported to the user."""
118
 
 
119
 
 
120
 
class ISSHPrivateKeyWithMind(credentials.ISSHPrivateKey):
121
 
    """Marker interface for SSH credentials that reference a Mind."""
122
 
 
123
 
 
124
 
@implementer(ISSHPrivateKeyWithMind)
125
 
class SSHPrivateKeyWithMind(credentials.SSHPrivateKey):
126
 
    """SSH credentials that also reference a Mind."""
127
 
 
128
 
    def __init__(self, username, algName, blob, sigData, signature, mind):
129
 
        credentials.SSHPrivateKey.__init__(
130
 
            self, username, algName, blob, sigData, signature)
131
 
        self.mind = mind
132
 
 
133
 
 
134
 
class UserDetailsMind:
135
 
    """A 'Mind' object that answers and caches requests for user details.
136
 
 
137
 
    A mind is a (poorly named) concept from twisted.cred that basically can be
138
 
    passed to portal.login to represent the client side view of
139
 
    authentication.  In our case we attach a mind to the SSHUserAuthServer
140
 
    object that corresponds to an attempt to authenticate against the server.
141
 
    """
142
 
 
143
 
    def __init__(self):
144
 
        self.cache = {}
145
 
 
146
 
    def lookupUserDetails(self, proxy, username):
147
 
        """Find details for the named user, including registered SSH keys.
148
 
 
149
 
        This method basically wraps `IAuthServer.getUserAndSSHKeys` -- see the
150
 
        documentation of that method for more details -- and caches the
151
 
        details found for any particular user.
152
 
 
153
 
        :param proxy: A twisted.web.xmlrpc.Proxy object for the authentication
154
 
            endpoint.
155
 
        :param username: The username to look up.
156
 
        """
157
 
        username = username.decode('UTF-8')
158
 
        if username in self.cache:
159
 
            return defer.succeed(self.cache[username])
160
 
        else:
161
 
            d = proxy.callRemote('getUserAndSSHKeys', username)
162
 
            d.addBoth(self._add_to_cache, username)
163
 
            return d
164
 
 
165
 
    def _add_to_cache(self, result, username):
166
 
        """Add the results to our cache."""
167
 
        self.cache[username] = result
168
 
        return result
169
 
 
170
 
 
171
 
class SSHUserAuthServer(userauth.SSHUserAuthServer):
172
 
    """Subclass of Conch's SSHUserAuthServer to customize various behaviours.
173
 
 
174
 
    There are two main differences:
175
 
 
176
 
     * We override ssh_USERAUTH_REQUEST to display as a banner the reason why
177
 
       an authentication attempt failed.
178
 
 
179
 
     * We override auth_publickey to create credentials that reference a
180
 
       UserDetailsMind and pass the same mind to self.portal.login.
181
 
 
182
 
    Conch is not written in a way to make this easy; we've had to copy and
183
 
    paste and change the implementations of these methods.
184
 
    """
185
 
 
186
 
    def __init__(self, transport=None, banner=None):
187
 
        self.transport = transport
188
 
        self._banner = banner
189
 
        self._configured_banner_sent = False
190
 
        self._mind = UserDetailsMind()
191
 
        self.interfaceToMethod = userauth.SSHUserAuthServer.interfaceToMethod
192
 
        self.interfaceToMethod[ISSHPrivateKeyWithMind] = b'publickey'
193
 
 
194
 
    def sendBanner(self, text, language='en'):
195
 
        bytes = b'\r\n'.join(text.encode('UTF8').splitlines() + [b''])
196
 
        self.transport.sendPacket(userauth.MSG_USERAUTH_BANNER,
197
 
                                  NS(bytes) + NS(language))
198
 
 
199
 
    def _sendConfiguredBanner(self, passed_through):
200
 
        if not self._configured_banner_sent and self._banner:
201
 
            self._configured_banner_sent = True
202
 
            self.sendBanner(self._banner)
203
 
        return passed_through
204
 
 
205
 
    def ssh_USERAUTH_REQUEST(self, packet):
206
 
        # This is copied and pasted from twisted/conch/ssh/userauth.py in
207
 
        # Twisted 8.0.1. We do this so we can add _ebLogToBanner between
208
 
        # two existing errbacks.
209
 
        user, nextService, method, rest = getNS(packet, 3)
210
 
        if user != self.user or nextService != self.nextService:
211
 
            self.authenticatedWith = []  # clear auth state
212
 
        self.user = user
213
 
        self.nextService = nextService
214
 
        self.method = method
215
 
        d = self.tryAuth(method, user, rest)
216
 
        if not d:
217
 
            self._ebBadAuth(failure.Failure(ConchError('auth returned none')))
218
 
            return
219
 
        d.addCallback(self._sendConfiguredBanner)
220
 
        d.addCallback(self._cbFinishedAuth)
221
 
        d.addErrback(self._ebMaybeBadAuth)
222
 
        # This line does not appear in the original.
223
 
        d.addErrback(self._ebLogToBanner)
224
 
        d.addErrback(self._ebBadAuth)
225
 
        return d
226
 
 
227
 
    def _cbFinishedAuth(self, result):
228
 
        ret = userauth.SSHUserAuthServer._cbFinishedAuth(self, result)
229
 
        # Tell the avatar about the transport, so we can tie it to the
230
 
        # connection in the logs.
231
 
        avatar = self.transport.avatar
232
 
        avatar.transport = self.transport
233
 
        notify(events.UserLoggedIn(avatar))
234
 
        return ret
235
 
 
236
 
    def _ebLogToBanner(self, reason):
237
 
        reason.trap(UserDisplayedUnauthorizedLogin)
238
 
        self.sendBanner(reason.getErrorMessage())
239
 
        return reason
240
 
 
241
 
    def getMind(self):
242
 
        """Return the mind that should be passed to self.portal.login().
243
 
 
244
 
        If multiple requests to authenticate within this overall login attempt
245
 
        should share state, this method can return the same mind each time.
246
 
        """
247
 
        return self._mind
248
 
 
249
 
    def makePublicKeyCredentials(self, username, algName, blob, sigData,
250
 
                                 signature):
251
 
        """Construct credentials for a request to login with a public key.
252
 
 
253
 
        Our implementation returns a SSHPrivateKeyWithMind.
254
 
 
255
 
        :param username: The username the request is for.
256
 
        :param algName: The algorithm name for the blob.
257
 
        :param blob: The public key blob as sent by the client.
258
 
        :param sigData: The data the signature was made from.
259
 
        :param signature: The signed data.  This is checked to verify that the
260
 
            user owns the private key.
261
 
        """
262
 
        mind = self.getMind()
263
 
        return SSHPrivateKeyWithMind(
264
 
                username, algName, blob, sigData, signature, mind)
265
 
 
266
 
    def auth_publickey(self, packet):
267
 
        # This is copied and pasted from twisted/conch/ssh/userauth.py in
268
 
        # Twisted 8.0.1. We do this so we can customize how the credentials
269
 
        # are built and pass a mind to self.portal.login.
270
 
        hasSig = ord(packet[0:1])
271
 
        algName, blob, rest = getNS(packet[1:], 2)
272
 
        try:
273
 
            pubKey = keys.Key.fromString(blob)
274
 
        except Exception as e:
275
 
            return defer.fail(UnauthorizedLogin(str(e)))
276
 
        signature = hasSig and getNS(rest)[0] or None
277
 
        if hasSig:
278
 
            # Work around a bug in paramiko < 2.0.0: if the most significant
279
 
            # byte of an RSA signature is zero, then it strips leading zero
280
 
            # bytes rather than zero-padding it to the correct length.
281
 
            if algName == b'ssh-rsa':
282
 
                signatureType, rawSignature, rest = getNS(signature, 2)
283
 
                pubKeyLen = (pubKey.size() + 7) // 8
284
 
                if len(rawSignature) < pubKeyLen:
285
 
                    rawSignature = (
286
 
                        b'\x00' * (pubKeyLen - len(rawSignature)) +
287
 
                        rawSignature)
288
 
                    signature = NS(signatureType) + NS(rawSignature) + rest
289
 
            b = (
290
 
                NS(self.transport.sessionID) +
291
 
                _bytesChr(userauth.MSG_USERAUTH_REQUEST) + NS(self.user) +
292
 
                NS(self.nextService) + NS(b'publickey') +
293
 
                _bytesChr(hasSig) + NS(pubKey.sshType()) + NS(blob))
294
 
            # The next three lines are different from the original.
295
 
            c = self.makePublicKeyCredentials(
296
 
                self.user, algName, blob, b, signature)
297
 
            return self.portal.login(c, self.getMind(), IConchUser)
298
 
        else:
299
 
            # The next four lines are different from the original.
300
 
            c = self.makePublicKeyCredentials(
301
 
                self.user, algName, blob, None, None)
302
 
            return self.portal.login(
303
 
                c, self.getMind(), IConchUser).addErrback(
304
 
                    self._ebCheckKey, packet[1:])
305
 
 
306
 
 
307
 
# XXX cjwatson 2019-10-18: Ideally we'd use
308
 
# twisted.conch.checkers.SSHPublicKeyChecker rather than cloning-and-hacking
309
 
# it.  Unfortunately the IAuthorizedKeysDB interface doesn't allow
310
 
# getAuthorizedKeys to return a Deferred, so we have no way to do
311
 
# asynchronous work there such as talking to the authserver.
312
 
@implementer(ICredentialsChecker)
313
 
class PublicKeyFromLaunchpadChecker:
314
 
    """Cred checker for getting public keys from launchpad.
315
 
 
316
 
    It knows how to get the public keys from the authserver.
317
 
    """
318
 
    credentialInterfaces = (ISSHPrivateKeyWithMind,)
319
 
 
320
 
    def __init__(self, authserver):
321
 
        self.authserver = authserver
322
 
 
323
 
    def requestAvatarId(self, credentials):
324
 
        """See `ICredentialsChecker`."""
325
 
        d = defer.maybeDeferred(self._checkKey, credentials)
326
 
        d.addCallback(self._verifyKey, credentials)
327
 
        return d
328
 
 
329
 
    def _checkKey(self, credentials):
330
 
        """Check whether `credentials` is a valid request to authenticate.
331
 
 
332
 
        We check the key data in credentials against the keys the named user
333
 
        has registered in Launchpad.
334
 
        """
335
 
        try:
336
 
            username = credentials.username.decode('UTF-8')
337
 
        except UnicodeDecodeError:
338
 
            # Launchpad account names must be valid UTF-8.
339
 
            return defer.fail(UserDisplayedUnauthorizedLogin(
340
 
                "No such Launchpad account: %r" % credentials.username))
341
 
        d = credentials.mind.lookupUserDetails(
342
 
            self.authserver, credentials.username)
343
 
        d.addCallback(self._checkForAuthorizedKey, credentials)
344
 
        d.addErrback(self._reportNoSuchUser, username)
345
 
        return d
346
 
 
347
 
    def _reportNoSuchUser(self, failure, username):
348
 
        """Report that the given username does not exist."""
349
 
        failure.trap(xmlrpc.Fault)
350
 
        fault = failure.value
351
 
        if fault.faultCode == NoSuchPersonWithName.error_code:
352
 
            raise UserDisplayedUnauthorizedLogin(
353
 
                "No such Launchpad account: %s" % username)
354
 
        raise failure
355
 
 
356
 
    def _checkForAuthorizedKey(self, user_dict, credentials):
357
 
        """Check the key data in credentials against the keys found in LP."""
358
 
        if credentials.algName == b'ssh-dss':
359
 
            wantKeyType = 'DSA'
360
 
        elif credentials.algName == b'ssh-rsa':
361
 
            wantKeyType = 'RSA'
362
 
        elif credentials.algName.startswith(b'ecdsa-sha2-'):
363
 
            wantKeyType = 'ECDSA'
364
 
        elif credentials.algName == b'ssh-ed25519':
365
 
            wantKeyType = 'ED25519'
366
 
        else:
367
 
            # unknown key type
368
 
            return False
369
 
 
370
 
        if len(user_dict['keys']) == 0:
371
 
            raise UserDisplayedUnauthorizedLogin(
372
 
                "Launchpad user '%s' doesn't have a registered SSH key"
373
 
                % user_dict['name'])
374
 
 
375
 
        for keytype, keytext in user_dict['keys']:
376
 
            if keytype != wantKeyType:
377
 
                continue
378
 
            try:
379
 
                if base64.b64decode(keytext) == credentials.blob:
380
 
                    return True
381
 
            except binascii.Error:
382
 
                continue
383
 
 
384
 
        raise UnauthorizedLogin(
385
 
            "Your SSH key does not match any key registered for Launchpad "
386
 
            "user %s" % user_dict['name'])
387
 
 
388
 
    def _verifyKey(self, validKey, credentials):
389
 
        """Check whether the credentials themselves are valid.
390
 
 
391
 
        By this point, we know that the key matches the user.
392
 
        """
393
 
        if not validKey:
394
 
            raise UnauthorizedLogin("invalid key")
395
 
        if not credentials.signature:
396
 
            raise ValidPublicKey()
397
 
        try:
398
 
            pubKey = keys.Key.fromString(credentials.blob)
399
 
            if pubKey.verify(credentials.signature, credentials.sigData):
400
 
                return credentials.username
401
 
        except Exception:  # Any error should be treated as a failed login
402
 
            log.failure("Error while verifying key")
403
 
            raise UnauthorizedLogin("Error while verifying key")
404
 
 
405
 
        raise UnauthorizedLogin("Key signature invalid.")