├── .gitignore ├── .travis.yml ├── INSTALL.md ├── LICENSE ├── README.md ├── cursed ├── CHANGELOG.md ├── VERSION ├── aliases.conf ├── auth.go ├── cert.go ├── cursed.service ├── cursed.yaml-example ├── db.go ├── log.go ├── main.go ├── principals.go ├── setup.sh ├── tls.go ├── tlsca.go ├── tlsweb.go ├── utils.go ├── web.go └── x509.go └── jinx ├── CHANGELOG.md ├── README.md ├── VERSION ├── auth.go ├── cmd └── root.go ├── jinx.yaml-example ├── jinxlib ├── conf.go ├── jinx.go ├── keys.go ├── request.go ├── utils.go └── x509.go └── main.go /.gitignore: -------------------------------------------------------------------------------- 1 | cursed/cursed 2 | cursed/cursed.db 3 | cursed/cursed.sh 4 | cursed/cursed.toml 5 | cursed/cursed.yaml 6 | cursed/test_keys 7 | *.bak 8 | *.swp 9 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: go 2 | 3 | sudo: false 4 | -------------------------------------------------------------------------------- /INSTALL.md: -------------------------------------------------------------------------------- 1 | Manual Install (OUT OF DATE) 2 | -------------- 3 | These instructions largely apply to CURSE version 0.7 4 | They will be updated at a later date. 5 | 6 | These instructions assume the bastion host is hosting the curse daemon. Adjust instructions as necessary if hosting cursed on another server, but for security reasons the reverse proxy and cursed service should always be hosted on the same server unless you have valid SSL certificates for both the reverse proxy and CURSE daemon, and have the `proxy_ssl_verify` setting enabled in nginx. 7 | 8 | **CURSE Daemon Installation** 9 | 10 | First, ensure you have a working Go environment (prebuilt packages will be available in the future). 11 | 12 | Add a curse service user (as root): 13 | 14 | $ sudo useradd -r -m -d "/opt/curse" -s /usr/sbin/nologin curse 15 | 16 | `go get` the daemon and client: 17 | 18 | $ go get github.com/mikesmitty/curse/cursed 19 | $ go get github.com/mikesmitty/curse/jinx 20 | 21 | Create directories inside the curse directory and set their permission: 22 | 23 | $ sudo mkdir -p /opt/curse/{etc,sbin} 24 | $ sudo chown -R curse. /opt/curse/ 25 | $ sudo chmod 700 /opt/curse/ 26 | 27 | Copy the cursed binary (built in the go get command) to the curse path: 28 | 29 | $ sudo mv $GOPATH/bin/cursed /opt/curse/sbin/ 30 | 31 | Copy the jinx client to your prefered system path: 32 | 33 | $ sudo mv $GOPATH/bin/jinx /usr/bin/ 34 | 35 | **Install cursed Systemd Service** 36 | 37 | You can use whatever method you prefer for running, but a systemd unit file has been added for convenience. Please note that the setcap command in the unit file is necessary to run on a privileged port as an unprivileged user. On Debian/Ubuntu you will likely need to install the `libcap2-bin` package, and the setcap binary is found at `/sbin/setcap` instead of `/usr/sbin/setcap`. 38 | 39 | Edit the unit file if necessary, and copy the unit file to your systemd system directory. This will either be `/usr/lib/systemd/system/` or `/lib/systemd/system/`, and you can find out which by running the following command: `pkg-config systemd --variable=systemdsystemunitdir` 40 | 41 | $ cp $GOPATH/src/github/mikesmitty/curse/cursed.service cursed.service 42 | $ vim cursed.service 43 | $ sudo mv cursed.service /usr/lib/systemd/system/ 44 | $ sudo systemctl daemon-reload 45 | $ sudo systemctl start cursed.service 46 | 47 | **Configure cursed** 48 | 49 | Generate your CA keypair and move it to the cursed config directory. Elliptic curve algorithms (ed25519, ecdsa) are strongly recommended, provided all your servers support them. If elliptic curves are not viable in your environment, RSA with a bit size of 4096 or greater is recommended. 50 | 51 | $ ssh-keygen -t ed25519 -f ./user_ca 52 | $ sudo mv user_ca user_ca.pub /opt/curse/etc/ 53 | $ sudo chmod 600 /opt/curse/etc/user_ca 54 | $ sudo chmod 644 /opt/curse/etc/user_ca.pub 55 | 56 | Next, generate SSL certificates for the curse daemon and move them to the curse config directory. Feel free to adjust certificate lifespan to a reasonable level: 57 | 58 | $ openssl ecparam -genkey -name secp384r1 -out server.key 59 | $ openssl req -new -x509 -sha256 -key server.key -out server.crt -days 730 60 | $ sudo mv server.key server.crt /opt/curse/etc/ 61 | $ sudo chmod 600 /opt/curse/etc/server.key 62 | $ sudo chmod 644 /opt/curse/etc/server.crt 63 | $ sudo chown -R curse. /opt/curse/etc/ 64 | 65 | Copy the example cursed config file and edit it. The following fields are required: 66 | * cakeyfile (SSH CA key file: `/opt/curse/etc/user_ca` in this example) 67 | * proxyuser (used to authenticate the proxy to the curse daemon) 68 | * proxypass 69 | * sslcert (SSL key file location: `/opt/curse/etc/server.crt` in this example) 70 | * sslkey (SSL cert file location: `/opt/curse/etc/server.key` in this example) 71 | 72 | The curse daemon's port can be changed, but should be kept to a privileged port (below 1024) for security reasons. 73 | 74 | $ cp $GOPATH/src/github.com/mikesmitty/curse/cursed.yaml.example cursed.yaml 75 | $ vim cursed.yaml 76 | $ sudo mv cursed.yaml /opt/curse/etc/ 77 | 78 | Be sure to restrict file permissions on the cursed.yaml config file: 79 | 80 | $ sudo chmod 600 /opt/curse/etc/cursed.yaml 81 | $ sudo chown -R curse. /opt/curse/etc/ 82 | 83 | **Reverse Proxy Setup** 84 | 85 | The reverse proxy should have a valid SSL certificate configured. Feel free to use Let's Encrypt or any other reputable cert provider, as long as the certificate can be verified (i.e. not a self-signed certificate). If this is not feasible, you can use self-signed certificates and enable the insecure flag in the jinx config file, but this is not recommended whatsoever for production use. 86 | 87 | If using nginx, copy and edit the provided template, adjusting the following fields: 88 | * server_name (needs to match your valid SSL certificate's FQDN) 89 | * ssl_certificate (ssl certificate filename, should be chowned root) 90 | * ssl_certificate_key (ssl key filename, should be chowned root, chmod 600) 91 | * proxy_set_header Authorization (replace BASICAUTHSTRINGHERE with a base64-encoded string of the proxyuser and proxypass fields from the cursed setup earlier, like so: `echo -n 'proxyuser_goes_here:proxypass_goes_here' | base64` 92 | 93 | At this point you will also need to configure authentication for the reverse proxy, which provides authentication for the curse daemon. You can use any authentication that nginx (or apache, provided you have opted for it) provides, such as htpasswd file authentication, local authentication using PAM, or LDAP authentication. If using htpasswd authentication be sure to `chown root.` your htpasswd file. 94 | 95 | $ cp $GOPATH/src/github.com/mikesmitty/curse/cursed.conf-example.nginx cursed.conf 96 | $ vim cursed.conf 97 | $ sudo mv cursed.conf /etc/nginx/conf.d/ 98 | $ sudo chown root. /etc/nginx/conf.d/cursed.conf 99 | $ sudo chmod 600 /etc/nginx/conf.d/cursed.conf 100 | 101 | If you want to use htpasswd-file authentication simply uncomment the `auth_basic` and `auth_basic_user_file` entries in the provided cursed.conf-example.nginx file and add users to your htpasswd file: 102 | 103 | $ sudo yum install httpd-tools # install the htpasswd utility 104 | $ sudo htpasswd -c /etc/nginx/htpasswd USERNAME_GOES_HERE 105 | $ sudo chown root. /etc/nginx/htpasswd 106 | 107 | **Configure jinx** 108 | 109 | Copy the example cursed config file and edit it with the commands below. The following fields are required: 110 | * bastionip (if auto-detection of your bastion server's public IP fails) 111 | * pubkey (if you do not want CURSE to periodically regenerate your SSH keys) 112 | * url (URL of the proxy server, which should match your reverse proxy's hostname and SSL certificate) 113 | 114 | Note: Jinx can be configured with a system-wide file at `/etc/jinx/jinx.yaml` 115 | For testing purposes, `~/.jinx/jinx.yaml` can be used as well, but if `/etc/jinx/jinx.yaml` exists it will be ignored in favor of the system file. 116 | 117 | $ cp $GOPATH/src/github.com/mikesmitty/curse/jinx.yaml-example jinx.yaml 118 | $ vim jinx.yaml 119 | $ sudo mkdir /etc/jinx 120 | $ sudo mv jinx.yaml /etc/jinx/ 121 | $ sudo chmod 755 /etc/jinx/ 122 | $ sudo chmod 644 /etc/jinx/jinx.yaml 123 | $ sudo chown root. /etc/jinx/jinx.yaml 124 | 125 | **Test Service** 126 | 127 | By this point, you should have a working instance of CURSE, and you can generate a certificate by running `jinx`, then inspect the certificate file, which will be created in the folder with your pubkey by running `ssh-keygen -Lf ~/.ssh/id_ed25519-cert.pub` (substitute the proper filename based on the name of your pubkey) 128 | 129 | **Configuring Remote Hosts** 130 | 131 | In order for hosts to allow logins with certificates you'll need to do the following: 132 | 133 | * Add `TrustedUserCAKeys /etc/ssh/cas.pub` to `/etc/ssh/sshd_config` 134 | * Add the contents of your CA private key (`/opt/curse/etc/user_ca.pub`) to `/etc/ssh/cas.pub` like you would a regular `authorized_keys` file. 135 | 136 | Netflix recommends generating several CA keypairs and storing the private keys of all but one offline, in order to simplify CA key rotation. If you choose to do this you will want to also add the pubkeys of all of your CA keypairs to the `/etc/ssh/cas.pub` file at this time as well. 137 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2017 Michael Smith 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # CURSE 2 | 3 | CURSE is an SSH certificate signing server, built as an alternative to Netflix's BLESS tool, but without a dependency on AWS. 4 | 5 | ## Demo 6 | 7 | ![gif](http://i.imgur.com/UtDkYNo.gif) 8 | 9 | This software is currently in a beta state, feel free to submit issues on GitHub with any suggestions for improvement/feature requests or issues encountered. 10 | 11 | Table of Contents 12 | ----------------- 13 | 14 | * [Requirements](#requirements) 15 | * [Install](#install) 16 | * [Ubuntu/Debian](#ubuntudebian) 17 | * [CentOS](#centos) 18 | * [TODO List](#todo) 19 | 20 | Requirements 21 | ------------ 22 | * OpenSSH 5.6+ 23 | * CentOS 7 24 | * Ubuntu 14.04+ (Destination servers) 25 | * Ubuntu 15.10+ (Server running CURSE daemon) 26 | * Debian 7+ (Destination servers) 27 | * Debian 8+ (Server running CURSE daemon) 28 | 29 | Because SSH certificates are a relatively recent feature in OpenSSH, older versions of CentOS unfortunately do not support their use. 30 | 31 | Install 32 | ------- 33 | These instructions assume the bastion host is hosting the curse daemon. Adjust instructions as necessary if hosting cursed on another server. 34 | 35 | ### Ubuntu/Debian 36 | 37 | **Ubuntu 15.10+/Debian 8+** 38 | 39 | First, install the debian repo and GPG key: 40 | 41 | $ sudo sh -c 'echo "deb http://mirror.go-repo.io/curse/deb/ curse main" >/etc/apt/sources.list.d/curse.list' 42 | $ sudo apt-key adv --keyserver keyserver.ubuntu.com --recv 0732065B92735F2F 43 | 44 | Update and install pwauth, curse and jinx: 45 | 46 | $ sudo apt-get update && sudo apt-get install curse jinx pwauth 47 | 48 | Run the curse post-install setup script 49 | 50 | $ sudo bash /opt/curse/sbin/setup.sh 51 | 52 | This will output your CA public key to be added to destination servers, and setup the curse daemon for running. 53 | 54 | If all went well you should now be able to request certificates: 55 | 56 | $ jinx echo test 57 | $ ssh-keygen -Lf ~/.ssh/id_jinx-cert.pub 58 | 59 | Now, all that is left is to add the CA public key on the servers you want to connect to: 60 | 61 | Add `TrustedUserCAKeys /etc/ssh/cas.pub` to `/etc/ssh/sshd_config` on your destination servers and 62 | Put the contents of `/opt/curse/etc/user_ca.pub` into your /etc/ssh/cas.pub on the destination server. 63 | 64 | Netflix recommends generating several CA keypairs and storing the private keys of all but one offline, in order to simplify CA key rotation. If you choose to do this you will want to also add the pubkeys of all of your CA keypairs to the `/etc/ssh/cas.pub` file at this time as well. 65 | 66 | ### CentOS 67 | 68 | **CentOS 7** 69 | 70 | First, install pwauth, curse, and jinx: 71 | 72 | $ sudo rpm --import https://mirror.go-repo.io/curse/centos/RPM-GPG-KEY-GO-REPO 73 | $ sudo curl -s https://mirror.go-repo.io/curse/centos/curse-repo.repo | tee /etc/yum.repos.d/curse-repo.repo 74 | $ sudo yum install curse jinx pwauth 75 | 76 | Unless you're using httpd on this server for any other reason you should mask the httpd service: 77 | 78 | $ sudo systemctl mask httpd 79 | 80 | Run the curse post-install setup script 81 | 82 | $ sudo bash /opt/curse/sbin/setup.sh 83 | 84 | This will output your CA public key to be added to destination servers, and setup the curse daemon for running. 85 | 86 | If all went well you should now be able to request certificates: 87 | 88 | $ jinx echo test 89 | $ ssh-keygen -Lf ~/.ssh/id_jinx-cert.pub 90 | 91 | Now, all that is left is to add the CA public key on the servers you want to connect to: 92 | 93 | Add `TrustedUserCAKeys /etc/ssh/cas.pub` to `/etc/ssh/sshd_config` and 94 | Put the contents of `/opt/curse/etc/user_ca.pub` into your /etc/ssh/cas.pub on the destination server. 95 | 96 | Netflix recommends generating several CA keypairs and storing the private keys of all but one offline, in order to simplify CA key rotation. If you choose to do this you will want to also add the pubkeys of all of your CA keypairs to the `/etc/ssh/cas.pub` file at this time as well. 97 | 98 | TODO 99 | ---- 100 | * ~~Authentication~~ 101 | * ~~Document Authentication Setup~~ 102 | * ~~SSL support~~ 103 | * ~~Add support for maximum pubkey ages in daemon~~ 104 | * ~~Client app~~ 105 | * ~~More configuration options~~ 106 | * ~~Add support for maximum pubkey ages in client and automatic key regeneration~~ 107 | * ~~Add support for key algorithm enforcement/auto-key-generation~~ 108 | * ~~RPM/DEB packages for easier installation~~ 109 | * Per-user access ACLs 110 | 111 | Maybe Someday 112 | ------------- 113 | * Interactive ssh client for command logging 114 | -------------------------------------------------------------------------------- /cursed/CHANGELOG.md: -------------------------------------------------------------------------------- 1 | #CURSE Changelog 2 | 3 | Version 0.8.1 4 | ------------- 5 | Added support for non-standard usernames such as TLS certificate fingerprints when using TLS mutual authentication 6 | 7 | Version 0.8 8 | ----------- 9 | Added support for TLS mutual auth and removed basic auth support from proxy to curse daemon 10 | 11 | Version 0.7 12 | ----------- 13 | Initial release 14 | -------------------------------------------------------------------------------- /cursed/VERSION: -------------------------------------------------------------------------------- 1 | 1.0 2 | -------------------------------------------------------------------------------- /cursed/aliases.conf: -------------------------------------------------------------------------------- 1 | # Allow all users to log in as root 2 | root:* 3 | -------------------------------------------------------------------------------- /cursed/auth.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "io" 7 | "os/exec" 8 | ) 9 | 10 | func pwauth(conf *config, user, pass string) (bool, error) { 11 | // Build our timeout context 12 | ctx, cancel := context.WithTimeout(context.Background(), conf.authTimeout) 13 | defer cancel() 14 | 15 | // Build our command with context for timeout 16 | cmd := exec.CommandContext(ctx, conf.Pwauth) 17 | stdin, err := cmd.StdinPipe() 18 | if err != nil { 19 | return false, fmt.Errorf("failed to open stdin to pwauth: %v", err) 20 | } 21 | 22 | // Run pwauth 23 | err = cmd.Start() 24 | if err != nil { 25 | return false, fmt.Errorf("failed to start pwauth: %v", err) 26 | } 27 | // Send the user/pass over stdin 28 | _, err = io.WriteString(stdin, fmt.Sprintf("%s\n%s\n", user, pass)) 29 | if err != nil { 30 | return false, fmt.Errorf("failed to pass username/password to pwauth: %v", err) 31 | } 32 | // Wait for pwauth to complete 33 | err = cmd.Wait() 34 | if err != nil { 35 | return false, fmt.Errorf("pwauth failed: %v", err) 36 | } 37 | 38 | return true, nil 39 | } 40 | 41 | func unixgroup(conf *config, user, principal string) error { 42 | // If this is a wildcard ACL, allow immediately 43 | groups := conf.principalMap[principal] 44 | if groups == "*" { 45 | return nil 46 | } else if groups == "" { 47 | return fmt.Errorf("unknown principal: %s", principal) 48 | } 49 | 50 | // Build our timeout context 51 | ctx, cancel := context.WithTimeout(context.Background(), conf.authTimeout) 52 | defer cancel() 53 | 54 | // Build our command with context for timeout 55 | cmd := exec.CommandContext(ctx, conf.Unixgroup) 56 | 57 | // Set our env variables 58 | cmd.Env = append(cmd.Env, fmt.Sprintf("USER=%s", user)) 59 | cmd.Env = append(cmd.Env, fmt.Sprintf("GROUP=%s", groups)) 60 | 61 | // Run unixgroup 62 | err := cmd.Run() 63 | if err != nil { 64 | return fmt.Errorf("invalid groups or unixgroup command error: (user: %s) %v", user, err) 65 | } 66 | 67 | return nil 68 | } 69 | -------------------------------------------------------------------------------- /cursed/cert.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "crypto/rand" 5 | "fmt" 6 | "io/ioutil" 7 | "log" 8 | "time" 9 | 10 | "golang.org/x/crypto/ssh" 11 | ) 12 | 13 | type certConfig struct { 14 | certType uint32 15 | command string 16 | extensions map[string]string 17 | keyID string 18 | principals []string 19 | srcAddr string 20 | validAfter time.Time 21 | validBefore time.Time 22 | } 23 | 24 | func checkPubKeyAge(conf *config, fp string) (bool, error) { 25 | 26 | // Check our key's age from the DB 27 | keyBirthday, ok, err := dbGetPubKeyAge(conf, fp) 28 | if !ok && conf.KeyAgeCritical { 29 | return true, fmt.Errorf("critical - failed to verify pubkey age: [%s] %v", fp, err) 30 | } else if !ok { 31 | log.Printf("warning - failed to verify pubkey age: [%s] %v", fp, err) 32 | } 33 | 34 | // If this is a new key, add it to the database with a timestamp 35 | if keyBirthday == 0 { 36 | err = dbAddPubKeyBday(conf, fp) 37 | if err != nil { 38 | return true, err 39 | } 40 | } else if keyBirthday > 0 { 41 | kb := time.Unix(keyBirthday, 0) 42 | keyAge := time.Since(kb) 43 | if keyAge > conf.keyLifeSpan { 44 | return true, nil 45 | } 46 | } else { 47 | err = fmt.Errorf("something went very wrong. negative timestamp encountered: %d", keyBirthday) 48 | return true, err 49 | } 50 | 51 | return false, nil 52 | } 53 | 54 | func loadSSHCA(conf *config) (ssh.Signer, []byte, error) { 55 | // Read in our private key PEM file 56 | key, err := ioutil.ReadFile(conf.CAKeyFile) 57 | if err != nil { 58 | err = fmt.Errorf("failed to read ca key file: '%v'", err) 59 | return nil, nil, err 60 | } 61 | 62 | sk, err := ssh.ParsePrivateKey(key) 63 | if err != nil { 64 | err = fmt.Errorf("failed to parse ca key: '%v'", err) 65 | return nil, nil, err 66 | } 67 | 68 | // Get our CA fingerprint 69 | rawPub, err := ioutil.ReadFile(fmt.Sprintf("%s.pub", conf.CAKeyFile)) 70 | if err != nil { 71 | err = fmt.Errorf("failed to read ca pubkey file: '%v'", err) 72 | return nil, nil, err 73 | } 74 | 75 | // Parse the pubkey 76 | pubKey, _, _, _, err := ssh.ParseAuthorizedKey(rawPub) 77 | if err != nil { 78 | err = fmt.Errorf("failed to parse ca pubkey: %v", err) 79 | return nil, nil, err 80 | } 81 | 82 | // Get the key's fingerprint for logging 83 | fp := ssh.FingerprintSHA256(pubKey) 84 | 85 | return sk, []byte(fp), nil 86 | } 87 | 88 | func signPubKey(conf *config, rawKey []byte, cc certConfig) ([]byte, error) { 89 | pubKey, _, _, _, err := ssh.ParseAuthorizedKey(rawKey) // FIXME look into handling additional fields 90 | if err != nil { 91 | return nil, fmt.Errorf("failed to parse pubkey: %v", err) 92 | } 93 | 94 | // Get/update our ssh cert serial number 95 | var serial uint64 96 | if conf.SSHSerial { 97 | serial, err = dbIncSSHSerial(conf) 98 | if err != nil { 99 | return nil, err 100 | } 101 | } else { 102 | serial = 0 103 | } 104 | 105 | critOpt := make(map[string]string) 106 | if cc.command != "" { 107 | critOpt["force-command"] = cc.command 108 | } 109 | critOpt["source-address"] = cc.srcAddr 110 | 111 | perms := ssh.Permissions{ 112 | CriticalOptions: critOpt, 113 | Extensions: cc.extensions, 114 | } 115 | 116 | // Make a cert from our pubkey 117 | cert := &ssh.Certificate{ 118 | Key: pubKey, 119 | Serial: serial, 120 | CertType: cc.certType, 121 | KeyId: cc.keyID, 122 | ValidPrincipals: cc.principals, 123 | ValidAfter: uint64(cc.validAfter.Unix()), 124 | ValidBefore: uint64(cc.validBefore.Unix()), 125 | Permissions: perms, 126 | } 127 | 128 | err = cert.SignCert(rand.Reader, conf.sshCASigner) 129 | if err != nil { 130 | err = fmt.Errorf("failed to sign pubkey: %v", err) 131 | return nil, err 132 | } 133 | authorizedKey := ssh.MarshalAuthorizedKey(cert) 134 | 135 | return authorizedKey, err 136 | } 137 | -------------------------------------------------------------------------------- /cursed/cursed.service: -------------------------------------------------------------------------------- 1 | [Unit] 2 | Description=CURSED Ephemeral SSH Certificate Authority 3 | 4 | [Service] 5 | ExecStart=/opt/curse/sbin/cursed 6 | User=curse 7 | Environment=HOME=/opt/curse 8 | 9 | [Install] 10 | WantedBy=multi-user.target 11 | -------------------------------------------------------------------------------- /cursed/cursed.yaml-example: -------------------------------------------------------------------------------- 1 | ## IP to bind listener on 2 | #addr: 127.0.0.1 3 | 4 | ## Port to listen on (should be a privileged port < 1024 for security) 5 | #port: 444 6 | 7 | ## Location of the SSH CA key 8 | #cakeyfile: /opt/curse/etc/user_ca 9 | 10 | ## Embedded database used to track users' pubkey age 11 | #dbfile: /opt/curse/etc/cursed.db 12 | 13 | ## Duration of SSH certificate validity in seconds 14 | #duration: 120 15 | 16 | ## Permitted SSH extensions (only permit-pty is enabled by default) 17 | #extensions: 18 | # - permit-X11-forwarding 19 | # - permit-agent-forwarding 20 | # - permit-port-forwarding 21 | # - permit-pty 22 | # - permit-user-rc 23 | 24 | ## Saves the command to be run in the certificate, permitting only that one command 25 | #forcecmd: false 26 | 27 | ## Disallow users to log in as another username 28 | #forceusermatch: true 29 | 30 | ## If a pubkey's age can't be verified, reject the request 31 | #keyagecritical: true 32 | 33 | ## Include timestamps in log output (for when not using systemd logging) 34 | #logtimestamp: false 35 | 36 | ## Maximum age of a user's SSH keypair for lifecycling 37 | ## Set to -1 to disable key cycling 38 | #maxkeyage: 90 39 | 40 | ## Principal user/PAM group alias file (format: ssh_principal:pam_group1,pam_group2) 41 | ## Additionally, asterisks can be used as a wildcard like so: root:* 42 | #principalaliases: /opt/curse/etc/aliases.conf 43 | 44 | ## pwauth binary path (/usr/sbin/pwauth on debian/ubuntu) 45 | #pwauth: /usr/bin/pwauth 46 | pwauth: /usr/bin/pwauth 47 | #unixgroup: /opt/curse/sbin/unixgroup 48 | #authtimeout: 30 49 | 50 | ## Require client IP to be sent with ssh cert requests (as set by ssh in the SSH_CLIENT and SSH_CONNECTION environment variables) 51 | #requireclientip: true 52 | 53 | ## Enable ssh certificate serial numbers 54 | #sshserial: false 55 | 56 | ## SSL key and cert for cursed service 57 | #sslca: /opt/curse/etc/cursed.crt 58 | #sslcert: /opt/curse/etc/cursed.crt 59 | #sslkey: /opt/curse/etc/cursed.key 60 | 61 | ## Validity duration in days of the auto-generated CA certificate 62 | #sslcaduration: 730 63 | 64 | ## DNS hostname for the cert service's certificate (must be accurate for running curse on another server) 65 | #sslcerthostname: localhost 66 | 67 | ## Curve type for the TLS CA private key 68 | ## Valid curves: p256, p384, p521 69 | #sslkeycurve: p384 70 | 71 | ## Validity duration in minutes of TLS client certificates, before password login is required again 72 | ## Set to -1 for unlimited length sessions 73 | #sslduration: 720 74 | -------------------------------------------------------------------------------- /cursed/db.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "encoding/binary" 5 | "fmt" 6 | "math/big" 7 | "strconv" 8 | "time" 9 | 10 | "github.com/boltdb/bolt" 11 | ) 12 | 13 | func dbAddPubKeyBday(conf *config, fp string) error { 14 | err := conf.db.Update(func(tx *bolt.Tx) error { 15 | bucket, err := tx.CreateBucketIfNotExists(conf.bucketNameFP) 16 | if err != nil { 17 | return err 18 | } 19 | 20 | // Convert unix timestamp to string to byte array and store in the DB (gross, I know) 21 | now := strconv.FormatInt(time.Now().Unix(), 10) 22 | err = bucket.Put([]byte(fp), []byte(now)) 23 | if err != nil { 24 | return err 25 | } 26 | return nil 27 | }) 28 | 29 | return err 30 | } 31 | 32 | func dbGetPubKeyAge(conf *config, fp string) (int64, bool, error) { 33 | var ( 34 | keyBirthday int64 35 | ok bool 36 | ) 37 | 38 | // Check if this fingerprint exists in our DB 39 | err := conf.db.View(func(tx *bolt.Tx) error { 40 | var err error 41 | 42 | bucket := tx.Bucket(conf.bucketNameFP) 43 | if bucket == nil { 44 | msg := "did not find db bucket %q" 45 | ok = false 46 | return fmt.Errorf(msg, conf.bucketNameFP) 47 | } 48 | 49 | // Get timestamp string from database and convert to int 50 | val := bucket.Get([]byte(fp)) 51 | if len(val) == 0 { 52 | keyBirthday = 0 53 | ok = true 54 | return nil 55 | } 56 | 57 | // Convert byte array to string to int64 58 | keyBirthday, err = strconv.ParseInt(string(val), 10, 64) 59 | if err != nil { 60 | msg := "timestamp in db corrupted for key %s: %v" 61 | ok = false 62 | return fmt.Errorf(msg, fp, err) 63 | } 64 | 65 | ok = true 66 | return nil 67 | }) 68 | 69 | return keyBirthday, ok, err 70 | } 71 | 72 | func dbInitPubKeyBucket(conf *config) error { 73 | err := conf.db.Update(func(tx *bolt.Tx) error { 74 | _, err := tx.CreateBucketIfNotExists(conf.bucketNameFP) 75 | 76 | return err 77 | }) 78 | 79 | return err 80 | } 81 | 82 | func dbIncSSHSerial(conf *config) (uint64, error) { 83 | var newSerial uint64 84 | key := conf.sshCAFP 85 | 86 | err := conf.db.Update(func(tx *bolt.Tx) error { 87 | bucket, err := tx.CreateBucketIfNotExists(conf.bucketNameSSHSerial) 88 | if err != nil { 89 | return err 90 | } 91 | 92 | // Get serial from database and convert to uint64 93 | var serial uint64 94 | val := bucket.Get(key) 95 | if len(val) == 0 { 96 | serial = 0 97 | } else { 98 | serial = binary.LittleEndian.Uint64(val) 99 | if err != nil { 100 | return fmt.Errorf("ssh serial counter in db corrupted: %v", err) 101 | } 102 | } 103 | 104 | // Increment and update the serial 105 | newSerial = serial + 1 106 | sb := make([]byte, 8) 107 | binary.LittleEndian.PutUint64(sb, newSerial) 108 | err = bucket.Put(key, sb) 109 | if err != nil { 110 | return err 111 | } 112 | 113 | return nil 114 | }) 115 | if err != nil { 116 | return 0, fmt.Errorf("failed to increment ssh certificate serial counter in database: %v", err) 117 | } 118 | 119 | return newSerial, nil 120 | } 121 | 122 | func dbSetSSHSerial(conf *config, serial uint64) error { 123 | key := conf.sshCAFP 124 | 125 | err := conf.db.Update(func(tx *bolt.Tx) error { 126 | bucket, err := tx.CreateBucketIfNotExists(conf.bucketNameSSHSerial) 127 | if err != nil { 128 | return err 129 | } 130 | 131 | // Save the serial number counter to the db 132 | sb := make([]byte, 8) 133 | binary.LittleEndian.PutUint64(sb, serial) 134 | err = bucket.Put(key, sb) 135 | if err != nil { 136 | return err 137 | } 138 | 139 | return nil 140 | }) 141 | if err != nil { 142 | return fmt.Errorf("failed to update ssh certificate serial counter in database: %v", err) 143 | } 144 | 145 | return nil 146 | } 147 | 148 | func dbIncTLSSerial(conf *config) (*big.Int, error) { 149 | var newSerial *big.Int 150 | key := []byte("serial") 151 | 152 | err := conf.db.Update(func(tx *bolt.Tx) error { 153 | bucket, err := tx.CreateBucketIfNotExists(conf.bucketNameTLSSerial) 154 | if err != nil { 155 | return err 156 | } 157 | 158 | // Get serial from database and convert to uint64 159 | val := bucket.Get(key) 160 | serial := big.NewInt(0) 161 | serial = serial.SetBytes(val) 162 | 163 | // Increment and update the serial 164 | plusOne := big.NewInt(1) 165 | newSerial = serial.Add(serial, plusOne) 166 | err = bucket.Put(key, newSerial.Bytes()) 167 | if err != nil { 168 | return err 169 | } 170 | 171 | return nil 172 | }) 173 | if err != nil { 174 | return nil, fmt.Errorf("failed to increment tls certificate serial counter in database: %v", err) 175 | } 176 | 177 | return newSerial, nil 178 | } 179 | 180 | func dbSetTLSSerial(conf *config, serial *big.Int) error { 181 | key := []byte("serial") 182 | 183 | err := conf.db.Update(func(tx *bolt.Tx) error { 184 | bucket, err := tx.CreateBucketIfNotExists(conf.bucketNameTLSSerial) 185 | if err != nil { 186 | return err 187 | } 188 | 189 | err = bucket.Put(key, serial.Bytes()) 190 | if err != nil { 191 | return err 192 | } 193 | 194 | return nil 195 | }) 196 | if err != nil { 197 | return fmt.Errorf("failed to update tls certificate serial counter in database: %v", err) 198 | } 199 | 200 | return nil 201 | } 202 | -------------------------------------------------------------------------------- /cursed/log.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "fmt" 5 | "log" 6 | ) 7 | 8 | type logTmpl struct { 9 | conf *config 10 | ip string 11 | reqType string 12 | rip string 13 | } 14 | 15 | func (t *logTmpl) req(user string, code int, msg string) { 16 | // ip - ip of bastion server making request 17 | // reqType should be ssh or tls, depending on the handler logging this request 18 | // code - http status code in response 19 | // msg - message to be logged 20 | // rip - user's remote IP from bastion connection 21 | 22 | line := fmt.Sprintf("%s %s %s %d %s %s", t.ip, t.reqType, user, code, msg, t.rip) 23 | 24 | if t.conf.LogTimestamp { 25 | log.Print(line) 26 | } else { 27 | fmt.Println(line) 28 | } 29 | } 30 | 31 | func newLog(conf *config, ip, reqType, rip string) *logTmpl { 32 | t := logTmpl{ 33 | conf: conf, 34 | ip: ip, 35 | reqType: reqType, 36 | rip: rip, 37 | } 38 | 39 | return &t 40 | } 41 | -------------------------------------------------------------------------------- /cursed/main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "crypto/ecdsa" 5 | "crypto/x509" 6 | "fmt" 7 | "log" 8 | "net/http" 9 | "regexp" 10 | "time" 11 | 12 | "golang.org/x/crypto/ssh" 13 | 14 | "github.com/boltdb/bolt" 15 | "github.com/spf13/viper" 16 | ) 17 | 18 | type config struct { 19 | authTimeout time.Duration 20 | bucketNameFP []byte 21 | bucketNameSSHSerial []byte 22 | bucketNameTLSSerial []byte 23 | db *bolt.DB 24 | dur time.Duration 25 | exts map[string]string 26 | keyLifeSpan time.Duration 27 | principalMap map[string]string 28 | sshCAFP []byte 29 | sshCASigner ssh.Signer 30 | tlsDur time.Duration 31 | tlsCACert *x509.Certificate 32 | tlsCAKey *ecdsa.PrivateKey 33 | userRegex *regexp.Regexp 34 | 35 | Addr string 36 | AuthTimeout int 37 | CAKeyFile string 38 | DBFile string 39 | Duration int 40 | Extensions []string 41 | ForceCmd bool 42 | ForceUserMatch bool 43 | KeyAgeCritical bool 44 | LogTimestamp bool 45 | MaxKeyAge int 46 | Port int 47 | PrincipalAliases string 48 | Pwauth string 49 | RequireClientIP bool 50 | SSHSerial bool 51 | SSLCA string 52 | SSLCADuration int 53 | SSLCert string 54 | SSLCertHostname string 55 | SSLKey string 56 | SSLKeyCurve string 57 | SSLDuration int 58 | Unixgroup string 59 | } 60 | 61 | func main() { 62 | // Process/load our config options 63 | conf, err := getConf() 64 | if err != nil { 65 | log.Fatal(err) 66 | } 67 | 68 | // Convert our cert validity duration and pubkey lifespan from int to time.Duration 69 | conf.dur = time.Duration(conf.Duration) * time.Second 70 | if conf.MaxKeyAge < 0 { 71 | // Negative MaxKeyAge means unlimited age keys, set lifespan to 100 years 72 | conf.keyLifeSpan = 100 * 365 * 24 * time.Hour 73 | } else { 74 | conf.keyLifeSpan = time.Duration(conf.MaxKeyAge) * 24 * time.Hour 75 | } 76 | 77 | // Convert our TLS cert "session" length and pubkey lifespan from int to time.Duration 78 | if conf.SSLDuration < 0 { 79 | // Negative SSLDuration means unlimited age keys, set lifespan to 100 years 80 | conf.tlsDur = 100 * 365 * 24 * time.Hour 81 | } else { 82 | conf.tlsDur = time.Duration(conf.SSLDuration) * time.Second 83 | } 84 | 85 | // Convert our auth command timeout to a duration 86 | conf.authTimeout = time.Duration(conf.AuthTimeout) * time.Second 87 | 88 | // Load the CA key into an ssh.Signer 89 | conf.sshCASigner, conf.sshCAFP, err = loadSSHCA(conf) 90 | if err != nil { 91 | log.Fatalf("%v", err) 92 | } 93 | 94 | // Open our key tracking database file 95 | conf.db, err = bolt.Open(conf.DBFile, 0600, nil) 96 | if err != nil { 97 | log.Fatalf("could not open database file %v", err) 98 | } 99 | defer conf.db.Close() 100 | 101 | // Initialize/check the PubKey lifecycle database 102 | err = dbInitPubKeyBucket(conf) 103 | if err != nil { 104 | log.Fatalf("could not open database file %v", err) 105 | } 106 | 107 | // Check TLS certs 108 | ok, err := initTLSCerts(conf) 109 | if !ok { 110 | log.Fatal(err) 111 | } 112 | if err != nil { 113 | log.Printf("%v", err) 114 | } 115 | 116 | // Start auth service 117 | s := http.NewServeMux() 118 | 119 | // Set our cert service web handler 120 | s.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) { 121 | sshCertHandler(w, r, conf) 122 | }) 123 | 124 | // Set our auth service web handler 125 | s.HandleFunc("/auth/", func(w http.ResponseWriter, r *http.Request) { 126 | tlsCertHandler(w, r, conf) 127 | }) 128 | 129 | // Prepare our TLS settings 130 | addrPort := fmt.Sprintf("%s:%d", conf.Addr, conf.Port) // FIXME update config options if this becomes permanent 131 | tlsConf, err := getTLSConfig(conf) 132 | if err != nil { 133 | log.Fatal(err) 134 | } 135 | server := &http.Server{ 136 | Addr: addrPort, 137 | Handler: s, 138 | IdleTimeout: 60 * time.Second, 139 | ReadTimeout: 5 * time.Second, 140 | TLSConfig: tlsConf, 141 | WriteTimeout: 10 * time.Second, 142 | } 143 | 144 | // Start our listener service 145 | if conf.LogTimestamp { 146 | log.Printf("Starting HTTPS cert server on %s", addrPort) 147 | } else { 148 | fmt.Printf("Starting HTTPS cert server on %s\n", addrPort) 149 | } 150 | err = server.ListenAndServeTLS(conf.SSLCert, conf.SSLKey) 151 | if err != nil { 152 | log.Fatalf("listener service: %v", err) 153 | } 154 | } 155 | 156 | func init() { 157 | //if cfgFile != "" { // enable ability to specify config file via flag 158 | // viper.SetConfigFile(cfgFile) 159 | //} 160 | 161 | viper.SetConfigName("cursed") // name of config file (without extension) 162 | viper.AddConfigPath("/opt/curse/etc/") 163 | viper.AddConfigPath("/etc/curse/") 164 | viper.AddConfigPath(".") 165 | viper.ReadInConfig() 166 | 167 | // If a config file is found, read it in. 168 | if err := viper.ReadInConfig(); err == nil { 169 | fmt.Printf("Using config file: %s\n", viper.ConfigFileUsed()) 170 | } 171 | 172 | viper.SetDefault("addr", "127.0.0.1") 173 | viper.SetDefault("authtimeout", 30) // 30 second default 174 | viper.SetDefault("cakeyfile", "/opt/curse/etc/user_ca") 175 | viper.SetDefault("dbfile", "/opt/curse/etc/cursed.db") 176 | viper.SetDefault("duration", 2*60) // 2 minute default 177 | viper.SetDefault("extensions", []string{"permit-pty"}) 178 | viper.SetDefault("forcecmd", false) 179 | viper.SetDefault("forceusermatch", true) 180 | viper.SetDefault("keyagecritical", false) 181 | viper.SetDefault("logtimestamp", false) 182 | viper.SetDefault("maxkeyage", 90) // 90 day default 183 | viper.SetDefault("port", 444) 184 | viper.SetDefault("principalaliases", "/opt/curse/etc/aliases.conf") 185 | viper.SetDefault("pwauth", "/usr/bin/pwauth") 186 | viper.SetDefault("requireclientip", true) 187 | viper.SetDefault("sshserial", false) 188 | viper.SetDefault("sslca", "/opt/curse/etc/cursed.crt") 189 | viper.SetDefault("sslcaduration", 730) // 2 year default 190 | viper.SetDefault("sslcert", "/opt/curse/etc/cursed.crt") 191 | viper.SetDefault("sslcerthostname", "localhost") 192 | viper.SetDefault("sslkey", "/opt/curse/etc/cursed.key") 193 | viper.SetDefault("sslkeycurve", "p384") 194 | viper.SetDefault("sslduration", 12*60) // 12 hour default 195 | viper.SetDefault("unixgroup", "/opt/curse/sbin/unixgroup") 196 | } 197 | 198 | func validateExtensions(confExts []string) (map[string]string, []error) { 199 | validExts := []string{"permit-X11-forwarding", "permit-agent-forwarding", 200 | "permit-port-forwarding", "permit-pty", "permit-user-rc"} 201 | exts := make(map[string]string) 202 | errSlice := make([]error, 0) 203 | 204 | // Compare each of the config items from our config file against our known-good list, and 205 | // add them as a key in a map[string]string with empty value, as SSH expects 206 | for i := range confExts { 207 | valid := false 208 | for j := range validExts { 209 | if confExts[i] == validExts[j] { 210 | name := confExts[i] 211 | exts[name] = "" 212 | valid = true 213 | break 214 | } 215 | } 216 | if !valid { 217 | err := fmt.Errorf("invalid extension in config: %s", confExts[i]) 218 | errSlice = append(errSlice, err) 219 | } 220 | } 221 | 222 | return exts, errSlice 223 | } 224 | 225 | func getConf() (*config, error) { 226 | // Read config into a struct 227 | var conf config 228 | err := viper.Unmarshal(&conf) 229 | if err != nil { 230 | return nil, fmt.Errorf("unable to read config into struct: %v", err) 231 | } 232 | // Hardcoding the DB bucket name 233 | conf.bucketNameFP = []byte("pubkeybirthdays") 234 | conf.bucketNameSSHSerial = []byte("sshserial") 235 | conf.bucketNameTLSSerial = []byte("certserial") 236 | 237 | // Require TLS mutual authentication for security 238 | if conf.SSLCA == "" || conf.SSLKey == "" || conf.SSLCert == "" { 239 | return nil, fmt.Errorf("sslca, sslkey, and sslcert are required fields") 240 | } 241 | 242 | // Expand $HOME into service user's home path 243 | conf.DBFile = expandHome(conf.DBFile) 244 | 245 | // Check our certificate extensions (permissions) for validity 246 | var errSlice []error 247 | conf.exts, errSlice = validateExtensions(conf.Extensions) 248 | if len(errSlice) > 0 { 249 | for _, err := range errSlice { 250 | log.Printf("%v", err) 251 | } 252 | } 253 | 254 | // Load principal aliases file 255 | conf.principalMap, err = loadPrincipalMap(conf) 256 | if err != nil { 257 | return nil, err 258 | } 259 | 260 | // Compile our user-matching regex (usernames are limited to 32 characters, must start 261 | // with a-z or _, and contain only these characters: a-z, 0-9, - and _ 262 | conf.userRegex = regexp.MustCompile(`(?i)^[a-z_][a-z0-9_-]{1,31}$`) 263 | 264 | return &conf, nil 265 | } 266 | -------------------------------------------------------------------------------- /cursed/principals.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "bufio" 5 | "bytes" 6 | "fmt" 7 | "os" 8 | ) 9 | 10 | func loadPrincipalMap(conf config) (map[string]string, error) { 11 | file, err := os.Open(conf.PrincipalAliases) 12 | if err != nil { 13 | err = fmt.Errorf("failed to open principalaliases file: '%v'", err) 14 | return nil, err 15 | } 16 | defer file.Close() 17 | 18 | principalMap := make(map[string]string) 19 | 20 | scanner := bufio.NewScanner(file) 21 | Line: 22 | for scanner.Scan() { 23 | line := scanner.Bytes() 24 | if len(line) == 0 || line[0] == '#' { 25 | continue 26 | } 27 | 28 | // Split line into principal/group aliases on colon 29 | parts := bytes.Split(line, []byte{':'}) 30 | if len(parts) < 2 { 31 | continue 32 | } 33 | 34 | // Split groups on comma 35 | groups := bytes.Split(parts[1], []byte{','}) 36 | if len(groups) < 1 { 37 | continue 38 | } 39 | 40 | prin := string(parts[0]) 41 | 42 | // Check for wildcard groups 43 | for _, v := range groups { 44 | // If we got a wildcard, ignore everything else 45 | if bytes.Equal(v, []byte{'*'}) { 46 | principalMap[prin] = "*" 47 | continue Line 48 | } 49 | } 50 | 51 | principalMap[prin] = string(parts[1]) 52 | } 53 | 54 | return principalMap, nil 55 | } 56 | -------------------------------------------------------------------------------- /cursed/setup.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | # Requires: 4 | # openssh 5 | # libcap || libcap2-bin (debian/ubuntu) 6 | 7 | CURSE_ROOT="/opt/curse" 8 | CURSE_ALGO="ed25519" 9 | 10 | # Create curse system user account 11 | NOLOGIN=$(which nologin) 12 | getent passwd curse >/dev/null || useradd -r -m -d "$CURSE_ROOT" -s $NOLOGIN curse 13 | chmod 700 "$CURSE_ROOT" 14 | chown curse. "$CURSE_ROOT" 15 | 16 | # Generate SSH CA keypair 17 | if [ ! -e "$CURSE_ROOT/etc/user_ca" ] || [ ! -e "$CURSE_ROOT/etc/user_ca.pub" ]; then 18 | echo "Generating $CURSE_ALGO SSH CA certificates..." 19 | ssh-keygen -q -N "" -t "$CURSE_ALGO" -f "$CURSE_ROOT/etc/user_ca" 20 | chmod 600 "$CURSE_ROOT/etc/user_ca" 21 | chmod 644 "$CURSE_ROOT/etc/user_ca.pub" 22 | echo "$CURSE_ALGO SSH CA keypair generated. Here is the CA PubKey for adding to your servers:" 23 | echo 24 | echo 25 | cat "$CURSE_ROOT/etc/user_ca.pub" 26 | echo 27 | echo 28 | echo "This key can also be found at $CURSE_ROOT/etc/user_ca.pub" 29 | else 30 | echo "SSH CA keypair already exists. Skipping generation." 31 | fi 32 | 33 | # Fix curse permissions 34 | chown -R curse. "$CURSE_ROOT" 35 | /usr/bin/env setcap 'cap_net_bind_service=+ep' /opt/curse/sbin/cursed 36 | 37 | if [ -f /etc/redhat-release ]; then 38 | # Add ourselves to the apache group on centos for access to pwauth 39 | usermod -a -G apache curse 40 | elif [ -f /etc/debian_version ]; then 41 | # Update the pwauth path in cursed.yaml for debian/ubuntu 42 | sed -i 's|^pwauth: /usr/bin/|pwauth: /usr/sbin/|' $CURSE_ROOT/etc/cursed.yaml 43 | fi 44 | 45 | echo "Starting cursed service" 46 | systemctl start cursed 47 | 48 | # Copy the newly-generated CA file to /etc/jinx/ca.crt 49 | sleep 4 50 | pid_count=$(ps aux |grep cursed |grep -vc grep) 51 | if [ "$pid_count" -gt "0" ]; then 52 | mkdir -p /etc/jinx/ && cp $CURSE_ROOT/etc/cursed.crt /etc/jinx/ca.crt 53 | else 54 | echo "If using jinx on this server, copy the newly generate curse CA certificate to /etc/jinx/" 55 | echo "mkdir -p /etc/jinx/" 56 | echo "cp $CURSE_ROOT/etc/cursed.crt /etc/jinx/ca.crt" 57 | fi 58 | -------------------------------------------------------------------------------- /cursed/tls.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "crypto/tls" 5 | "crypto/x509" 6 | "fmt" 7 | "io/ioutil" 8 | ) 9 | 10 | func getTLSConfig(conf *config) (*tls.Config, error) { 11 | tlsCACert, err := ioutil.ReadFile(conf.SSLCA) 12 | if err != nil { 13 | return nil, fmt.Errorf("could not read sslca certificate: %v", err) 14 | } 15 | 16 | certPool := x509.NewCertPool() 17 | if ok := certPool.AppendCertsFromPEM(tlsCACert); !ok { 18 | return nil, fmt.Errorf("could not import sslca certificate: %v", err) 19 | } 20 | 21 | // Set our TLS config 22 | tlsConf := &tls.Config{ 23 | ClientAuth: tls.VerifyClientCertIfGiven, 24 | ClientCAs: certPool, 25 | MinVersion: tls.VersionTLS12, 26 | PreferServerCipherSuites: true, 27 | 28 | CipherSuites: []uint16{ 29 | tls.TLS_ECDHE_ECDSA_WITH_AES_256_GCM_SHA384, 30 | tls.TLS_ECDHE_RSA_WITH_AES_256_GCM_SHA384, 31 | tls.TLS_ECDHE_ECDSA_WITH_CHACHA20_POLY1305, 32 | tls.TLS_ECDHE_RSA_WITH_CHACHA20_POLY1305, 33 | tls.TLS_ECDHE_ECDSA_WITH_AES_128_GCM_SHA256, 34 | tls.TLS_ECDHE_RSA_WITH_AES_128_GCM_SHA256, 35 | }, 36 | CurvePreferences: []tls.CurveID{ 37 | tls.CurveP256, 38 | tls.X25519, 39 | }, 40 | } 41 | 42 | tlsConf.BuildNameToCertificate() 43 | 44 | return tlsConf, nil 45 | } 46 | -------------------------------------------------------------------------------- /cursed/tlsca.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "crypto/ecdsa" 5 | "crypto/elliptic" 6 | "crypto/rand" 7 | "crypto/sha256" 8 | "crypto/x509" 9 | "crypto/x509/pkix" 10 | "encoding/base64" 11 | "encoding/pem" 12 | "fmt" 13 | "math/big" 14 | "time" 15 | ) 16 | 17 | type certOpts struct { 18 | CA *x509.Certificate 19 | CAKey *ecdsa.PrivateKey 20 | CN string 21 | CSR *x509.CertificateRequest 22 | IsCA bool 23 | PubKey *ecdsa.PublicKey 24 | NotBefore time.Time 25 | NotAfter time.Time 26 | SAN string 27 | Serial *big.Int 28 | } 29 | 30 | func tlsCertFP(c *x509.Certificate) []byte { 31 | hash := make([]byte, base64.RawStdEncoding.EncodedLen(len(c.Raw))) 32 | sha256sum := sha256.Sum256(c.Raw) 33 | base64.RawStdEncoding.Encode(hash, sha256sum[:]) 34 | return hash 35 | } 36 | 37 | func tlsGenKey(curve string) ([]byte, *ecdsa.PrivateKey, error) { 38 | var ( 39 | key *ecdsa.PrivateKey 40 | err error 41 | ) 42 | 43 | switch curve { 44 | case "p256": 45 | key, err = ecdsa.GenerateKey(elliptic.P256(), rand.Reader) 46 | case "p384": 47 | key, err = ecdsa.GenerateKey(elliptic.P384(), rand.Reader) 48 | case "p521": 49 | key, err = ecdsa.GenerateKey(elliptic.P521(), rand.Reader) 50 | default: 51 | return nil, nil, fmt.Errorf("could not generate tls key, invalid elliptic curve: %s", curve) 52 | } 53 | if err != nil { 54 | return nil, nil, fmt.Errorf("error generating tls key: %v", err) 55 | } 56 | 57 | // Marshal key and write to disk 58 | keyBytes, err := x509.MarshalECPrivateKey(key) 59 | if err != nil { 60 | return nil, nil, fmt.Errorf("unable to convert tls private key format to der: %v", err) 61 | } 62 | pemKey := &pem.Block{ 63 | Type: "EC PRIVATE KEY", 64 | Bytes: keyBytes, 65 | } 66 | privateKeyPEM := pem.EncodeToMemory(pemKey) 67 | 68 | return privateKeyPEM, key, nil 69 | } 70 | 71 | func tlsSignCert(c certOpts) ([]byte, []byte, error) { 72 | var ( 73 | certBytes []byte 74 | err error 75 | san []string 76 | subject pkix.Name 77 | ) 78 | 79 | // Add the SAN field if we've got it 80 | if c.SAN != "" { 81 | san = []string{c.SAN} 82 | } 83 | 84 | if c.CSR == nil { 85 | subject = pkix.Name{ 86 | CommonName: c.CN, 87 | Organization: []string{"CURSED"}, 88 | } 89 | } else { 90 | subject = c.CSR.Subject 91 | } 92 | 93 | tmpl := &x509.Certificate{ 94 | BasicConstraintsValid: true, 95 | DNSNames: san, 96 | ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageClientAuth}, 97 | IsCA: false, 98 | KeyUsage: x509.KeyUsageDigitalSignature | x509.KeyUsageKeyEncipherment, 99 | NotBefore: c.NotBefore, 100 | NotAfter: c.NotAfter, 101 | SerialNumber: c.Serial, 102 | Subject: subject, 103 | } 104 | 105 | if c.IsCA { 106 | tmpl.IsCA = true 107 | tmpl.KeyUsage |= x509.KeyUsageCertSign 108 | tmpl.ExtKeyUsage = append(tmpl.ExtKeyUsage, x509.ExtKeyUsageServerAuth) 109 | 110 | certBytes, err = x509.CreateCertificate(rand.Reader, tmpl, tmpl, &c.CAKey.PublicKey, c.CAKey) 111 | if err != nil { 112 | return nil, nil, fmt.Errorf("failed to create certificate: %v", err) 113 | } 114 | } else { 115 | certBytes, err = x509.CreateCertificate(rand.Reader, tmpl, c.CA, c.CSR.PublicKey, c.CAKey) 116 | if err != nil { 117 | return nil, nil, fmt.Errorf("failed to create certificate: %v", err) 118 | } 119 | } 120 | 121 | // Convert to PEM format 122 | pemCert := &pem.Block{ 123 | Type: "CERTIFICATE", 124 | Bytes: certBytes, 125 | } 126 | certPem := pem.EncodeToMemory(pemCert) 127 | 128 | return certPem, certBytes, nil 129 | } 130 | -------------------------------------------------------------------------------- /cursed/tlsweb.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "crypto/x509" 5 | "encoding/pem" 6 | "fmt" 7 | "log" 8 | "net/http" 9 | "strings" 10 | ) 11 | 12 | func tlsCertHandler(w http.ResponseWriter, r *http.Request, conf *config) { 13 | // Set up some useful info for logging 14 | parts := strings.Split(r.RemoteAddr, ":") 15 | if len(parts) == 0 { 16 | log.Print("critical error, could not get client IP from request") 17 | http.Error(w, "not authorized", http.StatusUnauthorized) 18 | return 19 | } 20 | ip := parts[0] 21 | un := "-" 22 | 23 | // Start up our logger 24 | logger := newLog(conf, ip, "tls", "") 25 | 26 | // Load our form parameters into a struct 27 | p, err := getJSONParams(r) 28 | if err != nil { 29 | msg := fmt.Sprintf("bad json in request: %v", err) 30 | code := http.StatusBadRequest 31 | logger.req(un, code, msg) 32 | http.Error(w, "bad request", code) 33 | return 34 | } 35 | 36 | // Update our logger 37 | logger.rip = p.UserIP 38 | 39 | // Get our user/pass from basic auth 40 | user, pass, ok := r.BasicAuth() 41 | if !ok { 42 | msg := "client basic auth failure" 43 | code := http.StatusUnauthorized 44 | logger.req(un, code, msg) 45 | http.Error(w, "not authorized", code) 46 | return 47 | } 48 | un = user 49 | 50 | // Check the credentials 51 | ok, err = pwauth(conf, user, pass) 52 | if !ok { 53 | msg := fmt.Sprintf("authorization failure: %v", err) 54 | code := http.StatusUnauthorized 55 | logger.req(un, code, msg) 56 | http.Error(w, "not authorized", code) 57 | return 58 | } 59 | 60 | // Make sure we have everything we need from our parameters 61 | err = validateTLSParams(p, conf) 62 | if err != nil { 63 | msg := fmt.Sprintf("invalid parameters: %v", err) 64 | code := http.StatusBadRequest 65 | logger.req(un, code, msg) 66 | http.Error(w, "invalid parameters", code) 67 | return 68 | } 69 | 70 | // Decode our pem-encapsulated CSR 71 | csrBlock, _ := pem.Decode([]byte(p.CSR)) 72 | if csrBlock == nil { 73 | msg := fmt.Sprintf("failed to decode csr: '%v'", err) 74 | code := http.StatusBadRequest 75 | logger.req(un, code, msg) 76 | http.Error(w, "invalid csr", code) 77 | return 78 | } 79 | 80 | // Parse the CSR 81 | csr, err := x509.ParseCertificateRequest(csrBlock.Bytes) 82 | if err != nil { 83 | msg := fmt.Sprintf("failed to parse CSR: %v", err) 84 | code := http.StatusBadRequest 85 | logger.req(un, code, msg) 86 | http.Error(w, "invalid csr", code) 87 | return 88 | } 89 | 90 | // Validate the CSR signature 91 | err = csr.CheckSignature() 92 | if err != nil { 93 | msg := fmt.Sprintf("failed to check csr signature: %v", err) 94 | code := http.StatusBadRequest 95 | logger.req(un, code, msg) 96 | http.Error(w, "invalid csr", code) 97 | return 98 | } 99 | 100 | // Check if our username received matches the CSR name 101 | if conf.ForceUserMatch && csr.Subject.CommonName != p.BastionUser { 102 | msg := "csr commonname field does not match logged-in user, denying request" 103 | code := http.StatusBadRequest 104 | logger.req(un, code, msg) 105 | http.Error(w, "invalid csr", code) 106 | return 107 | } 108 | 109 | // Sign the CSR 110 | cert, rawCert, err := signTLSClientCert(conf, csr) 111 | if err != nil { 112 | msg := fmt.Sprintf("error signing client cert: %v", err) 113 | code := http.StatusInternalServerError 114 | logger.req(un, code, msg) 115 | http.Error(w, "server error", code) 116 | return 117 | } 118 | 119 | // Parse the DER formatted cert 120 | c, err := x509.ParseCertificate(rawCert) 121 | if err != nil { 122 | msg := fmt.Sprintf("error parsing raw certificate: %v", err) 123 | code := http.StatusInternalServerError 124 | logger.req(un, code, msg) 125 | http.Error(w, "server error", code) 126 | return 127 | } 128 | 129 | // Get a public key fingerprint 130 | fp := tlsCertFP(c) 131 | 132 | // Generate our log entry 133 | keyID := fmt.Sprintf("user[%s] from[%s] serial[%d] fingerprint[%s]", p.BastionUser, p.UserIP, c.SerialNumber, fp) 134 | 135 | // Log the request 136 | code := http.StatusOK 137 | logger.req(un, code, keyID) 138 | 139 | w.Write(cert) 140 | } 141 | 142 | func validateTLSParams(p httpParams, conf *config) error { 143 | if !conf.userRegex.MatchString(p.BastionUser) { 144 | return fmt.Errorf("username is invalid: |%s|", p.BastionUser) 145 | } 146 | if p.CSR == "" { 147 | return fmt.Errorf("csr missing from request") 148 | } 149 | if conf.RequireClientIP && !validIP(p.UserIP) { 150 | return fmt.Errorf("invalid userIP: |%s|", p.UserIP) 151 | } 152 | 153 | return nil 154 | } 155 | -------------------------------------------------------------------------------- /cursed/utils.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "net" 5 | "os" 6 | "strings" 7 | ) 8 | 9 | func expandHome(path string) string { 10 | // Swap out $HOME for service user's home dir in path 11 | home := os.Getenv("HOME") 12 | if strings.HasPrefix(path, "$HOME") && home != "" { 13 | path = strings.Replace(path, "$HOME", home, 1) 14 | } 15 | 16 | return path 17 | } 18 | 19 | func validIP(ip string) bool { 20 | res := net.ParseIP(ip) 21 | 22 | return res != nil 23 | } 24 | -------------------------------------------------------------------------------- /cursed/web.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "encoding/json" 5 | "fmt" 6 | "io/ioutil" 7 | "log" 8 | "net/http" 9 | "strings" 10 | "time" 11 | 12 | "golang.org/x/crypto/ssh" 13 | ) 14 | 15 | type httpParams struct { 16 | BastionIP string `json:"bastion_ip,omitempty"` 17 | BastionUser string `json:"bastion_user,omitempty"` 18 | Cmd string `json:"cmd,omitempty"` 19 | CSR string `json:"csr,omitempty"` 20 | Key string `json:"key,omitempty"` 21 | RemoteUser string `json:"remote_user,omitempty"` 22 | UserIP string `json:"user_ip,omitempty"` 23 | 24 | user string 25 | } 26 | 27 | func getJSONParams(r *http.Request) (httpParams, error) { 28 | var p httpParams 29 | 30 | body, err := ioutil.ReadAll(r.Body) 31 | if err != nil { 32 | return p, err 33 | } 34 | r.Body.Close() 35 | 36 | err = json.Unmarshal(body, &p) 37 | if err != nil { 38 | return p, err 39 | } 40 | 41 | return p, nil 42 | } 43 | 44 | func sshCertHandler(w http.ResponseWriter, r *http.Request, conf *config) { 45 | // Set up some useful info for logging 46 | parts := strings.Split(r.RemoteAddr, ":") 47 | if len(parts) == 0 { 48 | log.Print("critical error, could not get client IP from request") 49 | http.Error(w, "not authorized", http.StatusUnauthorized) 50 | return 51 | } 52 | ip := parts[0] 53 | un := "-" 54 | 55 | // Start up our logger 56 | logger := newLog(conf, ip, "ssh", "") 57 | 58 | // Load our form parameters into a struct 59 | p, err := getJSONParams(r) 60 | if err != nil { 61 | msg := fmt.Sprintf("bad json in request: %v", err) 62 | code := http.StatusBadRequest 63 | logger.req(un, code, msg) 64 | http.Error(w, "bad request", code) 65 | return 66 | } 67 | 68 | // Update our logger 69 | logger.rip = p.UserIP 70 | 71 | // Verify the client certificate 72 | if len(r.TLS.VerifiedChains) == 0 { 73 | msg := "no valid client certificate provided" 74 | code := http.StatusUnauthorized 75 | logger.req(un, code, msg) 76 | http.Error(w, "not authorized", code) 77 | return 78 | } 79 | 80 | // Get the client certificate CN 81 | p.user = r.TLS.PeerCertificates[0].Subject.CommonName 82 | un = p.user 83 | 84 | // Make sure we have everything we need from our parameters 85 | err = validateHTTPParams(conf, p) 86 | if err != nil { 87 | msg := fmt.Sprintf("validation failure: %v", err) 88 | code := http.StatusBadRequest 89 | logger.req(un, code, msg) 90 | http.Error(w, msg, code) 91 | return 92 | } 93 | 94 | // Set our certificate validity times 95 | va := time.Now().Add(-30 * time.Second) 96 | vb := time.Now().Add(conf.dur) 97 | 98 | // Generate a fingerprint of the received public key for our key_id string 99 | pk, _, _, _, err := ssh.ParseAuthorizedKey([]byte(p.Key)) 100 | if err != nil { 101 | msg := "unable to parse authorized key" 102 | code := http.StatusBadRequest 103 | logger.req(un, code, msg) 104 | http.Error(w, msg, code) 105 | return 106 | } 107 | // Using md5 because that's what ssh-keygen prints out, making searches for a particular key easier 108 | fp := ssh.FingerprintLegacyMD5(pk) 109 | 110 | // Generate our key_id for the certificate 111 | keyID := fmt.Sprintf("user[%s] from[%s] command[%s] sshKey[%s] ca[%s] valid to[%s]", 112 | p.user, p.UserIP, p.Cmd, fp, conf.sshCAFP, vb.Format(time.RFC3339)) 113 | 114 | // Check if user is authorized for this principal 115 | err = unixgroup(conf, p.user, p.RemoteUser) 116 | if err != nil { 117 | msg := fmt.Sprintf("authorization failure: %v", err) 118 | code := http.StatusUnauthorized 119 | logger.req(un, code, msg) 120 | http.Error(w, "not authorized", code) 121 | return 122 | } 123 | 124 | // Check if we've seen this pubkey before and if it's too old 125 | expired, err := checkPubKeyAge(conf, fp) 126 | if expired { 127 | code := http.StatusUnprocessableEntity 128 | msg := fmt.Sprintf("pubkey expired: user[%s] pubkey[%s]: %v", p.user, fp, err) 129 | logger.req(un, code, msg) 130 | http.Error(w, "submitted pubkey is too old. Please generate new key.", code) 131 | return 132 | } 133 | 134 | // Set all of our certificate options 135 | cc := certConfig{ 136 | certType: ssh.UserCert, 137 | command: p.Cmd, 138 | extensions: conf.exts, 139 | keyID: keyID, 140 | principals: []string{p.RemoteUser}, 141 | srcAddr: p.BastionIP, 142 | validAfter: va, 143 | validBefore: vb, 144 | } 145 | 146 | // Sign the public key 147 | authorizedKey, err := signPubKey(conf, []byte(p.Key), cc) 148 | if err != nil { 149 | code := http.StatusInternalServerError 150 | msg := err.Error() 151 | logger.req(un, code, msg) 152 | http.Error(w, "server error", code) 153 | return 154 | } 155 | 156 | // Log the request 157 | code := http.StatusOK 158 | logger.req(un, code, keyID) 159 | 160 | // Return the cert 161 | w.Write(authorizedKey) 162 | } 163 | 164 | func validateHTTPParams(conf *config, p httpParams) error { 165 | if conf.ForceCmd && p.Cmd == "" { 166 | err := fmt.Errorf("cmd missing from request") 167 | return err 168 | } 169 | if p.BastionIP == "" || !validIP(p.BastionIP) { 170 | err := fmt.Errorf("bastionIP is invalid") 171 | return err 172 | } 173 | if p.Key == "" { 174 | err := fmt.Errorf("key missing from request") 175 | return err 176 | } 177 | if p.RemoteUser == "" { 178 | err := fmt.Errorf("remoteUser missing from request") 179 | return err 180 | } 181 | if conf.RequireClientIP && !validIP(p.UserIP) { 182 | err := fmt.Errorf("invalid userIP") 183 | log.Printf("invalid userIP: |%s|", p.UserIP) // FIXME This should be re-evaluated in the logging refactor 184 | return err 185 | } 186 | if p.user == "" { 187 | err := fmt.Errorf("empty username not permitted") 188 | return err 189 | } 190 | 191 | return nil 192 | } 193 | -------------------------------------------------------------------------------- /cursed/x509.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "crypto/x509" 5 | "encoding/pem" 6 | "fmt" 7 | "io/ioutil" 8 | "math/big" 9 | "os" 10 | "time" 11 | ) 12 | 13 | func genTLSCACert(conf *config) error { 14 | // Generate CA private key 15 | caKeyBytes, caKey, err := tlsGenKey(conf.SSLKeyCurve) 16 | if err != nil { 17 | return fmt.Errorf("failed to generate ca private key: %v", err) 18 | } 19 | 20 | // Set our CA cert validity constraints 21 | notBefore := time.Now() 22 | notAfter := notBefore.Add(time.Duration(conf.SSLCADuration) * 24 * time.Hour) 23 | 24 | // Set our serial to 1 25 | serial := big.NewInt(1) 26 | 27 | // Set our CA cert options 28 | opts := certOpts{ 29 | CAKey: caKey, 30 | CN: "curse", 31 | IsCA: true, 32 | NotBefore: notBefore, 33 | NotAfter: notAfter, 34 | SAN: conf.SSLCertHostname, 35 | Serial: serial, 36 | } 37 | 38 | // Sign the CA cert 39 | caCert, _, err := tlsSignCert(opts) 40 | if err != nil { 41 | return fmt.Errorf("failed to generate ca cert: %v", err) 42 | } 43 | 44 | err = ioutil.WriteFile(conf.SSLKey, caKeyBytes, 0600) 45 | if err != nil { 46 | return fmt.Errorf("failed to write ca private key file: %v", err) 47 | } 48 | 49 | err = ioutil.WriteFile(conf.SSLCert, caCert, 0644) 50 | if err != nil { 51 | return fmt.Errorf("failed to write cert file: %v", err) 52 | } 53 | err = ioutil.WriteFile(conf.SSLCA, caCert, 0644) 54 | if err != nil { 55 | return fmt.Errorf("failed to write ca cert file: %v", err) 56 | } 57 | 58 | // Update our CA's serial index 59 | return dbSetTLSSerial(conf, serial) 60 | } 61 | 62 | func signTLSClientCert(conf *config, csr *x509.CertificateRequest) ([]byte, []byte, error) { 63 | // Set our cert validity constraints 64 | notBefore := time.Now() 65 | notAfter := notBefore.Add(conf.tlsDur) 66 | 67 | // Get the next available serial number 68 | serial, err := dbIncTLSSerial(conf) 69 | if err != nil { 70 | return nil, nil, fmt.Errorf("failed to generate client certficate: %v", err) 71 | } 72 | 73 | // Set our CA cert options 74 | opts := certOpts{ 75 | CA: conf.tlsCACert, 76 | CAKey: conf.tlsCAKey, 77 | CSR: csr, 78 | IsCA: false, 79 | NotBefore: notBefore, 80 | NotAfter: notAfter, 81 | Serial: serial, 82 | } 83 | 84 | // Sign the CA cert 85 | pemCert, rawCert, err := tlsSignCert(opts) 86 | if err != nil { 87 | return nil, nil, fmt.Errorf("failed to generate client cert: %v", err) 88 | } 89 | 90 | return pemCert, rawCert, nil 91 | } 92 | 93 | func initTLSCerts(conf *config) (bool, error) { 94 | var err error 95 | 96 | _, errK := os.Stat(conf.SSLKey) 97 | _, errC := os.Stat(conf.SSLCert) 98 | if os.IsNotExist(errK) && os.IsNotExist(errC) { 99 | // Generate CA/server key/cert 100 | err = genTLSCACert(conf) 101 | if err != nil { 102 | return false, err 103 | } 104 | } 105 | if os.IsNotExist(errK) && !os.IsNotExist(errC) { 106 | return false, fmt.Errorf("error initializing ca certificate: sslcert exists, but sslkey does not") 107 | } 108 | 109 | if _, err = os.Stat(conf.SSLCA); os.IsNotExist(err) { 110 | conf.SSLCA = conf.SSLCert 111 | return true, fmt.Errorf("discrete ca cert not supported with automatic cert generation. Using sslcert file as ca cert: %s", conf.SSLCert) 112 | } 113 | 114 | // Load our CA cert/key for signing 115 | err = loadTLSCA(conf) 116 | if err != nil { 117 | return false, err 118 | } 119 | 120 | return true, nil 121 | } 122 | 123 | func loadTLSCA(conf *config) error { 124 | // Load CA key for signing 125 | caKeyPem, err := ioutil.ReadFile(conf.SSLKey) 126 | if err != nil { 127 | return fmt.Errorf("failed to read tls key file: '%v'", err) 128 | } 129 | caKey, _ := pem.Decode(caKeyPem) 130 | if caKey == nil { 131 | return fmt.Errorf("failed to parse tls key file: '%v'", err) 132 | } 133 | conf.tlsCAKey, err = x509.ParseECPrivateKey(caKey.Bytes) 134 | if err != nil { 135 | return fmt.Errorf("failed to parse tls cert file: '%v'", err) 136 | } 137 | 138 | // Load CA cert for signing 139 | caCertPem, err := ioutil.ReadFile(conf.SSLCert) 140 | if err != nil { 141 | return fmt.Errorf("failed to read tls cert file: '%v'", err) 142 | } 143 | caCert, _ := pem.Decode(caCertPem) 144 | if caCert == nil { 145 | return fmt.Errorf("failed to decode tls cert file: '%v'", err) 146 | } 147 | conf.tlsCACert, err = x509.ParseCertificate(caCert.Bytes) 148 | if err != nil { 149 | return fmt.Errorf("failed to parse tls cert file: '%v'", err) 150 | } 151 | 152 | return nil 153 | } 154 | -------------------------------------------------------------------------------- /jinx/CHANGELOG.md: -------------------------------------------------------------------------------- 1 | #JINX Changelog 2 | 3 | Version 0.8.1 4 | ------------- 5 | Added support for TLS mutual authentication from client to proxy for password-less authentication 6 | 7 | Version 0.8 8 | ----------- 9 | Added support for force-command, which allows the server to bake the command to be run into the certificate (and no other commands can be run) 10 | 11 | Version 0.7.1 12 | ------------- 13 | Minor text fixes. (Error message typos) 14 | 15 | Version 0.7 16 | ----------- 17 | Initial release 18 | -------------------------------------------------------------------------------- /jinx/README.md: -------------------------------------------------------------------------------- 1 | # JINX: Jinx Is a Nonce eXtractor 2 | 3 | JINX is a client to the CURSE SSH certificate signing server, which provides one-time use SSH certificates. 4 | 5 | This software is currently in an alpha state and not recommended for use. 6 | 7 | Requirements 8 | ------------ 9 | * OpenSSH 5.6+ 10 | * CentOS 7 11 | * Ubuntu 12.04+ 12 | 13 | Because SSH certificates are a relatively recent feature in OpenSSH, older versions of CentOS unfortunately do not support their use. 14 | -------------------------------------------------------------------------------- /jinx/VERSION: -------------------------------------------------------------------------------- 1 | 1.0 2 | -------------------------------------------------------------------------------- /jinx/auth.go: -------------------------------------------------------------------------------- 1 | package main 2 | -------------------------------------------------------------------------------- /jinx/cmd/root.go: -------------------------------------------------------------------------------- 1 | // Copyright © 2017 Michael Smith 2 | // 3 | // Permission is hereby granted, free of charge, to any person obtaining a copy 4 | // of this software and associated documentation files (the "Software"), to deal 5 | // in the Software without restriction, including without limitation the rights 6 | // to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 7 | // copies of the Software, and to permit persons to whom the Software is 8 | // furnished to do so, subject to the following conditions: 9 | // 10 | // The above copyright notice and this permission notice shall be included in 11 | // all copies or substantial portions of the Software. 12 | // 13 | // THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 14 | // IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 15 | // FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 16 | // AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 17 | // LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 18 | // OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 19 | // THE SOFTWARE. 20 | 21 | package cmd 22 | 23 | import ( 24 | "fmt" 25 | "os" 26 | 27 | "github.com/mikesmitty/curse/jinx/jinxlib" 28 | "github.com/spf13/cobra" 29 | "github.com/spf13/viper" 30 | ) 31 | 32 | var verbose bool 33 | 34 | // RootCmd represents the base command when called without any subcommands 35 | var RootCmd = &cobra.Command{ 36 | Use: "jinx", 37 | Short: "SSH certificate client", 38 | Long: `JINX is a client to the CURSE SSH certificate authority. 39 | It is used to provide short-lived SSH certificates in place of semi-permanent SSH pubkeys 40 | in authorized_keys files, which are difficult to manage at scale and over long periods 41 | of time.`, 42 | Run: func(cmd *cobra.Command, args []string) { 43 | jinxlib.Jinx(verbose, args) 44 | }, 45 | } 46 | 47 | // Execute adds all child commands to the root command sets flags appropriately. 48 | // This is called by main.main(). It only needs to happen once to the rootCmd. 49 | func Execute() { 50 | if err := RootCmd.Execute(); err != nil { 51 | fmt.Println(err) 52 | os.Exit(-1) 53 | } 54 | } 55 | 56 | func init() { 57 | cobra.OnInitialize(initConfig) 58 | 59 | //RootCmd.PersistentFlags().StringVar(&cfgFile, "config", "", "config file (default is $HOME/.jinx.yaml)") 60 | RootCmd.PersistentFlags().BoolVarP(&verbose, "verbose", "v", false, "enable verbose mode") 61 | } 62 | 63 | // initConfig reads in config file and ENV variables if set. 64 | func initConfig() { 65 | //if cfgFile != "" { // enable ability to specify config file via flag 66 | // viper.SetConfigFile(cfgFile) 67 | //} 68 | 69 | viper.SetConfigName("jinx") // name of config file (without extension) 70 | viper.AddConfigPath("/etc/jinx") 71 | viper.AddConfigPath("$HOME/.jinx/") 72 | 73 | // If a config file is found, read it in. 74 | if err := viper.ReadInConfig(); err == nil { 75 | //fmt.Println("Using config file:", viper.ConfigFileUsed()) 76 | } 77 | 78 | viper.SetDefault("autogenkeys", true) 79 | viper.SetDefault("bastionip", "") 80 | viper.SetDefault("insecure", false) 81 | viper.SetDefault("keygenbitsize", 2048) 82 | viper.SetDefault("keygenpubkey", "$HOME/.ssh/id_jinx.pub") 83 | viper.SetDefault("keygentype", "ed25519") 84 | viper.SetDefault("promptusername", false) 85 | viper.SetDefault("pubkey", "$HOME/.ssh/id_ed25519.pub") 86 | viper.SetDefault("sshuser", "root") // FIXME Need to revisit this? 87 | viper.SetDefault("sslcafile", "/etc/jinx/ca.crt") 88 | viper.SetDefault("sslcertfile", "$HOME/.jinx/client.crt") 89 | viper.SetDefault("sslkeycurve", "p384") 90 | viper.SetDefault("sslkeyfile", "$HOME/.jinx/client.key") 91 | viper.SetDefault("timeout", 30) 92 | viper.SetDefault("urlauth", "https://localhost:444/auth/") 93 | viper.SetDefault("urlcurse", "https://localhost:444/") 94 | viper.SetDefault("usesslca", true) 95 | } 96 | -------------------------------------------------------------------------------- /jinx/jinx.yaml-example: -------------------------------------------------------------------------------- 1 | ## Automatically generate keys when requested by the CA 2 | #autogenkeys: true 3 | 4 | ## Outgoing bastion IP used in the SSH certificate 5 | #bastionip: 1.2.3.4 6 | 7 | ## Turn on insecure ssl mode (NOT RECOMMENDED) 8 | #insecure: false 9 | 10 | ## Type of SSH keys to use during auto-generation (ssh-keygen -t ____ ) 11 | ## Valid types: ed25519, ecdsa, rsa 12 | #keygentype: ed25519 13 | 14 | ## Bit length of SSH keys to use during auto-generation (ssh-keygen -t -b ____ ) 15 | ## Valid only with keygentype: rsa or ecdsa 16 | ## Valid ecdsa values: 256, 384, 521 17 | #keygenbitsize: 2048 18 | 19 | ## Filename of the SSH private key to be generated if autogenkeys is enabled, 20 | ## with matching public key file 21 | #keygenpubkey: $HOME/.ssh/id_jinx.pub 22 | 23 | ## Prompt for username (as opposed to using logged-in account's username) 24 | #promptusername: false 25 | 26 | ## Location of the SSH pubkey to be signed (if autogenkeys is disabled) 27 | #pubkey: $HOME/.ssh/id_ed25519.pub 28 | 29 | ## User account to on remote server 30 | #sshuser: root 31 | 32 | ## SSL certificates for TLS mutual authentication 33 | ## Valid sslkeycurves: p256, p384, p521 34 | #sslcafile: /etc/jinx/ca.crt 35 | #sslcertfile: $HOME/.jinx/client.crt 36 | #sslkeycurve: p384 37 | #sslkeyfile: $HOME/.jinx/client.key 38 | 39 | ## URL of the auth server (change localhost to your server's hostname) 40 | #urlauth: https://localhost:444/auth/ 41 | #urlcurse: https://localhost:444/ 42 | 43 | ## Use the sslcafile to authenticate the authentication server 44 | ## Should be disabled if using a Let's Encrypt certificate, or other certificate authority this system already trusts 45 | #usesslca: true 46 | -------------------------------------------------------------------------------- /jinx/jinxlib/conf.go: -------------------------------------------------------------------------------- 1 | package jinxlib 2 | 3 | import ( 4 | "fmt" 5 | "os" 6 | "regexp" 7 | "strings" 8 | 9 | "github.com/spf13/viper" 10 | ) 11 | 12 | type config struct { 13 | certFile string 14 | cmd string 15 | privKeyFile string 16 | pubKeyFile string 17 | userIP string 18 | userName string 19 | userPass string 20 | verbose bool 21 | 22 | AutoGenKeys bool 23 | BastionIP string 24 | Insecure bool 25 | KeyGenBitSize int 26 | KeyGenPubKey string 27 | KeyGenType string 28 | PromptUsername bool 29 | PubKey string 30 | SSHUser string 31 | SSLCAFile string 32 | SSLCertFile string 33 | SSLKeyCurve string 34 | SSLKeyFile string 35 | Timeout int 36 | URLAuth string 37 | URLCurse string 38 | UseSSLCA bool 39 | } 40 | 41 | func getConf() (*config, error) { 42 | // Read config into a struct 43 | var conf config 44 | err := viper.Unmarshal(&conf) 45 | if err != nil { 46 | return nil, fmt.Errorf("unable to process config: %v", err) 47 | } 48 | 49 | // Verify config options 50 | if conf.BastionIP == "" { 51 | conf.BastionIP, _ = getBastionIP() 52 | if conf.BastionIP == "" { 53 | return nil, fmt.Errorf("could not find server's public ip. bastionip field required") 54 | } 55 | } 56 | if conf.PubKey == "" { 57 | return nil, fmt.Errorf("pubkey is a required configuration field") 58 | } 59 | 60 | // Replace $HOME with the current user's home directory 61 | conf.PubKey = expandHome(conf.PubKey) 62 | conf.KeyGenPubKey = expandHome(conf.KeyGenPubKey) 63 | conf.SSLCAFile = expandHome(conf.SSLCAFile) 64 | conf.SSLCertFile = expandHome(conf.SSLCertFile) 65 | conf.SSLKeyFile = expandHome(conf.SSLKeyFile) 66 | 67 | // Generate our key and certificate filepaths 68 | r := regexp.MustCompile(`\.pub$`) 69 | if conf.AutoGenKeys { 70 | conf.certFile = r.ReplaceAllString(conf.KeyGenPubKey, "-cert.pub") 71 | conf.pubKeyFile = conf.KeyGenPubKey 72 | } else { 73 | conf.certFile = r.ReplaceAllString(conf.PubKey, "-cert.pub") 74 | conf.pubKeyFile = conf.PubKey 75 | } 76 | conf.privKeyFile = r.ReplaceAllString(conf.pubKeyFile, "") 77 | if conf.privKeyFile == conf.pubKeyFile { 78 | return nil, fmt.Errorf("invalid public key name (must end in .pub): %s", conf.pubKeyFile) 79 | } 80 | 81 | // Check for non-SSL URL configuration (for warning) 82 | if strings.HasPrefix(conf.URLAuth, "http://") { 83 | conf.Insecure = true 84 | } 85 | 86 | // Try to get the user's local IP from env variables 87 | sc := os.Getenv("SSH_CLIENT") 88 | scs := strings.Split(sc, " ") 89 | if len(scs) > 0 { 90 | conf.userIP = scs[0] 91 | } 92 | sc = os.Getenv("SSH_CONNECTION") 93 | scs = strings.Split(sc, " ") 94 | if conf.userIP == "" && len(scs) > 0 { 95 | conf.userIP = scs[0] 96 | } 97 | if conf.userIP == "" { 98 | conf.userIP = "ip missing" 99 | } 100 | 101 | return &conf, nil 102 | } 103 | -------------------------------------------------------------------------------- /jinx/jinxlib/jinx.go: -------------------------------------------------------------------------------- 1 | package jinxlib 2 | 3 | import ( 4 | "fmt" 5 | "io/ioutil" 6 | "net/http" 7 | "os" 8 | "strings" 9 | ) 10 | 11 | // Jinx Run the jinx client to generate keys and make request 12 | func Jinx(verbose bool, args []string) { 13 | // Process/load our config options 14 | conf, err := getConf() 15 | if err != nil { 16 | fmt.Fprintln(os.Stderr, err) 17 | os.Exit(1) 18 | } 19 | conf.verbose = verbose 20 | 21 | // Use our first argument as our command 22 | if len(args) > 0 { 23 | conf.cmd = strings.Join(args, " ") 24 | } 25 | 26 | // Get our pubkey 27 | pubKey, err := getPubKey(conf) 28 | if err != nil { 29 | fmt.Fprintln(os.Stderr, err) 30 | os.Exit(1) 31 | } 32 | 33 | // Check if our TLS key/cert exist and are valid 34 | ok, keyExists, err := checkTLSCert(conf) 35 | if !ok { 36 | fmt.Fprintln(os.Stderr, err) 37 | os.Exit(1) 38 | } 39 | if ok && !keyExists { 40 | // Key is invalid or does not yet exist 41 | keyBytes, err := genTLSKey(conf) 42 | if err != nil { 43 | fmt.Fprintln(os.Stderr, err) 44 | os.Exit(1) 45 | } 46 | err = saveTLSKey(conf, keyBytes) 47 | if err != nil { 48 | fmt.Fprintln(os.Stderr, err) 49 | os.Exit(1) 50 | } 51 | } 52 | if ok && err != nil { 53 | // Cert is invalid, need to request a new cert 54 | if verbose { 55 | fmt.Fprintln(os.Stderr, err) 56 | } 57 | 58 | // Prompt user for username and password 59 | conf.userName, conf.userPass, err = getUserPass(conf) 60 | if err != nil { 61 | fmt.Fprintf(os.Stderr, "%v", err) 62 | os.Exit(1) 63 | } 64 | 65 | // Make the cert request 66 | if conf.verbose { 67 | fmt.Fprintln(os.Stderr, "making tls cert request") 68 | } 69 | respBody, statusCode, err := requestTLSCert(conf) 70 | if err != nil { 71 | fmt.Fprintln(os.Stderr, err) 72 | os.Exit(statusCode) 73 | } 74 | 75 | switch statusCode { 76 | case http.StatusOK: 77 | err = ioutil.WriteFile(conf.SSLCertFile, respBody, 0644) 78 | if err != nil { 79 | fmt.Fprintf(os.Stderr, "failed to write tls cert file: %v\n", err) 80 | os.Exit(1) 81 | } 82 | default: 83 | out := fmt.Sprintf("server response: %s", respBody) 84 | fmt.Fprintf(os.Stderr, out) 85 | os.Exit(statusCode) 86 | } 87 | } 88 | 89 | // Send our pubkey to be signed 90 | if conf.verbose { 91 | fmt.Fprintln(os.Stderr, "making ssh cert request") 92 | } 93 | respBody, statusCode, err := requestSSHCert(conf, string(pubKey)) 94 | if err != nil { 95 | fmt.Fprintln(os.Stderr, err) 96 | os.Exit(statusCode) 97 | } 98 | 99 | switch statusCode { 100 | case http.StatusOK: 101 | err = ioutil.WriteFile(conf.certFile, respBody, 0644) 102 | if err != nil { 103 | fmt.Fprintf(os.Stderr, "failed to write cert file: %v\n", err) 104 | os.Exit(1) 105 | } 106 | case http.StatusUnprocessableEntity: 107 | if conf.AutoGenKeys { 108 | fmt.Fprintln(os.Stderr, "server denied pubkey due to age. regenerating keypairs. run command again after keys are regenerated.") 109 | err = saveNewKeyPair(conf) 110 | if err != nil { 111 | fmt.Fprintf(os.Stderr, "failed to generate key pair: %v\n", err) 112 | os.Exit(1) 113 | } 114 | } else { 115 | fmt.Fprintln(os.Stderr, "server denied pubkey due to age and automatic regeneration disabled. please manually regenerate your ssh keys.") 116 | os.Exit(1) 117 | } 118 | default: 119 | fmt.Fprintf(os.Stderr, string(respBody)) 120 | os.Exit(statusCode) 121 | } 122 | } 123 | -------------------------------------------------------------------------------- /jinx/jinxlib/keys.go: -------------------------------------------------------------------------------- 1 | package jinxlib 2 | 3 | import ( 4 | "crypto/ecdsa" 5 | "crypto/elliptic" 6 | "crypto/rand" 7 | "crypto/rsa" 8 | "crypto/x509" 9 | "encoding/pem" 10 | "fmt" 11 | "io/ioutil" 12 | "os" 13 | 14 | "github.com/mikesmitty/edkey" 15 | 16 | "golang.org/x/crypto/ed25519" 17 | "golang.org/x/crypto/ssh" 18 | ) 19 | 20 | func getPubKey(conf *config) ([]byte, error) { 21 | var ( 22 | pubKey []byte 23 | err error 24 | ) 25 | 26 | // Check if our keys exist, otherwise generate it 27 | if conf.AutoGenKeys { 28 | if _, err := os.Stat(conf.privKeyFile); os.IsNotExist(err) { 29 | fmt.Fprintf(os.Stderr, "configured ssh private key missing (%s), generating new key pair.\n", conf.privKeyFile) 30 | err = saveNewKeyPair(conf) 31 | if err != nil { 32 | return nil, fmt.Errorf("failed to generate key pair: %v", err) 33 | } 34 | } 35 | if _, err := os.Stat(conf.pubKeyFile); os.IsNotExist(err) { 36 | fmt.Fprintf(os.Stderr, "configured ssh public key missing (%s), generating new key pair.\n", conf.pubKeyFile) 37 | err = saveNewKeyPair(conf) 38 | if err != nil { 39 | return nil, fmt.Errorf("failed to generate key pair: %v", err) 40 | } 41 | } 42 | } 43 | 44 | // Read in our specified pubkey file 45 | pubKey, err = ioutil.ReadFile(conf.pubKeyFile) 46 | if err != nil { 47 | return nil, fmt.Errorf("failed to read pubkey file: %v", err) 48 | } 49 | 50 | return pubKey, nil 51 | } 52 | 53 | func genKeyPair(conf *config) ([]byte, []byte, error) { 54 | var ( 55 | authorizedKey []byte 56 | privateKeyPEM []byte 57 | err error 58 | ) 59 | 60 | switch conf.KeyGenType { 61 | case "ed25519": 62 | // Generate our private and public keys 63 | pubKey, privateKey, err := ed25519.GenerateKey(rand.Reader) 64 | if err != nil { 65 | return nil, nil, fmt.Errorf("unable to generate ed25519 private key: %v", err) 66 | } 67 | publicKey, err := ssh.NewPublicKey(pubKey) 68 | if err != nil { 69 | return nil, nil, fmt.Errorf("unable to convert ed25519 pubkey format: %v", err) 70 | } 71 | 72 | // Convert to a writable format 73 | pemKey := &pem.Block{ 74 | Type: "OPENSSH PRIVATE KEY", 75 | Bytes: edkey.MarshalED25519PrivateKey(privateKey), 76 | } 77 | privateKeyPEM = pem.EncodeToMemory(pemKey) 78 | authorizedKey = ssh.MarshalAuthorizedKey(publicKey) 79 | case "ecdsa": 80 | var privateKey *ecdsa.PrivateKey 81 | 82 | // Generate our private and public keys 83 | if conf.KeyGenBitSize == 256 { 84 | privateKey, err = ecdsa.GenerateKey(elliptic.P256(), rand.Reader) 85 | } else if conf.KeyGenBitSize == 384 { 86 | privateKey, err = ecdsa.GenerateKey(elliptic.P384(), rand.Reader) 87 | } else if conf.KeyGenBitSize == 521 { 88 | privateKey, err = ecdsa.GenerateKey(elliptic.P521(), rand.Reader) 89 | } else { 90 | return nil, nil, fmt.Errorf("invalid keygenbitsize for ecdsa: %d", conf.KeyGenBitSize) 91 | } 92 | if err != nil { 93 | return nil, nil, fmt.Errorf("unable to generate ecdsa private key: %v", err) 94 | } 95 | pubKey, ok := privateKey.Public().(*ecdsa.PublicKey) 96 | if !ok { 97 | return nil, nil, fmt.Errorf("ecdsa.PublicKey type assertion failed on an ecdsa public key. This should never ever happen") 98 | } 99 | publicKey, err := ssh.NewPublicKey(pubKey) 100 | if err != nil { 101 | return nil, nil, fmt.Errorf("unable to convert ecdsa pubkey format: %v", err) 102 | } 103 | 104 | // Convert to a writable format 105 | ecBytes, err := x509.MarshalECPrivateKey(privateKey) 106 | if err != nil { 107 | return nil, nil, fmt.Errorf("unable to convert ecdsa private key format: %v", err) 108 | } 109 | pemKey := &pem.Block{ 110 | Type: "EC PARAMETERS", 111 | Bytes: ecBytes, 112 | } 113 | privateKeyPEM = pem.EncodeToMemory(pemKey) 114 | authorizedKey = ssh.MarshalAuthorizedKey(publicKey) 115 | case "rsa": 116 | // Generate our private and public keys 117 | privateKey, err := rsa.GenerateKey(rand.Reader, conf.KeyGenBitSize) 118 | if err != nil { 119 | return nil, nil, fmt.Errorf("unable to generate rsa private key: %v", err) 120 | } 121 | pubKey, ok := privateKey.Public().(*rsa.PublicKey) 122 | if !ok { 123 | return nil, nil, fmt.Errorf("rsa.PublicKey type assertion failed on an rsa public key. This should never ever happen") 124 | } 125 | publicKey, err := ssh.NewPublicKey(pubKey) 126 | if err != nil { 127 | return nil, nil, fmt.Errorf("unable to convert rsa pubkey format: %v", err) 128 | } 129 | 130 | // Convert to a writable format 131 | pemKey := &pem.Block{ 132 | Type: "RSA PRIVATE KEY", 133 | Bytes: x509.MarshalPKCS1PrivateKey(privateKey), 134 | } 135 | privateKeyPEM = pem.EncodeToMemory(pemKey) 136 | authorizedKey = ssh.MarshalAuthorizedKey(publicKey) 137 | default: 138 | return nil, nil, fmt.Errorf("key type '%s' not recognized. Unable to generate new keypair", conf.KeyGenType) 139 | } 140 | 141 | return authorizedKey, privateKeyPEM, nil 142 | } 143 | 144 | func saveNewKeyPair(conf *config) error { 145 | if !conf.AutoGenKeys { 146 | return fmt.Errorf("autogenkeys disabled. Not generating new keys") 147 | } 148 | 149 | publicKey, privateKey, err := genKeyPair(conf) 150 | if err != nil { 151 | return fmt.Errorf("failed to generate new keys: %v", err) 152 | } 153 | 154 | err = ioutil.WriteFile(conf.privKeyFile, privateKey, 0600) 155 | if err != nil { 156 | return fmt.Errorf("failed to write private key file: %v", err) 157 | } 158 | err = ioutil.WriteFile(conf.pubKeyFile, publicKey, 0644) 159 | if err != nil { 160 | return fmt.Errorf("failed to write public key file: %v", err) 161 | } 162 | 163 | return nil 164 | } 165 | -------------------------------------------------------------------------------- /jinx/jinxlib/request.go: -------------------------------------------------------------------------------- 1 | package jinxlib 2 | 3 | import ( 4 | "bytes" 5 | "crypto/tls" 6 | "crypto/x509" 7 | "encoding/json" 8 | "fmt" 9 | "io/ioutil" 10 | "net/http" 11 | "os/user" 12 | "time" 13 | ) 14 | 15 | type params struct { 16 | BastionIP string `json:"bastion_ip,omitempty"` 17 | BastionUser string `json:"bastion_user,omitempty"` 18 | Cmd string `json:"cmd,omitempty"` 19 | CSR string `json:"csr,omitempty"` 20 | Key string `json:"key,omitempty"` 21 | RemoteUser string `json:"remote_user,omitempty"` 22 | UserIP string `json:"user_ip,omitempty"` 23 | } 24 | 25 | func requestSSHCert(conf *config, pubKey string) ([]byte, int, error) { 26 | // Prep our mutual auth cert/key and TLS settings 27 | keyPair, err := tls.LoadX509KeyPair(conf.SSLCertFile, conf.SSLKeyFile) 28 | if err != nil { 29 | return nil, 1, fmt.Errorf("failed to load tls mutual auth client certfificate/key pair: %v", err) 30 | } 31 | ca, err := ioutil.ReadFile(conf.SSLCAFile) 32 | if err != nil { 33 | return nil, 1, fmt.Errorf("failed to load tls mutual auth ca: %v", err) 34 | } 35 | certPool := x509.NewCertPool() 36 | certPool.AppendCertsFromPEM(ca) 37 | 38 | tlsConf := &tls.Config{ 39 | Certificates: []tls.Certificate{keyPair}, 40 | RootCAs: certPool, 41 | } 42 | tlsConf.BuildNameToCertificate() 43 | 44 | tr := &http.Transport{ 45 | TLSClientConfig: tlsConf, 46 | } 47 | 48 | client := &http.Client{ 49 | Transport: tr, 50 | Timeout: time.Duration(conf.Timeout) * time.Second, 51 | } 52 | 53 | // Assemble our parameters 54 | p := params{ 55 | BastionIP: conf.BastionIP, 56 | Cmd: conf.cmd, 57 | Key: pubKey, 58 | RemoteUser: conf.SSHUser, 59 | UserIP: conf.userIP, 60 | } 61 | 62 | // Assemble our json payload 63 | pl, err := json.Marshal(p) 64 | if err != nil { 65 | return nil, 1, fmt.Errorf("failed to marshal json for request: %v", err) 66 | } 67 | 68 | req, err := http.NewRequest("POST", conf.URLCurse, bytes.NewBuffer(pl)) 69 | req.Header.Add("Content-Type", "application/json") 70 | 71 | resp, err := client.Do(req) 72 | if err != nil { 73 | return nil, 2, fmt.Errorf("connection failed: %v", err) 74 | } 75 | defer resp.Body.Close() 76 | 77 | respBody, err := ioutil.ReadAll(resp.Body) 78 | if err != nil { 79 | return nil, 2, fmt.Errorf("failed to process response: %v", err) 80 | } 81 | 82 | return respBody, resp.StatusCode, nil 83 | } 84 | 85 | func requestTLSCert(conf *config) ([]byte, int, error) { 86 | var csrBytes []byte 87 | 88 | // Generate CSR since our cert is invalid 89 | csrBytes, err := genTLSCSR(conf) 90 | if err != nil { 91 | return nil, 1, err 92 | } 93 | 94 | var tlsConf *tls.Config 95 | if conf.UseSSLCA { 96 | // Use /etc/jinx/ca.crt as our CA for verifying the curse daemon 97 | ca, err := ioutil.ReadFile(conf.SSLCAFile) 98 | if err != nil { 99 | return nil, 1, fmt.Errorf("failed to load tls mutual auth ca: %v", err) 100 | } 101 | certPool := x509.NewCertPool() 102 | certPool.AppendCertsFromPEM(ca) 103 | 104 | tlsConf = &tls.Config{ 105 | RootCAs: certPool, 106 | } 107 | tlsConf.BuildNameToCertificate() 108 | } else { 109 | // For regular tls connections set our verify settings 110 | tlsConf = &tls.Config{InsecureSkipVerify: conf.Insecure} 111 | } 112 | 113 | tr := &http.Transport{ 114 | TLSClientConfig: tlsConf, 115 | } 116 | 117 | client := &http.Client{ 118 | Transport: tr, 119 | Timeout: time.Duration(conf.Timeout) * time.Second, 120 | } 121 | 122 | // Get our system username 123 | u, err := user.Current() 124 | if err != nil { 125 | return nil, 1, fmt.Errorf("failed to get username: %v", err) 126 | } 127 | curUser := u.Username 128 | 129 | // Assemble our parameters 130 | p := params{ 131 | BastionUser: curUser, 132 | CSR: string(csrBytes), 133 | UserIP: conf.userIP, 134 | } 135 | 136 | // Assemble our json payload 137 | pl, err := json.Marshal(p) 138 | if err != nil { 139 | return nil, 1, fmt.Errorf("failed to marshal json for request: %v", err) 140 | } 141 | 142 | req, err := http.NewRequest("POST", conf.URLAuth, bytes.NewBuffer(pl)) 143 | req.Header.Add("Content-Type", "application/json") 144 | 145 | req.SetBasicAuth(conf.userName, conf.userPass) 146 | 147 | resp, err := client.Do(req) 148 | if err != nil { 149 | return nil, 2, fmt.Errorf("connection failed: %v", err) 150 | } 151 | defer resp.Body.Close() 152 | 153 | respBody, err := ioutil.ReadAll(resp.Body) 154 | if err != nil { 155 | return nil, 2, fmt.Errorf("failed to process response: %v", err) 156 | } 157 | 158 | return respBody, resp.StatusCode, nil 159 | } 160 | -------------------------------------------------------------------------------- /jinx/jinxlib/utils.go: -------------------------------------------------------------------------------- 1 | package jinxlib 2 | 3 | import ( 4 | "bufio" 5 | "fmt" 6 | "net" 7 | "os" 8 | "os/user" 9 | "regexp" 10 | "strings" 11 | 12 | "github.com/bgentry/speakeasy" 13 | ) 14 | 15 | func getPathByFilename(path string) string { 16 | e := strings.Split(path, "/") 17 | n := len(e) - 1 18 | dir := strings.Join(e[:n], "/") 19 | 20 | return dir 21 | } 22 | 23 | func expandHome(path string) string { 24 | // Swap out $HOME for service user's home dir in path 25 | home := os.Getenv("HOME") 26 | if strings.HasPrefix(path, "$HOME") && home != "" { 27 | path = strings.Replace(path, "$HOME", home, 1) 28 | } 29 | 30 | return path 31 | } 32 | 33 | func getBastionIP() (string, error) { 34 | // Compile our private address space/loopback address matching regex 35 | localIPs := regexp.MustCompile(`^(fe80|::1|127\.|192\.168|10\.|172\.(1[6-9]|2[0-9]|3[01])\.)`) 36 | 37 | addrs, err := net.InterfaceAddrs() 38 | if err != nil { 39 | // FIXME add some logging level verbosity here 40 | return "", fmt.Errorf("unable to find bastion ip: %v", err) 41 | } 42 | 43 | for _, ip := range addrs { 44 | ipNet := strings.Split(ip.String(), "/") 45 | if !localIPs.MatchString(ipNet[0]) { 46 | return ipNet[0], nil 47 | } 48 | } 49 | 50 | return "", fmt.Errorf("found no public ip addresses") 51 | } 52 | 53 | func getUserPass(conf *config) (string, string, error) { 54 | var ( 55 | un string 56 | err error 57 | ) 58 | 59 | // Nag-mode for inadvertent/malicious insecure setting 60 | if conf.Insecure { 61 | fmt.Println("warning, your password is about to be sent insecurely. ctrl+c to quit") 62 | } 63 | 64 | if !conf.PromptUsername { 65 | u, err := user.Current() 66 | if err == nil { 67 | un = u.Username 68 | } 69 | } 70 | 71 | // Read in our username and password 72 | if un == "" { 73 | reader := bufio.NewReader(os.Stdin) 74 | fmt.Printf("username: ") 75 | un, err = reader.ReadString('\n') 76 | if err != nil { 77 | return "", "", fmt.Errorf("Input error: %v", err) 78 | } 79 | un = strings.TrimSpace(un) 80 | } 81 | 82 | pass, err := speakeasy.Ask("password: ") 83 | if err != nil { 84 | return "", "", fmt.Errorf("shell error: %v", err) 85 | } 86 | 87 | return un, pass, nil 88 | } 89 | -------------------------------------------------------------------------------- /jinx/jinxlib/x509.go: -------------------------------------------------------------------------------- 1 | package jinxlib 2 | 3 | import ( 4 | "crypto/ecdsa" 5 | "crypto/elliptic" 6 | "crypto/rand" 7 | "crypto/x509" 8 | "crypto/x509/pkix" 9 | "encoding/pem" 10 | "fmt" 11 | "io/ioutil" 12 | "os" 13 | ) 14 | 15 | func checkTLSCert(conf *config) (bool, bool, error) { 16 | keyExists := false 17 | 18 | // Check if the client key exists 19 | if _, err := os.Stat(conf.SSLKeyFile); os.IsNotExist(err) { 20 | return true, keyExists, fmt.Errorf("tls key file does not exist") 21 | } 22 | keyExists = true 23 | 24 | // Check if the client cert exists 25 | if _, err := os.Stat(conf.SSLCertFile); os.IsNotExist(err) { 26 | return true, keyExists, fmt.Errorf("tls cert file does not exist") 27 | } 28 | 29 | // Read, decode, and parse the client cert for verification 30 | certRaw, err := ioutil.ReadFile(conf.SSLCertFile) 31 | if err != nil { 32 | return false, keyExists, fmt.Errorf("failed to read tls cert file: %v", err) 33 | } 34 | certBlock, _ := pem.Decode(certRaw) 35 | if certBlock == nil { 36 | return false, keyExists, fmt.Errorf("failed to decode tls cert: %v", err) 37 | } 38 | cert, err := x509.ParseCertificate(certBlock.Bytes) 39 | if err != nil { 40 | return false, keyExists, fmt.Errorf("failed to parse tls cert: %v", err) 41 | } 42 | 43 | // Load CA cert for client cert verification 44 | caBytes, err := ioutil.ReadFile(conf.SSLCAFile) 45 | if err != nil { 46 | return false, keyExists, fmt.Errorf("failed to read tls ca cert file: %v", err) 47 | } 48 | certPool := x509.NewCertPool() 49 | certPool.AppendCertsFromPEM(caBytes) 50 | 51 | opts := x509.VerifyOptions{ 52 | Roots: certPool, 53 | KeyUsages: []x509.ExtKeyUsage{x509.ExtKeyUsageClientAuth, x509.ExtKeyUsageServerAuth}, 54 | } 55 | _, err = cert.Verify(opts) 56 | // FIXME need to either trim this or use it 57 | //if err != nil && err.(type) == x509.CertificateInvalidError && err.Reason == x509.Expired { 58 | if err != nil { 59 | return true, keyExists, fmt.Errorf("failed to verify tls client cert: %v", err) 60 | } 61 | 62 | return true, keyExists, nil 63 | } 64 | 65 | func genTLSCSR(conf *config) ([]byte, error) { 66 | // Hard-coding elliptic curve for now, since we control both client and server. This will need 67 | // to be widened in the future for other key types 68 | keyRaw, err := ioutil.ReadFile(conf.SSLKeyFile) 69 | if err != nil { 70 | return nil, fmt.Errorf("failed to read tls private key file: %v", err) 71 | } 72 | keyBlock, _ := pem.Decode(keyRaw) 73 | if keyBlock == nil { 74 | return nil, fmt.Errorf("failed to decode tls private key file: %v", err) 75 | } 76 | key, err := x509.ParseECPrivateKey(keyBlock.Bytes) 77 | if err != nil { 78 | return nil, fmt.Errorf("failed to parse tls private key: %v", err) 79 | } 80 | 81 | req := &x509.CertificateRequest{ 82 | Subject: pkix.Name{ 83 | CommonName: conf.userName, 84 | Organization: []string{"CURSE"}, 85 | }, 86 | } 87 | 88 | publicKey := key.Public() 89 | switch pub := publicKey.(type) { 90 | case *ecdsa.PublicKey: 91 | req.PublicKeyAlgorithm = x509.ECDSA 92 | 93 | switch pub.Curve { 94 | case elliptic.P256(): 95 | req.SignatureAlgorithm = x509.ECDSAWithSHA256 96 | case elliptic.P384(): 97 | req.SignatureAlgorithm = x509.ECDSAWithSHA384 98 | case elliptic.P521(): 99 | req.SignatureAlgorithm = x509.ECDSAWithSHA512 100 | default: 101 | req.SignatureAlgorithm = x509.ECDSAWithSHA1 102 | } 103 | default: 104 | return nil, fmt.Errorf("invalid tls key type") 105 | } 106 | 107 | csrBytes, err := x509.CreateCertificateRequest(rand.Reader, req, key) 108 | if err != nil { 109 | return nil, fmt.Errorf("failed to generate tls client csr: %v", err) 110 | } 111 | 112 | // Encode our CSR into a PEM 113 | pemBlock := &pem.Block{ 114 | Type: "CERTIFICATE REQUEST", 115 | Bytes: csrBytes, 116 | } 117 | csr := pem.EncodeToMemory(pemBlock) 118 | 119 | return csr, nil 120 | } 121 | 122 | func genTLSKey(conf *config) ([]byte, error) { 123 | // Hard-coding elliptic curve for now, since we control both client and server. This will need 124 | // to be widened in the future for other key types 125 | var ( 126 | key *ecdsa.PrivateKey 127 | err error 128 | ) 129 | 130 | switch conf.SSLKeyCurve { 131 | case "p256": 132 | key, err = ecdsa.GenerateKey(elliptic.P256(), rand.Reader) 133 | case "p384": 134 | key, err = ecdsa.GenerateKey(elliptic.P384(), rand.Reader) 135 | case "p521": 136 | key, err = ecdsa.GenerateKey(elliptic.P521(), rand.Reader) 137 | default: 138 | return nil, fmt.Errorf("could not generate tls client key, invalid elliptic curve: %s", conf.SSLKeyCurve) 139 | } 140 | if err != nil { 141 | return nil, fmt.Errorf("error generating tls client key: %v", err) 142 | } 143 | 144 | keyBytes, err := x509.MarshalECPrivateKey(key) 145 | if err != nil { 146 | return nil, fmt.Errorf("unable to convert tls private key format to der: %v", err) 147 | } 148 | 149 | pemKey := &pem.Block{ 150 | Type: "EC PRIVATE KEY", 151 | Bytes: keyBytes, 152 | } 153 | privateKeyPEM := pem.EncodeToMemory(pemKey) 154 | 155 | return privateKeyPEM, nil 156 | } 157 | 158 | func saveTLSKey(conf *config, keyBytes []byte) error { 159 | // Create the jinxDir if it doesn't exist 160 | jinxDir := getPathByFilename(conf.SSLKeyFile) 161 | if _, err := os.Stat(jinxDir); os.IsNotExist(err) { 162 | err := os.MkdirAll(jinxDir, 0700) 163 | if err != nil { 164 | return fmt.Errorf("failed to create path to tls key: %v", err) 165 | } 166 | } 167 | 168 | if _, err := os.Stat(conf.SSLKeyFile); os.IsNotExist(err) { 169 | err := ioutil.WriteFile(conf.SSLKeyFile, keyBytes, 0600) 170 | if err != nil { 171 | return fmt.Errorf("failed to write tls private key file: %v", err) 172 | } 173 | } 174 | 175 | return nil 176 | } 177 | -------------------------------------------------------------------------------- /jinx/main.go: -------------------------------------------------------------------------------- 1 | // Copyright © 2017 Michael Smith 2 | // 3 | // Permission is hereby granted, free of charge, to any person obtaining a copy 4 | // of this software and associated documentation files (the "Software"), to deal 5 | // in the Software without restriction, including without limitation the rights 6 | // to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 7 | // copies of the Software, and to permit persons to whom the Software is 8 | // furnished to do so, subject to the following conditions: 9 | // 10 | // The above copyright notice and this permission notice shall be included in 11 | // all copies or substantial portions of the Software. 12 | // 13 | // THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 14 | // IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 15 | // FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 16 | // AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 17 | // LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 18 | // OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 19 | // THE SOFTWARE. 20 | 21 | package main 22 | 23 | import "github.com/mikesmitty/curse/jinx/cmd" 24 | 25 | func main() { 26 | cmd.Execute() 27 | } 28 | --------------------------------------------------------------------------------