From 51607386c7609a483568ad935083c9668fe6241b Mon Sep 17 00:00:00 2001 From: Robey Pointer Date: Tue, 4 Nov 2003 08:34:24 +0000 Subject: [PATCH] [project @ Arch-1:robey@lag.net--2003-public%secsh--dev--1.0--base-0] initial import (automatically generated log message) --- LICENSE | 504 ++++++++++++++++++++++++++++++ MANIFEST | 13 + Makefile | 22 ++ NOTES | 13 + README | 166 ++++++++++ auth_transport.py | 224 ++++++++++++++ ber.py | 112 +++++++ channel.py | 608 +++++++++++++++++++++++++++++++++++++ demo-server.py | 56 ++++ demo.py | 180 +++++++++++ dsskey.py | 121 ++++++++ kex_gex.py | 180 +++++++++++ kex_group1.py | 104 +++++++ message.py | 119 ++++++++ rsakey.py | 102 +++++++ secsh.py | 23 ++ setup.py | 30 ++ transport.py | 758 ++++++++++++++++++++++++++++++++++++++++++++++ util.py | 89 ++++++ 19 files changed, 3424 insertions(+) create mode 100644 LICENSE create mode 100644 MANIFEST create mode 100644 Makefile create mode 100644 NOTES create mode 100644 README create mode 100644 auth_transport.py create mode 100644 ber.py create mode 100644 channel.py create mode 100755 demo-server.py create mode 100755 demo.py create mode 100644 dsskey.py create mode 100644 kex_gex.py create mode 100644 kex_group1.py create mode 100644 message.py create mode 100644 rsakey.py create mode 100644 secsh.py create mode 100644 setup.py create mode 100644 transport.py create mode 100644 util.py diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..b1e3f5a --- /dev/null +++ b/LICENSE @@ -0,0 +1,504 @@ + GNU LESSER GENERAL PUBLIC LICENSE + Version 2.1, February 1999 + + Copyright (C) 1991, 1999 Free Software Foundation, Inc. + 59 Temple Place, Suite 330, Boston, MA 02111-1307 USA + Everyone is permitted to copy and distribute verbatim copies + of this license document, but changing it is not allowed. + +[This is the first released version of the Lesser GPL. It also counts + as the successor of the GNU Library Public License, version 2, hence + the version number 2.1.] + + Preamble + + The licenses for most software are designed to take away your +freedom to share and change it. By contrast, the GNU General Public +Licenses are intended to guarantee your freedom to share and change +free software--to make sure the software is free for all its users. + + This license, the Lesser General Public License, applies to some +specially designated software packages--typically libraries--of the +Free Software Foundation and other authors who decide to use it. You +can use it too, but we suggest you first think carefully about whether +this license or the ordinary General Public License is the better +strategy to use in any particular case, based on the explanations below. + + When we speak of free software, we are referring to freedom of use, +not price. Our General Public Licenses are designed to make sure that +you have the freedom to distribute copies of free software (and charge +for this service if you wish); that you receive source code or can get +it if you want it; that you can change the software and use pieces of +it in new free programs; and that you are informed that you can do +these things. + + To protect your rights, we need to make restrictions that forbid +distributors to deny you these rights or to ask you to surrender these +rights. These restrictions translate to certain responsibilities for +you if you distribute copies of the library or if you modify it. + + For example, if you distribute copies of the library, whether gratis +or for a fee, you must give the recipients all the rights that we gave +you. You must make sure that they, too, receive or can get the source +code. If you link other code with the library, you must provide +complete object files to the recipients, so that they can relink them +with the library after making changes to the library and recompiling +it. And you must show them these terms so they know their rights. + + We protect your rights with a two-step method: (1) we copyright the +library, and (2) we offer you this license, which gives you legal +permission to copy, distribute and/or modify the library. + + To protect each distributor, we want to make it very clear that +there is no warranty for the free library. Also, if the library is +modified by someone else and passed on, the recipients should know +that what they have is not the original version, so that the original +author's reputation will not be affected by problems that might be +introduced by others. + + Finally, software patents pose a constant threat to the existence of +any free program. We wish to make sure that a company cannot +effectively restrict the users of a free program by obtaining a +restrictive license from a patent holder. Therefore, we insist that +any patent license obtained for a version of the library must be +consistent with the full freedom of use specified in this license. + + Most GNU software, including some libraries, is covered by the +ordinary GNU General Public License. This license, the GNU Lesser +General Public License, applies to certain designated libraries, and +is quite different from the ordinary General Public License. We use +this license for certain libraries in order to permit linking those +libraries into non-free programs. + + When a program is linked with a library, whether statically or using +a shared library, the combination of the two is legally speaking a +combined work, a derivative of the original library. The ordinary +General Public License therefore permits such linking only if the +entire combination fits its criteria of freedom. The Lesser General +Public License permits more lax criteria for linking other code with +the library. + + We call this license the "Lesser" General Public License because it +does Less to protect the user's freedom than the ordinary General +Public License. It also provides other free software developers Less +of an advantage over competing non-free programs. These disadvantages +are the reason we use the ordinary General Public License for many +libraries. However, the Lesser license provides advantages in certain +special circumstances. + + For example, on rare occasions, there may be a special need to +encourage the widest possible use of a certain library, so that it becomes +a de-facto standard. To achieve this, non-free programs must be +allowed to use the library. A more frequent case is that a free +library does the same job as widely used non-free libraries. In this +case, there is little to gain by limiting the free library to free +software only, so we use the Lesser General Public License. + + In other cases, permission to use a particular library in non-free +programs enables a greater number of people to use a large body of +free software. For example, permission to use the GNU C Library in +non-free programs enables many more people to use the whole GNU +operating system, as well as its variant, the GNU/Linux operating +system. + + Although the Lesser General Public License is Less protective of the +users' freedom, it does ensure that the user of a program that is +linked with the Library has the freedom and the wherewithal to run +that program using a modified version of the Library. + + The precise terms and conditions for copying, distribution and +modification follow. Pay close attention to the difference between a +"work based on the library" and a "work that uses the library". The +former contains code derived from the library, whereas the latter must +be combined with the library in order to run. + + GNU LESSER GENERAL PUBLIC LICENSE + TERMS AND CONDITIONS FOR COPYING, DISTRIBUTION AND MODIFICATION + + 0. This License Agreement applies to any software library or other +program which contains a notice placed by the copyright holder or +other authorized party saying it may be distributed under the terms of +this Lesser General Public License (also called "this License"). +Each licensee is addressed as "you". + + A "library" means a collection of software functions and/or data +prepared so as to be conveniently linked with application programs +(which use some of those functions and data) to form executables. + + The "Library", below, refers to any such software library or work +which has been distributed under these terms. A "work based on the +Library" means either the Library or any derivative work under +copyright law: that is to say, a work containing the Library or a +portion of it, either verbatim or with modifications and/or translated +straightforwardly into another language. (Hereinafter, translation is +included without limitation in the term "modification".) + + "Source code" for a work means the preferred form of the work for +making modifications to it. For a library, complete source code means +all the source code for all modules it contains, plus any associated +interface definition files, plus the scripts used to control compilation +and installation of the library. + + Activities other than copying, distribution and modification are not +covered by this License; they are outside its scope. The act of +running a program using the Library is not restricted, and output from +such a program is covered only if its contents constitute a work based +on the Library (independent of the use of the Library in a tool for +writing it). Whether that is true depends on what the Library does +and what the program that uses the Library does. + + 1. You may copy and distribute verbatim copies of the Library's +complete source code as you receive it, in any medium, provided that +you conspicuously and appropriately publish on each copy an +appropriate copyright notice and disclaimer of warranty; keep intact +all the notices that refer to this License and to the absence of any +warranty; and distribute a copy of this License along with the +Library. + + You may charge a fee for the physical act of transferring a copy, +and you may at your option offer warranty protection in exchange for a +fee. + + 2. You may modify your copy or copies of the Library or any portion +of it, thus forming a work based on the Library, and copy and +distribute such modifications or work under the terms of Section 1 +above, provided that you also meet all of these conditions: + + a) The modified work must itself be a software library. + + b) You must cause the files modified to carry prominent notices + stating that you changed the files and the date of any change. + + c) You must cause the whole of the work to be licensed at no + charge to all third parties under the terms of this License. + + d) If a facility in the modified Library refers to a function or a + table of data to be supplied by an application program that uses + the facility, other than as an argument passed when the facility + is invoked, then you must make a good faith effort to ensure that, + in the event an application does not supply such function or + table, the facility still operates, and performs whatever part of + its purpose remains meaningful. + + (For example, a function in a library to compute square roots has + a purpose that is entirely well-defined independent of the + application. Therefore, Subsection 2d requires that any + application-supplied function or table used by this function must + be optional: if the application does not supply it, the square + root function must still compute square roots.) + +These requirements apply to the modified work as a whole. If +identifiable sections of that work are not derived from the Library, +and can be reasonably considered independent and separate works in +themselves, then this License, and its terms, do not apply to those +sections when you distribute them as separate works. But when you +distribute the same sections as part of a whole which is a work based +on the Library, the distribution of the whole must be on the terms of +this License, whose permissions for other licensees extend to the +entire whole, and thus to each and every part regardless of who wrote +it. + +Thus, it is not the intent of this section to claim rights or contest +your rights to work written entirely by you; rather, the intent is to +exercise the right to control the distribution of derivative or +collective works based on the Library. + +In addition, mere aggregation of another work not based on the Library +with the Library (or with a work based on the Library) on a volume of +a storage or distribution medium does not bring the other work under +the scope of this License. + + 3. You may opt to apply the terms of the ordinary GNU General Public +License instead of this License to a given copy of the Library. To do +this, you must alter all the notices that refer to this License, so +that they refer to the ordinary GNU General Public License, version 2, +instead of to this License. (If a newer version than version 2 of the +ordinary GNU General Public License has appeared, then you can specify +that version instead if you wish.) Do not make any other change in +these notices. + + Once this change is made in a given copy, it is irreversible for +that copy, so the ordinary GNU General Public License applies to all +subsequent copies and derivative works made from that copy. + + This option is useful when you wish to copy part of the code of +the Library into a program that is not a library. + + 4. You may copy and distribute the Library (or a portion or +derivative of it, under Section 2) in object code or executable form +under the terms of Sections 1 and 2 above provided that you accompany +it with the complete corresponding machine-readable source code, which +must be distributed under the terms of Sections 1 and 2 above on a +medium customarily used for software interchange. + + If distribution of object code is made by offering access to copy +from a designated place, then offering equivalent access to copy the +source code from the same place satisfies the requirement to +distribute the source code, even though third parties are not +compelled to copy the source along with the object code. + + 5. A program that contains no derivative of any portion of the +Library, but is designed to work with the Library by being compiled or +linked with it, is called a "work that uses the Library". Such a +work, in isolation, is not a derivative work of the Library, and +therefore falls outside the scope of this License. + + However, linking a "work that uses the Library" with the Library +creates an executable that is a derivative of the Library (because it +contains portions of the Library), rather than a "work that uses the +library". The executable is therefore covered by this License. +Section 6 states terms for distribution of such executables. + + When a "work that uses the Library" uses material from a header file +that is part of the Library, the object code for the work may be a +derivative work of the Library even though the source code is not. +Whether this is true is especially significant if the work can be +linked without the Library, or if the work is itself a library. The +threshold for this to be true is not precisely defined by law. + + If such an object file uses only numerical parameters, data +structure layouts and accessors, and small macros and small inline +functions (ten lines or less in length), then the use of the object +file is unrestricted, regardless of whether it is legally a derivative +work. (Executables containing this object code plus portions of the +Library will still fall under Section 6.) + + Otherwise, if the work is a derivative of the Library, you may +distribute the object code for the work under the terms of Section 6. +Any executables containing that work also fall under Section 6, +whether or not they are linked directly with the Library itself. + + 6. As an exception to the Sections above, you may also combine or +link a "work that uses the Library" with the Library to produce a +work containing portions of the Library, and distribute that work +under terms of your choice, provided that the terms permit +modification of the work for the customer's own use and reverse +engineering for debugging such modifications. + + You must give prominent notice with each copy of the work that the +Library is used in it and that the Library and its use are covered by +this License. You must supply a copy of this License. If the work +during execution displays copyright notices, you must include the +copyright notice for the Library among them, as well as a reference +directing the user to the copy of this License. Also, you must do one +of these things: + + a) Accompany the work with the complete corresponding + machine-readable source code for the Library including whatever + changes were used in the work (which must be distributed under + Sections 1 and 2 above); and, if the work is an executable linked + with the Library, with the complete machine-readable "work that + uses the Library", as object code and/or source code, so that the + user can modify the Library and then relink to produce a modified + executable containing the modified Library. (It is understood + that the user who changes the contents of definitions files in the + Library will not necessarily be able to recompile the application + to use the modified definitions.) + + b) Use a suitable shared library mechanism for linking with the + Library. A suitable mechanism is one that (1) uses at run time a + copy of the library already present on the user's computer system, + rather than copying library functions into the executable, and (2) + will operate properly with a modified version of the library, if + the user installs one, as long as the modified version is + interface-compatible with the version that the work was made with. + + c) Accompany the work with a written offer, valid for at + least three years, to give the same user the materials + specified in Subsection 6a, above, for a charge no more + than the cost of performing this distribution. + + d) If distribution of the work is made by offering access to copy + from a designated place, offer equivalent access to copy the above + specified materials from the same place. + + e) Verify that the user has already received a copy of these + materials or that you have already sent this user a copy. + + For an executable, the required form of the "work that uses the +Library" must include any data and utility programs needed for +reproducing the executable from it. However, as a special exception, +the materials to be distributed need not include anything that is +normally distributed (in either source or binary form) with the major +components (compiler, kernel, and so on) of the operating system on +which the executable runs, unless that component itself accompanies +the executable. + + It may happen that this requirement contradicts the license +restrictions of other proprietary libraries that do not normally +accompany the operating system. Such a contradiction means you cannot +use both them and the Library together in an executable that you +distribute. + + 7. You may place library facilities that are a work based on the +Library side-by-side in a single library together with other library +facilities not covered by this License, and distribute such a combined +library, provided that the separate distribution of the work based on +the Library and of the other library facilities is otherwise +permitted, and provided that you do these two things: + + a) Accompany the combined library with a copy of the same work + based on the Library, uncombined with any other library + facilities. This must be distributed under the terms of the + Sections above. + + b) Give prominent notice with the combined library of the fact + that part of it is a work based on the Library, and explaining + where to find the accompanying uncombined form of the same work. + + 8. You may not copy, modify, sublicense, link with, or distribute +the Library except as expressly provided under this License. Any +attempt otherwise to copy, modify, sublicense, link with, or +distribute the Library is void, and will automatically terminate your +rights under this License. However, parties who have received copies, +or rights, from you under this License will not have their licenses +terminated so long as such parties remain in full compliance. + + 9. You are not required to accept this License, since you have not +signed it. However, nothing else grants you permission to modify or +distribute the Library or its derivative works. These actions are +prohibited by law if you do not accept this License. Therefore, by +modifying or distributing the Library (or any work based on the +Library), you indicate your acceptance of this License to do so, and +all its terms and conditions for copying, distributing or modifying +the Library or works based on it. + + 10. Each time you redistribute the Library (or any work based on the +Library), the recipient automatically receives a license from the +original licensor to copy, distribute, link with or modify the Library +subject to these terms and conditions. You may not impose any further +restrictions on the recipients' exercise of the rights granted herein. +You are not responsible for enforcing compliance by third parties with +this License. + + 11. If, as a consequence of a court judgment or allegation of patent +infringement or for any other reason (not limited to patent issues), +conditions are imposed on you (whether by court order, agreement or +otherwise) that contradict the conditions of this License, they do not +excuse you from the conditions of this License. If you cannot +distribute so as to satisfy simultaneously your obligations under this +License and any other pertinent obligations, then as a consequence you +may not distribute the Library at all. For example, if a patent +license would not permit royalty-free redistribution of the Library by +all those who receive copies directly or indirectly through you, then +the only way you could satisfy both it and this License would be to +refrain entirely from distribution of the Library. + +If any portion of this section is held invalid or unenforceable under any +particular circumstance, the balance of the section is intended to apply, +and the section as a whole is intended to apply in other circumstances. + +It is not the purpose of this section to induce you to infringe any +patents or other property right claims or to contest validity of any +such claims; this section has the sole purpose of protecting the +integrity of the free software distribution system which is +implemented by public license practices. Many people have made +generous contributions to the wide range of software distributed +through that system in reliance on consistent application of that +system; it is up to the author/donor to decide if he or she is willing +to distribute software through any other system and a licensee cannot +impose that choice. + +This section is intended to make thoroughly clear what is believed to +be a consequence of the rest of this License. + + 12. If the distribution and/or use of the Library is restricted in +certain countries either by patents or by copyrighted interfaces, the +original copyright holder who places the Library under this License may add +an explicit geographical distribution limitation excluding those countries, +so that distribution is permitted only in or among countries not thus +excluded. In such case, this License incorporates the limitation as if +written in the body of this License. + + 13. The Free Software Foundation may publish revised and/or new +versions of the Lesser General Public License from time to time. +Such new versions will be similar in spirit to the present version, +but may differ in detail to address new problems or concerns. + +Each version is given a distinguishing version number. If the Library +specifies a version number of this License which applies to it and +"any later version", you have the option of following the terms and +conditions either of that version or of any later version published by +the Free Software Foundation. If the Library does not specify a +license version number, you may choose any version ever published by +the Free Software Foundation. + + 14. If you wish to incorporate parts of the Library into other free +programs whose distribution conditions are incompatible with these, +write to the author to ask for permission. For software which is +copyrighted by the Free Software Foundation, write to the Free +Software Foundation; we sometimes make exceptions for this. Our +decision will be guided by the two goals of preserving the free status +of all derivatives of our free software and of promoting the sharing +and reuse of software generally. + + NO WARRANTY + + 15. BECAUSE THE LIBRARY IS LICENSED FREE OF CHARGE, THERE IS NO +WARRANTY FOR THE LIBRARY, TO THE EXTENT PERMITTED BY APPLICABLE LAW. +EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT HOLDERS AND/OR +OTHER PARTIES PROVIDE THE LIBRARY "AS IS" WITHOUT WARRANTY OF ANY +KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO, THE +IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR +PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE +LIBRARY IS WITH YOU. SHOULD THE LIBRARY PROVE DEFECTIVE, YOU ASSUME +THE COST OF ALL NECESSARY SERVICING, REPAIR OR CORRECTION. + + 16. IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN +WRITING WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MAY MODIFY +AND/OR REDISTRIBUTE THE LIBRARY AS PERMITTED ABOVE, BE LIABLE TO YOU +FOR DAMAGES, INCLUDING ANY GENERAL, SPECIAL, INCIDENTAL OR +CONSEQUENTIAL DAMAGES ARISING OUT OF THE USE OR INABILITY TO USE THE +LIBRARY (INCLUDING BUT NOT LIMITED TO LOSS OF DATA OR DATA BEING +RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD PARTIES OR A +FAILURE OF THE LIBRARY TO OPERATE WITH ANY OTHER SOFTWARE), EVEN IF +SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF SUCH +DAMAGES. + + END OF TERMS AND CONDITIONS + + How to Apply These Terms to Your New Libraries + + If you develop a new library, and you want it to be of the greatest +possible use to the public, we recommend making it free software that +everyone can redistribute and change. You can do so by permitting +redistribution under these terms (or, alternatively, under the terms of the +ordinary General Public License). + + To apply these terms, attach the following notices to the library. It is +safest to attach them to the start of each source file to most effectively +convey the exclusion of warranty; and each file should have at least the +"copyright" line and a pointer to where the full notice is found. + + + Copyright (C) + + This library is free software; you can redistribute it and/or + modify it under the terms of the GNU Lesser General Public + License as published by the Free Software Foundation; either + version 2.1 of the License, or (at your option) any later version. + + This library is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU + Lesser General Public License for more details. + + You should have received a copy of the GNU Lesser General Public + License along with this library; if not, write to the Free Software + Foundation, Inc., 59 Temple Place, Suite 330, Boston, MA 02111-1307 USA + +Also add information on how to contact you by electronic and paper mail. + +You should also get your employer (if you work as a programmer) or your +school, if any, to sign a "copyright disclaimer" for the library, if +necessary. Here is a sample; alter the names: + + Yoyodyne, Inc., hereby disclaims all copyright interest in the + library `Frob' (a library for tweaking knobs) written by James Random Hacker. + + , 1 April 1990 + Ty Coon, President of Vice + +That's all there is to it! + + diff --git a/MANIFEST b/MANIFEST new file mode 100644 index 0000000..68d7bde --- /dev/null +++ b/MANIFEST @@ -0,0 +1,13 @@ +README +ber.py +channel.py +dsskey.py +kex_gex.py +kex_group1.py +message.py +rsakey.py +secsh.py +setup.py +transport.py +util.py +demo.py diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..beb5526 --- /dev/null +++ b/Makefile @@ -0,0 +1,22 @@ +# releases: +# aerodactyl (13sep03) +# bulbasaur +# charmander + +RELEASE=bulbasaur + +release: + mkdir ../secsh-$(RELEASE) + cp README ../secsh-$(RELEASE) + cp *.py ../secsh-$(RELEASE) + cd .. && zip -r secsh-$(RELEASE).zip secsh-$(RELEASE) + echo rm -rf ../secsh-$(RELEASE) + +py: + python ./setup.py sdist --formats=zip + +# places where the version number is stored: +# +# setup.py +# secsh.py +# README diff --git a/NOTES b/NOTES new file mode 100644 index 0000000..9e8ce06 --- /dev/null +++ b/NOTES @@ -0,0 +1,13 @@ + + +-------------------+ +-----------------+ +(Socket)InputStream ---> | secsh transport | <===> | secsh channel | +(Socket)OutputStream --> | (auth, pipe) | N | (buffer) | + +-------------------+ +-----------------+ + @ feeder thread | | + - read InputStream | +-> InputStream + - feed into channel +---> OutputStream + buffers + +SIS <-- @ --> (parse, find chan) --> secsh chan: buffer <-- SSHInputStream +SSHOutputStream --> secsh chan --> secsh transport --> SOS [no thread] + diff --git a/README b/README new file mode 100644 index 0000000..d861ff2 --- /dev/null +++ b/README @@ -0,0 +1,166 @@ +secsh 0.1 +"bulbasaur" release, 18 sep 2003 + +(c) 2003 Robey Pointer + +http://www.lag.net/~robey/secsh/ + + +*** WHAT + +secsh is a module for python 2.3 that implements the SSH2 protocol for secure +(encrypted and authenticated) connections to remote machines. unlike SSL (aka +TLS), SSH2 protocol does not require heirarchical certificates signed by a +powerful central authority. you may know SSH2 as the protocol that replaced +telnet and rsh for secure access to remote shells, but the protocol also +includes the ability to open arbitrary channels to remote services across the +encrypted tunnel (this is how sftp works, for example). + +the module works by taking a socket-like object that you pass in, negotiating +with the remote server, authenticating (using a password or a given private +key), and opening flow-controled "channels" to the server, which are returned +as socket-like objects. you are responsible for verifying that the server's +host key is the one you expected to see, and you have control over which kinds +of encryption or hashing you prefer (if you care), but all of the heavy lifting +is done by the secsh module. + +it is written entirely in python (no C or platform-dependent code) and is +released under the GNU LGPL (lesser GPL). + + +*** REQUIREMENTS + +python 2.3 +pyCrypto + + +*** PORTABILITY + +i code and test this library on Linux and MacOS X. for that reason, i'm +pretty sure that it works for all posix platforms, including MacOS. i also +think it will work on Windows, though i've never tested it there. if you +run into Windows problems, send me a patch: portability is important to me. + +the Channel object supports a "fileno()" call so that it can be passed into +select or poll, for polling on posix. once you call "fileno()" on a Channel, +it changes behavior in some fundamental ways, and these ways require posix. +so don't call "fileno()" on a Channel on Windows. (the problem is that pipes +are used to simulate an open socket, so that the ssh "socket" has an OS-level +file descriptor. i haven't figured out how to make pipes on Windows go into +non-blocking mode yet. [if you don't understand this last sentence, don't +be afraid. the point is to make the API simple enough that you don't HAVE to +know these screwy steps. i just don't understand windows enough.]) + + +*** DEMO + +the demo app (demo.py) is a raw implementation of the normal 'ssh' CLI tool. +while the secsh library should work on all platforms, the demo app will only +run on posix, because it uses select. + +you can run demo.py with no arguments, or you can give a hostname (or +username@hostname) on the command line. if you don't, it'll prompt you for +a hostname and username. if you have an ".ssh/" folder, it will try to read +the host keys from there, though it's easily confused. you can choose to +authenticate with a password, or with an RSA or DSS key, but it can only +read your private key file(s) if they're not password-protected. + +the demo app leaves a logfile called "demo.log" so you can see what secsh +logs as it works. but the most interesting part is probably the code itself, +which hopefully demonstrates how you can use the secsh library. + + +*** USE + +(this section could probably be improved a lot.) + +first, create a Transport by passing in an existing socket (connected to the +desired server). call "start_client(event)", passing in an event which will +be triggered when the negotiation is finished (either successfully or not). +the event is required because each new Transport creates a new worker thread +to handle incoming data asynchronously. + +after the event triggers, use "is_active()" to determine if the Transport was +successfully connected. if so, you should check the server's host key to make +sure it's what you expected. don't worry, i don't mean "check" in any crypto +sense: i mean compare the key, byte for byte, with what you saw last time, to +make sure it's the same key. Transport will handle verifying that the server's +key works. + +next, authenticate, using either "auth_key" or "auth_password". in the future, +this API may change to accomodate servers that require both forms of auth. +pass another event in so you can determine when the authentication dance is +over. if it was successful, "is_authenticated()" will return true. + +once authentication is successful, the Transport is ready to use. call +"open_channel" or "open_session" to create new channels over the Transport +(SSH2 supports many different channels over the same connection). these calls +block until they succeed or fail, and return a Channel object on success, or +None on failure. Channel objects can be treated as "socket-like objects": they +implement: + recv(nbytes) + send(data) + settimeout(timeout_in_seconds) + close() + fileno() [* see note below] +because SSH2 has a windowing kind of flow control, if you stop reading data +from a Channel and its buffer fills up, the server will be unable to send you +any more data until you read some of it. (this won't affect other channels on +the Transport, though.) + +* NOTE that if you use "fileno()", the behavior of the Channel will change +slightly, underneath. this shouldn't be noticable outside the library, but +this alternate implementation will not work on non-posix systems. so don't +try calling "fileno()" on Windows! this has the side effect that you can't +pass a Channel to "select" or "poll" on Windows (which should be fine, since +those calls don't exist on Windows). calling "fileno()" creates an OS-level +pipe and generates a real file descriptor which can be used for polling, BUT +should not be used for reading data from the channel: use "recv" instead. + +because each Transport has a worker thread running in the background, you +must call "close()" on the Transport to kill this thread. on many platforms, +the python interpreter will refuse to exit cleanly if any of these threads +are still running (and you'll have to kill -9 from another shell window). + + +*** CHANGELOG + +2003-08-24: + * implemented the other hashes: all 4 from the draft are working now + * added 'aes128-cbc' and '3des-cbc' cipher support + * fixed channel eof/close semantics +2003-09-12: version "aerodactyl" + * implemented group-exchange kex ("kex-gex") + * implemented RSA/DSA private key auth +2003-09-13: + * fixed inflate_long and deflate_long to handle negatives, even though + they're never used in the current ssh protocol +2003-09-14: + * fixed session_id handling: re-keying works now + * added the ability for a Channel to have a fileno() for select/poll + purposes, although this will cause worse window performance if the + client app isn't careful +2003-09-16: version "bulbasaur" + * fixed pipe (fileno) method to be nonblocking and it seems to work now + * fixed silly bug that caused large blocks to be truncated +2003-10-08: + * patch to fix Channel.invoke_subsystem and add Channel.exec_command + [vaclav dvorak] + * patch to add Channel.sendall [vaclav dvorak] + * patch to add Channel.shutdown [vaclav dvorak] + * patch to add Channel.makefile and a ChannelFile class which emulates + a python file object [vaclav dvorak] +2003-10-26: + * thread creation no longer happens during construction -- use the new + method "start_client(event)" to get things rolling + * re-keying now takes place after 1GB of data or 1 billion packets + (these limits can be easily changed per-session if needed) + + +*** MISSING LINKS + +* ctr forms of ciphers are missing (blowfish-ctr, aes128-ctr, aes256-ctr) +* can't handle password-protected private key files +* multi-part auth not supported (ie, need username AND pk) +* should have a simple synchronous method that handles all auth & events, + by pre-seeding the password or key info, and the expected key diff --git a/auth_transport.py b/auth_transport.py new file mode 100644 index 0000000..1a06326 --- /dev/null +++ b/auth_transport.py @@ -0,0 +1,224 @@ +#!/usr/bin/python + +from transport import BaseTransport +from transport import MSG_SERVICE_REQUEST, MSG_SERVICE_ACCEPT, MSG_USERAUTH_REQUEST, MSG_USERAUTH_FAILURE, \ + MSG_USERAUTH_SUCCESS, MSG_USERAUTH_BANNER +from message import Message +from secsh import SSHException +from logging import DEBUG, INFO, WARNING, ERROR, CRITICAL + +DISCONNECT_SERVICE_NOT_AVAILABLE, DISCONNECT_AUTH_CANCELLED_BY_USER, \ + DISCONNECT_NO_MORE_AUTH_METHODS_AVAILABLE = 7, 13, 14 + +AUTH_SUCCESSFUL, AUTH_PARTIALLY_SUCCESSFUL, AUTH_FAILED = range(3) + + +class Transport(BaseTransport): + "BaseTransport with the auth framework hooked up" + def __init__(self, sock): + BaseTransport.__init__(self, sock) + self.auth_event = None + # for server mode: + self.auth_username = None + self.auth_fail_count = 0 + self.auth_complete = 0 + + def request_auth(self): + m = Message() + m.add_byte(chr(MSG_SERVICE_REQUEST)) + m.add_string('ssh-userauth') + self.send_message(m) + + def auth_key(self, username, key, event): + if (not self.active) or (not self.initial_kex_done): + # we should never try to send the password unless we're on a secure link + raise SSHException('No existing session') + try: + self.lock.acquire() + self.auth_event = event + self.auth_method = 'publickey' + self.username = username + self.private_key = key + self.request_auth() + finally: + self.lock.release() + + def auth_password(self, username, password, event): + 'authenticate using a password; event is triggered on success or fail' + if (not self.active) or (not self.initial_kex_done): + # we should never try to send the password unless we're on a secure link + raise SSHException('No existing session') + try: + self.lock.acquire() + self.auth_event = event + self.auth_method = 'password' + self.username = username + self.password = password + self.request_auth() + finally: + self.lock.release() + + def disconnect_service_not_available(self): + m = Message() + m.add_byte(chr(MSG_DISCONNECT)) + m.add_int(DISCONNECT_SERVICE_NOT_AVAILABLE) + m.add_string('Service not available') + m.add_string('en') + self.send_message(m) + self.close() + + def disconnect_no_more_auth(self): + m = Message() + m.add_byte(chr(MSG_DISCONNECT)) + m.add_int(DISCONNECT_NO_MORE_AUTH_METHODS_AVAILABLE) + m.add_string('No more auth methods available') + m.add_string('en') + self.send_message(m) + self.close() + + def parse_service_request(self, m): + service = m.get_string() + if self.server_mode and (service == 'ssh-userauth'): + # accepted + m = Message() + m.add_byte(chr(MSG_SERVICE_ACCEPT)) + m.add_string(service) + self.send_message(m) + return + # dunno this one + self.disconnect_service_not_available() + + def parse_service_accept(self, m): + service = m.get_string() + if service == 'ssh-userauth': + self.log(DEBUG, 'userauth is OK') + m = Message() + m.add_byte(chr(MSG_USERAUTH_REQUEST)) + m.add_string(self.username) + m.add_string('ssh-connection') + m.add_string(self.auth_method) + if self.auth_method == 'password': + m.add_boolean(0) + m.add_string(self.password.encode('UTF-8')) + elif self.auth_method == 'publickey': + m.add_boolean(1) + m.add_string(self.private_key.get_name()) + m.add_string(str(self.private_key)) + m.add_string(self.private_key.sign_ssh_session(self.randpool, self.H, self.username)) + else: + raise SSHException('Unknown auth method "%s"' % self.auth_method) + self.send_message(m) + else: + self.log(DEBUG, 'Service request "%s" accepted (?)' % service) + + def get_allowed_auths(self): + "override me!" + return 'password' + + def check_auth_none(self, username): + "override me! return tuple of (int, string) ==> (auth status, list of acceptable auth methods)" + return (AUTH_FAILED, self.get_allowed_auths()) + + def check_auth_password(self, username, password): + "override me! return tuple of (int, string) ==> (auth status, list of acceptable auth methods)" + return (AUTH_FAILED, self.get_allowed_auths()) + + def check_auth_publickey(self, username, key): + "override me! return tuple of (int, string) ==> (auth status, list of acceptable auth methods)" + return (AUTH_FAILED, self.get_allowed_auths()) + + def parse_userauth_request(self, m): + if not self.server_mode: + # er, uh... what? + m = Message() + m.add_byte(chr(MSG_USERAUTH_FAILURE)) + m.add_string('none') + m.add_boolean(0) + self.send_message(m) + return + if self.auth_complete: + # ignore + return + username = m.get_string() + service = m.get_string() + method = m.get_string() + if service != 'ssh-connection': + self.disconnect_service_not_available() + return + if (self.auth_username is not None) and (self.auth_username != username): + # trying to change username in mid-flight! + self.disconnect_no_more_auth() + return + if method == 'none': + result = self.check_auth_none(username) + elif method == 'password': + changereq = m.get_boolean() + password = m.get_string().decode('UTF-8') + if changereq: + # always treated as failure, since we don't support changing passwords, but collect + # the list of valid auth types from the callback anyway + newpassword = m.get_string().decode('UTF-8') + result = self.check_auth_password(username, password) + result = (AUTH_FAILED, result[1]) + else: + result = self.check_auth_password(username, password) + elif method == 'publickey': + # FIXME + result = self.check_auth_none(username) + result = (AUTH_FAILED, result[1]) + else: + result = self.check_auth_none(username) + result = (AUTH_FAILED, result[1]) + # okay, send result + m = Message() + if result[0] == AUTH_SUCCESSFUL: + m.add_byte(chr(MSG_USERAUTH_SUCCESSFUL)) + self.auth_complete = 1 + else: + m.add_byte(chr(MSG_USERAUTH_FAILURE)) + m.add_string(result[1]) + if result[0] == AUTH_PARTIALLY_SUCCESSFUL: + m.add_boolean(1) + else: + m.add_boolean(0) + self.auth_fail_count += 1 + self.send_message(m) + if self.auth_fail_count >= 10: + self.disconnect_no_more_auth() + + def parse_userauth_success(self, m): + self.log(INFO, 'Authentication successful!') + self.authenticated = 1 + if self.auth_event != None: + self.auth_event.set() + + def parse_userauth_failure(self, m): + authlist = m.get_list() + partial = m.get_boolean() + if partial: + self.log(INFO, 'Authentication continues...') + self.log(DEBUG, 'Methods: ' + str(partial)) + # FIXME - do something + pass + self.log(INFO, 'Authentication failed.') + self.authenticated = 0 + self.close() + if self.auth_event != None: + self.auth_event.set() + + def parse_userauth_banner(self, m): + banner = m.get_string() + lang = m.get_string() + self.log(INFO, 'Auth banner: ' + banner) + # who cares. + + handler_table = BaseTransport.handler_table.copy() + handler_table.update({ + MSG_SERVICE_REQUEST: parse_service_request, + MSG_SERVICE_ACCEPT: parse_service_accept, + MSG_USERAUTH_REQUEST: parse_userauth_request, + MSG_USERAUTH_SUCCESS: parse_userauth_success, + MSG_USERAUTH_FAILURE: parse_userauth_failure, + MSG_USERAUTH_BANNER: parse_userauth_banner, + }) + diff --git a/ber.py b/ber.py new file mode 100644 index 0000000..7fe1dd0 --- /dev/null +++ b/ber.py @@ -0,0 +1,112 @@ +#!/usr/bin/python + +import struct + +def inflate_long(s, always_positive=0): + "turns a normalized byte string into a long-int (adapted from Crypto.Util.number)" + out = 0L + if len(s) % 4: + filler = '\x00' + if not always_positive and (ord(s[0]) >= 0x80): + # negative + filler = '\xff' + s = filler * (4 - len(s) % 4) + s + # FIXME: this doesn't actually handle negative. + # luckily ssh never uses negative bignums. + for i in range(0, len(s), 4): + out = (out << 32) + struct.unpack('>I', s[i:i+4])[0] + return out + +def deflate_long(n, add_sign_padding=1): + "turns a long-int into a normalized byte string (adapted from Crypto.Util.number)" + # after much testing, this algorithm was deemed to be the fastest + s = '' + n = long(n) + while n > 0: + s = struct.pack('>I', n & 0xffffffffL) + s + n = n >> 32 + # strip off leading zeros + for i in enumerate(s): + if i[1] != '\000': + break + else: + # only happens when n == 0 + s = '\000' + i = (0,) + s = s[i[0]:] + if (ord(s[0]) >= 0x80) and add_sign_padding: + s = '\x00' + s + return s + + +class BER(object): + + def __init__(self, content=''): + self.content = content + self.idx = 0 + + def __str__(self): + return self.content + + def __repr__(self): + return 'BER(' + repr(self.content) + ')' + + def decode(self): + return self.decode_next() + + def decode_next(self): + if self.idx >= len(self.content): + return None + id = ord(self.content[self.idx]) + self.idx += 1 + if (id & 31) == 31: + # identifier > 30 + id = 0 + while self.idx < len(self.content): + t = ord(self.content[self.idx]) + if not (t & 0x80): + break + id = (id << 7) | (t & 0x7f) + self.idx += 1 + if self.idx >= len(self.content): + return None + # now fetch length + size = ord(self.content[self.idx]) + self.idx += 1 + if size & 0x80: + # more complimicated... + # FIXME: theoretically should handle indefinite-length (0x80) + t = size & 0x7f + if self.idx + t > len(self.content): + return None + size = 0 + while t > 0: + size = (size << 8) | ord(self.content[self.idx]) + self.idx += 1 + t -= 1 + if self.idx + size > len(self.content): + # can't fit + return None + data = self.content[self.idx : self.idx + size] + self.idx += size + # now switch on id + if id == 0x30: + # sequence + return self.decode_sequence(data) + elif id == 2: + # int + return inflate_long(data) + else: + # 1: boolean (00 false, otherwise true) + raise Exception('Unknown ber encoding type %d (robey is lazy)' % id) + + def decode_sequence(data): + out = [] + b = BER(data) + while 1: + x = b.decode_next() + if x == None: + return out + out.append(x) + decode_sequence = staticmethod(decode_sequence) + diff --git a/channel.py b/channel.py new file mode 100644 index 0000000..275c0a2 --- /dev/null +++ b/channel.py @@ -0,0 +1,608 @@ +from message import Message +from secsh import SSHException +from transport import MSG_CHANNEL_REQUEST, MSG_CHANNEL_CLOSE, MSG_CHANNEL_WINDOW_ADJUST, MSG_CHANNEL_DATA, \ + MSG_CHANNEL_EOF + +import time, threading, logging, socket, os +from logging import DEBUG + + +# this is ugly, and won't work on windows +def set_nonblocking(fd): + import fcntl + fcntl.fcntl(fd, fcntl.F_SETFL, os.O_NONBLOCK) + + +class Channel(object): + """ + Abstraction for a secsh channel. + """ + + def __init__(self, chanid, transport): + self.chanid = chanid + self.transport = transport + self.active = 0 + self.eof_received = 0 + self.eof_sent = 0 + self.in_buffer = '' + self.timeout = None + self.closed = 0 + self.lock = threading.Lock() + self.in_buffer_cv = threading.Condition(self.lock) + self.out_buffer_cv = threading.Condition(self.lock) + self.name = str(chanid) + self.logger = logging.getLogger('secsh.chan.' + str(chanid)) + self.pipe_rfd = self.pipe_wfd = None + + def __repr__(self): + out = ' 0: + out += ' in-buffer=%d' % (len(self.in_buffer),) + out += ' -> ' + repr(self.transport) + out += '>' + return out + + def log(self, level, msg): + self.logger.log(level, msg) + + def set_window(self, window_size, max_packet_size): + self.in_window_size = window_size + self.in_max_packet_size = max_packet_size + # threshold of bytes we receive before we bother to send a window update + self.in_window_threshold = window_size // 10 + self.in_window_sofar = 0 + + def set_server_channel(self, chanid, window_size, max_packet_size): + self.server_chanid = chanid + self.out_window_size = window_size + self.out_max_packet_size = max_packet_size + self.active = 1 + + def request_success(self, m): + self.log(DEBUG, 'Sesch channel %d request ok' % self.chanid) + return + + def request_failed(self, m): + self.close() + + def feed(self, m): + s = m.get_string() + try: + self.lock.acquire() + self.log(DEBUG, 'fed %d bytes' % len(s)) + if self.pipe_wfd != None: + self.feed_pipe(s) + else: + self.in_buffer += s + self.in_buffer_cv.notifyAll() + self.log(DEBUG, '(out from feed)') + finally: + self.lock.release() + + def window_adjust(self, m): + nbytes = m.get_int() + try: + self.lock.acquire() + self.log(DEBUG, 'window up %d' % nbytes) + self.out_window_size += nbytes + self.out_buffer_cv.notifyAll() + finally: + self.lock.release() + + def handle_request(self, m): + key = m.get_string() + if key == 'exit-status': + self.exit_status = m.get_int() + return + elif key == 'xon-xoff': + # ignore + return + else: + self.log(DEBUG, 'Unhandled channel request "%s"' % key) + + def handle_eof(self, m): + self.eof_received = 1 + try: + self.lock.acquire() + self.in_buffer_cv.notifyAll() + if self.pipe_wfd != None: + os.close(self.pipe_wfd) + self.pipe_wfd = None + finally: + self.lock.release() + self.log(DEBUG, 'EOF received') + + def handle_close(self, m): + self.close() + try: + self.lock.acquire() + self.in_buffer_cv.notifyAll() + self.out_buffer_cv.notifyAll() + if self.pipe_wfd != None: + os.close(self.pipe_wfd) + self.pipe_wfd = None + finally: + self.lock.release() + + + # API for external use + + def get_pty(self, term='vt100', width=80, height=24): + if self.closed or self.eof_received or self.eof_sent or not self.active: + raise SSHException('Channel is not open') + m = Message() + m.add_byte(chr(MSG_CHANNEL_REQUEST)) + m.add_int(self.server_chanid) + m.add_string('pty-req') + m.add_boolean(0) + m.add_string(term) + m.add_int(width) + m.add_int(height) + # pixel height, width (usually useless) + m.add_int(0).add_int(0) + m.add_string('') + self.transport.send_message(m) + + def invoke_shell(self): + if self.closed or self.eof_received or self.eof_sent or not self.active: + raise SSHException('Channel is not open') + m = Message() + m.add_byte(chr(MSG_CHANNEL_REQUEST)) + m.add_int(self.server_chanid) + m.add_string('shell') + m.add_boolean(1) + self.transport.send_message(m) + + def exec_command(self, command): + if self.closed or self.eof_received or self.eof_sent or not self.active: + raise SSHException('Channel is not open') + m = Message() + m.add_byte(chr(MSG_CHANNEL_REQUEST)) + m.add_int(self.server_chanid) + m.add_string('exec') + m.add_boolean(1) + m.add_string(command) + self.transport.send_message(m) + + def invoke_subsystem(self, subsystem): + if self.closed or self.eof_received or self.eof_sent or not self.active: + raise SSHException('Channel is not open') + m = Message() + m.add_byte(chr(MSG_CHANNEL_REQUEST)) + m.add_int(self.server_chanid) + m.add_string('subsystem') + m.add_boolean(1) + m.add_string(subsystem) + self.transport.send_message(m) + + def resize_pty(self, width=80, height=24): + if self.closed or self.eof_received or self.eof_sent or not self.active: + raise SSHException('Channel is not open') + m = Message() + m.add_byte(chr(MSG_CHANNEL_REQUEST)) + m.add_int(self.server_chanid) + m.add_string('window-change') + m.add_boolean(0) + m.add_int(width) + m.add_int(height) + m.add_int(0).add_int(0) + self.transport.send_message(m) + + def get_transport(self): + return self.transport + + def set_name(self, name): + self.name = name + self.logger = logging.getLogger('secsh.chan.' + name) + + def get_name(self): + return self.name + + def send_eof(self): + if self.eof_sent: + return + m = Message() + m.add_byte(chr(MSG_CHANNEL_EOF)) + m.add_int(self.server_chanid) + self.transport.send_message(m) + self.eof_sent = 1 + self.log(DEBUG, 'EOF sent') + return + + + # socket equivalency methods... + + def settimeout(self, timeout): + self.timeout = timeout + + def gettimeout(self): + return self.timeout + + def setblocking(self, blocking): + if blocking: + self.settimeout(None) + else: + self.settimeout(0.0) + + def close(self): + if self.closed or not self.active: + return + self.send_eof() + m = Message() + m.add_byte(chr(MSG_CHANNEL_CLOSE)) + m.add_int(self.server_chanid) + self.transport.send_message(m) + self.closed = 1 + self.transport.unlink_channel(self.chanid) + + def recv_ready(self): + "doesn't work if you've called fileno()" + try: + self.lock.acquire() + if len(self.in_buffer) == 0: + return 0 + return 1 + finally: + self.lock.release() + + def recv(self, nbytes): + out = '' + try: + self.lock.acquire() + if self.pipe_rfd != None: + # use the pipe + return self.read_pipe(nbytes) + if len(self.in_buffer) == 0: + if self.closed or self.eof_received: + return out + # should we block? + if self.timeout == 0.0: + raise socket.timeout() + # loop here in case we get woken up but a different thread has grabbed everything in the buffer + timeout = self.timeout + while (len(self.in_buffer) == 0) and not self.closed and not self.eof_received: + then = time.time() + self.in_buffer_cv.wait(timeout) + if timeout != None: + timeout -= time.time() - then + if timeout <= 0.0: + raise socket.timeout() + # something in the buffer and we have the lock + if len(self.in_buffer) <= nbytes: + out = self.in_buffer + self.in_buffer = '' + else: + out = self.in_buffer[:nbytes] + self.in_buffer = self.in_buffer[nbytes:] + self.check_add_window(len(out)) + finally: + self.lock.release() + return out + + def send(self, s): + size = 0 + if self.closed or self.eof_sent: + return size + try: + self.lock.acquire() + if self.out_window_size == 0: + # should we block? + if self.timeout == 0.0: + raise socket.timeout() + # loop here in case we get woken up but a different thread has filled the buffer + timeout = self.timeout + while self.out_window_size == 0: + then = time.time() + self.out_buffer_cv.wait(timeout) + if timeout != None: + timeout -= time.time() - then + if timeout <= 0.0: + raise socket.timeout() + # we have some window to squeeze into + if self.closed: + return 0 + size = len(s) + if self.out_window_size < size: + size = self.out_window_size + if self.out_max_packet_size < size: + size = self.out_max_packet_size + m = Message() + m.add_byte(chr(MSG_CHANNEL_DATA)) + m.add_int(self.server_chanid) + m.add_string(s[:size]) + self.transport.send_message(m) + self.out_window_size -= size + finally: + self.lock.release() + return size + + def sendall(self, s): + while s: + if self.closed: + # this doesn't seem useful, but it is the documented behavior of Socket + raise socket.error('Socket is closed') + sent = self.send(s) + s = s[sent:] + return None + + def makefile(self, *params): + return ChannelFile(*([self] + list(params))) + + def fileno(self): + """ + returns an OS-level fd which can be used for polling and reading (but + NOT for writing). this is primarily to allow python's \"select\" module + to work. the first time this function is called, a pipe is created to + simulate real OS-level fd behavior. because of this, two actual fds are + created: one to return and one to feed. this may be inefficient if you + plan to use many fds. + + the channel's receive window will be updated as data comes in, not as + you read it, so if you fail to poll the channel often enough, it may + block ALL channels across the transport. + """ + try: + self.lock.acquire() + if self.pipe_rfd != None: + return self.pipe_rfd + # create the pipe and feed in any existing data + self.pipe_rfd, self.pipe_wfd = os.pipe() + set_nonblocking(self.pipe_wfd) + set_nonblocking(self.pipe_rfd) + if len(self.in_buffer) > 0: + x = self.in_buffer + self.in_buffer = '' + self.feed_pipe(x) + return self.pipe_rfd + finally: + self.lock.release() + + def shutdown(self, how): + if (how == 0) or (how == 2): + # feign "read" shutdown + self.eof_received = 1 + if (how == 1) or (how == 2): + self.send_eof() + + + # internal use... + + def feed_pipe(self, data): + "you are already holding the lock" + if len(self.in_buffer) > 0: + self.in_buffer += data + return + try: + n = os.write(self.pipe_wfd, data) + if n < len(data): + # at least on linux, this will never happen, as the writes are + # considered atomic... but just in case. + self.in_buffer = data[n:] + self.check_add_window(n) + self.in_buffer_cv.notifyAll() + return + except OSError, e: + pass + if len(data) > 1: + # try writing just one byte then + x = data[0] + data = data[1:] + try: + os.write(self.pipe_wfd, x) + self.in_buffer = data + self.check_add_window(1) + self.in_buffer_cv.notifyAll() + return + except OSError, e: + data = x + data + # pipe is very full + self.in_buffer = data + self.in_buffer_cv.notifyAll() + + def read_pipe(self, nbytes): + "you are already holding the lock" + try: + x = os.read(self.pipe_rfd, nbytes) + if len(x) > 0: + self.push_pipe(len(x)) + return x + except OSError, e: + pass + # nothing in the pipe + if self.closed or self.eof_received: + return '' + # should we block? + if self.timeout == 0.0: + raise socket.timeout() + # loop here in case we get woken up but a different thread has grabbed everything in the buffer + timeout = self.timeout + while not self.closed and not self.eof_received: + then = time.time() + self.in_buffer_cv.wait(timeout) + if timeout != None: + timeout -= time.time() - then + if timeout <= 0.0: + raise socket.timeout() + try: + x = os.read(self.pipe_rfd, nbytes) + if len(x) > 0: + self.push_pipe(len(x)) + return x + except OSError, e: + pass + pass + + def push_pipe(self, nbytes): + # successfully read N bytes from the pipe, now re-feed the pipe if necessary + # (assumption: the pipe can hold as many bytes as were read out) + if len(self.in_buffer) == 0: + return + if len(self.in_buffer) <= nbytes: + os.write(self.pipe_wfd, self.in_buffer) + self.in_buffer = '' + return + x = self.in_buffer[:nbytes] + self.in_buffer = self.in_buffer[nbytes:] + os.write(self.pipd_wfd, x) + + def unlink(self): + if self.closed or not self.active: + return + self.closed = 1 + self.transport.unlink_channel(self.chanid) + + def check_add_window(self, n): + # already holding the lock! + if self.closed or self.eof_received or not self.active: + return + self.log(DEBUG, 'addwindow %d' % n) + self.in_window_sofar += n + if self.in_window_sofar > self.in_window_threshold: + self.log(DEBUG, 'addwindow send %d' % self.in_window_sofar) + m = Message() + m.add_byte(chr(MSG_CHANNEL_WINDOW_ADJUST)) + m.add_int(self.server_chanid) + m.add_int(self.in_window_sofar) + self.transport.send_message(m) + self.in_window_sofar = 0 + + +class ChannelFile(object): + """ + A file-like wrapper around Channel. + Doesn't have the non-portable side effect of Channel.fileno(). + XXX Todo: the channel and its file-wrappers should be able to be closed or + garbage-collected independently, for compatibility with real sockets and + their file-wrappers. Currently, closing does nothing but flush the buffer. + XXX Todo: translation of the various forms of newline is not implemented, + let alone the universal newline. Line buffering (for writing) is + implemented, though, which makes little sense without text mode support. + """ + + def __init__(self, channel, mode = "r", buf_size = -1): + self.channel = channel + self.mode = mode + if buf_size < 0: + self.buf_size = 1024 + self.line_buffered = 0 + elif buf_size == 1: + self.buf_size = 1 + self.line_buffered = 1 + else: + self.buf_size = buf_size + self.line_buffered = 0 + self.wbuffer = "" + self.rbuffer = "" + self.readable = ("r" in mode) + self.writable = ("w" in mode) or ("+" in mode) or ("a" in mode) + self.binary = ("b" in mode) + if not self.binary: + raise NotImplementedError("text mode not supported") + self.softspace = 0 + + def __iter__(self): + return self + + def next(self): + line = self.readline() + if not line: + raise StopIteration + return line + + def write(self, str): + if not self.writable: + raise IOError("file not open for writing") + if self.buf_size == 0 and not self.line_buffered: + self.channel.sendall(str) + return + self.wbuffer += str + if self.line_buffered: + last_newline_pos = self.wbuffer.rfind("\n") + if last_newline_pos >= 0: + self.channel.sendall(self.wbuffer[:last_newline_pos+1]) + self.wbuffer = self.wbuffer[last_newline_pos+1:] + else: + if len(self.wbuffer) >= self.buf_size: + self.channel.sendall(self.wbuffer) + self.wbuffer = "" + return + + def writelines(self, sequence): + for s in sequence: + self.write(s) + return + + def flush(self): + self.channel.sendall(self.wbuffer) + self.wbuffer = "" + return + + def read(self, size = None): + if not self.readable: + raise IOError("file not open for reading") + if size is None or size < 0: + result = self.rbuffer + self.rbuffer = "" + while not self.channel.eof_received: + new_data = self.channel.recv(65536) + if not new_data: + break + result += new_data + return result + if size <= len(self.rbuffer): + result = self.rbuffer[:size] + self.rbuffer = self.rbuffer[size:] + return result + while len(self.rbuffer) < size and not self.channel.eof_received: + new_data = self.channel.recv(max(self.buf_size, size-len(self.rbuffer))) + if not new_data: + break + self.rbuffer += new_data + result = self.rbuffer[:size] + self.rbuffer[size:] + return result + + def readline(self, size = None): + line = "" + while "\n" not in line: + if size >= 0: + new_data = self.read(size - len(line)) + else: + new_data = self.read(64) + if not new_data: + break + line += new_data + newline_pos = line.find("\n") + if newline_pos >= 0: + self.rbuffer = line[newline_pos+1:] + self.rbuffer + return line[:newline_pos+1] + elif len(line) > size: + self.rbuffer = line[size:] + self.rbuffer + return line[:size] + return line + + def readlines(self, sizehint = None): + lines = [] + while 1: + line = self.readline() + if not line: + break + lines.append(line) + return lines + + def xreadlines(self): + return self + + def close(self): + self.flush() + return + +# vim: set shiftwidth=4 expandtab : diff --git a/demo-server.py b/demo-server.py new file mode 100755 index 0000000..6819ea9 --- /dev/null +++ b/demo-server.py @@ -0,0 +1,56 @@ +#!/usr/bin/python + +import sys, os, socket, threading, logging, traceback +import secsh + +# setup logging +l = logging.getLogger("secsh") +l.setLevel(logging.DEBUG) +if len(l.handlers) == 0: + f = open('demo-server.log', 'w') + lh = logging.StreamHandler(f) + lh.setFormatter(logging.Formatter('%(levelname)-.3s [%(asctime)s] %(name)s: %(message)s', '%Y%m%d:%H%M%S')) + l.addHandler(lh) + +host_key = secsh.RSAKey() +host_key.read_private_key_file('/home/robey/sshkey/ssh_host_rsa_key') + +# now connect +try: + sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM) + sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) + sock.bind(('', 2200)) +except Exception, e: + print '*** Bind failed: ' + str(e) + traceback.print_exc() + sys.exit(1) + +try: + sock.listen(100) + client, addr = sock.accept() +except Exception, e: + print '*** Listen/accept failed: ' + str(e) + traceback.print_exc() + sys.exit(1) + +try: + event = threading.Event() + t = secsh.Transport(client) + t.add_server_key(host_key) + t.ultra_debug = 1 + t.start_server(event) + # print repr(t) + event.wait(10) + if not t.is_active(): + print '*** SSH negotiation failed.' + sys.exit(1) + # print repr(t) +except Exception, e: + print '*** Caught exception: ' + str(e.__class__) + ': ' + str(e) + traceback.print_exc() + try: + t.close() + except: + pass + sys.exit(1) + diff --git a/demo.py b/demo.py new file mode 100755 index 0000000..fc707e4 --- /dev/null +++ b/demo.py @@ -0,0 +1,180 @@ +#!/usr/bin/python + +import sys, os, socket, threading, getpass, logging, time, base64, select, termios, tty, traceback +import secsh + + +##### utility functions + +def load_host_keys(): + filename = os.environ['HOME'] + '/.ssh/known_hosts' + keys = {} + try: + f = open(filename, 'r') + except Exception, e: + print '*** Unable to open host keys file (%s)' % filename + return + for line in f: + keylist = line.split(' ') + if len(keylist) != 3: + continue + hostlist, keytype, key = keylist + hosts = hostlist.split(',') + for host in hosts: + if not keys.has_key(host): + keys[host] = {} + keys[host][keytype] = base64.decodestring(key) + f.close() + return keys + + +##### main demo + +# setup logging +l = logging.getLogger("secsh") +l.setLevel(logging.DEBUG) +if len(l.handlers) == 0: + f = open('demo.log', 'w') + lh = logging.StreamHandler(f) + lh.setFormatter(logging.Formatter('%(levelname)-.3s [%(asctime)s] %(name)s: %(message)s', '%Y%m%d:%H%M%S')) + l.addHandler(lh) + +username = '' +if len(sys.argv) > 1: + hostname = sys.argv[1] + if hostname.find('@') >= 0: + username, hostname = hostname.split('@') +else: + hostname = raw_input('Hostname: ') +if len(hostname) == 0: + print '*** Hostname required.' + sys.exit(1) +port = 22 +if hostname.find(':') >= 0: + hostname, portstr = hostname.split(':') + port = int(portstr) + +# now connect +try: + sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM) + sock.connect((hostname, port)) +except Exception, e: + print '*** Connect failed: ' + str(e) + traceback.print_exc() + sys.exit(1) + +try: + event = threading.Event() + t = secsh.Transport(sock) + t.ultra_debug = 1 + t.start_client(event) + # print repr(t) + event.wait(10) + if not t.is_active(): + print '*** SSH negotiation failed.' + sys.exit(1) + # print repr(t) + + keys = load_host_keys() + keytype, hostkey = t.get_host_key() + if not keys.has_key(hostname): + print '*** WARNING: Unknown host key!' + elif not keys[hostname].has_key(keytype): + print '*** WARNING: Unknown host key!' + elif keys[hostname][keytype] != hostkey: + print '*** WARNING: Host key has changed!!!' + sys.exit(1) + else: + print '*** Host key OK.' + + event.clear() + + # get username + if username == '': + default_username = getpass.getuser() + username = raw_input('Username [%s]: ' % default_username) + if len(username) == 0: + username = default_username + + # ask for what kind of authentication to try + default_auth = 'p' + auth = raw_input('Auth by (p)assword, (r)sa key, or (d)ss key? [%s] ' % default_auth) + if len(auth) == 0: + auth = default_auth + + if auth == 'r': + key = secsh.RSAKey() + default_path = os.environ['HOME'] + '/.ssh/id_rsa' + path = raw_input('RSA key [%s]: ' % default_path) + if len(path) == 0: + path = default_path + key.read_private_key_file(path) + t.auth_key(username, key, event) + elif auth == 'd': + key = secsh.DSSKey() + default_path = os.environ['HOME'] + '/.ssh/id_dsa' + path = raw_input('DSS key [%s]: ' % default_path) + if len(path) == 0: + path = default_path + key.read_private_key_file(path) + t.auth_key(username, key, event) + else: + pw = getpass.getpass('Password for %s@%s: ' % (username, hostname)) + t.auth_password(username, pw, event) + + event.wait(10) + # print repr(t) + if not t.is_authenticated(): + print '*** Authentication failed. :(' + t.close() + sys.exit(1) + + chan = t.open_session() + chan.get_pty() + chan.invoke_shell() + print '*** Here we go!' + print + + try: + oldtty = termios.tcgetattr(sys.stdin) + tty.setraw(sys.stdin.fileno()) + tty.setcbreak(sys.stdin.fileno()) + chan.settimeout(0.0) + + while 1: + r, w, e = select.select([chan, sys.stdin], [], []) + if chan in r: + try: + x = chan.recv(1024) + if len(x) == 0: + print + print '*** EOF\r\n', + break + sys.stdout.write(x) + sys.stdout.flush() + except socket.timeout: + pass + if sys.stdin in r: + # FIXME: reading 1 byte at a time is incredibly dumb. + x = sys.stdin.read(1) + if len(x) == 0: + print + print '*** Bye.\r\n', + break + chan.send(x) + + finally: + termios.tcsetattr(sys.stdin, termios.TCSADRAIN, oldtty) + + chan.close() + t.close() + +except Exception, e: + print '*** Caught exception: ' + str(e.__class__) + ': ' + str(e) + traceback.print_exc() + try: + t.close() + except: + pass + sys.exit(1) + diff --git a/dsskey.py b/dsskey.py new file mode 100644 index 0000000..c203129 --- /dev/null +++ b/dsskey.py @@ -0,0 +1,121 @@ +#!/usr/bin/python + +import base64 +from message import Message +from transport import MSG_USERAUTH_REQUEST +from util import inflate_long, deflate_long +from Crypto.PublicKey import DSA +from Crypto.Hash import SHA +from ber import BER + +from util import format_binary + + +class DSSKey(object): + + def __init__(self, msg=None): + self.valid = 0 + if (msg == None) or (msg.get_string() != 'ssh-dss'): + return + self.p = msg.get_mpint() + self.q = msg.get_mpint() + self.g = msg.get_mpint() + self.y = msg.get_mpint() + self.size = len(deflate_long(self.p, 0)) + self.valid = 1 + + def __str__(self): + if not self.valid: + return '' + m = Message() + m.add_string('ssh-dss') + m.add_mpint(self.p) + m.add_mpint(self.q) + m.add_mpint(self.g) + m.add_mpint(self.y) + return str(m) + + def get_name(self): + return 'ssh-dss' + + def verify_ssh_sig(self, data, msg): + if not self.valid: + return 0 + if len(str(msg)) == 40: + # spies.com bug: signature has no header + sig = str(msg) + else: + kind = msg.get_string() + if kind != 'ssh-dss': + return 0 + sig = msg.get_string() + + # pull out (r, s) which are NOT encoded as mpints + sigR = inflate_long(sig[:20], 1) + sigS = inflate_long(sig[20:], 1) + sigM = inflate_long(SHA.new(data).digest(), 1) + + dss = DSA.construct((long(self.y), long(self.g), long(self.p), long(self.q))) + return dss.verify(sigM, (sigR, sigS)) + + def sign_ssh_data(self, data): + hash = SHA.new(data).digest() + dss = DSA.construct((long(self.y), long(self.g), long(self.p), long(self.q), long(self.x))) + # generate a suitable k + qsize = len(deflate_long(self.q, 0)) + while 1: + k = inflate_long(randpool.get_bytes(qsize), 1) + if (k > 2) and (k < self.q): + break + r, s = dss.sign(inflate_long(hash, 1), k) + m = Message() + m.add_string('ssh-dss') + m.add_string(deflate_long(r, 0) + deflate_long(s, 0)) + return str(m) + + + rsa = RSA.construct((long(self.n), long(self.e), long(self.d))) + sig = deflate_long(rsa.sign(self.pkcs1imify(hash), '')[0], 0) + m = Message() + m.add_string('ssh-rsa') + m.add_string(sig) + return str(m) + + def read_private_key_file(self, filename): + # private key file contains: + # DSAPrivateKey = { version = 0, p, q, g, y, x } + self.valid = 0 + try: + f = open(filename, 'r') + lines = f.readlines() + f.close() + except: + return + if lines[0].strip() != '-----BEGIN DSA PRIVATE KEY-----': + return + try: + data = base64.decodestring(''.join(lines[1:-1])) + except: + return + keylist = BER(data).decode() + if (type(keylist) != type([])) or (len(keylist) < 6) or (keylist[0] != 0): + return + self.p = keylist[1] + self.q = keylist[2] + self.g = keylist[3] + self.y = keylist[4] + self.x = keylist[5] + self.size = len(deflate_long(self.p, 0)) + self.valid = 1 + + def sign_ssh_session(self, randpool, sid, username): + m = Message() + m.add_string(sid) + m.add_byte(chr(MSG_USERAUTH_REQUEST)) + m.add_string(username) + m.add_string('ssh-connection') + m.add_string('publickey') + m.add_boolean(1) + m.add_string('ssh-dss') + m.add_string(str(self)) + return self.sign_ssh_data(str(m)) diff --git a/kex_gex.py b/kex_gex.py new file mode 100644 index 0000000..01e74f2 --- /dev/null +++ b/kex_gex.py @@ -0,0 +1,180 @@ +#!/usr/bin/python + +# variant on group1 (see kex_group1.py) where the prime "p" and generator "g" +# are provided by the server. a bit more work is required on our side (and a +# LOT more on the server side). + +from message import Message, inflate_long, deflate_long +from secsh import SSHException +from transport import MSG_NEWKEYS +from Crypto.Hash import SHA +from Crypto.Util import number +from logging import DEBUG + +MSG_KEXDH_GEX_GROUP, MSG_KEXDH_GEX_INIT, MSG_KEXDH_GEX_REPLY, MSG_KEXDH_GEX_REQUEST = range(31, 35) + + +class KexGex(object): + + name = 'diffie-hellman-group-exchange-sha1' + min_bits = 1024 + max_bits = 8192 + preferred_bits = 2048 + + def __init__(self, transport): + self.transport = transport + + def start_kex(self): + if self.transport.server_mode: + self.transport.expected_packet = MSG_KEXDH_GEX_REQUEST + return + # request a bit range: we accept (min_bits) to (max_bits), but prefer + # (preferred_bits). according to the spec, we shouldn't pull the + # minimum up above 1024. + m = Message() + m.add_byte(chr(MSG_KEXDH_GEX_REQUEST)) + m.add_int(self.min_bits) + m.add_int(self.preferred_bits) + m.add_int(self.max_bits) + self.transport.send_message(m) + self.transport.expected_packet = MSG_KEXDH_GEX_GROUP + + def parse_next(self, ptype, m): + if ptype == MSG_KEXDH_GEX_REQUEST: + return self.parse_kexdh_gex_request(m) + elif ptype == MSG_KEXDH_GEX_GROUP: + return self.parse_kexdh_gex_group(m) + elif ptype == MSG_KEXDH_GEX_INIT: + return self.parse_kexdh_gex_init(m) + elif ptype == MSG_KEXDH_GEX_REPLY: + return self.parse_kexdh_gex_reply(m) + raise SSHException('KexGex asked to handle packet type %d' % ptype) + + def bit_length(n): + norm = deflate_long(n, 0) + hbyte = ord(norm[0]) + bitlen = len(norm) * 8 + while not (hbyte & 0x80): + hbyte <<= 1 + bitlen -= 1 + return bitlen + bit_length = staticmethod(bit_length) + + def generate_x(self): + # generate an "x" (1 < x < (p-1)/2). + q = (self.p - 1) // 2 + qnorm = deflate_long(q, 0) + qhbyte = ord(qnorm[0]) + bytes = len(qnorm) + qmask = 0xff + while not (qhbyte & 0x80): + qhbyte <<= 1 + qmask >>= 1 + while 1: + self.transport.randpool.stir() + x_bytes = self.transport.randpool.get_bytes(bytes) + x_bytes = chr(ord(x_bytes[0]) & qmask) + x_bytes[1:] + x = inflate_long(x_bytes, 1) + if (x > 1) and (x < q): + break + self.x = x + + def parse_kexdh_gex_request(self, m): + min = m.get_int() + preferred = m.get_int() + max = m.get_int() + # smoosh the user's preferred size into our own limits + if preferred > self.max_bits: + preferred = self.max_bits + if preferred < self.min_bits: + preferred = self.min_bits + # now save a copy + self.min_bits = min + self.preferred_bits = preferred + self.max_bits = max + # generate prime + while 1: + self.transport.log(DEBUG, 'stir...') + self.transport.randpool.stir() + self.transport.log(DEBUG, 'get-prime %d...' % preferred) + self.p = number.getRandomNumber(preferred, self.transport.randpool.get_bytes) + self.transport.log(DEBUG, 'got ' + repr(self.p)) + if number.isPrime((self.p - 1) // 2): + break + self.g = 2 + m = Message() + m.add_byte(chr(MSG_KEXDH_GEX_GROUP)) + m.add_mpint(self.p) + m.add_mpint(self.g) + self.transport.send_message(m) + self.transport.expected_packet = MSG_KEXDH_GEX_INIT + + def parse_kexdh_gex_group(self, m): + self.p = m.get_mpint() + self.g = m.get_mpint() + # reject if p's bit length < 1024 or > 8192 + bitlen = self.bit_length(self.p) + if (bitlen < 1024) or (bitlen > 8192): + raise SSHException('Server-generated gex p (don\'t ask) is out of range (%d bits)' % bitlen) + self.transport.log(DEBUG, 'Got server p (%d bits)' % bitlen) + self.generate_x() + # now compute e = g^x mod p + self.e = pow(self.g, self.x, self.p) + m = Message() + m.add_byte(chr(MSG_KEXDH_GEX_INIT)) + m.add_mpint(self.e) + self.transport.send_message(m) + self.transport.expected_packet = MSG_KEXDH_GEX_REPLY + + def parse_kexdh_gex_init(self, m): + self.e = m.get_mpint() + if (self.e < 1) or (self.e > self.p - 1): + raise SSHException('Client kex "e" is out of range') + self.generate_x() + K = pow(self.e, self.x, P) + key = str(self.transport.get_server_key()) + # okay, build up the hash H of (V_C || V_S || I_C || I_S || K_S || min || n || max || p || g || e || f || K) + hm = Message().add(self.transport.remote_version).add(self.transport.local_version) + hm.add(self.transport.remote_kex_init).add(self.transport.local_kex_init).add(key) + hm.add_int(self.min_bits) + hm.add_int(self.preferred_bits) + hm.add_int(self.max_bits) + hm.add_mpint(self.p) + hm.add_mpint(self.g) + hm.add(self.e).add(self.f).add(K) + H = SHA.new(str(hm)).digest() + self.transport.set_K_H(K, H) + # sign it + sig = self.transport.get_server_key().sign_ssh_data(H) + # send reply + m = Message() + m.add_byte(chr(MSG_KEXDH_GEX_REPLY)) + m.add_string(key) + m.add_mpint(self.f) + m.add_string(sig) + self.transport.send_message(m) + self.transport.activate_outbound() + self.transport.expected_packet = MSG_NEWKEYS + + def parse_kexdh_gex_reply(self, m): + host_key = m.get_string() + self.f = m.get_mpint() + sig = m.get_string() + if (self.f < 1) or (self.f > self.p - 1): + raise SSHException('Server kex "f" is out of range') + K = pow(self.f, self.x, self.p) + # okay, build up the hash H of (V_C || V_S || I_C || I_S || K_S || min || n || max || p || g || e || f || K) + hm = Message().add(self.transport.local_version).add(self.transport.remote_version) + hm.add(self.transport.local_kex_init).add(self.transport.remote_kex_init).add(host_key) + hm.add_int(self.min_bits) + hm.add_int(self.preferred_bits) + hm.add_int(self.max_bits) + hm.add_mpint(self.p) + hm.add_mpint(self.g) + hm.add(self.e).add(self.f).add(K) + self.transport.set_K_H(K, SHA.new(str(hm)).digest()) + self.transport.verify_key(host_key, sig) + self.transport.activate_outbound() + self.transport.expected_packet = MSG_NEWKEYS + + diff --git a/kex_group1.py b/kex_group1.py new file mode 100644 index 0000000..7d65c38 --- /dev/null +++ b/kex_group1.py @@ -0,0 +1,104 @@ +#!/usr/bin/python + +# standard SSH key exchange ("kex" if you wanna sound cool): +# diffie-hellman of 1024 bit key halves, using a known "p" prime and +# "g" generator. + +from message import Message, inflate_long +from secsh import SSHException +from transport import MSG_NEWKEYS +from Crypto.Hash import SHA +from logging import DEBUG, INFO, WARNING, ERROR, CRITICAL + +MSG_KEXDH_INIT, MSG_KEXDH_REPLY = range(30, 32) + +# draft-ietf-secsh-transport-09.txt, page 17 +P = 0xFFFFFFFFFFFFFFFFC90FDAA22168C234C4C6628B80DC1CD129024E088A67CC74020BBEA63B139B22514A08798E3404DDEF9519B3CD3A431B302B0A6DF25F14374FE1356D6D51C245E485B576625E7EC6F44C42E9A637ED6B0BFF5CB6F406B7EDEE386BFB5A899FA5AE9F24117C4B1FE649286651ECE65381FFFFFFFFFFFFFFFFL +G = 2 + + +class KexGroup1(object): + + name = 'diffie-hellman-group1-sha1' + + def __init__(self, transport): + self.transport = transport + + def generate_x(self): + # generate an "x" (1 < x < q), where q is (p-1)/2. + # p is a 128-byte (1024-bit) number, where the first 64 bits are 1. + # therefore q can be approximated as a 2^1023. we drop the subset of + # potential x where the first 63 bits are 1, because some of those will be + # larger than q (but this is a tiny tiny subset of potential x). + while 1: + self.transport.randpool.stir() + x_bytes = self.transport.randpool.get_bytes(128) + x_bytes = chr(ord(x_bytes[0]) & 0x7f) + x_bytes[1:] + if (x_bytes[:8] != '\x7F\xFF\xFF\xFF\xFF\xFF\xFF\xFF') and \ + (x_bytes[:8] != '\x00\x00\x00\x00\x00\x00\x00\x00'): + break + self.x = inflate_long(x_bytes) + + def start_kex(self): + self.generate_x() + if self.transport.server_mode: + # compute f = g^x mod p, but don't send it yet + self.f = pow(G, self.x, P) + self.transport.expected_packet = MSG_KEXDH_INIT + return + # compute e = g^x mod p (where g=2), and send it + self.e = pow(G, self.x, P) + m = Message() + m.add_byte(chr(MSG_KEXDH_INIT)) + m.add_mpint(self.e) + self.transport.send_message(m) + self.transport.expected_packet = MSG_KEXDH_REPLY + + def parse_next(self, ptype, m): + if self.transport.server_mode and (ptype == MSG_KEXDH_INIT): + return self.parse_kexdh_init(m) + elif not self.transport.server_mode and (ptype == MSG_KEXDH_REPLY): + return self.parse_kexdh_reply(m) + raise SSHException('KexGroup1 asked to handle packet type %d' % ptype) + + def parse_kexdh_reply(self, m): + # client mode + host_key = m.get_string() + self.f = m.get_mpint() + if (self.f < 1) or (self.f > P - 1): + raise SSHException('Server kex "f" is out of range') + sig = m.get_string() + K = pow(self.f, self.x, P) + # okay, build up the hash H of (V_C || V_S || I_C || I_S || K_S || e || f || K) + hm = Message().add(self.transport.local_version).add(self.transport.remote_version) + hm.add(self.transport.local_kex_init).add(self.transport.remote_kex_init).add(host_key) + hm.add(self.e).add(self.f).add(K) + self.transport.set_K_H(K, SHA.new(str(hm)).digest()) + self.transport.verify_key(host_key, sig) + self.transport.activate_outbound() + self.transport.expected_packet = MSG_NEWKEYS + + def parse_kexdh_init(self, m): + # server mode + self.e = m.get_mpint() + if (self.e < 1) or (self.e > P - 1): + raise SSHException('Client kex "e" is out of range') + K = pow(self.e, self.x, P) + key = str(self.transport.get_server_key()) + # okay, build up the hash H of (V_C || V_S || I_C || I_S || K_S || e || f || K) + hm = Message().add(self.transport.remote_version).add(self.transport.local_version) + hm.add(self.transport.remote_kex_init).add(self.transport.local_kex_init).add(key) + hm.add(self.e).add(self.f).add(K) + H = SHA.new(str(hm)).digest() + self.transport.set_K_H(K, H) + # sign it + sig = self.transport.get_server_key().sign_ssh_data(H) + # send reply + m = Message() + m.add_byte(chr(MSG_KEXDH_REPLY)) + m.add_string(key) + m.add_mpint(self.f) + m.add_string(sig) + self.transport.send_message(m) + self.transport.activate_outbound() + self.transport.expected_packet = MSG_NEWKEYS diff --git a/message.py b/message.py new file mode 100644 index 0000000..aeccd9f --- /dev/null +++ b/message.py @@ -0,0 +1,119 @@ +# implementation of a secsh "message" + +import string, types, struct +from util import inflate_long, deflate_long + + +class Message(object): + "represents the encoding of a secsh message" + + def __init__(self, content=''): + self.packet = content + self.idx = 0 + self.seqno = -1 + + def __str__(self): + return self.packet + + def __repr__(self): + return 'Message(' + repr(self.packet) + ')' + + def get_remainder(self): + "remaining bytes still unparsed" + return self.packet[self.idx:] + + def get_so_far(self): + "bytes that have been parsed" + return self.packet[:self.idx] + + def get_bytes(self, n): + if self.idx + n > len(self.packet): + return '\x00'*n + b = self.packet[self.idx:self.idx+n] + self.idx = self.idx + n + return b + + def get_byte(self): + return self.get_bytes(1) + + def get_boolean(self): + b = self.get_bytes(1) + if b == '\x00': + return 0 + else: + return 1 + + def get_int(self): + x = self.packet + i = self.idx + if i + 4 > len(x): + return 0 + n = struct.unpack('>I', x[i:i+4])[0] + self.idx = i+4 + return n + + def get_mpint(self): + return inflate_long(self.get_string()) + + def get_string(self): + l = self.get_int() + if self.idx + l > len(self.packet): + return '' + str = self.packet[self.idx:self.idx+l] + self.idx = self.idx + l + return str + + def get_list(self): + str = self.get_string() + l = string.split(str, ',') + return l + + def add_bytes(self, b): + self.packet = self.packet + b + return self + + def add_byte(self, b): + self.packet = self.packet + b + return self + + def add_boolean(self, b): + if b: + self.add_byte('\x01') + else: + self.add_byte('\x00') + return self + + def add_int(self, n): + self.packet = self.packet + struct.pack('>I', n) + return self + + def add_mpint(self, z): + "this only works on positive numbers" + self.add_string(deflate_long(z)) + return self + + def add_string(self, s): + self.add_int(len(s)) + self.packet = self.packet + s + return self + + def add_list(self, l): + out = string.join(l, ',') + self.add_int(len(out)) + self.packet = self.packet + out + return self + + def add(self, i): + if type(i) == types.StringType: + return self.add_string(i) + elif type(i) == types.IntType: + return self.add_int(i) + elif type(i) == types.LongType: + if i > 0xffffffffL: + return self.add_mpint(i) + else: + return self.add_int(i) + elif type(i) == types.ListType: + return self.add_list(i) + else: + raise exception('Unknown type') diff --git a/rsakey.py b/rsakey.py new file mode 100644 index 0000000..49c1c28 --- /dev/null +++ b/rsakey.py @@ -0,0 +1,102 @@ +#!/usr/bin/python + +from message import Message +from transport import MSG_USERAUTH_REQUEST +from Crypto.PublicKey import RSA +from Crypto.Hash import SHA +from ber import BER +from util import format_binary, inflate_long, deflate_long +import base64 + +class RSAKey(object): + + def __init__(self, msg=None): + self.valid = 0 + if (msg == None) or (msg.get_string() != 'ssh-rsa'): + return + self.e = msg.get_mpint() + self.n = msg.get_mpint() + self.size = len(deflate_long(self.n, 0)) + self.valid = 1 + + def __str__(self): + if not self.valid: + return '' + m = Message() + m.add_string('ssh-rsa') + m.add_mpint(self.e) + m.add_mpint(self.n) + return str(m) + + def get_name(self): + return 'ssh-rsa' + + def pkcs1imify(self, data): + """ + turn a 20-byte SHA1 hash into a blob of data as large as the key's N, + using PKCS1's \"emsa-pkcs1-v1_5\" encoding. totally bizarre. + """ + SHA1_DIGESTINFO = '\x30\x21\x30\x09\x06\x05\x2b\x0e\x03\x02\x1a\x05\x00\x04\x14' + filler = '\xff' * (self.size - len(SHA1_DIGESTINFO) - len(data) - 3) + return '\x00\x01' + filler + '\x00' + SHA1_DIGESTINFO + data + + def verify_ssh_sig(self, data, msg): + if (not self.valid) or (msg.get_string() != 'ssh-rsa'): + return 0 + sig = inflate_long(msg.get_string(), 1) + # verify the signature by SHA'ing the data and encrypting it using the + # public key. some wackiness ensues where we "pkcs1imify" the 20-byte + # hash into a string as long as the RSA key. + hash = inflate_long(self.pkcs1imify(SHA.new(data).digest()), 1) + rsa = RSA.construct((long(self.n), long(self.e))) + return rsa.verify(hash, (sig,)) + + def sign_ssh_data(self, data): + hash = SHA.new(data).digest() + rsa = RSA.construct((long(self.n), long(self.e), long(self.d))) + sig = deflate_long(rsa.sign(self.pkcs1imify(hash), '')[0], 0) + m = Message() + m.add_string('ssh-rsa') + m.add_string(sig) + return str(m) + + def read_private_key_file(self, filename): + # private key file contains: + # RSAPrivateKey = { version = 0, n, e, d, p, q, d mod p-1, d mod q-1, q**-1 mod p } + self.valid = 0 + try: + f = open(filename, 'r') + lines = f.readlines() + f.close() + except: + return + if lines[0].strip() != '-----BEGIN RSA PRIVATE KEY-----': + return + try: + data = base64.decodestring(''.join(lines[1:-1])) + except: + return + keylist = BER(data).decode() + if (type(keylist) != type([])) or (len(keylist) < 4) or (keylist[0] != 0): + return + self.n = keylist[1] + self.e = keylist[2] + self.d = keylist[3] + # not really needed + self.p = keylist[4] + self.q = keylist[5] + self.size = len(deflate_long(self.n, 0)) + self.valid = 1 + + def sign_ssh_session(self, randpool, sid, username): + m = Message() + m.add_string(sid) + m.add_byte(chr(MSG_USERAUTH_REQUEST)) + m.add_string(username) + m.add_string('ssh-connection') + m.add_string('publickey') + m.add_boolean(1) + m.add_string('ssh-rsa') + m.add_string(str(self)) + return self.sign_ssh_data(str(m)) + diff --git a/secsh.py b/secsh.py new file mode 100644 index 0000000..6f12494 --- /dev/null +++ b/secsh.py @@ -0,0 +1,23 @@ +#!/usr/bin/python + +import sys + +if (sys.version_info[0] < 2) or ((sys.version_info[0] == 2) and (sys.version_info[1] < 3)): + raise RuntimeError('You need python 2.3 for this module.') + +# FIXME rename +class SSHException(Exception): + pass + + +from auth_transport import Transport +from channel import Channel +from rsakey import RSAKey +from dsskey import DSSKey + + +__author__ = "Robey Pointer " +__date__ = "18 Sep 2003" +__version__ = "0.1-bulbasaur" +__credits__ = "Huzzah!" + diff --git a/setup.py b/setup.py new file mode 100644 index 0000000..64b3401 --- /dev/null +++ b/setup.py @@ -0,0 +1,30 @@ +from distutils.core import setup + +longdesc = ''' +This is a library for making client-side SSH2 connections (server-side is +coming soon). All major ciphers and hash methods are supported. + +Required packages: + pyCrypto +''' + +setup(name = "secsh", + version = "0.1-bulbasaur", + description = "SSH2 protocol library", + author = "Robey Pointer", + author_email = "robey@lag.net", + url = "http://www.lag.net/~robey/secsh/", + py_modules = [ 'secsh', 'transport', 'channel', 'message', 'util', 'ber', + 'kex_group1', 'kex_gex', 'rsakey', 'dsskey' ], + scripts = [ 'demo.py' ], + download_url = 'http://www.lag.net/~robey/secsh/secsh-0.1-bulbasaur.zip', + license = 'LGPL', + platforms = 'Posix; MacOS X; Windows', + classifiers = [ 'Development Status :: 3 - Alpha', + 'Intended Audience :: Developers', + 'License :: OSI Approved :: GNU Library or Lesser General Public License (LGPL)', + 'Operating System :: OS Independent', + 'Topic :: Internet', + 'Topic :: Security :: Cryptography' ], + long_description = longdesc, + ) diff --git a/transport.py b/transport.py new file mode 100644 index 0000000..9790f2b --- /dev/null +++ b/transport.py @@ -0,0 +1,758 @@ +#!/usr/bin/python + +MSG_DISCONNECT, MSG_IGNORE, MSG_UNIMPLEMENTED, MSG_DEBUG, MSG_SERVICE_REQUEST, \ + MSG_SERVICE_ACCEPT = range(1, 7) +MSG_KEXINIT, MSG_NEWKEYS = range(20, 22) +MSG_USERAUTH_REQUEST, MSG_USERAUTH_FAILURE, MSG_USERAUTH_SUCCESS, \ + MSG_USERAUTH_BANNER = range(50, 54) +MSG_USERAUTH_PK_OK = 60 +MSG_CHANNEL_OPEN, MSG_CHANNEL_OPEN_SUCCESS, MSG_CHANNEL_OPEN_FAILURE, \ + MSG_CHANNEL_WINDOW_ADJUST, MSG_CHANNEL_DATA, MSG_CHANNEL_EXTENDED_DATA, \ + MSG_CHANNEL_EOF, MSG_CHANNEL_CLOSE, MSG_CHANNEL_REQUEST, \ + MSG_CHANNEL_SUCCESS, MSG_CHANNEL_FAILURE = range(90, 101) + + +import sys, os, string, threading, socket, logging, struct +from message import Message +from channel import Channel +from secsh import SSHException +from util import format_binary, safe_string, inflate_long, deflate_long +from rsakey import RSAKey +from dsskey import DSSKey +from kex_group1 import KexGroup1 +from kex_gex import KexGex + +# these come from PyCrypt +# http://www.amk.ca/python/writing/pycrypt/ +# i believe this on the standards track. +# PyCrypt compiled for Win32 can be downloaded from the HashTar homepage: +# http://nitace.bsd.uchicago.edu:8080/hashtar +from Crypto.Util.randpool import PersistentRandomPool, RandomPool +from Crypto.Cipher import Blowfish, AES, DES3 +from Crypto.Hash import SHA, MD5, HMAC +from Crypto.PublicKey import RSA + +from logging import DEBUG, INFO, WARNING, ERROR, CRITICAL + + +# channel request failed reasons: +CONNECTION_FAILED_CODE = { + 1: 'Administratively prohibited', + 2: 'Connect failed', + 3: 'Unknown channel type', + 4: 'Resource shortage' +} + + +# keep a crypto-strong PRNG nearby +try: + randpool = PersistentRandomPool(os.getenv('HOME') + '/.randpool') +except: + # the above will likely fail on Windows - fall back to non-persistent random pool + randpool = RandomPool() + +randpool.randomize() + + +class BaseTransport(threading.Thread): + ''' + An SSH Transport attaches to a stream (usually a socket), negotiates an + encrypted session, authenticates, and then creates stream tunnels, called + "channels", across the session. Multiple channels can be multiplexed + across a single session (and often are, in the case of port forwardings). + + Transport expects to receive a "socket-like object" to talk to the SSH + server. This means it has a method "settimeout" which sets a timeout for + read/write calls, and a method "send()" to write bytes and "recv()" to + read bytes. "recv" returns from 1 to n bytes, or 0 if the stream has been + closed. EOFError may also be raised on a closed stream. (A return value + of 0 is converted to an EOFError internally.) "send(s)" writes from 1 to + len(s) bytes, and returns the number of bytes written, or returns 0 if the + stream has been closed. As with instream, EOFError may be raised instead + of returning 0. + + FIXME: Describe events here. + ''' + + PROTO_ID = '2.0' + CLIENT_ID = 'pyssh_1.1' + + preferred_ciphers = [ 'aes128-cbc', 'blowfish-cbc', 'aes256-cbc', '3des-cbc' ] + preferred_macs = [ 'hmac-sha1', 'hmac-md5', 'hmac-sha1-96', 'hmac-md5-96' ] + preferred_keys = [ 'ssh-rsa', 'ssh-dss' ] + preferred_kex = [ 'diffie-hellman-group1-sha1', 'diffie-hellman-group-exchange-sha1' ] + + cipher_info = { + 'blowfish-cbc': { 'class': Blowfish, 'mode': Blowfish.MODE_CBC, 'block-size': 8, 'key-size': 16 }, + 'aes128-cbc': { 'class': AES, 'mode': AES.MODE_CBC, 'block-size': 16, 'key-size': 16 }, + 'aes256-cbc': { 'class': AES, 'mode': AES.MODE_CBC, 'block-size': 16, 'key-size': 32 }, + '3des-cbc': { 'class': DES3, 'mode': DES3.MODE_CBC, 'block-size': 8, 'key-size': 24 }, + } + + mac_info = { + 'hmac-sha1': { 'class': SHA, 'size': 20 }, + 'hmac-sha1-96': { 'class': SHA, 'size': 12 }, + 'hmac-md5': { 'class': MD5, 'size': 16 }, + 'hmac-md5-96': { 'class': MD5, 'size': 12 }, + } + + kex_info = { + 'diffie-hellman-group1-sha1': KexGroup1, + 'diffie-hellman-group-exchange-sha1': KexGex, + } + + REKEY_PACKETS = pow(2, 30) + REKEY_BYTES = pow(2, 30) + + def __init__(self, sock): + threading.Thread.__init__(self) + self.randpool = randpool + self.sock = sock + self.sock.settimeout(0.1) + # negotiated crypto parameters + self.local_version = 'SSH-' + self.PROTO_ID + '-' + self.CLIENT_ID + self.remote_version = '' + self.block_size_out = self.block_size_in = 8 + self.local_mac_len = self.remote_mac_len = 0 + self.engine_in = self.engine_out = None + self.local_cipher = self.remote_cipher = '' + self.sequence_number_in = self.sequence_number_out = 0L + self.local_kex_init = self.remote_kex_init = None + self.session_id = None + # /negotiated crypto parameters + self.expected_packet = 0 + self.active = 0 + self.initial_kex_done = 0 + self.write_lock = threading.Lock() # lock around outbound writes (packet computation) + self.lock = threading.Lock() # synchronization (always higher level than write_lock) + self.authenticated = 0 + self.channels = { } # (id -> Channel) + self.channel_events = { } # (id -> Event) + self.channel_counter = 1 + self.logger = logging.getLogger('secsh.transport') + self.window_size = 65536 + self.max_packet_size = 2048 + self.ultra_debug = 0 + # used for noticing when to re-key: + self.received_bytes = 0 + self.received_packets = 0 + self.received_packets_overflow = 0 + # user-defined event callbacks: + self.completion_event = None + # server mode: + self.server_mode = 0 + self.server_key_dict = { } + + def start_client(self, event=None): + self.completion_event = event + self.start() + + def start_server(self, event=None): + self.server_mode = 1 + self.completion_event = event + self.start() + + def add_server_key(self, key): + self.server_key_dict[key.get_name()] = key + + def get_server_key(self): + try: + return self.server_key_dict[self.host_key_type] + except KeyError: + return None + + def __repr__(self): + if not self.active: + return '' + out = ' 0: + try: + x = self.sock.recv(n) + if len(x) == 0: + raise EOFError() + out += x + n -= len(x) + except socket.timeout: + if not self.active: + raise EOFError() + return out + + def write_all(self, out): + while len(out) > 0: + n = self.sock.send(out) + if n <= 0: + raise EOFError() + if n == len(out): + return + out = out[n:] + return + + def build_packet(self, payload): + # pad up at least 4 bytes, to nearest block-size (usually 8) + bsize = self.block_size_out + padding = 3 + bsize - ((len(payload) + 8) % bsize) + packet = struct.pack('>I', len(payload) + padding + 1) + packet += chr(padding) + packet += payload + packet += randpool.get_bytes(padding) + return packet + + def send_message(self, data): + # encrypt this sucka + packet = self.build_packet(str(data)) + if self.ultra_debug: + self.log(DEBUG, format_binary(packet, 'OUT: ')) + if self.engine_out != None: + out = self.engine_out.encrypt(packet) + else: + out = packet + # + mac + try: + self.write_lock.acquire() + if self.engine_out != None: + payload = struct.pack('>I', self.sequence_number_out) + packet + out += HMAC.HMAC(self.mac_key_out, payload, self.local_mac_engine).digest()[:self.local_mac_len] + self.sequence_number_out += 1L + self.sequence_number_out %= 0x100000000L + self.write_all(out) + finally: + self.write_lock.release() + + def read_message(self): + "only one thread will ever be in this function" + header = self.read_all(self.block_size_in) + if self.engine_in != None: + header = self.engine_in.decrypt(header) + if self.ultra_debug: + self.log(DEBUG, format_binary(header, 'IN: ')); + packet_size = struct.unpack('>I', header[:4])[0] + # leftover contains decrypted bytes from the first block (after the length field) + leftover = header[4:] + if (packet_size - len(leftover)) % self.block_size_in != 0: + raise SSHException('Invalid packet blocking') + buffer = self.read_all(packet_size + self.remote_mac_len - len(leftover)) + packet = buffer[:packet_size - len(leftover)] + post_packet = buffer[packet_size - len(leftover):] + if self.engine_in != None: + packet = self.engine_in.decrypt(packet) + if self.ultra_debug: + self.log(DEBUG, format_binary(packet, 'IN: ')); + packet = leftover + packet + if self.remote_mac_len > 0: + mac = post_packet[:self.remote_mac_len] + mac_payload = struct.pack('>II', self.sequence_number_in, packet_size) + packet + my_mac = HMAC.HMAC(self.mac_key_in, mac_payload, self.remote_mac_engine).digest()[:self.remote_mac_len] + if my_mac != mac: + raise SSHException('Mismatched MAC') + padding = ord(packet[0]) + payload = packet[1:packet_size - padding + 1] + randpool.add_event(packet[packet_size - padding + 1]) + #self.log(DEBUG, 'Got payload (%d bytes, %d padding)' % (packet_size, padding)) + msg = Message(payload[1:]) + msg.seqno = self.sequence_number_in + self.sequence_number_in = (self.sequence_number_in + 1) & 0xffffffffL + # check for rekey + self.received_bytes += packet_size + self.remote_mac_len + 4 + self.received_packets += 1 + if (self.received_packets >= self.REKEY_PACKETS) or (self.received_bytes >= self.REKEY_BYTES): + # only ask once for rekeying + if self.local_kex_init is None: + self.log(DEBUG, 'Rekeying (hit %d packets, %d bytes)' % (self.received_packets, + self.received_bytes)) + self.received_packets_overflow = 0 + self.send_kex_init() + else: + # we've asked to rekey already -- give them 20 packets to + # comply, then just drop the connection + self.received_packets_overflow += 1 + if self.received_packets_overflow >= 20: + raise SSHException('Remote transport is ignoring rekey requests') + + return ord(payload[0]), msg + + def set_K_H(self, k, h): + "used by a kex object to set the K (root key) and H (exchange hash)" + self.K = k + self.H = h + if self.session_id == None: + self.session_id = h + + def verify_key(self, host_key, sig): + if self.host_key_type == 'ssh-rsa': + key = RSAKey(Message(host_key)) + elif self.host_key_type == 'ssh-dss': + key = DSSKey(Message(host_key)) + else: + key = None + if (key == None) or not key.valid: + raise SSHException('Unknown host key type') + if not key.verify_ssh_sig(self.H, Message(sig)): + raise SSHException('Signature verification (%s) failed. Boo. Robey should debug this.' % self.host_key_type) + self.host_key = host_key + + def compute_key(self, id, nbytes): + "id is 'A' - 'F' for the various keys used by ssh" + m = Message() + m.add_mpint(self.K) + m.add_bytes(self.H) + m.add_byte(id) + m.add_bytes(self.session_id) + out = sofar = SHA.new(str(m)).digest() + while len(out) < nbytes: + m = Message() + m.add_mpint(self.K) + m.add_bytes(self.H) + m.add_bytes(sofar) + hash = SHA.new(str(m)).digest() + out += hash + sofar += hash + return out[:nbytes] + + def get_cipher(self, name, key, iv): + if not self.cipher_info.has_key(name): + raise SSHException('Unknown client cipher ' + name) + return self.cipher_info[name]['class'].new(key, self.cipher_info[name]['mode'], iv) + + def run(self): + self.active = 1 + try: + # SSH-1.99-OpenSSH_2.9p2 + self.write_all(self.local_version + '\r\n') + self.check_banner() + self.send_kex_init() + self.expected_packet = MSG_KEXINIT + + while self.active: + ptype, m = self.read_message() + if ptype == MSG_IGNORE: + continue + elif ptype == MSG_DISCONNECT: + self.parse_disconnect(m) + self.active = 0 + break + elif ptype == MSG_DEBUG: + self.parse_debug(m) + continue + if self.expected_packet != 0: + if ptype != self.expected_packet: + raise SSHException('Expecting packet %d, got %d' % (self.expected_packet, ptype)) + self.expected_packet = 0 + if (ptype >= 30) and (ptype <= 39): + self.kex_engine.parse_next(ptype, m) + continue + + if self.handler_table.has_key(ptype): + self.handler_table[ptype](self, m) + elif self.channel_handler_table.has_key(ptype): + chanid = m.get_int() + if self.channels.has_key(chanid): + self.channel_handler_table[ptype](self.channels[chanid], m) + else: + self.log(WARNING, 'Oops, unhandled type %d' % ptype) + msg = Message() + msg.add_byte(chr(MSG_UNIMPLEMENTED)) + msg.add_int(m.seqno) + self.send_message(msg) + except SSHException, e: + self.log(DEBUG, 'Exception: ' + str(e)) + except EOFError, e: + self.log(DEBUG, 'EOF') + except Exception, e: + self.log(DEBUG, 'Unknown exception: ' + str(e)) + if self.active: + self.active = 0 + if self.completion_event != None: + self.completion_event.set() + if self.auth_event != None: + self.auth_event.set() + for e in self.channel_events.values(): + e.set() + self.sock.close() + + ### protocol stages + + def renegotiate_keys(self): + self.completion_event = threading.Event() + self.send_kex_init() + while 1: + self.completion_event.wait(0.1); + if not self.active: + return 0 + if self.completion_event.isSet(): + break + return 1 + + def negotiate_keys(self, m): + # throws SSHException on anything unusual + if self.local_kex_init == None: + # remote side wants to renegotiate + self.send_kex_init() + self.parse_kex_init(m) + self.kex_engine.start_kex() + + def check_banner(self): + # this is slow, but we only have to do it once + for i in range(5): + buffer = '' + while not '\n' in buffer: + buffer += self.read_all(1) + buffer = buffer[:-1] + if (len(buffer) > 0) and (buffer[-1] == '\r'): + buffer = buffer[:-1] + if buffer[:4] == 'SSH-': + break + self.log(DEBUG, 'Banner: ' + buffer) + if buffer[:4] != 'SSH-': + raise SSHException('Indecipherable protocol version "' + buffer + '"') + # save this server version string for later + self.remote_version = buffer + # pull off any attached comment + comment = '' + i = string.find(buffer, ' ') + if i >= 0: + comment = buffer[i+1:] + buffer = buffer[:i] + # parse out version string and make sure it matches + _unused, version, client = string.split(buffer, '-') + if version != '1.99' and version != '2.0': + raise SSHException('Incompatible version (%s instead of 2.0)' % (version,)) + self.log(INFO, 'Connected (version %s, client %s)' % (version, client)) + + def send_kex_init(self): + # send a really wimpy kex-init packet that says we're a bare-bones ssh client + m = Message() + m.add_byte(chr(MSG_KEXINIT)) + m.add_bytes(randpool.get_bytes(16)) + m.add(','.join(self.preferred_kex)) + m.add(','.join(self.preferred_keys)) + m.add(','.join(self.preferred_ciphers)) + m.add(','.join(self.preferred_ciphers)) + m.add(','.join(self.preferred_macs)) + m.add(','.join(self.preferred_macs)) + m.add('none') + m.add('none') + m.add('') + m.add('') + m.add_boolean(0) + m.add_int(0) + # save a copy for later (needed to compute a hash) + self.local_kex_init = str(m) + self.send_message(m) + + def parse_kex_init(self, m): + # reset counters of when to re-key, since we are now re-keying + self.received_bytes = 0 + self.received_packets = 0 + self.received_packets_overflow = 0 + + cookie = m.get_bytes(16) + kex_algo_list = m.get_list() + server_key_algo_list = m.get_list() + client_encrypt_algo_list = m.get_list() + server_encrypt_algo_list = m.get_list() + client_mac_algo_list = m.get_list() + server_mac_algo_list = m.get_list() + client_compress_algo_list = m.get_list() + server_compress_algo_list = m.get_list() + client_lang_list = m.get_list() + server_lang_list = m.get_list() + kex_follows = m.get_boolean() + unused = m.get_int() + + # no compression support (yet?) + if (not('none' in client_compress_algo_list) or + not('none' in server_compress_algo_list)): + raise SSHException('Incompatible ssh peer.') + + # as a server, we pick the first item in the client's list that we support. + # as a client, we pick the first item in our list that the server supports. + if self.server_mode: + agreed_kex = filter(self.preferred_kex.__contains__, kex_algo_list) + else: + agreed_kex = filter(kex_algo_list.__contains__, self.preferred_kex) + if len(agreed_kex) == 0: + raise SSHException('Incompatible ssh peer (no acceptable kex algorithm)') + self.kex_engine = self.kex_info[agreed_kex[0]](self) + + if self.server_mode: + agreed_keys = filter(self.preferred_keys.__contains__, server_key_algo_list) + else: + agreed_keys = filter(server_key_algo_list.__contains__, self.preferred_keys) + if len(agreed_keys) == 0: + raise SSHException('Incompatible ssh peer (no acceptable host key)') + self.host_key_type = agreed_keys[0] + if self.server_mode and (self.get_server_key() is None): + raise SSHException('Incompatible ssh peer (can\'t match requested host key type)') + + if self.server_mode: + agreed_local_ciphers = filter(self.preferred_ciphers.__contains__, + server_encrypt_algo_list) + agreed_remote_ciphers = filter(self.preferred_ciphers.__contains__, + client_encrypt_algo_list) + else: + agreed_local_ciphers = filter(client_encrypt_algo_list.__contains__, + self.preferred_ciphers) + agreed_remote_ciphers = filter(server_encrypt_algo_list.__contains__, + self.preferred_ciphers) + if (len(agreed_local_ciphers) == 0) or (len(agreed_remote_ciphers) == 0): + raise SSHException('Incompatible ssh server (no acceptable ciphers)') + self.local_cipher = agreed_local_ciphers[0] + self.remote_cipher = agreed_remote_ciphers[0] + self.log(DEBUG, 'Ciphers agreed: local=%s, remote=%s' % (self.local_cipher, self.remote_cipher)) + + if self.server_mode: + agreed_remote_macs = filter(self.preferred_macs.__contains__, client_mac_algo_list) + agreed_local_macs = filter(self.preferred_macs.__contains__, server_mac_algo_list) + else: + agreed_local_macs = filter(client_mac_algo_list.__contains__, self.preferred_macs) + agreed_remote_macs = filter(server_mac_algo_list.__contains__, self.preferred_macs) + if (len(agreed_local_macs) == 0) or (len(agreed_remote_macs) == 0): + raise SSHException('Incompatible ssh server (no acceptable macs)') + self.local_mac = agreed_local_macs[0] + self.remote_mac = agreed_remote_macs[0] + + self.log(DEBUG, 'kex algos:' + str(kex_algo_list) + ' server key:' + str(server_key_algo_list) + \ + ' client encrypt:' + str(client_encrypt_algo_list) + \ + ' server encrypt:' + str(server_encrypt_algo_list) + \ + ' client mac:' + str(client_mac_algo_list) + \ + ' server mac:' + str(server_mac_algo_list) + \ + ' client compress:' + str(client_compress_algo_list) + \ + ' server compress:' + str(server_compress_algo_list) + \ + ' client lang:' + str(client_lang_list) + \ + ' server lang:' + str(server_lang_list) + \ + ' kex follows?' + str(kex_follows)) + self.log(DEBUG, 'using kex %s; server key type %s; cipher: local %s, remote %s; mac: local %s, remote %s' % + (agreed_kex[0], self.host_key_type, self.local_cipher, self.remote_cipher, self.local_mac, + self.remote_mac)) + + # save for computing hash later... + # now wait! openssh has a bug (and others might too) where there are + # actually some extra bytes (one NUL byte in openssh's case) added to + # the end of the packet but not parsed. turns out we need to throw + # away those bytes because they aren't part of the hash. + self.remote_kex_init = chr(MSG_KEXINIT) + m.get_so_far() + + def activate_inbound(self): + "switch on newly negotiated encryption parameters for inbound traffic" + self.block_size_in = self.cipher_info[self.remote_cipher]['block-size'] + if self.server_mode: + IV_in = self.compute_key('A', self.block_size_in) + key_in = self.compute_key('C', self.cipher_info[self.remote_cipher]['key-size']) + else: + IV_in = self.compute_key('B', self.block_size_in) + key_in = self.compute_key('D', self.cipher_info[self.remote_cipher]['key-size']) + self.engine_in = self.get_cipher(self.remote_cipher, key_in, IV_in) + self.remote_mac_len = self.mac_info[self.remote_mac]['size'] + self.remote_mac_engine = self.mac_info[self.remote_mac]['class'] + # initial mac keys are done in the hash's natural size (not the potentially truncated + # transmission size) + if self.server_mode: + self.mac_key_in = self.compute_key('E', self.remote_mac_engine.digest_size) + else: + self.mac_key_in = self.compute_key('F', self.remote_mac_engine.digest_size) + + def activate_outbound(self): + "switch on newly negotiated encryption parameters for outbound traffic" + m = Message() + m.add_byte(chr(MSG_NEWKEYS)) + self.send_message(m) + self.block_size_out = self.cipher_info[self.local_cipher]['block-size'] + if self.server_mode: + IV_out = self.compute_key('B', self.block_size_out) + key_out = self.compute_key('D', self.cipher_info[self.local_cipher]['key-size']) + else: + IV_out = self.compute_key('A', self.block_size_out) + key_out = self.compute_key('C', self.cipher_info[self.local_cipher]['key-size']) + self.engine_out = self.get_cipher(self.local_cipher, key_out, IV_out) + self.local_mac_len = self.mac_info[self.local_mac]['size'] + self.local_mac_engine = self.mac_info[self.local_mac]['class'] + # initial mac keys are done in the hash's natural size (not the potentially truncated + # transmission size) + if self.server_mode: + self.mac_key_out = self.compute_key('F', self.local_mac_engine.digest_size) + else: + self.mac_key_out = self.compute_key('E', self.local_mac_engine.digest_size) + + def parse_newkeys(self, m): + self.log(DEBUG, 'Switch to new keys ...') + self.activate_inbound() + # can also free a bunch of stuff here + self.local_kex_init = self.remote_kex_init = None + self.e = self.f = self.K = self.x = None + if not self.initial_kex_done: + # this was the first key exchange + self.initial_kex_done = 1 + # send an event? + if self.completion_event != None: + self.completion_event.set() + return + + def parse_disconnect(self, m): + code = m.get_int() + desc = m.get_string() + self.log(INFO, 'Disconnect (code %d): %s' % (code, desc)) + def parse_channel_open_success(self, m): + chanid = m.get_int() + server_chanid = m.get_int() + server_window_size = m.get_int() + server_max_packet_size = m.get_int() + if not self.channels.has_key(chanid): + self.log(WARNING, 'Success for unrequested channel! [??]') + return + try: + self.lock.acquire() + chan = self.channels[chanid] + chan.set_server_channel(server_chanid, server_window_size, server_max_packet_size) + self.log(INFO, 'Secsh channel %d opened.' % chanid) + if self.channel_events.has_key(chanid): + self.channel_events[chanid].set() + del self.channel_events[chanid] + finally: + self.lock.release() + return + + def parse_channel_open_failure(self, m): + chanid = m.get_int() + reason = m.get_int() + reason_str = m.get_string() + lang = m.get_string() + if CONNECTION_FAILED_CODE.has_key(reason): + reason_text = CONNECTION_FAILED_CODE[reason] + else: + reason_text = '(unknown code)' + self.log(INFO, 'Secsh channel %d open FAILED: %s: %s' % (chanid, reason_str, reason_text)) + try: + self.lock.aquire() + if self.channels.has_key(chanid): + del self.channels[chanid] + if self.channel_events.has_key(chanid): + self.channel_events[chanid].set() + del self.channel_events[chanid] + finally: + self.lock_release() + return + + def parse_channel_open(self, m): + kind = m.get_string() + self.log(DEBUG, 'Rejecting "%s" channel request from server.' % kind) + chanid = m.get_int() + msg = Message() + msg.add_byte(chr(MSG_CHANNEL_OPEN_FAILURE)) + msg.add_int(chanid) + msg.add_int(1) + msg.add_string('Client connections are not allowed.') + msg.add_string('en') + self.send_message(msg) + + def parse_debug(self, m): + always_display = m.get_boolean() + msg = m.get_string() + lang = m.get_string() + self.log(DEBUG, 'Debug msg: ' + safe_string(msg)) + + handler_table = { + MSG_NEWKEYS: parse_newkeys, + MSG_CHANNEL_OPEN_SUCCESS: parse_channel_open_success, + MSG_CHANNEL_OPEN_FAILURE: parse_channel_open_failure, + MSG_CHANNEL_OPEN: parse_channel_open, + MSG_KEXINIT: negotiate_keys, + } + + channel_handler_table = { + MSG_CHANNEL_SUCCESS: Channel.request_success, + MSG_CHANNEL_FAILURE: Channel.request_failed, + MSG_CHANNEL_DATA: Channel.feed, + MSG_CHANNEL_WINDOW_ADJUST: Channel.window_adjust, + MSG_CHANNEL_REQUEST: Channel.handle_request, + MSG_CHANNEL_EOF: Channel.handle_eof, + MSG_CHANNEL_CLOSE: Channel.handle_close, + } diff --git a/util.py b/util.py new file mode 100644 index 0000000..2834972 --- /dev/null +++ b/util.py @@ -0,0 +1,89 @@ +#!/usr/bin/python + +import struct + +def inflate_long(s, always_positive=0): + "turns a normalized byte string into a long-int (adapted from Crypto.Util.number)" + out = 0L + negative = 0 + if not always_positive and (len(s) > 0) and (ord(s[0]) >= 0x80): + negative = 1 + if len(s) % 4: + filler = '\x00' + if negative: + filler = '\xff' + s = filler * (4 - len(s) % 4) + s + for i in range(0, len(s), 4): + out = (out << 32) + struct.unpack('>I', s[i:i+4])[0] + if negative: + out -= (1L << (8 * len(s))) + return out + +def deflate_long(n, add_sign_padding=1): + "turns a long-int into a normalized byte string (adapted from Crypto.Util.number)" + # after much testing, this algorithm was deemed to be the fastest + s = '' + n = long(n) + while (n != 0) and (n != -1): + s = struct.pack('>I', n & 0xffffffffL) + s + n = n >> 32 + # strip off leading zeros, FFs + for i in enumerate(s): + if (n == 0) and (i[1] != '\000'): + break + if (n == -1) and (i[1] != '\xff'): + break + else: + # degenerate case, n was either 0 or -1 + i = (0,) + if n == 0: + s = '\000' + else: + s = '\xff' + s = s[i[0]:] + if add_sign_padding: + if (n == 0) and (ord(s[0]) >= 0x80): + s = '\x00' + s + if (n == -1) and (ord(s[0]) < 0x80): + s = '\xff' + s + return s + +def format_binary_weird(data): + out = '' + for i in enumerate(data): + out += '%02X' % ord(i[1]) + if i[0] % 2: + out += ' ' + if i[0] % 16 == 15: + out += '\n' + return out + +def format_binary(data, prefix=''): + x = 0 + out = [] + while len(data) > x + 16: + out.append(format_binary_line(data[x:x+16])) + x += 16 + if x < len(data): + out.append(format_binary_line(data[x:])) + return [prefix + x for x in out] + +def format_binary_line(data): + left = ' '.join(['%02X' % ord(c) for c in data]) + right = ''.join([('.%c..' % c)[(ord(c)+61)//94] for c in data]) + return '%-50s %s' % (left, right) + +def hexify(s): + "turn a string into a hex sequence" + return ''.join(['%02X' % ord(c) for c in s]) + +def safe_string(s): + out = '' + for c in s: + if (ord(c) >= 32) and (ord(c) <= 127): + out += c + else: + out += '%%%02X' % ord(c) + return out + +# ''.join([['%%%02X' % ord(c), c][(ord(c) >= 32) and (ord(c) <= 127)] for c in s])