├── .gitignore ├── BUILDING.rst ├── Dockerfile ├── Dockerfile-buildenv ├── LICENSE ├── LOGGING.rst ├── Makefile ├── README.rst ├── SECURITY.rst ├── USAGE.rst ├── build.sh ├── client ├── client.go └── client_test.go ├── encrypt.go ├── encrypt_test.go ├── examples └── startup │ └── ssh-cert-authority.conf ├── generate_config.go ├── get_cert.go ├── go.mod ├── go.sum ├── list_requests.go ├── main.go ├── request_cert.go ├── sign_cert.go ├── sign_certd.go ├── sign_certd_test.go ├── signer └── gcpkms.go ├── util ├── certificate.go ├── certificate_test.go ├── config.go └── ssh.go └── version └── version.go /.gitignore: -------------------------------------------------------------------------------- 1 | # Compiled Object files, Static and Dynamic libs (Shared Objects) 2 | *.o 3 | *.a 4 | *.so 5 | 6 | # Folders 7 | _obj 8 | _test 9 | 10 | # Architecture specific extensions/prefixes 11 | *.[568vq] 12 | [568vq].out 13 | 14 | *.cgo1.go 15 | *.cgo2.c 16 | _cgo_defun.c 17 | _cgo_gotypes.go 18 | _cgo_export.* 19 | 20 | _testmain.go 21 | 22 | *.exe 23 | *.test 24 | *.prof 25 | *.gz 26 | 27 | ssh-cert-authority 28 | -------------------------------------------------------------------------------- /BUILDING.rst: -------------------------------------------------------------------------------- 1 | =============== 2 | Building SSH CA 3 | =============== 4 | 5 | We use docker to both build and run ssh-cert-authority. Images are 6 | available at hub.docker.com. To use these images simply 7 | `docker pull cloudtools/ssh-cert-authority`. If you want to build your 8 | own images, these instructions should help. 9 | 10 | We have two Dockerfiles. One is for building an environment to build 11 | ssh-cert-authority. This bootstraps a linux machine to the point that it 12 | can compile a go binary for linux and OS X. The next is for building a 13 | container that can run the ssh-cert-authority web service. 14 | 15 | Though keep in mind that you probably don't need to do any of this 16 | unless you're hacking on the software or have stringent security 17 | requirements that state you build your own copy of this. 18 | 19 | Building with Docker 20 | ==================== 21 | 22 | As an alternative to installing and maintaining a go build environment on your 23 | machine, you can utilize Docker to run the build process in isolation. To do 24 | so, you first need to build the container defined by the Dockerfile included 25 | in this repository.:: 26 | 27 | docker build -f Dockerfile-buildenv -t cloudtools/ssh-cert-authority-buildenv . 28 | 29 | Once this process has completed, you can run an instance of the container 30 | image with a bind mount to this project's directory and the build script 31 | specified as the command to run.:: 32 | 33 | docker run \ 34 | -v `pwd`:/build/ssh-cert-authority/go/src/github.com/cloudtools/ssh-cert-authority \ 35 | -t cloudtools/ssh-cert-authority-buildenv \ 36 | bash build.sh 37 | 38 | This will generate two files in the project directory; a gzipped binary for 39 | 64bit linux and another for 64bit OSX. You can use this binary directly to run 40 | the program by gunzipping it and `chmod +x` ing it 41 | 42 | Creating a Runtime container 43 | ============================ 44 | 45 | Once you've built the software and have the linux .gz file in the 46 | current directory you can also choose to build a container that runs the 47 | cert authority service. This is as simple as :: 48 | 49 | docker build -t cloudtools/ssh-cert-authority . 50 | 51 | That container is setup to run ssh-cert-authority underneath ssh-agent 52 | (as recommended in other documentation in this repository). This creates 53 | an interesting challenge. When you start the container using something 54 | like this:: 55 | 56 | docker run --name ssh-cert-authority -v my_ca_encrypted_secret_key:/etc/ssh/my_ca_encrypted_secret_key -v sign_certd_config.json:/etc/ssh-cert-authority.json:ro cloudtools/ssh-cert-authority --config-file /etc/ssh-cert-authority.json 57 | 2015/08/12 15:38:12 Server running version 1.0.0 58 | 2015/08/12 15:38:12 Server started with config ... 59 | 2015/08/12 15:38:12 Using SSH agent at /tmp/ssh-YMAx2oKHLPrU/agent.1 60 | 61 | You now need to load your CA key into that agent. If you trust your 62 | environment you can use:: 63 | 64 | # Add your key to the agent, it will prompt for passphrase 65 | docker exec -it ssh-cert-authority bash -c ssh-add bash -c "export SSH_AUTH_SOCK=/tmp/ssh-MAx2oKHLPrU/agent.1; ssh-add /etc/ssh/my_ca_encrypted_secret_key" 66 | # Show the identities in the agent. 67 | docker exec -it ssh-cert-authority bash -c ssh-add bash -c "export SSH_AUTH_SOCK=/tmp/ssh-MAx2oKHLPrU/agent.1; ssh-add -l" 68 | 69 | The problem is in how docker exec works. If you're on the host that's 70 | running this container the exec is forwarding your keystrokes (the tty 71 | it sets up) over local sockets and depending on your security posture 72 | this may or may not be ok depending on your security posture. If its 73 | over a network and you've setup docker with TLS this, again, may or may 74 | not be acceptable to you. 75 | 76 | The maintainers of this project are interested in feedback in this area. 77 | Should we spin up sshd inside this container too so that you can 78 | more-securely get inside the container and run ssh-add? 79 | 80 | Should ssh-agent be decoupled from this container (running ssh-agent on 81 | the host makes it ~difficult to pass the agent socket into the running 82 | container, for better or worse)? 83 | 84 | Feedback is welcome here. 85 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM ubuntu:16.04 2 | MAINTAINER Bob Van Zant 3 | LABEL Description="ssh-cert-authority" 4 | RUN apt-get update && apt-get install -y openssh-client && apt-get clean && rm -rf /var/lib/apt 5 | COPY ssh-cert-authority-linux-amd64.gz /usr/local/bin/ssh-cert-authority.gz 6 | RUN gunzip /usr/local/bin/ssh-cert-authority.gz 7 | RUN chmod +x /usr/local/bin/ssh-cert-authority 8 | ENTRYPOINT ["ssh-agent", "/usr/local/bin/ssh-cert-authority", "runserver"] 9 | -------------------------------------------------------------------------------- /Dockerfile-buildenv: -------------------------------------------------------------------------------- 1 | FROM ubuntu:16.04 2 | MAINTAINER Bob Van Zant 3 | LABEL Description="up-to-date ubuntu environment" Vendor="Cloudtools" 4 | 5 | RUN apt-get update && \ 6 | apt-get install -y \ 7 | build-essential \ 8 | curl \ 9 | git-core && \ 10 | apt-get clean && \ 11 | rm -rf /var/lib/apt 12 | 13 | LABEL Description="up-to-date golang environment" 14 | 15 | RUN mkdir -p /build \ 16 | && curl -O https://storage.googleapis.com/golang/go1.9.linux-amd64.tar.gz \ 17 | && echo "d70eadefce8e160638a9a6db97f7192d8463069ab33138893ad3bf31b0650a79 go1.9.linux-amd64.tar.gz" | sha256sum -c - \ 18 | && tar -C /usr/local -xzf go1.9.linux-amd64.tar.gz \ 19 | && rm -f go1.9.linux-amd64.tar.gz 20 | 21 | ENV GOROOT_BOOTSTRAP=/usr/local/go 22 | ENV GOROOT=/build/go-build/go 23 | ENV PATH=/build/go-build/go/bin:/usr/local/bin:/usr/bin:/bin:/usr/local/go/bin 24 | 25 | RUN mkdir -p /build/go-build \ 26 | && cd /build/go-build \ 27 | && curl -o go.tar.gz -s https://storage.googleapis.com/golang/go1.9.src.tar.gz \ 28 | && echo "a4ab229028ed167ba1986825751463605264e44868362ca8e7accc8be057e993 go.tar.gz" | sha256sum -c - \ 29 | && tar -zxf go.tar.gz \ 30 | && cd /build/go-build/go/src \ 31 | && GOOS=darwin GOARCH=amd64 ./make.bash --no-clean 2>&1 > /dev/null ; 32 | 33 | LABEL Description="ssh-cert-authority builder" 34 | 35 | ENV GOPATH=/build/ssh-cert-authority/go 36 | RUN mkdir -p $GOPATH/src/github.com/cloudtools/ssh-cert-authority 37 | WORKDIR $GOPATH/src/github.com/cloudtools/ssh-cert-authority 38 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright (c) 2015, Bob Van Zant 2 | All rights reserved. 3 | 4 | Redistribution and use in source and binary forms, with or without 5 | modification, are permitted provided that the following conditions are met: 6 | 7 | * Redistributions of source code must retain the above copyright notice, this 8 | list of conditions and the following disclaimer. 9 | 10 | * Redistributions in binary form must reproduce the above copyright notice, 11 | this list of conditions and the following disclaimer in the documentation 12 | and/or other materials provided with the distribution. 13 | 14 | THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" 15 | AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE 16 | IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE 17 | DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE 18 | FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL 19 | DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR 20 | SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER 21 | CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, 22 | OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE 23 | OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. 24 | 25 | -------------------------------------------------------------------------------- /LOGGING.rst: -------------------------------------------------------------------------------- 1 | ========================== 2 | SSH Cert Authority Logging 3 | ========================== 4 | 5 | One of the main goals of this project is to provide an audit trail from 6 | the moment a user requests access to a cluster through their trials on 7 | the cluster until finally they disconnect from the cluster for the last 8 | time. To help administrators and auditors understand this auditability 9 | this document describes each of the log messages that are generated. 10 | 11 | Service Startup 12 | =============== 13 | 14 | At system startup the daemon prints out its current configuration. The 15 | current configuration will show exactly what was loaded into the daemon 16 | including, for each configured environment, the authorized users and 17 | authorized signers as well as the policy. 18 | 19 | Example startup messages, formatted for improved clarity. 20 | 21 | Here is our first log message indicating the version of software we're 22 | running. In this case its a development build. 23 | :: 24 | 2016/04/29 14:47:53 Server running version dev 25 | 26 | Here we log which ssh-agent we're using. ssh-cert-authority does not 27 | sign certificates itself, it relies on a "security module" to do the 28 | signing and ssh-agent is that module providing separation between the 29 | user-facing service and the actual secrets. 30 | 31 | :: 32 | 2016/04/29 14:47:53 Using SSH agent at /private/tmp/com.apple.launchd.GZGjDj9R8K/Listeners 33 | 34 | And here is the config dump. From this we can see that the daemon was 35 | configured to accept cert requests from bvz & dennis and that bvz is the 36 | only configured signer. When a cert gets the correct number of signers, 37 | 1, the cert will be signed using the CA with fingerprint "00:f3:ce:...":: 38 | 39 | map[string]ssh_ca_util.SignerdConfig{ 40 | "test": 41 | ssh_ca_util.SignerdConfig{ 42 | SigningKeyFingerprint: "00:f3:ce:02:e7:63:77:dc:65:be:c5:24:ee:1d:63:c0", 43 | AuthorizedSigners: map[string]string{ 44 | "66:b5:be:e5:7e:09:3f:98:97:36:9b:64:ec:ea:3a:fe":"bvz" 45 | }, 46 | AuthorizedUsers: map[string]string{ 47 | "66:b5:be:e5:7e:09:3f:98:97:36:9b:64:ec:ea:3a:fe":"bvz", 48 | "3e:1d:18:28:d0:56:d5:34:e5:97:89:9a:71:b0:62:3d":"dennis" 49 | }, 50 | NumberSignersRequired:1, 51 | SlackUrl:"", 52 | SlackChannel:"", 53 | MaxCertLifetime:0, 54 | PrivateKeyFile:"", 55 | KmsRegion:"" 56 | }, 57 | } 58 | 59 | Cert Request 60 | ============ 61 | 62 | When a certificate is requested from a user a log message is generated:: 63 | 64 | 2016/04/29 15:00:38 Cert request serial 1 id ONMZX7GITLGJ4BCN env test 65 | from 66:b5:be:e5:7e:09:3f:98:97:36:9b:64:ec:ea:3a:fe (bvz) @ [::1]:64273 66 | principals [ec2-user ubuntu] valid from 1461956318 to 1461963638 for 67 | 'Investigate disk full scenario' 68 | 69 | Let's parse this one. 70 | 71 | ``Cert request serial 1``: This certificate has been allocated serial 72 | number 1. 73 | 74 | ``id ONMZX7GITLGJ4BCN``: This certificate was generated this random id. 75 | This id is used by requesters and signers to sign and retrieve signed 76 | certificates from the system. It is not embedded in the certificate. 77 | 78 | ``env test``: This certificate is for the "test" environment. 79 | 80 | ``from 66:b5:be:e5:7e:09:3f:98:97:36:9b:64:ec:ea:3a:fe (bvz)`` The request 81 | was received for the certificate with the fingerprint listed there. 82 | According to our configuration file this certificate is for the user 83 | bvz. 84 | 85 | ``@ [::1]:64273`` The certificate request came in from localhost with a 86 | source port of 64273. Typically this contains a non-localhost IP 87 | address but that varies based on the deployment configuration (if you're 88 | behind a reverse proxy ssh-cert-authority doesn't yet parse the 89 | X-Forwarded-For header). 90 | 91 | ``principals [ec2-user ubuntu]`` This certificate has the principals 92 | ec2-user and ubuntu. That maps directly to the principals option in the 93 | certificate and allows this certificate holder to attempt to login as 94 | either the user ``ec2-user`` or ``ubuntu``. 95 | 96 | ``valid from 1461956318 to 1461963638`` This certificate is valid between 97 | these two unix timestamps. An easy way of decoding this timestamp is 98 | with python:: 99 | 100 | >>> import time 101 | >>> time.ctime(1461956318) 102 | 'Fri Apr 29 14:58:38 2016' 103 | 104 | ``for 'Investigate disk full scenario'`` This is the reason that the user 105 | specified when requesting the certificate. The encoding of these log 106 | messages is UTF-8 and the reason field in particular is capable of 107 | containing non-ascii characters if the user enters them. The reason is 108 | encoded into the certificate. 109 | 110 | Listing Pending Requests 111 | ======================== 112 | 113 | Prior to signing a request the signer must download the certificate 114 | request. The downloading of the request is logged as shown below and the 115 | fields are thought to be self-explanatory. 116 | 117 | :: 118 | 119 | 2016/04/29 15:37:30 List pending requests received from [::1]:49439 for request id 'ONMZX7GITLGJ4BCN' 120 | 121 | Signing of a Request 122 | ==================== 123 | 124 | When a cert signer signs a request they are acknowledging and explicitly 125 | approving the request. The log message for a signature is as follows: 126 | 127 | :: 128 | 129 | 2016/04/29 15:37:32 Signature for serial 1 id ONMZX7GITLGJ4BCN received from 66:b5:be:e5:7e:09:3f:98:97:36:9b:64:ec:ea:3a:fe (bvz) @ [::1]:49439 and determined valid 130 | 131 | The fields in this log message map closely to those in the request. We 132 | see the certificate serial number (covered by the signature on the 133 | certificate) and id (decoupled from the cert). We also see the 134 | fingerprint of the key used to sign the certificate and print (bvz) to 135 | indicate that our configuration shows that this key is held by the user 136 | 'bvz'. 137 | 138 | Rejecting Requests 139 | ================== 140 | 141 | An administrator can mark a request as rejected if he or she deems it 142 | appropriate. For example, if a user requests a certificate and does not 143 | adequately document the request or perhaps asks for more time than the 144 | signer is willing to sign off on it can be rejected and no other signer 145 | can turn that over. 146 | 147 | When rejected a pair of log messages are generated 148 | 149 | :: 150 | 151 | 2016/04/29 15:51:16 Signature for serial 2 id C6EMOLWB3UHAQXMK received from 66:b5:be:e5:7e:09:3f:98:97:36:9b:64:ec:ea:3a:fe (bvz) @ [::1]:49459 and determined valid 152 | 2016/04/29 15:51:16 Reject received for id C6EMOLWB3UHAQXMK 153 | 154 | Signing of Cert by CA 155 | ===================== 156 | 157 | When a certificate has received enough approvals to be deemed valid 158 | (the exact number is a configuration parameter) it is signed by the 159 | certificate authority. This generates a log message like so:: 160 | 161 | 2016/04/29 15:37:32 Received 1 signatures for ONMZX7GITLGJ4BCN, signing now. 162 | 163 | 164 | Certificate usage 165 | ================= 166 | 167 | After a user obtains their certificate they use it to login to a remote 168 | machine. OpenSSH can be configured in many ways. In certain linux 169 | distributions you may need to enable debug logging in sshd_config (debug 170 | does not generate a logging burden) On a default CentOS 7 installation 171 | this message is printed on login: 172 | 173 | :: 174 | 175 | Apr 29 17:01:20 ip-10-204-24-252 sshd[9236]: Accepted publickey for centos from 10.0.1.30 port 58964 ssh2: RSA-CERT ID bvz (serial 1) CA RSA 00:f3:ce:02:e7:63:77:dc:65:be:c5:24:ee:1d:63:c0 176 | 177 | Parsing this message we see that the user logged in using the generic 178 | 'centos' user that comes on AWS instances. However we also have logged 179 | the RSA-CERT ID "bvz" which came from our ssh-cert-authority 180 | configuration file. 181 | 182 | At this point we have tracked a user accessing a system from the time 183 | that they requested access to when someone approved that access and 184 | ultimately to when they accessed a specific server. 185 | 186 | Were auditd or similar configured on the CentOS machine we could also 187 | see what this user did once connected to this host. 188 | 189 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | TAG?=2.0.0 2 | VERSION := $(shell echo `git describe --tags --long --match=*.*.* --dirty` | sed s/version-//g) 3 | 4 | PKG=github.com/cloudtools/ssh-cert-authority 5 | 6 | .PHONY: test vet 7 | 8 | ssh-cert-authority: 9 | go build -ldflags "-X ${PKG}/version.Tag=${TAG} -X ${PKG}/version.BuildVersion=${VERSION}" . 10 | 11 | ssh-cert-authority-linux-amd64: 12 | GOOS=linux GOARCH=amd64 \ 13 | go build -o ssh-cert-authority-linux-amd64 \ 14 | -ldflags "-X ${PKG}/version.Tag=${TAG} -X ${PKG}/version.BuildVersion=${VERSION}" . 15 | 16 | ssh-cert-authority-linux-amd64.gz: ssh-cert-authority-linux-amd64 17 | gzip -f ssh-cert-authority-linux-amd64 18 | 19 | ssh-cert-authority-linux-arm64: 20 | GOOS=linux GOARCH=arm64 \ 21 | go build -o ssh-cert-authority-linux-arm64 \ 22 | -ldflags "-X ${PKG}/version.Tag=${TAG} -X ${PKG}/version.BuildVersion=${VERSION}" . 23 | 24 | ssh-cert-authority-linux-arm64.gz: ssh-cert-authority-linux-arm64 25 | gzip -f ssh-cert-authority-linux-arm64 26 | 27 | ssh-cert-authority-linux-arm7: 28 | GOOS=linux GOARCH=arm7 \ 29 | go build -o ssh-cert-authority-linux-arm7 \ 30 | -ldflags "-X ${PKG}/version.Tag=${TAG} -X ${PKG}/version.BuildVersion=${VERSION}" . 31 | 32 | ssh-cert-authority-linux-arm7.gz: ssh-cert-authority-linux-arm7 33 | gzip -f ssh-cert-authority-linux-arm7 34 | 35 | ssh-cert-authority-darwin-amd64: 36 | GOOS=darwin GOARCH=amd64 \ 37 | go build -o ssh-cert-authority-darwin-amd64 \ 38 | -ldflags "-X ${PKG}/version.Tag=${TAG} -X ${PKG}/version.BuildVersion=${VERSION}" . 39 | 40 | ssh-cert-authority-darwin-amd64.gz: ssh-cert-authority-darwin-amd64 41 | gzip -f ssh-cert-authority-darwin-amd64 42 | 43 | ssh-cert-authority-darwin-arm64: 44 | GOOS=darwin GOARCH=arm64 \ 45 | go build -o ssh-cert-authority-darwin-arm64 \ 46 | -ldflags "-X ${PKG}/version.Tag=${TAG} -X ${PKG}/version.BuildVersion=${VERSION}" . 47 | 48 | ssh-cert-authority-darwin-arm64.gz: ssh-cert-authority-darwin-arm64 49 | gzip -f ssh-cert-authority-darwin-arm64 50 | 51 | release: ssh-cert-authority-darwin-amd64.gz ssh-cert-authority-darwin-arm64.gz ssh-cert-authority-linux-amd64.gz ssh-cert-authority-linux-arm64.gz ssh-cert-authority-linux-arm7 52 | 53 | test: 54 | @go test ./... 55 | 56 | vet: 57 | @go vet ./... 58 | 59 | -------------------------------------------------------------------------------- /README.rst: -------------------------------------------------------------------------------- 1 | ================== 2 | ssh-cert-authority 3 | ================== 4 | 5 | Introduction 6 | ============ 7 | 8 | A democratic SSH certificate authority. 9 | 10 | Operators of ssh-cert-authority want to use SSH certificates to provide 11 | fine-grained access control to servers they operate, enforce the 2-person rule, 12 | keep their certificate signing key a secret and not need to be required to get 13 | involved to actually sign certificates. A tall order. 14 | 15 | The idea here is that a user wishing to access a server runs 16 | ``ssh-cert-authority request`` and specifies a few parameters for the cert 17 | request like how long he/she wants it to be valid for. This is POSTed to 18 | the ``ssh-cert-authority runserver`` daemon which validates that the 19 | certificate request was signed by a valid user (configured on the daemon 20 | side) before storing a little state and returning a certificate id to 21 | the requester. 22 | 23 | The requester then convinces one or more of his or her authorized 24 | friends (which users are authorized and the number required is 25 | configured on the daemon side) to run the ``ssh-cert-authority sign`` 26 | command specifying the request id. The signer is allowed to see the 27 | parameters of the certificate before deciding whether or not to actually 28 | sign the cert request. The signed certificate is again POSTed back to 29 | the server where the signature is validated. 30 | 31 | Note that a requester may not sign their own request. If a +1 is 32 | received for a request by the same key as the one in the request then 33 | the signing request is rejected. 34 | 35 | Once enough valid signatures are received the cert request is 36 | automatically signed using the signing key for the cert authority and 37 | made available for download by the requester using the request id. 38 | 39 | None of the code here ever sees or even attempts to look at secrets. All 40 | signing operations are performed by ``ssh-agent`` running on respective 41 | local machines. In order to bootstrap the signing daemon you must 42 | ``ssh-add`` the signing key. In order to request a cert or sign someone's 43 | cert request the user must have the key used for signing loaded up in 44 | ``ssh-agent``. Secrets are really hard to keep, we'll leave them in the 45 | memory space of ``ssh-agent``. 46 | 47 | Background 48 | ========== 49 | 50 | In general the authors of this project believe that SSH access to hosts 51 | running in production is a sometimes-necessary evil. We prefer systems 52 | that are capable of resolving faults by themselves and that are always 53 | fault tolerant. However, when things go wrong or when tools for 54 | managing the system without SSH have not been built we recognize that 55 | getting on the box is often the only option remaining to attempt to 56 | restore service. 57 | 58 | SSH access to hosts in dynamic datacenters like those afforded by Amazon 59 | Web Services and Google Compute poses its own challenges. Instances may 60 | be spun up or torn down at any time. Typically organizations do one of 61 | two things to facilitate SSH access to instances: 62 | 63 | - Generate an SSH keypair and share it amongst anyone that may need 64 | to access production systems 65 | - Put everyone's public key into an ``authorized_keys`` file (perhaps 66 | baked into an AMI, perhaps via cloudinit) 67 | 68 | In security conscience environments organizations may have built a tool 69 | that automates the process of adding and removing public keys from an 70 | ``authorized_keys`` file. 71 | 72 | None of these options are great. The first two options do not meet the 73 | security requirements of the author's employer. Sharing secrets is 74 | simply unacceptable and it means that an ex-employee now has access to 75 | systems that he or she shouldn't have access to until the key can be 76 | rotated out of use. 77 | 78 | Managing a large ``authorized_keys`` file is a problem because it isn't 79 | limited to the exact set of people that require access to nodes right 80 | now. 81 | 82 | As part of our ISO 27001 certification we are additionally required to: 83 | 84 | - Automatically revoke access to systems when engineers no longer 85 | need access to them. 86 | - Audit who accessed which host when and what they did 87 | 88 | SSH certificates solve these problems and more. 89 | 90 | An OpenSSH certificate is able to encode a set of permissions of the 91 | form (see also the ``CERTIFICATES`` section of ``ssh-keygen(1)``): 92 | 93 | - Which user may use this certificate 94 | - The user id of the user 95 | - When access to servers may begin 96 | - When access to servers expires 97 | - Whether or not the user may open a PTY, do port forwarding or SSH 98 | agent forwarding. 99 | - Which servers may be accessed 100 | 101 | The certificate is signed by some trusted authority (an SSH private key) 102 | and machines within the environment are told to trust any certificate 103 | signed by that authority. This is very, very similar to how trust works 104 | for TLS certificates on your favorite websites. 105 | 106 | A piece of trivia is that SSH certificates are not X.509 certs, they're 107 | instead more along the lines of a tag-length-value encoding of a C 108 | struct. 109 | 110 | Using OpenSSH Certificates 111 | ========================== 112 | 113 | This section describes using OpenSSH certificates manually, without the 114 | ssh-cert-authority tool. 115 | 116 | To begin using OpenSSH certificates you first must generate an ssh key 117 | that will be kept secret and used as the certificate authority in your 118 | environment. This can be done with a command like:: 119 | 120 | ssh-keygen -f my_ssh_cert_authority 121 | 122 | That command outputs two files:: 123 | 124 | my_ssh_cert_authority: The encrypted private key for your new authority 125 | my_ssh_cert_authority.pub: The public key for your new authority. 126 | 127 | Be sure you choose a passphrase when prompted so that the secret is 128 | stored encrypted. Other options to ``ssh-keygen`` are permitted including 129 | both key type and key parameters. For example, you might choose to use 130 | ECDSA keys instead of RSA. 131 | 132 | Grab the fingerprint of your new CA:: 133 | 134 | $ ssh-keygen -l -f my_ssh_cert_authority 135 | 2048 2b:a1:16:84:79:0a:2e:38:84:6f:32:96:ab:d4:af:5d my_ssh_cert_authority.pub (RSA) 136 | 137 | Now that you have a certificate authority you'll need to tell the hosts 138 | in your environment to trust this authority. This is done very similar 139 | to user SSH keys by setting up the ``authorized_keys`` on your hosts (the 140 | expectation is that you're setting this up at launch time via cloudinit 141 | or perhaps baking the change into an OS image or other form of snapshot). 142 | 143 | You have a choice of putting this ``authorized_keys`` file into 144 | ``$HOME/.ssh/authorized_keys`` or the change can be made system wide. For 145 | system wide configuration see ``sshd_config(5)`` and the 146 | ``TrustedUserCAKeys`` option. 147 | 148 | If you are modifying the user's ``authorized_keys`` file simply add a new 149 | line to ``authorized_keys`` of the form:: 150 | 151 | cert-authority 152 | 153 | A valid line might look like this for an RSA key:: 154 | 155 | cert-authority ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAAAYQC6Shl5kUuTGqkSc8D2vP2kls2GoB/eGlgIb0BnM/zsIsbw5cWsPournZN2IwnwMhCFLT/56CzT9ZzVfn26hxn86KMpg76NcfP5Gnd66dsXHhiMXnBeS9r6KPQeqzVInwE= 156 | 157 | At this point your host has been configured to accept a certificate 158 | signed by your authority's private key. Let's generate a certificate for 159 | ourselves that permits us to login as the user ubuntu and that is valid 160 | for the next hour (This assumes that our personal public SSH key is 161 | stored at ``~/.ssh/id_rsa.pub)`` :: 162 | 163 | ssh-keygen -V +1h -s my_ssh_cert_authority -I bvanzant -n ubuntu ~/.ssh/id_rsa.pub 164 | 165 | The output of that command is the file ``~/.ssh/id_rsa-cert.pub``. If you 166 | open it it's just a base64 encoded blob. However, we can ask ``ssh-keygen`` 167 | to show us the contents:: 168 | 169 | $ ssh-keygen -L -f ~/.ssh/id_rsa-cert.pub 170 | /tmp/test_main_ssh-cert.pub: 171 | Type: ssh-rsa-cert-v01@openssh.com user certificate 172 | Public key: RSA-CERT f6:e3:42:5e:72:85:ce:26:e8:45:1f:79:2d:dc:0d:52 173 | Signing CA: RSA 4c:c6:1e:31:ed:7b:7c:33:ff:7d:51:9e:59:da:68:f5 174 | Key ID: "bvz-test" 175 | Serial: 0 176 | Valid: from 2015-04-13T06:48:00 to 2015-04-13T07:49:13 177 | Principals: 178 | ubuntu 179 | Critical Options: (none) 180 | Extensions: 181 | permit-X11-forwarding 182 | permit-agent-forwarding 183 | permit-port-forwarding 184 | permit-pty 185 | permit-user-rc 186 | 187 | Let's use the certificate now:: 188 | 189 | # Add the key into our ssh-agent (this will find and add the certificate as well) 190 | ssh-add ~/.ssh/id_rsa 191 | # And SSH to a host 192 | ssh ubuntu@ 193 | 194 | If the steps above were followed carefully you're now SSHed to the 195 | remote host. Fancy? 196 | 197 | At this point if you look in ``/var/log/auth.log`` (Ubuntu) (``/var/log/secure`` 198 | on Red Hat based systems) you'll see that the user ubuntu logged in to this 199 | machine. This isn't very useful data. If you change the sshd_config on your 200 | servers to include ``LogLevel VERBOSE`` you'll see that the certificate key id 201 | is also logged when a user logs in via certificate. This allows you to map 202 | that user ``bvanzant`` logged into the host using username ubuntu. This will 203 | make your auditors happy. 204 | 205 | You're now an SSH cert signer. The problem, however, is that you 206 | probably don't want to be the signer. Signing certificates is not fun. 207 | And it's really not fun at 3:00AM when someone on the team needs to 208 | access a host for a production outage and you were not that person. That 209 | person now has to wake you up to get a certificate signed. And you 210 | probably don't want that. And now you perhaps are ready to appreciate 211 | this project a bit more. 212 | 213 | Setting up ssh-cert-authority 214 | ============================= 215 | 216 | This section is going to build off of parts of the prior section. In 217 | particular it assumes that you have configured an SSH authority already 218 | and that you know how to configure servers to accept your certificates. 219 | 220 | ssh-cert-authority is a single tool that has subcommands (the decision 221 | to do this mostly came from trying to follow Go's preferred way of 222 | building and distributing software). The subcommands are: 223 | 224 | - runserver 225 | - request 226 | - sign 227 | - get 228 | - encrypt-key 229 | - generate-config 230 | 231 | As you might have guessed by now this means that a server needs to be 232 | running and serving the ssh-cert-authority service. Users that require 233 | SSH certificates will need to be able to access this service in order to 234 | request, sign and get certificates. 235 | 236 | This tool was built with the idea that organizations have more than one 237 | environment with perhaps different requirements for obtaining and using 238 | certificates. For example, there might be a test environment, a staging 239 | environment and a production environment. Throughout the examples we 240 | assume a single environment named "production." 241 | 242 | In all cases this tool relies heavily on ``ssh-agent``. It is entirely 243 | feasible that ``ssh-agent`` could be replaced by any other process capable 244 | of signing a blob of data with a specified key including an HSM. 245 | 246 | Many of the configuration files use SSH key fingerprints. To get a key's 247 | fingerprint you may run ``ssh-keygen -l -f `` or, if the key is 248 | already stored in your ``ssh-agent`` you can ``ssh-agent -l``. 249 | 250 | Setting up the daemon 251 | --------------------- 252 | 253 | ssh-cert-authority uses json for its configuration files. By default the 254 | daemon expects to find its configuration information in 255 | ``$HOME/.ssh_ca/sign_certd_config.json`` (you can change this with a 256 | command line argument). A valid config file for our production 257 | environment might be:: 258 | { 259 | "production": { 260 | "NumberSignersRequired": 1, 261 | "MaxCertLifetime": 86400, 262 | "SigningKeyFingerprint": "66:b5:be:e5:7e:09:3f:98:97:36:9b:64:ec:ea:3a:fe", 263 | "AuthorizedSigners": { 264 | "66:b5:be:e5:7e:09:3f:98:97:36:9b:64:ec:ea:3a:fe": "bvz" 265 | }, 266 | "AuthorizedUsers": { 267 | "1c:fd:36:27:db:48:3f:ad:e2:fe:55:45:67:b1:47:99": "bvz" 268 | } 269 | } 270 | } 271 | 272 | Effectively the format is:: 273 | 274 | { 275 | "environment name": { 276 | NumberSignersRequired 277 | MaxCertLifetime 278 | SigningKeyFingerprint 279 | PrivateKeyFile 280 | KmsRegion 281 | AuthorizedSigners { 282 | : 283 | } 284 | AuthorizedUsers { 285 | : 286 | } 287 | } 288 | 289 | - ``NumberSignersRequired``: The number of people that must sign a request 290 | before the request is considered complete and signed by the authority. 291 | If this field is < 0 valid certificate requests will be automatically 292 | signed at request time. It is highly recommended that if auto signing 293 | is enabled a ``MaxCertLifetime`` be specified. 294 | - ``MaxCertLifetime``: The maximum duration certificate, measured from Now() 295 | in seconds, that is permitted. The default is 0, meaning unlimited. A 296 | value of 86400 would mean that the server will reject requests for 297 | certificates that are valid for more than 1 day. 298 | - ``SigningKeyFingerprint``: The fingerprint of the key that will be used to 299 | sign complete requests. This should be the fingerprint of your CA. When using 300 | this option you must, somehow, load the private key into the agent such that 301 | the daemon can use it. 302 | - ``PrivateKeyFile``: A path to a private key file or a Google KMS key url. 303 | 304 | If you have specified a file system path the key may be unencrypted or have 305 | previousl been encrypted using Amazon's KMS. If the key was encrypted using 306 | KMS simply name it with a ".kms" extension and ssh-cert-authority will 307 | attempt to decrypt the key on startup. See the section on Encrypting a CA Key 308 | for help in using KMS to encrypt the key. 309 | 310 | If you specified a Google KMS key it should be of the form: 311 | ``gcpkms:///projects//locations//keyRings//cryptoKeys//cryptoKeyVersions/`` 313 | 314 | - ``KmsRegion``: If sign_certd encounters a privatekey file with an 315 | extension of ".kms" it will attempt to decrypt it using KMS in the 316 | same region that the software is running in. It determines this using 317 | the local instance's metadata server. If you're not running 318 | ssh-cert-authority within AWS or if the key is in a different region 319 | you'll need to specify the region here as a string, e.g. us-west-2. 320 | - ``AuthorizedSigners``: A hash keyed by key fingerprints and values of key 321 | ids. I recommend this be set to a username. It will appear in the 322 | resultant SSH certificate in the KeyId field as well in 323 | ssh-cert-authority log files. The ``AuthorizedSigners`` field is used to 324 | indicate which users are allowed to sign requests. 325 | - ``AuthorizedUsers``: Same as ``AuthorizedSigners`` except that these are 326 | fingerprints of people allowed to submit requests. 327 | - ``CriticalOptions``: A hash of critical options to be added to all 328 | certificate requests. By specifying these in your configuration file 329 | all cert requests to this environment will have these options embedded 330 | in them. You can use this option, for example, to restrict the IP 331 | addresses that are allowed to use a certificate or to force a user 332 | to only be able to run a single command. Those are the only two 333 | options supported by sshd right now. This document describes them in 334 | the section ``Critical options``: 335 | http://cvsweb.openbsd.org/cgi-bin/cvsweb/src/usr.bin/ssh/PROTOCOL.certkeys?rev=HEAD 336 | 337 | The same users and fingerprints may appear in both ``AuthorizedSigners`` and 338 | ``AuthorizedUsers``. 339 | 340 | You're now ready to start the daemon. I recommend putting this under the 341 | control of some sort of process monitor like upstart or supervisor or 342 | whatever suits your fancy.:: 343 | 344 | ssh-cert-authority runserver 345 | 346 | Log messages go to stdout. When the server starts it prints its config 347 | file as well as the location of the ``$SSH_AUTH_SOCK`` that it found 348 | 349 | If you are running this from within a process monitor getting a 350 | functioning ``ssh-agent`` may not be intuitive. I run it like this:: 351 | 352 | ssh-agent ssh-cert-authority runserver 353 | 354 | This means that a new ``ssh-agent`` is used exclusively for the server. And 355 | that means that every time the service starts (or restarts) you must 356 | manually add your signing keys to the agent via ``ssh-add``. To help with 357 | this the server prints the socket it's using:: 358 | 359 | 2015/04/12 16:05:05 Using SSH agent at /private/tmp/com.apple.launchd.MzybvK44OP/Listeners 360 | 361 | You can take that value and add in your keys like so:: 362 | 363 | SSH_AUTH_SOCK=/private/tmp/com.apple.launchd.MzybvK44OP/Listeners ssh-add path-to-ca-key 364 | 365 | Once the server is up and running it is bound to 0.0.0.0 on port 8080. 366 | 367 | Running behind a reverse proxy (e.g. nginx) 368 | ------------------------------------------- 369 | 370 | If you're running behind a reverse proxy, which this project recommends, 371 | you will want to set one additional command line argument, 372 | ``reverse-proxy``. You can instead set the environment variable 373 | SSH_CERT_AUTHORITY_PROXY=true if that is more your style. Setting this 374 | flag to true instructs the daemon to trust the X-Forwarded-For header 375 | that nginx will set and to use that IP address in log messages. Know 376 | that you must not set this value to true if you are not running behind a 377 | proxy as this allows a malicious user to control the value of the IP 378 | address that is put into your log files. 379 | 380 | Command Line Flags 381 | ------------------ 382 | 383 | - ``config-file``: The path to a config.json file. Used to override the 384 | default of $HOME/.ssh_ca/sign_certd_config.json 385 | - ``listen-address``: Controls the bind address of the daemon. By 386 | default we bind to localhost which means you will not be able to 387 | connect to the daemon from hosts other than this one without using a 388 | reverse proxy (e.g. nginx) in front of this daemon. A reverse proxy is 389 | the recommended method for running this service in production. 390 | - ``reverse-proxy``: When specified the daemon will trust the 391 | X-Forwarded-For header as added to requests by your reverse proxy. 392 | This flag must not be set when you are not using a reverse proxy as it 393 | permits a malicious user to control the IP address that is written to 394 | log files. 395 | 396 | Storing Your CA Signing Key in Google Cloud 397 | =========================================== 398 | Google Cloud KMS supports signing operations and ssh-cert-authority can use 399 | these keys to sign the SSH certificates it issues. If you do this you'll likely 400 | want to have your ssh-cert-authority running on an instance in GCP and 401 | configured with a service account that can use the key. 402 | 403 | ssh-cert-authority has been tested with ecdsa keys from prime256v1 both 404 | software and hardware backed. Other key kinds and curves might work. 405 | 406 | This example assumes you have a functioning gcloud already. 407 | 408 | Setting up keys:: 409 | 410 | # First create a keyring to store keys 411 | gcloud kms keyrings create ssh-cert-authority-demo --location us-central1 412 | 413 | # Create keys on that keyring for dev and prod 414 | gcloud alpha kms keys create --purpose asymmetric-signing --keyring ssh-cert-authority-demo \ 415 | --location us-central1 --default-algorithm ec-sign-p256-sha256 dev 416 | gcloud alpha kms keys create --purpose asymmetric-signing --keyring ssh-cert-authority-demo \ 417 | --location us-central1 --default-algorithm ec-sign-p256-sha256 prod 418 | 419 | # Create a service account for the system 420 | gcloud iam service-accounts create ssh-cert-authority-demo 421 | 422 | # If you're using a GCP instance you should launch your instance and specify 423 | # that service account as the account for the instance. If you're running 424 | # this on a local machine or an AWS instance or something you will need to 425 | # explicitly get the service account key 426 | gcloud iam service-accounts keys create ssh-cert-authority-demo-serviceaccount.json 427 | --iam-account ssh-cert-authority-demo@YOUR_GOOGLE_PROJECT_ID.iam.gserviceaccount.com 428 | 429 | # You need to set that key file in an environment variable now: 430 | export GOOGLE_APPLICATION_CREDENTIALS=/path/to/ssh-cert-authority-demo-serviceaccount.json 431 | 432 | # Give that service account permission to use our newly created keys: 433 | gcloud kms keys add-iam-policy-binding ssh-cert-authority-dev-hsm --location us-central1 \ 434 | --keyring ssh-cert-authority-demo \ 435 | --member serviceAccount:ssh-cert-authority-demo@YOUR_GOOGLE_PROJECT_ID.iam.gserviceaccount.com \ 436 | --role roles/cloudkms.signerVerifier 437 | 438 | # Get the path to the keys we created: 439 | gcloud kms keys list --location us-central1 --keyring ssh-cert-authority-demo 440 | 441 | # That will print out the two keys we created earlier including the name of 442 | # the key. The name of the key is a big path that begins with projects/. We 443 | # need to copy this entire path into our sign_certd_config.json as the 444 | # PrivateKeyFile for the environment. A minimal example showing only dev: 445 | 446 | { 447 | "dev": { 448 | "NumberSignersRequired": -1, 449 | "MaxCertLifetime": 86400, 450 | "PrivateKeyFile": "gcpkms:///projects/YOUR_GOOGLE_PROJECT_ID/locations/us-central1/keyRings/ssh-cert-authority-demo/cryptoKeys/dev/cryptoKeyVersions/1", 451 | "AuthorizedSigners": { 452 | "a7:64:9e:35:5d:ae:c6:bd:79:f1:e3:c8:92:0b:9a:51": "bvz" 453 | }, 454 | "AuthorizedUsers": { 455 | "a7:64:9e:35:5d:ae:c6:bd:79:f1:e3:c8:92:0b:9a:51": "bvz" 456 | } 457 | } 458 | } 459 | 460 | 461 | Encrypting a CA Key Using Amazon's KMS 462 | ====================================== 463 | 464 | Amazon's KMS (Key Management Service) provides an encryption key 465 | management service that can be used to encrypt small chunks of arbitrary 466 | data (including other keys). This project supports using KMS to keep the 467 | CA key secure. 468 | 469 | The recommended deployment is to launch ssh-cert-authority onto an EC2 470 | instance that has an EC2 instance profile attached to it that allows it 471 | to use KMS to decrypt the CA key. A sample cloudformation stack is 472 | forthcoming to do all of this on your behalf. 473 | 474 | Create Instance Profile 475 | ----------------------- 476 | 477 | In the mean time you can set things up by hand. A sample EC2 instance 478 | profile access policy:: 479 | 480 | { 481 | "Statement": [ 482 | { 483 | "Resource": [ 484 | "*" 485 | ], 486 | "Action": [ 487 | "kms:Encrypt", 488 | "kms:Decrypt", 489 | "kms:ReEncrypt", 490 | "kms:GenerateDataKey", 491 | "kms:DescribeKey" 492 | ], 493 | "Effect": "Allow" 494 | } 495 | ], 496 | "Version": "2012-10-17" 497 | } 498 | 499 | Create KMS Key 500 | -------------- 501 | 502 | Create a KMS key in the AWS IAM console. When specifying key usage allow the 503 | instance profile you created earlier to use the key. The key you create 504 | will have an id associated with it, it looks something like this:: 505 | 506 | arn:aws:kms:us-west-2:123412341234:key/debae348-3666-4cc7-9d25-41e33edb2909 507 | 508 | Save that for the next step. 509 | 510 | Launch Instance 511 | --------------- 512 | 513 | Now launch an instance and use the EC2 instance profile. A t2 class instance is 514 | likely sufficient. Copy over the latest ssh-cert-authority binary (you 515 | can also use the container) and generate a new key for the CA using 516 | ssh-cert-authority. The nice thing here is that the key is never written 517 | anywhere unencrypted. It is generated within ssh-cert-authority, 518 | encrypted via KMS and then written to disk in encrypted form. :: 519 | 520 | environment_name=production 521 | ssh-cert-authority encrypt-key --generate-rsa \ 522 | --key-id arn:aws:kms:us-west-2:881577346222:key/d1401480-8220-4bb7-a1de-d03dfda44a13 \ 523 | --output ca-key-${environment}.kms 524 | 525 | The output of this is two files: ``ca-key-production.kms`` and 526 | ``ca-key-production.kms.pub``. The kms file should be referenced in the ssh 527 | cert authority config file, as documented elsewhere in this file, and 528 | the .pub file will be used within authorized_keys on servers you wish to 529 | SSH to. 530 | 531 | ``--generate-rsa`` will generate a 4096 bit RSA key. ``--generate-ecdsa`` will 532 | generate a key from nist's p384 curve. 533 | 534 | Requesting Certificates 535 | ======================= 536 | 537 | See USAGE.rst in this directory. 538 | 539 | Signing Requests 540 | ================ 541 | 542 | See USAGE.rst in this directory. 543 | 544 | All in one basic happy test case:: 545 | 546 | go build && reqId=$(./ssh-cert-authority request --reason testing --environment test --quiet) && \ 547 | ./ssh-cert-authority sign --environment test --cert-request-id $reqId && \ 548 | ./ssh-cert-authority get --add-key=false --environment test $reqId` 549 | -------------------------------------------------------------------------------- /SECURITY.rst: -------------------------------------------------------------------------------- 1 | ============== 2 | Security Model 3 | ============== 4 | 5 | This document attempts to describe the threats that ssh-cert-authority 6 | is designed to cope with and how or why it meets them. 7 | 8 | ------- 9 | Threats 10 | ------- 11 | 12 | Against ssh-cert-authority 13 | ========================== 14 | 15 | It is recommended that the daemon be configured to run in a secure 16 | network location accessible to a minimal set of networks. 17 | 18 | It is recommended that the daemon be configured to run over a current 19 | version of TLS using valid certificates. 20 | 21 | Disclosure of the private key 22 | ----------------------------- 23 | 24 | The private key of the cert authority must be kept secret at all times. 25 | A compromise of the key allows an attacker to sign any certificate and 26 | gain access to other systems. 27 | 28 | The ssh-cert-authority daemon does not have access to the private key at 29 | any time. We rely on the security of the ssh-agent daemon to keep the 30 | key as secure as possible (given that its simply sitting in memory). 31 | 32 | It is recommended that the ssh-cert-authority and corresponding 33 | ssh-agent daemon be run using an unprivileged service account that is 34 | not shared by other services on the same machine. 35 | 36 | It may be possible, especially with security focused kernels, to set 37 | ACLs on the ssh-agent socket that only allow the pid of the 38 | ssh-cert-authority daemon to connect to it. 39 | 40 | Sign certificate of an attacker 41 | ------------------------------- 42 | 43 | The ssh-cert-authority daemon must not accept the submission of requests 44 | from unauthorized users or signers. 45 | 46 | Authorized users and signers are configured in a json file sitting on 47 | the same server as the daemon. Were an attacker able to modify this file 48 | or simply the configuration information stored in the currently running 49 | daemon they could become an authorized user or signer. 50 | 51 | The ssh-cert-authority must defend against an attacker submitting 52 | signatures on either requests or signing commands using keys that are 53 | not in the config. 54 | 55 | Attacker inserts vulnerabilities into the code 56 | ---------------------------------------------- 57 | 58 | This project is open source for many good reasons. An attacker could 59 | develop a patch that potentially injects a vulnerability into the code 60 | that is activated at a later time. Code review will be used to mitigate 61 | these kinds of threats. 62 | 63 | Unauthorized user submits signature for request 64 | ----------------------------------------------- 65 | 66 | Including: 67 | cross-environment tweaks (request from one env signed by signer from 68 | a different one?) 69 | 70 | Against users 71 | ============= 72 | 73 | Private key compromise 74 | ---------------------- 75 | 76 | Users of this service must keep their private key file a secret. A 77 | compromised user key allows an attacker to submit cert requests (or 78 | signing commands) on behalf of the compromised key. 79 | 80 | ------ 81 | Design 82 | ------ 83 | 84 | The ssh-cert-authority daemon is an HTTP service that by default listens 85 | on port 8080. 86 | 87 | It uses json files for configuration. Configuration information includes 88 | sets of both authorized users and authorized signers that are identified 89 | by SSH key fingerprints. In addition the fingerprint of the signing key 90 | (the cert authority's key) is stored in configuration. 91 | 92 | Users are able to request certificates from the system. 93 | 94 | Signers are able to +1 requests. 95 | 96 | A certificate that has been +1'ed by the requisite number of people is 97 | signed by a local ssh-agent process. This means that the secret key for 98 | signing certificates is never stored or even made visible to the 99 | ssh-cert-authority daemon. 100 | 101 | Users are authenticated by signing requests for certificates and +1 102 | commands with their own SSH private key. 103 | 104 | Cert requests and signing commands are done using actual SSH 105 | certificates. This ensures that the entire block of relevant information 106 | is included in the signature that is being verified by the server. 107 | 108 | For example, an end to end request and sign: 109 | 110 | - Requester generates and signs a complete SSH certificate specifying parameters 111 | like lifetime and valid principals. 112 | - Server verifies that the certificate is valid and that it was signed 113 | by a user in the AuthorizedUsers configuration section. 114 | - Certificate is modified by the server in two ways: 115 | - KeyId is overwritten to be the value stored in the server's 116 | configuration file. This means that the server administrator has 117 | control over what value appears in the KeyId field since this is 118 | used for logging in many places outside of ssh-cert-authority. 119 | - The Serial field is overwritten to be the next serial number. When 120 | OpenSSH supports it this may be used for certificate revocation. 121 | OpenSSH does not support revocation today. To combat this it is 122 | recommended that certificates not be generated with long 123 | lifetimes. 124 | - Certificate is stored, verbatim, in memory. 125 | - A random request id is generated and returned the caller. 126 | - Signer downloads that exact certificate from the server using the 127 | request id. 128 | - Signer verifies the signature is valid 129 | - Signer, a user that is presumably human, decides to sign the certificate 130 | - The Nonce value in the certificate is made anew and the certificate is 131 | signed by the signer in the exact same way that the requester signs. 132 | - The server verifies that the certificate has a valid signature from a 133 | user in the AuthorizedSigners configuration section. 134 | - Server verifies that all fields in the signing command exactly match 135 | those of the original request (except signature, nonce, and signing 136 | key) 137 | - If the requisite number of signatures has been received the 138 | certificate request that was stored in memory (with updated KeyId and 139 | Serial fields) as part of the request is pushed over to the local 140 | ssh-agent and asked to be signed by the cert authority's private key. 141 | - Any one with the request id may download the signed certificate. 142 | 143 | The ssh-cert-authority tools and daemon never ever see or transit 144 | secrets. We deal only in public keys and certificates none of which need 145 | to be treated as secret. This limits the scope of the threats we protect 146 | against to be mostly around "don't sign a cert for someone that isn't 147 | supposed to request one" and "don't allow someone unauthorized to sign a 148 | cert." 149 | 150 | -------------------------------------------------------------------------------- /USAGE.rst: -------------------------------------------------------------------------------- 1 | ============ 2 | Using SSH CA 3 | ============ 4 | 5 | Requesting a signed certificate 6 | =============================== 7 | 8 | Super basic 9 | ----------- 10 | ssh-cert-authority request --environment stage --reason "Do important maintenance work" 11 | 12 | 13 | Configuration 14 | ------------- 15 | 16 | ssh_ca assumes that you have several environments each with different 17 | certificate authorities and that they are configured differently. For 18 | example you might have separate environments for staging and production 19 | each with different CAs. This tool supports that. 20 | 21 | Here's a sample requester config file. The default location for this is 22 | ``$HOME/.ssh_ca/requester_config.json`` :: 23 | { 24 | "stage": { 25 | "PublicKeyPath": "/Users/bob/.ssh/bvanzant-stage.pub", 26 | "SignerUrl": "http://ssh-ca:8080/" 27 | }, 28 | "prod": { 29 | "PublicKeyPath": "/Users/bob.ssh/bvanzant-prod.pub", 30 | "SignerUrl": "http://ssh-ca:8080/" 31 | } 32 | } 33 | 34 | The contents should be reasonably self explanatory. Here we have a json 35 | blob containing a stage and prod environment. The user has chosen to use 36 | different SSH keys for production and staging, however, this is not 37 | required. The ``SignerUrl`` is the location of the ssh-cert-authority daemon. 38 | 39 | Generating this configuration file can be a little cumbersome. The 40 | ``ssh-cert-authority`` program has a ``generate-config`` subcommand that 41 | tries to aid in generating this file. New users can run something like :: 42 | 43 | ssh-cert-authority generate-config --url https://ssh-ca.example.com 44 | 45 | And that will do two things. First, it goes to ssh-ca.example.com and 46 | requests a listing of the server's configured environments (e.g. "stage" 47 | and "prod"). Second it creates the configuration file inserting 48 | https://ssh-ca.example.com as the SignerUrl and $HOME/.ssh/id_rsa.pub as 49 | the PublicKeyPath. 50 | 51 | The generated config file is printed to stdout. You can redirect or 52 | manually copy it into the default location of 53 | ``~/.ssh_ca/requester_config.json``. 54 | 55 | Users using multiple SSH keys or keys other than id_rsa.pub will need to 56 | manually edit the configuration after it is generated. 57 | 58 | Making a request 59 | ---------------- 60 | 61 | Once configured requesting a certificate is as simple as:: 62 | 63 | ssh-cert-authority request --environment stage --reason "Do important maintenance work" 64 | 65 | This will print out a certificate request id like so:: 66 | 67 | Cert request id: HWK6CTFJDPTXRAXD7S6NZHO3 68 | 69 | Hand this id off to someone that can sign your cert. 70 | 71 | If instead you got an error like 72 | ``ssh-add the private half of the key you want to use.`` then go do that. 73 | Under the hood the ``request`` command is going to use your SSH 74 | private key to sign your certificate request. This is how the signing 75 | daemon knows that you are the person that actually requested the cert 76 | (you have control of the private key and your private key is actually 77 | private, right?). This is typically as simple as ``ssh-add ~/.ssh/id_rsa`` 78 | but if you've got lots of keys or per-environment config you'll need to 79 | adjust and ensure you both add the right key and that your 80 | ``requester_config.json`` is specifying the right public key. 81 | 82 | Signing certificates 83 | ==================== 84 | 85 | Super basic 86 | ----------- 87 | ssh-cert-authority sign --environment stage HWK6CTFJDPTXRAXD7S6NZHO3 88 | 89 | Configuration 90 | ------------- 91 | 92 | A sample signer config. By default this is in 93 | ``$HOME/.ssh_ca/signer_config.json`` :: 94 | 95 | { 96 | "stage": { 97 | "KeyFingerprint": "66:b5:be:e5:7e:09:3f:98:97:36:9b:64:ec:ea:3a:fe", 98 | "SignerUrl": "http://ssh-ca:8080/" 99 | }, 100 | "prod": { 101 | "KeyFingerprint": "66:b5:be:e5:7e:09:3f:98:97:36:9b:64:ec:ea:3a:fe", 102 | "SignerUrl": "http://ssh-ca:8080/" 103 | } 104 | } 105 | 106 | In this case the configuration is slightly different from requesting 107 | because we're dealing with fingerprints instead of paths. You can easily 108 | get the fingerprint of the private key you want to sign with by doing 109 | ``ssh-keygen -l -f ~/.ssh/id_rsa`` or inspecting the output of ``ssh-add 110 | -l`` (the ``ssh-add -l`` output is only relevant if your private key is 111 | loaded in your agent). 112 | 113 | In recent versions of OpenSSH the fingerprint format has changed from 114 | MD5 (shown above) to sha256. If you fingerprint is not colon separated 115 | like above you need to tell OpenSSH to give you an MD5 fingerprint 116 | instead via the -E md5 option. For example: ``ssh-keygen -l -E md5 -f 117 | ~/.ssh/id_rsa``. When passing in md5 do not include the "MD5:" prefix on 118 | the fingerprint. 119 | 120 | Github issue #23 is tracking supporting sha256 (and sha384, etc). 121 | 122 | Signing a request 123 | ----------------- 124 | 125 | A word of caution: Treat signing requests very seriously. This is easily 126 | the weak point in the entire system. Inspect requests intently and look 127 | for violations of your policy on shell access to machines. 128 | 129 | The signing portion of a request begins when someone sends you a request 130 | id. You then sign it by:: 131 | $ ssh-cert-authority sign --environment stage HWK6CTFJDPTXRAXD7S6NZHO3 132 | Certificate data: 133 | Serial: 2 134 | Key id: bvanzant+stage@brkt.com 135 | Principals: [ec2-user ubuntu] 136 | Options: map[] 137 | Permissions: map[permit-agent-forwarding: permit-port-forwarding: permit-pty:] 138 | Valid for public key: 1c:fd:36:27:db:48:3f:ad:e2:fe:55:45:67:b1:47:99 139 | Valid from 2015-03-31 08:21:39 -0700 PDT - 2015-03-31 10:21:39 -0700 PDT 140 | Type 'yes' if you'd like to sign this cert request, 'reject' to reject it, anything else to cancel 141 | 142 | Inspect every field and compare it to what you know about who is requesting 143 | this certificate and why. I'll provide a brief explanation of these here 144 | but for more information checkout the ``CERTIFICATES`` section of 145 | ``ssh-keygen(1)`` 146 | 147 | - Does the key id match with who requested the cert? 148 | - Principals specifies the list of usernames that a requester can 149 | use to login to systems as. In our example here the user is 150 | allowed to use ``ec2-user`` and ``ubuntu``. 151 | - Permissions is a list of ssh permissions that this cert grants. In 152 | particular ``permit-pty`` will allow the user to open up a shell. Here 153 | we also see ``permit-agent-forwarding`` which allows the user to 154 | forward along their ``ssh-agent`` connection (generally useful) and 155 | ``permit-port-forwarding`` which allows the user of this cert to 156 | forward ports along connections. 157 | 158 | Also inspect the validity period. What is normal for your organization? 159 | In general the less time a certificate is valid for the less likely it 160 | is to be abused. sign_cert will print out the expiry time of a 161 | certificate in red if the value is more than 48 hours in the future. 162 | 163 | If you, as a signer, are happy with the certificate request you can type 164 | ``yes`` and the certificate will be, effectively, +1'ed by you. 165 | 166 | If you believe this request is a Bad Idea and should not be approved by 167 | anyone you can reject it forcefully and authoritatively by typing 168 | ``reject``. This will permanently mark the request as rejected and it can 169 | never be signed after that. 170 | 171 | Any other input is ignored and sign_cert exits. 172 | 173 | In order for sign_cert to run your SSH key must be loaded in ``ssh-agent`` 174 | (via ``ssh-add``). Otherwise ``sign`` will exit with an error:: 175 | 176 | ssh-add the private half of the key you want to use. 177 | 178 | Downloading a signed certificate 179 | ================================ 180 | 181 | Super basic 182 | ----------- 183 | ssh-cert-authority get --environment stage HWK6CTFJDPTXRAXD7S6NZHO3 184 | 185 | Configuration 186 | ------------- 187 | 188 | The get command uses the ``requester_config.json`` file described under 189 | requesting a certificate. 190 | 191 | -------------------------------------------------------------------------------- /build.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash -e 2 | 3 | go get 4 | make test ssh-cert-authority-linux-amd64.gz ssh-cert-authority-darwin-amd64.gz 5 | 6 | -------------------------------------------------------------------------------- /client/client.go: -------------------------------------------------------------------------------- 1 | package ssh_ca_client 2 | 3 | import ( 4 | "encoding/base64" 5 | "encoding/json" 6 | "fmt" 7 | "github.com/cloudtools/ssh-cert-authority/util" 8 | "golang.org/x/crypto/ssh" 9 | "io/ioutil" 10 | "net/http" 11 | "net/url" 12 | "strings" 13 | "time" 14 | ) 15 | 16 | type CertRequest struct { 17 | environment string 18 | reason string 19 | validAfter uint64 20 | validBefore uint64 21 | principals []string 22 | publicKey ssh.PublicKey 23 | keyID string 24 | config ssh_ca_util.RequesterConfig 25 | } 26 | 27 | func MakeCertRequest() CertRequest { 28 | var request CertRequest 29 | return request 30 | } 31 | 32 | type SigningRequest struct { 33 | signedCert ssh.Certificate 34 | requestID string 35 | config ssh_ca_util.SignerConfig 36 | } 37 | 38 | func MakeSigningRequest(cert ssh.Certificate, requestID string, config ssh_ca_util.SignerConfig) SigningRequest { 39 | var request SigningRequest 40 | request.signedCert = cert 41 | request.requestID = requestID 42 | request.config = config 43 | return request 44 | } 45 | 46 | func (req *SigningRequest) BuildWebRequest() url.Values { 47 | signedRequest := req.signedCert.Marshal() 48 | requestParameters := make(url.Values) 49 | requestParameters["cert"] = make([]string, 1) 50 | requestParameters["cert"][0] = base64.StdEncoding.EncodeToString(signedRequest) 51 | return requestParameters 52 | } 53 | 54 | func (req *SigningRequest) PostToWeb(requestParameters url.Values) error { 55 | resp, err := http.PostForm(req.config.SignerUrl+"cert/requests/"+req.requestID, requestParameters) 56 | if err != nil { 57 | return err 58 | } 59 | defer resp.Body.Close() 60 | if resp.StatusCode != 200 { 61 | respBuf, _ := ioutil.ReadAll(resp.Body) 62 | return fmt.Errorf("HTTP %s: %s", resp.Status, string(respBuf)) 63 | } 64 | return nil 65 | } 66 | 67 | func (req *SigningRequest) DeleteToWeb(requestParameters url.Values) error { 68 | var client http.Client 69 | encodedParameters := requestParameters.Encode() 70 | request, err := http.NewRequest("DELETE", req.config.SignerUrl+"cert/requests/"+req.requestID+"?"+encodedParameters, nil) 71 | if err != nil { 72 | return err 73 | } 74 | request.Header.Add("Content-Type", "application/x-www-form-urlencoded") 75 | resp, err := client.Do(request) 76 | if err != nil { 77 | return err 78 | } 79 | defer resp.Body.Close() 80 | if resp.StatusCode != 200 { 81 | respBuf, _ := ioutil.ReadAll(resp.Body) 82 | return fmt.Errorf("HTTP %s: %s", resp.Status, string(respBuf)) 83 | } 84 | return nil 85 | } 86 | 87 | func (req *CertRequest) SetConfig(config ssh_ca_util.RequesterConfig) error { 88 | if config.SignerUrl == "" { 89 | return fmt.Errorf("Signer URL is empty. This isn't going to work") 90 | } 91 | req.config = config 92 | return nil 93 | } 94 | 95 | func (req *CertRequest) SetEnvironment(environment string) error { 96 | if environment == "" { 97 | return fmt.Errorf("Environment must be set.") 98 | } 99 | if len(environment) > 50 { 100 | return fmt.Errorf("Environment is too long.") 101 | } 102 | req.environment = environment 103 | return nil 104 | } 105 | 106 | func (req *CertRequest) SetReason(reason string) error { 107 | if reason == "" { 108 | return fmt.Errorf("You must specify a reason.") 109 | } 110 | if len(reason) > 255 { 111 | return fmt.Errorf("Reason is too long.") 112 | } 113 | req.reason = reason 114 | return nil 115 | } 116 | 117 | func (req *CertRequest) SetValidAfter(validAfter time.Duration) error { 118 | timeNow := time.Now().Unix() 119 | req.validAfter = uint64(timeNow + int64(validAfter.Seconds())) 120 | return nil 121 | } 122 | 123 | func (req *CertRequest) SetValidBefore(validBefore time.Duration) error { 124 | timeNow := time.Now().Unix() 125 | req.validBefore = uint64(timeNow + int64(validBefore.Seconds())) 126 | return nil 127 | } 128 | 129 | func (req *CertRequest) SetPrincipalsFromString(principalsStr string) error { 130 | principals := strings.Split(strings.TrimSpace(principalsStr), ",") 131 | if principalsStr == "" { 132 | return fmt.Errorf("You didn't specify any principals. This cert is worthless.") 133 | } 134 | req.principals = principals 135 | return nil 136 | } 137 | 138 | func (req *CertRequest) SetPublicKey(pubKey ssh.PublicKey, keyID string) error { 139 | req.publicKey = pubKey 140 | req.keyID = keyID 141 | return nil 142 | } 143 | 144 | func (req *CertRequest) Validate() error { 145 | if req.validAfter >= req.validBefore { 146 | return fmt.Errorf("valid-after (%v) >= valid-before (%v). Which does not make sense.\n", 147 | time.Unix(int64(req.validAfter), 0), time.Unix(int64(req.validBefore), 0)) 148 | } 149 | return nil 150 | } 151 | 152 | func (req *CertRequest) EncodeAsCertificate() (*ssh.Certificate, error) { 153 | err := req.Validate() 154 | if err != nil { 155 | return nil, err 156 | } 157 | 158 | newCert := ssh_ca_util.MakeCertificate() 159 | newCert.Key = req.publicKey 160 | newCert.Serial = 0 161 | newCert.CertType = ssh.UserCert 162 | newCert.KeyId = req.keyID 163 | newCert.ValidPrincipals = req.principals 164 | newCert.ValidAfter = req.validAfter 165 | newCert.ValidBefore = req.validBefore 166 | newCert.Extensions = make(map[string]string) 167 | newCert.Extensions["permit-agent-forwarding"] = "" 168 | newCert.Extensions["permit-port-forwarding"] = "" 169 | newCert.Extensions["permit-pty"] = "" 170 | newCert.Extensions["reason@cloudtools.github.io"] = req.reason 171 | newCert.Extensions["environment@cloudtools.github.io"] = req.environment 172 | return &newCert, nil 173 | } 174 | 175 | func (req *CertRequest) BuildWebRequest(signedCert []byte) url.Values { 176 | requestParameters := make(url.Values) 177 | requestParameters["cert"] = make([]string, 1) 178 | requestParameters["cert"][0] = base64.StdEncoding.EncodeToString(signedCert) 179 | 180 | return requestParameters 181 | } 182 | 183 | func (req *CertRequest) PostToWeb(requestParameters url.Values) (string, bool, error) { 184 | resp, err := http.PostForm(req.config.SignerUrl+"cert/requests", requestParameters) 185 | if err != nil { 186 | return "", false, err 187 | } 188 | respBuf, err := ioutil.ReadAll(resp.Body) 189 | resp.Body.Close() 190 | if err != nil { 191 | return "", false, err 192 | } 193 | if resp.StatusCode == 201 || resp.StatusCode == 202 { 194 | signed := resp.StatusCode == 202 195 | return string(respBuf), signed, nil 196 | } 197 | return "", false, fmt.Errorf("Cert request rejected: %s", string(respBuf)) 198 | } 199 | 200 | type SlackWebhookInput struct { 201 | Text string `json:"text"` 202 | Channel string `json:"channel"` 203 | } 204 | 205 | func PostToSlack(slackUrl, slackChannel, msg string) error { 206 | if slackUrl == "" || slackChannel == "" { 207 | return nil 208 | } 209 | var webhookInput SlackWebhookInput 210 | webhookInput.Text = msg 211 | if slackChannel != "" { 212 | webhookInput.Channel = slackChannel 213 | } 214 | output, err := json.Marshal(webhookInput) 215 | if err != nil { 216 | return err 217 | } 218 | requestParameters := make(url.Values) 219 | requestParameters["payload"] = make([]string, 1) 220 | requestParameters["payload"][0] = string(output) 221 | 222 | resp, err := http.PostForm(slackUrl, requestParameters) 223 | if err != nil { 224 | return err 225 | } 226 | respBuf, err := ioutil.ReadAll(resp.Body) 227 | resp.Body.Close() 228 | if err != nil { 229 | return err 230 | } 231 | if resp.StatusCode == 200 { 232 | return nil 233 | } 234 | return fmt.Errorf("Slack post rejected: %s", string(respBuf)) 235 | } 236 | -------------------------------------------------------------------------------- /client/client_test.go: -------------------------------------------------------------------------------- 1 | package ssh_ca_client 2 | 3 | import ( 4 | "golang.org/x/crypto/ssh" 5 | "testing" 6 | "time" 7 | ) 8 | 9 | const samplePublicKeyString string = "ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAABAQCqwK8IchsTOuW3snG3MuZHjINw8YNR4T0+jlSgjH/d93bN2fuABVvlxRiAjSNNWTmuln8Jyto5PFP4/FqDgjhra3qIb6luf1XPmlnHH23/o56RS3boNDsaXAPIPhghwODjPZX2dau5jAC2y0zvcSJv0nXrpDthdHiUjYLjHKcSSpSoTAXHV2yOYe+hQ6rA+ZwVevXQrkR1mexlm0eOdNxC4AsTp7kE5E4IN6Pa4w2K5axNa9cZ1MSh9afySpmc1dinbrUmqBFtOLh8tarPsuDcTGso9jWGH/06ENkU8jTP7YKf7J0YlSsye/iuVEASYz8w1M6PlK5D06VYp0P6flgP a-testing-key" 10 | 11 | func TestMakeRequest(t *testing.T) { 12 | req := MakeCertRequest() 13 | if req.environment != "" && req.reason != "" { 14 | t.Errorf("Failed the very basic task of making an empty request") 15 | } 16 | } 17 | 18 | func TestEmptyIsInvalid(t *testing.T) { 19 | req := MakeCertRequest() 20 | err := req.Validate() 21 | if err == nil { 22 | t.Errorf("An empty request was somehow valid.") 23 | } 24 | } 25 | 26 | func TestValid(t *testing.T) { 27 | req := MakeCertRequest() 28 | req.SetEnvironment("testing") 29 | req.SetReason("this is a test of the emergency broadcast system") 30 | dur, _ := time.ParseDuration("+2d") 31 | req.SetValidBefore(dur) 32 | dur, _ = time.ParseDuration("-2m") 33 | req.SetValidAfter(dur) 34 | req.SetPrincipalsFromString("ubuntu") 35 | pubKey, comment, _, _, _ := ssh.ParseAuthorizedKey([]byte(samplePublicKeyString)) 36 | req.SetPublicKey(pubKey, comment) 37 | err := req.Validate() 38 | if err != nil { 39 | t.Fatalf("Cert that should have been valid didn't validate: %v", err) 40 | } 41 | cert, err := req.EncodeAsCertificate() 42 | if err != nil { 43 | t.Fatalf("Unable to make a certificate: %v", err) 44 | } 45 | 46 | if cert.Key != req.publicKey { 47 | t.Fatalf("Public key not set correctly.") 48 | } 49 | if cert.Serial != 0 { 50 | t.Fatalf("Serial not valid.") 51 | } 52 | if cert.CertType != ssh.UserCert { 53 | t.Fatalf("Cert isn't a user cert?.") 54 | } 55 | if cert.KeyId != req.keyID { 56 | t.Fatalf("key ids don't match") 57 | } 58 | // Use len of slice as a proxy for equality of slice 59 | if len(cert.ValidPrincipals) != len(req.principals) { 60 | t.Fatalf("principals mismatch") 61 | } 62 | if cert.ValidAfter != req.validAfter { 63 | t.Fatalf("valid after mismatch") 64 | } 65 | if cert.ValidBefore != req.validBefore { 66 | t.Fatalf("valid before mismatch") 67 | } 68 | } 69 | -------------------------------------------------------------------------------- /encrypt.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "bufio" 5 | "crypto/ecdsa" 6 | "crypto/elliptic" 7 | "crypto/rand" 8 | "crypto/rsa" 9 | "crypto/x509" 10 | "encoding/pem" 11 | "fmt" 12 | "github.com/aws/aws-sdk-go/aws" 13 | "github.com/aws/aws-sdk-go/aws/session" 14 | "github.com/aws/aws-sdk-go/service/kms" 15 | "github.com/codegangsta/cli" 16 | "golang.org/x/crypto/ssh" 17 | "io/ioutil" 18 | "os" 19 | "regexp" 20 | ) 21 | 22 | // If you update this regex know that despite my naming the match groups it's 23 | // the order that matters. See uses of keyIdRegex in this file and update 24 | // accordingly. 25 | var keyIdRegex = regexp.MustCompile("arn:aws:kms:(?P[^:]+):(?P[^:]+):(?P[^:]+)") 26 | 27 | func encryptFlags() []cli.Flag { 28 | return []cli.Flag{ 29 | cli.StringFlag{ 30 | Name: "key-id", 31 | Value: "", 32 | Usage: "The ARN of the KMS key to use", 33 | }, 34 | cli.StringFlag{ 35 | Name: "output", 36 | Value: "ca-key.kms", 37 | Usage: "The filename for key output", 38 | }, 39 | cli.BoolFlag{ 40 | Name: "generate-ecdsa", 41 | Usage: "When set generate an ECDSA key from Curve P384", 42 | }, 43 | cli.BoolFlag{ 44 | Name: "generate-rsa", 45 | Usage: "When set generate a 4096 bit RSA key", 46 | }, 47 | } 48 | } 49 | 50 | func generateRsa() ([]byte, error) { 51 | key, err := rsa.GenerateKey(rand.Reader, 4096) 52 | if err != nil { 53 | return nil, err 54 | } 55 | derBlock := x509.MarshalPKCS1PrivateKey(key) 56 | pemBlock := &pem.Block{ 57 | Type: "RSA PRIVATE KEY", 58 | Bytes: derBlock, 59 | } 60 | return pem.EncodeToMemory(pemBlock), nil 61 | } 62 | 63 | func generateEcdsa() ([]byte, error) { 64 | key, err := ecdsa.GenerateKey(elliptic.P384(), rand.Reader) 65 | if err != nil { 66 | return nil, err 67 | } 68 | derBlock, err := x509.MarshalECPrivateKey(key) 69 | if err != nil { 70 | return nil, err 71 | } 72 | pemBlock := &pem.Block{ 73 | Type: "EC PRIVATE KEY", 74 | Bytes: derBlock, 75 | } 76 | return pem.EncodeToMemory(pemBlock), nil 77 | } 78 | 79 | func cmdEncryptKey(c *cli.Context) error { 80 | var err error 81 | keyId := c.String("key-id") 82 | regexResults := keyIdRegex.FindStringSubmatch(keyId) 83 | if regexResults == nil { 84 | return cli.NewExitError("--key-id doesn't look like an AWS KMS ARN.", 1) 85 | } 86 | region := regexResults[1] 87 | 88 | var ciphertext []byte 89 | if c.Bool("generate-ecdsa") || c.Bool("generate-rsa") { 90 | var key []byte 91 | if c.Bool("generate-ecdsa") { 92 | key, err = generateEcdsa() 93 | } else { 94 | key, err = generateRsa() 95 | } 96 | if err != nil { 97 | return cli.NewExitError(fmt.Sprintf("Unable to generate key: %s", err), 1) 98 | } 99 | ciphertext, err = encryptKey(key, region, keyId) 100 | if err != nil { 101 | return cli.NewExitError(fmt.Sprintf("Unable to generate ecdsa key: %s", err), 1) 102 | } 103 | signer, err := ssh.ParsePrivateKey(key) 104 | if err != nil { 105 | return cli.NewExitError(fmt.Sprintf("Unable to parse generated private key: %s", err), 1) 106 | } 107 | err = ioutil.WriteFile(c.String("output")+".pub", ssh.MarshalAuthorizedKey(signer.PublicKey()), 0644) 108 | if err != nil { 109 | return cli.NewExitError(fmt.Sprintf("Unable to write new public key: %s", err), 1) 110 | } 111 | } else { 112 | ciphertext, err = encryptKeyFromStdin(keyId, region) 113 | if err != nil { 114 | return cli.NewExitError(fmt.Sprintf("Failed to encrypt key: %s", err), 1) 115 | } 116 | } 117 | err = ioutil.WriteFile(c.String("output"), ciphertext, 0644) 118 | if err != nil { 119 | return cli.NewExitError(fmt.Sprintf("Unable to write new encrypted private key: %s", err), 1) 120 | } 121 | return nil 122 | } 123 | 124 | func encryptKeyFromStdin(keyId, region string) ([]byte, error) { 125 | keyContents, err := ioutil.ReadAll(bufio.NewReader(os.Stdin)) 126 | if err != nil { 127 | return nil, cli.NewExitError(fmt.Sprintf("Unable to read private key: %s", err), 1) 128 | } 129 | return encryptKey(keyContents, region, keyId) 130 | } 131 | 132 | func encryptKey(plaintextKey []byte, region, kmsKeyId string) ([]byte, error) { 133 | svc := kms.New(session.New(), aws.NewConfig().WithRegion(region)) 134 | params := &kms.EncryptInput{ 135 | Plaintext: plaintextKey, 136 | KeyId: aws.String(kmsKeyId), 137 | } 138 | resp, err := svc.Encrypt(params) 139 | if err != nil { 140 | return nil, fmt.Errorf("Unable to Encrypt CA key: %v\n", err) 141 | } 142 | return []byte(resp.CiphertextBlob), nil 143 | } 144 | -------------------------------------------------------------------------------- /encrypt_test.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "strings" 5 | "testing" 6 | ) 7 | 8 | func TestRsaGeneration(t *testing.T) { 9 | rsaKey, err := generateRsa() 10 | if err != nil { 11 | t.Errorf("failed to generate rsa key: %s", err) 12 | } 13 | if !strings.Contains(string(rsaKey), "RSA PRIVATE KEY") { 14 | t.Errorf("Didn't generate an RSA key?: %s", rsaKey) 15 | } 16 | } 17 | 18 | func TestEcdsaGeneration(t *testing.T) { 19 | ecdsaKey, err := generateEcdsa() 20 | if err != nil { 21 | t.Errorf("failed to generate ecdsa key: %s", err) 22 | } 23 | if !strings.Contains(string(ecdsaKey), "EC PRIVATE KEY") { 24 | t.Errorf("Didn't generate an ECDSA key?: %s", ecdsaKey) 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /examples/startup/ssh-cert-authority.conf: -------------------------------------------------------------------------------- 1 | # this is for upstart 2 | start on runlevel [2345] 3 | setuid ubuntu 4 | setgid ubuntu 5 | 6 | script 7 | export HOME=/home/ubuntu 8 | ssh-agent /usr/local/bin/ssh-cert-authority runserver 9 | end script 10 | -------------------------------------------------------------------------------- /generate_config.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "encoding/json" 5 | "fmt" 6 | "github.com/cloudtools/ssh-cert-authority/util" 7 | "github.com/codegangsta/cli" 8 | "io/ioutil" 9 | "net/http" 10 | "os" 11 | "strings" 12 | ) 13 | 14 | func generateConfigFlags() []cli.Flag { 15 | home := os.Getenv("HOME") 16 | if home == "" { 17 | home = "/" 18 | } 19 | return []cli.Flag{ 20 | cli.StringFlag{ 21 | Name: "url, u", 22 | Value: "", 23 | Usage: "An ssh-cert-authority url (e.g. https://ssh-cert-authority.example.com).", 24 | }, 25 | cli.StringFlag{ 26 | Name: "key-file, k", 27 | Value: fmt.Sprintf("%s/.ssh/id_rsa.pub", home), 28 | Usage: "Path to your SSH public key. The filename will be inserted into the generated config file.", 29 | }, 30 | } 31 | } 32 | 33 | func cmdGenerateConfig(c *cli.Context) error { 34 | 35 | url := c.String("url") 36 | if url == "" { 37 | return cli.NewExitError("url is a required option.", 1) 38 | } 39 | if !strings.HasSuffix(url, "/") { 40 | url = url + "/" 41 | } 42 | 43 | getResp, err := http.Get(url + "config/environments") 44 | if err != nil { 45 | return cli.NewExitError(fmt.Sprintf("Didn't get a valid response: %s", err), 1) 46 | } 47 | 48 | getRespBuf, err := ioutil.ReadAll(getResp.Body) 49 | if err != nil { 50 | return cli.NewExitError(fmt.Sprintf("Error reading response body: %s", err), 1) 51 | } 52 | getResp.Body.Close() 53 | if getResp.StatusCode != 200 { 54 | return cli.NewExitError(fmt.Sprintf("Error getting listing of environments: %s", string(getRespBuf)), 1) 55 | } 56 | 57 | var environments []string 58 | json.Unmarshal(getRespBuf, &environments) 59 | 60 | wholeConfig := make(map[string]ssh_ca_util.RequesterConfig, len(environments)) 61 | for _, envName := range environments { 62 | wholeConfig[envName] = ssh_ca_util.RequesterConfig{ 63 | PublicKeyPath: c.String("key-file"), 64 | SignerUrl: url, 65 | } 66 | } 67 | result, err := json.MarshalIndent(wholeConfig, "", " ") 68 | if err != nil { 69 | return cli.NewExitError(fmt.Sprintf("Failed to serialize config file: %v", err), 1) 70 | } 71 | fmt.Print(string(result)) 72 | return nil 73 | } 74 | -------------------------------------------------------------------------------- /get_cert.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "bytes" 5 | "fmt" 6 | "github.com/cloudtools/ssh-cert-authority/util" 7 | "github.com/codegangsta/cli" 8 | "golang.org/x/crypto/ssh" 9 | "io/ioutil" 10 | "net/http" 11 | "os" 12 | "os/exec" 13 | "strings" 14 | "time" 15 | ) 16 | 17 | func getCertFlags() []cli.Flag { 18 | home := os.Getenv("HOME") 19 | if home == "" { 20 | home = "/" 21 | } 22 | configPath := home + "/.ssh_ca/requester_config.json" 23 | 24 | return []cli.Flag{ 25 | cli.StringFlag{ 26 | Name: "environment, e", 27 | Value: "", 28 | Usage: "An environment name (e.g. prod)", 29 | }, 30 | cli.StringFlag{ 31 | Name: "config-file", 32 | Value: configPath, 33 | Usage: "Path to config.json", 34 | }, 35 | cli.BoolTFlag{ 36 | Name: "add-key", 37 | Usage: "When set automatically call ssh-add", 38 | }, 39 | cli.StringFlag{ 40 | Name: "ssh-dir", 41 | Value: os.Getenv("HOME") + "/.ssh", 42 | Usage: "Directory where SSH identity files (like 'id_rsa') reside", 43 | }, 44 | } 45 | } 46 | 47 | func getCert(c *cli.Context) error { 48 | 49 | configPath := c.String("config-file") 50 | environment := c.String("environment") 51 | sshDir := c.String("ssh-dir") 52 | certRequestID := c.Args().First() 53 | 54 | allConfig := make(map[string]ssh_ca_util.RequesterConfig) 55 | err := ssh_ca_util.LoadConfig(configPath, &allConfig) 56 | if err != nil { 57 | return cli.NewExitError(fmt.Sprintf("%s", err), 1) 58 | } 59 | wrongTypeConfig, err := ssh_ca_util.GetConfigForEnv(environment, &allConfig) 60 | if err != nil { 61 | return cli.NewExitError(fmt.Sprintf("%s", err), 1) 62 | } 63 | config := wrongTypeConfig.(ssh_ca_util.RequesterConfig) 64 | cert, err := downloadCert(config, certRequestID, sshDir) 65 | if err != nil { 66 | return cli.NewExitError(fmt.Sprintf("%s", err), 1) 67 | } 68 | if c.BoolT("add-key") { 69 | err = addCertToAgent(cert, sshDir) 70 | if err != nil { 71 | return cli.NewExitError(fmt.Sprintf("%s", err), 1) 72 | } 73 | } 74 | return nil 75 | } 76 | 77 | func addCertToAgent(cert *ssh.Certificate, sshDir string) error { 78 | secondsRemaining := int64(cert.ValidBefore) - int64(time.Now().Unix()) 79 | if secondsRemaining < 1 { 80 | return fmt.Errorf("This certificate has already expired.") 81 | } 82 | pubKeyPath, err := findKeyLocally(cert.Key, sshDir) 83 | privKeyPath := strings.Replace(pubKeyPath, ".pub", "", 1) 84 | fmt.Printf("pubkey %s, privkey %s\n", pubKeyPath, privKeyPath) 85 | cmd := exec.Command("ssh-add", "-t", fmt.Sprintf("%d", secondsRemaining), privKeyPath) 86 | cmd.Stdout = os.Stdout 87 | cmd.Stderr = os.Stderr 88 | cmd.Stdin = os.Stdin 89 | err = cmd.Run() 90 | if err != nil { 91 | return fmt.Errorf("Error in ssh-add: %s", err) 92 | } 93 | return nil 94 | } 95 | 96 | func downloadCert(config ssh_ca_util.RequesterConfig, certRequestID string, sshDir string) (*ssh.Certificate, error) { 97 | getResp, err := http.Get(config.SignerUrl + "cert/requests/" + certRequestID) 98 | if err != nil { 99 | return nil, fmt.Errorf("Didn't get a valid response: %s", err) 100 | } 101 | getRespBuf, err := ioutil.ReadAll(getResp.Body) 102 | if err != nil { 103 | return nil, fmt.Errorf("Error reading response body: %s", err) 104 | } 105 | getResp.Body.Close() 106 | if getResp.StatusCode != 200 { 107 | return nil, fmt.Errorf("Error getting that request id: %s", string(getRespBuf)) 108 | } 109 | 110 | pubKey, _, _, _, err := ssh.ParseAuthorizedKey(getRespBuf) 111 | if err != nil { 112 | return nil, fmt.Errorf("Trouble parsing response: %s", err) 113 | } 114 | cert := pubKey.(*ssh.Certificate) 115 | 116 | pubKeyPath, err := findKeyLocally(cert.Key, sshDir) 117 | 118 | if err != nil { 119 | return nil, err 120 | } 121 | pubKeyPath = strings.Replace(pubKeyPath, ".pub", "-cert.pub", 1) 122 | err = ioutil.WriteFile(pubKeyPath, getRespBuf, 0644) 123 | if err != nil { 124 | fmt.Printf("Couldn't write certificate file to %s: %s\n", pubKeyPath, err) 125 | } 126 | 127 | ssh_ca_util.PrintForInspection(*cert) 128 | return cert, nil 129 | } 130 | 131 | func findKeyLocally(key ssh.PublicKey, sshDir string) (string, error) { 132 | dirEntries, err := ioutil.ReadDir(sshDir) 133 | if err != nil { 134 | return "", fmt.Errorf("Could not read your .ssh directory %s: %s\n", sshDir, err) 135 | } 136 | for idx := range dirEntries { 137 | entry := dirEntries[idx] 138 | if strings.HasSuffix(entry.Name(), ".pub") { 139 | pubKeyPath := sshDir + "/" + entry.Name() 140 | pubBuf, err := ioutil.ReadFile(pubKeyPath) 141 | if err != nil { 142 | fmt.Printf("Trouble reading public key %s: %s\n", pubKeyPath, err) 143 | continue 144 | } 145 | pubKey, _, _, _, err := ssh.ParseAuthorizedKey(pubBuf) 146 | if err != nil { 147 | fmt.Printf("Trouble parsing public key %s (might be unsupported format): %s\n", pubKeyPath, err) 148 | continue 149 | } 150 | if bytes.Equal(pubKey.Marshal(), key.Marshal()) { 151 | return pubKeyPath, nil 152 | } 153 | } 154 | } 155 | return "", fmt.Errorf("Couldn't find ssh key for cert.\n") 156 | } 157 | -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module github.com/cloudtools/ssh-cert-authority 2 | 3 | require ( 4 | cloud.google.com/go v0.33.0 5 | cloud.google.com/go/compute/metadata v0.0.0-20181115181204-d50f0e9b2506 // indirect 6 | github.com/aws/aws-sdk-go v1.15.76 7 | github.com/codegangsta/cli v1.20.0 8 | github.com/davecgh/go-spew v1.1.1 // indirect 9 | github.com/googleapis/gax-go v2.0.2+incompatible // indirect 10 | github.com/gorilla/context v1.1.1 // indirect 11 | github.com/gorilla/handlers v1.4.0 12 | github.com/gorilla/mux v1.6.2 13 | github.com/pmezard/go-difflib v1.0.0 // indirect 14 | github.com/stretchr/testify v1.2.2 // indirect 15 | go.opencensus.io v0.18.0 // indirect 16 | golang.org/x/crypto v0.0.0-20181112202954-3d3f9f413869 17 | golang.org/x/net v0.0.0-20181114220301-adae6a3d119a // indirect 18 | golang.org/x/oauth2 v0.0.0-20181106182150-f42d05182288 19 | golang.org/x/sys v0.0.0-20181107165924-66b7b1311ac8 // indirect 20 | google.golang.org/api v0.0.0-20181114235557-83a9d304b1e6 21 | google.golang.org/genproto v0.0.0-20181109154231-b5d43981345b 22 | ) 23 | -------------------------------------------------------------------------------- /go.sum: -------------------------------------------------------------------------------- 1 | cloud.google.com/go v0.26.0/go.mod h1:aQUYkXzVsufM+DwF1aE+0xfcU+56JwCaLick0ClmMTw= 2 | cloud.google.com/go v0.33.0 h1:1kNZapR5iXMPsPEca6Rqg+EN4/8/ZukNjMdwNQEllWk= 3 | cloud.google.com/go v0.33.0/go.mod h1:aQUYkXzVsufM+DwF1aE+0xfcU+56JwCaLick0ClmMTw= 4 | cloud.google.com/go/compute/metadata v0.0.0-20181115181204-d50f0e9b2506 h1:toHF+GJCU8Zr/qhrb6FOELllmvo6e+Np7FdhZFX9SHA= 5 | cloud.google.com/go/compute/metadata v0.0.0-20181115181204-d50f0e9b2506/go.mod h1:bDzgiyYlSneEi8ypjdQR5QS9yAMUX2nlrSb6UVd6Ghk= 6 | git.apache.org/thrift.git v0.0.0-20180902110319-2566ecd5d999/go.mod h1:fPE2ZNJGynbRyZ4dJvy6G277gSllfV2HJqblrnkyeyg= 7 | github.com/aws/aws-sdk-go v1.15.76 h1:AZB4clNWIk13YJaTm07kqyrHkj7gZYBQCgyTh/v4Sec= 8 | github.com/aws/aws-sdk-go v1.15.76/go.mod h1:E3/ieXAlvM0XWO57iftYVDLLvQ824smPP3ATZkfNZeM= 9 | github.com/beorn7/perks v0.0.0-20180321164747-3a771d992973/go.mod h1:Dwedo/Wpr24TaqPxmxbtue+5NUziq4I4S80YR8gNf3Q= 10 | github.com/client9/misspell v0.3.4/go.mod h1:qj6jICC3Q7zFZvVWo7KLAzC3yx5G7kyvSDkc90ppPyw= 11 | github.com/codegangsta/cli v1.20.0 h1:iX1FXEgwzd5+XN6wk5cVHOGQj6Q3Dcp20lUeS4lHNTw= 12 | github.com/codegangsta/cli v1.20.0/go.mod h1:/qJNoX69yVSKu5o4jLyXAENLRyk1uhi7zkbQ3slBdOA= 13 | github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= 14 | github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 15 | github.com/ghodss/yaml v1.0.0/go.mod h1:4dBDuWmgqj2HViK6kFavaiC9ZROes6MMH2rRYeMEF04= 16 | github.com/golang/glog v0.0.0-20160126235308-23def4e6c14b/go.mod h1:SBH7ygxi8pfUlaOkMMuAQtPIUF8ecWP5IEl/CR7VP2Q= 17 | github.com/golang/lint v0.0.0-20180702182130-06c8688daad7/go.mod h1:tluoj9z5200jBnyusfRPU2LqT6J+DAorxEvtC7LHB+E= 18 | github.com/golang/mock v1.1.1/go.mod h1:oTYuIxOrZwtPieC+H1uAHpcLFnEyAGVDL/k47Jfbm0A= 19 | github.com/golang/protobuf v1.2.0 h1:P3YflyNX/ehuJFLhxviNdFxQPkGK5cDcApsge1SqnvM= 20 | github.com/golang/protobuf v1.2.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= 21 | github.com/google/go-cmp v0.2.0/go.mod h1:oXzfMopK8JAjlY9xF4vHSVASa0yLyX7SntLO5aqRK0M= 22 | github.com/googleapis/gax-go v2.0.2+incompatible h1:silFMLAnr330+NRuag/VjIGF7TLp/LBrV2CJKFLWEww= 23 | github.com/googleapis/gax-go v2.0.2+incompatible/go.mod h1:SFVmujtThgffbyetf+mdk2eWhX2bMyUtNHzFKcPA9HY= 24 | github.com/gorilla/context v1.1.1 h1:AWwleXJkX/nhcU9bZSnZoi3h/qGYqQAGhq6zZe/aQW8= 25 | github.com/gorilla/context v1.1.1/go.mod h1:kBGZzfjB9CEq2AlWe17Uuf7NDRt0dE0s8S51q0aT7Yg= 26 | github.com/gorilla/handlers v1.4.0 h1:XulKRWSQK5uChr4pEgSE4Tc/OcmnU9GJuSwdog/tZsA= 27 | github.com/gorilla/handlers v1.4.0/go.mod h1:Qkdc/uu4tH4g6mTK6auzZ766c4CA0Ng8+o/OAirnOIQ= 28 | github.com/gorilla/mux v1.6.2 h1:Pgr17XVTNXAk3q/r4CpKzC5xBM/qW1uVLV+IhRZpIIk= 29 | github.com/gorilla/mux v1.6.2/go.mod h1:1lud6UwP+6orDFRuTfBEV8e9/aOM/c4fVVCaMa2zaAs= 30 | github.com/grpc-ecosystem/grpc-gateway v1.5.0/go.mod h1:RSKVYQBd5MCa4OVpNdGskqpgL2+G+NZTnrVHpWWfpdw= 31 | github.com/jmespath/go-jmespath v0.0.0-20160202185014-0b12d6b521d8 h1:12VvqtR6Aowv3l/EQUlocDHW2Cp4G9WJVH7uyH8QFJE= 32 | github.com/jmespath/go-jmespath v0.0.0-20160202185014-0b12d6b521d8/go.mod h1:Nht3zPeWKUH0NzdCt2Blrr5ys8VGpn0CEB0cQHVjt7k= 33 | github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck= 34 | github.com/matttproud/golang_protobuf_extensions v1.0.1/go.mod h1:D8He9yQNgCq6Z5Ld7szi9bcBfOoFv/3dc6xSMkL2PC0= 35 | github.com/openzipkin/zipkin-go v0.1.1/go.mod h1:NtoC/o8u3JlF1lSlyPNswIbeQH9bJTmOf0Erfk+hxe8= 36 | github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= 37 | github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= 38 | github.com/prometheus/client_golang v0.8.0/go.mod h1:7SWBe2y4D6OKWSNQJUaRYU/AaXPKyh/dDVn+NZz0KFw= 39 | github.com/prometheus/client_model v0.0.0-20180712105110-5c3871d89910/go.mod h1:MbSGuTsp3dbXC40dX6PRTWyKYBIrTGTE9sqQNg2J8bo= 40 | github.com/prometheus/common v0.0.0-20180801064454-c7de2306084e/go.mod h1:daVV7qP5qjZbuso7PdcryaAu0sAZbrN9i7WWcTMWvro= 41 | github.com/prometheus/procfs v0.0.0-20180725123919-05ee40e3a273/go.mod h1:c3At6R/oaqEKCNdg8wHV1ftS6bRYblBhIjjI8uT2IGk= 42 | github.com/stretchr/testify v1.2.2 h1:bSDNvY7ZPG5RlJ8otE/7V6gMiyenm9RtJ7IUVIAoJ1w= 43 | github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs= 44 | go.opencensus.io v0.18.0 h1:Mk5rgZcggtbvtAun5aJzAtjKKN/t0R3jJPlWILlv938= 45 | go.opencensus.io v0.18.0/go.mod h1:vKdFvxhtzZ9onBp9VKHK8z/sRpBMnKAsufL7wlDrCOA= 46 | golang.org/x/crypto v0.0.0-20181112202954-3d3f9f413869 h1:kkXA53yGe04D0adEYJwEVQjeBppL01Exg+fnMjfUraU= 47 | golang.org/x/crypto v0.0.0-20181112202954-3d3f9f413869/go.mod h1:6SG95UA2DQfeDnfUPMdvaQW0Q7yPrPDi9nlGo2tz2b4= 48 | golang.org/x/lint v0.0.0-20180702182130-06c8688daad7/go.mod h1:UVdnD1Gm6xHRNCYTkRU2/jEulfH38KcIWyp/GAMgvoE= 49 | golang.org/x/net v0.0.0-20180826012351-8a410e7b638d/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= 50 | golang.org/x/net v0.0.0-20180906233101-161cd47e91fd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= 51 | golang.org/x/net v0.0.0-20181106065722-10aee1819953/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= 52 | golang.org/x/net v0.0.0-20181114220301-adae6a3d119a h1:gOpx8G595UYyvj8UK4+OFyY4rx037g3fmfhe5SasG3U= 53 | golang.org/x/net v0.0.0-20181114220301-adae6a3d119a/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= 54 | golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U= 55 | golang.org/x/oauth2 v0.0.0-20181106182150-f42d05182288 h1:JIqe8uIcRBHXDQVvZtHwp80ai3Lw3IJAeJEs55Dc1W0= 56 | golang.org/x/oauth2 v0.0.0-20181106182150-f42d05182288/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U= 57 | golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= 58 | golang.org/x/sys v0.0.0-20180830151530-49385e6e1522/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= 59 | golang.org/x/sys v0.0.0-20180909124046-d0be0721c37e/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= 60 | golang.org/x/sys v0.0.0-20181107165924-66b7b1311ac8 h1:YoY1wS6JYVRpIfFngRf2HHo9R9dAne3xbkGOQ5rJXjU= 61 | golang.org/x/sys v0.0.0-20181107165924-66b7b1311ac8/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= 62 | golang.org/x/text v0.3.0 h1:g61tztE5qeGQ89tm6NTjjM9VPIm088od1l6aSorWRWg= 63 | golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= 64 | golang.org/x/tools v0.0.0-20180828015842-6cd1fcedba52/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= 65 | google.golang.org/api v0.0.0-20180910000450-7ca32eb868bf/go.mod h1:4mhQ8q/RsB7i+udVvVy5NUi08OU8ZlA0gRVgrF7VFY0= 66 | google.golang.org/api v0.0.0-20181114235557-83a9d304b1e6 h1:oDEtqBIUq5MDzbdy1TgCnw2sW+63bnr1N1OoBZWhLOc= 67 | google.golang.org/api v0.0.0-20181114235557-83a9d304b1e6/go.mod h1:4mhQ8q/RsB7i+udVvVy5NUi08OU8ZlA0gRVgrF7VFY0= 68 | google.golang.org/appengine v1.1.0/go.mod h1:EbEs0AVv82hx2wNQdGPgUI5lhzA/G0D9YwlJXL52JkM= 69 | google.golang.org/genproto v0.0.0-20180817151627-c66870c02cf8/go.mod h1:JiN7NxoALGmiZfu7CAH4rXhgtRTLTxftemlI0sWmxmc= 70 | google.golang.org/genproto v0.0.0-20180831171423-11092d34479b/go.mod h1:JiN7NxoALGmiZfu7CAH4rXhgtRTLTxftemlI0sWmxmc= 71 | google.golang.org/genproto v0.0.0-20181109154231-b5d43981345b h1:WkFtVmaZoTRVoRYr0LTC9SYNhlw0X0HrVPz2OVssVm4= 72 | google.golang.org/genproto v0.0.0-20181109154231-b5d43981345b/go.mod h1:7Ep/1NZk928CDR8SjdVbjWNpdIf6nzjE3BTgJDr2Atg= 73 | google.golang.org/grpc v1.14.0/go.mod h1:yo6s7OP7yaDglbqo1J04qKzAhqBH6lvTonzMVmEdcZw= 74 | google.golang.org/grpc v1.16.0 h1:dz5IJGuC2BB7qXR5AyHNwAUBhZscK2xVez7mznh72sY= 75 | google.golang.org/grpc v1.16.0/go.mod h1:0JHn/cJsOMiMfNA9+DeHDlAU7KAAB5GDlYFpa9MZMio= 76 | gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= 77 | gopkg.in/yaml.v2 v2.2.1/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= 78 | honnef.co/go/tools v0.0.0-20180728063816-88497007e858/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4= 79 | -------------------------------------------------------------------------------- /list_requests.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "encoding/base64" 5 | "encoding/json" 6 | "fmt" 7 | "github.com/cloudtools/ssh-cert-authority/util" 8 | "github.com/codegangsta/cli" 9 | "golang.org/x/crypto/ssh" 10 | "io/ioutil" 11 | "net/http" 12 | "os" 13 | "time" 14 | ) 15 | 16 | func listCertFlags() []cli.Flag { 17 | home := os.Getenv("HOME") 18 | if home == "" { 19 | home = "/" 20 | } 21 | configPath := home + "/.ssh_ca/signer_config.json" 22 | 23 | return []cli.Flag{ 24 | cli.StringFlag{ 25 | Name: "environment, e", 26 | Value: "", 27 | Usage: "An environment name (e.g. prod)", 28 | }, 29 | cli.StringFlag{ 30 | Name: "config-file, c", 31 | Value: configPath, 32 | Usage: "Path to config.json", 33 | }, 34 | cli.BoolFlag{ 35 | Name: "show-all, a", 36 | Usage: "Show certs that have already been signed as well", 37 | }, 38 | } 39 | } 40 | 41 | func listCerts(c *cli.Context) error { 42 | 43 | configPath := c.String("config-file") 44 | environment := c.String("environment") 45 | showAll := c.Bool("show-all") 46 | 47 | allConfig := make(map[string]ssh_ca_util.RequesterConfig) 48 | err := ssh_ca_util.LoadConfig(configPath, &allConfig) 49 | wrongTypeConfig, err := ssh_ca_util.GetConfigForEnv(environment, &allConfig) 50 | if err != nil { 51 | return cli.NewExitError(fmt.Sprintf("%s", err), 1) 52 | } 53 | config := wrongTypeConfig.(ssh_ca_util.RequesterConfig) 54 | 55 | getResp, err := http.Get(config.SignerUrl + "cert/requests") 56 | if err != nil { 57 | return cli.NewExitError(fmt.Sprintf("Didn't get a valid response: %s", err), 1) 58 | } 59 | getRespBuf, err := ioutil.ReadAll(getResp.Body) 60 | if err != nil { 61 | return cli.NewExitError(fmt.Sprintf("Error reading response body: %s", err), 1) 62 | } 63 | getResp.Body.Close() 64 | if getResp.StatusCode != 200 { 65 | return cli.NewExitError(fmt.Sprintf("Error getting listing of certs: %s", string(getRespBuf)), 1) 66 | } 67 | 68 | certs := make(certRequestResponse) 69 | json.Unmarshal(getRespBuf, &certs) 70 | for requestID, respElement := range certs { 71 | if showAll || !respElement.Signed { 72 | rawCert, err := base64.StdEncoding.DecodeString(respElement.CertBlob) 73 | if err != nil { 74 | return cli.NewExitError(fmt.Sprintf("Trouble base64 decoding response '%s': %s", err, respElement.CertBlob), 1) 75 | } 76 | pubKey, err := ssh.ParsePublicKey(rawCert) 77 | if err != nil { 78 | return cli.NewExitError(fmt.Sprintf("Trouble parsing response: %s", err), 1) 79 | } 80 | cert := *pubKey.(*ssh.Certificate) 81 | env, ok := cert.Extensions["environment@cloudtools.github.io"] 82 | if !ok { 83 | env = "unknown env" 84 | } 85 | expired := int64(cert.ValidBefore)-int64(time.Now().Unix()) < 1 86 | if !expired || showAll { 87 | expiredMsg := "" 88 | if expired { 89 | expiredMsg = ", \033[91mexpired\033[0m" 90 | } 91 | fmt.Printf("%d %s[%s, %d/%d%s]: %s - %s\n", 92 | respElement.Serial, 93 | requestID, 94 | env, 95 | respElement.NumSignatures, 96 | respElement.SignaturesRequired, 97 | expiredMsg, 98 | cert.KeyId, 99 | cert.Extensions["reason@cloudtools.github.io"], 100 | ) 101 | } 102 | } 103 | } 104 | return nil 105 | } 106 | -------------------------------------------------------------------------------- /main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "github.com/cloudtools/ssh-cert-authority/version" 5 | "github.com/codegangsta/cli" 6 | "os" 7 | ) 8 | 9 | func main() { 10 | app := cli.NewApp() 11 | app.Name = "ssh-cert-authority" 12 | app.EnableBashCompletion = true 13 | app.Version = version.BuildVersion 14 | 15 | app.Commands = []cli.Command{ 16 | { 17 | Name: "request", 18 | Aliases: []string{"r"}, 19 | Flags: requestCertFlags(), 20 | Usage: "Request a new certificate", 21 | Action: requestCert, 22 | }, 23 | { 24 | Name: "sign", 25 | Aliases: []string{"s"}, 26 | Flags: signCertFlags(), 27 | Usage: "Sign a certificate", 28 | Action: signCert, 29 | }, 30 | { 31 | Name: "get", 32 | Aliases: []string{"g"}, 33 | Flags: getCertFlags(), 34 | Usage: "Get a certificate", 35 | Action: getCert, 36 | }, 37 | { 38 | Name: "list", 39 | Aliases: []string{"l"}, 40 | Flags: listCertFlags(), 41 | Usage: "List pending requests on the server", 42 | Action: listCerts, 43 | }, 44 | { 45 | Name: "runserver", 46 | Flags: signdFlags(), 47 | Usage: "Run the cert-authority web service", 48 | Action: signCertd, 49 | }, 50 | { 51 | Name: "encrypt-key", 52 | Flags: encryptFlags(), 53 | Usage: "Optionally generate and then encrypt an ssh private key", 54 | Action: cmdEncryptKey, 55 | }, 56 | { 57 | Name: "generate-config", 58 | Flags: generateConfigFlags(), 59 | Usage: "Try to generate a configuration file for a requester", 60 | Action: cmdGenerateConfig, 61 | }, 62 | } 63 | app.Run(os.Args) 64 | } 65 | -------------------------------------------------------------------------------- /request_cert.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "bufio" 5 | "crypto/rand" 6 | "fmt" 7 | "github.com/cloudtools/ssh-cert-authority/client" 8 | "github.com/cloudtools/ssh-cert-authority/util" 9 | "github.com/codegangsta/cli" 10 | "golang.org/x/crypto/ssh" 11 | "io/ioutil" 12 | "net" 13 | "os" 14 | "strings" 15 | "time" 16 | ) 17 | 18 | func trueOnError(err error) uint { 19 | if err != nil { 20 | fmt.Println(err) 21 | return 1 22 | } 23 | return 0 24 | } 25 | 26 | func requestCertFlags() []cli.Flag { 27 | validBeforeDur, _ := time.ParseDuration("2h") 28 | validAfterDur, _ := time.ParseDuration("-2m") 29 | home := os.Getenv("HOME") 30 | if home == "" { 31 | home = "/" 32 | } 33 | configPath := home + "/.ssh_ca/requester_config.json" 34 | 35 | return []cli.Flag{ 36 | cli.StringFlag{ 37 | Name: "principals, p", 38 | Value: "ec2-user,ubuntu", 39 | Usage: "Valid usernames for login, comma separated (e.g. ec2-user,ubuntu)", 40 | }, 41 | cli.StringFlag{ 42 | Name: "environment, e", 43 | Value: "", 44 | Usage: "An environment name (e.g. prod)", 45 | }, 46 | cli.StringFlag{ 47 | Name: "config-file, c", 48 | Value: configPath, 49 | Usage: "Path to config.json", 50 | }, 51 | cli.StringFlag{ 52 | Name: "reason, r", 53 | Value: "", 54 | Usage: "Your reason for needing this SSH certificate.", 55 | }, 56 | cli.DurationFlag{ 57 | Name: "valid-after", 58 | Value: validAfterDur, 59 | Usage: "Relative time", 60 | }, 61 | cli.DurationFlag{ 62 | Name: "valid-before", 63 | Value: validBeforeDur, 64 | Usage: "Relative time", 65 | }, 66 | cli.BoolFlag{ 67 | Name: "quiet", 68 | Usage: "Print only the request id on success", 69 | }, 70 | cli.BoolTFlag{ 71 | Name: "add-key", 72 | Usage: "When set automatically call ssh-add if cert was auto-signed by server", 73 | }, 74 | cli.StringFlag{ 75 | Name: "ssh-dir", 76 | Value: os.Getenv("HOME") + "/.ssh", 77 | Usage: "Directory where SSH identity files (like 'id_rsa') reside", 78 | }, 79 | } 80 | } 81 | 82 | func requestCert(c *cli.Context) error { 83 | allConfig := make(map[string]ssh_ca_util.RequesterConfig) 84 | configPath := c.String("config-file") 85 | sshDir := c.String("ssh-dir") 86 | err := ssh_ca_util.LoadConfig(configPath, &allConfig) 87 | if err != nil { 88 | return cli.NewExitError(fmt.Sprintf("Load Config failed: %s", err), 1) 89 | } 90 | 91 | environment := c.String("environment") 92 | wrongTypeConfig, err := ssh_ca_util.GetConfigForEnv(environment, &allConfig) 93 | if err != nil { 94 | return cli.NewExitError(fmt.Sprintf("%s", err), 1) 95 | } 96 | config := wrongTypeConfig.(ssh_ca_util.RequesterConfig) 97 | 98 | reason := c.String("reason") 99 | if reason == "" { 100 | reader := bufio.NewReader(os.Stdin) 101 | fmt.Print("Please give a reason: ") 102 | reason, _ = reader.ReadString('\n') 103 | reason = strings.TrimSpace(reason) 104 | } 105 | if reason == "" { 106 | return cli.NewExitError("Failed to give a reason", 1) 107 | } 108 | 109 | caRequest := ssh_ca_client.MakeCertRequest() 110 | caRequest.SetConfig(config) 111 | failed := trueOnError(caRequest.SetEnvironment(environment)) 112 | failed |= trueOnError(caRequest.SetReason(reason)) 113 | failed |= trueOnError(caRequest.SetValidAfter(c.Duration("valid-after"))) 114 | failed |= trueOnError(caRequest.SetValidBefore(c.Duration("valid-before"))) 115 | failed |= trueOnError(caRequest.SetPrincipalsFromString(c.String("principals"))) 116 | 117 | if failed == 1 { 118 | return cli.NewExitError("One or more errors found. Aborting request.", 1) 119 | } 120 | 121 | var chosenKeyFingerprint, pubKeyComment string 122 | var pubKey ssh.PublicKey 123 | if config.PublicKeyPath != "" { 124 | pubKeyContents, err := ioutil.ReadFile(config.PublicKeyPath) 125 | if err != nil { 126 | return cli.NewExitError(fmt.Sprintf("Trouble opening your public key file %s: %s", config.PublicKeyPath, err), 1) 127 | } 128 | pubKey, pubKeyComment, _, _, err = ssh.ParseAuthorizedKey(pubKeyContents) 129 | if err != nil { 130 | return cli.NewExitError(fmt.Sprintf("Trouble parsing your public key: %s", err), 1) 131 | } 132 | chosenKeyFingerprint = ssh_ca_util.MakeFingerprint(pubKey.Marshal()) 133 | } else { 134 | chosenKeyFingerprint = config.PublicKeyFingerprint 135 | pubKeyComment = "unknown" 136 | } 137 | 138 | if chosenKeyFingerprint == "" { 139 | return cli.NewExitError("No SSH fingerprint found. Try setting PublicKeyFingerprint in requester config.", 1) 140 | } 141 | 142 | conn, err := net.Dial("unix", os.Getenv("SSH_AUTH_SOCK")) 143 | if err != nil { 144 | return cli.NewExitError(fmt.Sprintf("Dial failed: %s", err), 1) 145 | } 146 | 147 | signer, err := ssh_ca_util.GetSignerForFingerprint(chosenKeyFingerprint, conn) 148 | if err != nil { 149 | return cli.NewExitError(fmt.Sprintf("%s", err), 1) 150 | } 151 | switch signer.PublicKey().Type() { 152 | case ssh.KeyAlgoRSA, ssh.KeyAlgoDSA, ssh.KeyAlgoECDSA256, ssh.KeyAlgoECDSA384, ssh.KeyAlgoECDSA521, ssh.KeyAlgoED25519: 153 | default: 154 | return cli.NewExitError(fmt.Sprintf("Unsupported ssh key type: %s\nWe support rsa, dsa, edd25519 and ecdsa. Need golang support for other algorithms.", signer.PublicKey().Type()), 1) 155 | } 156 | 157 | caRequest.SetPublicKey(signer.PublicKey(), pubKeyComment) 158 | newCert, err := caRequest.EncodeAsCertificate() 159 | if err != nil { 160 | return cli.NewExitError(fmt.Sprintf("Error encoding certificate request: %s", err), 1) 161 | } 162 | err = newCert.SignCert(rand.Reader, signer) 163 | if err != nil { 164 | return cli.NewExitError(fmt.Sprintf("Error signing: %s", err), 1) 165 | } 166 | 167 | certRequest := newCert.Marshal() 168 | requestParameters := caRequest.BuildWebRequest(certRequest) 169 | requestID, signed, err := caRequest.PostToWeb(requestParameters) 170 | if err == nil { 171 | if c.Bool("quiet") { 172 | fmt.Println(requestID) 173 | } else { 174 | var appendage string 175 | if signed { 176 | appendage = " auto-signed" 177 | } 178 | fmt.Printf("Cert request id: %s%s\n", requestID, appendage) 179 | if signed && c.BoolT("add-key") { 180 | cert, err := downloadCert(config, requestID, sshDir) 181 | if err != nil { 182 | return cli.NewExitError(fmt.Sprintf("%s", err), 1) 183 | } 184 | err = addCertToAgent(cert, sshDir) 185 | if err != nil { 186 | return cli.NewExitError(fmt.Sprintf("%s", err), 1) 187 | } 188 | } 189 | } 190 | } else { 191 | return cli.NewExitError(fmt.Sprintf("%s", err), 1) 192 | } 193 | return nil 194 | } 195 | -------------------------------------------------------------------------------- /sign_cert.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "bufio" 5 | "crypto/rand" 6 | "encoding/base64" 7 | "encoding/json" 8 | "fmt" 9 | "github.com/cloudtools/ssh-cert-authority/client" 10 | "github.com/cloudtools/ssh-cert-authority/util" 11 | "github.com/codegangsta/cli" 12 | "golang.org/x/crypto/ssh" 13 | "io/ioutil" 14 | "net" 15 | "net/http" 16 | "net/url" 17 | "os" 18 | "strings" 19 | ) 20 | 21 | type OperationKind string 22 | 23 | const ( 24 | OperationApprove OperationKind = "approve" 25 | OperationReject OperationKind = "reject" 26 | ) 27 | 28 | func signCertFlags() []cli.Flag { 29 | home := os.Getenv("HOME") 30 | if home == "" { 31 | home = "/" 32 | } 33 | configPath := home + "/.ssh_ca/signer_config.json" 34 | 35 | return []cli.Flag{ 36 | cli.StringFlag{ 37 | Name: "environment, e", 38 | Value: "", 39 | Usage: "An environment name (e.g. prod)", 40 | }, 41 | cli.StringFlag{ 42 | Name: "config-file, c", 43 | Value: configPath, 44 | Usage: "Path to config.json", 45 | }, 46 | cli.StringFlag{ 47 | Name: "cert-request-id", 48 | Value: "", 49 | Usage: "The certificate request id to look at. Also works as a positional argument.", 50 | }, 51 | } 52 | } 53 | 54 | func signCert(c *cli.Context) error { 55 | configPath := c.String("config-file") 56 | allConfig := make(map[string]ssh_ca_util.SignerConfig) 57 | err := ssh_ca_util.LoadConfig(configPath, &allConfig) 58 | if err != nil { 59 | return cli.NewExitError(fmt.Sprintf("Load Config failed: %s", err), 1) 60 | } 61 | 62 | certRequestID := c.String("cert-request-id") 63 | if certRequestID == "" { 64 | certRequestID = c.Args().First() 65 | if certRequestID == "" { 66 | return cli.NewExitError("Specify a cert-request-id", 1) 67 | } 68 | } 69 | environment := c.String("environment") 70 | wrongTypeConfig, err := ssh_ca_util.GetConfigForEnv(environment, &allConfig) 71 | if err != nil { 72 | return cli.NewExitError(fmt.Sprintf("%s", err), 1) 73 | } 74 | config := wrongTypeConfig.(ssh_ca_util.SignerConfig) 75 | 76 | conn, err := net.Dial("unix", os.Getenv("SSH_AUTH_SOCK")) 77 | if err != nil { 78 | return cli.NewExitError(fmt.Sprintf("Dial failed: %s", err), 1) 79 | } 80 | 81 | signer, err := ssh_ca_util.GetSignerForFingerprint(config.KeyFingerprint, conn) 82 | if err != nil { 83 | return cli.NewExitError(fmt.Sprintf("%s", err), 1) 84 | } 85 | 86 | requestParameters := make(url.Values) 87 | requestParameters["certRequestId"] = make([]string, 1) 88 | requestParameters["certRequestId"][0] = certRequestID 89 | getResp, err := http.Get(config.SignerUrl + "cert/requests?" + requestParameters.Encode()) 90 | if err != nil { 91 | return cli.NewExitError(fmt.Sprintf("Didn't get a valid response: %s", err), 1) 92 | } 93 | getRespBuf, err := ioutil.ReadAll(getResp.Body) 94 | if err != nil { 95 | return cli.NewExitError(fmt.Sprintf("Error reading response body: %s", err), 1) 96 | } 97 | getResp.Body.Close() 98 | if getResp.StatusCode != 200 { 99 | return cli.NewExitError(fmt.Sprintf("Error getting that request id: %s", string(getRespBuf)), 1) 100 | } 101 | getResponse := make(certRequestResponse) 102 | err = json.Unmarshal(getRespBuf, &getResponse) 103 | if err != nil { 104 | return cli.NewExitError(fmt.Sprintf("Unable to unmarshall response: %s", err), 1) 105 | } 106 | if getResponse[certRequestID].Signed { 107 | return cli.NewExitError("Certificate already signed. Thanks for trying.", 1) 108 | } 109 | rawCert, err := base64.StdEncoding.DecodeString(getResponse[certRequestID].CertBlob) 110 | if err != nil { 111 | return cli.NewExitError(fmt.Sprintf("Trouble base64 decoding response: %s", err), 1) 112 | } 113 | pubKey, err := ssh.ParsePublicKey(rawCert) 114 | if err != nil { 115 | return cli.NewExitError(fmt.Sprintf("Trouble parsing response: %s", err), 1) 116 | } 117 | cert := *pubKey.(*ssh.Certificate) 118 | ssh_ca_util.PrintForInspection(cert) 119 | fmt.Printf("Type 'yes' if you'd like to sign this cert request, 'reject' to reject it, anything else to cancel ") 120 | reader := bufio.NewReader(os.Stdin) 121 | text, _ := reader.ReadString('\n') 122 | text = strings.TrimSpace(text) 123 | if text != "yes" && text != "reject" { 124 | return cli.NewExitError("", 0) 125 | } 126 | var operation OperationKind 127 | if text == "yes" { 128 | operation = OperationApprove 129 | } else { 130 | operation = OperationReject 131 | } 132 | 133 | err = cert.SignCert(rand.Reader, signer) 134 | if err != nil { 135 | return cli.NewExitError(fmt.Sprintf("Error signing: %s", err), 1) 136 | } 137 | 138 | request := ssh_ca_client.MakeSigningRequest(cert, certRequestID, config) 139 | requestWebParameters := request.BuildWebRequest() 140 | if operation == OperationApprove { 141 | err = request.PostToWeb(requestWebParameters) 142 | } else { 143 | err = request.DeleteToWeb(requestWebParameters) 144 | } 145 | if err != nil { 146 | return cli.NewExitError(fmt.Sprintf("Error sending in +1: %s", err), 1) 147 | } 148 | fmt.Println("Signature accepted by server.") 149 | return nil 150 | 151 | } 152 | -------------------------------------------------------------------------------- /sign_certd.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "bytes" 5 | "crypto/rand" 6 | "encoding/base32" 7 | "encoding/base64" 8 | "encoding/json" 9 | "errors" 10 | "fmt" 11 | "github.com/aws/aws-sdk-go/aws" 12 | "github.com/aws/aws-sdk-go/aws/ec2metadata" 13 | "github.com/aws/aws-sdk-go/aws/session" 14 | "github.com/aws/aws-sdk-go/service/kms" 15 | "github.com/cloudtools/ssh-cert-authority/client" 16 | "github.com/cloudtools/ssh-cert-authority/util" 17 | "github.com/cloudtools/ssh-cert-authority/version" 18 | "github.com/codegangsta/cli" 19 | "github.com/gorilla/handlers" 20 | "github.com/gorilla/mux" 21 | "golang.org/x/crypto/ssh" 22 | "golang.org/x/crypto/ssh/agent" 23 | "io" 24 | "io/ioutil" 25 | "log" 26 | "net" 27 | "net/http" 28 | "net/url" 29 | "os" 30 | "reflect" 31 | "regexp" 32 | "strings" 33 | "sync" 34 | "time" 35 | ) 36 | 37 | // Yanked from PROTOCOL.certkeys 38 | var supportedCriticalOptions = []string{ 39 | "force-command", 40 | "source-address", 41 | } 42 | 43 | func isSupportedOption(x string) bool { 44 | for optionIdx := range supportedCriticalOptions { 45 | if supportedCriticalOptions[optionIdx] == x { 46 | return true 47 | } 48 | } 49 | return false 50 | } 51 | 52 | func areCriticalOptionsValid(criticalOptions map[string]string) error { 53 | for optionName := range criticalOptions { 54 | if !isSupportedOption(optionName) { 55 | return fmt.Errorf("Invalid critical option name: '%s'", optionName) 56 | } 57 | } 58 | return nil 59 | } 60 | 61 | type certRequest struct { 62 | // This struct tracks state for certificate requests. Imagine this one day 63 | // being stored in a persistent data store. 64 | request *ssh.Certificate 65 | submitTime time.Time 66 | environment string 67 | signatures map[string]bool 68 | certSigned bool 69 | certRejected bool 70 | reason string 71 | } 72 | 73 | func compareCerts(one, two *ssh.Certificate) bool { 74 | /* Compare two SSH certificates in a special way. 75 | 76 | The specialness is in that we expect these certs to be more or less the 77 | same but they will have been signed by different people. The act of signing 78 | the cert changes the Key, SignatureKey, Signature and Nonce fields of the 79 | Certificate struct so we compare the cert except for those fields. 80 | */ 81 | if one.Serial != two.Serial { 82 | return false 83 | } 84 | if one.CertType != two.CertType { 85 | return false 86 | } 87 | if one.KeyId != two.KeyId { 88 | return false 89 | } 90 | if !reflect.DeepEqual(one.ValidPrincipals, two.ValidPrincipals) { 91 | return false 92 | } 93 | if one.ValidAfter != two.ValidAfter { 94 | return false 95 | } 96 | if one.ValidBefore != two.ValidBefore { 97 | return false 98 | } 99 | if !reflect.DeepEqual(one.CriticalOptions, two.CriticalOptions) { 100 | return false 101 | } 102 | if !reflect.DeepEqual(one.Extensions, two.Extensions) { 103 | return false 104 | } 105 | if !bytes.Equal(one.Reserved, two.Reserved) { 106 | return false 107 | } 108 | if !reflect.DeepEqual(one.Key, two.Key) { 109 | return false 110 | } 111 | return true 112 | } 113 | 114 | func newcertRequest() certRequest { 115 | var cr certRequest 116 | cr.submitTime = time.Now() 117 | cr.certSigned = false 118 | cr.signatures = make(map[string]bool) 119 | return cr 120 | } 121 | 122 | type certRequestHandler struct { 123 | Config map[string]ssh_ca_util.SignerdConfig 124 | state map[string]certRequest 125 | sshAgentConn io.ReadWriter 126 | stateMutex sync.RWMutex 127 | } 128 | 129 | type signingRequest struct { 130 | config *ssh_ca_util.SignerdConfig 131 | environment string 132 | cert *ssh.Certificate 133 | } 134 | 135 | func (h *certRequestHandler) setupPrivateKeys(config map[string]ssh_ca_util.SignerdConfig) error { 136 | for env, cfg := range config { 137 | if cfg.PrivateKeyFile == "" { 138 | continue 139 | } 140 | keyUrl, err := url.Parse(cfg.PrivateKeyFile) 141 | if err != nil { 142 | log.Printf("Ignoring invalid private key file: '%s'. Error parsing: %s", cfg.PrivateKeyFile, err) 143 | continue 144 | } 145 | if keyUrl.Scheme == "gcpkms" { 146 | cfg = config[env] 147 | cfg.SigningKeyFingerprint = cfg.PrivateKeyFile 148 | config[env] = cfg 149 | } else if keyUrl.Scheme == "" || keyUrl.Scheme == "file" { 150 | keyContents, err := ioutil.ReadFile(keyUrl.Path) 151 | if err != nil { 152 | return fmt.Errorf("Failed reading private key file %s: %v", keyUrl.Path, err) 153 | } 154 | if strings.HasSuffix(keyUrl.Path, ".kms") { 155 | var region string 156 | if cfg.KmsRegion != "" { 157 | region = cfg.KmsRegion 158 | } else { 159 | region, err = ec2metadata.New(session.New(), aws.NewConfig()).Region() 160 | if err != nil { 161 | return fmt.Errorf("Unable to determine our region: %s", err) 162 | } 163 | } 164 | svc := kms.New(session.New(), aws.NewConfig().WithRegion(region)) 165 | params := &kms.DecryptInput{ 166 | CiphertextBlob: keyContents, 167 | } 168 | resp, err := svc.Decrypt(params) 169 | if err != nil { 170 | // We try only one time to speak with KMS. If this pukes, and it 171 | // will occasionally because "the cloud", the caller is responsible 172 | // for trying again, possibly after a crash/restart. 173 | return fmt.Errorf("Unable to decrypt CA key: %v\n", err) 174 | } 175 | keyContents = resp.Plaintext 176 | } 177 | key, err := ssh.ParseRawPrivateKey(keyContents) 178 | if err != nil { 179 | return fmt.Errorf("Failed parsing private key %s: %v", keyUrl.Path, err) 180 | } 181 | keyToAdd := agent.AddedKey{ 182 | PrivateKey: key, 183 | Comment: fmt.Sprintf("ssh-cert-authority-%s-%s", env, keyUrl.Path), 184 | LifetimeSecs: 0, 185 | } 186 | agentClient := agent.NewClient(h.sshAgentConn) 187 | err = agentClient.Add(keyToAdd) 188 | if err != nil { 189 | return fmt.Errorf("Unable to add private key %s: %v", keyUrl.Path, err) 190 | } 191 | signer, err := ssh.NewSignerFromKey(key) 192 | if err != nil { 193 | return fmt.Errorf("Unable to create signer from pk %s: %v", keyUrl.Path, err) 194 | } 195 | keyFp := ssh_ca_util.MakeFingerprint(signer.PublicKey().Marshal()) 196 | log.Printf("Added private key for env %s: %s", env, keyFp) 197 | cfg = config[env] 198 | cfg.SigningKeyFingerprint = keyFp 199 | config[env] = cfg 200 | } 201 | } 202 | return nil 203 | } 204 | 205 | func (h *certRequestHandler) createSigningRequest(rw http.ResponseWriter, req *http.Request) { 206 | err := req.ParseForm() 207 | if err != nil { 208 | http.Error(rw, fmt.Sprintf("%v", err), http.StatusBadRequest) 209 | return 210 | } 211 | 212 | cert, err := h.extractCertFromRequest(req) 213 | if err != nil { 214 | http.Error(rw, fmt.Sprintf("%v", err), http.StatusBadRequest) 215 | return 216 | } 217 | 218 | environment, ok := cert.Extensions["environment@cloudtools.github.io"] 219 | if !ok || environment == "" { 220 | http.Error(rw, "You forgot to send in the environment", http.StatusBadRequest) 221 | return 222 | } 223 | 224 | reason, ok := cert.Extensions["reason@cloudtools.github.io"] 225 | if !ok || reason == "" { 226 | http.Error(rw, "You forgot to send in a reason", http.StatusBadRequest) 227 | return 228 | } 229 | 230 | config, ok := h.Config[environment] 231 | if !ok { 232 | http.Error(rw, "Unknown environment.", http.StatusBadRequest) 233 | return 234 | } 235 | 236 | err = h.validateCert(cert, config.AuthorizedUsers) 237 | if err != nil { 238 | log.Printf("Invalid certificate signing request received from %s, ignoring", req.RemoteAddr) 239 | http.Error(rw, fmt.Sprintf("%v", err), http.StatusBadRequest) 240 | return 241 | } 242 | 243 | // Ideally we put the critical options into the cert and let validateCert 244 | // do the validation. However, this also checks the signature on the cert 245 | // which would fail if we modified it prior to validation. So we validate 246 | // by hand. 247 | if len(config.CriticalOptions) > 0 { 248 | for optionName, optionVal := range config.CriticalOptions { 249 | cert.CriticalOptions[optionName] = optionVal 250 | } 251 | } 252 | 253 | requestID := make([]byte, 8) 254 | rand.Reader.Read(requestID) 255 | requestIDStr := base32.StdEncoding.EncodeToString(requestID) 256 | requestIDStr = strings.Replace(requestIDStr, "=", "", 10) 257 | // the serial number is the same as the request id, just encoded differently. 258 | var nextSerial uint64 259 | nextSerial = 0 260 | for _, byteVal := range requestID { 261 | nextSerial <<= 8 262 | nextSerial |= uint64(byteVal) 263 | } 264 | 265 | requesterFp := ssh_ca_util.MakeFingerprint(cert.SignatureKey.Marshal()) 266 | 267 | signed, err := h.saveSigningRequest(config, environment, reason, requestIDStr, nextSerial, cert) 268 | if err != nil { 269 | http.Error(rw, fmt.Sprintf("Request not made: %v", err), http.StatusBadRequest) 270 | return 271 | } 272 | 273 | // Serial and id are the same value, just encoded differently. Logging them 274 | // both because they didn't use to be the same value and folks may be 275 | // parsing these log messages and I don't want to break the format. 276 | log.Printf("Cert request serial %d id %s env %s from %s (%s) @ %s principals %v valid from %d to %d for '%s'\n", 277 | cert.Serial, requestIDStr, environment, requesterFp, config.AuthorizedUsers[requesterFp], 278 | req.RemoteAddr, cert.ValidPrincipals, cert.ValidAfter, cert.ValidBefore, reason) 279 | 280 | if config.SlackUrl != "" { 281 | slackMsg := fmt.Sprintf("SSH cert request from %s with id %s for %s", config.AuthorizedUsers[requesterFp], requestIDStr, reason) 282 | err = ssh_ca_client.PostToSlack(config.SlackUrl, config.SlackChannel, slackMsg) 283 | if err != nil { 284 | log.Printf("Unable to post to slack: %v", err) 285 | } 286 | } 287 | 288 | var returnStatus int 289 | if signed { 290 | slackMsg := fmt.Sprintf("SSH cert request %s auto signed.", requestIDStr) 291 | err := ssh_ca_client.PostToSlack(config.SlackUrl, config.SlackChannel, slackMsg) 292 | if err != nil { 293 | log.Printf("Unable to post to slack for %s: %v", requestIDStr, err) 294 | } 295 | returnStatus = http.StatusAccepted 296 | } else { 297 | returnStatus = http.StatusCreated 298 | } 299 | rw.WriteHeader(returnStatus) 300 | rw.Write([]byte(requestIDStr)) 301 | 302 | return 303 | } 304 | 305 | func (h *certRequestHandler) saveSigningRequest(config ssh_ca_util.SignerdConfig, environment, reason, requestIDStr string, requestSerial uint64, cert *ssh.Certificate) (bool, error) { 306 | requesterFp := ssh_ca_util.MakeFingerprint(cert.SignatureKey.Marshal()) 307 | 308 | maxValidBefore := uint64(time.Now().Add(time.Duration(config.MaxCertLifetime) * time.Second).Unix()) 309 | 310 | if config.MaxCertLifetime != 0 && cert.ValidBefore > maxValidBefore { 311 | return false, fmt.Errorf("Certificate is valid longer than maximum permitted by configuration %d > %d", 312 | cert.ValidBefore, maxValidBefore) 313 | } 314 | 315 | // We override keyid here so that its a server controlled value. Instead of 316 | // letting a requester attempt to spoof it. 317 | var ok bool 318 | cert.KeyId, ok = config.AuthorizedUsers[requesterFp] 319 | if !ok { 320 | return false, fmt.Errorf("Requester fingerprint (%s) not found in config", requesterFp) 321 | } 322 | 323 | if requestSerial == 0 { 324 | return false, fmt.Errorf("Serial number not set.") 325 | } 326 | cert.Serial = requestSerial 327 | 328 | certRequest := newcertRequest() 329 | certRequest.request = cert 330 | if environment == "" { 331 | return false, fmt.Errorf("Environment is a required field") 332 | } 333 | certRequest.environment = environment 334 | 335 | if reason == "" { 336 | return false, fmt.Errorf("Reason is a required field") 337 | } 338 | certRequest.reason = reason 339 | 340 | if len(requestIDStr) < 12 { 341 | return false, fmt.Errorf("Request id is too short to be useful.") 342 | } 343 | h.stateMutex.RLock() 344 | _, ok = h.state[requestIDStr] 345 | h.stateMutex.RUnlock() 346 | if ok { 347 | return false, fmt.Errorf("Request id '%s' already in use.", requestIDStr) 348 | } 349 | h.stateMutex.Lock() 350 | h.state[requestIDStr] = certRequest 351 | h.stateMutex.Unlock() 352 | 353 | // This is the special case of supporting auto-signing. 354 | if config.NumberSignersRequired < 0 { 355 | signed, err := h.maybeSignWithCa(requestIDStr, config.NumberSignersRequired, config.SigningKeyFingerprint) 356 | if signed && err == nil { 357 | return true, nil 358 | } 359 | } 360 | 361 | return false, nil 362 | } 363 | 364 | func (h *certRequestHandler) extractCertFromRequest(req *http.Request) (*ssh.Certificate, error) { 365 | 366 | if req.Form["cert"] == nil || len(req.Form["cert"]) == 0 { 367 | err := errors.New("Please specify exactly one cert request") 368 | return nil, err 369 | } 370 | 371 | rawCertRequest, err := base64.StdEncoding.DecodeString(req.Form["cert"][0]) 372 | if err != nil { 373 | err := errors.New("Unable to base64 decode cert request") 374 | return nil, err 375 | } 376 | pubKey, err := ssh.ParsePublicKey(rawCertRequest) 377 | if err != nil { 378 | err := errors.New("Unable to parse cert request") 379 | return nil, err 380 | } 381 | 382 | return pubKey.(*ssh.Certificate), nil 383 | } 384 | 385 | func (h *certRequestHandler) validateCert(cert *ssh.Certificate, authorizedSigners map[string]string) error { 386 | var certChecker ssh.CertChecker 387 | certChecker.IsUserAuthority = func(auth ssh.PublicKey) bool { 388 | fingerprint := ssh_ca_util.MakeFingerprint(auth.Marshal()) 389 | _, ok := authorizedSigners[fingerprint] 390 | return ok 391 | } 392 | certChecker.SupportedCriticalOptions = supportedCriticalOptions 393 | 394 | err := certChecker.CheckCert(cert.ValidPrincipals[0], cert) 395 | if err != nil { 396 | err := fmt.Errorf("Cert not valid: %v", err) 397 | return err 398 | } 399 | 400 | if cert.CertType != ssh.UserCert { 401 | err = errors.New("Cert not valid: not a user certificate") 402 | return err 403 | } 404 | 405 | // explicitly call IsUserAuthority 406 | if !certChecker.IsUserAuthority(cert.SignatureKey) { 407 | err = errors.New("Cert not valid: not signed by an authorized key") 408 | return err 409 | } 410 | 411 | return nil 412 | } 413 | 414 | type listResponseElement struct { 415 | Signed bool 416 | Rejected bool 417 | CertBlob string 418 | NumSignatures int 419 | SignaturesRequired int 420 | Serial uint64 421 | Environment string 422 | Reason string 423 | Cert *ssh.Certificate `json:"-"` 424 | } 425 | type certRequestResponse map[string]listResponseElement 426 | 427 | func newResponseElement(cert *ssh.Certificate, certBlob string, signed bool, rejected bool, numSignatures, signaturesRequired int, serial uint64, reason string, environment string) listResponseElement { 428 | var element listResponseElement 429 | element.Cert = cert 430 | element.CertBlob = certBlob 431 | element.Signed = signed 432 | element.Rejected = rejected 433 | element.NumSignatures = numSignatures 434 | element.SignaturesRequired = signaturesRequired 435 | element.Serial = serial 436 | element.Reason = reason 437 | element.Environment = environment 438 | return element 439 | } 440 | 441 | func (h *certRequestHandler) listEnvironments(rw http.ResponseWriter, req *http.Request) { 442 | var environments []string 443 | for k := range h.Config { 444 | environments = append(environments, k) 445 | } 446 | result, err := json.Marshal(environments) 447 | if err != nil { 448 | http.Error(rw, fmt.Sprintf("Unable to marshal environment names: %v", err), http.StatusInternalServerError) 449 | return 450 | } 451 | log.Printf("List environments received from '%s'\n", req.RemoteAddr) 452 | rw.Header().Set("Content-Type", "application/json; charset=utf-8") 453 | rw.Write(result) 454 | } 455 | 456 | func (h *certRequestHandler) listPendingRequests(rw http.ResponseWriter, req *http.Request) { 457 | var certRequestID string 458 | 459 | err := req.ParseForm() 460 | if err != nil { 461 | http.Error(rw, fmt.Sprintf("%v", err), http.StatusBadRequest) 462 | return 463 | } 464 | 465 | certRequestIDs, ok := req.Form["certRequestId"] 466 | if ok { 467 | certRequestID = certRequestIDs[0] 468 | } 469 | 470 | matched, _ := regexp.MatchString("^[A-Z2-7=]{10,16}$", certRequestID) 471 | if certRequestID != "" && !matched { 472 | http.Error(rw, "Invalid certRequestId", http.StatusBadRequest) 473 | return 474 | } 475 | log.Printf("List pending requests received from %s for request id '%s'\n", 476 | req.RemoteAddr, certRequestID) 477 | 478 | foundSomething := false 479 | results := make(certRequestResponse) 480 | h.stateMutex.RLock() 481 | defer h.stateMutex.RUnlock() 482 | for k, v := range h.state { 483 | encodedCert := base64.StdEncoding.EncodeToString(v.request.Marshal()) 484 | element := newResponseElement(v.request, encodedCert, v.certSigned, v.certRejected, len(v.signatures), h.Config[v.environment].NumberSignersRequired, v.request.Serial, v.reason, v.environment) 485 | // Two ways to use this URL. If caller specified a certRequestId 486 | // then we return only that one. Otherwise everything. 487 | if certRequestID == "" { 488 | results[k] = element 489 | foundSomething = true 490 | } else { 491 | if certRequestID == k { 492 | results[k] = element 493 | foundSomething = true 494 | break 495 | } 496 | } 497 | } 498 | if foundSomething { 499 | output, err := json.Marshal(results) 500 | if err != nil { 501 | http.Error(rw, fmt.Sprintf("Trouble marshaling json response %v", err), http.StatusInternalServerError) 502 | return 503 | } 504 | rw.Header().Set("Content-Type", "application/json; charset=utf-8") 505 | rw.Write(output) 506 | } else { 507 | http.Error(rw, fmt.Sprintf("No certs found."), http.StatusNotFound) 508 | return 509 | } 510 | } 511 | 512 | func (h *certRequestHandler) getRequestStatus(rw http.ResponseWriter, req *http.Request) { 513 | uriVars := mux.Vars(req) 514 | requestID := uriVars["requestID"] 515 | 516 | type Response struct { 517 | certSigned bool 518 | certRejected bool 519 | cert string 520 | } 521 | h.stateMutex.RLock() 522 | defer h.stateMutex.RUnlock() 523 | if h.state[requestID].certSigned { 524 | rw.Write([]byte(h.state[requestID].request.Type())) 525 | rw.Write([]byte(" ")) 526 | rw.Write([]byte(base64.StdEncoding.EncodeToString(h.state[requestID].request.Marshal()))) 527 | rw.Write([]byte("\n")) 528 | } else if h.state[requestID].certRejected { 529 | http.Error(rw, "Cert request was rejected.", http.StatusPreconditionFailed) 530 | } else { 531 | http.Error(rw, "Cert not signed yet.", http.StatusPreconditionFailed) 532 | } 533 | } 534 | 535 | func (h *certRequestHandler) signOrRejectRequest(rw http.ResponseWriter, req *http.Request) { 536 | requestID := mux.Vars(req)["requestID"] 537 | h.stateMutex.RLock() 538 | originalRequest, ok := h.state[requestID] 539 | h.stateMutex.RUnlock() 540 | if !ok { 541 | http.Error(rw, "Unknown request id", http.StatusNotFound) 542 | return 543 | } 544 | if originalRequest.certSigned { 545 | http.Error(rw, "Request already signed.", http.StatusConflict) 546 | return 547 | } 548 | if originalRequest.certRejected { 549 | http.Error(rw, "Request already rejected.", http.StatusConflict) 550 | return 551 | } 552 | 553 | err := req.ParseForm() 554 | if err != nil { 555 | http.Error(rw, fmt.Sprintf("%v", err), http.StatusBadRequest) 556 | return 557 | } 558 | 559 | envConfig, ok := h.Config[originalRequest.environment] 560 | if !ok { 561 | http.Error(rw, "Original request found to have an invalid env. Weird.", http.StatusBadRequest) 562 | return 563 | } 564 | 565 | signedCert, err := h.extractCertFromRequest(req) 566 | if err != nil { 567 | log.Printf("Unable to extract certificate signing request from %s, ignoring", req.RemoteAddr) 568 | http.Error(rw, fmt.Sprintf("%v", err), http.StatusBadRequest) 569 | return 570 | } 571 | err = h.validateCert(signedCert, envConfig.AuthorizedSigners) 572 | if err != nil { 573 | log.Printf("Invalid certificate signing request received from %s, ignoring", req.RemoteAddr) 574 | http.Error(rw, fmt.Sprintf("%v", err), http.StatusBadRequest) 575 | return 576 | } 577 | 578 | signerFp := ssh_ca_util.MakeFingerprint(signedCert.SignatureKey.Marshal()) 579 | 580 | // Verifying that the cert being posted to us here matches the one in the 581 | // request. That is, that an attacker isn't using an old signature to sign a 582 | // new/different request id 583 | h.stateMutex.RLock() 584 | requestedCert := h.state[requestID].request 585 | h.stateMutex.RUnlock() 586 | if !compareCerts(requestedCert, signedCert) { 587 | log.Printf("Signature was valid, but cert didn't match from %s.", req.RemoteAddr) 588 | log.Printf("Orig req: %#v\n", requestedCert) 589 | log.Printf("Sign req: %#v\n", signedCert) 590 | http.Error(rw, "Signature was valid, but cert didn't match.", http.StatusBadRequest) 591 | return 592 | } 593 | 594 | requesterFp := ssh_ca_util.MakeFingerprint(requestedCert.Key.Marshal()) 595 | 596 | // Make sure the key attempting to sign the request is not the same as the key in the CSR 597 | if signerFp == requesterFp { 598 | err = errors.New("Signed by the same key as key in request") 599 | http.Error(rw, fmt.Sprintf("%v", err), http.StatusBadRequest) 600 | return 601 | } 602 | 603 | log.Printf("Signature for serial %d id %s received from %s (%s) @ %s and determined valid\n", 604 | signedCert.Serial, requestID, signerFp, envConfig.AuthorizedSigners[signerFp], req.RemoteAddr) 605 | if req.Method == "POST" { 606 | err = h.addConfirmation(requestID, signerFp, envConfig) 607 | } else { 608 | err = h.rejectRequest(requestID, signerFp, envConfig) 609 | } 610 | if err != nil { 611 | http.Error(rw, fmt.Sprintf("%v", err), http.StatusNotFound) 612 | } 613 | } 614 | 615 | func (h *certRequestHandler) rejectRequest(requestID string, signerFp string, envConfig ssh_ca_util.SignerdConfig) error { 616 | log.Printf("Reject received for id %s", requestID) 617 | h.stateMutex.Lock() 618 | defer h.stateMutex.Unlock() 619 | stateInfo := h.state[requestID] 620 | stateInfo.certRejected = true 621 | // this is weird. see: https://code.google.com/p/go/issues/detail?id=3117 622 | h.state[requestID] = stateInfo 623 | return nil 624 | } 625 | 626 | func (h *certRequestHandler) addConfirmation(requestID string, signerFp string, envConfig ssh_ca_util.SignerdConfig) error { 627 | h.stateMutex.RLock() 628 | certRejected := h.state[requestID].certRejected 629 | h.stateMutex.RUnlock() 630 | if certRejected { 631 | return fmt.Errorf("Attempt to sign a rejected cert.") 632 | } 633 | h.stateMutex.Lock() 634 | h.state[requestID].signatures[signerFp] = true 635 | h.stateMutex.Unlock() 636 | 637 | if envConfig.SlackUrl != "" { 638 | slackMsg := fmt.Sprintf("SSH cert %s signed by %s making %d/%d signatures.", 639 | requestID, envConfig.AuthorizedSigners[signerFp], 640 | len(h.state[requestID].signatures), envConfig.NumberSignersRequired) 641 | err := ssh_ca_client.PostToSlack(envConfig.SlackUrl, envConfig.SlackChannel, slackMsg) 642 | if err != nil { 643 | log.Printf("Unable to post to slack for %s: %v", requestID, err) 644 | } 645 | } 646 | signed, err := h.maybeSignWithCa(requestID, envConfig.NumberSignersRequired, envConfig.SigningKeyFingerprint) 647 | if signed && err == nil { 648 | slackMsg := fmt.Sprintf("SSH cert request %s fully signed.", requestID) 649 | err := ssh_ca_client.PostToSlack(envConfig.SlackUrl, envConfig.SlackChannel, slackMsg) 650 | if err != nil { 651 | log.Printf("Unable to post to slack for %s: %v", requestID, err) 652 | } 653 | } 654 | return err 655 | } 656 | 657 | func (h *certRequestHandler) maybeSignWithCa(requestID string, numSignersRequired int, signingKeyFingerprint string) (bool, error) { 658 | h.stateMutex.Lock() 659 | defer h.stateMutex.Unlock() 660 | if len(h.state[requestID].signatures) >= numSignersRequired { 661 | if h.sshAgentConn == nil { 662 | // This is used for testing. We're effectively disabling working 663 | // with the ssh agent to avoid needing to mock it. 664 | log.Print("ssh agent uninitialized, will not attempt signing. This is normal in unittests") 665 | return true, nil 666 | } 667 | log.Printf("Received %d signatures for %s, signing now.\n", len(h.state[requestID].signatures), requestID) 668 | signer, err := ssh_ca_util.GetSignerForFingerprintOrUrl(signingKeyFingerprint, h.sshAgentConn) 669 | if err != nil { 670 | log.Printf("Couldn't find signing key for request %s, unable to sign request: %s\n", requestID, err) 671 | return false, fmt.Errorf("Couldn't find signing key, unable to sign. Sorry.") 672 | } 673 | stateInfo := h.state[requestID] 674 | for extensionName := range stateInfo.request.Extensions { 675 | // sshd up to version 6.8 has a bug where optional extensions are 676 | // treated as critical. If a cert contains any non-standard 677 | // extensions, like ours, the server rejects the cert because it 678 | // doesn't understand the extension. To cope with this we simply 679 | // strip our non-standard extensions before doing the final 680 | // signature. https://bugzilla.mindrot.org/show_bug.cgi?id=2387 681 | if strings.Contains(extensionName, "@") { 682 | delete(stateInfo.request.Extensions, extensionName) 683 | } 684 | } 685 | stateInfo.request.SignCert(rand.Reader, signer) 686 | stateInfo.certSigned = true 687 | // this is weird. see: https://code.google.com/p/go/issues/detail?id=3117 688 | h.state[requestID] = stateInfo 689 | return true, nil 690 | } 691 | return false, nil 692 | } 693 | 694 | func signdFlags() []cli.Flag { 695 | home := os.Getenv("HOME") 696 | if home == "" { 697 | home = "/" 698 | } 699 | configPath := home + "/.ssh_ca/sign_certd_config.json" 700 | 701 | return []cli.Flag{ 702 | cli.StringFlag{ 703 | Name: "config-file", 704 | Value: configPath, 705 | Usage: "Path to config.json", 706 | }, 707 | cli.StringFlag{ 708 | Name: "listen-address", 709 | Value: "127.0.0.1:8080", 710 | Usage: "HTTP service address", 711 | }, 712 | cli.BoolFlag{ 713 | Name: "reverse-proxy", 714 | Usage: "Set when service is behind a reverse proxy, like nginx", 715 | EnvVar: "SSH_CERT_AUTHORITY_PROXY", 716 | }, 717 | } 718 | } 719 | 720 | func signCertd(c *cli.Context) error { 721 | configPath := c.String("config-file") 722 | config := make(map[string]ssh_ca_util.SignerdConfig) 723 | err := ssh_ca_util.LoadConfig(configPath, &config) 724 | if err != nil { 725 | return cli.NewExitError(fmt.Sprintf("Load Config failed: %s", err), 1) 726 | } 727 | for envName, configObj := range config { 728 | err = areCriticalOptionsValid(configObj.CriticalOptions) 729 | if err != nil { 730 | return cli.NewExitError(fmt.Sprintf("Error validation config for env '%s': %s", envName, err), 1) 731 | } 732 | } 733 | err = runSignCertd(config, c.String("listen-address"), c.Bool("reverse-proxy")) 734 | return err 735 | } 736 | 737 | func makeCertRequestHandler(config map[string]ssh_ca_util.SignerdConfig) certRequestHandler { 738 | var requestHandler certRequestHandler 739 | requestHandler.Config = config 740 | requestHandler.state = make(map[string]certRequest) 741 | return requestHandler 742 | } 743 | 744 | func runSignCertd(config map[string]ssh_ca_util.SignerdConfig, addr string, is_proxied bool) error { 745 | log.Println("Server running version", version.BuildVersion) 746 | log.Println("Using SSH agent at", os.Getenv("SSH_AUTH_SOCK")) 747 | 748 | sshAgentConn, err := net.Dial("unix", os.Getenv("SSH_AUTH_SOCK")) 749 | if err != nil { 750 | return cli.NewExitError(fmt.Sprintf("Dial failed: %s", err), 1) 751 | } 752 | requestHandler := makeCertRequestHandler(config) 753 | requestHandler.sshAgentConn = sshAgentConn 754 | err = requestHandler.setupPrivateKeys(config) 755 | if err != nil { 756 | return cli.NewExitError(fmt.Sprintf("Failed CA key load: %v\n", err), 1) 757 | } 758 | 759 | log.Printf("Server started with config %#v\n", config) 760 | 761 | r := mux.NewRouter() 762 | requests := r.Path("/cert/requests").Subrouter() 763 | requests.Methods("POST").HandlerFunc(requestHandler.createSigningRequest) 764 | requests.Methods("GET").HandlerFunc(requestHandler.listPendingRequests) 765 | request := r.Path("/cert/requests/{requestID}").Subrouter() 766 | request.Methods("GET").HandlerFunc(requestHandler.getRequestStatus) 767 | request.Methods("POST", "DELETE").HandlerFunc(requestHandler.signOrRejectRequest) 768 | environments := r.Path("/config/environments").Subrouter() 769 | environments.Methods("GET").HandlerFunc(requestHandler.listEnvironments) 770 | 771 | if is_proxied { 772 | http.ListenAndServe(addr, handlers.ProxyHeaders(r)) 773 | } else { 774 | http.ListenAndServe(addr, r) 775 | } 776 | return nil 777 | } 778 | -------------------------------------------------------------------------------- /sign_certd_test.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "github.com/cloudtools/ssh-cert-authority/util" 5 | "golang.org/x/crypto/ssh" 6 | "testing" 7 | ) 8 | 9 | // 768 bit key to try to keep the paste as small as possible 10 | const signerPublicKeyString = "ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAAAYQDQ0aYhDW5wP8rVBIpXiCTiKw33/nI1mAti3EoX/zRYWtP2XMBePqnf2w2V+z5FEJm8hFSDpb8DtRBNonGEceuTn5CNjfNpDIJ7H3OOa8mP0CBiJY9A9Oopwn/hsRLy0vk= user-key" 11 | const userPublicKeyString = "ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAAAYQC6Shl5kUuTGqkSc8D2vP2kls2GoB/eGlgIb0BnM/zsIsbw5cWsPournZN2IwnwMhCFLT/56CzT9ZzVfn26hxn86KMpg76NcfP5Gnd66dsXHhiMXnBeS9r6KPQeqzVInwE= signer-key" 12 | const caPublicKeyString = "ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAAAYQDBdHEBPftP3CYZms38s0/1OgczR0UWSBmsbkoT1wL0PFG3OWkbiLE8zUue15EmajA/1VlbZPChbAF8Ub/9ZpdM/lzTv5v0cF8xSlw5oXOZEnIpSYD6M9OboTs3f5HyXfk= ca-key" 13 | const caPrivateKeyString = `-----BEGIN RSA PRIVATE KEY----- 14 | MIIBygIBAAJhAMF0cQE9+0/cJhmazfyzT/U6BzNHRRZIGaxuShPXAvQ8Ubc5aRuI 15 | sTzNS57XkSZqMD/VWVtk8KFsAXxRv/1ml0z+XNO/m/RwXzFKXDmhc5kScilJgPoz 16 | 05uhOzd/kfJd+QIDAQABAmBQExmfcP9wO+jNWmV+/t3O3JkUYaC4K1ntJK2m7q27 17 | WKheVfYqvnbWewedFQ9wvizH5USTOnt1TQXeMvIt3ysQCEfs36slJNhiYcndZtQg 18 | CHebcmfaVwz6bV7+oZxxuoECMQDuWAZInNwgy2H+Vl3ElkqqPWG13RwZf0DI5BHQ 19 | bmOMWPL1wCEJyCPs9sYh8QqQj8kCMQDPySM90tdhZUMZ0+7dWJmTEO6vxs+SdxBa 20 | lQB/chG2QaFdSUQJO08IUwmfv3DJVLECMCS+DyHshIbNs6qYt9XRcWszETgPAQDx 21 | PBR8DD78dX4yTCoUV0OBxgAGvt6GoSFN+QIwGZOVne+NCXUQfGZk+aQFS2ADMWnU 22 | dR/oyG2c4RMmcPvFJBl3oXdGdCzce2hyNqYRAjEApZH6zZCu/mLz0+jiuxiSKR1i 23 | USCW6VgmTJhf7XHWkp4GsykjNY/exQGdO+T3CPF+ 24 | -----END RSA PRIVATE KEY-----` 25 | const userPrivateKeyString = `-----BEGIN RSA PRIVATE KEY----- 26 | MIIBywIBAAJhANDRpiENbnA/ytUEileIJOIrDff+cjWYC2LcShf/NFha0/ZcwF4+ 27 | qd/bDZX7PkUQmbyEVIOlvwO1EE2icYRx65OfkI2N82kMgnsfc45ryY/QIGIlj0D0 28 | 6inCf+GxEvLS+QIDAQABAmEArs4x8g1aXCEq3LPWU3wm1CYSpX2dgfvr3DBo3jnH 29 | SgeO1PfEGaD/d+PaNamC8TH43KjYnnkz9d2vZvHiuqrKlAz/DzxRb1SIQumece86 30 | wbc3Z/kwkVSNThpnV/g6r8pZAjEA9Xyl6951daRsUPJGGjPTsQOiqyiJK88x/51V 31 | OkT/qUoyqsSbsSIax7nr81vria2fAjEA2cL/hKqUGRfPtPiejpEgzZqGMJY4i5YH 32 | 9k5TeFgxFenr0Pc2Kl1UW9jwIAVZPwhnAjAXrVMPgeBQXXB5CjUKt+72BsS8v2cj 33 | i5Nl9RXQTfFesaJbaCUgG4r7son4aeg42j8CMBPHu7AQUo2I9SwKHVTz59flPmUx 34 | cAd15FlCOiDHWgYUjoAXxIrKmXwSU5WFBttL5wIxAMUNKEElbLlK6/u/LknYWxUI 35 | wD1uIS8C/S226HDgbJI5fcI3sWlgr65NqMnW/6PnWA== 36 | -----END RSA PRIVATE KEY-----` 37 | const signerPrivateKeyString = `-----BEGIN RSA PRIVATE KEY----- 38 | MIIBzAIBAAJhALpKGXmRS5MaqRJzwPa8/aSWzYagH94aWAhvQGcz/OwixvDlxaw+ 39 | i6udk3YjCfAyEIUtP/noLNP1nNV+fbqHGfzooymDvo1x8/kad3rp2xceGIxecF5L 40 | 2voo9B6rNUifAQIDAQABAmEAp89wO05jIdR2USTswldktQsTgR5lFpHsk0yEW3M9 41 | dwms4/xXoN2Gu8VqvJS7sx+kr9QNr8N2tgnLz6UB1zU4Zusw3PVEb0qKTXAnYF9R 42 | iMXRV43sCT3yUvnQAz6Nj2FxAjEA6MuOnyd9vNsnFoVbXq9NqGFqhh5koAxEKZ30 43 | t3xxf2Ai+hUS+5hGINHfQUMVs9MHAjEAzNvQmCID63pEg34QAaa2Z7JTTCO6UCGi 44 | UN88qyZJzF7hVz8HWzMfqOlOozyVQtO3AjBi1YlHqMyJUcHWneec23Bs/G7tYhn2 45 | mT6XLKio/fxxx68R3cChcJTVekT+wCyGnCECMQCujByPg2wTl3oJD8BTp9iDQk32 46 | 8fotjHrgrVTj/xuiJrWZwPpjmou/QArgyx3icsECMQC7SWUafU8e1udo22uY8sVT 47 | c2wCh1KaBOotbpXX0zISFvWsDtAAb8/o2A43eRlTA2k= 48 | -----END RSA PRIVATE KEY-----` 49 | const boringUserCertString = "ssh-rsa-cert-v01@openssh.com AAAAHHNzaC1yc2EtY2VydC12MDFAb3BlbnNzaC5jb20AAAAgD1NjpHjT+p/i1qgvl9D/kNlm/sbK30K3yQQJrKbI694AAAADAQABAAAAYQC6Shl5kUuTGqkSc8D2vP2kls2GoB/eGlgIb0BnM/zsIsbw5cWsPournZN2IwnwMhCFLT/56CzT9ZzVfn26hxn86KMpg76NcfP5Gnd66dsXHhiMXnBeS9r6KPQeqzVInwEAAAAAAAAAAQAAAAEAAAAIYnZ6LXRlc3QAAAAWAAAABnVidW50dQAAAAhlYzItdXNlcgAAAAAAAAAA//////////8AAAAAAAAAggAAABVwZXJtaXQtWDExLWZvcndhcmRpbmcAAAAAAAAAF3Blcm1pdC1hZ2VudC1mb3J3YXJkaW5nAAAAAAAAABZwZXJtaXQtcG9ydC1mb3J3YXJkaW5nAAAAAAAAAApwZXJtaXQtcHR5AAAAAAAAAA5wZXJtaXQtdXNlci1yYwAAAAAAAAAAAAAAdwAAAAdzc2gtcnNhAAAAAwEAAQAAAGEAukoZeZFLkxqpEnPA9rz9pJbNhqAf3hpYCG9AZzP87CLG8OXFrD6Lq52TdiMJ8DIQhS0/+egs0/Wc1X59uocZ/OijKYO+jXHz+Rp3eunbFx4YjF5wXkva+ij0Hqs1SJ8BAAAAbwAAAAdzc2gtcnNhAAAAYEUiGq8/zcN4UglbHsbU4PwHoAfzQQDkXk41oxYya5Ig858Y4EDf+BzhOOLNoAoTawKyWkTCKRayXpNdRfSFjiraO3a57XRGEJWF4ybgP1leYQ7QlERmmViAHMSxrNpL3Q== bvanzant@bvz-air.local" 50 | const signedByInvalidUserCertString = "ssh-rsa-cert-v01@openssh.com AAAAHHNzaC1yc2EtY2VydC12MDFAb3BlbnNzaC5jb20AAAAg9KP0im2yxWQuEQkSGKK9Ym4tb17ZyWs9xSwN0lsU3o4AAAADAQABAAAAYQC6Shl5kUuTGqkSc8D2vP2kls2GoB/eGlgIb0BnM/zsIsbw5cWsPournZN2IwnwMhCFLT/56CzT9ZzVfn26hxn86KMpg76NcfP5Gnd66dsXHhiMXnBeS9r6KPQeqzVInwEAAAAAAAAAAQAAAAEAAAAIYnZ6LXRlc3QAAAAWAAAABnVidW50dQAAAAhlYzItdXNlcgAAAAAAAAAA//////////8AAAAAAAAAggAAABVwZXJtaXQtWDExLWZvcndhcmRpbmcAAAAAAAAAF3Blcm1pdC1hZ2VudC1mb3J3YXJkaW5nAAAAAAAAABZwZXJtaXQtcG9ydC1mb3J3YXJkaW5nAAAAAAAAAApwZXJtaXQtcHR5AAAAAAAAAA5wZXJtaXQtdXNlci1yYwAAAAAAAAAAAAAAdwAAAAdzc2gtcnNhAAAAAwEAAQAAAGEAwXRxAT37T9wmGZrN/LNP9ToHM0dFFkgZrG5KE9cC9DxRtzlpG4ixPM1LnteRJmowP9VZW2TwoWwBfFG//WaXTP5c07+b9HBfMUpcOaFzmRJyKUmA+jPTm6E7N3+R8l35AAAAbwAAAAdzc2gtcnNhAAAAYGuA1rEVeDacEoKSFHlxpD85O7ueBWHm8zRdmqc/txt88FnFKlb/cBFkL//8P5mB5WbjWGSnOXokUqj9Xf5gaIb1jfWzWql3HoYomlkyVGcI+g+lMq1w6/rI7lTcpEAjaw== bvanzant@bvz-air.local" 51 | const foreverUserCertString = "ssh-rsa-cert-v01@openssh.com AAAAHHNzaC1yc2EtY2VydC12MDFAb3BlbnNzaC5jb20AAAAgOQ95vOpIs7uJPiJ1VikwGbmf7LZWFCkD4SYzP9XEvrkAAAADAQABAAAAYQC6Shl5kUuTGqkSc8D2vP2kls2GoB/eGlgIb0BnM/zsIsbw5cWsPournZN2IwnwMhCFLT/56CzT9ZzVfn26hxn86KMpg76NcfP5Gnd66dsXHhiMXnBeS9r6KPQeqzVInwEAAAAAAAAAAAAAAAEAAAANdGVzdGZhcmZ1dHVyZQAAAAoAAAAGdWJ1bnR1AAAAAAAAAAD//////////wAAAAAAAACCAAAAFXBlcm1pdC1YMTEtZm9yd2FyZGluZwAAAAAAAAAXcGVybWl0LWFnZW50LWZvcndhcmRpbmcAAAAAAAAAFnBlcm1pdC1wb3J0LWZvcndhcmRpbmcAAAAAAAAACnBlcm1pdC1wdHkAAAAAAAAADnBlcm1pdC11c2VyLXJjAAAAAAAAAAAAAAB3AAAAB3NzaC1yc2EAAAADAQABAAAAYQC6Shl5kUuTGqkSc8D2vP2kls2GoB/eGlgIb0BnM/zsIsbw5cWsPournZN2IwnwMhCFLT/56CzT9ZzVfn26hxn86KMpg76NcfP5Gnd66dsXHhiMXnBeS9r6KPQeqzVInwEAAABvAAAAB3NzaC1yc2EAAABgrLrgF1y6t1s0APpKi2JcBDI9OhtsIa7SLaTvRrhAmXc+0OQPlgtmqp581Mwnpv+cuJpoPurO3cQpfzT+XDnRZdoEOBz9wTzkbb6tqQ/O+Ltf5ss5cyKd0GHgGCzUVYB7 user-key" 52 | const twentyTwentyFiveCertString = "ssh-rsa-cert-v01@openssh.com AAAAHHNzaC1yc2EtY2VydC12MDFAb3BlbnNzaC5jb20AAAAgFJu+SQd43RC/DZjYXtsiSAZslugnFX0LQV44bQL5XlgAAAADAQABAAAAYQC6Shl5kUuTGqkSc8D2vP2kls2GoB/eGlgIb0BnM/zsIsbw5cWsPournZN2IwnwMhCFLT/56CzT9ZzVfn26hxn86KMpg76NcfP5Gnd66dsXHhiMXnBeS9r6KPQeqzVInwEAAAAAAAAAAAAAAAEAAAANdGVzdGZhcmZ1dHVyZQAAAAoAAAAGdWJ1bnR1AAAAAFYW52AAAAAAaOLqwgAAAAAAAACCAAAAFXBlcm1pdC1YMTEtZm9yd2FyZGluZwAAAAAAAAAXcGVybWl0LWFnZW50LWZvcndhcmRpbmcAAAAAAAAAFnBlcm1pdC1wb3J0LWZvcndhcmRpbmcAAAAAAAAACnBlcm1pdC1wdHkAAAAAAAAADnBlcm1pdC11c2VyLXJjAAAAAAAAAAAAAAB3AAAAB3NzaC1yc2EAAAADAQABAAAAYQC6Shl5kUuTGqkSc8D2vP2kls2GoB/eGlgIb0BnM/zsIsbw5cWsPournZN2IwnwMhCFLT/56CzT9ZzVfn26hxn86KMpg76NcfP5Gnd66dsXHhiMXnBeS9r6KPQeqzVInwEAAABvAAAAB3NzaC1yc2EAAABgHPStnlKu6jr8bI0LG3LwwWczyLU56rMozAngOo6ovCAMqXtWZ5GvfUZf+Ok+B+6usJrMZQqrE+OhdC+GVWqzee9nH9Wy1n8ACbGVWJtN63iJxqrMqOnLVvSrd7s7tPqv user-key" 53 | 54 | func SetupSignerdConfig(numSignersRequired, maxCertLifetime int) map[string]ssh_ca_util.SignerdConfig { 55 | config := make(map[string]ssh_ca_util.SignerdConfig) 56 | config["testing"] = ssh_ca_util.SignerdConfig{ 57 | SigningKeyFingerprint: "4c:c6:1e:31:ed:7b:7c:33:ff:7d:51:9e:59:da:68:f5", 58 | AuthorizedUsers: map[string]string{ 59 | "f6:e3:42:5e:72:85:ce:26:e8:45:1f:79:2d:dc:0d:52": "test-user", 60 | }, 61 | AuthorizedSigners: map[string]string{ 62 | "23:10:8d:d0:54:90:d5:d1:2e:4d:05:fe:4b:54:29:e4": "test-signer", 63 | }, 64 | NumberSignersRequired: numSignersRequired, 65 | MaxCertLifetime: maxCertLifetime, 66 | } 67 | return config 68 | } 69 | 70 | func TestRejectRequest(t *testing.T) { 71 | allConfig := SetupSignerdConfig(1, 0) 72 | environment := "testing" 73 | envConfig := allConfig[environment] 74 | requestHandler := makeCertRequestHandler(allConfig) 75 | 76 | pubKey, _, _, _, err := ssh.ParseAuthorizedKey([]byte(boringUserCertString)) 77 | if err != nil { 78 | t.Fatalf("Parsing canned cert failed: %v", err) 79 | } 80 | cert := pubKey.(*ssh.Certificate) 81 | _, err = requestHandler.saveSigningRequest(envConfig, environment, "reason: testing", "DEADBEEFDEADBEEF", 1, cert) 82 | if err != nil { 83 | t.Fatalf("Should have succeeded. Failed with: %v", err) 84 | } 85 | 86 | err = requestHandler.rejectRequest("DEADBEEFDEADBEEF", "23:10:8d:d0:54:90:d5:d1:2e:4d:05:fe:4b:54:29:e4", envConfig) 87 | if err != nil { 88 | t.Fatalf("Should have succeeded. Failed with: %v", err) 89 | } 90 | err = requestHandler.addConfirmation("DEADBEEFDEADBEEF", "23:10:8d:d0:54:90:d5:d1:2e:4d:05:fe:4b:54:29:e4", envConfig) 91 | if err == nil { 92 | t.Fatalf("Sign after reject should fail.") 93 | } 94 | } 95 | 96 | func TestRejectRequestAfterSigning(t *testing.T) { 97 | allConfig := SetupSignerdConfig(2, 0) 98 | environment := "testing" 99 | envConfig := allConfig[environment] 100 | requestHandler := makeCertRequestHandler(allConfig) 101 | 102 | pubKey, _, _, _, err := ssh.ParseAuthorizedKey([]byte(boringUserCertString)) 103 | if err != nil { 104 | t.Fatalf("Parsing canned cert failed: %v", err) 105 | } 106 | cert := pubKey.(*ssh.Certificate) 107 | _, err = requestHandler.saveSigningRequest(envConfig, environment, "reason: testing", "DEADBEEFDEADBEEF", 1, cert) 108 | if err != nil { 109 | t.Fatalf("Should have succeeded. Failed with: %v", err) 110 | } 111 | 112 | err = requestHandler.addConfirmation("DEADBEEFDEADBEEF", "23:10:8d:d0:54:90:d5:d1:2e:4d:05:fe:4b:54:29:e4", envConfig) 113 | if err != nil { 114 | t.Fatalf("Sign should have worked. It failed: %v", err) 115 | } 116 | 117 | err = requestHandler.rejectRequest("DEADBEEFDEADBEEF", "23:10:8d:d0:54:90:d5:d1:2e:4d:05:fe:4b:54:29:e4", envConfig) 118 | if err != nil { 119 | t.Fatalf("Should have succeeded. Failed with: %v", err) 120 | } 121 | 122 | err = requestHandler.addConfirmation("DEADBEEFDEADBEEF", "23:10:8d:d0:54:90:d5:d1:2e:4d:05:fe:4b:54:29:e4", envConfig) 123 | if err == nil { 124 | t.Fatalf("Sign after reject should fail.") 125 | } 126 | } 127 | 128 | func TestSaveForeverCertDisallowed(t *testing.T) { 129 | allConfig := SetupSignerdConfig(1, 14400) 130 | environment := "testing" 131 | envConfig := allConfig[environment] 132 | requestHandler := makeCertRequestHandler(allConfig) 133 | 134 | pubKey, _, _, _, err := ssh.ParseAuthorizedKey([]byte(foreverUserCertString)) 135 | if err != nil { 136 | t.Fatalf("Parsing canned cert failed: %v", err) 137 | } 138 | cert := pubKey.(*ssh.Certificate) 139 | _, err = requestHandler.saveSigningRequest(envConfig, environment, "reason: testing", "DEADBEEFDEADBEEF", 1, cert) 140 | if err == nil { 141 | t.Fatalf("Should have failed because cert never expires.") 142 | } 143 | 144 | pubKey, _, _, _, err = ssh.ParseAuthorizedKey([]byte(twentyTwentyFiveCertString)) 145 | if err != nil { 146 | t.Fatalf("Parsing canned cert failed: %v", err) 147 | } 148 | cert = pubKey.(*ssh.Certificate) 149 | _, err = requestHandler.saveSigningRequest(envConfig, environment, "reason: testing", "DEADBEEFDEADBEEF", 1, cert) 150 | if err == nil { 151 | t.Fatalf("Should have failed because cert expires in 2025.") 152 | } 153 | } 154 | 155 | func TestSaveForeverCertAllowed(t *testing.T) { 156 | allConfig := SetupSignerdConfig(1, 0) 157 | environment := "testing" 158 | envConfig := allConfig[environment] 159 | requestHandler := makeCertRequestHandler(allConfig) 160 | 161 | pubKey, _, _, _, err := ssh.ParseAuthorizedKey([]byte(foreverUserCertString)) 162 | if err != nil { 163 | t.Fatalf("Parsing canned cert failed: %v", err) 164 | } 165 | cert := pubKey.(*ssh.Certificate) 166 | _, err = requestHandler.saveSigningRequest(envConfig, environment, "reason: testing", "DEADBEEFDEADBEEF", 1, cert) 167 | if err != nil { 168 | t.Fatalf("Should have worked, failed with: %v", err) 169 | } 170 | 171 | pubKey, _, _, _, err = ssh.ParseAuthorizedKey([]byte(twentyTwentyFiveCertString)) 172 | if err != nil { 173 | t.Fatalf("Parsing canned cert failed: %v", err) 174 | } 175 | cert = pubKey.(*ssh.Certificate) 176 | _, err = requestHandler.saveSigningRequest(envConfig, environment, "reason: testing", "DEADBEEFDEADBEEF2", 1, cert) 177 | if err != nil { 178 | t.Fatalf("Should have worked, failed with: %v", err) 179 | } 180 | } 181 | 182 | func TestSaveRequestAutoSign(t *testing.T) { 183 | allConfig := SetupSignerdConfig(-1, 0) 184 | environment := "testing" 185 | envConfig := allConfig[environment] 186 | requestHandler := makeCertRequestHandler(allConfig) 187 | 188 | pubKey, _, _, _, err := ssh.ParseAuthorizedKey([]byte(boringUserCertString)) 189 | if err != nil { 190 | t.Fatalf("Parsing canned cert failed: %v", err) 191 | } 192 | cert := pubKey.(*ssh.Certificate) 193 | signed, err := requestHandler.saveSigningRequest(envConfig, environment, "reason: testing", "DEADBEEFDEADBEEF", 1, cert) 194 | if err != nil { 195 | t.Fatalf("Should have succeeded. Failed with: %v", err) 196 | } 197 | if !signed { 198 | t.Fatal("Should have auto signed. But we didn't.") 199 | } 200 | } 201 | 202 | func TestSaveRequestValidCert(t *testing.T) { 203 | allConfig := SetupSignerdConfig(1, 0) 204 | environment := "testing" 205 | envConfig := allConfig[environment] 206 | requestHandler := makeCertRequestHandler(allConfig) 207 | 208 | pubKey, _, _, _, err := ssh.ParseAuthorizedKey([]byte(boringUserCertString)) 209 | if err != nil { 210 | t.Fatalf("Parsing canned cert failed: %v", err) 211 | } 212 | cert := pubKey.(*ssh.Certificate) 213 | _, err = requestHandler.saveSigningRequest(envConfig, environment, "reason: testing", "DEADBEEFDEADBEEF", 1, cert) 214 | if err != nil { 215 | t.Fatalf("Should have succeeded. Failed with: %v", err) 216 | } 217 | 218 | _, err = requestHandler.saveSigningRequest(envConfig, environment, "", "DEADBEEFDEAD1111", 1, cert) 219 | if err == nil { 220 | t.Fatalf("Should have failed, reason was missing.") 221 | } 222 | _, err = requestHandler.saveSigningRequest(envConfig, "", "reason: testing", "DEADBEEFDEAD2222", 1, cert) 223 | if err == nil { 224 | t.Fatalf("Should have failed, environment was missing.") 225 | } 226 | _, err = requestHandler.saveSigningRequest(envConfig, environment, "reason: testing", "EEEFDF", 1, cert) 227 | if err == nil { 228 | t.Fatalf("Should have failed with invalid request id (too short).") 229 | } 230 | 231 | _, err = requestHandler.saveSigningRequest(envConfig, environment, "reason: testing", "IAM_A_DUPLICATE_ID", 1, cert) 232 | if err != nil { 233 | t.Fatalf("Should have succeeded. Failed with: %v", err) 234 | } 235 | _, err = requestHandler.saveSigningRequest(envConfig, environment, "reason: testing", "IAM_A_DUPLICATE_ID", 1, cert) 236 | if err == nil { 237 | t.Fatalf("Should have failed with duplicate error") 238 | } 239 | } 240 | 241 | func TestSaveRequestInvalidCert(t *testing.T) { 242 | allConfig := SetupSignerdConfig(1, 0) 243 | environment := "testing" 244 | envConfig := allConfig[environment] 245 | requestHandler := makeCertRequestHandler(allConfig) 246 | 247 | pubKey, _, _, _, err := ssh.ParseAuthorizedKey([]byte(signedByInvalidUserCertString)) 248 | if err != nil { 249 | t.Fatalf("Parsing canned cert failed: %v", err) 250 | } 251 | cert := pubKey.(*ssh.Certificate) 252 | _, err = requestHandler.saveSigningRequest(envConfig, environment, "reason: testing", "DEADBEEFDEADBEEF", 1, cert) 253 | if err == nil { 254 | t.Fatalf("Should have failed with fingerprint not in list error.") 255 | } 256 | } 257 | 258 | func TestSaveRequestInvalidCriticalOptions(t *testing.T) { 259 | allConfig := SetupSignerdConfig(1, 0) 260 | environment := "testing" 261 | envConfig := allConfig[environment] 262 | envConfig.CriticalOptions = make(map[string]string) 263 | envConfig.CriticalOptions["non-existent-critical"] = "yes" 264 | if areCriticalOptionsValid(envConfig.CriticalOptions) == nil { 265 | t.Fatalf("Should have found invalid critical option and didn't") 266 | } 267 | } 268 | 269 | func TestSaveRequestValidCriticalOptions(t *testing.T) { 270 | allConfig := SetupSignerdConfig(1, 0) 271 | environment := "testing" 272 | envConfig := allConfig[environment] 273 | envConfig.CriticalOptions = make(map[string]string) 274 | envConfig.CriticalOptions["force-command"] = "/bin/ls" 275 | if areCriticalOptionsValid(envConfig.CriticalOptions) != nil { 276 | t.Fatalf("Critical option is valid. But our test failed.") 277 | } 278 | } 279 | 280 | func TestValidateCert(t *testing.T) { 281 | allConfig := SetupSignerdConfig(1, 0) 282 | environment := "testing" 283 | envConfig := allConfig[environment] 284 | requestHandler := makeCertRequestHandler(allConfig) 285 | 286 | pubKey, _, _, _, err := ssh.ParseAuthorizedKey([]byte(boringUserCertString)) 287 | if err != nil { 288 | t.Fatalf("Parsing canned cert failed: %v", err) 289 | } 290 | cert := pubKey.(*ssh.Certificate) 291 | 292 | // test-user is *not* in the list of authorized signers 293 | 294 | err = requestHandler.validateCert(cert, envConfig.AuthorizedSigners) 295 | 296 | if err == nil { 297 | t.Fatalf("Should have failed. Succeeded with: %v", err) 298 | } 299 | 300 | // test-user *is* in the list of authorized users 301 | 302 | err = requestHandler.validateCert(cert, envConfig.AuthorizedUsers) 303 | if err != nil { 304 | t.Fatalf("Should have succeeded. Failed with: %v", err) 305 | } 306 | } 307 | 308 | func getTwoBoringCerts(t *testing.T) (*ssh.Certificate, *ssh.Certificate) { 309 | pubKeyOne, _, _, _, err := ssh.ParseAuthorizedKey([]byte(boringUserCertString)) 310 | if err != nil { 311 | t.Fatalf("Parsing canned cert failed: %v", err) 312 | } 313 | boringCertOne := pubKeyOne.(*ssh.Certificate) 314 | 315 | pubKeyTwo, _, _, _, err := ssh.ParseAuthorizedKey([]byte(boringUserCertString)) 316 | if err != nil { 317 | t.Fatalf("Parsing canned cert failed: %v", err) 318 | } 319 | boringCertTwo := pubKeyTwo.(*ssh.Certificate) 320 | 321 | return boringCertOne, boringCertTwo 322 | } 323 | 324 | func TestCompareCertsEasy(t *testing.T) { 325 | boringCertOne, boringCertTwo := getTwoBoringCerts(t) 326 | if !compareCerts(boringCertOne, boringCertTwo) { 327 | t.Fatalf("Certs should have compared equal.") 328 | } 329 | } 330 | 331 | func TestCompareCertsExtendedTime(t *testing.T) { 332 | boringCertOne, boringCertTwo := getTwoBoringCerts(t) 333 | boringCertTwo.ValidBefore += 1024 334 | if compareCerts(boringCertOne, boringCertTwo) { 335 | t.Fatalf("Certs should not have been equal.") 336 | } 337 | } 338 | 339 | func TestCompareCertsSerialMismatch(t *testing.T) { 340 | boringCertOne, boringCertTwo := getTwoBoringCerts(t) 341 | boringCertTwo.Serial = 0 342 | if compareCerts(boringCertOne, boringCertTwo) { 343 | t.Fatalf("Certs should not have been equal.") 344 | } 345 | } 346 | 347 | func TestCompareCertsTypeMismatch(t *testing.T) { 348 | boringCertOne, boringCertTwo := getTwoBoringCerts(t) 349 | boringCertTwo.CertType = ssh.HostCert 350 | if compareCerts(boringCertOne, boringCertTwo) { 351 | t.Fatalf("Certs should not have been equal.") 352 | } 353 | } 354 | 355 | func TestCompareCertsKeyId(t *testing.T) { 356 | boringCertOne, boringCertTwo := getTwoBoringCerts(t) 357 | boringCertTwo.KeyId = "I'm a liar!" 358 | if compareCerts(boringCertOne, boringCertTwo) { 359 | t.Fatalf("Certs should not have been equal.") 360 | } 361 | } 362 | 363 | func TestCompareCertsAddInSomeRoot(t *testing.T) { 364 | boringCertOne, boringCertTwo := getTwoBoringCerts(t) 365 | boringCertTwo.ValidPrincipals = append(boringCertTwo.ValidPrincipals, "root") 366 | if compareCerts(boringCertOne, boringCertTwo) { 367 | t.Fatalf("Certs should not have been equal.") 368 | } 369 | } 370 | 371 | func TestCompareCertsReplaceWithRoot(t *testing.T) { 372 | boringCertOne, boringCertTwo := getTwoBoringCerts(t) 373 | boringCertTwo.ValidPrincipals[0] = "root" 374 | if compareCerts(boringCertOne, boringCertTwo) { 375 | t.Fatalf("Certs should not have been equal.") 376 | } 377 | } 378 | 379 | func TestCompareCertsCriticalOptions(t *testing.T) { 380 | boringCertOne, boringCertTwo := getTwoBoringCerts(t) 381 | boringCertTwo.CriticalOptions["foobar"] = "we don't use these" 382 | if compareCerts(boringCertOne, boringCertTwo) { 383 | t.Fatalf("Certs should not have been equal.") 384 | } 385 | } 386 | 387 | func TestCompareCertsExtensions(t *testing.T) { 388 | boringCertOne, boringCertTwo := getTwoBoringCerts(t) 389 | boringCertTwo.CriticalOptions["environment@cloudtools.github.io"] = "prod" 390 | if compareCerts(boringCertOne, boringCertTwo) { 391 | t.Fatalf("Certs should not have been equal.") 392 | } 393 | } 394 | 395 | func TestCompareCertsNonce(t *testing.T) { 396 | boringCertOne, boringCertTwo := getTwoBoringCerts(t) 397 | boringCertOne.Nonce = []byte("this is nonce one") 398 | boringCertTwo.Nonce = []byte("this is nonce two") 399 | if !compareCerts(boringCertOne, boringCertTwo) { 400 | t.Fatalf("Certs should have been equal, we don't compare Nonce.") 401 | } 402 | } 403 | 404 | func TestCompareCertsReserved(t *testing.T) { 405 | boringCertOne, boringCertTwo := getTwoBoringCerts(t) 406 | boringCertTwo.Reserved = []byte("some random garbage") 407 | if compareCerts(boringCertOne, boringCertTwo) { 408 | t.Fatalf("Certs should not have been equal.") 409 | } 410 | } 411 | 412 | func TestCompareCertsPublicKey(t *testing.T) { 413 | boringCertOne, boringCertTwo := getTwoBoringCerts(t) 414 | boringCertTwo.Key, _, _, _, _ = ssh.ParseAuthorizedKey([]byte(signerPublicKeyString)) 415 | if compareCerts(boringCertOne, boringCertTwo) { 416 | t.Fatalf("Certs should not have been equal.") 417 | } 418 | } 419 | -------------------------------------------------------------------------------- /signer/gcpkms.go: -------------------------------------------------------------------------------- 1 | package signer 2 | 3 | import ( 4 | "cloud.google.com/go/kms/apiv1" 5 | "context" 6 | "crypto" 7 | "crypto/x509" 8 | "encoding/pem" 9 | "fmt" 10 | "golang.org/x/crypto/ssh" 11 | kmspb "google.golang.org/genproto/googleapis/cloud/kms/v1" 12 | "io" 13 | "strings" 14 | "time" 15 | ) 16 | 17 | // Singleton client 18 | var kmsClient *kms.KeyManagementClient 19 | 20 | type GcpKmsSigner struct { 21 | keyUrl string 22 | kmsClient *kms.KeyManagementClient 23 | kmsPubKey crypto.PublicKey 24 | } 25 | 26 | func NewSshGcpKmsSigner(keyUrl string) (ssh.Signer, error) { 27 | kmsSigner, err := NewGcpKmsSigner(keyUrl) 28 | if err != nil { 29 | return nil, err 30 | } 31 | return ssh.NewSignerFromSigner(kmsSigner) 32 | } 33 | 34 | func NewGcpKmsSigner(keyUrl string) (*GcpKmsSigner, error) { 35 | keyUrl = strings.TrimPrefix(keyUrl, "/") 36 | kmsClient, err := getKmsClient() 37 | if err != nil { 38 | return nil, fmt.Errorf("Unable to initialize kms client: %s", err) 39 | } 40 | ctx := context.Background() 41 | ctx, _ = context.WithTimeout(ctx, 10*time.Second) 42 | getPubKeyReq := &kmspb.GetPublicKeyRequest{ 43 | Name: keyUrl, 44 | } 45 | kmsPubKeypb, err := kmsClient.GetPublicKey(ctx, getPubKeyReq) 46 | if err != nil { 47 | return nil, fmt.Errorf("Unable to get signing public key from kms: %s", err) 48 | } 49 | block, _ := pem.Decode([]byte(kmsPubKeypb.Pem)) 50 | pubKey, err := x509.ParsePKIXPublicKey(block.Bytes) 51 | if err != nil { 52 | return nil, fmt.Errorf("Unable to parse kms public key: %s", err) 53 | } 54 | 55 | kmsSigner := &GcpKmsSigner{ 56 | keyUrl: keyUrl, 57 | kmsClient: kmsClient, 58 | kmsPubKey: pubKey, 59 | } 60 | return kmsSigner, nil 61 | } 62 | 63 | // PublicKey returns an associated PublicKey instance. 64 | func (g GcpKmsSigner) Public() crypto.PublicKey { 65 | return g.kmsPubKey 66 | } 67 | 68 | func (g GcpKmsSigner) Sign(rand io.Reader, digest []byte, opts crypto.SignerOpts) (signature []byte, err error) { 69 | ctx := context.Background() 70 | ctx, _ = context.WithTimeout(ctx, 10*time.Second) 71 | 72 | req := &kmspb.AsymmetricSignRequest{ 73 | Name: g.keyUrl, 74 | Digest: &kmspb.Digest{ 75 | Digest: &kmspb.Digest_Sha256{ 76 | Sha256: digest, 77 | }, 78 | }, 79 | } 80 | resp, err := g.kmsClient.AsymmetricSign(ctx, req) 81 | if err != nil { 82 | return nil, fmt.Errorf("Unable to sign: %s", err) 83 | } 84 | return resp.GetSignature(), nil 85 | } 86 | 87 | func getKmsClient() (*kms.KeyManagementClient, error) { 88 | if kmsClient != nil { 89 | return kmsClient, nil 90 | } 91 | ctx := context.Background() 92 | c, err := kms.NewKeyManagementClient(ctx) 93 | if err != nil { 94 | return nil, err 95 | } 96 | return c, nil 97 | } 98 | -------------------------------------------------------------------------------- /util/certificate.go: -------------------------------------------------------------------------------- 1 | package ssh_ca_util 2 | 3 | import ( 4 | "crypto" 5 | "fmt" 6 | "golang.org/x/crypto/ssh" 7 | "strings" 8 | "time" 9 | ) 10 | 11 | func MakeCertificate() ssh.Certificate { 12 | var newCert ssh.Certificate 13 | // The sign() method fills in Nonce for us 14 | newCert.Nonce = make([]byte, 32) 15 | return newCert 16 | } 17 | 18 | func PrintForInspection(cert ssh.Certificate) { 19 | fmt.Println("Certificate data:") 20 | fmt.Printf(" Serial: %v\n", cert.Serial) 21 | fmt.Printf(" Key id: %v\n", cert.KeyId) 22 | fmt.Printf(" Principals: %v\n", cert.ValidPrincipals) 23 | fmt.Printf(" Options:\n") 24 | for key := range cert.CriticalOptions { 25 | fmt.Printf(" %s: %v\n", key, cert.Permissions.CriticalOptions[key]) 26 | } 27 | fmt.Printf(" Permissions:\n") 28 | for key := range cert.Extensions { 29 | fmt.Printf(" %s: %v\n", key, cert.Permissions.Extensions[key]) 30 | } 31 | if cert.Key != nil { 32 | fmt.Printf(" Valid for public key: %s\n", MakeFingerprint(cert.Key.Marshal())) 33 | } else { 34 | fmt.Printf(" PUBLIC KEY NOT PRESENT\n") 35 | } 36 | var colorStart, colorEnd string 37 | if uint64(time.Now().Unix()+3600*24) < cert.ValidBefore { 38 | colorStart = "\033[91m" 39 | colorEnd = "\033[0m" 40 | } 41 | fmt.Printf(" Valid from %v - %s%v%s\n", 42 | time.Unix(int64(cert.ValidAfter), 0), 43 | colorStart, time.Unix(int64(cert.ValidBefore), 0), colorEnd) 44 | } 45 | 46 | func Print(c ssh.Certificate) string { 47 | var output string 48 | 49 | output += fmt.Sprintf("Cert serial: %v\n", c.Serial) 50 | output += fmt.Sprintf("Cert valid for public key: %s\n", MakeFingerprint(c.Key.Marshal())) 51 | output += ValidityPeriodString(c) 52 | return output 53 | } 54 | 55 | func ValidityPeriodString(c ssh.Certificate) string { 56 | return fmt.Sprintf("Valid between %v and %v\n", 57 | time.Unix(int64(c.ValidAfter), 0), time.Unix(int64(c.ValidBefore), 0)) 58 | } 59 | 60 | func MakeFingerprint(key_blob []byte) string { 61 | hasher := crypto.MD5.New() 62 | hasher.Write(key_blob) 63 | hash_bytes := hasher.Sum(nil) 64 | retval := make([]string, hasher.Size(), hasher.Size()) 65 | for i := range hash_bytes { 66 | retval[i] = fmt.Sprintf("%02x", hash_bytes[i]) 67 | } 68 | return strings.Join(retval, ":") 69 | } 70 | -------------------------------------------------------------------------------- /util/certificate_test.go: -------------------------------------------------------------------------------- 1 | package ssh_ca_util 2 | 3 | import ( 4 | "testing" 5 | ) 6 | 7 | func TestMakeCert(t *testing.T) { 8 | cert := MakeCertificate() 9 | PrintForInspection(cert) 10 | } 11 | -------------------------------------------------------------------------------- /util/config.go: -------------------------------------------------------------------------------- 1 | package ssh_ca_util 2 | 3 | import ( 4 | "encoding/json" 5 | "fmt" 6 | "io/ioutil" 7 | ) 8 | 9 | type RequesterConfig struct { 10 | PublicKeyPath string `json:",omitempty"` 11 | PublicKeyFingerprint string `json:",omitempty"` 12 | SignerUrl string 13 | } 14 | 15 | type SignerdConfig struct { 16 | SigningKeyFingerprint string 17 | AuthorizedSigners map[string]string 18 | AuthorizedUsers map[string]string 19 | NumberSignersRequired int 20 | SlackUrl string 21 | SlackChannel string 22 | MaxCertLifetime int 23 | PrivateKeyFile string 24 | KmsRegion string 25 | CriticalOptions map[string]string 26 | } 27 | 28 | type SignerConfig struct { 29 | KeyFingerprint string 30 | SignerUrl string 31 | } 32 | 33 | func LoadConfig(configPath string, environmentConfigs interface{}) error { 34 | buf, err := ioutil.ReadFile(configPath) 35 | if err != nil { 36 | return err 37 | } 38 | 39 | switch configType := environmentConfigs.(type) { 40 | case *map[string]RequesterConfig, *map[string]SignerConfig, *map[string]SignerdConfig: 41 | return json.Unmarshal(buf, &environmentConfigs) 42 | default: 43 | return fmt.Errorf("oops: %T\n", configType) 44 | } 45 | } 46 | 47 | func GetConfigForEnv(environment string, environmentConfigs interface{}) (interface{}, error) { 48 | switch environmentConfigs.(type) { 49 | case *map[string]RequesterConfig: 50 | configs := *environmentConfigs.(*map[string]RequesterConfig) 51 | if len(configs) > 1 && environment == "" { 52 | return nil, fmt.Errorf("You must tell me which environment to use.") 53 | } 54 | if len(configs) == 1 && environment == "" { 55 | for environment = range configs { 56 | // lame way of extracting first and only key from a map? 57 | } 58 | } 59 | config, ok := configs[environment] 60 | if !ok { 61 | return nil, fmt.Errorf("Requested environment not found in config file.") 62 | } 63 | return config, nil 64 | case *map[string]SignerConfig: 65 | configs := *environmentConfigs.(*map[string]SignerConfig) 66 | if len(configs) > 1 && environment == "" { 67 | return nil, fmt.Errorf("You must tell me which environment to use.") 68 | } 69 | if len(configs) == 1 && environment == "" { 70 | for environment = range configs { 71 | // lame way of extracting first and only key from a map? 72 | } 73 | } 74 | config, ok := configs[environment] 75 | if !ok { 76 | return nil, fmt.Errorf("Requested environment not found in config file.") 77 | } 78 | return config, nil 79 | } 80 | return nil, fmt.Errorf("Programmer error at runtime. I think you passed in a bad config object.") 81 | } 82 | -------------------------------------------------------------------------------- /util/ssh.go: -------------------------------------------------------------------------------- 1 | package ssh_ca_util 2 | 3 | import ( 4 | "fmt" 5 | "github.com/cloudtools/ssh-cert-authority/signer" 6 | "golang.org/x/crypto/ssh" 7 | "golang.org/x/crypto/ssh/agent" 8 | "io" 9 | "net/url" 10 | "regexp" 11 | ) 12 | 13 | var md5Fingerprint = regexp.MustCompile("([0-9a-fA-F]{2}:){15}[0-9a-fA-F]{2}") 14 | 15 | func GetSignerForFingerprintOrUrl(fingerprint string, conn io.ReadWriter) (ssh.Signer, error) { 16 | isFingerprint := md5Fingerprint.MatchString(fingerprint) 17 | if isFingerprint { 18 | return GetSignerForFingerprint(fingerprint, conn) 19 | } 20 | keyUrl, err := url.Parse(fingerprint) 21 | if err != nil { 22 | return nil, fmt.Errorf("Ignoring invalid private key url: '%s'. Error parsing: %s", fingerprint, err) 23 | } 24 | if keyUrl.Scheme != "gcpkms" { 25 | return nil, fmt.Errorf("gcpkms:// is the only supported url scheme") 26 | } 27 | return getSignerForGcpKms(keyUrl.Path) 28 | } 29 | func getSignerForGcpKms(keyUrl string) (ssh.Signer, error) { 30 | return signer.NewSshGcpKmsSigner(keyUrl) 31 | } 32 | 33 | func GetSignerForFingerprint(fingerprint string, conn io.ReadWriter) (ssh.Signer, error) { 34 | sshAgent := agent.NewClient(conn) 35 | signers, err := sshAgent.Signers() 36 | if err != nil { 37 | return nil, fmt.Errorf("Unable to find your SSH key (%s) in agent. Consider ssh-add", fingerprint) 38 | } 39 | for i := range signers { 40 | signerFingerprint := MakeFingerprint(signers[i].PublicKey().Marshal()) 41 | if signerFingerprint == fingerprint { 42 | return signers[i], nil 43 | } 44 | } 45 | return nil, fmt.Errorf("Unable to find your SSH key (%s) in agent. Consider ssh-add", fingerprint) 46 | } 47 | -------------------------------------------------------------------------------- /version/version.go: -------------------------------------------------------------------------------- 1 | package version 2 | 3 | var Tag = "dev" 4 | var BuildVersion = "dev" 5 | --------------------------------------------------------------------------------