├── .gitignore ├── LICENSE ├── Pipfile ├── Pipfile.lock ├── README.adoc ├── setup.py └── yubihsm_ssh_tool ├── __init__.py ├── __main__.py ├── request.py ├── template.py └── validity.py /.gitignore: -------------------------------------------------------------------------------- 1 | *.pyc 2 | *.egg 3 | *.egg-info 4 | build/ 5 | .eggs/ 6 | *.pub 7 | *.pem 8 | *.dat 9 | id_rsa* 10 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Apache License 2 | 3 | Version 2.0, January 2004 4 | 5 | http://www.apache.org/licenses/ 6 | 7 | TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION 8 | 9 | 1. Definitions. 10 | 11 | "License" shall mean the terms and conditions for use, reproduction, 12 | and distribution as defined by Sections 1 through 9 of this document. 13 | 14 | "Licensor" shall mean the copyright owner or entity authorized by the 15 | copyright owner that is granting the License. 16 | 17 | "Legal Entity" shall mean the union of the acting entity and all 18 | other entities that control, are controlled by, or are under common 19 | control with that entity. For the purposes of this definition, 20 | "control" means (i) the power, direct or indirect, to cause the 21 | direction or management of such entity, whether by contract or 22 | otherwise, or (ii) ownership of fifty percent (50%) or more of the 23 | outstanding shares, or (iii) beneficial ownership of such entity. 24 | 25 | "You" (or "Your") shall mean an individual or Legal Entity exercising 26 | permissions granted by this License. 27 | 28 | "Source" form shall mean the preferred form for making modifications, 29 | including but not limited to software source code, documentation 30 | source, and configuration files. 31 | 32 | "Object" form shall mean any form resulting from mechanical 33 | transformation or translation of a Source form, including but not 34 | limited to compiled object code, generated documentation, and 35 | conversions to other media types. 36 | 37 | "Work" shall mean the work of authorship, whether in Source or Object 38 | form, made available under the License, as indicated by a copyright 39 | notice that is included in or attached to the work (an example is 40 | provided in the Appendix below). 41 | 42 | "Derivative Works" shall mean any work, whether in Source or Object 43 | form, that is based on (or derived from) the Work and for which the 44 | editorial revisions, annotations, elaborations, or other modifications 45 | represent, as a whole, an original work of authorship. For the 46 | purposes of this License, Derivative Works shall not include works 47 | that remain separable from, or merely link (or bind by name) to the 48 | interfaces of, the Work and Derivative Works thereof. 49 | 50 | "Contribution" shall mean any work of authorship, including the 51 | original version of the Work and any modifications or additions to 52 | that Work or Derivative Works thereof, that is intentionally submitted 53 | to Licensor for inclusion in the Work by the copyright owner or by an 54 | individual or Legal Entity authorized to submit on behalf of the 55 | copyright owner. For the purposes of this definition, "submitted" 56 | means any form of electronic, verbal, or written communication sent to 57 | the Licensor or its representatives, including but not limited to 58 | communication on electronic mailing lists, source code control 59 | systems, and issue tracking systems that are managed by, or on behalf 60 | of, the Licensor for the purpose of discussing and improving the Work, 61 | but excluding communication that is conspicuously marked or otherwise 62 | designated in writing by the copyright owner as "Not a Contribution." 63 | 64 | "Contributor" shall mean Licensor and any individual or Legal Entity 65 | on behalf of whom a Contribution has been received by Licensor and 66 | subsequently incorporated within the Work. 67 | 68 | 2. Grant of Copyright License. Subject to the terms and conditions of 69 | this License, each Contributor hereby grants to You a perpetual, 70 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 71 | copyright license to reproduce, prepare Derivative Works of, publicly 72 | display, publicly perform, sublicense, and distribute the Work and 73 | such Derivative Works in Source or Object form. 74 | 75 | 3. Grant of Patent License. Subject to the terms and conditions of 76 | this License, each Contributor hereby grants to You a perpetual, 77 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable (except 78 | as stated in this section) patent license to make, have made, use, 79 | offer to sell, sell, import, and otherwise transfer the Work, where 80 | such license applies only to those patent claims licensable by such 81 | Contributor that are necessarily infringed by their Contribution(s) 82 | alone or by combination of their Contribution(s) with the Work to 83 | which such Contribution(s) was submitted. If You institute patent 84 | litigation against any entity (including a cross-claim or counterclaim 85 | in a lawsuit) alleging that the Work or a Contribution incorporated 86 | within the Work constitutes direct or contributory patent 87 | infringement, then any patent licenses granted to You under this 88 | License for that Work shall terminate as of the date such litigation 89 | is filed. 90 | 91 | 4. Redistribution. You may reproduce and distribute copies of the Work 92 | or Derivative Works thereof in any medium, with or without 93 | modifications, and in Source or Object form, provided that You meet 94 | the following conditions: 95 | 96 | You must give any other recipients of the Work or Derivative Works 97 | a copy of this License; and 98 | 99 | You must cause any modified files to carry prominent notices 100 | stating that You changed the files; and 101 | 102 | You must retain, in the Source form of any Derivative Works that 103 | You distribute, all copyright, patent, trademark, and attribution 104 | notices from the Source form of the Work, excluding those notices 105 | that do not pertain to any part of the Derivative Works; and 106 | 107 | If the Work includes a "NOTICE" text file as part of its 108 | distribution, then any Derivative Works that You distribute must 109 | include a readable copy of the attribution notices contained 110 | within such NOTICE file, excluding those notices that do not 111 | pertain to any part of the Derivative Works, in at least one of 112 | the following places: within a NOTICE text file distributed as 113 | part of the Derivative Works; within the Source form or 114 | documentation, if provided along with the Derivative Works; or, 115 | within a display generated by the Derivative Works, if and 116 | wherever such third-party notices normally appear. The contents of 117 | the NOTICE file are for informational purposes only and do not 118 | modify the License. You may add Your own attribution notices 119 | within Derivative Works that You distribute, alongside or as an 120 | addendum to the NOTICE text from the Work, provided that such 121 | additional attribution notices cannot be construed as modifying 122 | the License. 123 | 124 | You may add Your own copyright statement to Your modifications and 125 | may provide additional or different license terms and conditions 126 | for use, reproduction, or distribution of Your modifications, or 127 | for any such Derivative Works as a whole, provided Your use, 128 | reproduction, and distribution of the Work otherwise complies with 129 | the conditions stated in this License. 130 | 131 | 5. Submission of Contributions. Unless You explicitly state otherwise, 132 | any Contribution intentionally submitted for inclusion in the Work by 133 | You to the Licensor shall be under the terms and conditions of this 134 | License, without any additional terms or conditions. Notwithstanding 135 | the above, nothing herein shall supersede or modify the terms of any 136 | separate license agreement you may have executed with Licensor 137 | regarding such Contributions. 138 | 139 | 6. Trademarks. This License does not grant permission to use the trade 140 | names, trademarks, service marks, or product names of the Licensor, 141 | except as required for reasonable and customary use in describing the 142 | origin of the Work and reproducing the content of the NOTICE file. 143 | 144 | 7. Disclaimer of Warranty. Unless required by applicable law or agreed 145 | to in writing, Licensor provides the Work (and each Contributor 146 | provides its Contributions) on an "AS IS" BASIS, WITHOUT WARRANTIES OR 147 | CONDITIONS OF ANY KIND, either express or implied, including, without 148 | limitation, any warranties or conditions of TITLE, NON-INFRINGEMENT, 149 | MERCHANTABILITY, or FITNESS FOR A PARTICULAR PURPOSE. You are solely 150 | responsible for determining the appropriateness of using or 151 | redistributing the Work and assume any risks associated with Your 152 | exercise of permissions under this License. 153 | 154 | 8. Limitation of Liability. In no event and under no legal theory, 155 | whether in tort (including negligence), contract, or otherwise, unless 156 | required by applicable law (such as deliberate and grossly negligent 157 | acts) or agreed to in writing, shall any Contributor be liable to You 158 | for damages, including any direct, indirect, special, incidental, or 159 | consequential damages of any character arising as a result of this 160 | License or out of the use or inability to use the Work (including but 161 | not limited to damages for loss of goodwill, work stoppage, computer 162 | failure or malfunction, or any and all other commercial damages or 163 | losses), even if such Contributor has been advised of the possibility 164 | of such damages. 165 | 166 | 9. Accepting Warranty or Additional Liability. While redistributing 167 | the Work or Derivative Works thereof, You may choose to offer, and 168 | charge a fee for, acceptance of support, warranty, indemnity, or other 169 | liability obligations and/or rights consistent with this License. 170 | However, in accepting such obligations, You may act only on Your own 171 | behalf and on Your sole responsibility, not on behalf of any other 172 | Contributor, and only if You agree to indemnify, defend, and hold each 173 | Contributor harmless for any liability incurred by, or claims asserted 174 | against, such Contributor by reason of your accepting any such 175 | warranty or additional liability. 176 | 177 | END OF TERMS AND CONDITIONS 178 | -------------------------------------------------------------------------------- /Pipfile: -------------------------------------------------------------------------------- 1 | [[source]] 2 | url = "https://pypi.org/simple" 3 | verify_ssl = true 4 | name = "pypi" 5 | 6 | [packages] 7 | yubihsm-ssh-tool = {editable = true, path = "."} 8 | 9 | [dev-packages] 10 | -------------------------------------------------------------------------------- /Pipfile.lock: -------------------------------------------------------------------------------- 1 | { 2 | "_meta": { 3 | "hash": { 4 | "sha256": "5f26ee7c9d8e8a3a35fa61c4602bc343eb930818f603079a6838183ec0016bd4" 5 | }, 6 | "pipfile-spec": 6, 7 | "requires": {}, 8 | "sources": [ 9 | { 10 | "name": "pypi", 11 | "url": "https://pypi.org/simple", 12 | "verify_ssl": true 13 | } 14 | ] 15 | }, 16 | "default": { 17 | "asn1crypto": { 18 | "hashes": [ 19 | "sha256:2f1adbb7546ed199e3c90ef23ec95c5cf3585bac7d11fb7eb562a3fe89c64e87", 20 | "sha256:9d5c20441baf0cb60a4ac34cc447c6c189024b6b4c6cd7877034f4965c464e49" 21 | ], 22 | "version": "==0.24.0" 23 | }, 24 | "cffi": { 25 | "hashes": [ 26 | "sha256:151b7eefd035c56b2b2e1eb9963c90c6302dc15fbd8c1c0a83a163ff2c7d7743", 27 | "sha256:1553d1e99f035ace1c0544050622b7bc963374a00c467edafac50ad7bd276aef", 28 | "sha256:1b0493c091a1898f1136e3f4f991a784437fac3673780ff9de3bcf46c80b6b50", 29 | "sha256:2ba8a45822b7aee805ab49abfe7eec16b90587f7f26df20c71dd89e45a97076f", 30 | "sha256:3bb6bd7266598f318063e584378b8e27c67de998a43362e8fce664c54ee52d30", 31 | "sha256:3c85641778460581c42924384f5e68076d724ceac0f267d66c757f7535069c93", 32 | "sha256:3eb6434197633b7748cea30bf0ba9f66727cdce45117a712b29a443943733257", 33 | "sha256:495c5c2d43bf6cebe0178eb3e88f9c4aa48d8934aa6e3cddb865c058da76756b", 34 | "sha256:4c91af6e967c2015729d3e69c2e51d92f9898c330d6a851bf8f121236f3defd3", 35 | "sha256:57b2533356cb2d8fac1555815929f7f5f14d68ac77b085d2326b571310f34f6e", 36 | "sha256:770f3782b31f50b68627e22f91cb182c48c47c02eb405fd689472aa7b7aa16dc", 37 | "sha256:79f9b6f7c46ae1f8ded75f68cf8ad50e5729ed4d590c74840471fc2823457d04", 38 | "sha256:7a33145e04d44ce95bcd71e522b478d282ad0eafaf34fe1ec5bbd73e662f22b6", 39 | "sha256:857959354ae3a6fa3da6651b966d13b0a8bed6bbc87a0de7b38a549db1d2a359", 40 | "sha256:87f37fe5130574ff76c17cab61e7d2538a16f843bb7bca8ebbc4b12de3078596", 41 | "sha256:95d5251e4b5ca00061f9d9f3d6fe537247e145a8524ae9fd30a2f8fbce993b5b", 42 | "sha256:9d1d3e63a4afdc29bd76ce6aa9d58c771cd1599fbba8cf5057e7860b203710dd", 43 | "sha256:a36c5c154f9d42ec176e6e620cb0dd275744aa1d804786a71ac37dc3661a5e95", 44 | "sha256:a6a5cb8809091ec9ac03edde9304b3ad82ad4466333432b16d78ef40e0cce0d5", 45 | "sha256:ae5e35a2c189d397b91034642cb0eab0e346f776ec2eb44a49a459e6615d6e2e", 46 | "sha256:b0f7d4a3df8f06cf49f9f121bead236e328074de6449866515cea4907bbc63d6", 47 | "sha256:b75110fb114fa366b29a027d0c9be3709579602ae111ff61674d28c93606acca", 48 | "sha256:ba5e697569f84b13640c9e193170e89c13c6244c24400fc57e88724ef610cd31", 49 | "sha256:be2a9b390f77fd7676d80bc3cdc4f8edb940d8c198ed2d8c0be1319018c778e1", 50 | "sha256:ca1bd81f40adc59011f58159e4aa6445fc585a32bb8ac9badf7a2c1aa23822f2", 51 | "sha256:d5d8555d9bfc3f02385c1c37e9f998e2011f0db4f90e250e5bc0c0a85a813085", 52 | "sha256:e55e22ac0a30023426564b1059b035973ec82186ddddbac867078435801c7801", 53 | "sha256:e90f17980e6ab0f3c2f3730e56d1fe9bcba1891eeea58966e89d352492cc74f4", 54 | "sha256:ecbb7b01409e9b782df5ded849c178a0aa7c906cf8c5a67368047daab282b184", 55 | "sha256:ed01918d545a38998bfa5902c7c00e0fee90e957ce036a4000a88e3fe2264917", 56 | "sha256:edabd457cd23a02965166026fd9bfd196f4324fe6032e866d0f3bd0301cd486f", 57 | "sha256:fdf1c1dc5bafc32bc5d08b054f94d659422b05aba244d6be4ddc1c72d9aa70fb" 58 | ], 59 | "version": "==1.11.5" 60 | }, 61 | "cryptography": { 62 | "hashes": [ 63 | "sha256:02602e1672b62e803e08617ec286041cc453e8d43f093a5f4162095506bc0beb", 64 | "sha256:10b48e848e1edb93c1d3b797c83c72b4c387ab0eb4330aaa26da8049a6cbede0", 65 | "sha256:17db09db9d7c5de130023657be42689d1a5f60502a14f6f745f6f65a6b8195c0", 66 | "sha256:227da3a896df1106b1a69b1e319dce218fa04395e8cc78be7e31ca94c21254bc", 67 | "sha256:2cbaa03ac677db6c821dac3f4cdfd1461a32d0615847eedbb0df54bb7802e1f7", 68 | "sha256:31db8febfc768e4b4bd826750a70c79c99ea423f4697d1dab764eb9f9f849519", 69 | "sha256:4a510d268e55e2e067715d728e4ca6cd26a8e9f1f3d174faf88e6f2cb6b6c395", 70 | "sha256:6a88d9004310a198c474d8a822ee96a6dd6c01efe66facdf17cb692512ae5bc0", 71 | "sha256:76936ec70a9b72eb8c58314c38c55a0336a2b36de0c7ee8fb874a4547cadbd39", 72 | "sha256:7e3b4aecc4040928efa8a7cdaf074e868af32c58ffc9bb77e7bf2c1a16783286", 73 | "sha256:8168bcb08403ef144ff1fb880d416f49e2728101d02aaadfe9645883222c0aa5", 74 | "sha256:8229ceb79a1792823d87779959184a1bf95768e9248c93ae9f97c7a2f60376a1", 75 | "sha256:8a19e9f2fe69f6a44a5c156968d9fc8df56d09798d0c6a34ccc373bb186cee86", 76 | "sha256:8d10113ca826a4c29d5b85b2c4e045ffa8bad74fb525ee0eceb1d38d4c70dfd6", 77 | "sha256:be495b8ec5a939a7605274b6e59fbc35e76f5ad814ae010eb679529671c9e119", 78 | "sha256:dc2d3f3b1548f4d11786616cf0f4415e25b0fbecb8a1d2cd8c07568f13fdde38", 79 | "sha256:e4aecdd9d5a3d06c337894c9a6e2961898d3f64fe54ca920a72234a3de0f9cb3", 80 | "sha256:e79ab4485b99eacb2166f3212218dd858258f374855e1568f728462b0e6ee0d9", 81 | "sha256:f995d3667301e1754c57b04e0bae6f0fa9d710697a9f8d6712e8cca02550910f" 82 | ], 83 | "version": "==2.3.1" 84 | }, 85 | "enum34": { 86 | "hashes": [ 87 | "sha256:2d81cbbe0e73112bdfe6ef8576f2238f2ba27dd0d55752a776c41d38b7da2850", 88 | "sha256:644837f692e5f550741432dd3f223bbb9852018674981b1664e5dc339387588a", 89 | "sha256:6bd0f6ad48ec2aa117d3d141940d484deccda84d4fcd884f5c3d93c23ecd8c79", 90 | "sha256:8ad8c4783bf61ded74527bffb48ed9b54166685e4230386a9ed9b1279e2df5b1" 91 | ], 92 | "markers": "python_version < '3'", 93 | "version": "==1.1.6" 94 | }, 95 | "idna": { 96 | "hashes": [ 97 | "sha256:156a6814fb5ac1fc6850fb002e0852d56c0c8d2531923a51032d1b70760e186e", 98 | "sha256:684a38a6f903c1d71d6d5fac066b58d7768af4de2b832e426ec79c30daa94a16" 99 | ], 100 | "version": "==2.7" 101 | }, 102 | "ipaddress": { 103 | "hashes": [ 104 | "sha256:64b28eec5e78e7510698f6d4da08800a5c575caa4a286c93d651c5d3ff7b6794", 105 | "sha256:b146c751ea45cad6188dd6cf2d9b757f6f4f8d6ffb96a023e6f2e26eea02a72c" 106 | ], 107 | "markers": "python_version < '3'", 108 | "version": "==1.0.22" 109 | }, 110 | "pycparser": { 111 | "hashes": [ 112 | "sha256:a988718abfad80b6b157acce7bf130a30876d27603738ac39f140993246b25b3" 113 | ], 114 | "version": "==2.19" 115 | }, 116 | "six": { 117 | "hashes": [ 118 | "sha256:70e8a77beed4562e7f14fe23a786b54f6296e34344c23bc42f07b15018ff98e9", 119 | "sha256:832dc0e10feb1aa2c68dcc57dbb658f1c7e65b9b61af69048abc87a2db00a0eb" 120 | ], 121 | "version": "==1.11.0" 122 | }, 123 | "yubihsm-ssh-tool": { 124 | "editable": true, 125 | "path": "." 126 | } 127 | }, 128 | "develop": {} 129 | } 130 | -------------------------------------------------------------------------------- /README.adoc: -------------------------------------------------------------------------------- 1 | == YubiHSM SSH Tool 2 | 3 | A tool for creating requests/templates for SSH Certificates with 4 | YubiHSM 2. 5 | 6 | This tool helps simplifying the process of creating OpenSSH 7 | Certificates using the 8 | link:https://developers.yubico.com/YubiHSM2/[YubiHSM 2]. 9 | 10 | It has two main functionalities, creating an SSH Template and an SSH 11 | Certificate Request. 12 | 13 | === Initial Setup 14 | 15 | [source, bash] 16 | ---- 17 | pipenv sync 18 | ---- 19 | 20 | === Invoke 21 | [source, bash] 22 | ---- 23 | pipenv run yubihsm-ssh-tool 24 | ---- 25 | 26 | === Example 27 | 28 | This is a quick example about how to generate an SSH Template, load it 29 | onto a YubiHSM, generate an SSH Certificate Request and get that 30 | signed by a YubiHSM to form an SSH Certificates. More information 31 | about this topic can be found in 32 | link:https://developers.yubico.com/YubiHSM2/Usage_Guides/OpenSSH_certificates.html[this] 33 | guide. 34 | 35 | For this example to work, `yubihsm-shell` (with either a 36 | `yubihsm-connector` or direct USB connection), a YubiHSM device, 37 | `OpenSSH` and `OpenSSL` must be available. 38 | 39 | First we want to generate the SSH CA key-pair. This is the key that 40 | will be used to sign the SSH Certificate at the end. In this example 41 | the key will be generated on a computer and imported onto the YubiHSM, 42 | but it could be generated directly in the device. 43 | 44 | [source, bash] 45 | ---- 46 | openssl genrsa -out ca.pem 47 | ---- 48 | 49 | Then we extract the public key and save it to a file. 50 | 51 | [source, bash] 52 | ---- 53 | openssl rsa -pubout -in ca.pem -out ca_pub.pem 54 | ---- 55 | 56 | Optionally, the public key can also be converted to the OpenSSH format 57 | by doing 58 | 59 | [source, bash] 60 | ---- 61 | ssh-keygen -i -f ca_pub.pem -m PKCS8 >ca.pub 62 | ---- 63 | 64 | We can now import this private key into the YubiHSM with Object ID 65 | `10` by running 66 | 67 | [source, bash] 68 | ---- 69 | yubihsm-shell -a put-asymmetric-key -p password -i 10 -l "SSH_CA_Key" -c "sign-ssh-certificate" --in ca.pem 70 | ---- 71 | 72 | The next step is to create an SSH Template. This is a collection of 73 | constraints that limit how the SSH CA key can be used, and whether or 74 | not a specific SSH Certificate Request should be signed. 75 | 76 | Since an SSH Certificate has a fixed validity, signed timestamps are 77 | used to provide the YubiHSM with the notion of `now`. This means that 78 | a timestamp key-pair is necessary. We will create it in the same way 79 | that we did before. 80 | 81 | [source, bash] 82 | ---- 83 | openssl genrsa -out timestamp.pem 84 | ---- 85 | 86 | [source, bash] 87 | ---- 88 | openssl rsa -pubout -in timestamp.pem -out timestamp_pub.pem 89 | ---- 90 | 91 | We can now use `yubihsm-ssh-tool` to generate the SSH Template. This 92 | template will only allow to use the Asymmetric Key with ID `10` to 93 | sign requests, and it will only allow validity intervals that fall in 94 | the range of `now ± 10h` (`36,000s = 10h`) where `now` it the current 95 | time that will be sent along with the SSH Certificate Request. It will 96 | also prevent certificates to be issued to the user `root`. The 97 | template will containt the timestamp public key to verify future 98 | timestamp signatures. The command for this is: 99 | 100 | [source, bash] 101 | ---- 102 | pipenv run yubihsm-ssh-tool templ -T timestamp_pub.pem -k 10 -b 36000 -a 36000 -p root 103 | ---- 104 | 105 | This will result in a file called `templ.dat` that can be imported on 106 | the YubiHSM with Object ID `20`. 107 | 108 | [source, bash] 109 | ---- 110 | yubihsm-shell -a put-template -p password -i 20 -l "SSH_Template" -A template-ssh --in templ.dat 111 | ---- 112 | 113 | Next we will create an SSH Certificate Request. First of all we need 114 | an OpenSSH key-pair, this is the key-pair of the user and what we will 115 | create a certificate for. This key can already exist somewhere, for 116 | example it can be stored on a YubiKey. To make this example easier to 117 | follow, we will generate a new pair of soft keys with the following 118 | command: 119 | 120 | [source, bash] 121 | ---- 122 | ssh-keygen -t rsa -N "" -f ./id_rsa 123 | ---- 124 | 125 | Once we have the key-pair, we can use `yubihsm-ssh-tool` to generate a 126 | request for a certificate issued to the user `username` with a 127 | validity period of `± 5h` from the current time. The timestamp in the 128 | request will be signed using the timestamp private key generated in 129 | one of the previous steps and it will be saved to a file called 130 | `req.dat`. 131 | 132 | [source, bash] 133 | ---- 134 | pipenv run yubihsm-ssh-tool req -s ca_pub.pem -t timestamp.pem -I user-identity -n username -V -5h:+5h id_rsa.pub 135 | ---- 136 | 137 | At this point it is possible to send the request to the YubiHSM to get 138 | it signed and produce an SSH Certificate in the file 139 | `id_rsa-cert.pub`. 140 | 141 | [source, bash] 142 | ---- 143 | yubihsm-shell -a sign-ssh-certificate -p password -i 10 --template-id 20 -A rsa-pkcs1-sha256 --in req.dat --out id_rsa-cert.pub 144 | ---- 145 | 146 | The certificate can then be printed in human-readable form by running 147 | 148 | [source, bash] 149 | ---- 150 | ssh-keygen -Lf id_rsa-cert.pub 151 | ---- 152 | 153 | === License 154 | 155 | .... 156 | Copyright 2015-2018 Yubico AB 157 | 158 | Licensed under the Apache License, Version 2.0 (the "License"); 159 | you may not use this file except in compliance with the License. 160 | You may obtain a copy of the License at 161 | 162 | http://www.apache.org/licenses/LICENSE-2.0 163 | 164 | Unless required by applicable law or agreed to in writing, software 165 | distributed under the License is distributed on an "AS IS" BASIS, 166 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 167 | See the License for the specific language governing permissions and 168 | limitations under the License. 169 | .... 170 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | # Copyright 2016-2018 Yubico AB 2 | # 3 | # Licensed under the Apache License, Version 2.0 (the "License"); 4 | # you may not use this file except in compliance with the License. 5 | # You may obtain a copy of the License at 6 | # 7 | # http://www.apache.org/licenses/LICENSE-2.0 8 | # 9 | # Unless required by applicable law or agreed to in writing, software 10 | # distributed under the License is distributed on an "AS IS" BASIS, 11 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | # See the License for the specific language governing permissions and 13 | # limitations under the License. 14 | 15 | from setuptools import setup 16 | 17 | 18 | setup( 19 | name='yubihsm-ssh-tool', 20 | url='https://developers.yubico.com/YubiHSM2/', 21 | author='Yubico', 22 | packages=['yubihsm_ssh_tool'], 23 | install_requires=['cryptography'], 24 | entry_points={ 25 | 'console_scripts': [ 26 | 'yubihsm-ssh-tool=yubihsm_ssh_tool.__main__:main' 27 | ] 28 | } 29 | ) 30 | -------------------------------------------------------------------------------- /yubihsm_ssh_tool/__init__.py: -------------------------------------------------------------------------------- 1 | # Copyright 2016-2018 Yubico AB 2 | # 3 | # Licensed under the Apache License, Version 2.0 (the "License"); 4 | # you may not use this file except in compliance with the License. 5 | # You may obtain a copy of the License at 6 | # 7 | # http://www.apache.org/licenses/LICENSE-2.0 8 | # 9 | # Unless required by applicable law or agreed to in writing, software 10 | # distributed under the License is distributed on an "AS IS" BASIS, 11 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | # See the License for the specific language governing permissions and 13 | # limitations under the License. 14 | -------------------------------------------------------------------------------- /yubihsm_ssh_tool/__main__.py: -------------------------------------------------------------------------------- 1 | # Copyright 2016-2018 Yubico AB 2 | # 3 | # Licensed under the Apache License, Version 2.0 (the 'License'); 4 | # you may not use this file except in compliance with the License. 5 | # You may obtain a copy of the License at 6 | # 7 | # http://www.apache.org/licenses/LICENSE-2.0 8 | # 9 | # Unless required by applicable law or agreed to in writing, software 10 | # distributed under the License is distributed on an 'AS IS' BASIS, 11 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | # See the License for the specific language governing permissions and 13 | # limitations under the License. 14 | 15 | from __future__ import absolute_import, division 16 | 17 | from cryptography.hazmat.primitives import hashes 18 | from cryptography.hazmat.primitives.asymmetric import padding, utils 19 | from cryptography.hazmat.backends import default_backend 20 | from cryptography.hazmat.primitives import serialization 21 | from binascii import b2a_hex 22 | 23 | import re 24 | import sys 25 | import struct 26 | import argparse 27 | 28 | from .request import create_request 29 | from .template import create_template 30 | from .validity import parse_validity 31 | 32 | 33 | _VALIDITY_DASH = re.compile(r'^-\d+[s|m|h|d|w]') 34 | 35 | 36 | def build_parser(): 37 | parser = argparse.ArgumentParser(prog='yubihsm-ssh-tool') 38 | parser.set_defaults(func=lambda _: parser.print_help()) 39 | 40 | subparsers = parser.add_subparsers() 41 | 42 | parser_req = subparsers.add_parser('req', help='Create an SSH request.') 43 | parser_req.set_defaults(func=req) 44 | 45 | parser_req.add_argument('-s', '--ca', required=True, 46 | help='CA PUBLIC key file, in PEM format.') 47 | parser_req.add_argument('-t', '--timestamp', required=True, 48 | help='Timestamp PRIVATE key file, in PEM format.') 49 | parser_req.add_argument('-I', '--identity', required=True, 50 | help='Certificate identity.') 51 | parser_req.add_argument('-n', '--principals', nargs='+', default=[], 52 | help='List of principals.') 53 | parser_req.add_argument('-O', '--option', help='Certificate option.') 54 | parser_req.add_argument('-V', '--validity', help='Validity interval.') 55 | parser_req.add_argument('-z', '--serial', type=int, default=0, 56 | help='Serial number.') 57 | parser_req.add_argument('public_key', help='Public key file.') 58 | 59 | parser_tplt = subparsers.add_parser('templ', help='Create an SSH template.') 60 | parser_tplt.set_defaults(func=templ) 61 | 62 | parser_tplt.add_argument('-T', '--timestamp', required=True, 63 | help='Timestamp PUBLIC key file, in PEM format.') 64 | parser_tplt.add_argument('-k', '--whitelist', required=True, 65 | nargs='+', help='White-list of key CA key IDs.') 66 | parser_tplt.add_argument('-b', '--before', required=True, 67 | help='Not before offset, in seconds.') 68 | parser_tplt.add_argument('-a', '--after', required=True, 69 | help='Not after offset, in seconds.') 70 | parser_tplt.add_argument('-p', '--blacklist', required=True, 71 | nargs='+', help='Black-list of principals.') 72 | 73 | return parser 74 | 75 | 76 | def main(): 77 | # Correctly parse validity argument that starts with "-". 78 | for pos, val in enumerate(sys.argv): 79 | if _VALIDITY_DASH.match(val): 80 | sys.argv[pos] = ' ' + val 81 | 82 | args = build_parser().parse_args() 83 | args.func(args) 84 | 85 | 86 | def req(args): 87 | with open(args.timestamp, 'rb') as ts_private_key_file: 88 | ts_private_key = serialization.load_pem_private_key( 89 | ts_private_key_file.read(), 90 | password=None, 91 | backend=default_backend() 92 | ) 93 | 94 | with open(args.public_key, 'rb') as user_public_key_file: 95 | user_public_key = serialization.load_ssh_public_key( 96 | user_public_key_file.read(), 97 | backend=default_backend() 98 | ) 99 | 100 | with open(args.ca, 'rb') as ca_public_key_file: 101 | ca_public_key = serialization.load_pem_public_key( 102 | ca_public_key_file.read(), 103 | backend=default_backend() 104 | ) 105 | 106 | now, not_after, not_before = parse_validity(args.validity) 107 | 108 | req = create_request( 109 | ca_public_key, 110 | user_public_key, 111 | args.identity, 112 | args.principals, 113 | args.option, 114 | not_before, 115 | not_after, 116 | args.serial 117 | ) 118 | 119 | # Hash the request 120 | digest = hashes.Hash(hashes.SHA256(), backend=default_backend()) 121 | digest.update(req) 122 | request_hash = digest.finalize() 123 | 124 | print('Hash is:', b2a_hex(request_hash)) 125 | 126 | # Hash request + timestamp for signing 127 | digest = hashes.Hash(hashes.SHA256(), backend=default_backend()) 128 | digest.update(request_hash) 129 | digest.update(struct.pack('!I', now)) 130 | message_hash = digest.finalize() 131 | 132 | signature = ts_private_key.sign( 133 | message_hash, 134 | padding.PKCS1v15(), 135 | utils.Prehashed(hashes.SHA256()) 136 | ) 137 | 138 | with open('req.dat', 'wb') as f: 139 | f.write(struct.pack('!I', now) + signature + req) 140 | 141 | 142 | def templ(args): 143 | with open(args.timestamp, 'rb') as ts_public_key_file: 144 | ts_public_key = serialization.load_pem_public_key( 145 | ts_public_key_file.read(), 146 | backend=default_backend() 147 | ) 148 | 149 | templ = create_template( 150 | ts_public_key, 151 | args.whitelist, 152 | args.before, 153 | args.after, 154 | args.blacklist 155 | ) 156 | 157 | with open('templ.dat', 'wb') as f: 158 | f.write(templ) 159 | 160 | 161 | if __name__ == '__main__': 162 | main() 163 | -------------------------------------------------------------------------------- /yubihsm_ssh_tool/request.py: -------------------------------------------------------------------------------- 1 | from __future__ import absolute_import, division 2 | 3 | import os 4 | import struct 5 | from cryptography.utils import int_to_bytes 6 | 7 | CERT_NAME = b'ssh-rsa-cert-v01@openssh.com' 8 | CERT_TYPE = 1 # 1 = user, 2 = host 9 | CA_KEY_TYPE = b'ssh-rsa' 10 | 11 | 12 | def create_request(ca_public_key, user_public_key, key_id, principals, options, 13 | not_before, not_after, serial): 14 | req = b'' 15 | 16 | req += struct.pack('!I', len(CERT_NAME)) + CERT_NAME 17 | 18 | nonce = os.urandom(32) 19 | req += struct.pack('!I', len(nonce)) + nonce 20 | 21 | numbers = user_public_key.public_numbers() 22 | pubkey_e = int_to_bytes(numbers.e) 23 | pubkey_n = int_to_bytes(numbers.n) 24 | if pubkey_n[0] >= 0x80: 25 | pubkey_n = b'\x00' + pubkey_n 26 | 27 | req += struct.pack('!I', len(pubkey_e)) + pubkey_e 28 | 29 | req += struct.pack('!I', len(pubkey_n)) + pubkey_n 30 | 31 | req += struct.pack('!Q', serial) 32 | 33 | req += struct.pack('!I', CERT_TYPE) 34 | 35 | key_id = key_id.encode('utf8') 36 | req += struct.pack('!I', len(key_id)) + key_id 37 | 38 | # for each principal print principals 39 | # starting with the total length of principal+length pairs 40 | n_principals = len(principals) 41 | total_principals_length = sum(len(s) for s in principals) 42 | 43 | req += struct.pack('!I', (n_principals * 4) + total_principals_length) 44 | 45 | for s in principals: 46 | s = s.encode('utf8') 47 | req += struct.pack('!I', len(s)) + s 48 | 49 | req += struct.pack('!Q', not_after) 50 | 51 | req += struct.pack('!Q', not_before) 52 | 53 | CRITICAL_OPTIONS = b'' # TODO(adma): FIXME 54 | req += CRITICAL_OPTIONS 55 | req += struct.pack('!I', len(CRITICAL_OPTIONS)) 56 | 57 | EXTENSIONS = b'\x00\x00\x00\x15\x70\x65\x72\x6d\x69\x74\x2d\x58\x31\x31\x2d\x66\x6f\x72\x77\x61\x72\x64\x69\x6e\x67\x00\x00\x00\x00\x00\x00\x00\x17\x70\x65\x72\x6d\x69\x74\x2d\x61\x67\x65\x6e\x74\x2d\x66\x6f\x72\x77\x61\x72\x64\x69\x6e\x67\x00\x00\x00\x00\x00\x00\x00\x16\x70\x65\x72\x6d\x69\x74\x2d\x70\x6f\x72\x74\x2d\x66\x6f\x72\x77\x61\x72\x64\x69\x6e\x67\x00\x00\x00\x00\x00\x00\x00\x0a\x70\x65\x72\x6d\x69\x74\x2d\x70\x74\x79\x00\x00\x00\x00\x00\x00\x00\x0e\x70\x65\x72\x6d\x69\x74\x2d\x75\x73\x65\x72\x2d\x72\x63\x00\x00\x00\x00' # noqa TODO(adma): FIXME 58 | req += struct.pack('!I', len(EXTENSIONS)) + EXTENSIONS 59 | 60 | req += struct.pack('!I', 0) # NOTE(adma): RFU 61 | 62 | numbers = ca_public_key.public_numbers() 63 | pubkey_e = int_to_bytes(numbers.e) 64 | pubkey_n = int_to_bytes(numbers.n) 65 | if pubkey_n[0] >= 0x80: 66 | pubkey_n = b'\x00' + pubkey_n 67 | 68 | req += struct.pack( 69 | '!I', 70 | 4 + len(CA_KEY_TYPE) + 4 + len(pubkey_e) + 4 + len(pubkey_n) 71 | ) 72 | 73 | req += struct.pack('!I', len(CA_KEY_TYPE)) + CA_KEY_TYPE 74 | req += struct.pack('!I', len(pubkey_e)) + pubkey_e 75 | req += struct.pack('!I', len(pubkey_n)) + pubkey_n 76 | 77 | return req 78 | -------------------------------------------------------------------------------- /yubihsm_ssh_tool/template.py: -------------------------------------------------------------------------------- 1 | from __future__ import absolute_import, division 2 | 3 | import struct 4 | from cryptography.utils import int_to_bytes 5 | 6 | 7 | def create_template(ts_public_key, key_whitelist, not_before, not_after, 8 | principals_blacklist): 9 | TS_ALGO_TAG = 1 10 | TS_KEY_TAG = 2 11 | CA_KEYS_WL_TAG = 3 12 | NB_TAG = 4 13 | NA_TAG = 5 14 | PRINCIPALS_BL_TAG = 6 15 | 16 | templ = b'' 17 | 18 | numbers = ts_public_key.public_numbers() 19 | pubkey_n = int_to_bytes(numbers.n) 20 | if len(pubkey_n) == 256: 21 | algo = 9 22 | elif len(pubkey_n) == 384: 23 | algo = 10 24 | elif len(pubkey_n) == 512: 25 | algo = 11 26 | else: 27 | return None 28 | 29 | templ += struct.pack('!B', TS_ALGO_TAG) 30 | templ += struct.pack('!H', 1) 31 | templ += struct.pack('!B', algo) 32 | 33 | templ += struct.pack('!B', TS_KEY_TAG) 34 | templ += struct.pack('!H', len(pubkey_n)) 35 | templ += pubkey_n 36 | 37 | templ += struct.pack('!B', CA_KEYS_WL_TAG) 38 | templ += struct.pack('!H', len(key_whitelist) * 2) 39 | for s in key_whitelist: 40 | templ += struct.pack('!H', int(s)) 41 | 42 | templ += struct.pack('!B', NB_TAG) 43 | templ += struct.pack('!H', 4) 44 | templ += struct.pack('!I', int(not_before)) 45 | 46 | templ += struct.pack('!B', NA_TAG) 47 | templ += struct.pack('!H', 4) 48 | templ += struct.pack('!I', int(not_after)) 49 | 50 | templ += struct.pack('!B', PRINCIPALS_BL_TAG) 51 | n_principals = len(principals_blacklist) 52 | total_principals_length = sum(len(s) for s in principals_blacklist) 53 | templ += struct.pack('!H', n_principals + total_principals_length) 54 | for s in principals_blacklist: 55 | templ += s.encode('utf8') + b'\x00' 56 | 57 | return templ 58 | -------------------------------------------------------------------------------- /yubihsm_ssh_tool/validity.py: -------------------------------------------------------------------------------- 1 | from __future__ import absolute_import, division 2 | 3 | from datetime import datetime 4 | import time 5 | import re 6 | 7 | 8 | _UNITS = { 9 | 's': 1, 10 | 'm': 60, 11 | 'h': 60 * 60, 12 | 'd': 60 * 60 * 24, 13 | 'w': 60 * 60 * 24 * 7 14 | } 15 | 16 | 17 | def _convtime(string): 18 | m = re.findall(r'(\d+)([s|m|h|d|w])', string, re.M | re.I) 19 | return sum(int(match[0]) * _UNITS[match[1].lower()] for match in m) 20 | 21 | 22 | def _parse_time(now, value): 23 | if value[0] == '+': 24 | return now + _convtime(value[1:]) 25 | if value[0] == '-': 26 | return now - _convtime(value[1:]) 27 | for pattern in ('%Y%m%d%H%M%S', '%Y%m%d'): 28 | try: 29 | return int(datetime.strptime(value, pattern).timestamp()) 30 | except ValueError: 31 | continue 32 | raise ValueError('Invalid validity format') 33 | 34 | 35 | def parse_validity(validity): 36 | """Parse a validity interval string, as used in `ssh-keygen -V`.""" 37 | now = int(time.time()) 38 | 39 | if validity: 40 | validity = validity.strip() 41 | if not validity: 42 | return now, 0, 0xffffffffffffffff 43 | 44 | if ':' in validity: 45 | from_part, to_part = validity.split(':', 1) 46 | if ':' in to_part: 47 | raise ValueError('Invalid Validity format') 48 | not_before = _parse_time(now, from_part) 49 | else: 50 | not_before = now - 60 51 | to_part = validity 52 | not_after = _parse_time(now, to_part) 53 | 54 | if not_before > not_after: 55 | raise ValueError('Invalid relative certificate time') 56 | 57 | return now, not_before, not_after 58 | --------------------------------------------------------------------------------