├── .github └── workflows │ └── gradle.yml ├── .gitignore ├── LICENSE ├── README.md ├── app ├── .gitignore ├── build.gradle ├── lint-baseline.xml ├── proguard-rules.pro └── src │ ├── androidTest │ └── java │ │ └── com │ │ └── sshdaemon │ │ └── ExampleInstrumentedTest.java │ ├── main │ ├── AndroidManifest.xml │ ├── ic_launcher-playstore.png │ ├── java │ │ └── com │ │ │ └── sshdaemon │ │ │ ├── MainActivity.java │ │ │ ├── net │ │ │ └── NetworkChangeReceiver.java │ │ │ ├── sshd │ │ │ ├── SshDaemon.java │ │ │ ├── SshFingerprint.java │ │ │ ├── SshPassword.java │ │ │ ├── SshPasswordAuthenticator.java │ │ │ ├── SshPublicKeyAuthenticator.java │ │ │ └── UnknownPublicKeyFormatException.java │ │ │ └── util │ │ │ ├── AndroidLogger.java │ │ │ ├── ExternalStorage.java │ │ │ └── TextViewHelper.java │ └── res │ │ ├── drawable │ │ ├── ic_launcher_foreground.xml │ │ ├── ic_launcher_foreground_monochrome.xml │ │ ├── key_black_24dp.xml │ │ ├── key_off_black_24dp.xml │ │ ├── pause_black_24dp.xml │ │ ├── play_arrow_black_24dp.xml │ │ ├── play_arrow_fill0_wght400_grad0_opsz48.xml │ │ ├── show_password_selector.xml │ │ ├── visibility_black_24dp.xml │ │ └── visibility_off_black_24dp.xml │ │ ├── layout │ │ └── main_activity.xml │ │ ├── logo-monochrome.svg │ │ ├── logo.png │ │ ├── logo.svg │ │ ├── mipmap │ │ └── ic_launcher.xml │ │ ├── values-pt-rBR │ │ └── strings.xml │ │ ├── values-ru │ │ └── strings.xml │ │ ├── values-uk │ │ └── strings.xml │ │ └── values │ │ ├── colors.xml │ │ ├── ic_launcher_background.xml │ │ ├── strings.xml │ │ ├── styles.xml │ │ └── themes.xml │ └── test │ ├── java │ └── com │ │ └── sshdaemon │ │ ├── net │ │ └── NetworkChangeReceiverTest.java │ │ ├── sshd │ │ ├── SshDaemonPerformanceTest.java │ │ ├── SshDaemonTest.java │ │ ├── SshFingerprintTest.java │ │ ├── SshPasswordTest.java │ │ └── SshPublicKeyAuthenticatorTest.java │ │ └── util │ │ └── ExternalStorageTest.java │ └── resources │ ├── authorized_keys │ ├── id_dsa.pub │ ├── ssh_host_rsa_key │ └── ssh_host_rsa_key.pub ├── build.gradle ├── fastlane └── metadata │ └── android │ ├── de │ ├── description.txt │ └── summary.txt │ ├── en-US │ ├── description.txt │ ├── images │ │ ├── featureGraphic.png │ │ └── icon.png │ ├── phoneScreenshots │ │ └── 01.png │ ├── sevenInchScreenshots │ │ └── 01.png │ └── summary.txt │ ├── pt-BR │ ├── description.txt │ └── summary.txt │ └── uk │ ├── description.txt │ └── summary.txt ├── gradle.properties ├── gradle └── wrapper │ ├── gradle-wrapper.jar │ └── gradle-wrapper.properties ├── gradlew ├── gradlew.bat ├── portforward.sh └── settings.gradle /.github/workflows/gradle.yml: -------------------------------------------------------------------------------- 1 | # This workflow uses actions that are not certified by GitHub. 2 | # They are provided by a third-party and are governed by 3 | # separate terms of service, privacy policy, and support 4 | # documentation. 5 | # This workflow will build a Java project with Gradle and cache/restore any dependencies to improve the workflow execution time 6 | # For more information see: https://docs.github.com/en/actions/automating-builds-and-tests/building-and-testing-java-with-gradle 7 | 8 | name: Java CI with Gradle 9 | 10 | on: 11 | push: 12 | branches: [ "master" ] 13 | pull_request: 14 | branches: [ "master" ] 15 | 16 | permissions: 17 | contents: read 18 | 19 | jobs: 20 | build: 21 | 22 | runs-on: ubuntu-latest 23 | 24 | steps: 25 | - uses: actions/checkout@v4 26 | - name: Set up JDK 17 27 | uses: actions/setup-java@v3 28 | with: 29 | java-version: '17' 30 | distribution: 'temurin' 31 | - name: Build with Gradle 32 | uses: gradle/gradle-build-action@v2 33 | with: 34 | arguments: build --continue 35 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | *.iml 2 | .gradle 3 | /local.properties 4 | .idea/* 5 | .DS_Store 6 | /build 7 | /captures 8 | .externalNativeBuild 9 | .cxx 10 | gradle 11 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | GNU GENERAL PUBLIC LICENSE 2 | Version 2, June 1991 3 | 4 | Copyright (C) 1989, 1991 Free Software Foundation, Inc., 5 | 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA 6 | Everyone is permitted to copy and distribute verbatim copies 7 | of this license document, but changing it is not allowed. 8 | 9 | Preamble 10 | 11 | The licenses for most software are designed to take away your 12 | freedom to share and change it. By contrast, the GNU General Public 13 | License is intended to guarantee your freedom to share and change free 14 | software--to make sure the software is free for all its users. This 15 | General Public License applies to most of the Free Software 16 | Foundation's software and to any other program whose authors commit to 17 | using it. (Some other Free Software Foundation software is covered by 18 | the GNU Lesser General Public License instead.) You can apply it to 19 | your programs, too. 20 | 21 | When we speak of free software, we are referring to freedom, not 22 | price. Our General Public Licenses are designed to make sure that you 23 | have the freedom to distribute copies of free software (and charge for 24 | this service if you wish), that you receive source code or can get it 25 | if you want it, that you can change the software or use pieces of it 26 | in new free programs; and that you know you can do these things. 27 | 28 | To protect your rights, we need to make restrictions that forbid 29 | anyone to deny you these rights or to ask you to surrender the rights. 30 | These restrictions translate to certain responsibilities for you if you 31 | distribute copies of the software, or if you modify it. 32 | 33 | For example, if you distribute copies of such a program, whether 34 | gratis or for a fee, you must give the recipients all the rights that 35 | you have. You must make sure that they, too, receive or can get the 36 | source code. And you must show them these terms so they know their 37 | rights. 38 | 39 | We protect your rights with two steps: (1) copyright the software, and 40 | (2) offer you this license which gives you legal permission to copy, 41 | distribute and/or modify the software. 42 | 43 | Also, for each author's protection and ours, we want to make certain 44 | that everyone understands that there is no warranty for this free 45 | software. If the software is modified by someone else and passed on, we 46 | want its recipients to know that what they have is not the original, so 47 | that any problems introduced by others will not reflect on the original 48 | authors' reputations. 49 | 50 | Finally, any free program is threatened constantly by software 51 | patents. We wish to avoid the danger that redistributors of a free 52 | program will individually obtain patent licenses, in effect making the 53 | program proprietary. To prevent this, we have made it clear that any 54 | patent must be licensed for everyone's free use or not licensed at all. 55 | 56 | The precise terms and conditions for copying, distribution and 57 | modification follow. 58 | 59 | GNU GENERAL PUBLIC LICENSE 60 | TERMS AND CONDITIONS FOR COPYING, DISTRIBUTION AND MODIFICATION 61 | 62 | 0. This License applies to any program or other work which contains 63 | a notice placed by the copyright holder saying it may be distributed 64 | under the terms of this General Public License. The "Program", below, 65 | refers to any such program or work, and a "work based on the Program" 66 | means either the Program or any derivative work under copyright law: 67 | that is to say, a work containing the Program or a portion of it, 68 | either verbatim or with modifications and/or translated into another 69 | language. (Hereinafter, translation is included without limitation in 70 | the term "modification".) Each licensee is addressed as "you". 71 | 72 | Activities other than copying, distribution and modification are not 73 | covered by this License; they are outside its scope. The act of 74 | running the Program is not restricted, and the output from the Program 75 | is covered only if its contents constitute a work based on the 76 | Program (independent of having been made by running the Program). 77 | Whether that is true depends on what the Program does. 78 | 79 | 1. You may copy and distribute verbatim copies of the Program's 80 | source code as you receive it, in any medium, provided that you 81 | conspicuously and appropriately publish on each copy an appropriate 82 | copyright notice and disclaimer of warranty; keep intact all the 83 | notices that refer to this License and to the absence of any warranty; 84 | and give any other recipients of the Program a copy of this License 85 | along with the Program. 86 | 87 | You may charge a fee for the physical act of transferring a copy, and 88 | you may at your option offer warranty protection in exchange for a fee. 89 | 90 | 2. You may modify your copy or copies of the Program or any portion 91 | of it, thus forming a work based on the Program, and copy and 92 | distribute such modifications or work under the terms of Section 1 93 | above, provided that you also meet all of these conditions: 94 | 95 | a) You must cause the modified files to carry prominent notices 96 | stating that you changed the files and the date of any change. 97 | 98 | b) You must cause any work that you distribute or publish, that in 99 | whole or in part contains or is derived from the Program or any 100 | part thereof, to be licensed as a whole at no charge to all third 101 | parties under the terms of this License. 102 | 103 | c) If the modified program normally reads commands interactively 104 | when run, you must cause it, when started running for such 105 | interactive use in the most ordinary way, to print or display an 106 | announcement including an appropriate copyright notice and a 107 | notice that there is no warranty (or else, saying that you provide 108 | a warranty) and that users may redistribute the program under 109 | these conditions, and telling the user how to view a copy of this 110 | License. (Exception: if the Program itself is interactive but 111 | does not normally print such an announcement, your work based on 112 | the Program is not required to print an announcement.) 113 | 114 | These requirements apply to the modified work as a whole. If 115 | identifiable sections of that work are not derived from the Program, 116 | and can be reasonably considered independent and separate works in 117 | themselves, then this License, and its terms, do not apply to those 118 | sections when you distribute them as separate works. But when you 119 | distribute the same sections as part of a whole which is a work based 120 | on the Program, the distribution of the whole must be on the terms of 121 | this License, whose permissions for other licensees extend to the 122 | entire whole, and thus to each and every part regardless of who wrote it. 123 | 124 | Thus, it is not the intent of this section to claim rights or contest 125 | your rights to work written entirely by you; rather, the intent is to 126 | exercise the right to control the distribution of derivative or 127 | collective works based on the Program. 128 | 129 | In addition, mere aggregation of another work not based on the Program 130 | with the Program (or with a work based on the Program) on a volume of 131 | a storage or distribution medium does not bring the other work under 132 | the scope of this License. 133 | 134 | 3. You may copy and distribute the Program (or a work based on it, 135 | under Section 2) in object code or executable form under the terms of 136 | Sections 1 and 2 above provided that you also do one of the following: 137 | 138 | a) Accompany it with the complete corresponding machine-readable 139 | source code, which must be distributed under the terms of Sections 140 | 1 and 2 above on a medium customarily used for software interchange; or, 141 | 142 | b) Accompany it with a written offer, valid for at least three 143 | years, to give any third party, for a charge no more than your 144 | cost of physically performing source distribution, a complete 145 | machine-readable copy of the corresponding source code, to be 146 | distributed under the terms of Sections 1 and 2 above on a medium 147 | customarily used for software interchange; or, 148 | 149 | c) Accompany it with the information you received as to the offer 150 | to distribute corresponding source code. (This alternative is 151 | allowed only for noncommercial distribution and only if you 152 | received the program in object code or executable form with such 153 | an offer, in accord with Subsection b above.) 154 | 155 | The source code for a work means the preferred form of the work for 156 | making modifications to it. For an executable work, complete source 157 | code means all the source code for all modules it contains, plus any 158 | associated interface definition files, plus the scripts used to 159 | control compilation and installation of the executable. However, as a 160 | special exception, the source code distributed need not include 161 | anything that is normally distributed (in either source or binary 162 | form) with the major components (compiler, kernel, and so on) of the 163 | operating system on which the executable runs, unless that component 164 | itself accompanies the executable. 165 | 166 | If distribution of executable or object code is made by offering 167 | access to copy from a designated place, then offering equivalent 168 | access to copy the source code from the same place counts as 169 | distribution of the source code, even though third parties are not 170 | compelled to copy the source along with the object code. 171 | 172 | 4. You may not copy, modify, sublicense, or distribute the Program 173 | except as expressly provided under this License. Any attempt 174 | otherwise to copy, modify, sublicense or distribute the Program is 175 | void, and will automatically terminate your rights under this License. 176 | However, parties who have received copies, or rights, from you under 177 | this License will not have their licenses terminated so long as such 178 | parties remain in full compliance. 179 | 180 | 5. You are not required to accept this License, since you have not 181 | signed it. However, nothing else grants you permission to modify or 182 | distribute the Program or its derivative works. These actions are 183 | prohibited by law if you do not accept this License. Therefore, by 184 | modifying or distributing the Program (or any work based on the 185 | Program), you indicate your acceptance of this License to do so, and 186 | all its terms and conditions for copying, distributing or modifying 187 | the Program or works based on it. 188 | 189 | 6. Each time you redistribute the Program (or any work based on the 190 | Program), the recipient automatically receives a license from the 191 | original licensor to copy, distribute or modify the Program subject to 192 | these terms and conditions. You may not impose any further 193 | restrictions on the recipients' exercise of the rights granted herein. 194 | You are not responsible for enforcing compliance by third parties to 195 | this License. 196 | 197 | 7. If, as a consequence of a court judgment or allegation of patent 198 | infringement or for any other reason (not limited to patent issues), 199 | conditions are imposed on you (whether by court order, agreement or 200 | otherwise) that contradict the conditions of this License, they do not 201 | excuse you from the conditions of this License. If you cannot 202 | distribute so as to satisfy simultaneously your obligations under this 203 | License and any other pertinent obligations, then as a consequence you 204 | may not distribute the Program at all. For example, if a patent 205 | license would not permit royalty-free redistribution of the Program by 206 | all those who receive copies directly or indirectly through you, then 207 | the only way you could satisfy both it and this License would be to 208 | refrain entirely from distribution of the Program. 209 | 210 | If any portion of this section is held invalid or unenforceable under 211 | any particular circumstance, the balance of the section is intended to 212 | apply and the section as a whole is intended to apply in other 213 | circumstances. 214 | 215 | It is not the purpose of this section to induce you to infringe any 216 | patents or other property right claims or to contest validity of any 217 | such claims; this section has the sole purpose of protecting the 218 | integrity of the free software distribution system, which is 219 | implemented by public license practices. Many people have made 220 | generous contributions to the wide range of software distributed 221 | through that system in reliance on consistent application of that 222 | system; it is up to the author/donor to decide if he or she is willing 223 | to distribute software through any other system and a licensee cannot 224 | impose that choice. 225 | 226 | This section is intended to make thoroughly clear what is believed to 227 | be a consequence of the rest of this License. 228 | 229 | 8. If the distribution and/or use of the Program is restricted in 230 | certain countries either by patents or by copyrighted interfaces, the 231 | original copyright holder who places the Program under this License 232 | may add an explicit geographical distribution limitation excluding 233 | those countries, so that distribution is permitted only in or among 234 | countries not thus excluded. In such case, this License incorporates 235 | the limitation as if written in the body of this License. 236 | 237 | 9. The Free Software Foundation may publish revised and/or new versions 238 | of the General Public License from time to time. Such new versions will 239 | be similar in spirit to the present version, but may differ in detail to 240 | address new problems or concerns. 241 | 242 | Each version is given a distinguishing version number. If the Program 243 | specifies a version number of this License which applies to it and "any 244 | later version", you have the option of following the terms and conditions 245 | either of that version or of any later version published by the Free 246 | Software Foundation. If the Program does not specify a version number of 247 | this License, you may choose any version ever published by the Free Software 248 | Foundation. 249 | 250 | 10. If you wish to incorporate parts of the Program into other free 251 | programs whose distribution conditions are different, write to the author 252 | to ask for permission. For software which is copyrighted by the Free 253 | Software Foundation, write to the Free Software Foundation; we sometimes 254 | make exceptions for this. Our decision will be guided by the two goals 255 | of preserving the free status of all derivatives of our free software and 256 | of promoting the sharing and reuse of software generally. 257 | 258 | NO WARRANTY 259 | 260 | 11. BECAUSE THE PROGRAM IS LICENSED FREE OF CHARGE, THERE IS NO WARRANTY 261 | FOR THE PROGRAM, TO THE EXTENT PERMITTED BY APPLICABLE LAW. EXCEPT WHEN 262 | OTHERWISE STATED IN WRITING THE COPYRIGHT HOLDERS AND/OR OTHER PARTIES 263 | PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY OF ANY KIND, EITHER EXPRESSED 264 | OR IMPLIED, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF 265 | MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE. THE ENTIRE RISK AS 266 | TO THE QUALITY AND PERFORMANCE OF THE PROGRAM IS WITH YOU. SHOULD THE 267 | PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF ALL NECESSARY SERVICING, 268 | REPAIR OR CORRECTION. 269 | 270 | 12. IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING 271 | WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MAY MODIFY AND/OR 272 | REDISTRIBUTE THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, 273 | INCLUDING ANY GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING 274 | OUT OF THE USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED 275 | TO LOSS OF DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY 276 | YOU OR THIRD PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER 277 | PROGRAMS), EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE 278 | POSSIBILITY OF SUCH DAMAGES. 279 | 280 | END OF TERMS AND CONDITIONS 281 | 282 | How to Apply These Terms to Your New Programs 283 | 284 | If you develop a new program, and you want it to be of the greatest 285 | possible use to the public, the best way to achieve this is to make it 286 | free software which everyone can redistribute and change under these terms. 287 | 288 | To do so, attach the following notices to the program. It is safest 289 | to attach them to the start of each source file to most effectively 290 | convey the exclusion of warranty; and each file should have at least 291 | the "copyright" line and a pointer to where the full notice is found. 292 | 293 | {description} 294 | Copyright (C) {year} {fullname} 295 | 296 | This program is free software; you can redistribute it and/or modify 297 | it under the terms of the GNU General Public License as published by 298 | the Free Software Foundation; either version 2 of the License, or 299 | (at your option) any later version. 300 | 301 | This program is distributed in the hope that it will be useful, 302 | but WITHOUT ANY WARRANTY; without even the implied warranty of 303 | MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 304 | GNU General Public License for more details. 305 | 306 | You should have received a copy of the GNU General Public License along 307 | with this program; if not, write to the Free Software Foundation, Inc., 308 | 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA. 309 | 310 | Also add information on how to contact you by electronic and paper mail. 311 | 312 | If the program is interactive, make it output a short notice like this 313 | when it starts in an interactive mode: 314 | 315 | Gnomovision version 69, Copyright (C) year name of author 316 | Gnomovision comes with ABSOLUTELY NO WARRANTY; for details type `show w'. 317 | This is free software, and you are welcome to redistribute it 318 | under certain conditions; type `show c' for details. 319 | 320 | The hypothetical commands `show w' and `show c' should show the appropriate 321 | parts of the General Public License. Of course, the commands you use may 322 | be called something other than `show w' and `show c'; they could even be 323 | mouse-clicks or menu items--whatever suits your program. 324 | 325 | You should also get your employer (if you work as a programmer) or your 326 | school, if any, to sign a "copyright disclaimer" for the program, if 327 | necessary. Here is a sample; alter the names: 328 | 329 | Yoyodyne, Inc., hereby disclaims all copyright interest in the program 330 | `Gnomovision' (which makes passes at compilers) written by James Hacker. 331 | 332 | {signature of Ty Coon}, 1 April 1989 333 | Ty Coon, President of Vice 334 | 335 | This General Public License does not permit incorporating your program into 336 | proprietary programs. If your program is a subroutine library, you may 337 | consider it more useful to permit linking proprietary applications with the 338 | library. If this is what you want to do, use the GNU Lesser General 339 | Public License instead of this License. 340 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | [Version](https://f-droid.org/packages/com.daemon.ssh/) 4 | 5 | # SshDaemon 6 | A simple SSH/SFTP server for your Android phone. 7 | 8 | ## Privacy policy 9 | 10 | **WE DO NOT STORE ANY DATA OR MESSAGES YOU PROCESS WITH THE APPLICATION. PERIOD.** 11 | 12 | ## Compilation 13 | 14 | Simply clone the repository and import it to Android Studio. 15 | 16 | This project is based on the [mina-sshd](https://github.com/apache/mina-sshd) project. 17 | 18 | ## Icons 19 | 20 | I used the material icons [from](https://fonts.google.com/icons) 21 | 22 | ## License 23 | 24 | This project is licensed under GNU GENERAL PUBLIC LICENSE Version 2 or any later version. 25 | 26 | [Get it on F-Droid](https://f-droid.org/packages/com.daemon.ssh/) 29 | [Get it on Google Play](https://play.google.com/store/apps/details?id=com.daemon.ssh) 32 | -------------------------------------------------------------------------------- /app/.gitignore: -------------------------------------------------------------------------------- 1 | /build 2 | /release/output-metadata.json 3 | /release/app-release.apk 4 | /release/app-release.aab 5 | -------------------------------------------------------------------------------- /app/build.gradle: -------------------------------------------------------------------------------- 1 | apply plugin: 'com.android.application' 2 | 3 | android { 4 | compileSdk = 35 5 | 6 | namespace = 'com.sshdaemon' 7 | 8 | defaultConfig { 9 | applicationId "com.daemon.ssh" 10 | minSdkVersion 26 11 | targetSdkVersion 35 12 | versionCode 49 13 | versionName "2.1.31" 14 | testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner" 15 | vectorDrawables.useSupportLibrary = true 16 | } 17 | 18 | buildTypes { 19 | release { 20 | minifyEnabled = true 21 | shrinkResources = true 22 | proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'), 'proguard-rules.pro' 23 | } 24 | } 25 | packagingOptions { 26 | resources { 27 | excludes += ['META-INF/DEPENDENCIES.txt', 28 | 'META-INF/LICENSE.txt', 29 | 'META-INF/NOTICE.txt', 30 | 'META-INF/NOTICE', 31 | 'META-INF/LICENSE', 32 | 'META-INF/DEPENDENCIES', 33 | 'META-INF/notice.txt', 34 | 'META-INF/license.txt', 35 | 'META-INF/dependencies.txt', 36 | 'META-INF/LGPL2.1', 37 | 'META-INF/services/javax.annotation.processing.Processor'] 38 | } 39 | } 40 | 41 | 42 | compileOptions { 43 | sourceCompatibility JavaVersion.VERSION_17 44 | targetCompatibility JavaVersion.VERSION_17 45 | } 46 | 47 | 48 | testOptions { 49 | unitTests.returnDefaultValues = true 50 | unitTests.includeAndroidResources = true 51 | unitTests.all { 52 | useJUnitPlatform() 53 | } 54 | } 55 | lintOptions { 56 | checkReleaseBuilds = false 57 | abortOnError = false 58 | } 59 | 60 | lint { 61 | baseline = file('lint-baseline.xml') 62 | } 63 | 64 | tasks.withType(JavaCompile).configureEach { 65 | options.compilerArgs << "-Xlint:deprecation" 66 | } 67 | } 68 | 69 | ext { 70 | sshdVersion = '2.15.0' 71 | } 72 | 73 | dependencies { 74 | api 'com.google.android.material:material:1.12.0' 75 | 76 | implementation "org.apache.sshd:sshd-core:${sshdVersion}" 77 | implementation "org.apache.sshd:sshd-sftp:${sshdVersion}" 78 | implementation "org.apache.sshd:sshd-contrib:${sshdVersion}" 79 | implementation "org.slf4j:slf4j-api:2.0.16" 80 | implementation "org.slf4j:slf4j-log4j12:2.0.16" 81 | implementation "org.bouncycastle:bcpkix-jdk15to18:1.80" 82 | implementation "net.i2p.crypto:eddsa:0.3.0" 83 | 84 | testImplementation "org.junit.jupiter:junit-jupiter-api:5.12.0" 85 | testRuntimeOnly "org.junit.jupiter:junit-jupiter-engine:5.12.0" 86 | testImplementation "org.junit.jupiter:junit-jupiter-params:5.12.0" 87 | testImplementation "org.junit.platform:junit-platform-launcher:1.12.0" 88 | 89 | testImplementation "org.hamcrest:hamcrest-all:1.3" 90 | testImplementation "org.mockito:mockito-core:5.14.1" 91 | androidTestImplementation "androidx.test:core:1.6.1" 92 | androidTestImplementation "androidx.test.ext:junit:1.2.1" 93 | androidTestImplementation "androidx.test:runner:1.6.2" 94 | androidTestImplementation "androidx.test.espresso:espresso-core:3.6.1" 95 | } -------------------------------------------------------------------------------- /app/lint-baseline.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 9 | 13 | 14 | 15 | 18 | 20 | 21 | 22 | 23 | -------------------------------------------------------------------------------- /app/proguard-rules.pro: -------------------------------------------------------------------------------- 1 | # Add project specific ProGuard rules here. 2 | # You can control the set of applied configuration files using the 3 | # proguardFiles setting in build.gradle. 4 | # 5 | # For more details, see 6 | # http://developer.android.com/guide/developing/tools/proguard.html 7 | 8 | # If your project uses WebView with JS, uncomment the following 9 | # and specify the fully qualified class name to the JavaScript interface 10 | # class: 11 | #-keepclassmembers class fqcn.of.javascript.interface.for.webview { 12 | # public *; 13 | #} 14 | 15 | # Uncomment this to preserve the line number information for 16 | # debugging stack traces. 17 | #-keepattributes SourceFile,LineNumberTable 18 | 19 | # If you keep the line number information, uncomment this to 20 | # hide the original source file name. 21 | #-renamesourcefileattribute SourceFile 22 | 23 | #-dontshrink 24 | #-dontoptimize 25 | #-dontpreverify 26 | -verbose 27 | 28 | -dontwarn javax.management.** 29 | -dontwarn javax.annotation.** 30 | -dontwarn java.lang.management.** 31 | -dontwarn org.apache.log4j.** 32 | -dontwarn org.apache.commons.logging.** 33 | -dontwarn org.slf4j.** 34 | -dontwarn org.json.** 35 | -dontwarn java.rmi.** 36 | -dontwarn javax.lang.** 37 | -dontwarn javax.naming.** 38 | -dontwarn javax.security.auth.** 39 | -dontwarn org.apache.tomcat.jni.** 40 | -dontwarn org.ietf.jgss.** 41 | -dontwarn org.hamcrest.** 42 | -dontwarn org.junit.** 43 | -dontwarn org.opentest4j.** 44 | -dontwarn org.w3c.dom.bootstrap.** 45 | -dontwarn edu.umd.cs.findbugs.** 46 | -dontwarn com.android.org.conscrypt.** 47 | -dontwarn org.apache.harmony.xnet.provider.** 48 | 49 | 50 | -keep public class * extends android.app.Activity 51 | -keep public class * extends android.app.Application 52 | -keep public class * extends android.app.Service 53 | -keep public class * extends android.content.BroadcastReceiver 54 | -keep public class * extends android.content.ContentProvider 55 | -keep public class * extends android.app.backup.BackupAgentHelper 56 | -keep public class * extends android.preference.Preference 57 | -keep class com.android.org.conscrypt.** { *; } 58 | -keep class org.apache.harmony.xnet.provider.** { *; } 59 | -keep class javax** { *; } 60 | -keep class org** { *; } 61 | 62 | -keepclasseswithmembers class * { 63 | native ; 64 | } 65 | 66 | -keepclasseswithmembers class * { 67 | public (android.content.Context, android.util.AttributeSet); 68 | } 69 | 70 | -keepclasseswithmembers class * { 71 | public (android.content.Context, android.util.AttributeSet, int); 72 | } 73 | 74 | -keepclassmembers enum * { 75 | public static **[] values(); 76 | public static ** valueOf(java.lang.String); 77 | } 78 | -------------------------------------------------------------------------------- /app/src/androidTest/java/com/sshdaemon/ExampleInstrumentedTest.java: -------------------------------------------------------------------------------- 1 | package com.sshdaemon; 2 | 3 | import static org.junit.Assert.assertEquals; 4 | 5 | import androidx.test.ext.junit.runners.AndroidJUnit4; 6 | import androidx.test.platform.app.InstrumentationRegistry; 7 | 8 | import org.junit.Test; 9 | import org.junit.runner.RunWith; 10 | 11 | /** 12 | * Instrumented test, which will execute on an Android device. 13 | * 14 | * @see Testing documentation 15 | */ 16 | @RunWith(AndroidJUnit4.class) 17 | public class ExampleInstrumentedTest { 18 | @Test 19 | public void useAppContext() { 20 | // Context of the app under test. 21 | var appContext = InstrumentationRegistry.getInstrumentation().getTargetContext(); 22 | assertEquals("com.daemon.ssh", appContext.getPackageName()); 23 | } 24 | } -------------------------------------------------------------------------------- /app/src/main/AndroidManifest.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 18 | 21 | 22 | 28 | 29 | 33 | 34 | 35 | 36 | 37 | 38 | 39 | 40 | 41 | -------------------------------------------------------------------------------- /app/src/main/ic_launcher-playstore.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jazzm0/ssh-daemon/e1ce6a56c9aaa8c797645d7bca64f519bf873b66/app/src/main/ic_launcher-playstore.png -------------------------------------------------------------------------------- /app/src/main/java/com/sshdaemon/MainActivity.java: -------------------------------------------------------------------------------- 1 | package com.sshdaemon; 2 | 3 | import static android.text.TextUtils.TruncateAt.END; 4 | import static com.sshdaemon.sshd.SshDaemon.INTERFACE; 5 | import static com.sshdaemon.sshd.SshDaemon.NOTIFICATION_ID; 6 | import static com.sshdaemon.sshd.SshDaemon.PASSWORD; 7 | import static com.sshdaemon.sshd.SshDaemon.PASSWORD_AUTH_ENABLED; 8 | import static com.sshdaemon.sshd.SshDaemon.PORT; 9 | import static com.sshdaemon.sshd.SshDaemon.READ_ONLY; 10 | import static com.sshdaemon.sshd.SshDaemon.SFTP_ROOT_PATH; 11 | import static com.sshdaemon.sshd.SshDaemon.SSH_DAEMON; 12 | import static com.sshdaemon.sshd.SshDaemon.USER; 13 | import static com.sshdaemon.sshd.SshDaemon.getFingerPrints; 14 | import static com.sshdaemon.sshd.SshDaemon.publicKeyAuthenticationExists; 15 | import static com.sshdaemon.sshd.SshPassword.getRandomString; 16 | import static com.sshdaemon.util.ExternalStorage.getAllStorageLocations; 17 | import static com.sshdaemon.util.ExternalStorage.hasMultipleStorageLocations; 18 | import static com.sshdaemon.util.TextViewHelper.createTextView; 19 | import static java.util.Objects.isNull; 20 | 21 | import android.Manifest; 22 | import android.app.ActivityManager; 23 | import android.app.NotificationManager; 24 | import android.content.Context; 25 | import android.content.Intent; 26 | import android.content.pm.PackageManager; 27 | import android.graphics.Typeface; 28 | import android.net.ConnectivityManager; 29 | import android.os.Build; 30 | import android.os.Bundle; 31 | import android.os.Environment; 32 | import android.provider.Settings; 33 | import android.view.View; 34 | import android.widget.ArrayAdapter; 35 | import android.widget.EditText; 36 | import android.widget.ImageView; 37 | import android.widget.LinearLayout; 38 | import android.widget.Spinner; 39 | import android.widget.TextView; 40 | import android.widget.Toast; 41 | 42 | import androidx.appcompat.app.AppCompatActivity; 43 | import androidx.core.app.ActivityCompat; 44 | import androidx.core.content.ContextCompat; 45 | import androidx.core.view.ViewCompat; 46 | import androidx.core.view.WindowCompat; 47 | import androidx.core.view.WindowInsetsCompat; 48 | 49 | import com.google.android.material.floatingactionbutton.FloatingActionButton; 50 | import com.google.android.material.switchmaterial.SwitchMaterial; 51 | import com.google.android.material.textfield.TextInputEditText; 52 | import com.sshdaemon.net.NetworkChangeReceiver; 53 | import com.sshdaemon.sshd.SshDaemon; 54 | import com.sshdaemon.sshd.SshFingerprint; 55 | 56 | import java.util.Map; 57 | 58 | 59 | public class MainActivity extends AppCompatActivity { 60 | 61 | private String selectedInterface; 62 | 63 | private String getValue(EditText t) { 64 | return t.getText().toString().isEmpty() ? t.getHint().toString() : t.getText().toString(); 65 | } 66 | 67 | private void createSpinnerAdapter(Spinner sftpRootPaths) { 68 | if (isNull(sftpRootPaths.getSelectedItem())) { 69 | var adapter = new ArrayAdapter<>(MainActivity.this, android.R.layout.simple_spinner_item, getAllStorageLocations(this)); 70 | adapter.setDropDownViewResource(android.R.layout.simple_spinner_dropdown_item); 71 | sftpRootPaths.setAdapter(adapter); 72 | } 73 | } 74 | 75 | private void enableViews(boolean enable) { 76 | var selectedInterface = findViewById(R.id.network_interface_spinner); 77 | var port = findViewById(R.id.port_value); 78 | var user = findViewById(R.id.user_value); 79 | var password = findViewById(R.id.password_value); 80 | var sftpRootPaths = (Spinner) findViewById(R.id.sftp_paths); 81 | var generate = findViewById(R.id.generate); 82 | var passwordAuthenticationEnabled = (SwitchMaterial) findViewById(R.id.password_authentication_enabled); 83 | var readonly = findViewById(R.id.readonly_switch); 84 | var imageView = (ImageView) findViewById(R.id.key_based_authentication); 85 | 86 | selectedInterface.setEnabled(enable); 87 | port.setEnabled(enable); 88 | user.setEnabled(enable); 89 | password.setEnabled(enable); 90 | sftpRootPaths.setEnabled(enable); 91 | generate.setClickable(enable); 92 | readonly.setEnabled(enable); 93 | 94 | if (hasMultipleStorageLocations(this)) { 95 | sftpRootPaths.setVisibility(View.VISIBLE); 96 | } else { 97 | sftpRootPaths.setVisibility(View.GONE); 98 | } 99 | 100 | createSpinnerAdapter(sftpRootPaths); 101 | 102 | if (publicKeyAuthenticationExists()) { 103 | imageView.setImageResource(R.drawable.key_black_24dp); 104 | if (passwordAuthenticationEnabled.isChecked()) { 105 | setPasswordGroupVisibility(View.VISIBLE); 106 | enablePasswordAuthentication(enable, true); 107 | } else { 108 | setPasswordGroupVisibility(View.GONE); 109 | enablePasswordAuthentication(enable, false); 110 | } 111 | } else { 112 | imageView.setImageResource(R.drawable.key_off_black_24dp); 113 | setPasswordGroupVisibility(View.VISIBLE); 114 | enablePasswordAuthentication(enable && publicKeyAuthenticationExists(), true); 115 | } 116 | 117 | var view = findViewById(R.id.start_stop_action); 118 | var button = (FloatingActionButton) view; 119 | if (enable) { 120 | button.setImageResource(R.drawable.play_arrow_black_24dp); 121 | } else { 122 | button.setImageResource(R.drawable.pause_black_24dp); 123 | } 124 | } 125 | 126 | private void setPasswordGroupVisibility(int visibility) { 127 | var generate = findViewById(R.id.generate); 128 | var passwordLayout = findViewById(R.id.password_layout); 129 | var userLayout = findViewById(R.id.user_layout); 130 | userLayout.setVisibility(visibility); 131 | passwordLayout.setVisibility(visibility); 132 | generate.setVisibility(visibility); 133 | } 134 | 135 | private void enablePasswordAuthentication(boolean enabled, boolean activated) { 136 | var passwordAuthenticationEnabled = (SwitchMaterial) findViewById(R.id.password_authentication_enabled); 137 | passwordAuthenticationEnabled.setEnabled(enabled); 138 | passwordAuthenticationEnabled.setChecked(activated); 139 | passwordAuthenticationEnabled.setActivated(activated); 140 | } 141 | 142 | private void setFingerPrints(Map fingerPrints) { 143 | 144 | LinearLayout fingerPrintsLayout = findViewById(R.id.server_fingerprints); 145 | 146 | fingerPrintsLayout.removeAllViews(); 147 | 148 | var interfacesText = new TextView(this); 149 | interfacesText.setEllipsize(END); 150 | interfacesText.setSingleLine(); 151 | interfacesText.setMaxLines(1); 152 | interfacesText.setTextSize(11); 153 | interfacesText.setText(R.string.fingerprints_label_text); 154 | interfacesText.setLayoutParams(new LinearLayout.LayoutParams(LinearLayout.LayoutParams.MATCH_PARENT, LinearLayout.LayoutParams.MATCH_PARENT)); 155 | interfacesText.setTypeface(null, Typeface.BOLD); 156 | 157 | fingerPrintsLayout.addView(interfacesText, new LinearLayout.LayoutParams(LinearLayout.LayoutParams.MATCH_PARENT, LinearLayout.LayoutParams.MATCH_PARENT)); 158 | 159 | for (Map.Entry e : fingerPrints.entrySet()) { 160 | var textView = createTextView(this, "(" + e.getKey() + ") " + e.getValue()); 161 | fingerPrintsLayout.addView(textView, 162 | new LinearLayout.LayoutParams(LinearLayout.LayoutParams.MATCH_PARENT, LinearLayout.LayoutParams.MATCH_PARENT)); 163 | } 164 | } 165 | 166 | private boolean isStarted() { 167 | var am = (ActivityManager) this.getSystemService(ACTIVITY_SERVICE); 168 | @SuppressWarnings("deprecation") 169 | var runningServices = am.getRunningServices(1); 170 | var started = false; 171 | if (!runningServices.isEmpty() && runningServices.get(0).service.flattenToString().contains(SSH_DAEMON)) { 172 | started = runningServices.get(0).started; 173 | } 174 | return started; 175 | } 176 | 177 | private void updateViews() { 178 | enableViews(!isStarted()); 179 | } 180 | 181 | private void storeValues(String selectedInterface, String port, String user, boolean passwordAuthenticationEnabled, boolean readOnly, String sftpRootPath) { 182 | var editor = this.getPreferences(Context.MODE_PRIVATE).edit(); 183 | 184 | editor.putString(getString(R.string.select_network_interface), selectedInterface); 185 | editor.putString(getString(R.string.default_port_value), port); 186 | editor.putString(getString(R.string.default_user_value), user); 187 | editor.putString(getString(R.string.sftp_root_path), sftpRootPath); 188 | editor.putBoolean(getString(R.string.password_authentication_enabled), passwordAuthenticationEnabled); 189 | editor.putBoolean(getString(R.string.read_only), readOnly); 190 | 191 | editor.apply(); 192 | } 193 | 194 | private void restoreValues() { 195 | var preferences = this.getPreferences(Context.MODE_PRIVATE); 196 | var networkInterfaceSpinner = (Spinner) findViewById(R.id.network_interface_spinner); 197 | var port = (TextView) findViewById(R.id.port_value); 198 | var user = (TextView) findViewById(R.id.user_value); 199 | var passwordAuthenticationEnabled = (SwitchMaterial) findViewById(R.id.password_authentication_enabled); 200 | var readonly = (SwitchMaterial) findViewById(R.id.readonly_switch); 201 | var sftpRootPath = (Spinner) findViewById(R.id.sftp_paths); 202 | 203 | this.setSelectedInterface(preferences.getString(getString(R.string.select_network_interface), null)); 204 | ArrayAdapter adapter = (ArrayAdapter) networkInterfaceSpinner.getAdapter(); 205 | 206 | var position = adapter.getPosition(this.selectedInterface); 207 | 208 | if (position >= 0) { 209 | networkInterfaceSpinner.setSelection(position); 210 | } 211 | 212 | port.setText(preferences.getString(getString(R.string.default_port_value), getString(R.string.default_port_value))); 213 | user.setText(preferences.getString(getString(R.string.default_user_value), getString(R.string.default_user_value))); 214 | passwordAuthenticationEnabled.setChecked(preferences.getBoolean(getString(R.string.password_authentication_enabled), true)); 215 | readonly.setChecked(preferences.getBoolean(getString(R.string.read_only), false)); 216 | createSpinnerAdapter(sftpRootPath); 217 | position = ((ArrayAdapter) sftpRootPath.getAdapter()).getPosition(preferences.getString(getString(R.string.sftp_root_path), "/")); 218 | sftpRootPath.setSelection(position); 219 | } 220 | 221 | @Override 222 | protected void onDestroy() { 223 | super.onDestroy(); 224 | NotificationManager notificationManager = getSystemService(NotificationManager.class); 225 | if (!isNull(notificationManager)) { 226 | notificationManager.cancel(NOTIFICATION_ID); 227 | } 228 | } 229 | 230 | @Override 231 | protected void onResume() { 232 | super.onResume(); 233 | restoreValues(); 234 | updateViews(); 235 | } 236 | 237 | @Override 238 | protected void onCreate(Bundle savedInstanceState) { 239 | super.onCreate(savedInstanceState); 240 | setContentView(R.layout.main_activity); 241 | 242 | var networkInterfaceSpinner = (Spinner) findViewById(R.id.network_interface_spinner); 243 | 244 | var connectivityManager = getSystemService(ConnectivityManager.class); 245 | connectivityManager.registerDefaultNetworkCallback(new NetworkChangeReceiver(networkInterfaceSpinner, 246 | this.getSystemService(ConnectivityManager.class), 247 | this)); 248 | 249 | if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R && !Environment.isExternalStorageManager()) { 250 | startActivity(new Intent(Settings.ACTION_MANAGE_ALL_FILES_ACCESS_PERMISSION)); 251 | } else if (checkSelfPermission(Manifest.permission.WRITE_EXTERNAL_STORAGE) != PackageManager.PERMISSION_GRANTED) { 252 | ActivityCompat.requestPermissions(this, 253 | new String[]{Manifest.permission.WRITE_EXTERNAL_STORAGE}, 1); 254 | } 255 | 256 | if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU && !getSystemService(NotificationManager.class).areNotificationsEnabled()) { 257 | ActivityCompat.requestPermissions(this, 258 | new String[]{Manifest.permission.POST_NOTIFICATIONS}, 1); 259 | } 260 | 261 | WindowCompat.setDecorFitsSystemWindows(getWindow(), false); 262 | 263 | ViewCompat.setOnApplyWindowInsetsListener(findViewById(R.id.root_layout), (view, insets) -> { 264 | var systemBarsInsets = insets.getInsets(WindowInsetsCompat.Type.systemBars()); 265 | view.setPadding(systemBarsInsets.left, systemBarsInsets.top, systemBarsInsets.right, systemBarsInsets.bottom); 266 | return insets; 267 | }); 268 | 269 | setFingerPrints(getFingerPrints()); 270 | generateClicked(null); 271 | restoreValues(); 272 | updateViews(); 273 | } 274 | 275 | public void setSelectedInterface(String selectedInterface) { 276 | this.selectedInterface = selectedInterface; 277 | } 278 | 279 | public void keyClicked(View view) { 280 | var text = publicKeyAuthenticationExists() ? 281 | getResources().getString(R.string.ssh_public_key_exists) : 282 | getResources().getString(R.string.ssh_public_key_doesnt_exists); 283 | 284 | Toast.makeText(this, text, Toast.LENGTH_SHORT).show(); 285 | } 286 | 287 | public void generateClicked(View view) { 288 | TextInputEditText password = findViewById(R.id.password_value); 289 | password.setText(getRandomString(6)); 290 | } 291 | 292 | public void passwordSwitchClicked(View passwordAuthenticationEnabled) { 293 | var passwordSwitch = (SwitchMaterial) passwordAuthenticationEnabled; 294 | enablePasswordAuthentication(true, !passwordSwitch.isActivated()); 295 | updateViews(); 296 | } 297 | 298 | public void startStopClicked(View view) { 299 | if (isStarted()) { 300 | enableViews(true); 301 | stopService(); 302 | } else { 303 | enableViews(false); 304 | setFingerPrints(getFingerPrints()); 305 | final var port = getValue(findViewById(R.id.port_value)); 306 | final var user = getValue(findViewById(R.id.user_value)); 307 | final var password = getValue(findViewById(R.id.password_value)); 308 | final var sftpRootPath = ((Spinner) findViewById(R.id.sftp_paths)).getSelectedItem().toString(); 309 | final var passwordAuthenticationEnabled = ((SwitchMaterial) findViewById(R.id.password_authentication_enabled)).isChecked(); 310 | final var readOnly = ((SwitchMaterial) findViewById(R.id.readonly_switch)).isChecked(); 311 | storeValues(this.selectedInterface, port, user, passwordAuthenticationEnabled, readOnly, sftpRootPath); 312 | 313 | startService(Integer.parseInt(port), user, password, sftpRootPath, passwordAuthenticationEnabled, readOnly); 314 | } 315 | } 316 | 317 | public void startService(int port, String user, String password, String sftpRootPath, boolean passwordAuthenticationEnabled, boolean readOnly) { 318 | var sshDaemonIntent = new Intent(this, SshDaemon.class); 319 | sshDaemonIntent.putExtra(INTERFACE, selectedInterface); 320 | sshDaemonIntent.putExtra(PORT, port); 321 | sshDaemonIntent.putExtra(USER, user); 322 | sshDaemonIntent.putExtra(PASSWORD, password); 323 | sshDaemonIntent.putExtra(SFTP_ROOT_PATH, sftpRootPath); 324 | sshDaemonIntent.putExtra(PASSWORD_AUTH_ENABLED, passwordAuthenticationEnabled); 325 | sshDaemonIntent.putExtra(READ_ONLY, readOnly); 326 | 327 | ContextCompat.startForegroundService(this, sshDaemonIntent); 328 | } 329 | 330 | public void stopService() { 331 | var sshDaemonIntent = new Intent(this, SshDaemon.class); 332 | stopService(sshDaemonIntent); 333 | } 334 | } 335 | -------------------------------------------------------------------------------- /app/src/main/java/com/sshdaemon/net/NetworkChangeReceiver.java: -------------------------------------------------------------------------------- 1 | package com.sshdaemon.net; 2 | 3 | import static com.sshdaemon.util.AndroidLogger.getLogger; 4 | import static java.util.Objects.isNull; 5 | 6 | import android.net.ConnectivityManager; 7 | import android.net.NetworkCapabilities; 8 | import android.view.View; 9 | import android.widget.AdapterView; 10 | import android.widget.ArrayAdapter; 11 | import android.widget.Spinner; 12 | 13 | import androidx.annotation.NonNull; 14 | 15 | import com.sshdaemon.MainActivity; 16 | 17 | import org.slf4j.Logger; 18 | 19 | import java.net.NetworkInterface; 20 | import java.net.SocketException; 21 | import java.util.ArrayList; 22 | import java.util.Collections; 23 | import java.util.List; 24 | import java.util.Set; 25 | import java.util.TreeSet; 26 | 27 | public class NetworkChangeReceiver extends ConnectivityManager.NetworkCallback { 28 | 29 | private static final Logger logger = getLogger(); 30 | private final Spinner networkInterfaces; 31 | private final MainActivity activity; 32 | private final ConnectivityManager connectivityManager; 33 | 34 | public NetworkChangeReceiver(Spinner networkInterfaces, ConnectivityManager connectivityManager, MainActivity activity) { 35 | this.networkInterfaces = networkInterfaces; 36 | this.connectivityManager = connectivityManager; 37 | this.activity = activity; 38 | 39 | networkInterfaces.setOnItemSelectedListener(new AdapterView.OnItemSelectedListener() { 40 | @Override 41 | public void onItemSelected(AdapterView parent, View view, int position, long id) { 42 | synchronized (NetworkChangeReceiver.this) { 43 | if (position == 0) { 44 | activity.setSelectedInterface(null); 45 | } else { 46 | activity.setSelectedInterface(getInterfaces().get(position)); 47 | } 48 | } 49 | } 50 | 51 | @Override 52 | public void onNothingSelected(AdapterView parent) { 53 | activity.setSelectedInterface(null); 54 | } 55 | }); 56 | 57 | setAdapter(); 58 | } 59 | 60 | private void setAdapter() { 61 | try { 62 | synchronized (this) { 63 | var interfaces = getInterfaces(); 64 | activity.runOnUiThread(() -> { 65 | var adapter = (ArrayAdapter) networkInterfaces.getAdapter(); 66 | if (!isNull(adapter)) { 67 | adapter.clear(); 68 | adapter.addAll(interfaces); 69 | adapter.notifyDataSetChanged(); 70 | } else { 71 | adapter = new ArrayAdapter<>(activity, android.R.layout.simple_spinner_item, interfaces); 72 | adapter.setDropDownViewResource(android.R.layout.simple_spinner_dropdown_item); 73 | networkInterfaces.setAdapter(adapter); 74 | } 75 | }); 76 | } 77 | } catch (Exception e) { 78 | logger.error("Error setting adapter for network interfaces: ", e); 79 | } 80 | } 81 | 82 | 83 | List getInterfaces() { 84 | var result = new TreeSet(); 85 | if (!hasConnectivity()) { 86 | logger.warn("No connectivity detected."); 87 | return Collections.emptyList(); 88 | } 89 | 90 | try { 91 | var networkInterfaces = NetworkInterface.getNetworkInterfaces(); 92 | while (networkInterfaces.hasMoreElements()) { 93 | var networkInterface = networkInterfaces.nextElement(); 94 | if (isValidInterface(networkInterface)) { 95 | addInterfaceAddresses(result, networkInterface); 96 | } 97 | } 98 | } catch (SocketException e) { 99 | logger.error("Exception while fetching network interfaces: ", e); 100 | } catch (Exception e) { 101 | logger.error("Unexpected error while fetching network interfaces: ", e); 102 | } 103 | 104 | var interfaces = new ArrayList<>(result); 105 | interfaces.add(0, "all interfaces"); // Default option 106 | return interfaces; 107 | } 108 | 109 | private static boolean isValidInterface(NetworkInterface networkInterface) { 110 | try { 111 | return !networkInterface.isLoopback() && networkInterface.isUp() && !networkInterface.isVirtual(); 112 | } catch (SocketException e) { 113 | logger.error("Error checking validity of network interface: ", e); 114 | return false; 115 | } 116 | } 117 | 118 | private static void addInterfaceAddresses(Set result, NetworkInterface networkInterface) { 119 | try { 120 | var addresses = networkInterface.getInetAddresses(); 121 | while (addresses.hasMoreElements()) { 122 | var inetAddress = addresses.nextElement(); 123 | var hostAddress = inetAddress.getHostAddress(); 124 | 125 | if (!isNull(hostAddress) && !(hostAddress.contains("dummy") || hostAddress.contains("rmnet"))) { 126 | result.add(hostAddress.split("%")[0]); // Exclude scope ID for IPv6 127 | } 128 | } 129 | } catch (Exception e) { 130 | logger.error("Error adding interface addresses: ", e); 131 | } 132 | } 133 | 134 | private boolean hasConnectivity() { 135 | try { 136 | var nw = connectivityManager.getActiveNetwork(); 137 | if (isNull(nw)) return false; 138 | var actNw = connectivityManager.getNetworkCapabilities(nw); 139 | return !isNull(actNw) && ( 140 | actNw.hasTransport(NetworkCapabilities.TRANSPORT_WIFI) || 141 | actNw.hasTransport(NetworkCapabilities.TRANSPORT_CELLULAR) || 142 | actNw.hasTransport(NetworkCapabilities.TRANSPORT_ETHERNET) 143 | ); 144 | } catch (Exception e) { 145 | logger.error("Error checking connectivity: ", e); 146 | return false; 147 | } 148 | } 149 | 150 | @Override 151 | public void onAvailable(@NonNull android.net.Network network) { 152 | super.onAvailable(network); 153 | logger.info("Network available. Updating network interfaces."); 154 | setAdapter(); 155 | } 156 | 157 | @Override 158 | public void onLost(@NonNull android.net.Network network) { 159 | super.onLost(network); 160 | logger.info("Network lost. Updating network interfaces."); 161 | setAdapter(); 162 | } 163 | } -------------------------------------------------------------------------------- /app/src/main/java/com/sshdaemon/sshd/SshDaemon.java: -------------------------------------------------------------------------------- 1 | package com.sshdaemon.sshd; 2 | 3 | import static android.app.PendingIntent.FLAG_IMMUTABLE; 4 | import static com.sshdaemon.sshd.SshFingerprint.fingerprintMD5; 5 | import static com.sshdaemon.sshd.SshFingerprint.fingerprintSHA256; 6 | import static com.sshdaemon.util.AndroidLogger.getLogger; 7 | import static com.sshdaemon.util.ExternalStorage.createDirIfNotExists; 8 | import static com.sshdaemon.util.ExternalStorage.getRootPath; 9 | import static org.apache.sshd.common.cipher.BuiltinCiphers.aes128ctr; 10 | import static org.apache.sshd.common.cipher.BuiltinCiphers.aes128gcm; 11 | import static org.apache.sshd.common.cipher.BuiltinCiphers.aes192ctr; 12 | import static org.apache.sshd.common.cipher.BuiltinCiphers.aes256ctr; 13 | import static org.apache.sshd.common.cipher.BuiltinCiphers.aes256gcm; 14 | import static java.lang.Math.max; 15 | import static java.util.Objects.isNull; 16 | import static java.util.Objects.requireNonNull; 17 | 18 | import android.app.Notification; 19 | import android.app.NotificationChannel; 20 | import android.app.NotificationManager; 21 | import android.app.PendingIntent; 22 | import android.app.Service; 23 | import android.content.Intent; 24 | import android.os.IBinder; 25 | 26 | import androidx.annotation.Nullable; 27 | import androidx.core.app.NotificationCompat; 28 | 29 | import com.sshdaemon.MainActivity; 30 | import com.sshdaemon.R; 31 | 32 | import org.apache.sshd.common.file.virtualfs.VirtualFileSystemFactory; 33 | import org.apache.sshd.common.util.security.SecurityUtils; 34 | import org.apache.sshd.common.util.threads.ThreadUtils; 35 | import org.apache.sshd.contrib.server.subsystem.sftp.SimpleAccessControlSftpEventListener; 36 | import org.apache.sshd.server.ServerBuilder; 37 | import org.apache.sshd.server.SshServer; 38 | import org.apache.sshd.server.keyprovider.SimpleGeneratorHostKeyProvider; 39 | import org.apache.sshd.server.shell.InteractiveProcessShellFactory; 40 | import org.apache.sshd.sftp.server.SftpSubsystemFactory; 41 | import org.bouncycastle.jce.provider.BouncyCastleProvider; 42 | import org.slf4j.Logger; 43 | 44 | import java.io.File; 45 | import java.io.IOException; 46 | import java.nio.file.Paths; 47 | import java.security.Security; 48 | import java.security.interfaces.ECPublicKey; 49 | import java.util.Collections; 50 | import java.util.HashMap; 51 | import java.util.List; 52 | import java.util.Map; 53 | 54 | public class SshDaemon extends Service { 55 | 56 | public static final int NOTIFICATION_ID = 1; 57 | public static final String AUTHORIZED_KEY_PATH = "SshDaemon/authorized_keys"; 58 | public static final String CHANNEL_ID = "SshDaemonServiceChannel"; 59 | public static final String SSH_DAEMON = "SshDaemon"; 60 | public static final String INTERFACE = "interface"; 61 | public static final String PORT = "port"; 62 | public static final String USER = "user"; 63 | public static final String PASSWORD = "password"; 64 | public static final String SFTP_ROOT_PATH = "sftpRootPath"; 65 | public static final String PASSWORD_AUTH_ENABLED = "passwordAuthenticationEnabled"; 66 | public static final String READ_ONLY = "readOnly"; 67 | private static final Logger logger = getLogger(); 68 | private static final int THREAD_POOL_SIZE = 10; 69 | private static final int DEFAULT_PORT = 8022; 70 | 71 | static { 72 | Security.removeProvider("BC"); 73 | if (SecurityUtils.isRegistrationCompleted()) { 74 | logger.info("Security provider registration is already completed"); 75 | } else { 76 | try { 77 | Security.addProvider(new BouncyCastleProvider()); 78 | logger.info("Set security provider to:{}, registration completed:{}", BouncyCastleProvider.PROVIDER_NAME, SecurityUtils.isRegistrationCompleted()); 79 | } catch (Exception e) { 80 | logger.error("Exception while registering security provider: ", e); 81 | } 82 | } 83 | } 84 | 85 | private SshServer sshd; 86 | 87 | public SshDaemon() { 88 | // Default constructor required for Service 89 | } 90 | 91 | public SshDaemon(String selectedInterface, int port, String user, String password, String sftpRootPath, 92 | boolean passwordAuthEnabled, boolean readOnly) { 93 | init(selectedInterface, port, user, password, sftpRootPath, passwordAuthEnabled, readOnly); 94 | } 95 | 96 | public static boolean publicKeyAuthenticationExists() { 97 | var authorizedKeyPath = getRootPath() + AUTHORIZED_KEY_PATH; 98 | var authorizedKeyFile = new File(authorizedKeyPath); 99 | if (!authorizedKeyFile.exists()) { 100 | return false; 101 | } 102 | var authenticator = new SshPublicKeyAuthenticator(); 103 | return authenticator.loadKeysFromPath(authorizedKeyPath); 104 | } 105 | 106 | public static Map getFingerPrints() { 107 | var result = new HashMap(); 108 | try { 109 | var rootPath = getRootPath(); 110 | var keyProvider = 111 | new SimpleGeneratorHostKeyProvider(Paths.get(rootPath + SSH_DAEMON + "/ssh_host_rsa_key")); 112 | var keyPairs = keyProvider.loadKeys(null); 113 | if (!keyPairs.isEmpty()) { 114 | ECPublicKey publicKey = (ECPublicKey) keyPairs.get(0).getPublic(); 115 | result.put(SshFingerprint.DIGESTS.MD5, fingerprintMD5(publicKey)); 116 | result.put(SshFingerprint.DIGESTS.SHA256, fingerprintSHA256(publicKey)); 117 | } else { 118 | logger.warn("No host key pairs available"); 119 | } 120 | } catch (Exception e) { 121 | logger.error("Failed to get fingerprints", e); 122 | } 123 | return result; 124 | } 125 | 126 | private void init(String selectedInterface, int port, String user, String password, String sftpRootPath, 127 | boolean passwordAuthEnabled, boolean readOnly) { 128 | 129 | if (port < 1024 || port > 65535) { 130 | throw new IllegalArgumentException("Port must be between 1024 and 65535"); 131 | } 132 | var sftpRoot = new File(sftpRootPath); 133 | if (!sftpRoot.exists() || !sftpRoot.canWrite()) { 134 | throw new IllegalArgumentException("SFTP root path does not exist or is not writable"); 135 | } 136 | 137 | var rootPath = getRootPath(); 138 | var path = rootPath + SSH_DAEMON; 139 | createDirIfNotExists(path); 140 | System.setProperty("user.home", sftpRootPath); 141 | 142 | this.sshd = ServerBuilder 143 | .builder() 144 | .cipherFactories(List.of(aes128ctr, aes192ctr, aes256ctr, aes128gcm, aes256gcm)) 145 | .build(); 146 | 147 | if (!isNull(selectedInterface)) { 148 | sshd.setHost(selectedInterface); 149 | } 150 | 151 | sshd.setPort(port); 152 | 153 | var authorizedKeyPath = rootPath + AUTHORIZED_KEY_PATH; 154 | var authorizedKeyFile = new File(authorizedKeyPath); 155 | if (authorizedKeyFile.exists()) { 156 | final var authenticator = new SshPublicKeyAuthenticator(); 157 | if (authenticator.loadKeysFromPath(authorizedKeyPath)) { 158 | sshd.setPublickeyAuthenticator(authenticator); 159 | } else { 160 | logger.warn("Failed to load authorized keys from {}", authorizedKeyPath); 161 | } 162 | } 163 | 164 | if (passwordAuthEnabled || !authorizedKeyFile.exists()) { 165 | sshd.setPasswordAuthenticator(new SshPasswordAuthenticator(user, password)); 166 | } 167 | 168 | var keyProvider = 169 | new SimpleGeneratorHostKeyProvider(Paths.get(path + "/ssh_host_rsa_key")); 170 | sshd.setKeyPairProvider(keyProvider); 171 | sshd.setShellFactory(new InteractiveProcessShellFactory()); 172 | 173 | int threadPools = max(THREAD_POOL_SIZE, Runtime.getRuntime().availableProcessors() * 2); 174 | logger.info("Thread pool size: {}", threadPools); 175 | SftpSubsystemFactory factory = new SftpSubsystemFactory.Builder() 176 | .withExecutorServiceProvider(() -> 177 | ThreadUtils.newFixedThreadPool("SFTP-Subsystem", threadPools)) 178 | .build(); 179 | if (readOnly) { 180 | factory.addSftpEventListener(SimpleAccessControlSftpEventListener.READ_ONLY_ACCESSOR); 181 | } 182 | sshd.setSubsystemFactories(Collections.singletonList(factory)); 183 | sshd.setFileSystemFactory(new VirtualFileSystemFactory(Paths.get(sftpRootPath))); 184 | } 185 | 186 | private Notification createNotification(String contentText, PendingIntent pendingIntent) { 187 | return new NotificationCompat.Builder(getApplicationContext(), CHANNEL_ID) 188 | .setContentTitle(SSH_DAEMON) 189 | .setContentText(contentText) 190 | .setSmallIcon(R.drawable.play_arrow_fill0_wght400_grad0_opsz48) 191 | .setOngoing(true) 192 | .setContentIntent(pendingIntent) 193 | .build(); 194 | } 195 | 196 | @Override 197 | public int onStartCommand(Intent intent, int flags, int startId) { 198 | 199 | var notificationIntent = new Intent(getApplicationContext(), MainActivity.class); 200 | var pendingIntent = PendingIntent.getActivity(getApplicationContext(), 201 | 0, notificationIntent, FLAG_IMMUTABLE | PendingIntent.FLAG_UPDATE_CURRENT); 202 | 203 | try { 204 | var serviceChannel = new NotificationChannel( 205 | CHANNEL_ID, 206 | CHANNEL_ID, 207 | NotificationManager.IMPORTANCE_HIGH 208 | ); 209 | 210 | var manager = getSystemService(NotificationManager.class); 211 | manager.createNotificationChannel(serviceChannel); 212 | 213 | var notification = createNotification(SSH_DAEMON, pendingIntent); 214 | startForeground(NOTIFICATION_ID, notification); 215 | 216 | var interfaceName = intent.getStringExtra(INTERFACE); 217 | var port = intent.getIntExtra(PORT, DEFAULT_PORT); 218 | var user = requireNonNull(intent.getStringExtra(USER), "User must not be null"); 219 | var password = requireNonNull(intent.getStringExtra(PASSWORD), "Password must not be null"); 220 | var sftpRootPath = requireNonNull(intent.getStringExtra(SFTP_ROOT_PATH), 221 | "SFTP root path must not be null"); 222 | var passwordAuthEnabled = intent.getBooleanExtra(PASSWORD_AUTH_ENABLED, true); 223 | var readOnly = intent.getBooleanExtra(READ_ONLY, false); 224 | 225 | init(interfaceName, port, user, password, sftpRootPath, passwordAuthEnabled, readOnly); 226 | sshd.start(); 227 | logger.info("SSH daemon started on port {}", port); 228 | updateNotification("SSH Server Running on port " + port, pendingIntent); 229 | } catch (IOException e) { 230 | logger.error("Failed to start SSH daemon", e); 231 | updateNotification("Failed to start SSH Server: " + e.getMessage(), pendingIntent); 232 | stopSelf(); 233 | } catch (IllegalArgumentException e) { 234 | logger.error("Invalid configuration", e); 235 | updateNotification("Invalid configuration: " + e.getMessage(), pendingIntent); 236 | stopSelf(); 237 | } 238 | return START_STICKY; 239 | } 240 | 241 | private void updateNotification(String status, PendingIntent pendingIntent) { 242 | Notification notification = createNotification(status, pendingIntent); 243 | NotificationManager manager = getSystemService(NotificationManager.class); 244 | manager.notify(NOTIFICATION_ID, notification); 245 | } 246 | 247 | @Override 248 | public void onDestroy() { 249 | super.onDestroy(); 250 | try { 251 | if (sshd != null && sshd.isStarted()) { 252 | sshd.stop(); 253 | logger.info("SSH daemon stopped"); 254 | var notificationIntent = new Intent(getApplicationContext(), MainActivity.class); 255 | var pendingIntent = PendingIntent.getActivity(getApplicationContext(), 256 | 0, notificationIntent, FLAG_IMMUTABLE | PendingIntent.FLAG_UPDATE_CURRENT); 257 | updateNotification("SSH Server Stopped", pendingIntent); 258 | stopForeground(true); 259 | } 260 | } catch (IOException e) { 261 | logger.error("Failed to stop SSH daemon", e); 262 | } 263 | } 264 | 265 | @Override 266 | public void onTaskRemoved(Intent rootIntent) { 267 | super.onTaskRemoved(rootIntent); 268 | stopForeground(true); 269 | } 270 | 271 | @Nullable 272 | @Override 273 | public IBinder onBind(Intent intent) { 274 | return null; 275 | } 276 | } -------------------------------------------------------------------------------- /app/src/main/java/com/sshdaemon/sshd/SshFingerprint.java: -------------------------------------------------------------------------------- 1 | package com.sshdaemon.sshd; 2 | 3 | import org.apache.sshd.common.digest.BuiltinDigests; 4 | 5 | import java.io.ByteArrayOutputStream; 6 | import java.nio.charset.StandardCharsets; 7 | import java.security.MessageDigest; 8 | import java.security.NoSuchAlgorithmException; 9 | import java.security.interfaces.ECPublicKey; 10 | import java.util.Base64; 11 | import java.util.Map; 12 | 13 | public class SshFingerprint { 14 | 15 | private static final char[] HEX_ARRAY = "0123456789abcdef".toCharArray(); 16 | private static final Map CURVE_MAP = Map.of( 17 | 256, "nistp256", 18 | 384, "nistp384", 19 | 521, "nistp521" 20 | ); 21 | 22 | private static final int BYTE_SHIFT = 8; 23 | private static final int INT_SIZE = 4; 24 | 25 | private static String bytesToHex(byte[] bytes) { 26 | var hexChars = new char[bytes.length * 2]; 27 | for (var j = 0; j < bytes.length; j++) { 28 | var v = bytes[j] & 0xFF; 29 | hexChars[j * 2] = HEX_ARRAY[v >>> 4]; 30 | hexChars[j * 2 + 1] = HEX_ARRAY[v & 0x0F]; 31 | } 32 | return new String(hexChars); 33 | } 34 | 35 | private static String getCurveName(int bitLength) { 36 | return CURVE_MAP.entrySet().stream() 37 | .filter(entry -> bitLength <= entry.getKey()) 38 | .map(Map.Entry::getValue) 39 | .findFirst() 40 | .orElseThrow(() -> new RuntimeException("ECDSA bit length unsupported: " + bitLength)); 41 | } 42 | 43 | private static int getQLen(int bitLength) { 44 | if (bitLength <= 256) return 65; 45 | if (bitLength <= 384) return 97; 46 | if (bitLength <= 521) return 133; 47 | throw new IllegalArgumentException("Unsupported ECDSA bit length: " + bitLength); 48 | } 49 | 50 | private static void writeArray(final byte[] arr, final ByteArrayOutputStream baos) { 51 | for (var shift = (INT_SIZE - 1) * BYTE_SHIFT; shift >= 0; shift -= BYTE_SHIFT) { 52 | baos.write((arr.length >>> shift) & 0xFF); 53 | } 54 | baos.write(arr, 0, arr.length); 55 | } 56 | 57 | public static String fingerprintMD5(ECPublicKey publicKey) throws NoSuchAlgorithmException { 58 | return fingerprintMD5(encode(publicKey)); 59 | } 60 | 61 | public static String fingerprintSHA256(ECPublicKey publicKey) throws NoSuchAlgorithmException { 62 | return fingerprintSHA256(encode(publicKey)); 63 | } 64 | 65 | public static String fingerprintMD5(byte[] keyBlob) throws NoSuchAlgorithmException { 66 | var md5DigestPublic = MessageDigest.getInstance(BuiltinDigests.Constants.MD5).digest(keyBlob); 67 | return bytesToHex(md5DigestPublic).replaceAll("(.{2})(?!$)", "$1:"); 68 | } 69 | 70 | public static String fingerprintSHA256(byte[] keyBlob) throws NoSuchAlgorithmException { 71 | var sha256DigestPublic = MessageDigest.getInstance(BuiltinDigests.Constants.SHA256).digest(keyBlob); 72 | return new String(Base64.getEncoder().encode(sha256DigestPublic)); 73 | } 74 | 75 | public static byte[] encode(final ECPublicKey key) { 76 | var buf = new ByteArrayOutputStream(); 77 | var bitLength = key.getW().getAffineX().bitLength(); 78 | var curveName = getCurveName(bitLength); 79 | var qLen = getQLen(bitLength); 80 | 81 | var name = ("ecdsa-sha2-" + curveName).getBytes(StandardCharsets.US_ASCII); 82 | var curve = curveName.getBytes(StandardCharsets.US_ASCII); 83 | writeArray(name, buf); 84 | writeArray(curve, buf); 85 | 86 | var javaEncoding = key.getEncoded(); 87 | if (javaEncoding.length < qLen) { 88 | throw new IllegalArgumentException("Invalid key encoding length"); 89 | } 90 | var q = new byte[qLen]; 91 | System.arraycopy(javaEncoding, javaEncoding.length - qLen, q, 0, qLen); 92 | writeArray(q, buf); 93 | 94 | return buf.toByteArray(); 95 | } 96 | 97 | public enum DIGESTS { 98 | MD5, 99 | SHA256 100 | } 101 | } -------------------------------------------------------------------------------- /app/src/main/java/com/sshdaemon/sshd/SshPassword.java: -------------------------------------------------------------------------------- 1 | package com.sshdaemon.sshd; 2 | 3 | import java.security.SecureRandom; 4 | 5 | public class SshPassword { 6 | 7 | private static final String ALLOWED_CHARACTERS = "0123456789qwertzuiopasdfghjklyxcvbnm"; 8 | 9 | public static String getRandomString(final int sizeOfPasswordString) { 10 | final var random = new SecureRandom(); 11 | final var sb = new StringBuilder(sizeOfPasswordString); 12 | 13 | for (var i = 0; i < sizeOfPasswordString; ++i) 14 | sb.append(ALLOWED_CHARACTERS.charAt(random.nextInt(ALLOWED_CHARACTERS.length()))); 15 | 16 | return sb.toString(); 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /app/src/main/java/com/sshdaemon/sshd/SshPasswordAuthenticator.java: -------------------------------------------------------------------------------- 1 | package com.sshdaemon.sshd; 2 | 3 | import org.apache.sshd.server.auth.AsyncAuthException; 4 | import org.apache.sshd.server.auth.password.PasswordAuthenticator; 5 | import org.apache.sshd.server.auth.password.PasswordChangeRequiredException; 6 | import org.apache.sshd.server.session.ServerSession; 7 | 8 | public record SshPasswordAuthenticator(String user, 9 | String password) implements PasswordAuthenticator { 10 | 11 | @Override 12 | public boolean authenticate(String username, String password, ServerSession session) throws PasswordChangeRequiredException, AsyncAuthException { 13 | return username.equals(user) && password.equals(this.password); 14 | } 15 | } -------------------------------------------------------------------------------- /app/src/main/java/com/sshdaemon/sshd/SshPublicKeyAuthenticator.java: -------------------------------------------------------------------------------- 1 | package com.sshdaemon.sshd; 2 | 3 | import static java.util.Collections.unmodifiableSet; 4 | import static java.util.Objects.isNull; 5 | 6 | import com.sshdaemon.util.AndroidLogger; 7 | 8 | import net.i2p.crypto.eddsa.EdDSAPublicKey; 9 | import net.i2p.crypto.eddsa.spec.EdDSANamedCurveTable; 10 | import net.i2p.crypto.eddsa.spec.EdDSAPublicKeySpec; 11 | 12 | import org.apache.sshd.server.auth.pubkey.PublickeyAuthenticator; 13 | import org.apache.sshd.server.session.ServerSession; 14 | import org.bouncycastle.crypto.params.Ed25519PublicKeyParameters; 15 | import org.slf4j.Logger; 16 | 17 | import java.io.BufferedReader; 18 | import java.io.ByteArrayInputStream; 19 | import java.io.DataInputStream; 20 | import java.io.File; 21 | import java.io.FileReader; 22 | import java.io.IOException; 23 | import java.math.BigInteger; 24 | import java.security.KeyFactory; 25 | import java.security.PublicKey; 26 | import java.security.spec.RSAPublicKeySpec; 27 | import java.util.Base64; 28 | import java.util.Collections; 29 | import java.util.Set; 30 | import java.util.concurrent.ConcurrentHashMap; 31 | 32 | 33 | public class SshPublicKeyAuthenticator implements PublickeyAuthenticator { 34 | 35 | private static final Logger LOGGER = AndroidLogger.getLogger(); 36 | private static final String KEY_TYPE_RSA = "ssh-rsa"; 37 | private static final String KEY_TYPE_ED25519 = "ssh-ed25519"; 38 | private final Set authorizedKeys = Collections.newSetFromMap(new ConcurrentHashMap<>()); 39 | 40 | public SshPublicKeyAuthenticator() { 41 | } 42 | 43 | private static byte[] readElement(DataInputStream dataInput) throws IOException { 44 | int length = dataInput.readInt(); 45 | if (length < 0 || length > 1024 * 1024) { // Prevent excessive allocation 46 | throw new IOException("Invalid element length: " + length); 47 | } 48 | byte[] buffer = new byte[length]; 49 | dataInput.readFully(buffer); 50 | return buffer; 51 | } 52 | 53 | protected static PublicKey readKey(String key) throws Exception { 54 | if (isNull(key) || key.trim().isEmpty()) { 55 | throw new IllegalArgumentException("Key string is empty or null"); 56 | } 57 | 58 | String[] parts = key.trim().split("\\s+"); 59 | if (parts.length < 2) { 60 | throw new IllegalArgumentException("Invalid key format: expected at least type and key"); 61 | } 62 | 63 | String keyType = parts[0]; 64 | byte[] decodedKey; 65 | try { 66 | decodedKey = Base64.getDecoder().decode(parts[1]); 67 | } catch (IllegalArgumentException e) { 68 | throw new IllegalArgumentException("Invalid Base64 encoding in key", e); 69 | } 70 | 71 | try (DataInputStream dataInputStream = new DataInputStream(new ByteArrayInputStream(decodedKey))) { 72 | String pubKeyFormat = new String(readElement(dataInputStream)); 73 | if (!pubKeyFormat.equals(keyType)) { 74 | throw new IllegalArgumentException("Key type mismatch: expected " + keyType + ", got " + pubKeyFormat); 75 | } 76 | 77 | switch (pubKeyFormat) { 78 | case KEY_TYPE_RSA: 79 | byte[] publicExponent = readElement(dataInputStream); 80 | byte[] modulus = readElement(dataInputStream); 81 | RSAPublicKeySpec spec = new RSAPublicKeySpec(new BigInteger(modulus), new BigInteger(publicExponent)); 82 | KeyFactory rsaFactory = KeyFactory.getInstance("RSA"); 83 | return rsaFactory.generatePublic(spec); 84 | 85 | case KEY_TYPE_ED25519: 86 | byte[] publicKeyBytes = readElement(dataInputStream); 87 | Ed25519PublicKeyParameters params = new Ed25519PublicKeyParameters(publicKeyBytes, 0); 88 | return new EdDSAPublicKey(new EdDSAPublicKeySpec(params.getEncoded(), EdDSANamedCurveTable.ED_25519_CURVE_SPEC)); 89 | 90 | default: 91 | throw new UnknownPublicKeyFormatException(pubKeyFormat); 92 | } 93 | } 94 | } 95 | 96 | Set getAuthorizedKeys() { 97 | return unmodifiableSet(authorizedKeys); 98 | } 99 | 100 | public boolean loadKeysFromPath(String authorizedKeysPath) { 101 | if (isNull(authorizedKeysPath)) { 102 | LOGGER.error("Authorized keys path is null"); 103 | return false; 104 | } 105 | 106 | File file = new File(authorizedKeysPath); 107 | if (!file.exists() || !file.canRead()) { 108 | LOGGER.error("Authorized keys file {} does not exist or is not readable", authorizedKeysPath); 109 | return false; 110 | } 111 | 112 | LOGGER.debug("Loading authorized keys from {}", authorizedKeysPath); 113 | authorizedKeys.clear(); 114 | 115 | try (BufferedReader reader = new BufferedReader(new FileReader(file))) { 116 | String line; 117 | while ((line = reader.readLine()) != null) { 118 | line = line.trim(); 119 | if (line.isEmpty() || line.startsWith("#")) { 120 | continue; 121 | } 122 | try { 123 | PublicKey key = readKey(line); 124 | if (authorizedKeys.add(key)) { 125 | LOGGER.debug("Added authorized key: type={}", key.getAlgorithm()); 126 | } else { 127 | LOGGER.warn("Duplicate key ignored: {}", key.getAlgorithm()); 128 | } 129 | } catch (Exception e) { 130 | LOGGER.error("Failed to parse key: {}", line, e); 131 | } 132 | } 133 | } catch (IOException e) { 134 | LOGGER.error("Failed to read authorized keys file {}", authorizedKeysPath, e); 135 | return false; 136 | } 137 | 138 | LOGGER.info("Loaded {} authorized keys from {}", authorizedKeys.size(), authorizedKeysPath); 139 | return !authorizedKeys.isEmpty(); 140 | } 141 | 142 | @Override 143 | public boolean authenticate(String user, PublicKey publicKey, ServerSession serverSession) { 144 | if (isNull(publicKey)) { 145 | LOGGER.warn("Public key is null for user: {}", user); 146 | return false; 147 | } 148 | boolean authorized = authorizedKeys.contains(publicKey); 149 | LOGGER.info("Public key authentication {} for user: {}, key type: {}", 150 | authorized ? "succeeded" : "failed", user, publicKey.getAlgorithm()); 151 | return authorized; 152 | } 153 | } -------------------------------------------------------------------------------- /app/src/main/java/com/sshdaemon/sshd/UnknownPublicKeyFormatException.java: -------------------------------------------------------------------------------- 1 | package com.sshdaemon.sshd; 2 | 3 | public class UnknownPublicKeyFormatException extends RuntimeException { 4 | public UnknownPublicKeyFormatException(String format) { 5 | super("Unknown public key format " + format); 6 | } 7 | } 8 | -------------------------------------------------------------------------------- /app/src/main/java/com/sshdaemon/util/AndroidLogger.java: -------------------------------------------------------------------------------- 1 | package com.sshdaemon.util; 2 | 3 | import org.apache.log4j.BasicConfigurator; 4 | import org.slf4j.Logger; 5 | import org.slf4j.LoggerFactory; 6 | 7 | public class AndroidLogger { 8 | 9 | private static final Logger logger = LoggerFactory.getLogger(AndroidLogger.class); 10 | 11 | public static Logger getLogger() { 12 | BasicConfigurator.configure(); 13 | return logger; 14 | } 15 | } 16 | 17 | -------------------------------------------------------------------------------- /app/src/main/java/com/sshdaemon/util/ExternalStorage.java: -------------------------------------------------------------------------------- 1 | package com.sshdaemon.util; 2 | 3 | 4 | import static java.util.Objects.isNull; 5 | 6 | import android.content.Context; 7 | import android.os.Environment; 8 | 9 | import java.io.File; 10 | import java.util.ArrayList; 11 | import java.util.Arrays; 12 | import java.util.LinkedHashSet; 13 | import java.util.List; 14 | import java.util.stream.Collectors; 15 | 16 | public class ExternalStorage { 17 | 18 | public static void createDirIfNotExists(String path) { 19 | var file = new File(path); 20 | if (!file.exists()) file.mkdirs(); 21 | } 22 | 23 | public static String getRootPath() { 24 | return isNull(Environment.getExternalStorageDirectory()) ? "/" : Environment.getExternalStorageDirectory().getPath() + "/"; 25 | } 26 | 27 | public static boolean hasMultipleStorageLocations(Context context) { 28 | return Arrays 29 | .stream(context.getExternalFilesDirs(null)) 30 | .filter(d -> !isNull(d)) 31 | .distinct() 32 | .count() > 1; 33 | } 34 | 35 | public static List getAllStorageLocations(Context context) { 36 | var locations = new LinkedHashSet(); 37 | 38 | final var directories = Arrays 39 | .stream(context.getExternalFilesDirs(null)) 40 | .filter(d -> !isNull(d)) 41 | .map(File::getPath) 42 | .collect(Collectors.toList()); 43 | 44 | if (directories.isEmpty()) { 45 | return List.of("/"); 46 | } 47 | 48 | final var appSuffixLength = directories.get(0).replace(getRootPath(), " ").length(); 49 | 50 | for (var directory : directories) { 51 | locations.add(directory.substring(0, directory.length() - appSuffixLength + 1)); 52 | } 53 | return new ArrayList<>(locations); 54 | } 55 | } 56 | -------------------------------------------------------------------------------- /app/src/main/java/com/sshdaemon/util/TextViewHelper.java: -------------------------------------------------------------------------------- 1 | package com.sshdaemon.util; 2 | 3 | import android.content.Context; 4 | import android.text.TextUtils; 5 | import android.widget.TableRow; 6 | import android.widget.TextView; 7 | 8 | public class TextViewHelper { 9 | 10 | public static TextView createTextView(Context context, String text) { 11 | var textView = new TextView(context); 12 | textView.setText(text); 13 | textView.setEllipsize(TextUtils.TruncateAt.MIDDLE); 14 | textView.setSingleLine(); 15 | textView.setTextSize(11); 16 | textView.setLayoutParams(new TableRow.LayoutParams(TableRow.LayoutParams.MATCH_PARENT, TableRow.LayoutParams.MATCH_PARENT)); 17 | return textView; 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /app/src/main/res/drawable/ic_launcher_foreground.xml: -------------------------------------------------------------------------------- 1 | 7 | 11 | 14 | 15 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 31 | 35 | 42 | 49 | 56 | 63 | 70 | 71 | 72 | -------------------------------------------------------------------------------- /app/src/main/res/drawable/ic_launcher_foreground_monochrome.xml: -------------------------------------------------------------------------------- 1 | 6 | 10 | 15 | 16 | 17 | -------------------------------------------------------------------------------- /app/src/main/res/drawable/key_black_24dp.xml: -------------------------------------------------------------------------------- 1 | 6 | 9 | 10 | -------------------------------------------------------------------------------- /app/src/main/res/drawable/key_off_black_24dp.xml: -------------------------------------------------------------------------------- 1 | 6 | 9 | 10 | -------------------------------------------------------------------------------- /app/src/main/res/drawable/pause_black_24dp.xml: -------------------------------------------------------------------------------- 1 | 6 | 9 | 10 | -------------------------------------------------------------------------------- /app/src/main/res/drawable/play_arrow_black_24dp.xml: -------------------------------------------------------------------------------- 1 | 6 | 9 | 10 | -------------------------------------------------------------------------------- /app/src/main/res/drawable/play_arrow_fill0_wght400_grad0_opsz48.xml: -------------------------------------------------------------------------------- 1 | 6 | 9 | -------------------------------------------------------------------------------- /app/src/main/res/drawable/show_password_selector.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | -------------------------------------------------------------------------------- /app/src/main/res/drawable/visibility_black_24dp.xml: -------------------------------------------------------------------------------- 1 | 6 | 9 | 10 | -------------------------------------------------------------------------------- /app/src/main/res/drawable/visibility_off_black_24dp.xml: -------------------------------------------------------------------------------- 1 | 6 | 9 | 10 | -------------------------------------------------------------------------------- /app/src/main/res/layout/main_activity.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 12 | 13 | 19 | 20 | 28 | 29 | 35 | 36 | 37 | 38 | 48 | 49 | 54 | 55 | 64 | 65 | 66 | 67 | 73 | 74 | 83 | 84 | 85 | 93 | 94 | 103 | 104 | 105 | 109 | 110 |