1
# Copyright 2009-2018 Canonical Ltd. This software is licensed under the
2
# GNU Lesser General Public License version 3 (see the file LICENSE).
4
"""Custom authentication for the SSH server.
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`).
12
from __future__ import absolute_import, print_function
17
'PublicKeyFromLaunchpadChecker',
25
from twisted.conch import avatar
26
from twisted.conch.error import (
30
from twisted.conch.interfaces import IConchUser
31
from twisted.conch.ssh import (
35
from twisted.conch.ssh.common import (
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
49
from lazr.sshserver import events
50
from lazr.sshserver.session import PatchedSSHSession
51
from lazr.sshserver.sftp import FileTransferServer
57
# Private helper copied from twisted.python.compat.
60
Like L{chr} but always works on ASCII, returning L{bytes}.
62
@param i: The ASCII code point to return.
67
if sys.version_info[0] >= 3:
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
76
class NoSuchPersonWithName(xmlrpc.Fault):
77
"""There's no Person with the specified name registered in Launchpad."""
80
msg_template = 'No such person or team: %(person_name)s'
82
def __init__(self, person_name):
83
super(NoSuchPersonWithName, self).__init__(
84
self.error_code, self.msg_template % {"person_name": person_name})
87
class LaunchpadAvatar(avatar.ConchUser):
88
"""An account on the SSH server, corresponding to a Launchpad person.
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.
96
def __init__(self, user_dict):
97
"""Construct a `LaunchpadAvatar`.
99
:param user_dict: The result of a call to
100
`IAuthServer.getUserAndSSHKeys`.
102
avatar.ConchUser.__init__(self)
103
self.user_id = user_dict['id']
104
self.username = user_dict['name']
106
# Set the only channel as a standard SSH session (with a couple of bug
108
self.channelLookup = {b'session': PatchedSSHSession}
109
# ...and set the only subsystem to be SFTP.
110
self.subsystemLookup = {b'sftp': FileTransferServer}
113
notify(events.UserLoggedOut(self))
116
class UserDisplayedUnauthorizedLogin(UnauthorizedLogin):
117
"""UnauthorizedLogin which should be reported to the user."""
120
class ISSHPrivateKeyWithMind(credentials.ISSHPrivateKey):
121
"""Marker interface for SSH credentials that reference a Mind."""
124
@implementer(ISSHPrivateKeyWithMind)
125
class SSHPrivateKeyWithMind(credentials.SSHPrivateKey):
126
"""SSH credentials that also reference a Mind."""
128
def __init__(self, username, algName, blob, sigData, signature, mind):
129
credentials.SSHPrivateKey.__init__(
130
self, username, algName, blob, sigData, signature)
134
class UserDetailsMind:
135
"""A 'Mind' object that answers and caches requests for user details.
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.
146
def lookupUserDetails(self, proxy, username):
147
"""Find details for the named user, including registered SSH keys.
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.
153
:param proxy: A twisted.web.xmlrpc.Proxy object for the authentication
155
:param username: The username to look up.
157
username = username.decode('UTF-8')
158
if username in self.cache:
159
return defer.succeed(self.cache[username])
161
d = proxy.callRemote('getUserAndSSHKeys', username)
162
d.addBoth(self._add_to_cache, username)
165
def _add_to_cache(self, result, username):
166
"""Add the results to our cache."""
167
self.cache[username] = result
171
class SSHUserAuthServer(userauth.SSHUserAuthServer):
172
"""Subclass of Conch's SSHUserAuthServer to customize various behaviours.
174
There are two main differences:
176
* We override ssh_USERAUTH_REQUEST to display as a banner the reason why
177
an authentication attempt failed.
179
* We override auth_publickey to create credentials that reference a
180
UserDetailsMind and pass the same mind to self.portal.login.
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.
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'
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))
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
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
213
self.nextService = nextService
215
d = self.tryAuth(method, user, rest)
217
self._ebBadAuth(failure.Failure(ConchError('auth returned none')))
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)
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))
236
def _ebLogToBanner(self, reason):
237
reason.trap(UserDisplayedUnauthorizedLogin)
238
self.sendBanner(reason.getErrorMessage())
242
"""Return the mind that should be passed to self.portal.login().
244
If multiple requests to authenticate within this overall login attempt
245
should share state, this method can return the same mind each time.
249
def makePublicKeyCredentials(self, username, algName, blob, sigData,
251
"""Construct credentials for a request to login with a public key.
253
Our implementation returns a SSHPrivateKeyWithMind.
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.
262
mind = self.getMind()
263
return SSHPrivateKeyWithMind(
264
username, algName, blob, sigData, signature, mind)
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)
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
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:
286
b'\x00' * (pubKeyLen - len(rawSignature)) +
288
signature = NS(signatureType) + NS(rawSignature) + rest
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)
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:])
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.
316
It knows how to get the public keys from the authserver.
318
credentialInterfaces = (ISSHPrivateKeyWithMind,)
320
def __init__(self, authserver):
321
self.authserver = authserver
323
def requestAvatarId(self, credentials):
324
"""See `ICredentialsChecker`."""
325
d = defer.maybeDeferred(self._checkKey, credentials)
326
d.addCallback(self._verifyKey, credentials)
329
def _checkKey(self, credentials):
330
"""Check whether `credentials` is a valid request to authenticate.
332
We check the key data in credentials against the keys the named user
333
has registered in Launchpad.
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)
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)
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':
360
elif credentials.algName == b'ssh-rsa':
362
elif credentials.algName.startswith(b'ecdsa-sha2-'):
363
wantKeyType = 'ECDSA'
364
elif credentials.algName == b'ssh-ed25519':
365
wantKeyType = 'ED25519'
370
if len(user_dict['keys']) == 0:
371
raise UserDisplayedUnauthorizedLogin(
372
"Launchpad user '%s' doesn't have a registered SSH key"
375
for keytype, keytext in user_dict['keys']:
376
if keytype != wantKeyType:
379
if base64.b64decode(keytext) == credentials.blob:
381
except binascii.Error:
384
raise UnauthorizedLogin(
385
"Your SSH key does not match any key registered for Launchpad "
386
"user %s" % user_dict['name'])
388
def _verifyKey(self, validKey, credentials):
389
"""Check whether the credentials themselves are valid.
391
By this point, we know that the key matches the user.
394
raise UnauthorizedLogin("invalid key")
395
if not credentials.signature:
396
raise ValidPublicKey()
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")
405
raise UnauthorizedLogin("Key signature invalid.")