├── .codespellrc ├── .env ├── .git-blame-ignore-revs ├── .gitattributes ├── .github ├── FUNDING.yml └── workflows │ ├── dco.yml │ ├── misc.yml │ └── rust.yml ├── .gitignore ├── .justfile ├── .rustfmt.toml ├── CONTRIBUTING.md ├── Cargo.lock ├── Cargo.toml ├── LICENSE-APACHE ├── LICENSE-MIT ├── README.md ├── SECURITY.md ├── deny.toml ├── examples ├── README.md ├── agent-socket-info.rs ├── extensions.rs ├── key-storage.rs ├── openpgp-card-agent.rs ├── pgp-wrapper.rs ├── proto-dumper.rs ├── ssh-agent-client-blocking.rs └── ssh-agent-client.rs ├── fuzz ├── .gitignore ├── Cargo.toml ├── README.md └── fuzz_targets │ └── request_decode.rs ├── scripts └── hooks │ ├── pre-commit │ └── pre-push ├── src ├── agent.rs ├── blocking.rs ├── client.rs ├── codec.rs ├── error.rs ├── lib.rs ├── proto.rs └── proto │ ├── error.rs │ ├── extension.rs │ ├── extension │ ├── constraint.rs │ └── message.rs │ ├── message.rs │ ├── message │ ├── add_remove.rs │ ├── add_remove │ │ ├── constrained.rs │ │ └── credential.rs │ ├── extension.rs │ ├── identity.rs │ ├── request.rs │ ├── response.rs │ ├── sign.rs │ └── unparsed.rs │ ├── privatekey.rs │ └── signature.rs └── tests ├── known_hosts ├── messages ├── req-add-identity-constrained-extension-restrict-destination.bin ├── req-add-identity-constrained-lifetime.bin ├── req-add-identity-constrained-multiple-extensions.bin ├── req-add-identity-constrained.bin ├── req-add-identity-ecdsa.bin ├── req-add-identity-with-cert.bin ├── req-add-identity.bin ├── req-add-smartcard-key-constrained.bin ├── req-add-smartcard-key.bin ├── req-extension.bin ├── req-lock.bin ├── req-parse-certificates.bin ├── req-request-identities.bin ├── req-sign-request.bin ├── req-unlock.bin ├── resp-identities-answer.bin ├── resp-parse-identities.bin ├── resp-sign-response.bin └── resp-success.bin ├── pwd-test.sh ├── roundtrip ├── expected.rs ├── expected │ ├── fixtures.rs │ ├── macros.rs │ ├── req_add_identity_constrained_extension_restrict_destination.rs │ ├── req_add_identity_constrained_lifetime.rs │ ├── req_add_identity_ecdsa.rs │ ├── req_parse_certificates.rs │ └── resp_parse_identities.rs └── main.rs ├── sign-and-verify-win.bat └── sign-and-verify.sh /.codespellrc: -------------------------------------------------------------------------------- 1 | [codespell] 2 | skip = .cargo,.git,target,Cargo.lock 3 | ignore-words-list = crate,ser 4 | -------------------------------------------------------------------------------- /.env: -------------------------------------------------------------------------------- 1 | # Ubuntu packages 2 | UBUNTU_PACKAGES=libpcsclite-dev 3 | # Windows packages 4 | WINDOWS_PACKAGES= 5 | # macOS packages 6 | MACOS_PACKAGES= 7 | -------------------------------------------------------------------------------- /.git-blame-ignore-revs: -------------------------------------------------------------------------------- 1 | # Reformat source code 2 | a1f1105546e642c5c964cb86a40075894fd1525e 3 | -------------------------------------------------------------------------------- /.gitattributes: -------------------------------------------------------------------------------- 1 | * text eol=lf 2 | -------------------------------------------------------------------------------- /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | github: wiktor-k 2 | -------------------------------------------------------------------------------- /.github/workflows/dco.yml: -------------------------------------------------------------------------------- 1 | name: DCO 2 | 3 | on: pull_request 4 | 5 | jobs: 6 | check: 7 | name: Developer Certificate of Origin 8 | runs-on: ubuntu-latest 9 | steps: 10 | - uses: tisonkun/actions-dco@v1.1 11 | -------------------------------------------------------------------------------- /.github/workflows/misc.yml: -------------------------------------------------------------------------------- 1 | name: Misc 2 | 3 | on: 4 | pull_request: 5 | push: 6 | tags: 7 | - 'v*' 8 | branches: [ main ] 9 | workflow_dispatch: 10 | 11 | concurrency: 12 | group: misc-${{ github.ref }} 13 | cancel-in-progress: true 14 | 15 | jobs: 16 | integration: 17 | name: Integration tests 18 | # If starting the example fails at runtime the integration test will 19 | # be stuck. Try to limit the damage. The value "10" selected arbitrarily. 20 | timeout-minutes: 10 21 | strategy: 22 | matrix: 23 | include: 24 | - os: windows-latest 25 | script: ".\\tests\\sign-and-verify-win.bat" 26 | - os: ubuntu-latest 27 | script: ./tests/sign-and-verify.sh 28 | - os: macos-latest 29 | script: ./tests/sign-and-verify.sh 30 | runs-on: ${{ matrix.os }} 31 | steps: 32 | - uses: actions/checkout@v4 33 | - uses: taiki-e/install-action@just 34 | - run: just install-packages 35 | # If the example doesn't compile the integration test will 36 | # be stuck. Check for compilation issues earlier to abort the job 37 | - name: Check if the key-storage example compiles 38 | run: cargo check --example key-storage 39 | - name: Check if the ssh-agent-client example compiles 40 | run: cargo check --example ssh-agent-client 41 | - name: Check if the ssh-agent-client-blocking example compiles 42 | run: cargo check --example ssh-agent-client-blocking 43 | - name: Run integration tests 44 | run: ${{ matrix.script }} 45 | -------------------------------------------------------------------------------- /.github/workflows/rust.yml: -------------------------------------------------------------------------------- 1 | name: CI 2 | 3 | on: 4 | pull_request: 5 | push: 6 | tags: 7 | - 'v*' 8 | branches: [ main ] 9 | workflow_dispatch: 10 | 11 | concurrency: 12 | group: rust-${{ github.ref }} 13 | cancel-in-progress: true 14 | 15 | jobs: 16 | check-spelling: 17 | name: Check spelling 18 | runs-on: ubuntu-latest 19 | steps: 20 | - uses: actions/checkout@v4 21 | - name: Check spelling 22 | uses: codespell-project/actions-codespell@v2 23 | 24 | formatting: 25 | name: Check formatting 26 | strategy: 27 | matrix: 28 | include: 29 | - os: ubuntu-latest 30 | - os: macos-latest 31 | - os: windows-latest 32 | runs-on: ${{ matrix.os }} 33 | steps: 34 | - uses: actions/checkout@v4 35 | - uses: taiki-e/install-action@just 36 | - run: rustup install nightly 37 | - run: rustup component add rustfmt --toolchain nightly 38 | - name: Check formatting 39 | run: just formatting 40 | 41 | tests: 42 | name: Unit tests 43 | strategy: 44 | matrix: 45 | include: 46 | - os: ubuntu-latest 47 | - os: macos-latest 48 | - os: windows-latest 49 | runs-on: ${{ matrix.os }} 50 | steps: 51 | - uses: actions/checkout@v4 52 | - uses: taiki-e/install-action@just 53 | - run: just install-packages 54 | - name: Run unit tests 55 | run: just tests 56 | 57 | deps: 58 | name: Check dependencies 59 | runs-on: ubuntu-latest 60 | steps: 61 | - uses: actions/checkout@v4 62 | - name: Run dependencies check 63 | uses: EmbarkStudios/cargo-deny-action@v1 64 | 65 | lints: 66 | name: Clippy lints 67 | strategy: 68 | matrix: 69 | include: 70 | - os: ubuntu-latest 71 | - os: macos-latest 72 | - os: windows-latest 73 | runs-on: ${{ matrix.os }} 74 | steps: 75 | - uses: actions/checkout@v4 76 | - uses: taiki-e/install-action@just 77 | - run: just install-packages 78 | - name: Check for lints 79 | run: just lints 80 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Generated by Cargo 2 | # will have compiled files and executables 3 | /target/ 4 | 5 | # These are backup files generated by rustfmt 6 | **/*.rs.bk 7 | 8 | .devcontainer 9 | -------------------------------------------------------------------------------- /.justfile: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env -S just --working-directory . --justfile 2 | # Load project-specific properties from the `.env` file 3 | 4 | set dotenv-load := true 5 | 6 | # Since this is a first recipe it's being run by default. 7 | # Faster checks need to be executed first for better UX. For example 8 | # codespell is very fast. cargo fmt does not need to download crates etc. 9 | 10 | # Perform all checks 11 | check: spelling formatting docs lints dependencies tests 12 | 13 | # Checks common spelling mistakes 14 | spelling: 15 | codespell 16 | 17 | # Checks source code formatting 18 | formatting: 19 | just --unstable --fmt --check 20 | # We're using nightly to properly group imports, see .rustfmt.toml 21 | cargo +nightly fmt --all -- --check 22 | 23 | # Lints the source code 24 | lints: 25 | cargo clippy --workspace --no-deps --all-targets -- -D warnings 26 | 27 | # Checks for issues with dependencies 28 | dependencies: 29 | cargo deny check 30 | 31 | # Runs all unit tests. By default ignored tests are not run. Run with `ignored=true` to run only ignored tests 32 | tests: 33 | cargo test --all 34 | 35 | # Build docs for this crate only 36 | docs: 37 | cargo doc --no-deps 38 | 39 | # Installs packages required to build 40 | [linux] 41 | install-packages: 42 | sudo apt-get install --assume-yes --no-install-recommends $UBUNTU_PACKAGES 43 | 44 | [macos] 45 | [windows] 46 | install-packages: 47 | echo no-op 48 | 49 | # Checks for commit messages 50 | check-commits REFS='main..': 51 | #!/usr/bin/env bash 52 | set -euo pipefail 53 | for commit in $(git rev-list "{{ REFS }}"); do 54 | MSG="$(git show -s --format=%B "$commit")" 55 | CODESPELL_RC="$(mktemp)" 56 | git show "$commit:.codespellrc" > "$CODESPELL_RC" 57 | if ! grep -q "Signed-off-by: " <<< "$MSG"; then 58 | printf "Commit %s lacks \"Signed-off-by\" line.\n" "$commit" 59 | printf "%s\n" \ 60 | " Please use:" \ 61 | " git rebase --signoff main && git push --force-with-lease" \ 62 | " See https://developercertificate.org/ for more details." 63 | exit 1; 64 | elif ! codespell --config "$CODESPELL_RC" - <<< "$MSG"; then 65 | printf "The spelling in commit %s needs improvement.\n" "$commit" 66 | exit 1; 67 | else 68 | printf "Commit %s is good.\n" "$commit" 69 | fi 70 | done 71 | 72 | # Fixes common issues. Files need to be git add'ed 73 | fix: 74 | #!/usr/bin/env bash 75 | set -euo pipefail 76 | if ! git diff-files --quiet ; then 77 | echo "Working tree has changes. Please stage them: git add ." 78 | exit 1 79 | fi 80 | 81 | codespell --write-changes 82 | just --unstable --fmt 83 | # try to fix rustc issues 84 | cargo fix --allow-staged 85 | # try to fix clippy issues 86 | cargo clippy --fix --allow-staged 87 | 88 | # fmt must be last as clippy's changes may break formatting 89 | cargo +nightly fmt --all 90 | -------------------------------------------------------------------------------- /.rustfmt.toml: -------------------------------------------------------------------------------- 1 | # CHECK: https://github.com/rust-lang/rustfmt/issues/5083 state == open 2 | group_imports = "StdExternalCrate" 3 | 4 | # CHECK: https://github.com/rust-lang/rustfmt/issues/3348 state == open 5 | format_code_in_doc_comments = true 6 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # Contributing 2 | 3 | Thanks for taking the time to contribute to this project! 4 | 5 | All changes need to: 6 | 7 | - pass basic checks, including tests, formatting and lints, 8 | - be signed-off. 9 | 10 | ## Basic checks 11 | 12 | We are using standard Rust ecosystem tools including `rustfmt` and `clippy` with one minor difference. 13 | Due to a couple of `rustfmt` features being available only in nightly (see the `.rustfmt.toml` file) nightly `rustfmt` is necessary. 14 | 15 | All of these details are captured in a `.justfile` and can be checked by running [`just`'](https://just.systems/). 16 | 17 | To run all checks locally before sending them to CI you can set your git hooks directory: 18 | 19 | ```sh 20 | git config core.hooksPath scripts/hooks/ 21 | ``` 22 | 23 | ## Developer Certificate of Origin 24 | 25 | The sign-off is a simple line at the end of the git commit message, which certifies that you wrote it or otherwise have the right to pass it on as a open-source patch. 26 | 27 | The rules are pretty simple: if you can [certify the below][DCO]: 28 | 29 | ``` 30 | Developer's Certificate of Origin 1.1 31 | 32 | By making a contribution to this project, I certify that: 33 | 34 | (a) The contribution was created in whole or in part by me and I 35 | have the right to submit it under the open source license 36 | indicated in the file; or 37 | 38 | (b) The contribution is based upon previous work that, to the best 39 | of my knowledge, is covered under an appropriate open source 40 | license and I have the right under that license to submit that 41 | work with modifications, whether created in whole or in part 42 | by me, under the same open source license (unless I am 43 | permitted to submit under a different license), as indicated 44 | in the file; or 45 | 46 | (c) The contribution was provided directly to me by some other 47 | person who certified (a), (b) or (c) and I have not modified 48 | it. 49 | 50 | (d) I understand and agree that this project and the contribution 51 | are public and that a record of the contribution (including all 52 | personal information I submit with it, including my sign-off) is 53 | maintained indefinitely and may be redistributed consistent with 54 | this project or the open source license(s) involved. 55 | ``` 56 | 57 | then you just add a line saying 58 | 59 | Signed-off-by: Random J Developer 60 | 61 | using your name. 62 | 63 | If you set your `user.name` and `user.email`, you can sign your commit automatically with [`git commit --signoff`][GSO]. 64 | 65 | To sign-off your last commit: 66 | 67 | git commit --amend --signoff 68 | 69 | [DCO]: https://developercertificate.org 70 | [GSO]: https://git-scm.com/docs/git-commit#git-commit---signoff 71 | 72 | If you want to fix multiple commits use: 73 | 74 | git rebase --signoff main 75 | 76 | To check if your commits are correctly signed-off locally use `just check-commits`. 77 | -------------------------------------------------------------------------------- /Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "ssh-agent-lib" 3 | description = "A collection of types for writing custom SSH agents" 4 | version = "0.5.1" 5 | license = "MIT OR Apache-2.0" 6 | authors = [ 7 | "Wiktor Kwapisiewicz ", 8 | "Arthur Gautier ", 9 | "James Spencer " 10 | ] 11 | repository = "https://github.com/wiktor-k/ssh-agent-lib" 12 | edition = "2021" 13 | rust-version = "1.75" 14 | keywords = ["ssh", "agent", "authentication", "openssh", "async"] 15 | categories = ["authentication", "cryptography", "encoding", "network-programming", "parsing"] 16 | exclude = [".github"] 17 | 18 | [workspace] 19 | members = [".", "fuzz"] 20 | 21 | [dependencies] 22 | byteorder = "1" 23 | async-trait = { version = "0.1", optional = true } 24 | futures = { version = "0.3", optional = true } 25 | log = { version = "0.4", optional = true } 26 | tokio = { version = "1", optional = true, features = ["rt", "net", "time"] } 27 | tokio-util = { version = "0.7", optional = true, features = ["codec"] } 28 | service-binding = { version = "^3", optional = true } 29 | ssh-encoding = { version = "0.2" } 30 | ssh-key = { version = "0.6", features = ["crypto", "alloc"] } 31 | thiserror = "1" 32 | subtle = { version = "2", default-features = false } 33 | signature = { version = "2", features = ["alloc"] } 34 | secrecy = "0.8" 35 | 36 | [features] 37 | default = ["agent"] 38 | codec = ["tokio-util"] 39 | agent = ["futures", "log", "tokio", "async-trait", "codec", "service-binding"] 40 | 41 | [dev-dependencies] 42 | env_logger = "0.11.5" 43 | rand = "0.8.5" 44 | rsa = { version = "0.9.6", features = ["sha2", "sha1"] } 45 | tokio = { version = "1", features = ["macros", "rt-multi-thread", "sync"] } 46 | sha1 = { version = "0.10.6", default-features = false, features = ["oid"] } 47 | testresult = "0.4.1" 48 | hex-literal = "0.4.1" 49 | ssh-key = { version = "0.6.6", features = ["p256", "rsa"] } 50 | p256 = { version = "0.13.2" } 51 | const-str = "0.5.7" 52 | rstest = "0.22.0" 53 | openpgp-card = "0.5.0" 54 | card-backend-pcsc = "0.5.0" 55 | clap = { version = "4.5.17", features = ["derive"] } 56 | secrecy = "0.8.0" 57 | retainer = "0.3.0" 58 | pgp = "0.13.2" 59 | chrono = "0.4.38" 60 | interprocess = "2.2.1" 61 | -------------------------------------------------------------------------------- /LICENSE-APACHE: -------------------------------------------------------------------------------- 1 | Apache License 2 | Version 2.0, January 2004 3 | http://www.apache.org/licenses/ 4 | 5 | TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION 6 | 7 | 1. Definitions. 8 | 9 | "License" shall mean the terms and conditions for use, reproduction, 10 | and distribution as defined by Sections 1 through 9 of this document. 11 | 12 | "Licensor" shall mean the copyright owner or entity authorized by 13 | the copyright owner that is granting the License. 14 | 15 | "Legal Entity" shall mean the union of the acting entity and all 16 | other entities that control, are controlled by, or are under common 17 | control with that entity. For the purposes of this definition, 18 | "control" means (i) the power, direct or indirect, to cause the 19 | direction or management of such entity, whether by contract or 20 | otherwise, or (ii) ownership of fifty percent (50%) or more of the 21 | outstanding shares, or (iii) beneficial ownership of such entity. 22 | 23 | "You" (or "Your") shall mean an individual or Legal Entity 24 | exercising permissions granted by this License. 25 | 26 | "Source" form shall mean the preferred form for making modifications, 27 | including but not limited to software source code, documentation 28 | source, and configuration files. 29 | 30 | "Object" form shall mean any form resulting from mechanical 31 | transformation or translation of a Source form, including but 32 | not limited to compiled object code, generated documentation, 33 | and conversions to other media types. 34 | 35 | "Work" shall mean the work of authorship, whether in Source or 36 | Object form, made available under the License, as indicated by a 37 | copyright notice that is included in or attached to the work 38 | (an example is provided in the Appendix below). 39 | 40 | "Derivative Works" shall mean any work, whether in Source or Object 41 | form, that is based on (or derived from) the Work and for which the 42 | editorial revisions, annotations, elaborations, or other modifications 43 | represent, as a whole, an original work of authorship. For the purposes 44 | of this License, Derivative Works shall not include works that remain 45 | separable from, or merely link (or bind by name) to the interfaces of, 46 | the Work and Derivative Works thereof. 47 | 48 | "Contribution" shall mean any work of authorship, including 49 | the original version of the Work and any modifications or additions 50 | to that Work or Derivative Works thereof, that is intentionally 51 | submitted to Licensor for inclusion in the Work by the copyright owner 52 | or by an individual or Legal Entity authorized to submit on behalf of 53 | the copyright owner. For the purposes of this definition, "submitted" 54 | means any form of electronic, verbal, or written communication sent 55 | to the Licensor or its representatives, including but not limited to 56 | communication on electronic mailing lists, source code control systems, 57 | and issue tracking systems that are managed by, or on behalf of, the 58 | Licensor for the purpose of discussing and improving the Work, but 59 | excluding communication that is conspicuously marked or otherwise 60 | designated in writing by the copyright owner as "Not a Contribution." 61 | 62 | "Contributor" shall mean Licensor and any individual or Legal Entity 63 | on behalf of whom a Contribution has been received by Licensor and 64 | subsequently incorporated within the Work. 65 | 66 | 2. Grant of Copyright License. Subject to the terms and conditions of 67 | this License, each Contributor hereby grants to You a perpetual, 68 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 69 | copyright license to reproduce, prepare Derivative Works of, 70 | publicly display, publicly perform, sublicense, and distribute the 71 | Work and such Derivative Works in Source or Object form. 72 | 73 | 3. Grant of Patent License. Subject to the terms and conditions of 74 | this License, each Contributor hereby grants to You a perpetual, 75 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 76 | (except as stated in this section) patent license to make, have made, 77 | use, offer to sell, sell, import, and otherwise transfer the Work, 78 | where such license applies only to those patent claims licensable 79 | by such Contributor that are necessarily infringed by their 80 | Contribution(s) alone or by combination of their Contribution(s) 81 | with the Work to which such Contribution(s) was submitted. If You 82 | institute patent litigation against any entity (including a 83 | cross-claim or counterclaim in a lawsuit) alleging that the Work 84 | or a Contribution incorporated within the Work constitutes direct 85 | or contributory patent infringement, then any patent licenses 86 | granted to You under this License for that Work shall terminate 87 | as of the date such litigation is filed. 88 | 89 | 4. Redistribution. You may reproduce and distribute copies of the 90 | Work or Derivative Works thereof in any medium, with or without 91 | modifications, and in Source or Object form, provided that You 92 | meet the following conditions: 93 | 94 | (a) You must give any other recipients of the Work or 95 | Derivative Works a copy of this License; and 96 | 97 | (b) You must cause any modified files to carry prominent notices 98 | stating that You changed the files; and 99 | 100 | (c) You must retain, in the Source form of any Derivative Works 101 | that You distribute, all copyright, patent, trademark, and 102 | attribution notices from the Source form of the Work, 103 | excluding those notices that do not pertain to any part of 104 | the Derivative Works; and 105 | 106 | (d) If the Work includes a "NOTICE" text file as part of its 107 | distribution, then any Derivative Works that You distribute must 108 | include a readable copy of the attribution notices contained 109 | within such NOTICE file, excluding those notices that do not 110 | pertain to any part of the Derivative Works, in at least one 111 | of the following places: within a NOTICE text file distributed 112 | as part of the Derivative Works; within the Source form or 113 | documentation, if provided along with the Derivative Works; or, 114 | within a display generated by the Derivative Works, if and 115 | wherever such third-party notices normally appear. The contents 116 | of the NOTICE file are for informational purposes only and 117 | do not modify the License. You may add Your own attribution 118 | notices within Derivative Works that You distribute, alongside 119 | or as an addendum to the NOTICE text from the Work, provided 120 | that such additional attribution notices cannot be construed 121 | as modifying the License. 122 | 123 | You may add Your own copyright statement to Your modifications and 124 | may provide additional or different license terms and conditions 125 | for use, reproduction, or distribution of Your modifications, or 126 | for any such Derivative Works as a whole, provided Your use, 127 | reproduction, and distribution of the Work otherwise complies with 128 | the conditions stated in this License. 129 | 130 | 5. Submission of Contributions. Unless You explicitly state otherwise, 131 | any Contribution intentionally submitted for inclusion in the Work 132 | by You to the Licensor shall be under the terms and conditions of 133 | this License, without any additional terms or conditions. 134 | Notwithstanding the above, nothing herein shall supersede or modify 135 | the terms of any separate license agreement you may have executed 136 | with Licensor regarding such Contributions. 137 | 138 | 6. Trademarks. This License does not grant permission to use the trade 139 | names, trademarks, service marks, or product names of the Licensor, 140 | except as required for reasonable and customary use in describing the 141 | origin of the Work and reproducing the content of the NOTICE file. 142 | 143 | 7. Disclaimer of Warranty. Unless required by applicable law or 144 | agreed to in writing, Licensor provides the Work (and each 145 | Contributor provides its Contributions) on an "AS IS" BASIS, 146 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or 147 | implied, including, without limitation, any warranties or conditions 148 | of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A 149 | PARTICULAR PURPOSE. You are solely responsible for determining the 150 | appropriateness of using or redistributing the Work and assume any 151 | risks associated with Your exercise of permissions under this License. 152 | 153 | 8. Limitation of Liability. In no event and under no legal theory, 154 | whether in tort (including negligence), contract, or otherwise, 155 | unless required by applicable law (such as deliberate and grossly 156 | negligent acts) or agreed to in writing, shall any Contributor be 157 | liable to You for damages, including any direct, indirect, special, 158 | incidental, or consequential damages of any character arising as a 159 | result of this License or out of the use or inability to use the 160 | Work (including but not limited to damages for loss of goodwill, 161 | work stoppage, computer failure or malfunction, or any and all 162 | other commercial damages or losses), even if such Contributor 163 | has been advised of the possibility of such damages. 164 | 165 | 9. Accepting Warranty or Additional Liability. While redistributing 166 | the Work or Derivative Works thereof, You may choose to offer, 167 | and charge a fee for, acceptance of support, warranty, indemnity, 168 | or other liability obligations and/or rights consistent with this 169 | License. However, in accepting such obligations, You may act only 170 | on Your own behalf and on Your sole responsibility, not on behalf 171 | of any other Contributor, and only if You agree to indemnify, 172 | defend, and hold each Contributor harmless for any liability 173 | incurred by, or claims asserted against, such Contributor by reason 174 | of your accepting any such warranty or additional liability. 175 | 176 | END OF TERMS AND CONDITIONS 177 | 178 | APPENDIX: How to apply the Apache License to your work. 179 | 180 | To apply the Apache License to your work, attach the following 181 | boilerplate notice, with the fields enclosed by brackets "[]" 182 | replaced with your own identifying information. (Don't include 183 | the brackets!) The text should be enclosed in the appropriate 184 | comment syntax for the file format. We also recommend that a 185 | file or class name and description of purpose be included on the 186 | same "printed page" as the copyright notice for easier 187 | identification within third-party archives. 188 | 189 | Copyright 2022 Wiktor Kwapisiewicz 190 | 191 | Licensed under the Apache License, Version 2.0 (the "License"); 192 | you may not use this file except in compliance with the License. 193 | You may obtain a copy of the License at 194 | 195 | http://www.apache.org/licenses/LICENSE-2.0 196 | 197 | Unless required by applicable law or agreed to in writing, software 198 | distributed under the License is distributed on an "AS IS" BASIS, 199 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 200 | See the License for the specific language governing permissions and 201 | limitations under the License. 202 | -------------------------------------------------------------------------------- /LICENSE-MIT: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2022 Wiktor Kwapisiewicz 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # ssh-agent-lib 2 | 3 | [![CI](https://github.com/wiktor-k/ssh-agent-lib/actions/workflows/rust.yml/badge.svg)](https://github.com/wiktor-k/ssh-agent-lib/actions/workflows/rust.yml) 4 | [![Crates.io](https://img.shields.io/crates/v/ssh-agent-lib)](https://crates.io/crates/ssh-agent-lib) 5 | 6 | A collection of types for writing custom SSH agents and connecting to existing ones. 7 | 8 | The types in this crate closely follow the [SSH Agent Protocol Internet Draft](https://datatracker.ietf.org/doc/html/draft-miller-ssh-agent) specification and can be used to utilize remote keys not supported by the default OpenSSH agent. 9 | 10 | ## Examples 11 | 12 | The following examples show a sample agent and a sample client. 13 | For more elaborate example see the `examples` directory or [crates using `ssh-agent-lib`](https://crates.io/crates/ssh-agent-lib/reverse_dependencies). 14 | 15 | ### Agent 16 | 17 | The following example starts listening on a socket and processing requests. 18 | On Unix it uses `ssh-agent.sock` Unix domain socket while on Windows it uses a named pipe `\\.\pipe\agent`. 19 | 20 | ```rust,no_run 21 | #[cfg(not(windows))] 22 | use tokio::net::UnixListener as Listener; 23 | #[cfg(windows)] 24 | use ssh_agent_lib::agent::NamedPipeListener as Listener; 25 | use ssh_agent_lib::error::AgentError; 26 | use ssh_agent_lib::agent::{Session, listen}; 27 | use ssh_agent_lib::proto::{Identity, SignRequest}; 28 | use ssh_key::{Algorithm, Signature}; 29 | 30 | #[derive(Default, Clone)] 31 | struct MyAgent; 32 | 33 | #[ssh_agent_lib::async_trait] 34 | impl Session for MyAgent { 35 | async fn request_identities(&mut self) -> Result, AgentError> { 36 | Ok(vec![ /* public keys that this agent knows of */ ]) 37 | } 38 | 39 | async fn sign(&mut self, request: SignRequest) -> Result { 40 | // get the signature by signing `request.data` 41 | let signature = vec![]; 42 | Ok(Signature::new( 43 | Algorithm::new("algorithm").map_err(AgentError::other)?, 44 | signature, 45 | ).map_err(AgentError::other)?) 46 | } 47 | } 48 | 49 | #[tokio::main] 50 | async fn main() -> Result<(), Box> { 51 | #[cfg(not(windows))] 52 | let socket = "ssh-agent.sock"; 53 | #[cfg(windows)] 54 | let socket = r"\\.\pipe\agent"; 55 | 56 | let _ = std::fs::remove_file(socket); // remove the socket if exists 57 | 58 | listen(Listener::bind(socket)?, MyAgent::default()).await?; 59 | Ok(()) 60 | } 61 | ``` 62 | 63 | Now, point your OpenSSH client to this socket using `SSH_AUTH_SOCK` environment variable and it will transparently use the agent: 64 | 65 | ```sh 66 | SSH_AUTH_SOCK=ssh-agent.sock ssh user@example.com 67 | ``` 68 | 69 | On Windows the path of the pipe has to be used: 70 | 71 | ```sh 72 | SSH_AUTH_SOCK=\\.\pipe\agent ssh user@example.com 73 | ``` 74 | 75 | ### Client 76 | 77 | The following example connects to the agent pointed to by the `SSH_AUTH_SOCK` environment variable and prints identities (public keys) that the agent knows of: 78 | 79 | ```rust,no_run 80 | use service_binding::Binding; 81 | use ssh_agent_lib::client::connect; 82 | 83 | #[tokio::main] 84 | async fn main() -> Result<(), Box> { 85 | #[cfg(unix)] 86 | let mut client = 87 | connect(Binding::FilePath(std::env::var("SSH_AUTH_SOCK")?.into()).try_into()?)?; 88 | 89 | #[cfg(windows)] 90 | let mut client = 91 | connect(Binding::NamedPipe(std::env::var("SSH_AUTH_SOCK")?.into()).try_into()?)?; 92 | 93 | eprintln!( 94 | "Identities that this agent knows of: {:#?}", 95 | client.request_identities().await? 96 | ); 97 | 98 | Ok(()) 99 | } 100 | ``` 101 | 102 | ## License 103 | 104 | This project is licensed under either of: 105 | 106 | - [Apache License, Version 2.0](https://www.apache.org/licenses/LICENSE-2.0), 107 | - [MIT license](https://opensource.org/licenses/MIT). 108 | 109 | at your option. 110 | 111 | ### Contribution 112 | 113 | Unless you explicitly state otherwise, any contribution intentionally submitted for inclusion in this crate by you, as defined in the Apache-2.0 license, shall be dual licensed as above, without any additional terms or conditions. 114 | 115 | ### Note 116 | 117 | This library has been forked from [sekey/ssh-agent.rs](https://github.com/sekey/ssh-agent.rs) as the upstream seems not be maintained (at least as of 2022). 118 | The library was previously licensed under MIT, however in [#36], we relicensed it to MIT/Apache 2.0. 119 | 120 | Contributors gave their approval for relicensing [#36] [screenshot] 121 | 122 | [#36]: https://github.com/wiktor-k/ssh-agent-lib/pull/36 123 | [screenshot]: http://web.archive.org/web/20240408190456/https://github.com/wiktor-k/ssh-agent-lib/pull/36 124 | 125 | What remains from the original library is considered minor and does not count for copyright assignment. 126 | -------------------------------------------------------------------------------- /SECURITY.md: -------------------------------------------------------------------------------- 1 | # Security policy 2 | 3 | If you have discovered a security vulnerability in this project, please report it privately. 4 | Do not disclose it as a public issue. 5 | This gives us time to work with you to fix the issue before public exposure, reducing the chance that the exploit will be used before a patch is released. 6 | 7 | This project is maintained by a team of volunteers on a reasonable-effort basis. 8 | As such, please give us at least 90 days to work on a fix before public exposure. 9 | We will contact you back within 2 business days after reporting the issue. 10 | 11 | Thanks for helping make the project safe for everyone! 12 | 13 | ## Reporting a vulnerability 14 | 15 | Please, report the vulnerability either through [new security advisory form][ADV] or by directly contacting our security contacts. 16 | 17 | [ADV]: https://github.com/wiktor-k/ssh-agent-lib/security/advisories/new 18 | 19 | Security contacts: 20 | - [Wiktor Kwapisiewicz][WK], preferably encrypted with the following OpenPGP certificate: [`6539 09A2 F0E3 7C10 6F5F AF54 6C88 57E0 D8E8 F074`][KEY]. 21 | 22 | [WK]: https://github.com/wiktor-k 23 | [KEY]: https://keys.openpgp.org/vks/v1/by-fingerprint/653909A2F0E37C106F5FAF546C8857E0D8E8F074 24 | 25 | ## Supported Versions 26 | 27 | Security updates are applied only to the most recent release. 28 | -------------------------------------------------------------------------------- /deny.toml: -------------------------------------------------------------------------------- 1 | [advisories] 2 | version = 2 3 | yanked = "deny" 4 | ignore = [ 5 | "RUSTSEC-2023-0071", # the vurnerable crate is used in tests only 6 | ] 7 | 8 | [bans] 9 | deny = [ 10 | ] 11 | multiple-versions = "allow" 12 | 13 | [licenses] 14 | version = 2 15 | allow = [ 16 | "Apache-2.0", 17 | "MIT", 18 | "Unicode-DFS-2016", 19 | "BSD-3-Clause", 20 | ] 21 | -------------------------------------------------------------------------------- /examples/README.md: -------------------------------------------------------------------------------- 1 | # Agent examples 2 | 3 | The examples in this directory show slightly more elaborate use-cases that can be implemented using this crate. 4 | 5 | ## Agents 6 | 7 | ### `key-storage` 8 | 9 | Implements a simple agent which remembers RSA private keys (added via `ssh-add`) and allows fetching their public keys and signing using three different signing mechanisms. 10 | 11 | This example additionally shows how to extract extensions from messages and works on all major OSes. 12 | 13 | It is used in integration tests that run as part of the CI. 14 | 15 | ### `openpgp-card-agent` 16 | 17 | Allows using OpenPGP Card devices to sign SSH requests. 18 | The PIN is stored in memory and can be time-constrained using SSH constraints. 19 | For the sake of simplicity this agent supports only `ed25519` subkeys. 20 | 21 | This example additionally shows how to create custom protocol based on SSH extensions (in this case decrypt/derive feature). 22 | 23 | ### `agent-socket-info` 24 | 25 | Shows how to extract information about the underlying connection. 26 | For example under Unix systems this displays connecting process PID. 27 | To keep the example brief the data is printed as part of a fake public key comment. 28 | 29 | ## Clients 30 | 31 | ### `pgp-wrapper` 32 | 33 | Wraps SSH keys in OpenPGP data thus allowing OpenPGP applications (such as GnuPG) to read and work with SSH keys. 34 | This makes it possible to create OpenPGP signatures utilizing SSH keys. 35 | 36 | If the connecting agent supports derive/decrypt extension this example additionally creates a decryption subkey and can be used to decrypt OpenPGP data. 37 | 38 | ### `proto-dumper` 39 | 40 | A simple forwarding example which works as an agent and client at the same time dumping all messages and forwarding them to the next agent. 41 | 42 | ### `ssh-agent-client` 43 | 44 | Dumps identities stored by the agent. 45 | Additionally invokes an extension and reads the result. 46 | 47 | ### `ssh-agent-client-blocking` 48 | 49 | Dumps identities stored by the agent using blocking (synchronous) API. 50 | -------------------------------------------------------------------------------- /examples/agent-socket-info.rs: -------------------------------------------------------------------------------- 1 | //! This example shows how to access the underlying socket info. 2 | //! The socket info can be used to implement fine-grained access controls based on UID/GID. 3 | //! 4 | //! Run the example with: `cargo run --example agent-socket-info -- -H unix:///tmp/sock` 5 | //! Then inspect the socket info with: `SSH_AUTH_SOCK=/tmp/sock ssh-add -L` which should display 6 | //! something like this: 7 | //! 8 | //! ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA unix: addr: (unnamed) cred: UCred { pid: Some(68463), uid: 1000, gid: 1000 } 9 | 10 | use clap::Parser; 11 | use service_binding::Binding; 12 | use ssh_agent_lib::{ 13 | agent::{bind, Agent, Session}, 14 | error::AgentError, 15 | proto::Identity, 16 | }; 17 | use ssh_key::public::KeyData; 18 | use testresult::TestResult; 19 | 20 | #[derive(Debug, Default)] 21 | struct AgentSocketInfo { 22 | comment: String, 23 | } 24 | 25 | #[ssh_agent_lib::async_trait] 26 | impl Session for AgentSocketInfo { 27 | async fn request_identities(&mut self) -> Result, AgentError> { 28 | Ok(vec![Identity { 29 | // this is just a dummy key, the comment is important 30 | pubkey: KeyData::Ed25519(ssh_key::public::Ed25519PublicKey([0; 32])), 31 | comment: self.comment.clone(), 32 | }]) 33 | } 34 | } 35 | 36 | #[cfg(unix)] 37 | impl Agent for AgentSocketInfo { 38 | fn new_session(&mut self, socket: &tokio::net::UnixStream) -> impl Session { 39 | Self { 40 | comment: format!( 41 | "unix: addr: {:?} cred: {:?}", 42 | socket.peer_addr().unwrap(), 43 | socket.peer_cred().unwrap() 44 | ), 45 | } 46 | } 47 | } 48 | 49 | impl Agent for AgentSocketInfo { 50 | fn new_session(&mut self, _socket: &tokio::net::TcpStream) -> impl Session { 51 | Self { 52 | comment: "tcp".into(), 53 | } 54 | } 55 | } 56 | 57 | #[cfg(windows)] 58 | impl Agent for AgentSocketInfo { 59 | fn new_session( 60 | &mut self, 61 | _socket: &tokio::net::windows::named_pipe::NamedPipeServer, 62 | ) -> impl Session { 63 | Self { 64 | comment: "pipe".into(), 65 | } 66 | } 67 | } 68 | 69 | #[derive(Debug, Parser)] 70 | struct Args { 71 | #[clap(short = 'H', long)] 72 | host: Binding, 73 | } 74 | 75 | #[tokio::main] 76 | async fn main() -> TestResult { 77 | env_logger::init(); 78 | 79 | let args = Args::parse(); 80 | bind(args.host.try_into()?, AgentSocketInfo::default()).await?; 81 | Ok(()) 82 | } 83 | -------------------------------------------------------------------------------- /examples/extensions.rs: -------------------------------------------------------------------------------- 1 | use ssh_agent_lib::proto::{extension::MessageExtension, Identity, ProtoError}; 2 | use ssh_encoding::{CheckedSum, Decode, Encode, Reader, Writer}; 3 | use ssh_key::public::KeyData; 4 | 5 | pub struct RequestDecryptIdentities; 6 | 7 | const DECRYPT_DERIVE_IDS: &str = "decrypt-derive-ids@metacode.biz"; 8 | 9 | impl MessageExtension for RequestDecryptIdentities { 10 | const NAME: &'static str = DECRYPT_DERIVE_IDS; 11 | } 12 | 13 | impl Encode for RequestDecryptIdentities { 14 | fn encoded_len(&self) -> Result { 15 | Ok(0) 16 | } 17 | 18 | fn encode(&self, _writer: &mut impl Writer) -> Result<(), ssh_encoding::Error> { 19 | Ok(()) 20 | } 21 | } 22 | 23 | impl Decode for RequestDecryptIdentities { 24 | type Error = ProtoError; 25 | 26 | fn decode(_reader: &mut impl Reader) -> core::result::Result { 27 | Ok(Self) 28 | } 29 | } 30 | 31 | #[derive(Debug)] 32 | pub struct DecryptIdentities { 33 | pub identities: Vec, 34 | } 35 | 36 | impl MessageExtension for DecryptIdentities { 37 | const NAME: &'static str = DECRYPT_DERIVE_IDS; 38 | } 39 | 40 | impl Decode for DecryptIdentities { 41 | type Error = ProtoError; 42 | 43 | fn decode(reader: &mut impl Reader) -> Result { 44 | let len = u32::decode(reader)?; 45 | let mut identities = vec![]; 46 | 47 | for _ in 0..len { 48 | identities.push(Identity::decode(reader)?); 49 | } 50 | 51 | Ok(Self { identities }) 52 | } 53 | } 54 | 55 | impl Encode for DecryptIdentities { 56 | fn encoded_len(&self) -> ssh_encoding::Result { 57 | let ids = &self.identities; 58 | let mut lengths = Vec::with_capacity(1 + ids.len()); 59 | // Prefixed length 60 | lengths.push(4); 61 | 62 | for id in ids { 63 | lengths.push(id.encoded_len()?); 64 | } 65 | 66 | lengths.checked_sum() 67 | } 68 | 69 | fn encode(&self, writer: &mut impl Writer) -> ssh_encoding::Result<()> { 70 | let ids = &self.identities; 71 | (ids.len() as u32).encode(writer)?; 72 | for id in ids { 73 | id.encode(writer)?; 74 | } 75 | Ok(()) 76 | } 77 | } 78 | 79 | const DECRYPT_DERIVE: &str = "decrypt-derive@metacode.biz"; 80 | 81 | #[derive(Clone, PartialEq, Debug)] 82 | pub struct DecryptDeriveRequest { 83 | pub pubkey: KeyData, 84 | 85 | pub data: Vec, 86 | 87 | pub flags: u32, 88 | } 89 | 90 | impl MessageExtension for DecryptDeriveRequest { 91 | const NAME: &'static str = DECRYPT_DERIVE; 92 | } 93 | 94 | impl Decode for DecryptDeriveRequest { 95 | type Error = ProtoError; 96 | 97 | fn decode(reader: &mut impl Reader) -> Result { 98 | let pubkey = reader.read_prefixed(KeyData::decode)?; 99 | let data = Vec::decode(reader)?; 100 | let flags = u32::decode(reader)?; 101 | 102 | Ok(Self { 103 | pubkey, 104 | data, 105 | flags, 106 | }) 107 | } 108 | } 109 | 110 | impl Encode for DecryptDeriveRequest { 111 | fn encoded_len(&self) -> ssh_encoding::Result { 112 | [ 113 | self.pubkey.encoded_len_prefixed()?, 114 | self.data.encoded_len()?, 115 | self.flags.encoded_len()?, 116 | ] 117 | .checked_sum() 118 | } 119 | 120 | fn encode(&self, writer: &mut impl Writer) -> ssh_encoding::Result<()> { 121 | self.pubkey.encode_prefixed(writer)?; 122 | self.data.encode(writer)?; 123 | self.flags.encode(writer)?; 124 | 125 | Ok(()) 126 | } 127 | } 128 | 129 | #[derive(Debug)] 130 | pub struct DecryptDeriveResponse { 131 | pub data: Vec, 132 | } 133 | 134 | impl MessageExtension for DecryptDeriveResponse { 135 | const NAME: &'static str = DECRYPT_DERIVE; 136 | } 137 | 138 | impl Encode for DecryptDeriveResponse { 139 | fn encoded_len(&self) -> Result { 140 | self.data.encoded_len() 141 | } 142 | 143 | fn encode(&self, writer: &mut impl Writer) -> Result<(), ssh_encoding::Error> { 144 | self.data.encode(writer) 145 | } 146 | } 147 | 148 | impl Decode for DecryptDeriveResponse { 149 | type Error = ProtoError; 150 | 151 | fn decode(reader: &mut impl Reader) -> core::result::Result { 152 | Ok(Self { 153 | data: Vec::decode(reader)?, 154 | }) 155 | } 156 | } 157 | 158 | #[allow(dead_code)] // rust will complain if main is missing in example crate 159 | fn main() { 160 | panic!("This is just a helper lib crate for extensions"); 161 | } 162 | -------------------------------------------------------------------------------- /examples/key-storage.rs: -------------------------------------------------------------------------------- 1 | use std::sync::{Arc, Mutex}; 2 | 3 | use async_trait::async_trait; 4 | use log::info; 5 | use rsa::pkcs1v15::SigningKey; 6 | use rsa::sha2::{Sha256, Sha512}; 7 | use rsa::signature::{RandomizedSigner, SignatureEncoding}; 8 | use sha1::Sha1; 9 | #[cfg(windows)] 10 | use ssh_agent_lib::agent::NamedPipeListener as Listener; 11 | use ssh_agent_lib::agent::{listen, Session}; 12 | use ssh_agent_lib::error::AgentError; 13 | use ssh_agent_lib::proto::extension::{QueryResponse, RestrictDestination, SessionBind}; 14 | use ssh_agent_lib::proto::{ 15 | message, signature, AddIdentity, AddIdentityConstrained, AddSmartcardKeyConstrained, 16 | Credential, Extension, KeyConstraint, RemoveIdentity, SignRequest, SmartcardKey, 17 | }; 18 | use ssh_key::{ 19 | private::{KeypairData, PrivateKey}, 20 | public::PublicKey, 21 | Algorithm, Signature, 22 | }; 23 | #[cfg(not(windows))] 24 | use tokio::net::UnixListener as Listener; 25 | 26 | #[derive(Clone, PartialEq, Debug)] 27 | struct Identity { 28 | pubkey: PublicKey, 29 | privkey: PrivateKey, 30 | comment: String, 31 | } 32 | 33 | #[derive(Default, Clone)] 34 | struct KeyStorage { 35 | identities: Arc>>, 36 | } 37 | 38 | impl KeyStorage { 39 | fn identity_index_from_pubkey(identities: &[Identity], pubkey: &PublicKey) -> Option { 40 | for (index, identity) in identities.iter().enumerate() { 41 | if &identity.pubkey == pubkey { 42 | return Some(index); 43 | } 44 | } 45 | None 46 | } 47 | 48 | fn identity_from_pubkey(&self, pubkey: &PublicKey) -> Option { 49 | let identities = self.identities.lock().unwrap(); 50 | 51 | let index = Self::identity_index_from_pubkey(&identities, pubkey)?; 52 | Some(identities[index].clone()) 53 | } 54 | 55 | fn identity_add(&self, identity: Identity) { 56 | let mut identities = self.identities.lock().unwrap(); 57 | if Self::identity_index_from_pubkey(&identities, &identity.pubkey).is_none() { 58 | identities.push(identity); 59 | } 60 | } 61 | 62 | fn identity_remove(&self, pubkey: &PublicKey) -> Result<(), AgentError> { 63 | let mut identities = self.identities.lock().unwrap(); 64 | 65 | if let Some(index) = Self::identity_index_from_pubkey(&identities, pubkey) { 66 | identities.remove(index); 67 | Ok(()) 68 | } else { 69 | Err(std::io::Error::other("Failed to remove identity: identity not found").into()) 70 | } 71 | } 72 | } 73 | 74 | #[crate::async_trait] 75 | impl Session for KeyStorage { 76 | async fn sign(&mut self, sign_request: SignRequest) -> Result { 77 | let pubkey: PublicKey = sign_request.pubkey.clone().into(); 78 | 79 | if let Some(identity) = self.identity_from_pubkey(&pubkey) { 80 | match identity.privkey.key_data() { 81 | KeypairData::Rsa(ref key) => { 82 | let algorithm; 83 | 84 | let private_key: rsa::RsaPrivateKey = 85 | key.try_into().map_err(AgentError::other)?; 86 | let mut rng = rand::thread_rng(); 87 | let data = &sign_request.data; 88 | 89 | let signature = if sign_request.flags & signature::RSA_SHA2_512 != 0 { 90 | algorithm = "rsa-sha2-512"; 91 | SigningKey::::new(private_key).sign_with_rng(&mut rng, data) 92 | } else if sign_request.flags & signature::RSA_SHA2_256 != 0 { 93 | algorithm = "rsa-sha2-256"; 94 | SigningKey::::new(private_key).sign_with_rng(&mut rng, data) 95 | } else { 96 | algorithm = "ssh-rsa"; 97 | SigningKey::::new(private_key).sign_with_rng(&mut rng, data) 98 | }; 99 | Ok(Signature::new( 100 | Algorithm::new(algorithm).map_err(AgentError::other)?, 101 | signature.to_bytes().to_vec(), 102 | ) 103 | .map_err(AgentError::other)?) 104 | } 105 | _ => Err(std::io::Error::other("Signature for key type not implemented").into()), 106 | } 107 | } else { 108 | Err(std::io::Error::other("Failed to create signature: identity not found").into()) 109 | } 110 | } 111 | 112 | async fn request_identities(&mut self) -> Result, AgentError> { 113 | let mut identities = vec![]; 114 | for identity in self.identities.lock().unwrap().iter() { 115 | identities.push(message::Identity { 116 | pubkey: identity.pubkey.key_data().clone(), 117 | comment: identity.comment.clone(), 118 | }) 119 | } 120 | Ok(identities) 121 | } 122 | 123 | async fn add_identity(&mut self, identity: AddIdentity) -> Result<(), AgentError> { 124 | if let Credential::Key { privkey, comment } = identity.credential { 125 | let privkey = PrivateKey::try_from(privkey).map_err(AgentError::other)?; 126 | self.identity_add(Identity { 127 | pubkey: PublicKey::from(&privkey), 128 | privkey, 129 | comment, 130 | }); 131 | Ok(()) 132 | } else { 133 | info!("Unsupported key type: {:#?}", identity.credential); 134 | Ok(()) 135 | } 136 | } 137 | 138 | async fn add_identity_constrained( 139 | &mut self, 140 | identity: AddIdentityConstrained, 141 | ) -> Result<(), AgentError> { 142 | let AddIdentityConstrained { 143 | identity, 144 | constraints, 145 | } = identity; 146 | info!("Would use these constraints: {constraints:#?}"); 147 | for constraint in constraints { 148 | if let KeyConstraint::Extension(extension) = constraint { 149 | if let Some(destination) = 150 | extension.parse_key_constraint::()? 151 | { 152 | info!("Destination constraint: {destination:?}"); 153 | } 154 | 155 | if let Credential::Key { privkey, comment } = identity.credential.clone() { 156 | let privkey = PrivateKey::try_from(privkey).map_err(AgentError::other)?; 157 | self.identity_add(Identity { 158 | pubkey: PublicKey::from(&privkey), 159 | privkey, 160 | comment, 161 | }); 162 | } 163 | } 164 | } 165 | self.add_identity(identity).await 166 | } 167 | 168 | async fn remove_identity(&mut self, identity: RemoveIdentity) -> Result<(), AgentError> { 169 | let pubkey: PublicKey = identity.pubkey.into(); 170 | self.identity_remove(&pubkey)?; 171 | Ok(()) 172 | } 173 | 174 | async fn add_smartcard_key(&mut self, key: SmartcardKey) -> Result<(), AgentError> { 175 | info!("Adding smartcard key: {key:?}"); 176 | 177 | Ok(()) 178 | } 179 | 180 | async fn add_smartcard_key_constrained( 181 | &mut self, 182 | key: AddSmartcardKeyConstrained, 183 | ) -> Result<(), AgentError> { 184 | info!("Adding smartcard key with constraints: {key:?}"); 185 | Ok(()) 186 | } 187 | async fn lock(&mut self, pwd: String) -> Result<(), AgentError> { 188 | info!("Locked with password: {pwd:?}"); 189 | Ok(()) 190 | } 191 | 192 | async fn unlock(&mut self, pwd: String) -> Result<(), AgentError> { 193 | info!("Unlocked with password: {pwd:?}"); 194 | Ok(()) 195 | } 196 | 197 | async fn extension(&mut self, extension: Extension) -> Result, AgentError> { 198 | info!("Extension: {extension:?}"); 199 | 200 | match extension.name.as_str() { 201 | "query" => { 202 | let response = Extension::new_message(QueryResponse { 203 | extensions: vec!["query".into(), "session-bind@openssh.com".into()], 204 | })?; 205 | Ok(Some(response)) 206 | } 207 | "session-bind@openssh.com" => match extension.parse_message::()? { 208 | Some(bind) => { 209 | bind.verify_signature() 210 | .map_err(|_| AgentError::ExtensionFailure)?; 211 | 212 | info!("Session binding: {bind:?}"); 213 | Ok(None) 214 | } 215 | None => Err(AgentError::Failure), 216 | }, 217 | _ => Err(AgentError::Failure), 218 | } 219 | } 220 | } 221 | 222 | #[tokio::main] 223 | async fn main() -> Result<(), AgentError> { 224 | env_logger::init(); 225 | 226 | #[cfg(not(windows))] 227 | let socket = "ssh-agent.sock"; 228 | #[cfg(windows)] 229 | let socket = r"\\.\pipe\agent"; 230 | 231 | let _ = std::fs::remove_file(socket); // remove the socket if exists 232 | 233 | // This is only used for integration tests on Windows: 234 | #[cfg(windows)] 235 | std::fs::File::create("server-started")?; 236 | 237 | listen(Listener::bind(socket)?, KeyStorage::default()).await?; 238 | Ok(()) 239 | } 240 | -------------------------------------------------------------------------------- /examples/openpgp-card-agent.rs: -------------------------------------------------------------------------------- 1 | //! OpenPGP Card SSH Agent 2 | //! 3 | //! Implements an SSH agent which forwards cryptographic operations to 4 | //! an OpenPGP Card device (such as Yubikey, Nitrokey etc). 5 | //! The PIN is stored in memory for the duration of the agent session. 6 | //! This agent supports only ed25519 authentication subkeys. 7 | //! To provision the token use [OpenPGP Card Tools](https://codeberg.org/openpgp-card/openpgp-card-tools/#generate-keys-on-the-card). 8 | //! Due to the use of PC/SC the agent requires pcsclite on Linux but no other libs 9 | //! on Windows and macOS as it will utilize built-in smartcard services. 10 | //! 11 | //! The typical session: 12 | //! - starting the SSH agent: `cargo run --example openpgp-card-agent -- -H unix:///tmp/sock` 13 | //! - listing available cards: `SSH_AUTH_SOCK=/tmp/sock ssh-add -L` (this will display the card ident used in the next step) 14 | //! - storing PIN for one of the cards: `SSH_AUTH_SOCK=/tmp/sock ssh-add -s 0006:15422467` (the agent will validate the PIN before storing it) 15 | //! - and that's it! You can use the agent to login to your SSH servers. 16 | 17 | use std::{sync::Arc, time::Duration}; 18 | 19 | use card_backend_pcsc::PcscBackend; 20 | use clap::Parser; 21 | use openpgp_card::{ 22 | ocard::algorithm::AlgorithmAttributes, 23 | ocard::crypto::{Cryptogram, EccType, PublicKeyMaterial}, 24 | ocard::KeyType, 25 | ocard::OpenPGP, 26 | }; 27 | use retainer::{Cache, CacheExpiration}; 28 | use secrecy::{ExposeSecret, SecretString}; 29 | use service_binding::Binding; 30 | use ssh_agent_lib::{ 31 | agent::{bind, Session}, 32 | error::AgentError, 33 | proto::{ 34 | extension::MessageExtension, AddSmartcardKeyConstrained, Extension, Identity, 35 | KeyConstraint, ProtoError, SignRequest, SmartcardKey, 36 | }, 37 | }; 38 | use ssh_key::{ 39 | public::{Ed25519PublicKey, KeyData}, 40 | Algorithm, Signature, 41 | }; 42 | use testresult::TestResult; 43 | mod extensions; 44 | use extensions::{ 45 | DecryptDeriveRequest, DecryptDeriveResponse, DecryptIdentities, RequestDecryptIdentities, 46 | }; 47 | 48 | #[derive(Clone)] 49 | struct CardSession { 50 | pwds: Arc>, 51 | } 52 | 53 | impl CardSession { 54 | pub fn new() -> Self { 55 | let pwds: Arc> = Arc::new(Default::default()); 56 | let clone = Arc::clone(&pwds); 57 | tokio::spawn(async move { clone.monitor(4, 0.25, Duration::from_secs(3)).await }); 58 | Self { pwds } 59 | } 60 | 61 | async fn handle_sign( 62 | &self, 63 | request: SignRequest, 64 | ) -> Result> { 65 | let cards = PcscBackend::cards(None).map_err(AgentError::other)?; 66 | for card in cards { 67 | let mut card = OpenPGP::new(card?)?; 68 | let mut tx = card.transaction()?; 69 | let ident = tx.application_identifier()?.ident(); 70 | if let PublicKeyMaterial::E(e) = tx.public_key(KeyType::Authentication)? { 71 | if let AlgorithmAttributes::Ecc(ecc) = e.algo() { 72 | if ecc.ecc_type() == EccType::EdDSA { 73 | let pubkey = KeyData::Ed25519(Ed25519PublicKey(e.data().try_into()?)); 74 | if pubkey == request.pubkey { 75 | let pin = self.pwds.get(&ident).await; 76 | return if let Some(pin) = pin { 77 | let str = pin.expose_secret().as_bytes().to_vec(); 78 | tx.verify_pw1_user(str.into())?; 79 | let signature = tx.internal_authenticate(request.data.clone())?; 80 | 81 | Ok(Signature::new(Algorithm::Ed25519, signature)?) 82 | } else { 83 | // no pin saved, use "ssh-add -s ..." 84 | Err(std::io::Error::other("no pin saved").into()) 85 | }; 86 | } 87 | } 88 | } 89 | } 90 | } 91 | Err(std::io::Error::other("no applicable card found").into()) 92 | } 93 | 94 | async fn handle_add_smartcard_key( 95 | &mut self, 96 | key: SmartcardKey, 97 | expiration: impl Into, 98 | ) -> Result<(), AgentError> { 99 | match PcscBackend::cards(None) { 100 | Ok(cards) => { 101 | let card_pin_matches = cards 102 | .flat_map(|card| { 103 | let mut card = OpenPGP::new(card?)?; 104 | let mut tx = card.transaction()?; 105 | let ident = tx.application_identifier()?.ident(); 106 | if ident == key.id { 107 | let str = key.pin.expose_secret().as_bytes().to_vec(); 108 | tx.verify_pw1_user(str.into())?; 109 | 110 | Ok::<_, Box>(true) 111 | } else { 112 | Ok(false) 113 | } 114 | }) 115 | .any(|x| x); 116 | if card_pin_matches { 117 | self.pwds.insert(key.id, key.pin, expiration).await; 118 | Ok(()) 119 | } else { 120 | Err(AgentError::IO(std::io::Error::other( 121 | "Card/PIN combination is not valid", 122 | ))) 123 | } 124 | } 125 | Err(error) => Err(AgentError::other(error)), 126 | } 127 | } 128 | 129 | async fn decrypt_derive( 130 | &mut self, 131 | req: DecryptDeriveRequest, 132 | ) -> Result>, Box> { 133 | if let Ok(cards) = PcscBackend::cards(None) { 134 | for card in cards { 135 | let mut card = OpenPGP::new(card?)?; 136 | let mut tx = card.transaction()?; 137 | if let PublicKeyMaterial::E(e) = tx.public_key(KeyType::Decryption)? { 138 | if let AlgorithmAttributes::Ecc(ecc) = e.algo() { 139 | if ecc.ecc_type() == EccType::ECDH { 140 | let pubkey = KeyData::Ed25519(Ed25519PublicKey(e.data().try_into()?)); 141 | if pubkey == req.pubkey { 142 | let ident = tx.application_identifier()?.ident(); 143 | let pin = self.pwds.get(&ident).await; 144 | if let Some(pin) = pin { 145 | let str = pin.expose_secret().as_bytes().to_vec(); 146 | tx.verify_pw1_user(str.into())?; 147 | 148 | let data = tx.decipher(Cryptogram::ECDH(&req.data))?; 149 | return Ok(Some(data)); 150 | } 151 | } 152 | } 153 | } 154 | } 155 | } 156 | } 157 | Ok(None) 158 | } 159 | } 160 | 161 | #[ssh_agent_lib::async_trait] 162 | impl Session for CardSession { 163 | async fn request_identities(&mut self) -> Result, AgentError> { 164 | Ok(if let Ok(cards) = PcscBackend::cards(None) { 165 | cards 166 | .flat_map(|card| { 167 | let mut card = OpenPGP::new(card?)?; 168 | let mut tx = card.transaction()?; 169 | let ident = tx.application_identifier()?.ident(); 170 | if let PublicKeyMaterial::E(e) = tx.public_key(KeyType::Authentication)? { 171 | if let AlgorithmAttributes::Ecc(ecc) = e.algo() { 172 | if ecc.ecc_type() == EccType::EdDSA { 173 | return Ok::<_, Box>(Some(Identity { 174 | pubkey: KeyData::Ed25519(Ed25519PublicKey( 175 | e.data().try_into()?, 176 | )), 177 | comment: ident, 178 | })); 179 | } 180 | } 181 | } 182 | Ok(None) 183 | }) 184 | .flatten() 185 | .collect::>() 186 | } else { 187 | vec![] 188 | }) 189 | } 190 | 191 | async fn add_smartcard_key(&mut self, key: SmartcardKey) -> Result<(), AgentError> { 192 | self.handle_add_smartcard_key(key, CacheExpiration::none()) 193 | .await 194 | } 195 | 196 | async fn add_smartcard_key_constrained( 197 | &mut self, 198 | key: AddSmartcardKeyConstrained, 199 | ) -> Result<(), AgentError> { 200 | if key.constraints.len() > 1 { 201 | return Err(AgentError::other(std::io::Error::other( 202 | "Only one lifetime constraint supported.", 203 | ))); 204 | } 205 | let expiration_in_seconds = if let KeyConstraint::Lifetime(seconds) = key.constraints[0] { 206 | Duration::from_secs(seconds as u64) 207 | } else { 208 | return Err(AgentError::other(std::io::Error::other( 209 | "Only one lifetime constraint supported.", 210 | ))); 211 | }; 212 | self.handle_add_smartcard_key(key.key, expiration_in_seconds) 213 | .await 214 | } 215 | 216 | async fn sign(&mut self, request: SignRequest) -> Result { 217 | self.handle_sign(request).await.map_err(AgentError::Other) 218 | } 219 | 220 | async fn extension(&mut self, extension: Extension) -> Result, AgentError> { 221 | if extension.name == RequestDecryptIdentities::NAME { 222 | let identities = if let Ok(cards) = PcscBackend::cards(None) { 223 | cards 224 | .flat_map(|card| { 225 | let mut card = OpenPGP::new(card?)?; 226 | let mut tx = card.transaction()?; 227 | let ident = tx.application_identifier()?.ident(); 228 | if let PublicKeyMaterial::E(e) = tx.public_key(KeyType::Decryption)? { 229 | if let AlgorithmAttributes::Ecc(ecc) = e.algo() { 230 | if ecc.ecc_type() == EccType::ECDH { 231 | return Ok::<_, Box>(Some(Identity { 232 | pubkey: KeyData::Ed25519(Ed25519PublicKey( 233 | e.data().try_into()?, 234 | )), 235 | comment: ident, 236 | })); 237 | } 238 | } 239 | } 240 | Ok(None) 241 | }) 242 | .flatten() 243 | .collect::>() 244 | } else { 245 | vec![] 246 | }; 247 | 248 | Ok(Some( 249 | Extension::new_message(DecryptIdentities { identities }) 250 | .map_err(AgentError::other)?, 251 | )) 252 | } else if extension.name == DecryptDeriveRequest::NAME { 253 | let req = extension 254 | .parse_message::()? 255 | .expect("message to be there"); 256 | 257 | let decrypted = self.decrypt_derive(req).await.map_err(AgentError::Other)?; 258 | 259 | if let Some(decrypted) = decrypted { 260 | Ok(Some( 261 | Extension::new_message(DecryptDeriveResponse { data: decrypted }) 262 | .map_err(AgentError::other)?, 263 | )) 264 | } else { 265 | Err(AgentError::from(ProtoError::UnsupportedCommand { 266 | command: 27, 267 | })) 268 | } 269 | } else { 270 | Err(AgentError::from(ProtoError::UnsupportedCommand { 271 | command: 27, 272 | })) 273 | } 274 | } 275 | } 276 | 277 | #[derive(Debug, Parser)] 278 | struct Args { 279 | #[clap(short = 'H', long)] 280 | host: Binding, 281 | } 282 | 283 | #[tokio::main] 284 | async fn main() -> TestResult { 285 | env_logger::init(); 286 | 287 | let args = Args::parse(); 288 | bind(args.host.try_into()?, CardSession::new()).await?; 289 | Ok(()) 290 | } 291 | -------------------------------------------------------------------------------- /examples/pgp-wrapper.rs: -------------------------------------------------------------------------------- 1 | //! OpenPGP wrapper for SSH keys 2 | //! 3 | //! Creates an OpenPGP certificate based on the SSH key and allows signing files 4 | //! emitting OpenPGP framed packets. 5 | //! 6 | //! Requires that the first key in SSH is ed25519 (see `ssh-add -L`). 7 | //! 8 | //! Generate a key with: 9 | //! `cargo run --example pgp-wrapper generate "John Doe " > key.pgp` 10 | //! 11 | //! Sign data using: 12 | //! `cargo run --example pgp-wrapper sign < Cargo.toml > Cargo.toml.sig` 13 | //! 14 | //! Import the certificate using GnuPG: 15 | //! ```sh 16 | //! $ gpg --import key.pgp 17 | //! gpg: key A142E92C91BE3AD5: public key "John Doe " imported 18 | //! gpg: Total number processed: 1 19 | //! gpg: imported: 1 20 | //! ``` 21 | //! 22 | //! Verify the signature using GnuPG: 23 | //! ```sh 24 | //! $ gpg --verify Cargo.toml.sig 25 | //! gpg: assuming signed data in 'Cargo.toml' 26 | //! gpg: Signature made Fri May 10 11:15:53 2024 CEST 27 | //! gpg: using EDDSA key 4EB27E153DDC454364B36B59A142E92C91BE3AD5 28 | //! gpg: Good signature from "John Doe " [unknown] 29 | //! gpg: WARNING: This key is not certified with a trusted signature! 30 | //! gpg: There is no indication that the signature belongs to the owner. 31 | //! Primary key fingerprint: 4EB2 7E15 3DDC 4543 64B3 6B59 A142 E92C 91BE 3AD5 32 | //! ``` 33 | //! 34 | //! Works perfectly in conjunction with `openpgp-card-agent.rs`! 35 | //! 36 | //! If the SSH agent implements `decrypt derive` extension this agent additionally 37 | //! creates encryption capable subkey and supports the `decrypt` subcommand: 38 | //! 39 | //! ```sh 40 | //! echo I like strawberries | gpg -er 4EB27E153DDC454364B36B59A142E92C91BE3AD5 > /tmp/encrypted.pgp 41 | //! SSH_AUTH_SOCK=/tmp/ext-agent.sock cargo run --example pgp-wrapper -- decrypt < /tmp/encrypted.pgp 42 | //! ... 43 | //! I like strawberries 44 | //! ``` 45 | 46 | use std::io::Write as _; 47 | 48 | use chrono::DateTime; 49 | use clap::Parser; 50 | use pgp::{ 51 | crypto::{ecc_curve::ECCCurve, hash::HashAlgorithm, public_key::PublicKeyAlgorithm}, 52 | packet::{ 53 | KeyFlags, PacketTrait, PublicKey, SignatureConfig, SignatureType, SignatureVersion, 54 | Subpacket, SubpacketData, UserId, 55 | }, 56 | ser::Serialize, 57 | types::{ 58 | CompressionAlgorithm, KeyTrait, KeyVersion, Mpi, PublicKeyTrait, PublicParams, 59 | SecretKeyTrait, Version, 60 | }, 61 | Deserializable as _, Esk, KeyDetails, Message, PlainSessionKey, Signature, 62 | }; 63 | use service_binding::Binding; 64 | use ssh_agent_lib::{ 65 | agent::Session, 66 | client::connect, 67 | proto::{Extension, SignRequest}, 68 | }; 69 | use ssh_key::public::KeyData; 70 | use tokio::runtime::Runtime; 71 | use tokio::sync::Mutex; 72 | mod extensions; 73 | use extensions::{ 74 | DecryptDeriveRequest, DecryptDeriveResponse, DecryptIdentities, RequestDecryptIdentities, 75 | }; 76 | 77 | struct WrappedKey { 78 | public_key: PublicKey, 79 | pubkey: KeyData, 80 | client: Mutex>, 81 | } 82 | 83 | #[derive(Clone, Copy, Debug)] 84 | enum KeyRole { 85 | Signing, 86 | Decryption, 87 | } 88 | 89 | impl From for PublicKeyAlgorithm { 90 | fn from(value: KeyRole) -> Self { 91 | match value { 92 | KeyRole::Signing => PublicKeyAlgorithm::EdDSA, 93 | KeyRole::Decryption => PublicKeyAlgorithm::ECDH, 94 | } 95 | } 96 | } 97 | 98 | fn ssh_to_pgp(pubkey: KeyData, key_role: KeyRole) -> PublicKey { 99 | let KeyData::Ed25519(key) = pubkey.clone() else { 100 | panic!("The first key was not ed25519!"); 101 | }; 102 | 103 | let mut key_bytes = key.0.to_vec(); 104 | // Add prefix to mark that this MPI uses EdDSA point representation. 105 | // See https://datatracker.ietf.org/doc/draft-koch-eddsa-for-openpgp/ 106 | key_bytes.insert(0, 0x40); 107 | 108 | let public_params = match key_role { 109 | KeyRole::Signing => PublicParams::EdDSA { 110 | curve: ECCCurve::Ed25519, 111 | q: key_bytes.into(), 112 | }, 113 | // most common values taken from 114 | // https://gitlab.com/sequoia-pgp/sequoia/-/issues/838#note_909813463 115 | KeyRole::Decryption => PublicParams::ECDH { 116 | curve: ECCCurve::Curve25519, 117 | p: key_bytes.into(), 118 | hash: HashAlgorithm::SHA2_256, 119 | alg_sym: pgp::crypto::sym::SymmetricKeyAlgorithm::AES128, 120 | }, 121 | }; 122 | 123 | PublicKey::new( 124 | Version::New, 125 | KeyVersion::V4, 126 | key_role.into(), 127 | // use fixed date so that the fingerprint generation is deterministic 128 | DateTime::parse_from_rfc3339("2016-09-06T17:00:00+02:00") 129 | .expect("date to be valid") 130 | .into(), 131 | None, 132 | public_params, 133 | ) 134 | .expect("key to be valid") 135 | } 136 | 137 | impl WrappedKey { 138 | fn new(pubkey: KeyData, client: Box, key_role: KeyRole) -> Self { 139 | let public_key = ssh_to_pgp(pubkey.clone(), key_role); 140 | Self { 141 | pubkey, 142 | client: Mutex::new(client), 143 | public_key, 144 | } 145 | } 146 | 147 | fn decrypt( 148 | &self, 149 | mpis: &[Mpi], 150 | ) -> Result<(Vec, pgp::crypto::sym::SymmetricKeyAlgorithm), pgp::errors::Error> { 151 | if let PublicParams::ECDH { 152 | curve, 153 | alg_sym, 154 | hash, 155 | .. 156 | } = self.public_key().public_params() 157 | { 158 | let ciphertext = mpis[0].as_bytes(); 159 | 160 | // encrypted and wrapped value derived from the session key 161 | let encrypted_session_key = mpis[2].as_bytes(); 162 | 163 | let ciphertext = if *curve == ECCCurve::Curve25519 { 164 | assert_eq!( 165 | ciphertext[0], 0x40, 166 | "Unexpected shape of Cv25519 encrypted data" 167 | ); 168 | 169 | // Strip trailing 0x40 170 | &ciphertext[1..] 171 | } else { 172 | unimplemented!(); 173 | }; 174 | 175 | let plaintext = Runtime::new() 176 | .expect("creating runtime to succeed") 177 | .handle() 178 | .block_on(async { 179 | let mut client = self.client.lock().await; 180 | let result = client.extension( 181 | Extension::new_message(DecryptDeriveRequest { 182 | pubkey: self.pubkey.clone(), 183 | data: ciphertext.to_vec(), 184 | flags: 0, 185 | }) 186 | .expect("encoding to work"), 187 | ); 188 | result.await 189 | }) 190 | .expect("decryption to succeed") 191 | .expect("result not to be empty"); 192 | 193 | let shared_secret = &plaintext 194 | .parse_message::() 195 | .expect("decoding to succeed") 196 | .expect("not to be empty") 197 | .data[..]; 198 | 199 | let encrypted_key_len: usize = mpis[1].first().copied().map(Into::into).unwrap_or(0); 200 | 201 | let decrypted_key: Vec = pgp::crypto::ecdh::derive_session_key( 202 | shared_secret, 203 | encrypted_session_key, 204 | encrypted_key_len, 205 | &(curve.clone(), *alg_sym, *hash), 206 | &self.public_key.fingerprint(), 207 | )?; 208 | 209 | // strip off the leading session key algorithm octet, and the two trailing checksum octets 210 | let dec_len = decrypted_key.len(); 211 | let (sessionkey, checksum) = ( 212 | &decrypted_key[1..dec_len - 2], 213 | &decrypted_key[dec_len - 2..], 214 | ); 215 | 216 | // ... check the checksum, while we have it at hand 217 | pgp::crypto::checksum::simple(checksum, sessionkey)?; 218 | 219 | let session_key_algorithm = decrypted_key[0].into(); 220 | Ok((sessionkey.to_vec(), session_key_algorithm)) 221 | } else { 222 | unimplemented!(); 223 | } 224 | } 225 | } 226 | 227 | impl std::fmt::Debug for WrappedKey { 228 | fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { 229 | write!(f, "WrappedKey") 230 | } 231 | } 232 | 233 | impl KeyTrait for WrappedKey { 234 | fn fingerprint(&self) -> Vec { 235 | self.public_key.fingerprint() 236 | } 237 | 238 | fn key_id(&self) -> pgp::types::KeyId { 239 | self.public_key.key_id() 240 | } 241 | 242 | fn algorithm(&self) -> pgp::crypto::public_key::PublicKeyAlgorithm { 243 | self.public_key.algorithm() 244 | } 245 | } 246 | 247 | impl PublicKeyTrait for WrappedKey { 248 | fn verify_signature( 249 | &self, 250 | hash: pgp::crypto::hash::HashAlgorithm, 251 | data: &[u8], 252 | sig: &[pgp::types::Mpi], 253 | ) -> pgp::errors::Result<()> { 254 | self.public_key.verify_signature(hash, data, sig) 255 | } 256 | 257 | fn encrypt( 258 | &self, 259 | rng: &mut R, 260 | plain: &[u8], 261 | ) -> pgp::errors::Result> { 262 | self.public_key.encrypt(rng, plain) 263 | } 264 | 265 | fn to_writer_old(&self, writer: &mut impl std::io::Write) -> pgp::errors::Result<()> { 266 | self.public_key.to_writer_old(writer) 267 | } 268 | } 269 | 270 | impl SecretKeyTrait for WrappedKey { 271 | type PublicKey = PublicKey; 272 | 273 | type Unlocked = Self; 274 | 275 | fn unlock(&self, _pw: F, work: G) -> pgp::errors::Result 276 | where 277 | F: FnOnce() -> String, 278 | G: FnOnce(&Self::Unlocked) -> pgp::errors::Result, 279 | { 280 | work(self) 281 | } 282 | 283 | fn create_signature( 284 | &self, 285 | _key_pw: F, 286 | _hash: pgp::crypto::hash::HashAlgorithm, 287 | data: &[u8], 288 | ) -> pgp::errors::Result> 289 | where 290 | F: FnOnce() -> String, 291 | { 292 | let signature = Runtime::new() 293 | .expect("creating runtime to succeed") 294 | .handle() 295 | .block_on(async { 296 | let mut client = self.client.lock().await; 297 | let result = client.sign(SignRequest { 298 | pubkey: self.pubkey.clone(), 299 | data: data.to_vec(), 300 | flags: 0, 301 | }); 302 | result.await 303 | }) 304 | .expect("signing to succeed"); 305 | 306 | let sig = &signature.as_bytes(); 307 | 308 | assert_eq!(sig.len(), 64); 309 | 310 | Ok(vec![ 311 | Mpi::from_raw_slice(&sig[..32]), 312 | Mpi::from_raw_slice(&sig[32..]), 313 | ]) 314 | } 315 | 316 | fn public_key(&self) -> Self::PublicKey { 317 | self.public_key.clone() 318 | } 319 | 320 | fn public_params(&self) -> &pgp::types::PublicParams { 321 | self.public_key.public_params() 322 | } 323 | } 324 | 325 | #[derive(Debug, Parser)] 326 | enum Args { 327 | Generate { userid: String }, 328 | Sign, 329 | Decrypt, 330 | } 331 | 332 | fn main() -> testresult::TestResult { 333 | let args = Args::parse(); 334 | 335 | let rt = Runtime::new()?; 336 | 337 | let (client, identities, decrypt_ids) = rt.block_on(async move { 338 | #[cfg(unix)] 339 | let mut client = 340 | connect(Binding::FilePath(std::env::var("SSH_AUTH_SOCK")?.into()).try_into()?)?; 341 | 342 | #[cfg(windows)] 343 | let mut client = 344 | connect(Binding::NamedPipe(std::env::var("SSH_AUTH_SOCK")?.into()).try_into()?)?; 345 | 346 | let identities = client.request_identities().await?; 347 | 348 | if identities.is_empty() { 349 | panic!("We need at least one ed25519 identity!"); 350 | } 351 | 352 | let decrypt_ids = if let Ok(Some(identities)) = client 353 | .extension(Extension::new_message(RequestDecryptIdentities)?) 354 | .await 355 | { 356 | identities 357 | .parse_message::()? 358 | .map(|d| d.identities) 359 | .unwrap_or_default() 360 | } else { 361 | vec![] 362 | }; 363 | 364 | Ok::<_, testresult::TestError>((client, identities, decrypt_ids)) 365 | })?; 366 | 367 | let pubkey = &identities[0].pubkey; 368 | 369 | match args { 370 | Args::Generate { userid } => { 371 | let subkeys = if let Some(decryption_id) = decrypt_ids.first() { 372 | let mut keyflags = KeyFlags::default(); 373 | keyflags.set_encrypt_comms(true); 374 | keyflags.set_encrypt_storage(true); 375 | let pk = ssh_to_pgp(decryption_id.pubkey.clone(), KeyRole::Decryption); 376 | vec![pgp::PublicSubkey::new( 377 | pgp::packet::PublicSubkey::new( 378 | pk.packet_version(), 379 | pk.version(), 380 | pk.algorithm(), 381 | *pk.created_at(), 382 | pk.expiration(), 383 | pk.public_params().clone(), 384 | )?, 385 | keyflags, 386 | )] 387 | } else { 388 | vec![] 389 | }; 390 | 391 | let signer = WrappedKey::new(pubkey.clone(), client, KeyRole::Signing); 392 | let mut keyflags = KeyFlags::default(); 393 | keyflags.set_sign(true); 394 | keyflags.set_certify(true); 395 | 396 | let composed_pk = pgp::PublicKey::new( 397 | signer.public_key(), 398 | KeyDetails::new( 399 | UserId::from_str(Default::default(), &userid), 400 | vec![], 401 | vec![], 402 | keyflags, 403 | Default::default(), 404 | Default::default(), 405 | vec![CompressionAlgorithm::Uncompressed].into(), 406 | None, 407 | ), 408 | subkeys, 409 | ); 410 | let signed_pk = composed_pk.sign(&signer, String::new)?; 411 | signed_pk.to_writer(&mut std::io::stdout())?; 412 | } 413 | Args::Sign => { 414 | let signer = WrappedKey::new(pubkey.clone(), client, KeyRole::Signing); 415 | let signature = SignatureConfig::new_v4( 416 | SignatureVersion::V4, 417 | SignatureType::Binary, 418 | signer.algorithm(), 419 | HashAlgorithm::SHA2_256, 420 | vec![ 421 | Subpacket::regular(SubpacketData::SignatureCreationTime( 422 | std::time::SystemTime::now().into(), 423 | )), 424 | Subpacket::regular(SubpacketData::Issuer(signer.key_id())), 425 | Subpacket::regular(SubpacketData::IssuerFingerprint( 426 | KeyVersion::V4, 427 | signer.fingerprint().into(), 428 | )), 429 | ], 430 | vec![], 431 | ); 432 | 433 | let mut hasher = signature.hash_alg.new_hasher()?; 434 | 435 | signature.hash_data_to_sign(&mut *hasher, std::io::stdin())?; 436 | let len = signature.hash_signature_data(&mut *hasher)?; 437 | hasher.update(&signature.trailer(len)?); 438 | 439 | let hash = &hasher.finish()[..]; 440 | 441 | let signed_hash_value = [hash[0], hash[1]]; 442 | let raw_sig = signer.create_signature(String::new, HashAlgorithm::SHA2_256, hash)?; 443 | 444 | let signature = Signature::from_config(signature, signed_hash_value, raw_sig); 445 | pgp::packet::write_packet(&mut std::io::stdout(), &signature)?; 446 | } 447 | Args::Decrypt => { 448 | let decryptor = 449 | WrappedKey::new(decrypt_ids[0].pubkey.clone(), client, KeyRole::Decryption); 450 | let message = Message::from_bytes(std::io::stdin())?; 451 | 452 | let Message::Encrypted { esk, edata } = message else { 453 | panic!("not encrypted"); 454 | }; 455 | 456 | let mpis = if let Esk::PublicKeyEncryptedSessionKey(ref k) = esk[0] { 457 | k.mpis() 458 | } else { 459 | panic!("whoops") 460 | }; 461 | 462 | let (session_key, session_key_algorithm) = 463 | decryptor.unlock(String::new, |priv_key| priv_key.decrypt(mpis))?; 464 | 465 | let plain_session_key = PlainSessionKey::V4 { 466 | key: session_key, 467 | sym_alg: session_key_algorithm, 468 | }; 469 | 470 | let decrypted = edata.decrypt(plain_session_key)?; 471 | 472 | if let Message::Literal(data) = decrypted { 473 | std::io::stdout().write_all(data.data())?; 474 | } else { 475 | eprintln!("decrypted: {:?}", &decrypted); 476 | } 477 | } 478 | } 479 | 480 | Ok(()) 481 | } 482 | -------------------------------------------------------------------------------- /examples/proto-dumper.rs: -------------------------------------------------------------------------------- 1 | //! This example illustrates a couple of features: First, it 2 | //! implements a forwarder, exposing an SSH agent socket and 3 | //! forwarding to a different one. Secondly it shows how to work with 4 | //! low-level handling of messages instead of parsed high-level 5 | //! structures. 6 | //! 7 | //! Run with 8 | //! RUST_LOG=info cargo run --example proto-dumper -- --target unix://$SSH_AUTH_SOCK -H unix:///tmp/test.sock 9 | 10 | use clap::Parser; 11 | use service_binding::Binding; 12 | use ssh_agent_lib::{ 13 | agent::bind, 14 | agent::Agent, 15 | agent::Session, 16 | async_trait, 17 | client::connect, 18 | error::AgentError, 19 | proto::{Request, Response}, 20 | }; 21 | use ssh_encoding::Encode; 22 | 23 | struct DumpAndForward { 24 | target: Box, 25 | session: u64, 26 | id: u64, 27 | } 28 | 29 | #[async_trait] 30 | impl Session for DumpAndForward { 31 | async fn handle(&mut self, message: Request) -> Result { 32 | use std::io::Write; 33 | 34 | self.id += 1; 35 | let req_file = format!("req-{}-{}.bin", self.session, self.id); 36 | log::info!("Writing request {message:?} to {req_file}"); 37 | 38 | let mut req = std::fs::File::create(req_file)?; 39 | let mut buf = vec![]; 40 | message.encode(&mut buf).map_err(AgentError::other)?; 41 | req.write_all(&buf)?; 42 | drop(req); 43 | 44 | let response = self.target.handle(message).await?; 45 | 46 | let resp_file = format!("resp-{}-{}.bin", self.session, self.id); 47 | log::info!("Writing response {response:?} to {resp_file}"); 48 | let mut resp = std::fs::File::create(resp_file)?; 49 | let mut buf = vec![]; 50 | response.encode(&mut buf).map_err(AgentError::other)?; 51 | resp.write_all(&buf)?; 52 | drop(resp); 53 | 54 | Ok(response) 55 | } 56 | } 57 | 58 | struct Forwarder { 59 | target: Binding, 60 | id: u64, 61 | } 62 | 63 | #[cfg(unix)] 64 | impl Agent for Forwarder { 65 | fn new_session(&mut self, _socket: &tokio::net::UnixStream) -> impl Session { 66 | self.create_new_session() 67 | } 68 | } 69 | 70 | impl Agent for Forwarder { 71 | fn new_session(&mut self, _socket: &tokio::net::TcpStream) -> impl Session { 72 | self.create_new_session() 73 | } 74 | } 75 | 76 | #[cfg(windows)] 77 | impl Agent for Forwarder { 78 | fn new_session( 79 | &mut self, 80 | _socket: &tokio::net::windows::named_pipe::NamedPipeServer, 81 | ) -> impl Session { 82 | self.create_new_session() 83 | } 84 | } 85 | 86 | impl Forwarder { 87 | fn create_new_session(&mut self) -> impl Session { 88 | self.id += 1; 89 | DumpAndForward { 90 | target: connect(self.target.clone().try_into().unwrap()).unwrap(), 91 | session: self.id, 92 | id: 0, 93 | } 94 | } 95 | } 96 | 97 | #[derive(Debug, Parser)] 98 | struct Args { 99 | /// Target SSH agent to which we will proxy all requests. 100 | #[clap(long)] 101 | target: Binding, 102 | 103 | /// Source that we will bind to. 104 | #[clap(long, short = 'H')] 105 | host: Binding, 106 | } 107 | 108 | #[tokio::main] 109 | async fn main() -> Result<(), Box> { 110 | env_logger::init(); 111 | 112 | let args = Args::parse(); 113 | 114 | bind( 115 | args.host.try_into()?, 116 | Forwarder { 117 | target: args.target, 118 | id: 0, 119 | }, 120 | ) 121 | .await?; 122 | 123 | Ok(()) 124 | } 125 | -------------------------------------------------------------------------------- /examples/ssh-agent-client-blocking.rs: -------------------------------------------------------------------------------- 1 | mod extensions; 2 | 3 | #[cfg(unix)] 4 | use std::os::unix::net::UnixStream; 5 | 6 | use extensions::{DecryptIdentities, RequestDecryptIdentities}; 7 | #[cfg(windows)] 8 | use interprocess::os::windows::named_pipe::*; 9 | use ssh_agent_lib::{blocking::Client, proto::Extension}; 10 | 11 | fn main() -> testresult::TestResult { 12 | let socket = std::env::var("SSH_AUTH_SOCK")?; 13 | #[cfg(unix)] 14 | let mut client = Client::new(UnixStream::connect(socket)?); 15 | #[cfg(windows)] 16 | let mut client = Client::new(DuplexPipeStream::::connect_by_path( 17 | socket, 18 | )?); 19 | 20 | eprintln!( 21 | "Identities that this agent knows of: {:#?}", 22 | client.request_identities()? 23 | ); 24 | 25 | if let Ok(Some(identities)) = 26 | client.extension(Extension::new_message(RequestDecryptIdentities)?) 27 | { 28 | let identities = identities.parse_message::()?; 29 | eprintln!("Decrypt identities that this agent knows of: {identities:#?}",); 30 | } else { 31 | eprintln!("No decryption identities found."); 32 | } 33 | 34 | Ok(()) 35 | } 36 | -------------------------------------------------------------------------------- /examples/ssh-agent-client.rs: -------------------------------------------------------------------------------- 1 | use service_binding::Binding; 2 | use ssh_agent_lib::{client::connect, proto::Extension}; 3 | mod extensions; 4 | use extensions::{DecryptIdentities, RequestDecryptIdentities}; 5 | #[tokio::main] 6 | async fn main() -> Result<(), Box> { 7 | #[cfg(unix)] 8 | let mut client = 9 | connect(Binding::FilePath(std::env::var("SSH_AUTH_SOCK")?.into()).try_into()?)?; 10 | 11 | #[cfg(windows)] 12 | let mut client = 13 | connect(Binding::NamedPipe(std::env::var("SSH_AUTH_SOCK")?.into()).try_into()?)?; 14 | 15 | eprintln!( 16 | "Identities that this agent knows of: {:#?}", 17 | client.request_identities().await? 18 | ); 19 | 20 | if let Ok(Some(identities)) = client 21 | .extension(Extension::new_message(RequestDecryptIdentities)?) 22 | .await 23 | { 24 | let identities = identities.parse_message::()?; 25 | eprintln!("Decrypt identities that this agent knows of: {identities:#?}",); 26 | } else { 27 | eprintln!("No decryption identities found."); 28 | } 29 | 30 | Ok(()) 31 | } 32 | -------------------------------------------------------------------------------- /fuzz/.gitignore: -------------------------------------------------------------------------------- 1 | target 2 | corpus 3 | artifacts 4 | coverage 5 | -------------------------------------------------------------------------------- /fuzz/Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "ssh-agent-lib-fuzz" 3 | version = "0.5.1" 4 | publish = false 5 | edition = "2021" 6 | 7 | [package.metadata] 8 | cargo-fuzz = true 9 | 10 | [dependencies] 11 | libfuzzer-sys = "0.4" 12 | ssh-encoding = "0.2.0" 13 | 14 | [dependencies.ssh-agent-lib] 15 | path = ".." 16 | 17 | [[bin]] 18 | name = "request_decode" 19 | path = "fuzz_targets/request_decode.rs" 20 | test = false 21 | doc = false 22 | bench = false 23 | -------------------------------------------------------------------------------- /fuzz/README.md: -------------------------------------------------------------------------------- 1 | # Fuzzing 2 | 3 | This directory contains fuzzing targets for ssh-agent-lib. 4 | 5 | ## Setup 6 | 7 | Install [`cargo-fuzz`](https://crates.io/crates/cargo-fuzz): 8 | 9 | ```sh 10 | cargo install --locked cargo-fuzz 11 | ``` 12 | 13 | ## Running 14 | 15 | Select a target from the list printed by `cargo fuzz list` e.g. `message_decode`: 16 | 17 | ```sh 18 | cargo +nightly fuzz run message_decode 19 | ``` 20 | 21 | Options that can be added to the `fuzz run` command: 22 | 23 | - `--jobs N` - increase parallelism, 24 | - `--sanitizer none` - disable sanitizer since ssh-agent-lib does not use any `unsafe` blocks, 25 | 26 | Note that due to a limitation of cargo-fuzz nightly version of the toolchain is required. 27 | 28 | For more details see [Fuzzing with cargo-fuzz](https://rust-fuzz.github.io/book/cargo-fuzz.html) or the [more detailed explanation of fuzzing output](https://github.com/rust-fuzz/cargo-fuzz/issues/72#issuecomment-284448618) in a `cargo-fuzz` comment. 29 | -------------------------------------------------------------------------------- /fuzz/fuzz_targets/request_decode.rs: -------------------------------------------------------------------------------- 1 | #![no_main] 2 | 3 | use libfuzzer_sys::fuzz_target; 4 | use ssh_agent_lib::proto::message::Request; 5 | use ssh_encoding::Decode; 6 | 7 | fuzz_target!(|data: &[u8]| { 8 | let _ = Request::decode(&mut &data[..]); 9 | }); 10 | -------------------------------------------------------------------------------- /scripts/hooks/pre-commit: -------------------------------------------------------------------------------- 1 | ../../.justfile -------------------------------------------------------------------------------- /scripts/hooks/pre-push: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env sh 2 | 3 | set -euo pipefail 4 | 5 | just check-commits 6 | -------------------------------------------------------------------------------- /src/agent.rs: -------------------------------------------------------------------------------- 1 | //! Traits for implementing custom SSH agents. 2 | //! 3 | //! Agents which store no state or their state is minimal should 4 | //! implement the [`Session`] trait. If a more elaborate state is 5 | //! needed, especially one which depends on the socket making the 6 | //! connection then it is advisable to implement the [`Agent`] trait. 7 | 8 | use std::fmt; 9 | use std::io; 10 | 11 | use async_trait::async_trait; 12 | use futures::{SinkExt, TryStreamExt}; 13 | pub use service_binding; 14 | use ssh_key::Signature; 15 | use tokio::io::{AsyncRead, AsyncWrite}; 16 | #[cfg(windows)] 17 | use tokio::net::windows::named_pipe::{NamedPipeServer, ServerOptions}; 18 | use tokio::net::{TcpListener, TcpStream}; 19 | #[cfg(unix)] 20 | use tokio::net::{UnixListener, UnixStream}; 21 | use tokio_util::codec::Framed; 22 | 23 | use super::error::AgentError; 24 | use super::proto::message::{Request, Response}; 25 | use crate::codec::Codec; 26 | use crate::proto::AddIdentity; 27 | use crate::proto::AddIdentityConstrained; 28 | use crate::proto::AddSmartcardKeyConstrained; 29 | use crate::proto::Extension; 30 | use crate::proto::Identity; 31 | use crate::proto::ProtoError; 32 | use crate::proto::RemoveIdentity; 33 | use crate::proto::SignRequest; 34 | use crate::proto::SmartcardKey; 35 | 36 | /// Type representing a socket that asynchronously returns a list of streams. 37 | /// 38 | /// This trait is implemented for [TCP sockets](TcpListener) on all 39 | /// platforms, Unix sockets on Unix platforms (e.g. Linux, macOS) and 40 | /// Named Pipes on Windows. 41 | /// 42 | /// Objects implementing this trait are passed to the [`listen`] 43 | /// function. 44 | /// 45 | /// # Examples 46 | /// 47 | /// The following example starts listening for connections and 48 | /// processes them with the `MyAgent` struct. 49 | /// 50 | /// ```no_run 51 | /// # async fn main_() -> testresult::TestResult { 52 | /// use ssh_agent_lib::agent::{listen, Session}; 53 | /// use tokio::net::TcpListener; 54 | /// 55 | /// #[derive(Default, Clone)] 56 | /// struct MyAgent; 57 | /// 58 | /// impl Session for MyAgent { 59 | /// // implement your agent logic here 60 | /// } 61 | /// 62 | /// listen( 63 | /// TcpListener::bind("127.0.0.1:8080").await?, 64 | /// MyAgent::default(), 65 | /// ) 66 | /// .await?; 67 | /// # Ok(()) } 68 | /// ``` 69 | 70 | #[async_trait] 71 | pub trait ListeningSocket { 72 | /// Stream type that represents an accepted socket. 73 | type Stream: fmt::Debug + AsyncRead + AsyncWrite + Send + Unpin + 'static; 74 | 75 | /// Waits until a client connects and returns connected stream. 76 | async fn accept(&mut self) -> io::Result; 77 | } 78 | 79 | #[cfg(unix)] 80 | #[async_trait] 81 | impl ListeningSocket for UnixListener { 82 | type Stream = UnixStream; 83 | async fn accept(&mut self) -> io::Result { 84 | UnixListener::accept(self).await.map(|(s, _addr)| s) 85 | } 86 | } 87 | 88 | #[async_trait] 89 | impl ListeningSocket for TcpListener { 90 | type Stream = TcpStream; 91 | async fn accept(&mut self) -> io::Result { 92 | TcpListener::accept(self).await.map(|(s, _addr)| s) 93 | } 94 | } 95 | 96 | /// Listener for Windows Named Pipes. 97 | #[cfg(windows)] 98 | #[derive(Debug)] 99 | pub struct NamedPipeListener(NamedPipeServer, std::ffi::OsString); 100 | 101 | #[cfg(windows)] 102 | impl NamedPipeListener { 103 | /// Bind to a pipe path. 104 | pub fn bind(pipe: impl Into) -> std::io::Result { 105 | let pipe = pipe.into(); 106 | Ok(NamedPipeListener( 107 | ServerOptions::new() 108 | .first_pipe_instance(true) 109 | .create(&pipe)?, 110 | pipe, 111 | )) 112 | } 113 | } 114 | 115 | #[cfg(windows)] 116 | #[async_trait] 117 | impl ListeningSocket for NamedPipeListener { 118 | type Stream = NamedPipeServer; 119 | async fn accept(&mut self) -> io::Result { 120 | self.0.connect().await?; 121 | Ok(std::mem::replace( 122 | &mut self.0, 123 | ServerOptions::new().create(&self.1)?, 124 | )) 125 | } 126 | } 127 | 128 | /// Represents one active SSH connection. 129 | /// 130 | /// This type is implemented by agents that want to handle incoming SSH agent 131 | /// connections. 132 | /// 133 | /// # Examples 134 | /// 135 | /// The following examples shows the most minimal [`Session`] 136 | /// implementation: one that returns a list of public keys that it 137 | /// manages and signs all incoming signing requests. 138 | /// 139 | /// Note that the `MyAgent` struct is cloned for all new sessions 140 | /// (incoming connections). If the cloning needs special behavior 141 | /// implementing [`Clone`] manually is a viable approach. If the newly 142 | /// created sessions require information from the underlying socket it 143 | /// is advisable to implement the [`Agent`] trait. 144 | /// 145 | /// ``` 146 | /// use ssh_agent_lib::{agent::Session, error::AgentError}; 147 | /// use ssh_agent_lib::proto::{Identity, SignRequest}; 148 | /// use ssh_key::{Algorithm, Signature}; 149 | /// 150 | /// #[derive(Default, Clone)] 151 | /// struct MyAgent; 152 | /// 153 | /// #[ssh_agent_lib::async_trait] 154 | /// impl Session for MyAgent { 155 | /// async fn request_identities(&mut self) -> Result, AgentError> { 156 | /// Ok(vec![ /* public keys that this agent knows of */ ]) 157 | /// } 158 | /// 159 | /// async fn sign(&mut self, request: SignRequest) -> Result { 160 | /// // get the signature by signing `request.data` 161 | /// let signature = vec![]; 162 | /// Ok(Signature::new( 163 | /// Algorithm::new("algorithm").map_err(AgentError::other)?, 164 | /// signature, 165 | /// ).map_err(AgentError::other)?) 166 | /// } 167 | /// } 168 | /// ``` 169 | #[async_trait] 170 | pub trait Session: 'static + Sync + Send + Unpin { 171 | /// Request a list of keys managed by this session. 172 | async fn request_identities(&mut self) -> Result, AgentError> { 173 | Err(AgentError::from(ProtoError::UnsupportedCommand { 174 | command: 11, 175 | })) 176 | } 177 | 178 | /// Perform a private key signature operation. 179 | async fn sign(&mut self, _request: SignRequest) -> Result { 180 | Err(AgentError::from(ProtoError::UnsupportedCommand { 181 | command: 13, 182 | })) 183 | } 184 | 185 | /// Add a private key to the agent. 186 | async fn add_identity(&mut self, _identity: AddIdentity) -> Result<(), AgentError> { 187 | Err(AgentError::from(ProtoError::UnsupportedCommand { 188 | command: 17, 189 | })) 190 | } 191 | 192 | /// Add a private key to the agent with a set of constraints. 193 | async fn add_identity_constrained( 194 | &mut self, 195 | _identity: AddIdentityConstrained, 196 | ) -> Result<(), AgentError> { 197 | Err(AgentError::from(ProtoError::UnsupportedCommand { 198 | command: 25, 199 | })) 200 | } 201 | 202 | /// Remove private key from an agent. 203 | async fn remove_identity(&mut self, _identity: RemoveIdentity) -> Result<(), AgentError> { 204 | Err(AgentError::from(ProtoError::UnsupportedCommand { 205 | command: 18, 206 | })) 207 | } 208 | 209 | /// Remove all keys from an agent. 210 | async fn remove_all_identities(&mut self) -> Result<(), AgentError> { 211 | Err(AgentError::from(ProtoError::UnsupportedCommand { 212 | command: 19, 213 | })) 214 | } 215 | 216 | /// Add a key stored on a smartcard. 217 | async fn add_smartcard_key(&mut self, _key: SmartcardKey) -> Result<(), AgentError> { 218 | Err(AgentError::from(ProtoError::UnsupportedCommand { 219 | command: 20, 220 | })) 221 | } 222 | 223 | /// Add a key stored on a smartcard with a set of constraints. 224 | async fn add_smartcard_key_constrained( 225 | &mut self, 226 | _key: AddSmartcardKeyConstrained, 227 | ) -> Result<(), AgentError> { 228 | Err(AgentError::from(ProtoError::UnsupportedCommand { 229 | command: 26, 230 | })) 231 | } 232 | 233 | /// Remove a smartcard key from the agent. 234 | async fn remove_smartcard_key(&mut self, _key: SmartcardKey) -> Result<(), AgentError> { 235 | Err(AgentError::from(ProtoError::UnsupportedCommand { 236 | command: 21, 237 | })) 238 | } 239 | 240 | /// Temporarily lock the agent with a password. 241 | async fn lock(&mut self, _key: String) -> Result<(), AgentError> { 242 | Err(AgentError::from(ProtoError::UnsupportedCommand { 243 | command: 22, 244 | })) 245 | } 246 | 247 | /// Unlock the agent with a password. 248 | async fn unlock(&mut self, _key: String) -> Result<(), AgentError> { 249 | Err(AgentError::from(ProtoError::UnsupportedCommand { 250 | command: 23, 251 | })) 252 | } 253 | 254 | /// Invoke a custom, vendor-specific extension on the agent. 255 | async fn extension(&mut self, _extension: Extension) -> Result, AgentError> { 256 | Err(AgentError::from(ProtoError::UnsupportedCommand { 257 | command: 27, 258 | })) 259 | } 260 | 261 | /// Handle a raw SSH agent request and return agent response. 262 | /// 263 | /// Note that it is preferable to use high-level functions instead of 264 | /// this function. This function should be overridden only for custom 265 | /// messages, outside of the SSH agent protocol specification. 266 | async fn handle(&mut self, message: Request) -> Result { 267 | match message { 268 | Request::RequestIdentities => { 269 | return Ok(Response::IdentitiesAnswer(self.request_identities().await?)) 270 | } 271 | Request::SignRequest(request) => { 272 | return Ok(Response::SignResponse(self.sign(request).await?)) 273 | } 274 | Request::AddIdentity(identity) => self.add_identity(identity).await?, 275 | Request::RemoveIdentity(identity) => self.remove_identity(identity).await?, 276 | Request::RemoveAllIdentities => self.remove_all_identities().await?, 277 | Request::AddSmartcardKey(key) => self.add_smartcard_key(key).await?, 278 | Request::RemoveSmartcardKey(key) => self.remove_smartcard_key(key).await?, 279 | Request::Lock(key) => self.lock(key).await?, 280 | Request::Unlock(key) => self.unlock(key).await?, 281 | Request::AddIdConstrained(identity) => self.add_identity_constrained(identity).await?, 282 | Request::AddSmartcardKeyConstrained(key) => { 283 | self.add_smartcard_key_constrained(key).await? 284 | } 285 | Request::Extension(extension) => { 286 | return match self.extension(extension).await? { 287 | Some(response) => Ok(Response::ExtensionResponse(response)), 288 | None => Ok(Response::Success), 289 | } 290 | } 291 | } 292 | Ok(Response::Success) 293 | } 294 | } 295 | 296 | async fn handle_socket( 297 | mut session: impl Session, 298 | mut adapter: Framed>, 299 | ) -> Result<(), AgentError> 300 | where 301 | S: ListeningSocket + fmt::Debug + Send, 302 | { 303 | loop { 304 | if let Some(incoming_message) = adapter.try_next().await? { 305 | log::debug!("Request: {incoming_message:?}"); 306 | let response = match session.handle(incoming_message).await { 307 | Ok(message) => message, 308 | Err(AgentError::ExtensionFailure) => { 309 | log::error!("Extension failure handling message"); 310 | Response::ExtensionFailure 311 | } 312 | Err(e) => { 313 | log::error!("Error handling message: {:?}", e); 314 | Response::Failure 315 | } 316 | }; 317 | log::debug!("Response: {response:?}"); 318 | 319 | adapter.send(response).await?; 320 | } else { 321 | // Reached EOF of the stream (client disconnected), 322 | // we can close the socket and exit the handler. 323 | return Ok(()); 324 | } 325 | } 326 | } 327 | 328 | /// Factory of sessions for the given type of sockets. 329 | /// 330 | /// An agent implementation is automatically created for types which 331 | /// implement [`Session`] and [`Clone`]: new sessions are created by 332 | /// cloning the agent object. This is usually sufficient for the 333 | /// majority of use cases. In case the information about the 334 | /// underlying socket (connection source) is needed the [`Agent`] can 335 | /// be implemented manually. 336 | /// 337 | /// # Examples 338 | /// 339 | /// This example shows how to retrieve the connecting process ID on Unix: 340 | /// 341 | /// ``` 342 | /// use ssh_agent_lib::agent::{Agent, Session}; 343 | /// 344 | /// #[derive(Debug, Default)] 345 | /// struct AgentSocketInfo; 346 | /// 347 | /// #[cfg(unix)] 348 | /// impl Agent for AgentSocketInfo { 349 | /// fn new_session(&mut self, socket: &tokio::net::UnixStream) -> impl Session { 350 | /// let _socket_info = format!( 351 | /// "unix: addr: {:?} cred: {:?}", 352 | /// socket.peer_addr().unwrap(), 353 | /// socket.peer_cred().unwrap() 354 | /// ); 355 | /// Self 356 | /// } 357 | /// } 358 | /// # impl Session for AgentSocketInfo { } 359 | /// ``` 360 | pub trait Agent: 'static + Send + Sync 361 | where 362 | S: ListeningSocket + fmt::Debug + Send, 363 | { 364 | /// Create a [`Session`] object for a given `socket`. 365 | fn new_session(&mut self, socket: &S::Stream) -> impl Session; 366 | } 367 | 368 | /// Listen for connections on a given socket and use session factory 369 | /// to create new session for each accepted socket. 370 | /// 371 | /// # Examples 372 | /// 373 | /// The following example starts listening for connections and 374 | /// processes them with the `MyAgent` struct. 375 | /// 376 | /// ```no_run 377 | /// # async fn main_() -> testresult::TestResult { 378 | /// use ssh_agent_lib::agent::{listen, Session}; 379 | /// use tokio::net::TcpListener; 380 | /// 381 | /// #[derive(Default, Clone)] 382 | /// struct MyAgent; 383 | /// 384 | /// impl Session for MyAgent { 385 | /// // implement your agent logic here 386 | /// } 387 | /// 388 | /// listen( 389 | /// TcpListener::bind("127.0.0.1:8080").await?, 390 | /// MyAgent::default(), 391 | /// ) 392 | /// .await?; 393 | /// # Ok(()) } 394 | /// ``` 395 | pub async fn listen(mut socket: S, mut agent: impl Agent) -> Result<(), AgentError> 396 | where 397 | S: ListeningSocket + fmt::Debug + Send, 398 | { 399 | log::info!("Listening; socket = {:?}", socket); 400 | loop { 401 | match socket.accept().await { 402 | Ok(socket) => { 403 | let session = agent.new_session(&socket); 404 | tokio::spawn(async move { 405 | let adapter = Framed::new(socket, Codec::::default()); 406 | if let Err(e) = handle_socket::(session, adapter).await { 407 | log::error!("Agent protocol error: {:?}", e); 408 | } 409 | }); 410 | } 411 | Err(e) => { 412 | log::error!("Failed to accept socket: {:?}", e); 413 | return Err(AgentError::IO(e)); 414 | } 415 | } 416 | } 417 | } 418 | 419 | #[cfg(unix)] 420 | impl Agent for T 421 | where 422 | T: Clone + Send + Sync + Session, 423 | { 424 | fn new_session(&mut self, _socket: &tokio::net::UnixStream) -> impl Session { 425 | Self::clone(self) 426 | } 427 | } 428 | 429 | impl Agent for T 430 | where 431 | T: Clone + Send + Sync + Session, 432 | { 433 | fn new_session(&mut self, _socket: &tokio::net::TcpStream) -> impl Session { 434 | Self::clone(self) 435 | } 436 | } 437 | 438 | #[cfg(windows)] 439 | impl Agent for T 440 | where 441 | T: Clone + Send + Sync + Session, 442 | { 443 | fn new_session( 444 | &mut self, 445 | _socket: &tokio::net::windows::named_pipe::NamedPipeServer, 446 | ) -> impl Session { 447 | Self::clone(self) 448 | } 449 | } 450 | 451 | #[cfg(unix)] 452 | type PlatformSpecificListener = tokio::net::UnixListener; 453 | 454 | #[cfg(windows)] 455 | type PlatformSpecificListener = NamedPipeListener; 456 | 457 | /// Bind to a service binding listener. 458 | /// 459 | /// # Examples 460 | /// 461 | /// The following example uses `clap` to parse the host socket data 462 | /// thus allowing the user to choose at runtime whether they want to 463 | /// use TCP sockets, Unix domain sockets (including systemd socket 464 | /// activation) or Named Pipes (under Windows). 465 | /// 466 | /// ```no_run 467 | /// use clap::Parser; 468 | /// use service_binding::Binding; 469 | /// use ssh_agent_lib::agent::{bind, Session}; 470 | /// 471 | /// #[derive(Debug, Parser)] 472 | /// struct Args { 473 | /// #[clap(long, short = 'H', default_value = "unix:///tmp/ssh.sock")] 474 | /// host: Binding, 475 | /// } 476 | /// 477 | /// #[derive(Default, Clone)] 478 | /// struct MyAgent; 479 | /// 480 | /// impl Session for MyAgent {} 481 | /// 482 | /// #[tokio::main] 483 | /// async fn main() -> Result<(), Box> { 484 | /// let args = Args::parse(); 485 | /// 486 | /// bind(args.host.try_into()?, MyAgent::default()).await?; 487 | /// 488 | /// Ok(()) 489 | /// } 490 | /// ``` 491 | pub async fn bind(listener: service_binding::Listener, agent: A) -> Result<(), AgentError> 492 | where 493 | A: Agent + Agent, 494 | { 495 | match listener { 496 | #[cfg(unix)] 497 | service_binding::Listener::Unix(listener) => { 498 | listen(UnixListener::from_std(listener)?, agent).await 499 | } 500 | service_binding::Listener::Tcp(listener) => { 501 | listen(TcpListener::from_std(listener)?, agent).await 502 | } 503 | #[cfg(windows)] 504 | service_binding::Listener::NamedPipe(pipe) => { 505 | listen(NamedPipeListener::bind(pipe)?, agent).await 506 | } 507 | #[allow(unreachable_patterns)] 508 | _ => Err(AgentError::IO(std::io::Error::other( 509 | "Unsupported type of a listener.", 510 | ))), 511 | } 512 | } 513 | -------------------------------------------------------------------------------- /src/blocking.rs: -------------------------------------------------------------------------------- 1 | //! Blocking SSH agent client API. 2 | //! 3 | //! Blocking API is always enabled since it doesn't use additional 4 | //! dependencies over what is in the `proto` module and Rust standard 5 | //! library. 6 | //! 7 | //! # Examples 8 | //! 9 | //! ```no_run 10 | //! # #[cfg(unix)] 11 | //! # fn main() -> testresult::TestResult { 12 | //! use std::os::unix::net::UnixStream; 13 | //! 14 | //! use ssh_agent_lib::blocking::Client; 15 | //! 16 | //! let mut client = Client::new(UnixStream::connect(std::env::var("SSH_AUTH_SOCK")?)?); 17 | //! 18 | //! eprintln!( 19 | //! "Identities that this agent knows of: {:#?}", 20 | //! client.request_identities()? 21 | //! ); 22 | //! # Ok(()) } 23 | //! # #[cfg(windows)] fn main() { } 24 | //! ``` 25 | 26 | use std::io::{Read, Write}; 27 | 28 | use byteorder::{BigEndian, ByteOrder}; 29 | use ssh_encoding::{Decode, Encode}; 30 | use ssh_key::Signature; 31 | 32 | use crate::{ 33 | error::AgentError, 34 | proto::{ 35 | AddIdentity, AddIdentityConstrained, AddSmartcardKeyConstrained, Extension, Identity, 36 | ProtoError, RemoveIdentity, Request, Response, SignRequest, SmartcardKey, 37 | }, 38 | }; 39 | 40 | /// Blocking SSH agent client. 41 | #[derive(Debug)] 42 | pub struct Client { 43 | stream: S, 44 | } 45 | 46 | impl Client { 47 | /// Construct a new SSH agent client for the given transport stream. 48 | pub fn new(stream: S) -> Self { 49 | Self { stream } 50 | } 51 | 52 | /// Extracts inner stream by consuming this object. 53 | pub fn into_inner(self) -> S { 54 | self.stream 55 | } 56 | 57 | fn handle(&mut self, request: Request) -> Result { 58 | // send the request 59 | let mut bytes = Vec::new(); 60 | let len = request.encoded_len()? as u32; 61 | len.encode(&mut bytes)?; 62 | request.encode(&mut bytes)?; 63 | self.stream.write_all(&bytes)?; 64 | 65 | // read the response 66 | let mut len: [u8; 4] = [0; 4]; 67 | self.stream.read_exact(&mut len[..])?; 68 | let len = BigEndian::read_u32(&len) as usize; 69 | bytes.resize(len, 0); 70 | self.stream.read_exact(&mut bytes)?; 71 | 72 | Response::decode(&mut &bytes[..]) 73 | } 74 | 75 | /// Request a list of keys managed by this session. 76 | pub fn request_identities(&mut self) -> Result, AgentError> { 77 | if let Response::IdentitiesAnswer(identities) = self.handle(Request::RequestIdentities)? { 78 | Ok(identities) 79 | } else { 80 | Err(ProtoError::UnexpectedResponse.into()) 81 | } 82 | } 83 | 84 | /// Perform a private key signature operation. 85 | pub fn sign(&mut self, request: SignRequest) -> Result { 86 | if let Response::SignResponse(response) = self.handle(Request::SignRequest(request))? { 87 | Ok(response) 88 | } else { 89 | Err(ProtoError::UnexpectedResponse.into()) 90 | } 91 | } 92 | 93 | /// Add a private key to the agent. 94 | pub fn add_identity(&mut self, identity: AddIdentity) -> Result<(), AgentError> { 95 | if let Response::Success = self.handle(Request::AddIdentity(identity))? { 96 | Ok(()) 97 | } else { 98 | Err(ProtoError::UnexpectedResponse.into()) 99 | } 100 | } 101 | 102 | /// Add a private key to the agent with a set of constraints. 103 | pub fn add_identity_constrained( 104 | &mut self, 105 | identity: AddIdentityConstrained, 106 | ) -> Result<(), AgentError> { 107 | if let Response::Success = self.handle(Request::AddIdConstrained(identity))? { 108 | Ok(()) 109 | } else { 110 | Err(ProtoError::UnexpectedResponse.into()) 111 | } 112 | } 113 | 114 | /// Remove private key from an agent. 115 | pub fn remove_identity(&mut self, identity: RemoveIdentity) -> Result<(), AgentError> { 116 | if let Response::Success = self.handle(Request::RemoveIdentity(identity))? { 117 | Ok(()) 118 | } else { 119 | Err(ProtoError::UnexpectedResponse.into()) 120 | } 121 | } 122 | 123 | /// Remove all keys from an agent. 124 | pub fn remove_all_identities(&mut self) -> Result<(), AgentError> { 125 | if let Response::Success = self.handle(Request::RemoveAllIdentities)? { 126 | Ok(()) 127 | } else { 128 | Err(ProtoError::UnexpectedResponse.into()) 129 | } 130 | } 131 | 132 | /// Add a key stored on a smartcard. 133 | pub fn add_smartcard_key(&mut self, key: SmartcardKey) -> Result<(), AgentError> { 134 | if let Response::Success = self.handle(Request::AddSmartcardKey(key))? { 135 | Ok(()) 136 | } else { 137 | Err(ProtoError::UnexpectedResponse.into()) 138 | } 139 | } 140 | 141 | /// Add a key stored on a smartcard with a set of constraints. 142 | pub fn add_smartcard_key_constrained( 143 | &mut self, 144 | key: AddSmartcardKeyConstrained, 145 | ) -> Result<(), AgentError> { 146 | if let Response::Success = self.handle(Request::AddSmartcardKeyConstrained(key))? { 147 | Ok(()) 148 | } else { 149 | Err(ProtoError::UnexpectedResponse.into()) 150 | } 151 | } 152 | 153 | /// Remove a smartcard key from the agent. 154 | pub fn remove_smartcard_key(&mut self, key: SmartcardKey) -> Result<(), AgentError> { 155 | if let Response::Success = self.handle(Request::RemoveSmartcardKey(key))? { 156 | Ok(()) 157 | } else { 158 | Err(ProtoError::UnexpectedResponse.into()) 159 | } 160 | } 161 | 162 | /// Temporarily lock the agent with a password. 163 | pub fn lock(&mut self, key: String) -> Result<(), AgentError> { 164 | if let Response::Success = self.handle(Request::Lock(key))? { 165 | Ok(()) 166 | } else { 167 | Err(ProtoError::UnexpectedResponse.into()) 168 | } 169 | } 170 | 171 | /// Unlock the agent with a password. 172 | pub fn unlock(&mut self, key: String) -> Result<(), AgentError> { 173 | if let Response::Success = self.handle(Request::Unlock(key))? { 174 | Ok(()) 175 | } else { 176 | Err(ProtoError::UnexpectedResponse.into()) 177 | } 178 | } 179 | 180 | /// Invoke a custom, vendor-specific extension on the agent. 181 | pub fn extension(&mut self, extension: Extension) -> Result, AgentError> { 182 | match self.handle(Request::Extension(extension))? { 183 | Response::Success => Ok(None), 184 | Response::ExtensionResponse(response) => Ok(Some(response)), 185 | _ => Err(ProtoError::UnexpectedResponse.into()), 186 | } 187 | } 188 | } 189 | -------------------------------------------------------------------------------- /src/client.rs: -------------------------------------------------------------------------------- 1 | //! SSH agent client support. 2 | 3 | use std::fmt; 4 | 5 | use futures::{SinkExt, TryStreamExt}; 6 | use ssh_key::Signature; 7 | use tokio::io::{AsyncRead, AsyncWrite}; 8 | use tokio_util::codec::Framed; 9 | 10 | use crate::{ 11 | codec::Codec, 12 | error::AgentError, 13 | proto::{ 14 | AddIdentity, AddIdentityConstrained, AddSmartcardKeyConstrained, Extension, Identity, 15 | ProtoError, RemoveIdentity, Request, Response, SignRequest, SmartcardKey, 16 | }, 17 | }; 18 | 19 | /// SSH agent client 20 | #[derive(Debug)] 21 | pub struct Client 22 | where 23 | Stream: fmt::Debug + AsyncRead + AsyncWrite + Send + Unpin + 'static, 24 | { 25 | adapter: Framed>, 26 | } 27 | 28 | impl Client 29 | where 30 | Stream: fmt::Debug + AsyncRead + AsyncWrite + Send + Unpin + 'static, 31 | { 32 | /// Create a new SSH agent client wrapping a given socket. 33 | pub fn new(socket: Stream) -> Self { 34 | let adapter = Framed::new(socket, Codec::default()); 35 | Self { adapter } 36 | } 37 | } 38 | 39 | /// Wrap a stream into an SSH agent client. 40 | pub fn connect( 41 | stream: service_binding::Stream, 42 | ) -> Result, Box> { 43 | match stream { 44 | #[cfg(unix)] 45 | service_binding::Stream::Unix(stream) => { 46 | let stream = tokio::net::UnixStream::from_std(stream)?; 47 | Ok(Box::new(Client::new(stream))) 48 | } 49 | service_binding::Stream::Tcp(stream) => { 50 | let stream = tokio::net::TcpStream::from_std(stream)?; 51 | Ok(Box::new(Client::new(stream))) 52 | } 53 | #[cfg(windows)] 54 | service_binding::Stream::NamedPipe(pipe) => { 55 | use tokio::net::windows::named_pipe::ClientOptions; 56 | let stream = loop { 57 | // https://docs.rs/windows-sys/latest/windows_sys/Win32/Foundation/constant.ERROR_PIPE_BUSY.html 58 | const ERROR_PIPE_BUSY: u32 = 231u32; 59 | 60 | // correct way to do it taken from 61 | // https://docs.rs/tokio/latest/tokio/net/windows/named_pipe/struct.NamedPipeClient.html 62 | match ClientOptions::new().open(&pipe) { 63 | Ok(client) => break client, 64 | Err(e) if e.raw_os_error() == Some(ERROR_PIPE_BUSY as i32) => (), 65 | Err(e) => Err(e)?, 66 | } 67 | 68 | std::thread::sleep(std::time::Duration::from_millis(50)); 69 | }; 70 | Ok(Box::new(Client::new(stream))) 71 | } 72 | #[cfg(not(windows))] 73 | service_binding::Stream::NamedPipe(_) => Err(ProtoError::IO(std::io::Error::other( 74 | "Named pipes supported on Windows only", 75 | )) 76 | .into()), 77 | } 78 | } 79 | 80 | #[async_trait::async_trait] 81 | impl crate::agent::Session for Client 82 | where 83 | Stream: fmt::Debug + AsyncRead + AsyncWrite + Send + Sync + Unpin + 'static, 84 | { 85 | async fn request_identities(&mut self) -> Result, AgentError> { 86 | if let Response::IdentitiesAnswer(identities) = 87 | self.handle(Request::RequestIdentities).await? 88 | { 89 | Ok(identities) 90 | } else { 91 | Err(ProtoError::UnexpectedResponse.into()) 92 | } 93 | } 94 | 95 | async fn sign(&mut self, request: SignRequest) -> Result { 96 | if let Response::SignResponse(response) = self.handle(Request::SignRequest(request)).await? 97 | { 98 | Ok(response) 99 | } else { 100 | Err(ProtoError::UnexpectedResponse.into()) 101 | } 102 | } 103 | 104 | async fn add_identity(&mut self, identity: AddIdentity) -> Result<(), AgentError> { 105 | if let Response::Success = self.handle(Request::AddIdentity(identity)).await? { 106 | Ok(()) 107 | } else { 108 | Err(ProtoError::UnexpectedResponse.into()) 109 | } 110 | } 111 | 112 | async fn add_identity_constrained( 113 | &mut self, 114 | identity: AddIdentityConstrained, 115 | ) -> Result<(), AgentError> { 116 | if let Response::Success = self.handle(Request::AddIdConstrained(identity)).await? { 117 | Ok(()) 118 | } else { 119 | Err(ProtoError::UnexpectedResponse.into()) 120 | } 121 | } 122 | 123 | async fn remove_identity(&mut self, identity: RemoveIdentity) -> Result<(), AgentError> { 124 | if let Response::Success = self.handle(Request::RemoveIdentity(identity)).await? { 125 | Ok(()) 126 | } else { 127 | Err(ProtoError::UnexpectedResponse.into()) 128 | } 129 | } 130 | 131 | async fn remove_all_identities(&mut self) -> Result<(), AgentError> { 132 | if let Response::Success = self.handle(Request::RemoveAllIdentities).await? { 133 | Ok(()) 134 | } else { 135 | Err(ProtoError::UnexpectedResponse.into()) 136 | } 137 | } 138 | 139 | async fn add_smartcard_key(&mut self, key: SmartcardKey) -> Result<(), AgentError> { 140 | if let Response::Success = self.handle(Request::AddSmartcardKey(key)).await? { 141 | Ok(()) 142 | } else { 143 | Err(ProtoError::UnexpectedResponse.into()) 144 | } 145 | } 146 | 147 | async fn add_smartcard_key_constrained( 148 | &mut self, 149 | key: AddSmartcardKeyConstrained, 150 | ) -> Result<(), AgentError> { 151 | if let Response::Success = self 152 | .handle(Request::AddSmartcardKeyConstrained(key)) 153 | .await? 154 | { 155 | Ok(()) 156 | } else { 157 | Err(ProtoError::UnexpectedResponse.into()) 158 | } 159 | } 160 | 161 | async fn remove_smartcard_key(&mut self, key: SmartcardKey) -> Result<(), AgentError> { 162 | if let Response::Success = self.handle(Request::RemoveSmartcardKey(key)).await? { 163 | Ok(()) 164 | } else { 165 | Err(ProtoError::UnexpectedResponse.into()) 166 | } 167 | } 168 | 169 | async fn lock(&mut self, key: String) -> Result<(), AgentError> { 170 | if let Response::Success = self.handle(Request::Lock(key)).await? { 171 | Ok(()) 172 | } else { 173 | Err(ProtoError::UnexpectedResponse.into()) 174 | } 175 | } 176 | 177 | async fn unlock(&mut self, key: String) -> Result<(), AgentError> { 178 | if let Response::Success = self.handle(Request::Unlock(key)).await? { 179 | Ok(()) 180 | } else { 181 | Err(ProtoError::UnexpectedResponse.into()) 182 | } 183 | } 184 | 185 | async fn extension(&mut self, extension: Extension) -> Result, AgentError> { 186 | match self.handle(Request::Extension(extension)).await? { 187 | Response::Success => Ok(None), 188 | Response::ExtensionResponse(response) => Ok(Some(response)), 189 | _ => Err(ProtoError::UnexpectedResponse.into()), 190 | } 191 | } 192 | 193 | async fn handle(&mut self, message: Request) -> Result { 194 | self.adapter.send(message).await?; 195 | if let Some(response) = self.adapter.try_next().await? { 196 | Ok(response) 197 | } else { 198 | Err(ProtoError::IO(std::io::Error::other("server disconnected")).into()) 199 | } 200 | } 201 | } 202 | -------------------------------------------------------------------------------- /src/codec.rs: -------------------------------------------------------------------------------- 1 | //! SSH agent protocol framing codec. 2 | 3 | use std::marker::PhantomData; 4 | use std::mem::size_of; 5 | 6 | use byteorder::{BigEndian, ReadBytesExt}; 7 | use ssh_encoding::{Decode, Encode}; 8 | use tokio_util::bytes::{Buf, BufMut, BytesMut}; 9 | use tokio_util::codec::{Decoder, Encoder}; 10 | 11 | use super::error::AgentError; 12 | use super::proto::ProtoError; 13 | 14 | /// SSH framing codec. 15 | /// 16 | /// This codec first reads an `u32` which indicates the length of the incoming 17 | /// message. Then decodes the message using specified `Input` type. 18 | /// 19 | /// The reverse transformation which appends the length of the encoded data 20 | /// is also implemented for the given `Output` type. 21 | #[derive(Debug)] 22 | pub struct Codec(PhantomData, PhantomData) 23 | where 24 | Input: Decode, 25 | Output: Encode, 26 | AgentError: From; 27 | 28 | impl Default for Codec 29 | where 30 | Input: Decode, 31 | Output: Encode, 32 | AgentError: From, 33 | { 34 | fn default() -> Self { 35 | Self(PhantomData, PhantomData) 36 | } 37 | } 38 | 39 | impl Decoder for Codec 40 | where 41 | Input: Decode, 42 | Output: Encode, 43 | AgentError: From, 44 | { 45 | type Item = Input; 46 | type Error = AgentError; 47 | 48 | fn decode(&mut self, src: &mut BytesMut) -> Result, Self::Error> { 49 | let mut bytes = &src[..]; 50 | 51 | if bytes.len() < size_of::() { 52 | return Ok(None); 53 | } 54 | 55 | let length = bytes.read_u32::()? as usize; 56 | 57 | if bytes.len() < length { 58 | return Ok(None); 59 | } 60 | 61 | let message = Self::Item::decode(&mut bytes)?; 62 | src.advance(size_of::() + length); 63 | Ok(Some(message)) 64 | } 65 | } 66 | 67 | impl Encoder for Codec 68 | where 69 | Input: Decode, 70 | Output: Encode, 71 | AgentError: From, 72 | { 73 | type Error = AgentError; 74 | 75 | fn encode(&mut self, item: Output, dst: &mut BytesMut) -> Result<(), Self::Error> { 76 | let mut bytes = Vec::new(); 77 | 78 | let len = item.encoded_len().map_err(ProtoError::SshEncoding)? as u32; 79 | len.encode(&mut bytes).map_err(ProtoError::SshEncoding)?; 80 | 81 | item.encode(&mut bytes).map_err(ProtoError::SshEncoding)?; 82 | dst.put(&*bytes); 83 | 84 | Ok(()) 85 | } 86 | } 87 | -------------------------------------------------------------------------------- /src/error.rs: -------------------------------------------------------------------------------- 1 | //! SSH agent errors. 2 | 3 | use std::io; 4 | 5 | use thiserror::Error; 6 | 7 | use crate::proto::ProtoError; 8 | 9 | /// SSH agent error. 10 | #[derive(Debug, Error)] 11 | pub enum AgentError { 12 | /// Protocol error. 13 | #[error("Agent: Protocol error: {0}")] 14 | Proto(#[from] ProtoError), 15 | 16 | /// Input/output error. 17 | #[error("Agent: I/O error: {0}")] 18 | IO(#[from] io::Error), 19 | 20 | /// Other unspecified error. 21 | #[error("Other error: {0:#}")] 22 | Other(#[from] Box), 23 | 24 | /// Generic agent extension failure 25 | #[error("Generic agent extension failure")] 26 | ExtensionFailure, 27 | 28 | /// Generic agent failure 29 | #[error("Generic agent failure")] 30 | Failure, 31 | } 32 | 33 | impl AgentError { 34 | /// Construct an `AgentError` from other error type. 35 | pub fn other(error: impl std::error::Error + Send + Sync + 'static) -> Self { 36 | Self::Other(Box::new(error)) 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /src/lib.rs: -------------------------------------------------------------------------------- 1 | #![doc = include_str!("../README.md")] 2 | #![deny(missing_debug_implementations)] 3 | #![deny(unsafe_code)] 4 | #![deny(missing_docs)] 5 | #![deny(clippy::unwrap_used)] 6 | 7 | pub mod proto; 8 | 9 | #[cfg(feature = "agent")] 10 | pub mod agent; 11 | pub mod blocking; 12 | #[cfg(feature = "agent")] 13 | pub mod client; 14 | #[cfg(feature = "codec")] 15 | pub mod codec; 16 | pub mod error; 17 | 18 | #[cfg(feature = "agent")] 19 | pub use async_trait::async_trait; 20 | // 21 | // re-export dependencies that are used in the public API of our crate 22 | pub use secrecy; 23 | pub use ssh_encoding; 24 | pub use ssh_key; 25 | -------------------------------------------------------------------------------- /src/proto.rs: -------------------------------------------------------------------------------- 1 | //! SSH agent protocol structures. 2 | 3 | pub mod error; 4 | pub mod extension; 5 | pub mod message; 6 | pub mod privatekey; 7 | pub mod signature; 8 | 9 | pub use self::error::{ProtoError as Error, ProtoResult as Result, *}; 10 | pub use self::message::*; 11 | pub use self::privatekey::*; 12 | pub use self::signature::*; 13 | -------------------------------------------------------------------------------- /src/proto/error.rs: -------------------------------------------------------------------------------- 1 | //! Agent protocol errors. 2 | 3 | use std::{io, string}; 4 | 5 | use thiserror::Error; 6 | 7 | /// SSH protocol error. 8 | #[derive(Debug, Error)] 9 | pub enum ProtoError { 10 | /// Received string was not UTF-8 encoded. 11 | #[error("String encoding failed: {0}")] 12 | StringEncoding(#[from] string::FromUtf8Error), 13 | 14 | /// Input/output error. 15 | #[error("I/O Error: {0}")] 16 | IO(#[from] io::Error), 17 | 18 | /// Error decoding SSH structures. 19 | #[error("SSH encoding error: {0}")] 20 | SshEncoding(#[from] ssh_encoding::Error), 21 | 22 | /// SSH key format error. 23 | #[error("SSH key error: {0}")] 24 | SshKey(#[from] ssh_key::Error), 25 | 26 | /// SSH signature error. 27 | #[error("SSH signature error: {0}")] 28 | SshSignature(#[from] signature::Error), 29 | 30 | /// Received command was not supported. 31 | #[error("Command not supported ({command})")] 32 | UnsupportedCommand { 33 | /// Command code that was unsupported. 34 | command: u8, 35 | }, 36 | 37 | /// The client expected a different response. 38 | #[error("Unexpected response received")] 39 | UnexpectedResponse, 40 | } 41 | 42 | /// Protocol result. 43 | pub type ProtoResult = Result; 44 | -------------------------------------------------------------------------------- /src/proto/extension.rs: -------------------------------------------------------------------------------- 1 | //! SSH agent extension structures (messages & key constraints) 2 | 3 | pub mod constraint; 4 | pub mod message; 5 | 6 | pub use self::constraint::*; 7 | pub use self::message::*; 8 | 9 | /// SSH agent protocol message extension 10 | /// 11 | /// Described in [draft-miller-ssh-agent-14 § 3.8](https://www.ietf.org/archive/id/draft-miller-ssh-agent-14.html#section-3.8) 12 | pub trait MessageExtension: 'static { 13 | /// Extension name, indicating the type of the message (as a UTF-8 string). 14 | /// 15 | /// Extension names should be suffixed by the implementation domain 16 | /// as per [RFC4251 § 4.2](https://www.rfc-editor.org/rfc/rfc4251.html#section-4.2), 17 | const NAME: &'static str; 18 | } 19 | 20 | /// SSH agent protocol key constraint extension 21 | /// 22 | /// Described in [draft-miller-ssh-agent-14 § 3.2.7.3](https://www.ietf.org/archive/id/draft-miller-ssh-agent-14.html#section-3.2.7.3) 23 | pub trait KeyConstraintExtension: 'static { 24 | /// Extension name, indicating the type of the key constraint (as a UTF-8 string). 25 | /// 26 | /// Extension names should be suffixed by the implementation domain 27 | /// as per [RFC4251 § 4.2](https://www.rfc-editor.org/rfc/rfc4251.html#section-4.2), 28 | const NAME: &'static str; 29 | } 30 | -------------------------------------------------------------------------------- /src/proto/extension/constraint.rs: -------------------------------------------------------------------------------- 1 | //! SSH agent protocol key constraint messages 2 | //! 3 | //! Includes extension message definitions from: 4 | //! - [OpenSSH `PROTOCOL.agent`](https://github.com/openssh/openssh-portable/blob/cbbdf868bce431a59e2fa36ca244d5739429408d/PROTOCOL.agent) 5 | 6 | use ssh_encoding::{CheckedSum, Decode, Encode, Error as EncodingError, Reader, Writer}; 7 | use ssh_key::public::KeyData; 8 | 9 | use super::KeyConstraintExtension; 10 | 11 | // Reserved fields are marked with an empty string 12 | const RESERVED_FIELD: &str = ""; 13 | 14 | /// `restrict-destination-v00@openssh.com` key constraint extension. 15 | /// 16 | /// The key constraint extension supports destination- and forwarding path- 17 | /// restricted keys. It may be attached as a constraint when keys or 18 | /// smartcard keys are added to an agent. 19 | /// 20 | /// *Note*: This is an OpenSSH-specific extension to the agent protocol. 21 | /// 22 | /// Described in [OpenSSH PROTOCOL.agent § 2](https://github.com/openssh/openssh-portable/blob/cbbdf868bce431a59e2fa36ca244d5739429408d/PROTOCOL.agent#L38) 23 | #[derive(Debug, Clone, PartialEq)] 24 | pub struct RestrictDestination { 25 | /// Set of constraints for the destination. 26 | pub constraints: Vec, 27 | } 28 | 29 | impl Decode for RestrictDestination { 30 | type Error = crate::proto::error::ProtoError; 31 | 32 | fn decode(reader: &mut impl Reader) -> Result { 33 | let mut constraints = Vec::new(); 34 | while !reader.is_finished() { 35 | constraints.push(reader.read_prefixed(DestinationConstraint::decode)?); 36 | } 37 | Ok(Self { constraints }) 38 | } 39 | } 40 | 41 | impl Encode for RestrictDestination { 42 | fn encoded_len(&self) -> ssh_encoding::Result { 43 | self.constraints.iter().try_fold(0, |acc, e| { 44 | let constraint_len = e.encoded_len_prefixed()?; 45 | usize::checked_add(acc, constraint_len).ok_or(EncodingError::Length) 46 | }) 47 | } 48 | 49 | fn encode(&self, writer: &mut impl Writer) -> ssh_encoding::Result<()> { 50 | for constraint in &self.constraints { 51 | constraint.encode_prefixed(writer)?; 52 | } 53 | Ok(()) 54 | } 55 | } 56 | 57 | impl KeyConstraintExtension for RestrictDestination { 58 | const NAME: &'static str = "restrict-destination-v00@openssh.com"; 59 | } 60 | 61 | /// Tuple containing username and hostname with keys. 62 | /// 63 | /// *Note*: This is an OpenSSH-specific extension to the agent protocol. 64 | /// 65 | /// Described in [OpenSSH PROTOCOL.agent § 2](https://github.com/openssh/openssh-portable/blob/cbbdf868bce431a59e2fa36ca244d5739429408d/PROTOCOL.agent#L38) 66 | #[derive(Debug, Clone, PartialEq)] 67 | pub struct HostTuple { 68 | /// Username part of the tuple. 69 | pub username: String, 70 | 71 | /// Hostname part of the tuple. 72 | pub hostname: String, 73 | 74 | /// Set of keys for the tuple. 75 | pub keys: Vec, 76 | } 77 | 78 | impl Decode for HostTuple { 79 | type Error = crate::proto::error::ProtoError; 80 | 81 | fn decode(reader: &mut impl Reader) -> Result { 82 | let username = String::decode(reader)?; 83 | let hostname = String::decode(reader)?; 84 | let _reserved = String::decode(reader)?; 85 | 86 | let mut keys = Vec::new(); 87 | while !reader.is_finished() { 88 | keys.push(KeySpec::decode(reader)?); 89 | } 90 | 91 | Ok(Self { 92 | username, 93 | hostname, 94 | keys, 95 | }) 96 | } 97 | } 98 | 99 | impl Encode for HostTuple { 100 | fn encoded_len(&self) -> ssh_encoding::Result { 101 | let prefix = [ 102 | self.username.encoded_len()?, 103 | self.hostname.encoded_len()?, 104 | RESERVED_FIELD.encoded_len()?, 105 | ] 106 | .checked_sum()?; 107 | self.keys.iter().try_fold(prefix, |acc, e| { 108 | let key_len = e.encoded_len()?; 109 | usize::checked_add(acc, key_len).ok_or(EncodingError::Length) 110 | }) 111 | } 112 | 113 | fn encode(&self, writer: &mut impl Writer) -> ssh_encoding::Result<()> { 114 | self.username.encode(writer)?; 115 | self.hostname.encode(writer)?; 116 | RESERVED_FIELD.encode(writer)?; 117 | for key in &self.keys { 118 | key.encode(writer)?; 119 | } 120 | Ok(()) 121 | } 122 | } 123 | 124 | /// Key destination constraint. 125 | /// 126 | /// One or more [`DestinationConstraint`]s are included in 127 | /// the [`RestrictDestination`] key constraint extension. 128 | /// 129 | /// *Note*: This is an OpenSSH-specific extension to the agent protocol. 130 | /// 131 | /// Described in [OpenSSH PROTOCOL.agent § 2](https://github.com/openssh/openssh-portable/blob/cbbdf868bce431a59e2fa36ca244d5739429408d/PROTOCOL.agent#L38) 132 | #[derive(Debug, Clone, PartialEq)] 133 | pub struct DestinationConstraint { 134 | /// Constraint's `from` endpoint. 135 | pub from: HostTuple, 136 | 137 | /// Constraint's `to` endpoint. 138 | pub to: HostTuple, 139 | } 140 | 141 | impl Decode for DestinationConstraint { 142 | type Error = crate::proto::error::ProtoError; 143 | 144 | fn decode(reader: &mut impl Reader) -> Result { 145 | let from = reader.read_prefixed(HostTuple::decode)?; 146 | let to = reader.read_prefixed(HostTuple::decode)?; 147 | let _reserved = String::decode(reader)?; 148 | 149 | Ok(Self { from, to }) 150 | } 151 | } 152 | 153 | impl Encode for DestinationConstraint { 154 | fn encoded_len(&self) -> ssh_encoding::Result { 155 | [ 156 | self.from.encoded_len_prefixed()?, 157 | self.to.encoded_len_prefixed()?, 158 | RESERVED_FIELD.encoded_len()?, 159 | ] 160 | .checked_sum() 161 | } 162 | 163 | fn encode(&self, writer: &mut impl Writer) -> ssh_encoding::Result<()> { 164 | self.from.encode_prefixed(writer)?; 165 | self.to.encode_prefixed(writer)?; 166 | RESERVED_FIELD.encode(writer)?; 167 | Ok(()) 168 | } 169 | } 170 | 171 | /// Public key specification. 172 | /// 173 | /// This structure is included in [`DestinationConstraint`], 174 | /// which in turn is used in the [`RestrictDestination`] key 175 | /// constraint extension. 176 | /// 177 | /// *Note*: This is an OpenSSH-specific extension to the agent protocol. 178 | /// 179 | /// Described in [OpenSSH PROTOCOL.agent § 2](https://github.com/openssh/openssh-portable/blob/cbbdf868bce431a59e2fa36ca244d5739429408d/PROTOCOL.agent#L38) 180 | #[derive(Debug, Clone, PartialEq)] 181 | pub struct KeySpec { 182 | /// The public parts of the key. 183 | pub keyblob: KeyData, 184 | 185 | /// Flag indicating if this key is for a CA. 186 | pub is_ca: bool, 187 | } 188 | 189 | impl Decode for KeySpec { 190 | type Error = crate::proto::error::ProtoError; 191 | 192 | fn decode(reader: &mut impl Reader) -> Result { 193 | let keyblob = reader.read_prefixed(KeyData::decode)?; 194 | Ok(Self { 195 | keyblob, 196 | is_ca: u8::decode(reader)? != 0, 197 | }) 198 | } 199 | } 200 | 201 | impl Encode for KeySpec { 202 | fn encoded_len(&self) -> ssh_encoding::Result { 203 | [self.keyblob.encoded_len_prefixed()?, 1u8.encoded_len()?].checked_sum() 204 | } 205 | 206 | fn encode(&self, writer: &mut impl Writer) -> ssh_encoding::Result<()> { 207 | self.keyblob.encode_prefixed(writer)?; 208 | // TODO: contribute `impl Encode for bool` in ssh-encoding 209 | // 210 | if self.is_ca { 211 | 1u8.encode(writer) 212 | } else { 213 | 0u8.encode(writer) 214 | } 215 | } 216 | } 217 | 218 | #[cfg(test)] 219 | mod tests { 220 | use hex_literal::hex; 221 | use testresult::TestResult; 222 | 223 | use super::*; 224 | use crate::proto::ProtoError; 225 | 226 | fn round_trip(msg: T) -> TestResult 227 | where 228 | T: Encode + Decode + std::fmt::Debug + std::cmp::PartialEq, 229 | { 230 | let mut buf: Vec = vec![]; 231 | msg.encode(&mut buf)?; 232 | let mut re_encoded = &buf[..]; 233 | 234 | let msg2 = T::decode(&mut re_encoded)?; 235 | assert_eq!(msg, msg2); 236 | 237 | Ok(()) 238 | } 239 | 240 | #[test] 241 | fn parse_destination_constraint() -> TestResult { 242 | let mut msg = &hex!( 243 | " 00 244 | 0002 6f00 0000 0c00 0000 0000 0000 0000 245 | 0000 0000 0002 5700 0000 0000 0000 0a67 246 | 6974 6875 622e 636f 6d00 0000 0000 0000 247 | 3300 0000 0b73 7368 2d65 6432 3535 3139 248 | 0000 0020 e32a aa79 15ce b9b4 49d1 ba50 249 | ea2a 28bb 1a6e 01f9 0bda 245a 2d1d 8769 250 | 7d18 a265 0000 0001 9700 0000 0773 7368 251 | 2d72 7361 0000 0003 0100 0100 0001 8100 252 | a3ee 774d c50a 3081 c427 8ec8 5c2e ba8f 253 | 1228 a986 7b7e 5534 ef0c fea6 1c12 fd8f 254 | 568d 5246 3851 ed60 bf09 c62d 594e 8467 255 | 98ae 765a 3204 4aeb e3ca 0945 da0d b0bb 256 | aad6 d6f2 0224 84be da18 2b0e aff0 b9e9 257 | 224c cbf0 4265 fc5d d675 b300 ec52 0cf8 258 | 15b2 67ab 3816 1f36 a96d 57df e158 2a81 259 | cb02 0d21 1fb9 7488 3a25 327b da97 04a4 260 | 48dc 6205 e413 6604 1575 7524 79ec 2a06 261 | cb58 d961 49ca 9bd9 49b2 4644 32ca d44b 262 | b4bf b7f1 31b1 9310 9f96 63be e59f 0249 263 | 2358 ec68 9d8c c219 ed0e 3332 3036 9f59 264 | c6ae 54c3 933c 030a cc3e c2a1 4f19 0035 265 | efd7 277c 658e 5915 6bba 3d7a cfa5 f2bf 266 | 1be3 2706 f3d3 0419 ef95 cae6 d292 6fb1 267 | 4dc9 e204 b384 d3e2 393e 4b87 613d e014 268 | 0b9c be6c 3622 ad88 0ce0 60bb b849 f3b6 269 | 7672 6955 90ec 1dfc d402 b841 daf0 b79d 270 | 59a8 4c4a 6d0a 5350 d9fe 123a a84f 0bea 271 | 363e 24ab 1e50 5022 344e 14bf 6243 b124 272 | 25e6 3d45 996e 18e9 0a0e 7a8b ed9a 07a0 273 | a62b 6246 867e 7b2b 99a3 d0c3 5d05 7038 274 | fd69 f01f a5e8 3d62 732b 9372 bb6c c1de 275 | 7019 a7e4 b986 942c fa9d 6f37 5ff0 b239 276 | 0000 0000 6800 0000 1365 6364 7361 2d73 277 | 6861 322d 6e69 7374 7032 3536 0000 0008 278 | 6e69 7374 7032 3536 0000 0041 0449 8a48 279 | 4363 4047 b33a 6c64 64cc bba2 92a0 c050 280 | 7d9e 4b79 611a d832 336e 1b93 7cee e460 281 | 83a0 8bad ba39 c007 53ff 2eaf d262 95d1 282 | 4db0 d166 7660 1ffe f93a 6872 4800 0000 283 | 0000" 284 | )[..]; 285 | 286 | let destination_constraint = RestrictDestination::decode(&mut msg)?; 287 | eprintln!("Destination constraint: {destination_constraint:?}"); 288 | 289 | round_trip(destination_constraint)?; 290 | 291 | #[rustfmt::skip] 292 | let mut buffer: &[u8] = const_str::concat_bytes!( 293 | [0, 0, 0, 110], // 294 | [0, 0, 0, 12], //from: 295 | [0, 0, 0, 0], //username 296 | [0, 0, 0, 0], //hostname 297 | [0, 0, 0, 0], //reserved 298 | // no host keys here 299 | [0, 0, 0, 86], //to: 300 | [0, 0, 0, 6], b"wiktor", 301 | [0, 0, 0, 12], b"metacode.biz", 302 | [0, 0, 0, 0], // reserved, not in the spec authfd.c:469 303 | [0, 0, 0, 51], // 304 | [0, 0, 0, 11], // 305 | b"ssh-ed25519", 306 | [0, 0, 0, 32], // raw key 307 | [177, 185, 198, 92, 165, 45, 127, 95, 202, 195, 226, 63, 6, 115, 10, 104, 18, 137, 172, 308 | 240, 153, 154, 174, 74, 83, 7, 1, 204, 14, 177, 153, 40], // 309 | [0], // is_ca 310 | [0, 0, 0, 0], // reserved, not in the spec, authfd.c:495 311 | ); 312 | 313 | let destination_constraint = RestrictDestination::decode(&mut buffer)?; 314 | eprintln!("Destination constraint: {destination_constraint:?}"); 315 | 316 | round_trip(destination_constraint)?; 317 | 318 | let mut buffer: &[u8] = &[ 319 | 0, 0, 0, 102, 0, 0, 0, 12, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 78, 0, 0, 0, 0, 320 | 0, 0, 0, 10, 103, 105, 116, 104, 117, 98, 46, 99, 111, 109, 0, 0, 0, 0, 0, 0, 0, 51, 0, 321 | 0, 0, 11, 115, 115, 104, 45, 101, 100, 50, 53, 53, 49, 57, 0, 0, 0, 32, 227, 42, 170, 322 | 121, 21, 206, 185, 180, 73, 209, 186, 80, 234, 42, 40, 187, 26, 110, 1, 249, 11, 218, 323 | 36, 90, 45, 29, 135, 105, 125, 24, 162, 101, 0, 0, 0, 0, 0, 324 | ]; 325 | let destination_constraint = RestrictDestination::decode(&mut buffer)?; 326 | eprintln!("Destination constraint: {destination_constraint:?}"); 327 | 328 | round_trip(destination_constraint)?; 329 | 330 | Ok(()) 331 | } 332 | } 333 | -------------------------------------------------------------------------------- /src/proto/extension/message.rs: -------------------------------------------------------------------------------- 1 | //! SSH agent protocol extension messages 2 | //! 3 | //! Includes extension message definitions from both: 4 | //! - [draft-miller-ssh-agent-14](https://www.ietf.org/archive/id/draft-miller-ssh-agent-14.html) 5 | //! - [OpenSSH `PROTOCOL.agent`](https://github.com/openssh/openssh-portable/blob/cbbdf868bce431a59e2fa36ca244d5739429408d/PROTOCOL.agent) 6 | 7 | use signature::Verifier; 8 | use ssh_encoding::{CheckedSum, Decode, Encode, Error as EncodingError, Reader, Writer}; 9 | use ssh_key::{public::KeyData, Signature}; 10 | 11 | use super::MessageExtension; 12 | use crate::proto::ProtoError; 13 | 14 | /// `query` message extension. 15 | /// 16 | /// An optional extension request "query" is defined to allow a 17 | /// client to query which, if any, extensions are supported by an agent. 18 | /// 19 | /// Described in [draft-miller-ssh-agent-14 § 3.8.1](https://www.ietf.org/archive/id/draft-miller-ssh-agent-14.html#section-3.8.1) 20 | #[derive(Debug, Clone, PartialEq)] 21 | pub struct QueryResponse { 22 | /// List of supported message extension names 23 | pub extensions: Vec, 24 | } 25 | 26 | impl Encode for QueryResponse { 27 | fn encoded_len(&self) -> Result { 28 | self.extensions.encoded_len() 29 | } 30 | 31 | fn encode(&self, writer: &mut impl Writer) -> Result<(), EncodingError> { 32 | self.extensions.encode(writer) 33 | } 34 | } 35 | 36 | impl Decode for QueryResponse { 37 | type Error = ProtoError; 38 | 39 | fn decode(reader: &mut impl Reader) -> Result { 40 | let extensions = Vec::::decode(reader)?; 41 | 42 | Ok(Self { extensions }) 43 | } 44 | } 45 | 46 | impl MessageExtension for QueryResponse { 47 | const NAME: &'static str = "query"; 48 | } 49 | 50 | /// `session-bind@openssh.com` message extension. 51 | /// 52 | /// This message extension allows an SSH client to bind an 53 | /// agent connection to a particular SSH session. 54 | /// 55 | /// *Note*: This is an OpenSSH-specific extension to the agent protocol. 56 | /// 57 | /// Described in [OpenSSH PROTOCOL.agent § 1](https://github.com/openssh/openssh-portable/blob/cbbdf868bce431a59e2fa36ca244d5739429408d/PROTOCOL.agent#L6) 58 | #[derive(Debug, Clone, PartialEq)] 59 | pub struct SessionBind { 60 | /// Server host public key. 61 | pub host_key: KeyData, 62 | 63 | /// Hash derived from the initial key exchange. 64 | pub session_id: Vec, 65 | 66 | /// Server's signature of the session identifier using the private hostkey. 67 | pub signature: Signature, 68 | 69 | /// Flag indicating whether this connection should be bound for user authentication or forwarding. 70 | pub is_forwarding: bool, 71 | } 72 | 73 | impl Decode for SessionBind { 74 | type Error = crate::proto::error::ProtoError; 75 | 76 | fn decode(reader: &mut impl Reader) -> Result { 77 | let host_key = reader.read_prefixed(KeyData::decode)?; 78 | let session_id = Vec::decode(reader)?; 79 | let signature = reader.read_prefixed(Signature::decode)?; 80 | Ok(Self { 81 | host_key, 82 | session_id, 83 | signature, 84 | is_forwarding: u8::decode(reader)? != 0, 85 | }) 86 | } 87 | } 88 | 89 | impl Encode for SessionBind { 90 | fn encoded_len(&self) -> ssh_encoding::Result { 91 | [ 92 | self.host_key.encoded_len_prefixed()?, 93 | self.session_id.encoded_len()?, 94 | self.signature.encoded_len_prefixed()?, 95 | 1u8.encoded_len()?, 96 | ] 97 | .checked_sum() 98 | } 99 | 100 | fn encode(&self, writer: &mut impl Writer) -> ssh_encoding::Result<()> { 101 | self.host_key.encode_prefixed(writer)?; 102 | self.session_id.encode(writer)?; 103 | self.signature.encode_prefixed(writer)?; 104 | 105 | if self.is_forwarding { 106 | 1u8.encode(writer) 107 | } else { 108 | 0u8.encode(writer) 109 | } 110 | } 111 | } 112 | 113 | impl SessionBind { 114 | /// Verify the server's signature of the session identifier 115 | /// using the public `host_key`. 116 | /// 117 | /// > When an agent receives \[a `session-bind@openssh.com` message\], 118 | /// > it will verify the signature. 119 | /// 120 | /// Described in [OpenSSH PROTOCOL.agent § 1](https://github.com/openssh/openssh-portable/blob/cbbdf868bce431a59e2fa36ca244d5739429408d/PROTOCOL.agent#L31) 121 | pub fn verify_signature(&self) -> Result<(), ProtoError> { 122 | self.host_key 123 | .verify(self.session_id.as_slice(), &self.signature)?; 124 | Ok(()) 125 | } 126 | } 127 | 128 | impl MessageExtension for SessionBind { 129 | const NAME: &'static str = "session-bind@openssh.com"; 130 | } 131 | 132 | #[cfg(test)] 133 | mod tests { 134 | use testresult::TestResult; 135 | 136 | use super::*; 137 | 138 | fn round_trip(msg: T) -> TestResult 139 | where 140 | T: Encode + Decode + std::fmt::Debug + std::cmp::PartialEq, 141 | { 142 | let mut buf: Vec = vec![]; 143 | msg.encode(&mut buf)?; 144 | let mut re_encoded = &buf[..]; 145 | 146 | let msg2 = T::decode(&mut re_encoded)?; 147 | assert_eq!(msg, msg2); 148 | 149 | Ok(()) 150 | } 151 | 152 | #[test] 153 | fn parse_bind() -> TestResult { 154 | let mut buffer: &[u8] = &[ 155 | 0, 0, 0, 51, 0, 0, 0, 11, 115, 115, 104, 45, 101, 100, 50, 53, 53, 49, 57, 0, 0, 0, 32, 156 | 177, 185, 198, 92, 165, 45, 127, 95, 202, 195, 226, 63, 6, 115, 10, 104, 18, 137, 172, 157 | 240, 153, 154, 174, 74, 83, 7, 1, 204, 14, 177, 153, 40, 0, 0, 0, 32, 138, 165, 196, 158 | 144, 149, 107, 183, 188, 222, 182, 34, 173, 59, 118, 9, 35, 186, 147, 114, 114, 50, 159 | 106, 41, 182, 196, 119, 226, 82, 233, 148, 236, 135, 0, 0, 0, 83, 0, 0, 0, 11, 115, 160 | 115, 104, 45, 101, 100, 50, 53, 53, 49, 57, 0, 0, 0, 64, 95, 212, 52, 189, 8, 162, 17, 161 | 3, 15, 218, 2, 4, 136, 7, 47, 57, 121, 6, 194, 165, 221, 27, 175, 241, 6, 57, 84, 141, 162 | 77, 55, 235, 9, 77, 160, 32, 76, 11, 227, 240, 235, 122, 178, 80, 133, 183, 91, 89, 89, 163 | 142, 115, 145, 15, 78, 112, 139, 28, 201, 8, 197, 222, 117, 141, 88, 5, 0, 164 | ]; 165 | let bind = SessionBind::decode(&mut buffer)?; 166 | eprintln!("Bind: {bind:#?}"); 167 | 168 | // Check `signature` (of `session_id`) against 169 | // server public-key `host_key` 170 | bind.verify_signature()?; 171 | 172 | round_trip(bind)?; 173 | 174 | Ok(()) 175 | } 176 | } 177 | -------------------------------------------------------------------------------- /src/proto/message.rs: -------------------------------------------------------------------------------- 1 | //! Agent protocol message structures. 2 | 3 | mod add_remove; 4 | mod extension; 5 | mod identity; 6 | mod request; 7 | mod response; 8 | mod sign; 9 | mod unparsed; 10 | 11 | pub use self::{ 12 | add_remove::*, extension::*, identity::*, request::*, response::*, sign::*, unparsed::*, 13 | }; 14 | #[doc(hidden)] 15 | /// For compatibility with pre-0.5.0 type alias in this module 16 | /// that duplicated crate::proto::error::ProtoResult 17 | pub use super::Result; 18 | -------------------------------------------------------------------------------- /src/proto/message/add_remove.rs: -------------------------------------------------------------------------------- 1 | //! Add a key to an agent with or without constraints and supporting data types. 2 | 3 | mod constrained; 4 | mod credential; 5 | 6 | pub use constrained::*; 7 | pub use credential::*; 8 | use secrecy::ExposeSecret as _; 9 | use secrecy::SecretString; 10 | use ssh_encoding::{self, CheckedSum, Decode, Encode, Reader, Writer}; 11 | use ssh_key::public::KeyData; 12 | 13 | use crate::proto::{Error, Result}; 14 | 15 | /// Add a key to an agent. 16 | /// 17 | /// This structure is sent in a [`Request::AddIdentity`](super::Request::AddIdentity) (`SSH_AGENTC_ADD_IDENTITY`) message. 18 | /// 19 | /// Described in [draft-miller-ssh-agent-14 § 3.2](https://www.ietf.org/archive/id/draft-miller-ssh-agent-14.html#section-3.2) 20 | #[derive(Clone, PartialEq, Debug)] 21 | pub struct AddIdentity { 22 | /// A credential (private & public key, or private key / certificate) to add to the agent 23 | pub credential: Credential, 24 | } 25 | 26 | impl Decode for AddIdentity { 27 | type Error = Error; 28 | 29 | fn decode(reader: &mut impl Reader) -> Result { 30 | let credential = Credential::decode(reader)?; 31 | 32 | Ok(Self { credential }) 33 | } 34 | } 35 | 36 | impl Encode for AddIdentity { 37 | fn encoded_len(&self) -> ssh_encoding::Result { 38 | self.credential.encoded_len() 39 | } 40 | 41 | fn encode(&self, writer: &mut impl Writer) -> ssh_encoding::Result<()> { 42 | self.credential.encode(writer) 43 | } 44 | } 45 | 46 | /// Pointer to a key in a hardware token, along with an optional PIN. 47 | /// 48 | /// This structure is sent in a [`Request::AddSmartcardKey`](super::Request::AddSmartcardKey) (`SSH_AGENTC_ADD_SMARTCARD_KEY`) message. 49 | /// 50 | /// Described in [draft-miller-ssh-agent-14 § 3.2](https://www.ietf.org/archive/id/draft-miller-ssh-agent-14.html#section-3.2) 51 | #[derive(Clone, Debug)] 52 | pub struct SmartcardKey { 53 | /// An opaque identifier for the hardware token 54 | /// 55 | /// Note: the interpretation of "id" is not defined by the protocol, 56 | /// but is left solely up to the agent. 57 | pub id: String, 58 | 59 | /// An optional password to unlock the key 60 | pub pin: SecretString, 61 | } 62 | 63 | impl Decode for SmartcardKey { 64 | type Error = Error; 65 | 66 | fn decode(reader: &mut impl Reader) -> Result { 67 | let id = String::decode(reader)?; 68 | let pin = String::decode(reader)?.into(); 69 | 70 | Ok(Self { id, pin }) 71 | } 72 | } 73 | 74 | impl Encode for SmartcardKey { 75 | fn encoded_len(&self) -> ssh_encoding::Result { 76 | [ 77 | self.id.encoded_len()?, 78 | self.pin.expose_secret().encoded_len()?, 79 | ] 80 | .checked_sum() 81 | } 82 | 83 | fn encode(&self, writer: &mut impl Writer) -> ssh_encoding::Result<()> { 84 | self.id.encode(writer)?; 85 | self.pin.expose_secret().encode(writer)?; 86 | 87 | Ok(()) 88 | } 89 | } 90 | 91 | impl PartialEq for SmartcardKey { 92 | fn eq(&self, other: &Self) -> bool { 93 | self.id == other.id && self.pin.expose_secret() == other.pin.expose_secret() 94 | } 95 | } 96 | 97 | /// Remove a key from an agent. 98 | /// 99 | /// This structure is sent in a [`Request::RemoveIdentity`](super::Request::RemoveIdentity) (`SSH_AGENTC_REMOVE_IDENTITY`) message. 100 | /// 101 | /// Described in [draft-miller-ssh-agent-14 § 3.4](https://www.ietf.org/archive/id/draft-miller-ssh-agent-14.html#section-3.4) 102 | #[derive(Clone, PartialEq, Debug)] 103 | pub struct RemoveIdentity { 104 | /// The public key portion of the [`Identity`](super::Identity) to be removed 105 | pub pubkey: KeyData, 106 | } 107 | 108 | impl Decode for RemoveIdentity { 109 | type Error = Error; 110 | 111 | fn decode(reader: &mut impl Reader) -> Result { 112 | let pubkey = reader.read_prefixed(KeyData::decode)?; 113 | 114 | Ok(Self { pubkey }) 115 | } 116 | } 117 | 118 | impl Encode for RemoveIdentity { 119 | fn encoded_len(&self) -> ssh_encoding::Result { 120 | self.pubkey.encoded_len_prefixed() 121 | } 122 | 123 | fn encode(&self, writer: &mut impl Writer) -> ssh_encoding::Result<()> { 124 | self.pubkey.encode_prefixed(writer) 125 | } 126 | } 127 | -------------------------------------------------------------------------------- /src/proto/message/add_remove/constrained.rs: -------------------------------------------------------------------------------- 1 | use ssh_encoding::{self, CheckedSum, Decode, Encode, Reader, Writer}; 2 | use ssh_key::Error as KeyError; 3 | 4 | use crate::proto::{AddIdentity, Error, Extension, Result, SmartcardKey, Unparsed}; 5 | 6 | /// A key constraint, used to place limitations on how and where a key can be used. 7 | /// 8 | /// Key constraints are set along with a key when are added to an agent. 9 | /// 10 | /// Specifically, they appear in special `SSH_AGENTC_ADD_*` message variants: 11 | /// - [`Request::AddIdConstrained`](crate::proto::Request::AddIdConstrained) 12 | /// - [`Request::AddSmartcardKeyConstrained`](crate::proto::Request::AddSmartcardKeyConstrained) 13 | #[derive(Clone, PartialEq, Debug)] 14 | pub enum KeyConstraint { 15 | /// Limit the key's lifetime by deleting it after the specified duration (in seconds) 16 | Lifetime(u32), 17 | 18 | /// Require explicit user confirmation for each private key operation using the key. 19 | Confirm, 20 | 21 | /// Experimental or private-use constraints 22 | /// 23 | /// Contains: 24 | /// - An extension name indicating the type of the constraint (as a UTF-8 string). 25 | /// - Extension-specific content 26 | /// 27 | /// Extension names should be suffixed by the implementation domain 28 | /// as per [RFC4251 § 4.2](https://www.rfc-editor.org/rfc/rfc4251.html#section-4.2), 29 | /// e.g. "foo@example.com" 30 | Extension(Extension), 31 | } 32 | 33 | impl Decode for KeyConstraint { 34 | type Error = Error; 35 | 36 | fn decode(reader: &mut impl Reader) -> Result { 37 | let constraint_type = u8::decode(reader)?; 38 | // see: https://www.ietf.org/archive/id/draft-miller-ssh-agent-12.html#section-5.2 39 | Ok(match constraint_type { 40 | 1 => KeyConstraint::Lifetime(u32::decode(reader)?), 41 | 2 => KeyConstraint::Confirm, 42 | 255 => { 43 | let name = String::decode(reader)?; 44 | let details: Vec = Vec::decode(reader)?; 45 | KeyConstraint::Extension(Extension { 46 | name, 47 | details: Unparsed::from(details), 48 | }) 49 | } 50 | _ => return Err(KeyError::AlgorithmUnknown)?, // FIXME: it should be our own type 51 | }) 52 | } 53 | } 54 | 55 | impl Encode for KeyConstraint { 56 | fn encoded_len(&self) -> ssh_encoding::Result { 57 | let base = u8::MAX.encoded_len()?; 58 | 59 | match self { 60 | Self::Lifetime(lifetime) => base 61 | .checked_add(lifetime.encoded_len()?) 62 | .ok_or(ssh_encoding::Error::Length), 63 | Self::Confirm => Ok(base), 64 | Self::Extension(extension) => [ 65 | base, 66 | extension.name.encoded_len()?, 67 | extension.details.encoded_len_prefixed()?, 68 | ] 69 | .checked_sum(), 70 | } 71 | } 72 | 73 | fn encode(&self, writer: &mut impl Writer) -> ssh_encoding::Result<()> { 74 | match self { 75 | Self::Lifetime(lifetime) => { 76 | 1u8.encode(writer)?; 77 | lifetime.encode(writer) 78 | } 79 | Self::Confirm => 2u8.encode(writer), 80 | Self::Extension(extension) => { 81 | 255u8.encode(writer)?; 82 | extension.name.encode(writer)?; 83 | extension.details.encode_prefixed(writer) 84 | } 85 | } 86 | } 87 | } 88 | 89 | /// Add a key to an agent, with constraints on it's use. 90 | /// 91 | /// This structure is sent in a [`Request::AddIdConstrained`](crate::proto::Request::AddIdConstrained) (`SSH_AGENTC_ADD_ID_CONSTRAINED`) message. 92 | /// 93 | /// This is a variant of [`Request::AddIdentity`](crate::proto::Request::AddIdentity) with a set of [`KeyConstraint`]s attached. 94 | /// 95 | /// Described in [draft-miller-ssh-agent-14 § 3.2](https://www.ietf.org/archive/id/draft-miller-ssh-agent-14.html#section-3.2) 96 | #[derive(Clone, PartialEq, Debug)] 97 | pub struct AddIdentityConstrained { 98 | /// The credential to be added to the agent. 99 | pub identity: AddIdentity, 100 | 101 | /// Constraints to be placed on the `identity`. 102 | pub constraints: Vec, 103 | } 104 | 105 | impl Decode for AddIdentityConstrained { 106 | type Error = Error; 107 | 108 | fn decode(reader: &mut impl Reader) -> Result { 109 | let identity = AddIdentity::decode(reader)?; 110 | let mut constraints = vec![]; 111 | 112 | while !reader.is_finished() { 113 | constraints.push(KeyConstraint::decode(reader)?); 114 | } 115 | 116 | Ok(Self { 117 | identity, 118 | constraints, 119 | }) 120 | } 121 | } 122 | 123 | impl Encode for AddIdentityConstrained { 124 | fn encoded_len(&self) -> ssh_encoding::Result { 125 | self.constraints 126 | .iter() 127 | .try_fold(self.identity.encoded_len()?, |acc, e| { 128 | let constraint_len = e.encoded_len()?; 129 | usize::checked_add(acc, constraint_len).ok_or(ssh_encoding::Error::Length) 130 | }) 131 | } 132 | 133 | fn encode(&self, writer: &mut impl Writer) -> ssh_encoding::Result<()> { 134 | self.identity.encode(writer)?; 135 | for constraint in &self.constraints { 136 | constraint.encode(writer)?; 137 | } 138 | Ok(()) 139 | } 140 | } 141 | 142 | /// Add a key in a hardware token to an agent, with constraints on it's use. 143 | /// 144 | /// This structure is sent in a [`Request::AddSmartcardKeyConstrained`](crate::proto::Request::AddSmartcardKeyConstrained) (`SSH_AGENTC_ADD_SMARTCARD_KEY_CONSTRAINED`) message. 145 | /// 146 | /// This is a variant of [`Request::AddSmartcardKey`](crate::proto::Request::AddSmartcardKey) with a set of [`KeyConstraint`]s attached. 147 | /// 148 | /// Described in [draft-miller-ssh-agent-14 § 3.2.6](https://www.ietf.org/archive/id/draft-miller-ssh-agent-14.html#section-3.2.6) 149 | #[derive(Clone, PartialEq, Debug)] 150 | pub struct AddSmartcardKeyConstrained { 151 | /// A key stored on a hardware token. 152 | pub key: SmartcardKey, 153 | 154 | /// Constraints to be placed on the `key`. 155 | pub constraints: Vec, 156 | } 157 | 158 | impl Decode for AddSmartcardKeyConstrained { 159 | type Error = Error; 160 | 161 | fn decode(reader: &mut impl Reader) -> Result { 162 | let key = SmartcardKey::decode(reader)?; 163 | let mut constraints = vec![]; 164 | 165 | while !reader.is_finished() { 166 | constraints.push(KeyConstraint::decode(reader)?); 167 | } 168 | Ok(Self { key, constraints }) 169 | } 170 | } 171 | 172 | impl Encode for AddSmartcardKeyConstrained { 173 | fn encoded_len(&self) -> ssh_encoding::Result { 174 | self.constraints 175 | .iter() 176 | .try_fold(self.key.encoded_len()?, |acc, e| { 177 | let constraint_len = e.encoded_len()?; 178 | usize::checked_add(acc, constraint_len).ok_or(ssh_encoding::Error::Length) 179 | }) 180 | } 181 | 182 | fn encode(&self, writer: &mut impl Writer) -> ssh_encoding::Result<()> { 183 | self.key.encode(writer)?; 184 | for constraint in &self.constraints { 185 | constraint.encode(writer)?; 186 | } 187 | Ok(()) 188 | } 189 | } 190 | -------------------------------------------------------------------------------- /src/proto/message/add_remove/credential.rs: -------------------------------------------------------------------------------- 1 | //! A container for a public / private key pair, or a certificate / private key. 2 | 3 | use core::str::FromStr; 4 | 5 | use ssh_encoding::{self, CheckedSum, Decode, Encode, Reader, Writer}; 6 | use ssh_key::{certificate::Certificate, private::KeypairData, Algorithm}; 7 | 8 | use crate::proto::{Error, PrivateKeyData, Result}; 9 | 10 | /// A container for a public / private key pair, or a certificate / private key. 11 | /// 12 | /// When adding an identity to an agent, a user can provide either: 13 | /// 1. A public / private key pair 14 | /// 2. An OpenSSH [certificate](https://github.com/openssh/openssh-portable/blob/master/PROTOCOL.certkeys) 15 | /// 16 | /// This structure covers both types of identities a user may 17 | /// send to an agent as part of a [`Request::AddIdentity`](crate::proto::Request::AddIdentity) message. 18 | #[derive(Clone, PartialEq, Debug)] 19 | pub enum Credential { 20 | /// A public/private key pair 21 | Key { 22 | /// Public/private key pair data 23 | privkey: KeypairData, 24 | 25 | /// Key comment, if any. 26 | comment: String, 27 | }, 28 | 29 | /// An OpenSSH [certificate](https://github.com/openssh/openssh-portable/blob/master/PROTOCOL.certkeys) 30 | Cert { 31 | /// Certificate algorithm. 32 | algorithm: Algorithm, 33 | 34 | /// Certificate data. 35 | certificate: Certificate, 36 | 37 | /// Private key data. 38 | privkey: PrivateKeyData, 39 | 40 | /// Comment, if any. 41 | comment: String, 42 | }, 43 | } 44 | 45 | impl Decode for Credential { 46 | type Error = Error; 47 | 48 | fn decode(reader: &mut impl Reader) -> Result { 49 | let alg = String::decode(reader)?; 50 | let cert_alg = Algorithm::new_certificate(&alg); 51 | 52 | if let Ok(algorithm) = cert_alg { 53 | let certificate = reader.read_prefixed(|reader| { 54 | let cert = Certificate::decode(reader)?; 55 | Ok::<_, Error>(cert) 56 | })?; 57 | let privkey = PrivateKeyData::decode_as(reader, algorithm.clone())?; 58 | let comment = String::decode(reader)?; 59 | 60 | Ok(Credential::Cert { 61 | algorithm, 62 | certificate, 63 | privkey, 64 | comment, 65 | }) 66 | } else { 67 | let algorithm = Algorithm::from_str(&alg).map_err(ssh_encoding::Error::from)?; 68 | let privkey = KeypairData::decode_as(reader, algorithm)?; 69 | let comment = String::decode(reader)?; 70 | Ok(Credential::Key { privkey, comment }) 71 | } 72 | } 73 | } 74 | 75 | impl Encode for Credential { 76 | fn encoded_len(&self) -> ssh_encoding::Result { 77 | match self { 78 | Self::Key { privkey, comment } => { 79 | [privkey.encoded_len()?, comment.encoded_len()?].checked_sum() 80 | } 81 | Self::Cert { 82 | algorithm, 83 | certificate, 84 | privkey, 85 | comment, 86 | } => [ 87 | algorithm.to_certificate_type().encoded_len()?, 88 | certificate.encoded_len_prefixed()?, 89 | privkey.encoded_len()?, 90 | comment.encoded_len()?, 91 | ] 92 | .checked_sum(), 93 | } 94 | } 95 | 96 | fn encode(&self, writer: &mut impl Writer) -> ssh_encoding::Result<()> { 97 | match self { 98 | Self::Key { privkey, comment } => { 99 | privkey.encode(writer)?; 100 | comment.encode(writer) 101 | } 102 | Self::Cert { 103 | algorithm, 104 | certificate, 105 | privkey, 106 | comment, 107 | } => { 108 | algorithm.to_certificate_type().encode(writer)?; 109 | certificate.encode_prefixed(writer)?; 110 | privkey.encode(writer)?; 111 | comment.encode(writer) 112 | } 113 | } 114 | } 115 | } 116 | -------------------------------------------------------------------------------- /src/proto/message/extension.rs: -------------------------------------------------------------------------------- 1 | //! Container for SSH agent protocol extension messages 2 | 3 | use ssh_encoding::{self, CheckedSum, Decode, Encode, Reader, Writer}; 4 | 5 | use crate::proto::{ 6 | extension::{KeyConstraintExtension, MessageExtension}, 7 | Error, Result, Unparsed, 8 | }; 9 | 10 | /// Container for SSH agent protocol extension messages 11 | /// 12 | /// This structure is sent as part of a [`Request::Extension`](super::Request::Extension) (`SSH_AGENT_EXTENSION_RESPONSE`) message. 13 | /// 14 | /// Described in [draft-miller-ssh-agent-14 § 3.8](https://www.ietf.org/archive/id/draft-miller-ssh-agent-14.html#section-3.8). 15 | #[derive(Clone, PartialEq, Debug)] 16 | pub struct Extension { 17 | /// Indicates the type of the extension message (as a UTF-8 string) 18 | /// 19 | /// Extension names should be suffixed by the implementation domain 20 | /// as per [RFC4251 § 4.2](https://www.rfc-editor.org/rfc/rfc4251.html#section-4.2), 21 | /// e.g. "foo@example.com" 22 | pub name: String, 23 | 24 | /// Extension-specific content 25 | pub details: Unparsed, 26 | } 27 | 28 | impl Extension { 29 | /// Create a new [`Extension`] from a [`MessageExtension`] 30 | /// structure implementing [`ssh_encoding::Encode`] 31 | pub fn new_message(extension: T) -> Result 32 | where 33 | T: MessageExtension + Encode, 34 | { 35 | let mut buffer: Vec = vec![]; 36 | extension.encode(&mut buffer)?; 37 | Ok(Self { 38 | name: T::NAME.into(), 39 | details: buffer.into(), 40 | }) 41 | } 42 | 43 | /// Attempt to parse a an extension object into a 44 | /// [`MessageExtension`] structure 45 | /// implementing [`ssh_encoding::Decode`]. 46 | /// 47 | /// If there is a mismatch between the extension name 48 | /// and the [`MessageExtension::NAME`], this method 49 | /// will return [`None`] 50 | pub fn parse_message(&self) -> std::result::Result, ::Error> 51 | where 52 | T: MessageExtension + Decode, 53 | { 54 | if T::NAME == self.name { 55 | Ok(Some(self.details.parse::()?)) 56 | } else { 57 | Ok(None) 58 | } 59 | } 60 | 61 | /// Create a new [`Extension`] from a [`KeyConstraintExtension`] 62 | /// structure implementing [`ssh_encoding::Encode`] 63 | pub fn new_key_constraint(extension: T) -> Result 64 | where 65 | T: KeyConstraintExtension + Encode, 66 | { 67 | let mut buffer: Vec = vec![]; 68 | extension.encode(&mut buffer)?; 69 | Ok(Self { 70 | name: T::NAME.into(), 71 | details: buffer.into(), 72 | }) 73 | } 74 | 75 | /// Attempt to parse a an extension object into a 76 | /// [`KeyConstraintExtension`] structure 77 | /// implementing [`ssh_encoding::Decode`]. 78 | /// 79 | /// If there is a mismatch between the extension name 80 | /// and the [`KeyConstraintExtension::NAME`], this method 81 | /// will return [`None`] 82 | pub fn parse_key_constraint(&self) -> std::result::Result, ::Error> 83 | where 84 | T: KeyConstraintExtension + Decode, 85 | { 86 | if T::NAME == self.name { 87 | Ok(Some(self.details.parse::()?)) 88 | } else { 89 | Ok(None) 90 | } 91 | } 92 | } 93 | 94 | impl Decode for Extension { 95 | type Error = Error; 96 | 97 | fn decode(reader: &mut impl Reader) -> Result { 98 | let name = String::decode(reader)?; 99 | let mut details = vec![0; reader.remaining_len()]; 100 | reader.read(&mut details)?; 101 | Ok(Self { 102 | name, 103 | details: details.into(), 104 | }) 105 | } 106 | } 107 | 108 | impl Encode for Extension { 109 | fn encoded_len(&self) -> ssh_encoding::Result { 110 | [self.name.encoded_len()?, self.details.encoded_len()?].checked_sum() 111 | } 112 | 113 | fn encode(&self, writer: &mut impl Writer) -> ssh_encoding::Result<()> { 114 | self.name.encode(writer)?; 115 | self.details.encode(writer)?; 116 | Ok(()) 117 | } 118 | } 119 | -------------------------------------------------------------------------------- /src/proto/message/identity.rs: -------------------------------------------------------------------------------- 1 | //! Data returned to the client when listing keys. 2 | 3 | use ssh_encoding::{self, CheckedSum, Decode, Encode, Reader, Writer}; 4 | use ssh_key::public::KeyData; 5 | 6 | use crate::proto::{Error, Result}; 7 | 8 | /// Data returned to the client when listing keys. 9 | /// 10 | /// A list of these structures are sent in a [`Response::IdentitiesAnswer`](super::Response::IdentitiesAnswer) (`SSH_AGENT_IDENTITIES_ANSWER`) message body. 11 | /// 12 | /// Described in [draft-miller-ssh-agent-14 § 3.5](https://www.ietf.org/archive/id/draft-miller-ssh-agent-14.html#section-3.5) 13 | #[derive(Clone, PartialEq, Debug)] 14 | pub struct Identity { 15 | /// A standard public-key encoding of an underlying key. 16 | pub pubkey: KeyData, 17 | 18 | /// A human-readable comment 19 | pub comment: String, 20 | } 21 | 22 | impl Identity { 23 | pub(crate) fn decode_vec(reader: &mut impl Reader) -> Result> { 24 | let len = u32::decode(reader)?; 25 | let mut identities = vec![]; 26 | 27 | for _ in 0..len { 28 | identities.push(Self::decode(reader)?); 29 | } 30 | 31 | Ok(identities) 32 | } 33 | } 34 | 35 | impl Decode for Identity { 36 | type Error = Error; 37 | 38 | fn decode(reader: &mut impl Reader) -> Result { 39 | let pubkey = reader.read_prefixed(KeyData::decode)?; 40 | let comment = String::decode(reader)?; 41 | 42 | Ok(Self { pubkey, comment }) 43 | } 44 | } 45 | 46 | impl Encode for Identity { 47 | fn encoded_len(&self) -> ssh_encoding::Result { 48 | [ 49 | self.pubkey.encoded_len_prefixed()?, 50 | self.comment.encoded_len()?, 51 | ] 52 | .checked_sum() 53 | } 54 | 55 | fn encode(&self, writer: &mut impl Writer) -> ssh_encoding::Result<()> { 56 | self.pubkey.encode_prefixed(writer)?; 57 | self.comment.encode(writer)?; 58 | 59 | Ok(()) 60 | } 61 | } 62 | -------------------------------------------------------------------------------- /src/proto/message/request.rs: -------------------------------------------------------------------------------- 1 | //! SSH agent protocol request messages. 2 | 3 | use ssh_encoding::{CheckedSum, Decode, Encode, Reader, Writer}; 4 | 5 | use super::{ 6 | AddIdentity, AddIdentityConstrained, AddSmartcardKeyConstrained, Extension, RemoveIdentity, 7 | SignRequest, SmartcardKey, 8 | }; 9 | use crate::proto::{Error, Result}; 10 | 11 | /// SSH agent protocol request messages. 12 | /// 13 | /// These message types are sent from a client *to* an agent. 14 | /// 15 | /// Described in [draft-miller-ssh-agent-14 § 3](https://www.ietf.org/archive/id/draft-miller-ssh-agent-14.html#section-3). 16 | #[derive(Clone, PartialEq, Debug)] 17 | pub enum Request { 18 | /// Request a list of all identities (public key/certificate & comment) 19 | /// from an agent 20 | RequestIdentities, 21 | 22 | /// Perform a private key signature operation using a key 23 | /// stored in the agent 24 | SignRequest(SignRequest), 25 | 26 | /// Add an identity (private key/certificate & comment) to an agent 27 | AddIdentity(AddIdentity), 28 | 29 | /// Remove an identity from an agent 30 | RemoveIdentity(RemoveIdentity), 31 | 32 | /// Remove all identities from an agent 33 | RemoveAllIdentities, 34 | 35 | /// Add an identity (private key/certificate & comment) to an agent 36 | /// where the private key is stored on a hardware token 37 | AddSmartcardKey(SmartcardKey), 38 | 39 | /// Remove a key stored on a hardware token from an agent 40 | RemoveSmartcardKey(SmartcardKey), 41 | 42 | /// Temporarily lock an agent with a pass-phrase 43 | Lock(String), 44 | 45 | /// Unlock a locked agaent with a pass-phrase 46 | Unlock(String), 47 | 48 | /// Add an identity (private key/certificate & comment) to an agent, 49 | /// with constraints on it's usage 50 | AddIdConstrained(AddIdentityConstrained), 51 | 52 | /// Add an identity (private key/certificate & comment) to an agent 53 | /// where the private key is stored on a hardware token, 54 | /// with constraints on it's usage 55 | AddSmartcardKeyConstrained(AddSmartcardKeyConstrained), 56 | 57 | /// Send a vendor-specific message via the agent protocol, 58 | /// identified by an *extension type*. 59 | Extension(Extension), 60 | } 61 | 62 | impl Request { 63 | /// The protocol message identifier for a given [`Request`] message type. 64 | /// 65 | /// Described in [draft-miller-ssh-agent-14 § 6.1](https://www.ietf.org/archive/id/draft-miller-ssh-agent-14.html#section-6.1). 66 | pub fn message_id(&self) -> u8 { 67 | match self { 68 | Self::RequestIdentities => 11, 69 | Self::SignRequest(_) => 13, 70 | Self::AddIdentity(_) => 17, 71 | Self::RemoveIdentity(_) => 18, 72 | Self::RemoveAllIdentities => 19, 73 | Self::AddSmartcardKey(_) => 20, 74 | Self::RemoveSmartcardKey(_) => 21, 75 | Self::Lock(_) => 22, 76 | Self::Unlock(_) => 23, 77 | Self::AddIdConstrained(_) => 25, 78 | Self::AddSmartcardKeyConstrained(_) => 26, 79 | Self::Extension(_) => 27, 80 | } 81 | } 82 | } 83 | 84 | impl Decode for Request { 85 | type Error = Error; 86 | 87 | fn decode(reader: &mut impl Reader) -> Result { 88 | let message_type = u8::decode(reader)?; 89 | 90 | match message_type { 91 | 11 => Ok(Self::RequestIdentities), 92 | 13 => SignRequest::decode(reader).map(Self::SignRequest), 93 | 17 => AddIdentity::decode(reader).map(Self::AddIdentity), 94 | 18 => RemoveIdentity::decode(reader).map(Self::RemoveIdentity), 95 | 19 => Ok(Self::RemoveAllIdentities), 96 | 20 => SmartcardKey::decode(reader).map(Self::AddSmartcardKey), 97 | 21 => SmartcardKey::decode(reader).map(Self::RemoveSmartcardKey), 98 | 22 => Ok(String::decode(reader).map(Self::Lock)?), 99 | 23 => Ok(String::decode(reader).map(Self::Unlock)?), 100 | 25 => AddIdentityConstrained::decode(reader).map(Self::AddIdConstrained), 101 | 26 => AddSmartcardKeyConstrained::decode(reader).map(Self::AddSmartcardKeyConstrained), 102 | 27 => Extension::decode(reader).map(Self::Extension), 103 | command => Err(Error::UnsupportedCommand { command }), 104 | } 105 | } 106 | } 107 | 108 | impl Encode for Request { 109 | fn encoded_len(&self) -> ssh_encoding::Result { 110 | let message_id_len = 1; 111 | let payload_len = match self { 112 | Self::RequestIdentities => 0, 113 | Self::SignRequest(request) => request.encoded_len()?, 114 | Self::AddIdentity(identity) => identity.encoded_len()?, 115 | Self::RemoveIdentity(identity) => identity.encoded_len()?, 116 | Self::RemoveAllIdentities => 0, 117 | Self::AddSmartcardKey(key) => key.encoded_len()?, 118 | Self::RemoveSmartcardKey(key) => key.encoded_len()?, 119 | Self::Lock(passphrase) => passphrase.encoded_len()?, 120 | Self::Unlock(passphrase) => passphrase.encoded_len()?, 121 | Self::AddIdConstrained(key) => key.encoded_len()?, 122 | Self::AddSmartcardKeyConstrained(key) => key.encoded_len()?, 123 | Self::Extension(extension) => extension.encoded_len()?, 124 | }; 125 | 126 | [message_id_len, payload_len].checked_sum() 127 | } 128 | 129 | fn encode(&self, writer: &mut impl Writer) -> ssh_encoding::Result<()> { 130 | let message_id: u8 = self.message_id(); 131 | message_id.encode(writer)?; 132 | 133 | match self { 134 | Self::RequestIdentities => {} 135 | Self::SignRequest(request) => request.encode(writer)?, 136 | Self::AddIdentity(identity) => identity.encode(writer)?, 137 | Self::RemoveIdentity(identity) => identity.encode(writer)?, 138 | Self::RemoveAllIdentities => {} 139 | Self::AddSmartcardKey(key) => key.encode(writer)?, 140 | Self::RemoveSmartcardKey(key) => key.encode(writer)?, 141 | Self::Lock(passphrase) => passphrase.encode(writer)?, 142 | Self::Unlock(passphrase) => passphrase.encode(writer)?, 143 | Self::AddIdConstrained(identity) => identity.encode(writer)?, 144 | Self::AddSmartcardKeyConstrained(key) => key.encode(writer)?, 145 | Self::Extension(extension) => extension.encode(writer)?, 146 | }; 147 | 148 | Ok(()) 149 | } 150 | } 151 | -------------------------------------------------------------------------------- /src/proto/message/response.rs: -------------------------------------------------------------------------------- 1 | //! SSH agent protocol response messages. 2 | use ssh_encoding::{CheckedSum, Decode, Encode, Reader, Writer}; 3 | use ssh_key::Signature; 4 | 5 | use super::{Extension, Identity}; 6 | use crate::proto::{Error, Result}; 7 | 8 | /// SSH agent protocol response messages. 9 | /// 10 | /// These message types are sent to a client *from* an agent (in response to a [`Request`](super::Request) message). 11 | /// 12 | /// Described in [draft-miller-ssh-agent-14 § 3](https://www.ietf.org/archive/id/draft-miller-ssh-agent-14.html#section-3). 13 | #[derive(Clone, PartialEq, Debug)] 14 | pub enum Response { 15 | /// Indicates generic agent failure 16 | Failure, 17 | 18 | /// Indicates generic agent success 19 | Success, 20 | 21 | /// A list of identities, sent in response to 22 | /// a [`Request::RequestIdentities`](super::Request::RequestIdentities) message. 23 | IdentitiesAnswer(Vec), 24 | 25 | /// A signature, sent in response to 26 | /// a [`Request::SignRequest`](super::Request::SignRequest) message. 27 | SignResponse(Signature), 28 | 29 | /// Indicates generic extension failure 30 | ExtensionFailure, 31 | 32 | /// Send a vendor-specific response message via the agent protocol, 33 | /// identified by an *extension type*. 34 | ExtensionResponse(Extension), 35 | } 36 | 37 | impl Response { 38 | /// The protocol message identifier for a given [`Response`](super::Response) message type. 39 | /// 40 | /// Described in [draft-miller-ssh-agent-14 § 6.1](https://www.ietf.org/archive/id/draft-miller-ssh-agent-14.html#section-6.1). 41 | pub fn message_id(&self) -> u8 { 42 | match self { 43 | Self::Failure => 5, 44 | Self::Success => 6, 45 | Self::IdentitiesAnswer(_) => 12, 46 | Self::SignResponse(_) => 14, 47 | Self::ExtensionFailure => 28, 48 | Self::ExtensionResponse(_) => 29, 49 | } 50 | } 51 | } 52 | 53 | impl Decode for Response { 54 | type Error = Error; 55 | 56 | fn decode(reader: &mut impl Reader) -> Result { 57 | let message_type = u8::decode(reader)?; 58 | 59 | match message_type { 60 | 5 => Ok(Self::Failure), 61 | 6 => Ok(Self::Success), 62 | 12 => Identity::decode_vec(reader).map(Self::IdentitiesAnswer), 63 | 14 => { 64 | Ok(reader 65 | .read_prefixed(|reader| Signature::decode(reader).map(Self::SignResponse))?) 66 | } 67 | 28 => Ok(Self::ExtensionFailure), 68 | 29 => Extension::decode(reader).map(Self::ExtensionResponse), 69 | command => Err(Error::UnsupportedCommand { command }), 70 | } 71 | } 72 | } 73 | 74 | impl Encode for Response { 75 | fn encoded_len(&self) -> ssh_encoding::Result { 76 | let message_id_len = 1; 77 | let payload_len = match self { 78 | Self::Failure => 0, 79 | Self::Success => 0, 80 | Self::IdentitiesAnswer(ids) => { 81 | let mut lengths = Vec::with_capacity(1 + ids.len()); 82 | // Prefixed length 83 | lengths.push(4); 84 | 85 | for id in ids { 86 | lengths.push(id.encoded_len()?); 87 | } 88 | 89 | lengths.checked_sum()? 90 | } 91 | Self::SignResponse(response) => response.encoded_len_prefixed()?, 92 | Self::ExtensionFailure => 0, 93 | Self::ExtensionResponse(extension) => extension.encoded_len()?, 94 | }; 95 | 96 | [message_id_len, payload_len].checked_sum() 97 | } 98 | 99 | fn encode(&self, writer: &mut impl Writer) -> ssh_encoding::Result<()> { 100 | let message_id: u8 = self.message_id(); 101 | message_id.encode(writer)?; 102 | 103 | match self { 104 | Self::Failure => {} 105 | Self::Success => {} 106 | Self::IdentitiesAnswer(ids) => { 107 | (ids.len() as u32).encode(writer)?; 108 | for id in ids { 109 | id.encode(writer)?; 110 | } 111 | } 112 | Self::SignResponse(response) => response.encode_prefixed(writer)?, 113 | Self::ExtensionFailure => {} 114 | Self::ExtensionResponse(extension) => extension.encode(writer)?, 115 | }; 116 | 117 | Ok(()) 118 | } 119 | } 120 | -------------------------------------------------------------------------------- /src/proto/message/sign.rs: -------------------------------------------------------------------------------- 1 | //! Signature request with data to be signed with a key in an agent. 2 | 3 | use ssh_encoding::{self, CheckedSum, Decode, Encode, Reader, Writer}; 4 | use ssh_key::public::KeyData; 5 | 6 | use crate::proto::{Error, Result}; 7 | 8 | /// Signature request with data to be signed with a key in an agent. 9 | /// 10 | /// This structure is sent in a [`Request::SignRequest`](super::Request::SignRequest) (`SSH_AGENTC_SIGN_REQUEST`) message. 11 | /// 12 | /// Described in [draft-miller-ssh-agent-14 § 3.6](https://www.ietf.org/archive/id/draft-miller-ssh-agent-14.html#section-3.6) 13 | #[derive(Clone, PartialEq, Debug)] 14 | pub struct SignRequest { 15 | /// The public key portion of the [`Identity`](super::Identity) in the agent to sign the data with 16 | pub pubkey: KeyData, 17 | 18 | /// Binary data to be signed 19 | pub data: Vec, 20 | 21 | /// Signature flags, as described in 22 | /// [draft-miller-ssh-agent-14 § 3.6.1](https://www.ietf.org/archive/id/draft-miller-ssh-agent-14.html#section-3.6.1) 23 | pub flags: u32, 24 | } 25 | 26 | impl Decode for SignRequest { 27 | type Error = Error; 28 | 29 | fn decode(reader: &mut impl Reader) -> Result { 30 | let pubkey = reader.read_prefixed(KeyData::decode)?; 31 | let data = Vec::decode(reader)?; 32 | let flags = u32::decode(reader)?; 33 | 34 | Ok(Self { 35 | pubkey, 36 | data, 37 | flags, 38 | }) 39 | } 40 | } 41 | 42 | impl Encode for SignRequest { 43 | fn encoded_len(&self) -> ssh_encoding::Result { 44 | [ 45 | self.pubkey.encoded_len_prefixed()?, 46 | self.data.encoded_len()?, 47 | self.flags.encoded_len()?, 48 | ] 49 | .checked_sum() 50 | } 51 | 52 | fn encode(&self, writer: &mut impl Writer) -> ssh_encoding::Result<()> { 53 | self.pubkey.encode_prefixed(writer)?; 54 | self.data.encode(writer)?; 55 | self.flags.encode(writer)?; 56 | 57 | Ok(()) 58 | } 59 | } 60 | -------------------------------------------------------------------------------- /src/proto/message/unparsed.rs: -------------------------------------------------------------------------------- 1 | //! Generic container for [`Extension`](super::Extension)-specific content 2 | 3 | use ssh_encoding::{self, Decode, Encode, Writer}; 4 | 5 | /// Generic container for [`Extension`](super::Extension)-specific content. 6 | /// Accessing the inner `Vec` is only possible via conversion methods. 7 | #[derive(Debug, PartialEq, Clone)] 8 | pub struct Unparsed(Vec); 9 | 10 | impl Unparsed { 11 | /// Decode unparsed bytes as SSH structures. 12 | pub fn parse(&self) -> std::result::Result::Error> 13 | where 14 | T: Decode, 15 | { 16 | let mut v = &self.0[..]; 17 | T::decode(&mut v) 18 | } 19 | 20 | /// Obtain the unparsed bytes as a `Vec`, consuming the `Unparsed` 21 | pub fn into_bytes(self) -> Vec { 22 | self.0 23 | } 24 | } 25 | 26 | impl From> for Unparsed { 27 | fn from(value: Vec) -> Self { 28 | Self(value) 29 | } 30 | } 31 | 32 | impl AsRef<[u8]> for Unparsed { 33 | fn as_ref(&self) -> &[u8] { 34 | self.0.as_ref() 35 | } 36 | } 37 | 38 | impl Encode for Unparsed { 39 | fn encoded_len(&self) -> ssh_encoding::Result { 40 | Ok(self.0.len()) 41 | } 42 | 43 | fn encode(&self, writer: &mut impl Writer) -> ssh_encoding::Result<()> { 44 | // NOTE: Unparsed fields do not embed a length u32, 45 | // as the inner Vec encoding is implementation-defined 46 | // (usually an Extension) 47 | writer.write(&self.0[..])?; 48 | 49 | Ok(()) 50 | } 51 | } 52 | -------------------------------------------------------------------------------- /src/proto/privatekey.rs: -------------------------------------------------------------------------------- 1 | //! Types for handling SSH private key data. 2 | 3 | use core::fmt; 4 | 5 | use ssh_encoding::{Decode, Encode, Reader, Writer}; 6 | use ssh_key::{ 7 | private::{self, DsaPrivateKey, Ed25519Keypair, RsaPrivateKey}, 8 | Algorithm, EcdsaCurve, Error, Result, 9 | }; 10 | use subtle::{Choice, ConstantTimeEq}; 11 | 12 | /// Elliptic Curve Digital Signature Algorithm (ECDSA) private/public key pair. 13 | #[derive(Clone, Debug)] 14 | pub enum EcdsaPrivateKey { 15 | /// NIST P-256 ECDSA private key. 16 | NistP256(private::EcdsaPrivateKey<32>), 17 | 18 | /// NIST P-384 ECDSA private key. 19 | NistP384(private::EcdsaPrivateKey<48>), 20 | 21 | /// NIST P-521 ECDSA private key. 22 | NistP521(private::EcdsaPrivateKey<66>), 23 | } 24 | 25 | impl ConstantTimeEq for EcdsaPrivateKey { 26 | fn ct_eq(&self, other: &Self) -> Choice { 27 | let private_key_a = match self { 28 | Self::NistP256(private) => private.as_slice(), 29 | Self::NistP384(private) => private.as_slice(), 30 | Self::NistP521(private) => private.as_slice(), 31 | }; 32 | 33 | let private_key_b = match other { 34 | Self::NistP256(private) => private.as_slice(), 35 | Self::NistP384(private) => private.as_slice(), 36 | Self::NistP521(private) => private.as_slice(), 37 | }; 38 | 39 | private_key_a.ct_eq(private_key_b) 40 | } 41 | } 42 | 43 | impl Eq for EcdsaPrivateKey {} 44 | 45 | impl PartialEq for EcdsaPrivateKey { 46 | fn eq(&self, other: &Self) -> bool { 47 | self.ct_eq(other).into() 48 | } 49 | } 50 | 51 | impl EcdsaPrivateKey { 52 | fn decode_as(reader: &mut impl Reader, curve: EcdsaCurve) -> Result { 53 | match curve { 54 | EcdsaCurve::NistP256 => { 55 | private::EcdsaPrivateKey::<32>::decode(reader).map(Self::NistP256) 56 | } 57 | EcdsaCurve::NistP384 => { 58 | private::EcdsaPrivateKey::<48>::decode(reader).map(Self::NistP384) 59 | } 60 | EcdsaCurve::NistP521 => { 61 | private::EcdsaPrivateKey::<66>::decode(reader).map(Self::NistP521) 62 | } 63 | } 64 | } 65 | } 66 | 67 | impl Encode for EcdsaPrivateKey { 68 | fn encoded_len(&self) -> ssh_encoding::Result { 69 | match self { 70 | Self::NistP256(private) => private.encoded_len(), 71 | Self::NistP384(private) => private.encoded_len(), 72 | Self::NistP521(private) => private.encoded_len(), 73 | } 74 | } 75 | 76 | fn encode(&self, writer: &mut impl Writer) -> ssh_encoding::Result<()> { 77 | match self { 78 | Self::NistP256(private) => private.encode(writer), 79 | Self::NistP384(private) => private.encode(writer), 80 | Self::NistP521(private) => private.encode(writer), 81 | } 82 | } 83 | } 84 | 85 | /// Private key data stored within a `Credential` object 86 | #[derive(Clone)] 87 | #[non_exhaustive] 88 | pub enum PrivateKeyData { 89 | /// Digital Signature Algorithm (DSA) private key. 90 | Dsa(DsaPrivateKey), 91 | 92 | /// ECDSA private key. 93 | Ecdsa(EcdsaPrivateKey), 94 | 95 | // Note: OpenSSH is a little inconsistent, Ed25519 is the only one 96 | // algorithm that will always encode the full key pair. 97 | /// Ed25519 key pair. 98 | Ed25519(Ed25519Keypair), 99 | 100 | /// RSA private key. 101 | Rsa(RsaPrivateKey), 102 | } 103 | 104 | impl PrivateKeyData { 105 | /// Decode [`PrivateKeyData`] for the specified algorithm. 106 | pub fn decode_as(reader: &mut impl Reader, algorithm: Algorithm) -> Result { 107 | match algorithm { 108 | Algorithm::Dsa => DsaPrivateKey::decode(reader).map(Self::Dsa), 109 | Algorithm::Ecdsa { curve } => { 110 | EcdsaPrivateKey::decode_as(reader, curve).map(Self::Ecdsa) 111 | } 112 | Algorithm::Ed25519 => Ed25519Keypair::decode(reader).map(Self::Ed25519), 113 | Algorithm::Rsa { .. } => RsaPrivateKey::decode(reader).map(Self::Rsa), 114 | #[allow(unreachable_patterns)] 115 | _ => Err(Error::AlgorithmUnknown), 116 | } 117 | } 118 | } 119 | 120 | impl fmt::Debug for PrivateKeyData { 121 | fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { 122 | match self { 123 | Self::Dsa(_) => write!(f, "PrivateKeyData::Dsa"), 124 | Self::Ecdsa(_) => write!(f, "PrivateKeyData::Ecdsa"), 125 | Self::Ed25519(_) => write!(f, "PrivateKeyData::Ed25519"), 126 | Self::Rsa(_) => write!(f, "PrivateKeyData::Rsa"), 127 | } 128 | } 129 | } 130 | 131 | impl Encode for PrivateKeyData { 132 | fn encoded_len(&self) -> ssh_encoding::Result { 133 | match self { 134 | Self::Dsa(key) => key.encoded_len(), 135 | Self::Ecdsa(key) => key.encoded_len(), 136 | Self::Ed25519(key) => key.encoded_len(), 137 | Self::Rsa(key) => key.encoded_len(), 138 | } 139 | } 140 | 141 | fn encode(&self, writer: &mut impl Writer) -> ssh_encoding::Result<()> { 142 | match self { 143 | Self::Dsa(key) => key.encode(writer)?, 144 | Self::Ecdsa(key) => key.encode(writer)?, 145 | Self::Ed25519(key) => key.encode(writer)?, 146 | Self::Rsa(key) => key.encode(writer)?, 147 | } 148 | 149 | Ok(()) 150 | } 151 | } 152 | 153 | impl ConstantTimeEq for PrivateKeyData { 154 | fn ct_eq(&self, other: &Self) -> Choice { 155 | // Note: constant-time with respect to key *data* comparisons, not algorithms 156 | match (self, other) { 157 | (Self::Dsa(a), Self::Dsa(b)) => a.ct_eq(b), 158 | (Self::Ecdsa(a), Self::Ecdsa(b)) => a.ct_eq(b), 159 | (Self::Ed25519(a), Self::Ed25519(b)) => a.ct_eq(b), 160 | (Self::Rsa(a), Self::Rsa(b)) => a.ct_eq(b), 161 | #[allow(unreachable_patterns)] 162 | _ => Choice::from(0), 163 | } 164 | } 165 | } 166 | 167 | impl Eq for PrivateKeyData {} 168 | 169 | impl PartialEq for PrivateKeyData { 170 | fn eq(&self, other: &Self) -> bool { 171 | self.ct_eq(other).into() 172 | } 173 | } 174 | -------------------------------------------------------------------------------- /src/proto/signature.rs: -------------------------------------------------------------------------------- 1 | //! Agent protocol signature flag constants. 2 | 3 | /// The `SSH_AGENT_RSA_SHA2_256` signature flag, as described in 4 | /// [draft-miller-ssh-agent-14 § 3.6.1](https://www.ietf.org/archive/id/draft-miller-ssh-agent-14.html#section-3.6.1) 5 | pub const RSA_SHA2_256: u32 = 0x02; 6 | /// The `SSH_AGENT_RSA_SHA2_512` signature flag, as described in 7 | /// [draft-miller-ssh-agent-14 § 3.6.1](https://www.ietf.org/archive/id/draft-miller-ssh-agent-14.html#section-3.6.1) 8 | pub const RSA_SHA2_512: u32 = 0x04; 9 | -------------------------------------------------------------------------------- /tests/known_hosts: -------------------------------------------------------------------------------- 1 | github.com ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIOMqqnkVzrm0SdG6UOoqKLsabgH5C9okWi0dh2l9GKJl 2 | -------------------------------------------------------------------------------- /tests/messages/req-add-identity-constrained-extension-restrict-destination.bin: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/wiktor-k/ssh-agent-lib/933166c568db76b32062f370cccbbc4fab7f427d/tests/messages/req-add-identity-constrained-extension-restrict-destination.bin -------------------------------------------------------------------------------- /tests/messages/req-add-identity-constrained-lifetime.bin: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/wiktor-k/ssh-agent-lib/933166c568db76b32062f370cccbbc4fab7f427d/tests/messages/req-add-identity-constrained-lifetime.bin -------------------------------------------------------------------------------- /tests/messages/req-add-identity-constrained-multiple-extensions.bin: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/wiktor-k/ssh-agent-lib/933166c568db76b32062f370cccbbc4fab7f427d/tests/messages/req-add-identity-constrained-multiple-extensions.bin -------------------------------------------------------------------------------- /tests/messages/req-add-identity-constrained.bin: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/wiktor-k/ssh-agent-lib/933166c568db76b32062f370cccbbc4fab7f427d/tests/messages/req-add-identity-constrained.bin -------------------------------------------------------------------------------- /tests/messages/req-add-identity-ecdsa.bin: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/wiktor-k/ssh-agent-lib/933166c568db76b32062f370cccbbc4fab7f427d/tests/messages/req-add-identity-ecdsa.bin -------------------------------------------------------------------------------- /tests/messages/req-add-identity-with-cert.bin: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/wiktor-k/ssh-agent-lib/933166c568db76b32062f370cccbbc4fab7f427d/tests/messages/req-add-identity-with-cert.bin -------------------------------------------------------------------------------- /tests/messages/req-add-identity.bin: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/wiktor-k/ssh-agent-lib/933166c568db76b32062f370cccbbc4fab7f427d/tests/messages/req-add-identity.bin -------------------------------------------------------------------------------- /tests/messages/req-add-smartcard-key-constrained.bin: -------------------------------------------------------------------------------- 1 | testtest -------------------------------------------------------------------------------- /tests/messages/req-add-smartcard-key.bin: -------------------------------------------------------------------------------- 1 | testtest -------------------------------------------------------------------------------- /tests/messages/req-extension.bin: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/wiktor-k/ssh-agent-lib/933166c568db76b32062f370cccbbc4fab7f427d/tests/messages/req-extension.bin -------------------------------------------------------------------------------- /tests/messages/req-lock.bin: -------------------------------------------------------------------------------- 1 | test -------------------------------------------------------------------------------- /tests/messages/req-parse-certificates.bin: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/wiktor-k/ssh-agent-lib/933166c568db76b32062f370cccbbc4fab7f427d/tests/messages/req-parse-certificates.bin -------------------------------------------------------------------------------- /tests/messages/req-request-identities.bin: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /tests/messages/req-sign-request.bin: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/wiktor-k/ssh-agent-lib/933166c568db76b32062f370cccbbc4fab7f427d/tests/messages/req-sign-request.bin -------------------------------------------------------------------------------- /tests/messages/req-unlock.bin: -------------------------------------------------------------------------------- 1 | test -------------------------------------------------------------------------------- /tests/messages/resp-identities-answer.bin: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/wiktor-k/ssh-agent-lib/933166c568db76b32062f370cccbbc4fab7f427d/tests/messages/resp-identities-answer.bin -------------------------------------------------------------------------------- /tests/messages/resp-parse-identities.bin: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/wiktor-k/ssh-agent-lib/933166c568db76b32062f370cccbbc4fab7f427d/tests/messages/resp-parse-identities.bin -------------------------------------------------------------------------------- /tests/messages/resp-sign-response.bin: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/wiktor-k/ssh-agent-lib/933166c568db76b32062f370cccbbc4fab7f427d/tests/messages/resp-sign-response.bin -------------------------------------------------------------------------------- /tests/messages/resp-success.bin: -------------------------------------------------------------------------------- 1 |  -------------------------------------------------------------------------------- /tests/pwd-test.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | echo test 4 | -------------------------------------------------------------------------------- /tests/roundtrip/expected.rs: -------------------------------------------------------------------------------- 1 | mod fixtures; 2 | #[macro_use] 3 | mod macros; 4 | 5 | use ssh_agent_lib::proto::{Request, Response}; 6 | 7 | // This macro generates a function with the following signature: 8 | // 9 | // `fn request(path: impl AsRef) -> Option` 10 | // 11 | // When called, it will take the filename without extension from the provided path (replacing any 12 | // dashes with underscores) and compare that string to the list of modules in the bracketed list. 13 | // If one of the listed modules matches the filename (e.g. `req-example-test.bin`), the function 14 | // `req_example_test::expected()` will be called, which must have the signature `pub fn expected() 15 | // -> Request`. (If none of the modules match the filename, `None` will be returned.) 16 | // 17 | // The roundtrip test code calls this to enhance `Encode`/`Decode` roundtrip tests with a known 18 | // static object that must match the deserialized bytes. 19 | // 20 | // The macro also declares the listed modules. 21 | make_expected_fn!(request -> Request, { 22 | req_add_identity_ecdsa, 23 | req_add_identity_constrained_lifetime, 24 | req_add_identity_constrained_extension_restrict_destination, 25 | req_parse_certificates 26 | }); 27 | 28 | make_expected_fn!(response -> Response, { 29 | resp_parse_identities 30 | }); 31 | -------------------------------------------------------------------------------- /tests/roundtrip/expected/fixtures.rs: -------------------------------------------------------------------------------- 1 | use hex_literal::hex; 2 | use ssh_encoding::Decode; 3 | use ssh_key::{ 4 | private::{EcdsaKeypair, EcdsaPrivateKey}, 5 | Certificate, 6 | }; 7 | 8 | pub fn demo_key() -> EcdsaKeypair { 9 | EcdsaKeypair::NistP256 { 10 | public: p256::EncodedPoint::from_affine_coordinates( 11 | &hex!( 12 | "cb244fcdb89de95bc8fd766e6b139abf" 13 | "c2649fb063b6c5e5a939e067e2a0d215" 14 | ) 15 | .into(), 16 | &hex!( 17 | "0a660daca78f6c24a0425373d6ea83e3" 18 | "6f8a1f8b828a60e77a97a9441bcc0987" 19 | ) 20 | .into(), 21 | false, 22 | ), 23 | private: EcdsaPrivateKey::from(p256::SecretKey::new( 24 | p256::elliptic_curve::ScalarPrimitive::new( 25 | p256::elliptic_curve::bigint::Uint::from_be_slice(&hex!( 26 | "ffd9f2ce4d0ee5870d8dc7cf771a7669" 27 | "a0b96fe44bb58a8a0bc75a76b4f78240" 28 | )), 29 | ) 30 | .unwrap(), 31 | )), 32 | } 33 | } 34 | 35 | pub fn demo_certificate() -> Certificate { 36 | let certificate = &hex!( 37 | " 38 | 0000001c7373682d7273612d63657274 2d763031406f70656e7373682e636f6d 39 | 00000020c551bbbb4b7a8cd1f0e5f016 89926b0253d51cd230aec837b6439f86 40 | Ad4f9b9a000000030100010000018100 e0419157579956319a7f810b747b2518 41 | 7f5ff26556f7ff037b57fa7d5911d55a bd59438d98a2205a87def0805ea6d888 42 | 1f9790a010cbe0a20d6145abac98de4f a3fc0f2b53b8241db205b79e64e0a7cc 43 | " " 44 | D33f9f2cd34ae9d2ce791bc6aabc8fe1 951e37a7af04b3fa0b029710e7e95840 45 | 3c7bf6d40c13b264834f37402ec6630c 486014b68413793db3340bceb6aa4c70 46 | 3170048b59c944c52678f91f872d1696 19eb39066bc78021925efd226113f252 47 | 3ecbefdaf5caa85336b760e7e458f7ab d1af48917a778805535dcf45345b2ed4 48 | C4aab2286bd12f381173e856e95929ac 27515608606f07ff8514188e2e9b14c8 49 | " " 50 | 22cfd8ce12946f2b562c3f51b4a86317 ebce585a832af467f8ea27fd3ed1aa59 51 | D187825e9e771ad8c383f6fdef2853ed 22579bc00a7fcf52d9906d25dcd5e80a 52 | E35115aeb4bcba671fa865c26bde4627 2806c4991fc9d548878d2b99ba522083 53 | B8863d7c434c21bd42da838ed0355ad2 fde62e8d0684bcc194f2911f235c85ff 54 | D3b2b4870e95460a2d3422130ccecf61 00000000000000010000000100000006 55 | " " 56 | 64617272656e0000000a000000066461 7272656e00000000660f5cc400000000 57 | 660f6b3c000000000000008200000015 7065726d69742d5831312d666f727761 58 | 7264696e670000000000000017706572 6d69742d6167656e742d666f72776172 59 | 64696e6700000000000000167065726d 69742d706f72742d666f727761726469 60 | 6e67000000000000000a7065726d6974 2d707479000000000000000e7065726d 61 | " " 62 | 69742d757365722d7263000000000000 0000000000330000000b7373682d6564 63 | 323535313900000020dc83ccfc6ef848 8b329f736057286325de5905237e55d7 64 | 711e0a0a8d792ce2cb00000053000000 0b7373682d6564323535313900000040 65 | 01f88ec5a9f1cbd54c1668b3e33ac6f5 2c32dff0c51207fbb55a55b88b8809c3 66 | 69e9ac008e3228dd90978ff2d6bebd9b bb392883bcb56d9f81f6afc200ce2703 67 | " 68 | )[..]; 69 | let mut reader = certificate; 70 | Certificate::decode(&mut reader).unwrap() 71 | } 72 | -------------------------------------------------------------------------------- /tests/roundtrip/expected/macros.rs: -------------------------------------------------------------------------------- 1 | //! 2 | //! The macro in this file takes 3 arguments: 3 | //! 1. The name of the function to generate 4 | //! 2. The return type of the function 5 | //! 3. A brace-enclosed, comma-separated list of module 6 | //! names 7 | //! 8 | //! Each module in (3) should match the basename of a file in `tests/messages` but with dashes 9 | //! replaced by underscores to make it a valid Rust identifier. When the generated function is 10 | //! called with a filename that matches one of these modules, it will call the `expected()` 11 | //! function of that module, which should return an object of the return type specified in the 12 | //! macro, which will be returned as Some(expected()). If the generated function is called with a 13 | //! filename that does not match any module, it will return `None`. 14 | //! 15 | //! # Example # 16 | //! 17 | //! ## Macro call ## 18 | //! 19 | //! ```ignore 20 | //! use ssh_agent_lib::proto::Request; 21 | //! 22 | //! make_expected_fn!(get_expected_request -> Request, { 23 | //! req_hello 24 | //! }); 25 | //! ``` 26 | //! 27 | //! ## Usage of generated function in test code ## 28 | //! 29 | //! ```ignore 30 | //! let test_data_path = PathBuf::from("test/messages/req-hello.bin"); 31 | //! if let Some(expected) = path::to::get_expected_request(&test_data_path) { 32 | //! assert_eq!(expected, ...); 33 | //! } 34 | //! ``` 35 | //! 36 | //! ## `path/to/req_hello.rs` ## 37 | //! 38 | //! ```ignore 39 | //! pub fn expected() -> Request { 40 | //! ... 41 | //! } 42 | //! ``` 43 | 44 | macro_rules! make_expected_fn { 45 | ($fn_name:ident -> $ty:ty, { $($case:ident),+ } ) => { 46 | $( mod $case; )+ 47 | 48 | pub fn $fn_name(path: impl ::core::convert::AsRef<::std::path::Path>) -> ::core::option::Option<$ty> { 49 | let cases: &[(&str, &dyn ::core::ops::Fn() -> $ty)] = &[ 50 | $( (::core::stringify!($case), &self::$case::expected,) ),+ 51 | ]; 52 | 53 | let path_case_name = path 54 | .as_ref() 55 | .file_stem() 56 | .expect("test path has no filename") 57 | .to_str() 58 | .expect("test filename not UTF-8") 59 | .replace("-", "_"); 60 | 61 | cases 62 | .into_iter() 63 | .find(|(c, _)| c == &path_case_name) 64 | .map(|(_, f)| f()) 65 | } 66 | }; 67 | } 68 | -------------------------------------------------------------------------------- /tests/roundtrip/expected/req_add_identity_constrained_extension_restrict_destination.rs: -------------------------------------------------------------------------------- 1 | use hex_literal::hex; 2 | use ssh_agent_lib::proto::{AddIdentity, AddIdentityConstrained, Credential, Extension, KeyConstraint, Request, Unparsed}; 3 | use ssh_key::private::KeypairData; 4 | 5 | use super::fixtures; 6 | 7 | pub fn expected() -> Request { 8 | Request::AddIdConstrained(AddIdentityConstrained { 9 | identity: AddIdentity { 10 | credential: Credential::Key { 11 | privkey: KeypairData::Ecdsa(fixtures::demo_key()), 12 | comment: "baloo@angela".to_string(), 13 | }, 14 | }, 15 | constraints: vec![KeyConstraint::Extension(Extension { 16 | name: "restrict-destination-v00@openssh.com".to_string(), 17 | details: Unparsed::from( 18 | hex!( 19 | " 00 20 | 0002 6f00 0000 0c00 0000 0000 0000 0000 21 | 0000 0000 0002 5700 0000 0000 0000 0a67 22 | 6974 6875 622e 636f 6d00 0000 0000 0000 23 | 3300 0000 0b73 7368 2d65 6432 3535 3139 24 | 0000 0020 e32a aa79 15ce b9b4 49d1 ba50 25 | ea2a 28bb 1a6e 01f9 0bda 245a 2d1d 8769 26 | 7d18 a265 0000 0001 9700 0000 0773 7368 27 | 2d72 7361 0000 0003 0100 0100 0001 8100 28 | a3ee 774d c50a 3081 c427 8ec8 5c2e ba8f 29 | 1228 a986 7b7e 5534 ef0c fea6 1c12 fd8f 30 | 568d 5246 3851 ed60 bf09 c62d 594e 8467 31 | 98ae 765a 3204 4aeb e3ca 0945 da0d b0bb 32 | aad6 d6f2 0224 84be da18 2b0e aff0 b9e9 33 | 224c cbf0 4265 fc5d d675 b300 ec52 0cf8 34 | 15b2 67ab 3816 1f36 a96d 57df e158 2a81 35 | cb02 0d21 1fb9 7488 3a25 327b da97 04a4 36 | 48dc 6205 e413 6604 1575 7524 79ec 2a06 37 | cb58 d961 49ca 9bd9 49b2 4644 32ca d44b 38 | b4bf b7f1 31b1 9310 9f96 63be e59f 0249 39 | 2358 ec68 9d8c c219 ed0e 3332 3036 9f59 40 | c6ae 54c3 933c 030a cc3e c2a1 4f19 0035 41 | efd7 277c 658e 5915 6bba 3d7a cfa5 f2bf 42 | 1be3 2706 f3d3 0419 ef95 cae6 d292 6fb1 43 | 4dc9 e204 b384 d3e2 393e 4b87 613d e014 44 | 0b9c be6c 3622 ad88 0ce0 60bb b849 f3b6 45 | 7672 6955 90ec 1dfc d402 b841 daf0 b79d 46 | 59a8 4c4a 6d0a 5350 d9fe 123a a84f 0bea 47 | 363e 24ab 1e50 5022 344e 14bf 6243 b124 48 | 25e6 3d45 996e 18e9 0a0e 7a8b ed9a 07a0 49 | a62b 6246 867e 7b2b 99a3 d0c3 5d05 7038 50 | fd69 f01f a5e8 3d62 732b 9372 bb6c c1de 51 | 7019 a7e4 b986 942c fa9d 6f37 5ff0 b239 52 | 0000 0000 6800 0000 1365 6364 7361 2d73 53 | 6861 322d 6e69 7374 7032 3536 0000 0008 54 | 6e69 7374 7032 3536 0000 0041 0449 8a48 55 | 4363 4047 b33a 6c64 64cc bba2 92a0 c050 56 | 7d9e 4b79 611a d832 336e 1b93 7cee e460 57 | 83a0 8bad ba39 c007 53ff 2eaf d262 95d1 58 | 4db0 d166 7660 1ffe f93a 6872 4800 0000 59 | 0000" 60 | ) 61 | .to_vec(), 62 | ), 63 | })], 64 | }) 65 | } 66 | -------------------------------------------------------------------------------- /tests/roundtrip/expected/req_add_identity_constrained_lifetime.rs: -------------------------------------------------------------------------------- 1 | use ssh_agent_lib::proto::{AddIdentity, AddIdentityConstrained, Credential, KeyConstraint, Request}; 2 | use ssh_key::private::KeypairData; 3 | 4 | use super::fixtures; 5 | 6 | pub fn expected() -> Request { 7 | Request::AddIdConstrained(AddIdentityConstrained { 8 | identity: AddIdentity { 9 | credential: Credential::Key { 10 | privkey: KeypairData::Ecdsa(fixtures::demo_key()), 11 | comment: "baloo@angela".to_string(), 12 | }, 13 | }, 14 | constraints: vec![KeyConstraint::Lifetime(2)], 15 | }) 16 | } 17 | -------------------------------------------------------------------------------- /tests/roundtrip/expected/req_add_identity_ecdsa.rs: -------------------------------------------------------------------------------- 1 | use ssh_agent_lib::proto::{AddIdentity, Credential, Request}; 2 | use ssh_key::private::KeypairData; 3 | 4 | use super::fixtures; 5 | 6 | pub fn expected() -> Request { 7 | Request::AddIdentity(AddIdentity { 8 | credential: Credential::Key { 9 | privkey: KeypairData::Ecdsa(fixtures::demo_key()), 10 | comment: "baloo@angela".to_string(), 11 | } 12 | }) 13 | } 14 | -------------------------------------------------------------------------------- /tests/roundtrip/expected/req_parse_certificates.rs: -------------------------------------------------------------------------------- 1 | use hex_literal::hex; 2 | use ssh_agent_lib::proto::{AddIdentity, AddIdentityConstrained, Credential, KeyConstraint, PrivateKeyData, Request,}; 3 | use ssh_key::{ 4 | private::RsaPrivateKey, Algorithm, Mpint 5 | }; 6 | 7 | use super::fixtures; 8 | 9 | pub fn expected() -> Request { 10 | Request::AddIdConstrained(AddIdentityConstrained { 11 | identity: AddIdentity { 12 | credential: Credential::Cert { 13 | algorithm: Algorithm::new("ssh-rsa").unwrap(), 14 | certificate: fixtures::demo_certificate(), 15 | privkey: PrivateKeyData::Rsa(RsaPrivateKey { 16 | d: Mpint::from_bytes(&hex!( 17 | " 18 | 063980B05C8B42329056DE1F025EB78D 68FDF1B2631811302C75913B86E81B28 19 | 8C975E6BFF04CF464705A2CE23DE7085 C2FF79E75CFEFD393F4B0420253B5526 20 | 9F9307CC627B8AC6579C5FB3DBF9C5C3 9658A28557E83132419A98491EF0AAE3 21 | 5A785937F0785E5AE430C83EDB0A91B9 5EFA6B840851A8C4C025B00752330DD1 22 | 53BE15BE190F79B0D31548877E5FCECD 498C8206488DC0F8C25216DB63850E86 23 | A82194AAA94DC3585F35CF73BB8F4645 66D6821DE52F18D5EE37A7E718E228AD 24 | F314668DB1285EEA7E34FA71E9FF787E EAC0BF3F97D038A5DD9ECF6A9782A6D1 25 | 354F5A74BE42C6CD15AAF6EFA77E0601 8E0A8D90DCAFFAC60972A58E39E27732 26 | 69AB3AC30D352D66586CF8E19A821B29 016B0F75AAAAD7CAF17ED4913665999F 27 | E491E0BD2C08141DAFEEB08BFE5BEDEA 52AB46E33851DEF2204462B59FA83F85 28 | 3D1E3645C6B7E4D8E4D95FE3B74E34FE 3E37C53D026BE9C19643AB4014BB82EF 29 | 922208AF68435BDC89BDBE0518655BB3 EA28078BEBB7BDE88FF44970181BD381 30 | " 31 | )) 32 | .unwrap(), 33 | iqmp: Mpint::from_bytes(&hex!( 34 | " 35 | 00E0DD19B95C563D9198F0F4E4B19677 FD17465875757DA008B93C0138FD89D7 36 | 1A1F5669D967B69814462530642A5595 DE4EE39A838AC8D38136CC2C20F7A7E6 37 | 2BBBA10146A35A2B8FBA51B70A0B1A43 B43FD26B84AE5A7D1EF7857EAB7B2301 38 | 0C1D35C3CC1C781407F45875684A63A2 5A3F71FD32F0984DAB7B70FEBADB1FE4 39 | 4395F80A228F46F3F7DD05205D453C40 4D88712D2051CFAC3A33E888A6FEA26B 40 | 332F5AC58EDFAD6A64CB16E39280AACC 607D32F90FB6FE45B21BD288FE9D4FC6 41 | B2" 42 | )) 43 | .unwrap(), 44 | p: Mpint::from_bytes(&hex!( 45 | " 46 | 00FABA9137F37DC9AB8B2821CE0C444 B03F5EA6EA5059488214ECCCC02417C 47 | 601E32E923710D2DC1417BFE293502A ED390EB93E544A51FD4686B4B520E49 48 | F559E259B9CD1C2E08E41CFB36B4979 BD5F4F6917D73AEB4A47D7CFC7114EC 49 | 7773AEC5A54B0CDC4244CDD1DB8ACC8 C98955BF1ABBE35DB3DC7F540FF8A85 50 | 8A61399001F0F9C4C440DE7A50AB1A5 5FF1BB24F3ECDBA42CA8A34A83BC76F 51 | FC5687D9093BA4EBA91723B9AE5ACDC FC650D8D95B5E8FDA85CE957075079D 52 | 2A134F4ED9B181" 53 | )) 54 | .unwrap(), 55 | q: Mpint::from_bytes(&hex!( 56 | " 57 | 00E4F88607532262EAF1DB3F11D0253 5C32A7506ACB9BCD2B3E9B852A71FEA 58 | 134921015399BE8830DB4000B7F33EC 3AF71B56448178BD4D3310AD322855C 59 | 80AFF5BF29FBEEBDBBB09A3F09CD5FC 017F0D004C08C3F569E4EFC15C5FA94 60 | 74E0BAE15E7B416CA5BD0F053D869F3 908BC042BD111AF7FC597EF541F7014 61 | 0CCDBAE1D5BC781D3DC14B3A113F939 F1DA21D2031D4F37805D36FC420A728 62 | FFBEED8E1E1DDB8D4D232DF1E02A152 965694139F38B5A60B9198C513AC733 63 | F51F2C04164DE1" 64 | )) 65 | .unwrap(), 66 | }), 67 | comment: "baloo@angela".to_string(), 68 | }, 69 | }, 70 | constraints: vec![KeyConstraint::Lifetime(2)], 71 | }) 72 | } 73 | -------------------------------------------------------------------------------- /tests/roundtrip/expected/resp_parse_identities.rs: -------------------------------------------------------------------------------- 1 | use ssh_agent_lib::proto::{Identity, Response}; 2 | use ssh_key::public::KeyData; 3 | 4 | use super::fixtures; 5 | 6 | pub fn expected() -> Response { 7 | Response::IdentitiesAnswer(vec![Identity { 8 | pubkey: KeyData::Ecdsa(fixtures::demo_key().into()), 9 | comment: "baloo@angela".to_string(), 10 | }]) 11 | } 12 | -------------------------------------------------------------------------------- /tests/roundtrip/main.rs: -------------------------------------------------------------------------------- 1 | mod expected; 2 | 3 | use std::path::{Path, PathBuf}; 4 | 5 | use rstest::rstest; 6 | use ssh_agent_lib::proto::{Request, Response}; 7 | use ssh_encoding::{Decode, Encode}; 8 | use testresult::TestResult; 9 | 10 | fn roundtrip(path: impl AsRef, expected: Option) -> TestResult 11 | where 12 | T: Decode + Encode + PartialEq + std::fmt::Debug, 13 | T::Error: std::fmt::Display, 14 | { 15 | let serialized = std::fs::read(path)?; 16 | let mut bytes: &[u8] = &serialized; 17 | let message = T::decode(&mut bytes)?; 18 | eprintln!("Message: {message:#?}"); 19 | if let Some(expected) = expected { 20 | eprintln!("Expected: {expected:#?}"); 21 | assert_eq!( 22 | expected, message, 23 | "parsed message does not match expected object" 24 | ); 25 | } 26 | let mut out = vec![]; 27 | message.encode(&mut out)?; 28 | assert_eq!( 29 | serialized, out, 30 | "roundtripped message should be exactly identical to saved sample" 31 | ); 32 | assert_eq!( 33 | out.len(), 34 | message.encoded_len()?, 35 | "the encoded message length should be equal to saved sample" 36 | ); 37 | Ok(()) 38 | } 39 | 40 | #[rstest] 41 | fn roundtrip_requests(#[files("tests/messages/req-*.bin")] path: PathBuf) -> TestResult { 42 | roundtrip::(&path, expected::request(&path)) 43 | } 44 | 45 | #[rstest] 46 | fn roundtrip_responses(#[files("tests/messages/resp-*.bin")] path: PathBuf) -> TestResult { 47 | roundtrip::(&path, expected::response(&path)) 48 | } 49 | -------------------------------------------------------------------------------- /tests/sign-and-verify-win.bat: -------------------------------------------------------------------------------- 1 | rem del /F /Q Cargo.toml.sig id_rsa id_rsa.pub agent.pub 2 | 3 | cmd /c "START /b cargo run --example key-storage" 4 | 5 | @echo off 6 | :waitloop 7 | IF EXIST "server-started" GOTO waitloopend 8 | rem timeout doesn't work in github actions so introduce delay some other way 9 | rem see https://stackoverflow.com/a/75054929 10 | ping localhost >nul 11 | goto waitloop 12 | :waitloopend 13 | @echo on 14 | 15 | ssh-keygen -t rsa -f id_rsa -N "" 16 | set SSH_AUTH_SOCK=\\.\pipe\agent 17 | ssh-add id_rsa 18 | ssh-add -L | tee agent.pub 19 | 20 | ssh-keygen -Y sign -f agent.pub -n file < Cargo.toml > Cargo.toml.sig 21 | if %errorlevel% neq 0 exit /b %errorlevel% 22 | 23 | ssh-keygen -Y check-novalidate -n file -f agent.pub -s Cargo.toml.sig < Cargo.toml 24 | if %errorlevel% neq 0 exit /b %errorlevel% 25 | 26 | rem del /F /Q Cargo.toml.sig id_rsa id_rsa.pub agent.pub 27 | 28 | rem run the examples 29 | cargo run --example ssh-agent-client 30 | if %errorlevel% neq 0 exit /b %errorlevel% 31 | 32 | cargo run --example ssh-agent-client-blocking 33 | if %errorlevel% neq 0 exit /b %errorlevel% 34 | -------------------------------------------------------------------------------- /tests/sign-and-verify.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | set -euxo pipefail 4 | 5 | rm -rf ssh-agent.sock Cargo.toml.sig id_rsa id_rsa.pub agent.pub ca_user_key ca_user_key.pub id_rsa-cert.pub 6 | RUST_LOG=info cargo run --example key-storage & 7 | 8 | while [ ! -e ssh-agent.sock ]; do 9 | echo "Waiting for ssh-agent.sock" 10 | sleep 1 11 | done 12 | 13 | ssh-keygen -t rsa -f id_rsa -N "" 14 | export SSH_AUTH_SOCK=ssh-agent.sock 15 | ssh-add id_rsa 16 | ssh-add -L | tee agent.pub 17 | ssh-keygen -Y sign -f agent.pub -n file < Cargo.toml > Cargo.toml.sig 18 | ssh-keygen -Y check-novalidate -n file -f agent.pub -s Cargo.toml.sig < Cargo.toml 19 | 20 | rm -rf Cargo.toml.sig agent.pub 21 | 22 | # Test other commands: 23 | export SSH_ASKPASS=`pwd`/tests/pwd-test.sh 24 | # AddSmartcardKey 25 | echo | ssh-add -s test 26 | # AddSmartcardKeyConstrained 27 | echo | ssh-add -c -t 4 -s test 28 | # Lock 29 | echo | ssh-add -x 30 | # Unlock 31 | echo | ssh-add -X 32 | # AddIdConstrained 33 | ssh-add -t 2 id_rsa 34 | 35 | rm -rf id_rsa id_rsa.pub 36 | 37 | # Create and sign SSH user certificate 38 | # see: https://cottonlinux.com/ssh-certificates/ 39 | echo | ssh-keygen -f ca_user_key 40 | ssh-keygen -t rsa -f id_rsa -N "" 41 | echo | ssh-keygen -s ca_user_key -I darren -n darren -V +1h -z 1 id_rsa.pub 42 | # Add the key with the cert 43 | if [ $(ssh-add -h 2>&1 | grep -ic hostkey_file) -eq 1 ]; then 44 | # has support for RestrictDestination constraint (ubuntu) 45 | ssh-add -t 2 -H tests/known_hosts -h github.com id_rsa 46 | else 47 | # does not support RestrictDestination constraint (macos) 48 | ssh-add -t 2 id_rsa 49 | fi 50 | 51 | # clean up the only leftover 52 | rm -rf id_rsa id_rsa.pub id_rsa-cert.pub ca_user_key ca_user_key.pub 53 | 54 | # run the examples 55 | cargo run --example ssh-agent-client 56 | cargo run --example ssh-agent-client-blocking 57 | --------------------------------------------------------------------------------