diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..3583229 --- /dev/null +++ b/.gitignore @@ -0,0 +1,5 @@ +.DS_Store +.idea +.vscode + +/vault \ No newline at end of file diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..ad606f1 --- /dev/null +++ b/Makefile @@ -0,0 +1,33 @@ +GOARCH = amd64 + +UNAME = $(shell uname -s) + +ifndef OS + ifeq ($(UNAME), Linux) + OS = linux + else ifeq ($(UNAME), Darwin) + OS = darwin + endif +endif + +.DEFAULT_GOAL := all + +all: fmt build start + +build: + mkdir -p vault/plugins + GOOS=$(OS) GOARCH="$(GOARCH)" go build -o vault/plugins/vault-plugin-auth-ssh cmd/vault-plugin-auth-ssh/main.go + +start: + vault server -dev -dev-root-token-id=root -dev-plugin-dir=./vault/plugins + +enable: + vault auth enable -path=ssh vault-plugin-auth-ssh + +clean: + rm -f ./vault/plugins/vault-plugin-auth-ssh + +fmt: + go fmt $$(go list ./...) + +.PHONY: build clean fmt start enable diff --git a/README.md b/README.md new file mode 100644 index 0000000..0ec54f5 --- /dev/null +++ b/README.md @@ -0,0 +1,209 @@ +# Vault Plugin: SSH Auth Backend + +This is a standalone backend plugin for use with [Hashicorp Vault](https://www.github.com/hashicorp/vault). +This plugin allows for SSH public keys and SSH certificates to authenticate with Vault. + + + +- [Vault Plugin: SSH Auth Backend](#vault-plugin-ssh-auth-backend) + - [Getting Started](#getting-started) + - [Usage](#usage) + - [Developing](#developing) + - [Dev setup](#dev-setup) + - [Using the plugin](#using-the-plugin) + - [Global configuration](#global-configuration) + - [Roles](#roles) + - [SSH certificate](#ssh-certificate) + - [SSH public keys](#ssh-public-keys) + - [Logging in](#logging-in) + - [SSH certificate](#ssh-certificate-1) + - [SSH public key](#ssh-public-key) + - [Creating signatures](#creating-signatures) + + + +## Getting Started + +This is a [Vault plugin](https://www.vaultproject.io/docs/internals/plugins.html) +and is meant to work with Vault. This guide assumes you have already installed Vault +and have a basic understanding of how Vault works. + +Otherwise, first read this guide on how to [get started with Vault](https://www.vaultproject.io/intro/getting-started/install.html). + +To learn specifically about how plugins work, see documentation on [Vault plugins](https://www.vaultproject.io/docs/internals/plugins.html). + +## Usage + +```sh +$ vault auth enable -path=ssh vault-plugin-auth-ssh +Success! Enabled vault-plugin-auth-ssh auth method at: ssh/ +``` + +## Developing + +If you wish to work on this plugin, you'll first need +[Go](https://www.golang.org) installed on your machine. + +Next, clone this repository into `vault-plugin-auth-ssh`. + +To compile a development version of this plugin, run `make build`. +This will put the plugin binary in the `./vault/plugins` folders. + +Run `make start` to start a development version of vault with this plugin. + +Enable the auth plugin backend using the SSH auth plugin: + +```sh +$ vault auth enable -path=ssh vault-plugin-auth-ssh +Success! Enabled vault-plugin-auth-ssh auth method at: ssh/ +``` + +### Dev setup + +Look into the `devsetup.sh` script, this will build the plugin, build certsig and setup a test environment with ssh-client signing, ssh certificate and public key test. + +## Using the plugin + +### Global configuration + +If you want to use ssh certificates you'll need to configure the ssh CA's which the certificates will validate against. + +sshca in this example is a file containing your SSH CA. You can specify multiple CA's. + +```sh +$ vault write auth/ssh/config ssh_ca_public_keys=@sshca + +$ vault read auth/ssh/config +Key Value +--- ----- +ssh_ca_public_keys [ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAACAQDrLFN/LCDOjPw327hWfHXMOk9+GmP+pOl2JEG7eSfkzwVhumDU12swjnPQ9H1tZVWzcfufTg+PgMd/hP19ADkRxQ2CTbz7YUPdD6LvJOCRK8TK+tKliaFL9/lWFtlitERyk91ZSqGbROjtCyGlnetxY1+tF5NqLFtQ1tsPrxjjdRQoUMHlF8yv/VUxMOCjAmuqxKrEl5mfZJcnYpnfEBgWoZNTKAXkp6KJWLAxyiHPVTt7azyMzivCTZc8eCKXIInRpOMR7TvHGxPG8tHn2XrI01ni9zXQ+xG1sqxecPBSWU8fekKxwg5bikrWw4/9kCNvxrwBpf1IzlIKhugig8MP3+Jlrjp5BFXFuaQatIk6zLMkzDpE/iZwDZv5qicXdLK/nbKHmGqFWupcvfHUe6rh16TOYFbpnRMOEvTYpR/PfLlnQKcbkQgbDR01N8DfLetxt635C+ANU4N1ebQqjKkwb8ZPr2ryF/Y8Z1PV0x5H25r8UZyoGAXIsP3zkP0Ev40Bx3umlU/jR8nF6QQmXdbs2McfZFO2g0VsXSzUOR0L5s5Sd/uoUCcpz9nmKlgRIqHIhVGF3+FjrIaj3tXT7ucyPAsVVk/l4yhMQSuNtFi0eqZRPcdMiKff5W9PfVyEkpXTcSFweGPdVehZxPnM7DfH7axpg73OLWxvwVzkah31WQ==] +``` + +### Roles + +You can create / list / delete roles which are used to link your certificate or public keys to vault policies. + +#### SSH certificate + +Create a role with the policy `ssh-policy` bound to a certificate with the principal `ubuntu`. +(prerequisite: a SSH CA needs to be configured in auth/ssh/config) + +```sh +$ vault write auth/ssh/role/ubuntu token_policies="ssh-policy" principals="ubuntu" + +$ vault read auth/ssh/role/ubuntu +Key Value +--- ----- +principals [ubuntu] +public_keys +token_bound_cidrs [] +token_explicit_max_ttl 0s +token_max_ttl 0s +token_no_default_policy false +token_num_uses 0 +token_period 0s +token_policies [ssh-policy] +token_ttl 0s +token_type default +``` + +#### SSH public keys + +Create a role with the policy `ssh-policy` bound to a specific publickey. + +```sh +$ vault write auth/ssh/role/ubuntu token_policies="ssh-policy" public_keys=@sshkey.pub + +$ vault read auth/ssh/role/ubuntu +Key Value +--- ----- +principals +public_keys [ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIGg0xzFrvEYbZGkF5vWlHUutACUTLH7WMUG09NOi6skL] +token_bound_cidrs [] +token_explicit_max_ttl 0s +token_max_ttl 0s +token_no_default_policy false +token_num_uses 0 +token_period 0s +token_policies [ssh-policy] +token_ttl 0s +token_type default +``` + +### Logging in + +#### SSH certificate + +```sh +vault write auth/ssh/login role= cert=@ nonce= signature= +``` + +For example + +```sh +vault write auth/ssh/login role=ubuntu cert=@id_rsa-cert.pub signature=7ou2bupUMNmMqcorurOnbKnpbh9Kc7aBrF7nk6li0AhgnYAzhgfgGB3qJqI4qmf9TIc/x3JoNzo+Xq7KqXOXCA== nonce=AQAAAA7XdbPVKJn3uwA8 + +Key Value +--- ----- +token s.TpHP2eRCZNhuOENZUMas6YmV +token_accessor 7FFAcAnxKOyM8BYWEN2KfYzR +token_duration 768h +token_renewable true +token_policies ["default" "ssh-policy"] +identity_policies [] +policies ["default" "ssh-policy"] +token_meta_role ubuntu +``` + +#### SSH public key + +```sh +vault write auth/ssh/login role= public_key=@ nonce= signature= +``` + +For example + +```sh +$ vault write auth/ssh/login role=ubuntu public_key=@id_rsa.pub signature=fiEHdDHClYJIlRNWMC6c5QpM3ePi1xJh1KB90NI7CedZh0Siya5SG8ohy6zOk7e5l8Mdhx/FelykL43KH+OwBw== nonce=AQAAAA7XdbTAIytAhQA8 + +Key Value +--- ----- +token s.8L1uONTtaLHzZEtNw2oKmurk +token_accessor SQ6jWOYKblSBFqywTezcdqVk +token_duration 768h +token_renewable true +token_policies ["default" "ssh-policy"] +identity_policies [] +policies ["default" "ssh-policy"] +token_meta_role ubuntu +``` + +### Creating signatures + +For now you can use the [createsig](createsig/README.md) tool to generate your signature and nonce. + +```text +This tool will print out a signature and nonce to be used with vault-plugin-auth-ssh + +Need createsig +eg. createsig id_rsa mypassword + +If you don't have a password just omit it +eg. createsig id_rsa +``` + +For example: + +```sh +$ vault write auth/ssh/login role=ubuntu public_key=@id_rsa.pub $(createsig id_rsa) +``` + +```sh +$ vault write auth/ssh/login role=ubuntu cert=@id_rsa-cert.pub $(createsig id_rsa) +``` + +With a pass + +```sh +$ vault write auth/ssh/login role=ubuntu public_key=@id_rsa.pub $(createsig id_rsa yourpass) +``` diff --git a/backend.go b/backend.go new file mode 100644 index 0000000..e509542 --- /dev/null +++ b/backend.go @@ -0,0 +1,106 @@ +package sshauth + +import ( + "context" + "errors" + "fmt" + "strings" + + "github.com/hashicorp/errwrap" + "github.com/hashicorp/vault/sdk/framework" + "github.com/hashicorp/vault/sdk/helper/tokenutil" + "github.com/hashicorp/vault/sdk/logical" +) + +const ( + rolePrefix string = "role/" +) + +// backend wraps the backend framework and adds a map for storing key value pairs. +type backend struct { + *framework.Backend +} + +type ConfigEntry struct { + tokenutil.TokenParams + + SSHCAPublicKeys []string `json:"ssh_ca_public_keys"` +} + +var _ logical.Factory = Factory + +// Factory configures and returns Mock backends +func Factory(ctx context.Context, conf *logical.BackendConfig) (logical.Backend, error) { + b := newBackend() + + if conf == nil { + return nil, fmt.Errorf("configuration passed into backend is nil") + } + + if err := b.Setup(ctx, conf); err != nil { + return nil, err + } + + return b, nil +} + +func newBackend() *backend { + b := &backend{} + + b.Backend = &framework.Backend{ + Help: strings.TrimSpace(backendHelp), + BackendType: logical.TypeCredential, + AuthRenew: b.pathAuthRenew, + PathsSpecial: &logical.Paths{ + Unauthenticated: []string{ + "login", + }, + SealWrapStorage: []string{ + "config", + }, + }, + Paths: framework.PathAppend( + []*framework.Path{ + b.pathLogin(), + b.pathConfig(), + b.pathRoleList(), + b.pathRole(), + }, + ), + } + + return b +} + +func (b *backend) pathAuthRenew(ctx context.Context, req *logical.Request, d *framework.FieldData) (*logical.Response, error) { + roleName := req.Auth.InternalData["role"].(string) + if roleName == "" { + return nil, errors.New("failed to fetch role_name during renewal") + } + + // Ensure that the Role still exists. + role, err := b.role(ctx, req.Storage, roleName) + if err != nil { + return nil, errwrap.Wrapf(fmt.Sprintf("failed to validate role %s during renewal: {{err}}", roleName), err) + } + + if role == nil { + return nil, fmt.Errorf("role %s does not exist during renewal", roleName) + } + + resp := &logical.Response{ + Auth: req.Auth, + } + + resp.Auth.TTL = role.TokenTTL + resp.Auth.MaxTTL = role.TokenMaxTTL + resp.Auth.Period = role.TokenPeriod + + return resp, nil +} + +const ( + backendHelp = ` +The SSH backend plugin allows authentication using SSH certificates and public keys. +` +) diff --git a/cmd/vault-plugin-auth-ssh/main.go b/cmd/vault-plugin-auth-ssh/main.go new file mode 100644 index 0000000..2596f9b --- /dev/null +++ b/cmd/vault-plugin-auth-ssh/main.go @@ -0,0 +1,30 @@ +package main + +import ( + "os" + + sshauth "github.com/42wim/vault-plugin-auth-ssh" + "github.com/hashicorp/go-hclog" + "github.com/hashicorp/vault/api" + "github.com/hashicorp/vault/sdk/plugin" +) + +func main() { + apiClientMeta := &api.PluginAPIClientMeta{} + flags := apiClientMeta.FlagSet() + flags.Parse(os.Args[1:]) + + tlsConfig := apiClientMeta.GetTLSConfig() + tlsProviderFunc := api.VaultPluginTLSProvider(tlsConfig) + + err := plugin.Serve(&plugin.ServeOpts{ + BackendFactoryFunc: sshauth.Factory, + TLSProviderFunc: tlsProviderFunc, + }) + if err != nil { + logger := hclog.New(&hclog.LoggerOptions{}) + + logger.Error("plugin shutting down", "error", err) + os.Exit(1) + } +} diff --git a/createsig/README.md b/createsig/README.md new file mode 100644 index 0000000..4fddcd7 --- /dev/null +++ b/createsig/README.md @@ -0,0 +1,19 @@ +# Createsig + +```text +This tool will print out a signature and nonce to be used with vault-plugin-auth-ssh + +Need createsig +eg. createsig id_rsa mypassword + +If you don't have a password just omit it +eg. createsig id_rsa +``` + +## building + +Use `go get`, resulting binary will be in `~/go/bin/createsig` + +```sh +go get github.com/42wim/vault-plugin-auth-ssh/createsig +``` diff --git a/createsig/main.go b/createsig/main.go new file mode 100644 index 0000000..b392331 --- /dev/null +++ b/createsig/main.go @@ -0,0 +1,68 @@ +package main + +import ( + "crypto/rand" + "encoding/base64" + "fmt" + "io/ioutil" + "log" + "os" + "time" + + "golang.org/x/crypto/ssh" +) + +func genSig(privatekey, password string) { + var ( + signer ssh.Signer + err error + ) + + pemBytes, err := ioutil.ReadFile(privatekey) + if err != nil { + log.Fatal(err) + } + + if password == "" { + signer, err = ssh.ParsePrivateKey(pemBytes) + if err != nil { + log.Fatalf("parse key failed:%v", err) + } + } else { + signer, err = ssh.ParsePrivateKeyWithPassphrase(pemBytes, []byte(password)) + } + + t := time.Now() + timeBytes, _ := t.MarshalBinary() + + var signBytes []byte + + signBytes = append(signBytes, timeBytes...) + res, _ := signer.Sign(rand.Reader, signBytes) + + signatureBlob := res.Blob + + fmt.Println("signature=" + base64.StdEncoding.EncodeToString(signatureBlob) + " nonce=" + base64.StdEncoding.EncodeToString(signBytes)) + +} + +func printHelp() { + fmt.Println("This tool will print out a signature and nonce to be used with vault-plugin-auth-ssh") + fmt.Println("") + fmt.Println("Need " + os.Args[0] + " ") + fmt.Println("eg. " + os.Args[0] + " ~/.ssh/id_rsa mypassword") + fmt.Println("") + fmt.Println("If you don't have a password just omit it") + fmt.Println("eg. " + os.Args[0] + " ~/.ssh/id_rsa") +} + +func main() { + switch len(os.Args) { + case 2: + genSig(os.Args[1], "") + case 3: + genSig(os.Args[1], os.Args[2]) + default: + printHelp() + } +} diff --git a/devsetup.sh b/devsetup.sh new file mode 100755 index 0000000..b476f82 --- /dev/null +++ b/devsetup.sh @@ -0,0 +1,54 @@ +#!/bin/bash +export VAULT_ADDR="http://127.0.0.1:8200" +mkdir tmp +mkdir -p vault/plugins + +set -e +echo "generating sshkey" +rm -f tmp/sshkey +ssh-keygen -t ed25519 -f tmp/sshkey -N "" + +echo "building plugin" +go build -o vault/plugins/vault-plugin-auth-ssh cmd/vault-plugin-auth-ssh/main.go + +echo "building createsig" +go build -o createsig/createsig createsig/main.go + +vault server -dev -dev-root-token-id=root -dev-plugin-dir=./vault/plugins & +sleep 5 + +vault login root +vault secrets enable -path=ssh-client-signer ssh +vault write -field=public_key ssh-client-signer/config/ca generate_signing_key=true >tmp/sshca +vault write ssh-client-signer/roles/my-role - <<"EOH" +{ + "allow_user_certificates": true, + "allowed_users": "*", + "allowed_extensions": "permit-pty,permit-port-forwarding", + "default_extensions": [ + { + "permit-pty": "" + } + ], + "key_type": "ca", + "default_user": "ubuntu", + "ttl": "30m0s" +} +EOH +vault write -field=signed_key ssh-client-signer/sign/my-role public_key=@tmp/sshkey.pub >tmp/sshkey-cert.pub +vault auth enable -path=ssh vault-plugin-auth-ssh +vault write auth/ssh/config ssh_ca_public_keys=@tmp/sshca +vault write auth/ssh/role/ubuntu token_policies="ssh-policy" principals="ubuntu" +vault write auth/ssh/role/ubuntu2 token_policies="ssh-policy" public_keys=@tmp/sshkey.pub + +echo "" +echo "" +echo "You can now login with certificate via:" +echo "vault write auth/ssh/login role=ubuntu cert=@tmp/sshkey-cert.pub $(createsig/createsig tmp/sshkey)" +echo "" +echo "" +echo "You can now login with publickey via:" +echo "vault write auth/ssh/login role=ubuntu2 public_key=@tmp/sshkey.pub $(createsig/createsig tmp/sshkey)" +echo "" +echo "" +echo "run killall -9 vault-plugin-auth-ssh && killall -9 vault to kill running dev" diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..04c8140 --- /dev/null +++ b/go.mod @@ -0,0 +1,11 @@ +module github.com/42wim/vault-plugin-auth-ssh + +go 1.15 + +require ( + github.com/hashicorp/errwrap v1.1.0 + github.com/hashicorp/go-hclog v0.8.0 + github.com/hashicorp/vault/api v1.0.4 + github.com/hashicorp/vault/sdk v0.1.13 + golang.org/x/crypto v0.0.0-20201221181555-eec23a3978ad +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..e395519 --- /dev/null +++ b/go.sum @@ -0,0 +1,145 @@ +cloud.google.com/go v0.26.0/go.mod h1:aQUYkXzVsufM+DwF1aE+0xfcU+56JwCaLick0ClmMTw= +github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU= +github.com/armon/go-metrics v0.0.0-20180917152333-f0300d1749da/go.mod h1:Q73ZrmVTwzkszR9V5SSuryQ31EELlFMUz1kKyl939pY= +github.com/armon/go-radix v0.0.0-20180808171621-7fddfc383310 h1:BUAU3CGlLvorLI26FmByPp2eC2qla6E1Tw+scpcg/to= +github.com/armon/go-radix v0.0.0-20180808171621-7fddfc383310/go.mod h1:ufUuZ+zHj4x4TnLV4JWEpy2hxWSpsRywHrMgIH9cCH8= +github.com/bgentry/speakeasy v0.1.0/go.mod h1:+zsyZBPWlz7T6j88CTgSN5bM796AkVf0kBD4zp0CCIs= +github.com/client9/misspell v0.3.4/go.mod h1:qj6jICC3Q7zFZvVWo7KLAzC3yx5G7kyvSDkc90ppPyw= +github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/fatih/color v1.7.0/go.mod h1:Zm6kSWBoL9eyXnKyktHP6abPY2pDugNf5KwzbycvMj4= +github.com/fatih/structs v1.1.0 h1:Q7juDM0QtcnhCpeyLGQKyg4TOIghuNXrkL32pHAUMxo= +github.com/fatih/structs v1.1.0/go.mod h1:9NiDSp5zOcgEDl+j00MP/WkGVPOlPRLejGD8Ga6PJ7M= +github.com/go-ldap/ldap v3.0.2+incompatible/go.mod h1:qfd9rJvER9Q0/D/Sqn1DfHRoBp40uXYvFoEVrNEPqRc= +github.com/go-test/deep v1.0.2-0.20181118220953-042da051cf31 h1:28FVBuwkwowZMjbA7M0wXsI6t3PYulRTMio3SO+eKCM= +github.com/go-test/deep v1.0.2-0.20181118220953-042da051cf31/go.mod h1:wGDj63lr65AM2AQyKZd/NYHGb0R+1RLqB8NKt3aSFNA= +github.com/golang/glog v0.0.0-20160126235308-23def4e6c14b h1:VKtxabqXZkF25pY9ekfRL6a582T4P37/31XEstQ5p58= +github.com/golang/glog v0.0.0-20160126235308-23def4e6c14b/go.mod h1:SBH7ygxi8pfUlaOkMMuAQtPIUF8ecWP5IEl/CR7VP2Q= +github.com/golang/mock v1.1.1/go.mod h1:oTYuIxOrZwtPieC+H1uAHpcLFnEyAGVDL/k47Jfbm0A= +github.com/golang/protobuf v1.2.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= +github.com/golang/protobuf v1.3.1 h1:YF8+flBXS5eO826T4nzqPrxfhQThhXl0YzfuUPu4SBg= +github.com/golang/protobuf v1.3.1/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= +github.com/golang/snappy v0.0.1 h1:Qgr9rKW7uDUkrbSmQeiDsGa8SjGyCOGtuasMWwvp2P4= +github.com/golang/snappy v0.0.1/go.mod h1:/XxbfmMg8lxefKM7IXC3fBNl/7bRcc72aCRzEWrmP2Q= +github.com/google/go-cmp v0.2.0/go.mod h1:oXzfMopK8JAjlY9xF4vHSVASa0yLyX7SntLO5aqRK0M= +github.com/hashicorp/errwrap v1.0.0/go.mod h1:YH+1FKiLXxHSkmPseP+kNlulaMuP3n2brvKWEqk/Jc4= +github.com/hashicorp/errwrap v1.1.0 h1:OxrOeh75EUXMY8TBjag2fzXGZ40LB6IKw45YeGUDY2I= +github.com/hashicorp/errwrap v1.1.0/go.mod h1:YH+1FKiLXxHSkmPseP+kNlulaMuP3n2brvKWEqk/Jc4= +github.com/hashicorp/go-cleanhttp v0.5.0/go.mod h1:JpRdi6/HCYpAwUzNwuwqhbovhLtngrth3wmdIIUrZ80= +github.com/hashicorp/go-cleanhttp v0.5.1 h1:dH3aiDG9Jvb5r5+bYHsikaOUIpcM0xvgMXVoDkXMzJM= +github.com/hashicorp/go-cleanhttp v0.5.1/go.mod h1:JpRdi6/HCYpAwUzNwuwqhbovhLtngrth3wmdIIUrZ80= +github.com/hashicorp/go-hclog v0.0.0-20180709165350-ff2cf002a8dd/go.mod h1:9bjs9uLqI8l75knNv3lV1kA55veR+WUPSiKIWcQHudI= +github.com/hashicorp/go-hclog v0.8.0 h1:z3ollgGRg8RjfJH6UVBaG54R70GFd++QOkvnJH3VSBY= +github.com/hashicorp/go-hclog v0.8.0/go.mod h1:5CU+agLiy3J7N7QjHK5d05KxGsuXiQLrjA0H7acj2lQ= +github.com/hashicorp/go-immutable-radix v1.0.0 h1:AKDB1HM5PWEA7i4nhcpwOrO2byshxBjXVn/J/3+z5/0= +github.com/hashicorp/go-immutable-radix v1.0.0/go.mod h1:0y9vanUI8NX6FsYoO3zeMjhV/C5i9g4Q3DwcSNZ4P60= +github.com/hashicorp/go-multierror v1.0.0 h1:iVjPR7a6H0tWELX5NxNe7bYopibicUzc7uPribsnS6o= +github.com/hashicorp/go-multierror v1.0.0/go.mod h1:dHtQlpGsu+cZNNAkkCN/P3hoUDHhCYQXV3UM06sGGrk= +github.com/hashicorp/go-plugin v1.0.1 h1:4OtAfUGbnKC6yS48p0CtMX2oFYtzFZVv6rok3cRWgnE= +github.com/hashicorp/go-plugin v1.0.1/go.mod h1:++UyYGoz3o5w9ZzAdZxtQKrWWP+iqPBn3cQptSMzBuY= +github.com/hashicorp/go-retryablehttp v0.5.4 h1:1BZvpawXoJCWX6pNtow9+rpEj+3itIlutiqnntI6jOE= +github.com/hashicorp/go-retryablehttp v0.5.4/go.mod h1:9B5zBasrRhHXnJnui7y6sL7es7NDiJgTc6Er0maI1Xs= +github.com/hashicorp/go-rootcerts v1.0.1 h1:DMo4fmknnz0E0evoNYnV48RjWndOsmd6OW+09R3cEP8= +github.com/hashicorp/go-rootcerts v1.0.1/go.mod h1:pqUvnprVnM5bf7AOirdbb01K4ccR319Vf4pU3K5EGc8= +github.com/hashicorp/go-sockaddr v1.0.2 h1:ztczhD1jLxIRjVejw8gFomI1BQZOe2WoVOu0SyteCQc= +github.com/hashicorp/go-sockaddr v1.0.2/go.mod h1:rB4wwRAUzs07qva3c5SdrY/NEtAUjGlgmH/UkBUC97A= +github.com/hashicorp/go-uuid v1.0.0/go.mod h1:6SBZvOh/SIDV7/2o3Jml5SYk/TvGqwFJ/bN7x4byOro= +github.com/hashicorp/go-uuid v1.0.1 h1:fv1ep09latC32wFoVwnqcnKJGnMSdBanPczbHAYm1BE= +github.com/hashicorp/go-uuid v1.0.1/go.mod h1:6SBZvOh/SIDV7/2o3Jml5SYk/TvGqwFJ/bN7x4byOro= +github.com/hashicorp/go-version v1.1.0 h1:bPIoEKD27tNdebFGGxxYwcL4nepeY4j1QP23PFRGzg0= +github.com/hashicorp/go-version v1.1.0/go.mod h1:fltr4n8CU8Ke44wwGCBoEymUuxUHl09ZGVZPK5anwXA= +github.com/hashicorp/golang-lru v0.5.0/go.mod h1:/m3WP610KZHVQ1SGc6re/UDhFvYD7pJ4Ao+sR/qLZy8= +github.com/hashicorp/golang-lru v0.5.1 h1:0hERBMJE1eitiLkihrMvRVBYAkpHzc/J3QdDN+dAcgU= +github.com/hashicorp/golang-lru v0.5.1/go.mod h1:/m3WP610KZHVQ1SGc6re/UDhFvYD7pJ4Ao+sR/qLZy8= +github.com/hashicorp/hcl v1.0.0 h1:0Anlzjpi4vEasTeNFn2mLJgTSwt0+6sfsiTG8qcWGx4= +github.com/hashicorp/hcl v1.0.0/go.mod h1:E5yfLk+7swimpb2L/Alb/PJmXilQ/rhwaUYs4T20WEQ= +github.com/hashicorp/vault/api v1.0.4 h1:j08Or/wryXT4AcHj1oCbMd7IijXcKzYUGw59LGu9onU= +github.com/hashicorp/vault/api v1.0.4/go.mod h1:gDcqh3WGcR1cpF5AJz/B1UFheUEneMoIospckxBxk6Q= +github.com/hashicorp/vault/sdk v0.1.13 h1:mOEPeOhT7jl0J4AMl1E705+BcmeRs1VmKNb9F0sMLy8= +github.com/hashicorp/vault/sdk v0.1.13/go.mod h1:B+hVj7TpuQY1Y/GPbCpffmgd+tSEwvhkWnjtSYCaS2M= +github.com/hashicorp/yamux v0.0.0-20180604194846-3520598351bb/go.mod h1:+NfK9FKeTrX5uv1uIXGdwYDTeHna2qgaIlx54MXqjAM= +github.com/hashicorp/yamux v0.0.0-20181012175058-2f1d1f20f75d h1:kJCB4vdITiW1eC1vq2e6IsrXKrZit1bv/TDYFGMp4BQ= +github.com/hashicorp/yamux v0.0.0-20181012175058-2f1d1f20f75d/go.mod h1:+NfK9FKeTrX5uv1uIXGdwYDTeHna2qgaIlx54MXqjAM= +github.com/mattn/go-colorable v0.0.9/go.mod h1:9vuHe8Xs5qXnSaW/c/ABM9alt+Vo+STaOChaDxuIBZU= +github.com/mattn/go-isatty v0.0.3/go.mod h1:M+lRXTBqGeGNdLjl/ufCoiOlB5xdOkqRJdNxMWT7Zi4= +github.com/mitchellh/cli v1.0.0/go.mod h1:hNIlj7HEI86fIcpObd7a0FcrxTWetlwJDGcceTlRvqc= +github.com/mitchellh/copystructure v1.0.0/go.mod h1:SNtv71yrdKgLRyLFxmLdkAbkKEFWgYaq1OVrnRcwhnw= +github.com/mitchellh/go-homedir v1.1.0 h1:lukF9ziXFxDFPkA1vsr5zpc1XuPDn/wFntq5mG+4E0Y= +github.com/mitchellh/go-homedir v1.1.0/go.mod h1:SfyaCUpYCn1Vlf4IUYiD9fPX4A5wJrkLzIz1N1q0pr0= +github.com/mitchellh/go-testing-interface v0.0.0-20171004221916-a61a99592b77/go.mod h1:kRemZodwjscx+RGhAo8eIhFbs2+BFgRtFPeD/KE+zxI= +github.com/mitchellh/go-testing-interface v1.0.0 h1:fzU/JVNcaqHQEcVFAKeR41fkiLdIPrefOvVG1VZ96U0= +github.com/mitchellh/go-testing-interface v1.0.0/go.mod h1:kRemZodwjscx+RGhAo8eIhFbs2+BFgRtFPeD/KE+zxI= +github.com/mitchellh/go-wordwrap v1.0.0/go.mod h1:ZXFpozHsX6DPmq2I0TCekCxypsnAUbP2oI0UX1GXzOo= +github.com/mitchellh/mapstructure v1.1.2 h1:fmNYVwqnSfB9mZU6OS2O6GsXM+wcskZDuKQzvN1EDeE= +github.com/mitchellh/mapstructure v1.1.2/go.mod h1:FVVH3fgwuzCH5S8UJGiWEs2h04kUh9fWfEaFds41c1Y= +github.com/mitchellh/reflectwalk v1.0.0/go.mod h1:mSTlrgnPZtwu0c4WaC2kGObEpuNDbx0jmZXqmk4esnw= +github.com/oklog/run v1.0.0 h1:Ru7dDtJNOyC66gQ5dQmaCa0qIsAUFY3sFpK1Xk8igrw= +github.com/oklog/run v1.0.0/go.mod h1:dlhp/R75TPv97u0XWUtDeV/lRKWPKSdTuV0TZvrmrQA= +github.com/pascaldekloe/goe v0.1.0/go.mod h1:lzWF7FIEvWOWxwDKqyGYQf6ZUaNfKdP144TG7ZOy1lc= +github.com/pierrec/lz4 v2.0.5+incompatible h1:2xWsjqPFWcplujydGg4WmhC/6fZqK42wMM8aXeqhl0I= +github.com/pierrec/lz4 v2.0.5+incompatible/go.mod h1:pdkljMzZIN41W+lC3N2tnIh5sFi+IEE17M5jbnwPHcY= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/posener/complete v1.1.1/go.mod h1:em0nMJCgc9GFtwrmVmEMR/ZL6WyhyjMBndrE9hABlRI= +github.com/ryanuber/columnize v2.1.0+incompatible/go.mod h1:sm1tb6uqfes/u+d4ooFouqFdy9/2g9QGwK3SQygK0Ts= +github.com/ryanuber/go-glob v1.0.0 h1:iQh3xXAumdQ+4Ufa5b25cRpC5TYKlno6hsv6Cb3pkBk= +github.com/ryanuber/go-glob v1.0.0/go.mod h1:807d1WSdnB0XRJzKNil9Om6lcp/3a0v4qIHxIXzX/Yc= +github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs= +github.com/stretchr/testify v1.3.0 h1:TivCn/peBQ7UY8ooIcPgZFpTNSz0Q2U6UrFlUfqbe0Q= +github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= +golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= +golang.org/x/crypto v0.0.0-20201221181555-eec23a3978ad h1:DN0cp81fZ3njFcrLCytUHRSUkqBjfTo4Tx9RJTWs0EY= +golang.org/x/crypto v0.0.0-20201221181555-eec23a3978ad/go.mod h1:jdWPYTVW3xRLrWPugEBEK3UY2ZEsg3UU495nc5E+M+I= +golang.org/x/exp v0.0.0-20190121172915-509febef88a4/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA= +golang.org/x/lint v0.0.0-20181026193005-c67002cb31c3/go.mod h1:UVdnD1Gm6xHRNCYTkRU2/jEulfH38KcIWyp/GAMgvoE= +golang.org/x/lint v0.0.0-20190227174305-5b3e6a55c961/go.mod h1:wehouNa3lNwaWXcvxsM5YxQ5yQlVC4a0KAMCusXpPoU= +golang.org/x/lint v0.0.0-20190313153728-d0100b6bd8b3/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc= +golang.org/x/net v0.0.0-20180724234803-3673e40ba225/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= +golang.org/x/net v0.0.0-20180826012351-8a410e7b638d/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= +golang.org/x/net v0.0.0-20190213061140-3a22650c66bd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= +golang.org/x/net v0.0.0-20190311183353-d8887717615a/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= +golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3 h1:0GoQqolDA55aaLxZyTzK/Y2ePZzZTUrRacwib7cNsYQ= +golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= +golang.org/x/net v0.0.0-20190620200207-3b0461eec859 h1:R/3boaszxrf1GEUWTVDzSKVwLmSJpwZ1yqXm8j0v2QI= +golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= +golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U= +golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20181108010431-42b317875d0f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20181221193216-37e7f081c4d4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20190227155943-e225da77a7e6/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sys v0.0.0-20180823144017-11551d06cbcc/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20180830151530-49385e6e1522/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20190129075346-302c3dd5f1cc/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20190403152447-81d4e9dc473e/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20191026070338-33540a1f6037 h1:YyJpGZS1sBuBCzLAR1VEpK193GlqGZbnPFnPV/5Rsb4= +golang.org/x/sys v0.0.0-20191026070338-33540a1f6037/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/term v0.0.0-20201117132131-f5c789dd3221 h1:/ZHdbVpdR/jk3g30/d4yUL0JU9kksj8+F/bnQUVLGDM= +golang.org/x/term v0.0.0-20201117132131-f5c789dd3221/go.mod h1:Nr5EML6q2oocZ2LXRh80K7BxOlk5/8JxuGnuhpl+muw= +golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= +golang.org/x/text v0.3.1-0.20181227161524-e6919f6577db h1:6/JqlYfC1CCaLnGceQTI+sDGhC9UBSPAsBqI0Gun6kU= +golang.org/x/text v0.3.1-0.20181227161524-e6919f6577db/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk= +golang.org/x/time v0.0.0-20190308202827-9d24e82272b4 h1:SvFZT6jyqRaOeXpc5h/JSfZenJ2O330aBsf7JfSUXmQ= +golang.org/x/time v0.0.0-20190308202827-9d24e82272b4/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= +golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= +golang.org/x/tools v0.0.0-20190114222345-bf090417da8b/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= +golang.org/x/tools v0.0.0-20190226205152-f727befe758c/go.mod h1:9Yl7xja0Znq3iFh3HoIrodX9oNMXvdceNzlUR8zjMvY= +golang.org/x/tools v0.0.0-20190311212946-11955173bddd/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs= +golang.org/x/tools v0.0.0-20190524140312-2c0ae7006135/go.mod h1:RgjU9mgBXZiqYHBnxXauZ1Gv1EHHAz9KjViQ78xBX0Q= +google.golang.org/appengine v1.1.0/go.mod h1:EbEs0AVv82hx2wNQdGPgUI5lhzA/G0D9YwlJXL52JkM= +google.golang.org/appengine v1.4.0/go.mod h1:xpcJRLb0r/rnEns0DIKYYv+WjYCduHsrkT7/EB5XEv4= +google.golang.org/genproto v0.0.0-20180817151627-c66870c02cf8/go.mod h1:JiN7NxoALGmiZfu7CAH4rXhgtRTLTxftemlI0sWmxmc= +google.golang.org/genproto v0.0.0-20190404172233-64821d5d2107 h1:xtNn7qFlagY2mQNFHMSRPjT2RkOV4OXM7P5TVy9xATo= +google.golang.org/genproto v0.0.0-20190404172233-64821d5d2107/go.mod h1:VzzqZJRnGkLBvHegQrXjBqPurQTc5/KpmUdxsrq26oE= +google.golang.org/grpc v1.14.0/go.mod h1:yo6s7OP7yaDglbqo1J04qKzAhqBH6lvTonzMVmEdcZw= +google.golang.org/grpc v1.19.0/go.mod h1:mqu4LbDTu4XGKhr4mRzUsmM4RtVoemTSY81AxZiDr8c= +google.golang.org/grpc v1.22.0 h1:J0UbZOIrCAl+fpTOf8YLs4dJo8L/owV4LYVtAXQoPkw= +google.golang.org/grpc v1.22.0/go.mod h1:Y5yQAOtifL1yxbo5wqy6BxZv8vAUGQwXBOALyacEbxg= +gopkg.in/asn1-ber.v1 v1.0.0-20181015200546-f715ec2f112d/go.mod h1:cuepJuh7vyXfUyUwEgHQXw849cJrilpS5NeIjOWESAw= +gopkg.in/square/go-jose.v2 v2.3.1 h1:SK5KegNXmKmqE342YYN2qPHEnUYeoMiXXl1poUlI+o4= +gopkg.in/square/go-jose.v2 v2.3.1/go.mod h1:M9dMgbHiYLoDGQrXy7OpJDJWiKiU//h+vD76mk0e1AI= +honnef.co/go/tools v0.0.0-20190102054323-c2f93a96b099/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4= +honnef.co/go/tools v0.0.0-20190523083050-ea95bdfd59fc/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4= diff --git a/path_config.go b/path_config.go new file mode 100644 index 0000000..4d8e530 --- /dev/null +++ b/path_config.go @@ -0,0 +1,100 @@ +package sshauth + +import ( + "context" + + "github.com/hashicorp/vault/sdk/framework" + "github.com/hashicorp/vault/sdk/logical" +) + +func (b *backend) pathConfig() *framework.Path { + return &framework.Path{ + Pattern: `config`, + Fields: map[string]*framework.FieldSchema{ + "ssh_ca_public_keys": { + Type: framework.TypeCommaStringSlice, + Description: `SSH CA public keys where ssh certificates are checked against.`, + }, + }, + + Operations: map[logical.Operation]framework.OperationHandler{ + logical.ReadOperation: &framework.PathOperation{ + Callback: b.pathConfigRead, + Summary: "Read the current SSH authentication backend configuration.", + }, + + logical.UpdateOperation: &framework.PathOperation{ + Callback: b.pathConfigWrite, + Summary: "Configure the SSH authentication backend.", + Description: confHelpDesc, + }, + }, + + HelpSynopsis: confHelpSyn, + HelpDescription: confHelpDesc, + } +} + +func (b *backend) config(ctx context.Context, s logical.Storage) (*ConfigEntry, error) { + entry, err := s.Get(ctx, "config") + if err != nil { + return nil, err + } + + if entry == nil { + return nil, nil + } + + config := &ConfigEntry{} + + if err := entry.DecodeJSON(config); err != nil { + return nil, err + } + + return config, nil +} + +func (b *backend) pathConfigRead(ctx context.Context, req *logical.Request, d *framework.FieldData) (*logical.Response, error) { + config, err := b.config(ctx, req.Storage) + if err != nil { + return nil, err + } + + if config == nil { + return nil, nil + } + + resp := &logical.Response{ + Data: map[string]interface{}{ + "ssh_ca_public_keys": config.SSHCAPublicKeys, + }, + } + + return resp, nil +} + +func (b *backend) pathConfigWrite(ctx context.Context, req *logical.Request, d *framework.FieldData) (*logical.Response, error) { + config := &ConfigEntry{ + SSHCAPublicKeys: d.Get("ssh_ca_public_keys").([]string), + } + + entry, err := logical.StorageEntryJSON("config", config) + if err != nil { + return nil, err + } + + if err := req.Storage.Put(ctx, entry); err != nil { + return nil, err + } + + return nil, nil +} + +const ( + confHelpSyn = ` +Configures the SSH authentication backend. +` + confHelpDesc = ` +The SSH authentication backend validates ssh certificates and public keys. +` +) diff --git a/path_login.go b/path_login.go new file mode 100644 index 0000000..7302726 --- /dev/null +++ b/path_login.go @@ -0,0 +1,179 @@ +package sshauth + +import ( + "context" + "encoding/base64" + "fmt" + + "github.com/hashicorp/vault/sdk/framework" + "github.com/hashicorp/vault/sdk/helper/cidrutil" + "github.com/hashicorp/vault/sdk/logical" +) + +func (b *backend) pathLogin() *framework.Path { + return &framework.Path{ + Pattern: "login$", + Fields: map[string]*framework.FieldSchema{ + "role": { + Type: framework.TypeString, + Description: "role to use (name must exist in the certificate principal)", + }, + "cert": { + Type: framework.TypeString, + Description: "SSH certificate (base64 encoded)", + }, + "public_key": { + Type: framework.TypeString, + Description: "SSH public key (base64 encoded)", + }, + "signature": { + Type: framework.TypeString, + Description: "Signature over the nonce (base64 encoded)", + }, + "nonce": { + Type: framework.TypeString, + Description: "Nonce (base64 encoded)", + }, + }, + Operations: map[logical.Operation]framework.OperationHandler{ + logical.UpdateOperation: &framework.PathOperation{ + Callback: b.handleLogin, + Summary: "Log in using ssh certificates", + }, + logical.AliasLookaheadOperation: &framework.PathOperation{ + Callback: b.handleLogin, + }, + }, + } +} + +func (b *backend) pathLoginAliasLookahead(ctx context.Context, req *logical.Request, d *framework.FieldData) (*logical.Response, error) { + role := d.Get("role").(string) + if role == "" { + return nil, fmt.Errorf("missing role") + } + + return &logical.Response{ + Auth: &logical.Auth{ + Alias: &logical.Alias{ + Name: role, + }, + }, + }, nil +} + +func (b *backend) handleLogin(ctx context.Context, req *logical.Request, data *framework.FieldData) (*logical.Response, error) { + config, err := b.config(ctx, req.Storage) + if err != nil { + return nil, err + } + + if config == nil { + return logical.ErrorResponse("could not load configuration"), nil + } + + roleName := data.Get("role").(string) + if roleName == "" { + return logical.ErrorResponse("role must be provided"), nil + } + + role, err := b.role(ctx, req.Storage, roleName) + if err != nil { + return nil, err + } + + if role == nil { + return logical.ErrorResponse("role %q could not be found", roleName), nil + } + + principals := []string{roleName} + + // if we have explicit principals we must check those + if len(role.Principals) > 0 { + principals = role.Principals + } + + if len(role.TokenBoundCIDRs) > 0 { + if req.Connection == nil { + b.Logger().Warn("token bound CIDRs found but no connection information available for validation") + return nil, logical.ErrPermissionDenied + } + + if !cidrutil.RemoteAddrIsOk(req.Connection.RemoteAddr, role.TokenBoundCIDRs) { + return nil, logical.ErrPermissionDenied + } + } + + signature := data.Get("signature").(string) + if signature == "" { + return logical.ErrorResponse("signature must be provided"), nil + } + + cert := data.Get("cert").(string) + + pubkey := data.Get("public_key").(string) + + if pubkey == "" && cert == "" { + return logical.ErrorResponse("cert or pubkey must be provided"), nil + } + + nonce := data.Get("nonce").(string) + if nonce == "" { + return logical.ErrorResponse("nonce must be provided"), nil + } + + sigDecode, err := base64.StdEncoding.DecodeString(signature) + if err != nil { + return logical.ErrorResponse("decoding signature failed"), nil + } + + nonceDecode, err := base64.StdEncoding.DecodeString(nonce) + if err != nil { + return logical.ErrorResponse("decoding nonce failed"), nil + } + + toParseKey := cert + if toParseKey == "" { + toParseKey = pubkey + } + + pk, err := parsePubkey(toParseKey) + if err != nil { + return logical.ErrorResponse("decoding public_key/cert failed: " + err.Error()), err + } + + if err := verifySignature(pk, nonceDecode, sigDecode); err != nil { + return logical.ErrorResponse(err.Error()), err + } + + if cert != "" { + if err := validateCert(pk, config, principals); err != nil { + return logical.ErrorResponse("validation cert failed: " + err.Error()), err + } + } else { + if err := validatePubkey(pubkey, role); err != nil { + return logical.ErrorResponse("validation public_key failed: " + err.Error()), err + } + } + + // Compose the response + resp := &logical.Response{} + auth := &logical.Auth{ + InternalData: map[string]interface{}{ + "role": roleName, + }, + Metadata: map[string]string{ + "role": roleName, + }, + DisplayName: roleName, + Alias: &logical.Alias{ + Name: roleName, + }, + } + + role.PopulateTokenAuth(auth) + + resp.Auth = auth + + return resp, nil +} diff --git a/path_role.go b/path_role.go new file mode 100644 index 0000000..8793b70 --- /dev/null +++ b/path_role.go @@ -0,0 +1,263 @@ +package sshauth + +import ( + "context" + "errors" + "fmt" + "strings" + + "github.com/hashicorp/vault/sdk/framework" + "github.com/hashicorp/vault/sdk/helper/tokenutil" + "github.com/hashicorp/vault/sdk/logical" + "golang.org/x/crypto/ssh" +) + +func (b *backend) pathRoleList() *framework.Path { + return &framework.Path{ + Pattern: "role/?", + Operations: map[logical.Operation]framework.OperationHandler{ + logical.ListOperation: &framework.PathOperation{ + Callback: b.doPathRoleList, + Summary: strings.TrimSpace(roleHelp["role-list"][0]), + Description: strings.TrimSpace(roleHelp["role-list"][1]), + }, + }, + HelpSynopsis: strings.TrimSpace(roleHelp["role-list"][0]), + HelpDescription: strings.TrimSpace(roleHelp["role-list"][1]), + } +} + +// pathRole returns the path configurations for the CRUD operations on roles +func (b *backend) pathRole() *framework.Path { + p := &framework.Path{ + Pattern: "role/" + framework.GenericNameRegex("name"), + Fields: map[string]*framework.FieldSchema{ + "name": { + Type: framework.TypeLowerCaseString, + Description: "Name of the role.", + }, + "public_keys": { + Type: framework.TypeCommaStringSlice, + Description: "Public keys allowed for this role.", + }, + "principals": { + Type: framework.TypeCommaStringSlice, + Description: "Principals allowed for this role.", + }, + }, + ExistenceCheck: b.pathRoleExistenceCheck, + Operations: map[logical.Operation]framework.OperationHandler{ + logical.ReadOperation: &framework.PathOperation{ + Callback: b.pathRoleRead, + Summary: "Read an existing role.", + }, + + logical.UpdateOperation: &framework.PathOperation{ + Callback: b.pathRoleCreateUpdate, + Summary: strings.TrimSpace(roleHelp["role"][0]), + Description: strings.TrimSpace(roleHelp["role"][1]), + }, + + logical.CreateOperation: &framework.PathOperation{ + Callback: b.pathRoleCreateUpdate, + Summary: strings.TrimSpace(roleHelp["role"][0]), + Description: strings.TrimSpace(roleHelp["role"][1]), + }, + + logical.DeleteOperation: &framework.PathOperation{ + Callback: b.pathRoleDelete, + Summary: "Delete an existing role.", + }, + }, + HelpSynopsis: strings.TrimSpace(roleHelp["role"][0]), + HelpDescription: strings.TrimSpace(roleHelp["role"][1]), + } + + tokenutil.AddTokenFields(p.Fields) + + return p +} + +type sshRole struct { + tokenutil.TokenParams + + PublicKeys []string `json:"public_keys"` + Principals []string `json:"principals"` +} + +// role takes a storage backend and the name and returns the role's storage +// entry +func (b *backend) role(ctx context.Context, s logical.Storage, name string) (*sshRole, error) { + raw, err := s.Get(ctx, rolePrefix+name) + if err != nil { + return nil, err + } + + if raw == nil { + return nil, nil + } + + role := new(sshRole) + if err := raw.DecodeJSON(role); err != nil { + return nil, err + } + + return role, nil +} + +// pathRoleExistenceCheck returns whether the role with the given name exists or not. +func (b *backend) pathRoleExistenceCheck(ctx context.Context, req *logical.Request, data *framework.FieldData) (bool, error) { + role, err := b.role(ctx, req.Storage, data.Get("name").(string)) + if err != nil { + return false, err + } + + return role != nil, nil +} + +// pathRoleList is used to list all the Roles registered with the backend. +func (b *backend) doPathRoleList(ctx context.Context, req *logical.Request, data *framework.FieldData) (*logical.Response, error) { + roles, err := req.Storage.List(ctx, rolePrefix) + if err != nil { + return nil, err + } + + return logical.ListResponse(roles), nil +} + +// pathRoleRead grabs a read lock and reads the options set on the role from the storage +func (b *backend) pathRoleRead(ctx context.Context, req *logical.Request, data *framework.FieldData) (*logical.Response, error) { + roleName := data.Get("name").(string) + if roleName == "" { + return logical.ErrorResponse("missing name"), nil + } + + role, err := b.role(ctx, req.Storage, roleName) + if err != nil { + return nil, err + } + + if role == nil { + return nil, nil + } + + // Create a map of data to be returned + d := map[string]interface{}{ + "principals": role.Principals, + "public_keys": role.PublicKeys, + } + + role.PopulateTokenData(d) + + return &logical.Response{ + Data: d, + }, nil +} + +// pathRoleDelete removes the role from storage +func (b *backend) pathRoleDelete(ctx context.Context, req *logical.Request, data *framework.FieldData) (*logical.Response, error) { + roleName := data.Get("name").(string) + if roleName == "" { + return logical.ErrorResponse("role name required"), nil + } + + // Delete the role itself + if err := req.Storage.Delete(ctx, rolePrefix+roleName); err != nil { + return nil, err + } + + return nil, nil +} + +// pathRoleCreateUpdate registers a new role with the backend or updates the options +// of an existing role +func (b *backend) pathRoleCreateUpdate(ctx context.Context, req *logical.Request, data *framework.FieldData) (*logical.Response, error) { + roleName := data.Get("name").(string) + if roleName == "" { + return logical.ErrorResponse("missing role name"), nil + } + + // Check if the role already exists + role, err := b.role(ctx, req.Storage, roleName) + if err != nil { + return nil, err + } + + // Create a new entry object if this is a CreateOperation + if role == nil { + if req.Operation == logical.UpdateOperation { + return nil, errors.New("role entry not found during update operation") + } + + role = new(sshRole) + } + + if publicKeys, ok := data.GetOk("public_keys"); ok { + role.PublicKeys = publicKeys.([]string) + } + + for idx, key := range role.PublicKeys { + certParsed, _, _, _, err := ssh.ParseAuthorizedKey([]byte(key)) + if err != nil { + return nil, fmt.Errorf("public_keys parsing failed: %s", err) + } + + role.PublicKeys[idx] = strings.TrimRight(string(ssh.MarshalAuthorizedKey(certParsed)), "\n") + } + + if principals, ok := data.GetOk("principals"); ok { + role.Principals = principals.([]string) + } + + if len(role.Principals) > 0 && len(role.PublicKeys) > 0 { + return nil, errors.New("public_keys and principals option are mutually exclusive") + } + + if err = role.ParseTokenFields(req, data); err != nil { + return logical.ErrorResponse(err.Error()), logical.ErrInvalidRequest + } + + if role.TokenPeriod > b.System().MaxLeaseTTL() { + return logical.ErrorResponse(fmt.Sprintf("'period' of '%q' is greater than the backend's maximum lease TTL of '%q'", role.TokenPeriod.String(), b.System().MaxLeaseTTL().String())), nil + } + + // Check that the TTL value provided is less than the MaxTTL. + // Sanitizing the TTL and MaxTTL is not required now and can be performed + // at credential issue time. + if role.TokenMaxTTL > 0 && role.TokenTTL > role.TokenMaxTTL { + return logical.ErrorResponse("ttl should not be greater than max ttl"), nil + } + + resp := &logical.Response{} + + if role.TokenMaxTTL > b.System().MaxLeaseTTL() { + resp.AddWarning("token max ttl is greater than the system or backend mount's maximum TTL value; issued tokens' max TTL value will be truncated") + } + + // Store the entry. + entry, err := logical.StorageEntryJSON(rolePrefix+roleName, role) + if err != nil { + return nil, err + } + + if err = req.Storage.Put(ctx, entry); err != nil { + return nil, err + } + + return resp, nil +} + +// roleStorageEntry stores all the options that are set on an role +var roleHelp = map[string][2]string{ + "role-list": { + "Lists all the roles registered with the backend.", + "The list will contain the names of the roles.", + }, + "role": { + "Register an role with the backend.", + `A role is required to authenticate with this backend. The role binds + ssh information with token policies and settings. + The bindings, token polices and token settings can all be configured + using this endpoint`, + }, +} diff --git a/ssh.go b/ssh.go new file mode 100644 index 0000000..369d0b5 --- /dev/null +++ b/ssh.go @@ -0,0 +1,146 @@ +package sshauth + +import ( + "bytes" + "crypto" + "crypto/ed25519" + "crypto/rsa" + "errors" + "fmt" + "strings" + + "github.com/hashicorp/vault/sdk/logical" + "golang.org/x/crypto/ssh" +) + +func validatePubkey(pubkey string, role *sshRole) error { + found := false + + pkParsed, _, _, _, err := ssh.ParseAuthorizedKey([]byte(pubkey)) + if err != nil { + return fmt.Errorf("key parsing failed: %s", err) + } + + pk := strings.TrimRight(string(ssh.MarshalAuthorizedKey(pkParsed)), "\n") + + for _, key := range role.PublicKeys { + if key != pk { + + continue + } + + found = true + + break + } + + if !found { + return logical.ErrPermissionDenied + } + + return nil +} + +func validateCert(pubkey ssh.PublicKey, config *ConfigEntry, principals []string) error { + cert, ok := pubkey.(*ssh.Certificate) + if !ok { + return errors.New("not a certificate") + } + + c := &ssh.CertChecker{ + IsUserAuthority: func(auth ssh.PublicKey) bool { + for _, caKey := range config.SSHCAPublicKeys { + pubKey, _, _, _, err := ssh.ParseAuthorizedKey([]byte(caKey)) + if err != nil { + return false + } + + if bytes.Equal(auth.Marshal(), pubKey.Marshal()) { + return true + } + } + + return false + }, + } + + // check the CA of the cert + if !c.IsUserAuthority(cert.SignatureKey) { + return errors.New("CA doesn't match") + } + + principal := "" + + for _, p := range principals { + for _, vp := range cert.ValidPrincipals { + if p == vp { + principal = p + + break + } + } + } + + if principal == "" { + return errors.New("no matching principal found") + } + + // check cert validity + if err := c.CheckCert(principal, cert); err != nil { + return fmt.Errorf("certificate validation failed: %s", err) + } + + return nil +} + +func parsePubkey(pubkey string) (ssh.PublicKey, error) { + certParsed, _, _, _, err := ssh.ParseAuthorizedKey([]byte(pubkey)) + if err != nil { + return nil, fmt.Errorf("parsing failed %s", err) + } + + parsedPubkey, err := ssh.ParsePublicKey(certParsed.Marshal()) + if err != nil { + return nil, fmt.Errorf("pubkey parsing failed %s", err) + } + + return parsedPubkey, nil +} + +func verifySignature(pubkey ssh.PublicKey, nonce, signature []byte) error { + if cert, ok := pubkey.(*ssh.Certificate); ok { + pubkey = cert.Key + } + + cryptoPubkey := pubkey.(ssh.CryptoPublicKey).CryptoPublicKey() + + switch key := cryptoPubkey.(type) { + case ed25519.PublicKey: + if !verifyEd25519(key, nonce, signature) { + return errors.New("signature verification failed") + } + case *rsa.PublicKey: + if !verifyRSA(key, nonce, signature) { + return errors.New("signature verification failed") + } + default: + return fmt.Errorf("invalid type %#v not supported", key) + } + + return nil +} + +func verifyEd25519(key ed25519.PublicKey, nonce, signature []byte) bool { + return ed25519.Verify(key, nonce, signature) +} + +func verifyRSA(key *rsa.PublicKey, nonce, signature []byte) bool { + hash := crypto.SHA1 + h := hash.New() + + h.Write(nonce) + + digest := h.Sum(nil) + + return rsa.VerifyPKCS1v15(key, hash, digest, signature) == nil +}