├── .github └── workflows │ └── go.yml ├── .gitignore ├── LICENSE ├── Makefile ├── README.md ├── debian ├── changelog ├── compat ├── control ├── pam-ussh.install └── rules ├── pam.go ├── pam_c.go ├── pam_darwin.go ├── pam_linux.go ├── pam_ussh.go └── pam_ussh_test.go /.github/workflows/go.yml: -------------------------------------------------------------------------------- 1 | name: CI 2 | 3 | on: 4 | push: 5 | branches: [ master ] 6 | pull_request: 7 | branches: [ master ] 8 | 9 | jobs: 10 | 11 | build: 12 | runs-on: ubuntu-latest 13 | env: 14 | GO111MODULE: auto 15 | steps: 16 | - uses: actions/checkout@v2 17 | 18 | - name: Install required packages 19 | run: sudo apt-get install -y libpam0g-dev 20 | 21 | - name: Set up Go 22 | uses: actions/setup-go@v2 23 | with: 24 | go-version: 1.17 25 | 26 | - name: Build & Test 27 | run: make 28 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .go 2 | 3 | # Ignore build artifacts 4 | *.so 5 | *.h 6 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright (c) 2017 Uber Technologies, Inc. 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 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | MODULE := pam_ussh 2 | NEED_SYMLINK := $(shell if ! stat -q .go/src/pam-ussh 2>&1 > /dev/null ; then echo "yes" ; fi) 3 | 4 | module: test 5 | GOPATH=${PWD}/.go go build -buildmode=c-shared -o ${MODULE}.so 6 | 7 | test: *.go .go/src 8 | GOPATH=${PWD}/.go go test -cover 9 | 10 | .go/src: 11 | -mkdir -p ${PWD}/.go/src 12 | ifeq ($(NEED_SYMLINK),yes) 13 | ln -s ${PWD} ${PWD}/.go/src/pam-ussh 14 | endif 15 | GOPATH=${PWD}/.go go get golang.org/x/crypto/ssh 16 | GOPATH=${PWD}/.go go get golang.org/x/crypto/ssh/agent 17 | GOPATH=${PWD}/.go go get github.com/stretchr/testify/require 18 | 19 | clean: 20 | go clean 21 | -rm -f ${MODULE}.so ${MODULE}.h 22 | -rm -rf .go/ 23 | 24 | .PHONY: test module download_deps clean 25 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | Uber's SSH certificate pam module. 2 | 3 | This is a pam module that will authenticate a user based on them having an ssh certificate in 4 | their ssh-agent signed by a specified ssh CA. 5 | 6 | This is primarily intended as an authentication module for sudo. Using it for something else 7 | may be unsafe (we haven't tested it anyway). We'd be happy to learn of other potential uses though. 8 | 9 | An example usage would be you ssh to a remote machine and sshd authenticates you (probably 10 | using your ssh cert, because if you're using it for this, you're probably using it for sshd 11 | as well). At that point when you want to run a command that requires authentication (eg. 12 | `sudo`), you can use pam-ussh for authentication. 13 | 14 | Works on linux and osx. BSD doesn't work because go doesn't (yet) support `buildmode=c-shared` 15 | on bsd. 16 | 17 | Building: 18 | 19 | 1. clone the repo and run 'make' 20 | ``` 21 | $ git clone github.com/uber/pam-ussh 22 | 23 | ... 24 | 25 | $ make 26 | mkdir -p /home/pmoody/tmp/pam-ussh/.go/src 27 | GOPATH=/home/pmoody/tmp/pam-ussh/.go go get golang.org/x/crypto/ssh 28 | GOPATH=/home/pmoody/tmp/pam-ussh/.go go get golang.org/x/crypto/ssh/agent 29 | GOPATH=/home/pmoody/tmp/pam-ussh/.go go get github.com/stretchr/testify/require 30 | GOPATH=/home/pmoody/tmp/pam-ussh/.go go test -cover 31 | PASS 32 | coverage: 71.8% of statements 33 | ok _/home/pmoody/tmp/pam-ussh 0.205s 34 | GOPATH=/home/pmoody/tmp/pam-ussh/.go go build -buildmode=c-shared -o pam_ussh.so 35 | 36 | $ 37 | ``` 38 | 39 | Usage: 40 | 41 | 1. put this pam module where ever pam modules live on your system, eg. `/lib/security` 42 | 43 | 2. add it as an authentication method, eg. 44 | 45 | ``` 46 | $ grep auth /etc/pam.d/sudo 47 | auth [success=1 default=ignore] /lib/security/pam_ussh.so 48 | auth requisite pam_deny.so 49 | auth required pam_permit.so 50 | ``` 51 | 52 | 3. make sure your SSH_AUTH_SOCK is available where you want to use this (eg. ssh -A user@host) 53 | 54 | Runtime configuration options: 55 | * `ca_file` - string, the path to your TrustedUserCAKeys file, default `/etc/ssh/trusted_user_ca`. 56 | This is the pubkey that signs your user certificates. 57 | 58 | * `authorized_principals` - string, comma separated list of authorized principals, default `""`. 59 | If set, the user needs to have a principal in this list in order to use this module. If 60 | this and `authorized_principals_file` are both set, only the last option listed is checked. 61 | 62 | * `authorized_principals_file` - string, path to an authorized_principals file, default `""`. 63 | If set, users need to have a principal listed in this file in order to use this module. 64 | If this and `authorized_principals` are both set, only the last option listed is checked. 65 | 66 | * `group` - string, default, `""` 67 | If set, the user needs to be a member of this group in order to use this module. 68 | 69 | 70 | Example configuration: 71 | 72 | the following looks for a certificate on $SSH_AUTH_SOCK that have been signed by user_ca. Additionally, 73 | the user needs to have a principal on the certificate that's listed in /etc/ssh/root_authorized_principals 74 | 75 | ``` 76 | auth [success=1 default=ignore] /lib/security/pam_ussh.so ca_file=/etc/ssh/user_ca authorized_principals_file=/etc/ssh/root_authorized_principals 77 | ``` 78 | 79 | FAQ: 80 | 81 | * How do I report a security issue? 82 | - Please report security issues at the [hackerone bug bounty page](https://hackerone.com/uber) and the bugbounty folks will determine bounty eligibility 83 | 84 | * does this work with non-certificate ssh-keys? 85 | - No, not at the moment. 86 | - There's no reason it can't though, we just didn't need it to do that so I never added the functionality 87 | 88 | * why aren't you using $DEP_SYSTEM? 89 | - We didn't need to so we didn't bother 90 | 91 | * can you make it do $X? 92 | - Submit a feature request, or better yet a pull request 93 | 94 | 95 | Information on ssh certificates: 96 | * http://cvsweb.openbsd.org/cgi-bin/cvsweb/src/usr.bin/ssh/PROTOCOL.certkeys?rev=HEAD 97 | * https://blog.habets.se/2011/07/OpenSSH-certificates.html 98 | -------------------------------------------------------------------------------- /debian/changelog: -------------------------------------------------------------------------------- 1 | pam-ussh (1.0.1) all; urgency=low 2 | 3 | * fix security issue reported by Solar Designer. 4 | 5 | -- Peter Moody Fri, 10 Feb 2017 01:23:45 +0000 6 | 7 | pam-ussh (1.0) all; urgency=low 8 | 9 | * Initial release. 10 | 11 | -- Peter Moody Fri, 19 Aug 2016 01:23:45 +0600 12 | -------------------------------------------------------------------------------- /debian/compat: -------------------------------------------------------------------------------- 1 | 9 -------------------------------------------------------------------------------- /debian/control: -------------------------------------------------------------------------------- 1 | Source: pam-ussh 2 | Section: admin 3 | Maintainer: Peter Moody 4 | Build-Depends: debhelper (>= 9), golang (>= 1.6.2), git, libpam0g-dev (>= 1.1.3-7) 5 | Standards-Version: 3.9.6 6 | Homepage: https://github.com/uber/pam-ussh 7 | 8 | Package: pam-ussh 9 | Architecture: all 10 | Depends: libpam0g (>= 1.1.3-7ubuntu2.3) 11 | Description: pam module for ussh. 12 | Uber's SSH certificate pam module. 13 | -------------------------------------------------------------------------------- /debian/pam-ussh.install: -------------------------------------------------------------------------------- 1 | pam_ussh.so lib/security 2 | -------------------------------------------------------------------------------- /debian/rules: -------------------------------------------------------------------------------- 1 | #!/usr/bin/make -f 2 | 3 | %: 4 | dh $@ 5 | 6 | override_dh_usrlocal: 7 | -------------------------------------------------------------------------------- /pam.go: -------------------------------------------------------------------------------- 1 | // +build darwin linux 2 | 3 | /* 4 | Copyright (c) 2017 Uber Technologies, Inc. 5 | 6 | Permission is hereby granted, free of charge, to any person obtaining a copy 7 | of this software and associated documentation files (the "Software"), to deal 8 | in the Software without restriction, including without limitation the rights 9 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 10 | copies of the Software, and to permit persons to whom the Software is 11 | furnished to do so, subject to the following conditions: 12 | 13 | The above copyright notice and this permission notice shall be included in 14 | all copies or substantial portions of the Software. 15 | 16 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 17 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 18 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 19 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 20 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 21 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 22 | THE SOFTWARE. 23 | */ 24 | 25 | package main 26 | 27 | // code in here can't be tested because it relies on cgo. :( 28 | 29 | import ( 30 | "os" 31 | "unsafe" 32 | ) 33 | 34 | /* 35 | #cgo LDFLAGS: -lpam -fPIC 36 | #include 37 | #include 38 | 39 | char *string_from_argv(int, char**); 40 | char *get_user(pam_handle_t *pamh); 41 | int get_uid(char *user); 42 | */ 43 | import "C" 44 | 45 | func init() { 46 | if !disablePtrace() { 47 | pamLog("unable to disable ptrace") 48 | } 49 | } 50 | 51 | func sliceFromArgv(argc C.int, argv **C.char) []string { 52 | r := make([]string, 0, argc) 53 | for i := 0; i < int(argc); i++ { 54 | s := C.string_from_argv(C.int(i), argv) 55 | defer C.free(unsafe.Pointer(s)) 56 | r = append(r, C.GoString(s)) 57 | } 58 | return r 59 | } 60 | 61 | //export pam_sm_authenticate 62 | func pam_sm_authenticate(pamh *C.pam_handle_t, flags, argc C.int, argv **C.char) C.int { 63 | cUsername := C.get_user(pamh) 64 | if cUsername == nil { 65 | return C.PAM_USER_UNKNOWN 66 | } 67 | defer C.free(unsafe.Pointer(cUsername)) 68 | 69 | uid := int(C.get_uid(cUsername)) 70 | if uid < 0 { 71 | return C.PAM_USER_UNKNOWN 72 | } 73 | 74 | r := pamAuthenticate(os.Stderr, uid, C.GoString(cUsername), sliceFromArgv(argc, argv)) 75 | if r == AuthError { 76 | return C.PAM_AUTH_ERR 77 | } 78 | return C.PAM_SUCCESS 79 | } 80 | 81 | //export pam_sm_setcred 82 | func pam_sm_setcred(pamh *C.pam_handle_t, flags, argc C.int, argv **C.char) C.int { 83 | return C.PAM_IGNORE 84 | } 85 | -------------------------------------------------------------------------------- /pam_c.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | /* 4 | Copyright (c) 2017 Uber Technologies, Inc. 5 | 6 | Permission is hereby granted, free of charge, to any person obtaining a copy 7 | of this software and associated documentation files (the "Software"), to deal 8 | in the Software without restriction, including without limitation the rights 9 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 10 | copies of the Software, and to permit persons to whom the Software is 11 | furnished to do so, subject to the following conditions: 12 | 13 | The above copyright notice and this permission notice shall be included in 14 | all copies or substantial portions of the Software. 15 | 16 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 17 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 18 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 19 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 20 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 21 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 22 | THE SOFTWARE. 23 | */ 24 | 25 | /* 26 | #include 27 | #include 28 | #include 29 | #include 30 | #include 31 | #include 32 | #include 33 | #include 34 | #include 35 | 36 | #ifdef __APPLE__ 37 | #include 38 | #elif __linux__ 39 | #include 40 | #endif 41 | 42 | char *string_from_argv(int i, char **argv) { 43 | return strdup(argv[i]); 44 | } 45 | 46 | // get_user pulls the username out of the pam handle. 47 | char *get_user(pam_handle_t *pamh) { 48 | if (!pamh) 49 | return NULL; 50 | 51 | int pam_err = 0; 52 | const char *user; 53 | if ((pam_err = pam_get_item(pamh, PAM_USER, (const void**)&user)) != PAM_SUCCESS) 54 | return NULL; 55 | 56 | return strdup(user); 57 | } 58 | 59 | // owner_uid returns the owner of a given file, if can be read. 60 | int owner_uid(char *path) { 61 | struct stat sb; 62 | int ret = -1; 63 | if ((ret = stat(path, &sb)) < 0) { 64 | return -1; 65 | } 66 | 67 | return (int)sb.st_uid; 68 | } 69 | 70 | // get_uid returns the uid for the given char *username 71 | int get_uid(char *user) { 72 | if (!user) 73 | return -1; 74 | struct passwd pw, *result; 75 | char buf[8192]; // 8k should be enough for anyone 76 | 77 | int i = getpwnam_r(user, &pw, buf, sizeof(buf), &result); 78 | if (!result || i != 0) 79 | return -1; 80 | return pw.pw_uid; 81 | } 82 | 83 | // get_username returns the username for the given uid. 84 | char *get_username(int uid) { 85 | if (uid < 0) 86 | return NULL; 87 | 88 | struct passwd pw, *result; 89 | char buf[8192]; // 8k should be enough for anyone 90 | 91 | int i = getpwuid_r(uid, &pw, buf, sizeof(buf), &result); 92 | if (!result || i != 0) 93 | return NULL; 94 | 95 | return strdup(pw.pw_name); 96 | } 97 | 98 | // change_euid sets the euid to the given euid 99 | int change_euid(int uid) { 100 | return seteuid(uid); 101 | } 102 | 103 | int disable_ptrace() { 104 | #ifdef __APPLE__ 105 | return ptrace(PT_DENY_ATTACH, 0, 0, 0); 106 | #elif __linux__ 107 | return prctl(PR_SET_DUMPABLE, 0); 108 | #endif 109 | return 1; 110 | } 111 | */ 112 | import "C" 113 | 114 | import ( 115 | "fmt" 116 | "os/user" 117 | "strconv" 118 | "unsafe" 119 | ) 120 | 121 | // ownerUID returns the uid of the owner of a given file or directory. 122 | func ownerUID(path string) int { 123 | cPath := C.CString(path) 124 | defer C.free(unsafe.Pointer(cPath)) 125 | 126 | return int(C.owner_uid(cPath)) 127 | } 128 | 129 | // getUID is used for testing. 130 | func getUID() int { 131 | u, err := user.Current() 132 | if err != nil { 133 | fmt.Printf("user.Current error: %v\n", err) 134 | return -1 135 | } 136 | 137 | i, err := strconv.Atoi(u.Uid) 138 | if err == nil { 139 | return i 140 | } 141 | 142 | cUsername := C.CString(u.Uid) 143 | defer C.free(unsafe.Pointer(cUsername)) 144 | return int(C.get_uid(cUsername)) 145 | } 146 | 147 | // getUsername returns the username associated with the given uid. 148 | func getUsername(uid int) string { 149 | cUsername := C.get_username(C.int(uid)) 150 | if cUsername == nil { 151 | return "" 152 | } 153 | defer C.free(unsafe.Pointer(cUsername)) 154 | return C.GoString(cUsername) 155 | } 156 | 157 | // seteuid drops privs. 158 | func seteuid(uid int) bool { 159 | return C.change_euid(C.int(uid)) == C.int(0) 160 | } 161 | 162 | // likely redundant, but try and make sure we can't be traced. 163 | func disablePtrace() bool { 164 | return C.disable_ptrace() == C.int(0) 165 | } 166 | -------------------------------------------------------------------------------- /pam_darwin.go: -------------------------------------------------------------------------------- 1 | /* 2 | Copyright (c) 2017 Uber Technologies, Inc. 3 | 4 | Permission is hereby granted, free of charge, to any person obtaining a copy 5 | of this software and associated documentation files (the "Software"), to deal 6 | in the Software without restriction, including without limitation the rights 7 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 8 | copies of the Software, and to permit persons to whom the Software is 9 | furnished to do so, subject to the following conditions: 10 | 11 | The above copyright notice and this permission notice shall be included in 12 | all copies or substantial portions of the Software. 13 | 14 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 15 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 16 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 17 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 18 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 19 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 20 | THE SOFTWARE. 21 | */ 22 | 23 | package main 24 | 25 | func isMemberOf(groupName string) bool { 26 | return true 27 | } 28 | -------------------------------------------------------------------------------- /pam_linux.go: -------------------------------------------------------------------------------- 1 | /* 2 | Copyright (c) 2017 Uber Technologies, Inc. 3 | 4 | Permission is hereby granted, free of charge, to any person obtaining a copy 5 | of this software and associated documentation files (the "Software"), to deal 6 | in the Software without restriction, including without limitation the rights 7 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 8 | copies of the Software, and to permit persons to whom the Software is 9 | furnished to do so, subject to the following conditions: 10 | 11 | The above copyright notice and this permission notice shall be included in 12 | all copies or substantial portions of the Software. 13 | 14 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 15 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 16 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 17 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 18 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 19 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 20 | THE SOFTWARE. 21 | */ 22 | 23 | package main 24 | 25 | /* 26 | #include 27 | #include // for C.free 28 | #include 29 | 30 | int group_member(int gid); 31 | struct group *getgrnam(const char *name); 32 | */ 33 | import "C" 34 | import "unsafe" 35 | 36 | func isMemberOf(groupName string) bool { 37 | if len(groupName) == 0 { 38 | return false 39 | } 40 | 41 | CgroupName := C.CString(groupName) 42 | if CgroupName == nil { 43 | pamLog("bad group name: %s", groupName) 44 | return false 45 | } 46 | defer C.free(unsafe.Pointer(CgroupName)) 47 | 48 | groupEnt := C.getgrnam(CgroupName) 49 | if groupEnt == nil { 50 | pamLog("bad group: %s", groupName) 51 | return false 52 | } 53 | 54 | return C.group_member(C.int(groupEnt.gr_gid)) != 0 55 | } 56 | -------------------------------------------------------------------------------- /pam_ussh.go: -------------------------------------------------------------------------------- 1 | // +build darwin linux 2 | 3 | /* 4 | Copyright (c) 2017 Uber Technologies, Inc. 5 | 6 | Permission is hereby granted, free of charge, to any person obtaining a copy 7 | of this software and associated documentation files (the "Software"), to deal 8 | in the Software without restriction, including without limitation the rights 9 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 10 | copies of the Software, and to permit persons to whom the Software is 11 | furnished to do so, subject to the following conditions: 12 | 13 | The above copyright notice and this permission notice shall be included in 14 | all copies or substantial portions of the Software. 15 | 16 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 17 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 18 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 19 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 20 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 21 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 22 | THE SOFTWARE. 23 | */ 24 | 25 | package main 26 | 27 | import ( 28 | "bufio" 29 | "bytes" 30 | "crypto/rand" 31 | "fmt" 32 | "io" 33 | "io/ioutil" 34 | "log/syslog" 35 | "net" 36 | "os" 37 | "path" 38 | "runtime" 39 | "strings" 40 | 41 | "golang.org/x/crypto/ssh" 42 | "golang.org/x/crypto/ssh/agent" 43 | ) 44 | 45 | var ( 46 | defaultUserCA = "/etc/ssh/trusted_user_ca" 47 | defaultGroup = "" 48 | ) 49 | 50 | // AuthResult is the result of the authentcate function. 51 | type AuthResult int 52 | 53 | const ( 54 | // AuthError is a failure. 55 | AuthError AuthResult = iota 56 | // AuthSuccess is a success. 57 | AuthSuccess 58 | ) 59 | 60 | func pamLog(format string, args ...interface{}) { 61 | l, err := syslog.New(syslog.LOG_AUTH|syslog.LOG_WARNING, "pam-ussh") 62 | if err != nil { 63 | return 64 | } 65 | l.Warning(fmt.Sprintf(format, args...)) 66 | } 67 | 68 | // authenticate validates certs loaded on the ssh-agent at the other end of 69 | // AuthSock. 70 | func authenticate(w io.Writer, uid int, username, ca string, principals map[string]struct{}) AuthResult { 71 | authSock := os.Getenv("SSH_AUTH_SOCK") 72 | if authSock == "" { 73 | fmt.Fprint(w, "No SSH_AUTH_SOCK") 74 | return AuthError 75 | } 76 | 77 | origEUID := os.Geteuid() 78 | if os.Getuid() != origEUID || origEUID == 0 { 79 | // Note: this only sets the euid and doesn't do anything with the egid. 80 | // That should be fine for most cases, but it's worth calling out. 81 | if !seteuid(uid) { 82 | pamLog("error dropping privs from %d to %d", origEUID, uid) 83 | return AuthError 84 | } 85 | defer func() { 86 | if !seteuid(origEUID) { 87 | pamLog("error resetting uid to %d", origEUID) 88 | } 89 | }() 90 | } 91 | 92 | agentSock, err := net.Dial("unix", authSock) 93 | if err != nil { 94 | fmt.Fprintf(w, "error connecting to %s: %v\n", authSock, err) 95 | // if we're here, we probably can't stat the socket to get the owner uid 96 | // to decorate the logs, but we might be able to read the parent directory. 97 | ownerUID := ownerUID(path.Dir(authSock)) 98 | pamLog("error opening auth sock (sock owner: %d/%s) by (caller: %d/%s)", 99 | ownerUID, getUsername(ownerUID), os.Getuid(), username) 100 | return AuthError 101 | } 102 | 103 | a := agent.NewClient(agentSock) 104 | keys, err := a.List() 105 | if err != nil { 106 | pamLog("Error listing keys: %v", err) 107 | return AuthError 108 | } 109 | 110 | if len(keys) == 0 { 111 | pamLog("No certs loaded.\n") 112 | return AuthError 113 | } 114 | 115 | caBytes, err := ioutil.ReadFile(ca) 116 | if err != nil { 117 | pamLog("error reading ca: %v\n", err) 118 | return AuthError 119 | } 120 | 121 | var caPubkeys []ssh.PublicKey 122 | in := caBytes 123 | for { 124 | pubKey, _, _, rest, err := ssh.ParseAuthorizedKey(in) 125 | if err != nil { 126 | pamLog("skipping bad public key: %v", err) 127 | } else { 128 | caPubkeys = append(caPubkeys, pubKey) 129 | } 130 | if len(rest) == 0 { 131 | break 132 | } 133 | in = rest 134 | } 135 | 136 | c := &ssh.CertChecker{ 137 | IsUserAuthority: func(auth ssh.PublicKey) bool { 138 | for _, k := range caPubkeys { 139 | if bytes.Equal(auth.Marshal(), k.Marshal()) { 140 | return true 141 | } 142 | } 143 | return false 144 | }, 145 | } 146 | 147 | for idx := range keys { 148 | pubKey, err := ssh.ParsePublicKey(keys[idx].Marshal()) 149 | if err != nil { 150 | continue 151 | } 152 | 153 | cert, ok := pubKey.(*ssh.Certificate) 154 | if !ok { 155 | continue 156 | } 157 | 158 | if err := c.CheckCert(username, cert); err != nil { 159 | continue 160 | } 161 | 162 | if !c.IsUserAuthority(cert.SignatureKey) { 163 | pamLog("certificate signed by unrecognized authority") 164 | continue 165 | } 166 | 167 | // for the ssh agent to sign some data validating that they do in fact 168 | // have the private key 169 | randBytes := make([]byte, 32) 170 | if _, err := rand.Read(randBytes); err != nil { 171 | pamLog("Error grabbing random bytes: %v\n", err) 172 | return AuthError 173 | } 174 | 175 | signedData, err := a.Sign(pubKey, randBytes) 176 | if err != nil { 177 | pamLog("error signing data: %v\n", err) 178 | return AuthError 179 | } 180 | 181 | if err := pubKey.Verify(randBytes, signedData); err != nil { 182 | pamLog("signature verification failed: %v\n", err) 183 | return AuthError 184 | } 185 | 186 | if len(principals) == 0 { 187 | pamLog("Authentication succeeded for %q (cert %q, %d)", 188 | username, cert.ValidPrincipals[0], cert.Serial) 189 | return AuthSuccess 190 | } 191 | 192 | for _, p := range cert.ValidPrincipals { 193 | if _, ok := principals[p]; ok { 194 | pamLog("Authentication succeded for %s. Matched principal %s, cert %d", 195 | cert.ValidPrincipals[0], p, cert.Serial) 196 | return AuthSuccess 197 | } 198 | } 199 | } 200 | pamLog("no valid certs found") 201 | return AuthError 202 | } 203 | 204 | func loadValidPrincipals(principals string) (map[string]struct{}, error) { 205 | f, err := os.Open(principals) 206 | if err != nil { 207 | return nil, err 208 | } 209 | defer f.Close() 210 | 211 | p := make(map[string]struct{}) 212 | scanner := bufio.NewScanner(f) 213 | for scanner.Scan() { 214 | p[scanner.Text()] = struct{}{} 215 | } 216 | return p, nil 217 | } 218 | 219 | func pamAuthenticate(w io.Writer, uid int, username string, argv []string) AuthResult { 220 | runtime.GOMAXPROCS(1) 221 | 222 | userCA := defaultUserCA 223 | group := defaultGroup 224 | authorizedPrincipals := make(map[string]struct{}) 225 | 226 | for _, arg := range argv { 227 | opt := strings.Split(arg, "=") 228 | switch opt[0] { 229 | case "ca_file": 230 | userCA = opt[1] 231 | pamLog("ca_file set to %s", userCA) 232 | case "group": 233 | group = opt[1] 234 | pamLog("group set to %s", group) 235 | case "authorized_principals": 236 | for _, s := range strings.Split(opt[1], ",") { 237 | authorizedPrincipals[s] = struct{}{} 238 | } 239 | case "authorized_principals_file": 240 | ap, err := loadValidPrincipals(opt[1]) 241 | if err != nil { 242 | pamLog("%v", err) 243 | return AuthError 244 | } 245 | authorizedPrincipals = ap 246 | default: 247 | pamLog("unkown option: %s\n", opt[0]) 248 | } 249 | } 250 | 251 | if len(group) == 0 || isMemberOf(group) { 252 | return authenticate(w, uid, username, userCA, authorizedPrincipals) 253 | } 254 | 255 | return AuthSuccess 256 | } 257 | 258 | func main() {} 259 | -------------------------------------------------------------------------------- /pam_ussh_test.go: -------------------------------------------------------------------------------- 1 | /* 2 | Copyright (c) 2017 Uber Technologies, Inc. 3 | 4 | Permission is hereby granted, free of charge, to any person obtaining a copy 5 | of this software and associated documentation files (the "Software"), to deal 6 | in the Software without restriction, including without limitation the rights 7 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 8 | copies of the Software, and to permit persons to whom the Software is 9 | furnished to do so, subject to the following conditions: 10 | 11 | The above copyright notice and this permission notice shall be included in 12 | all copies or substantial portions of the Software. 13 | 14 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 15 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 16 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 17 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 18 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 19 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 20 | THE SOFTWARE. 21 | */ 22 | 23 | package main 24 | 25 | import ( 26 | "bytes" 27 | "crypto/rand" 28 | "crypto/rsa" 29 | "fmt" 30 | "io/ioutil" 31 | "net" 32 | "os" 33 | "path" 34 | "testing" 35 | "time" 36 | 37 | "github.com/stretchr/testify/require" 38 | "golang.org/x/crypto/ssh" 39 | "golang.org/x/crypto/ssh/agent" 40 | ) 41 | 42 | func TestLoadPrincipals(t *testing.T) { 43 | WithTempDir(func(dir string) { 44 | p := path.Join(dir, "principals") 45 | e := ioutil.WriteFile(p, []byte("group:t"), 0444) 46 | require.NoError(t, e) 47 | 48 | r, e := loadValidPrincipals(p) 49 | require.NoError(t, e) 50 | _, ok := r["group:t"] 51 | require.True(t, ok) 52 | }) 53 | } 54 | 55 | func TestNoAuthSock(t *testing.T) { 56 | oldAgent := os.Getenv("SSH_AUTH_SOCK") 57 | defer os.Setenv("SSH_AUTH_SOCK", oldAgent) 58 | os.Unsetenv("SSH_AUTH_SOCK") 59 | b := new(bytes.Buffer) 60 | require.Equal(t, AuthError, authenticate(b, 0, "", "", nil)) 61 | require.Contains(t, b.String(), "No SSH_AUTH_SOCK") 62 | } 63 | 64 | func TestBadAuthSock(t *testing.T) { 65 | WithTempDir(func(dir string) { 66 | s := path.Join(dir, "badsock") 67 | 68 | oldAgent := os.Getenv("SSH_AUTH_SOCK") 69 | defer os.Setenv("SSH_AUTH_SOCK", oldAgent) 70 | os.Setenv("SSH_AUTH_SOCK", s) 71 | b := new(bytes.Buffer) 72 | require.Equal(t, AuthError, authenticate(b, 0, "", "", nil)) 73 | require.Contains(t, b.String(), "connect: no such file or directory") 74 | }) 75 | } 76 | 77 | func TestBadCA(t *testing.T) { 78 | WithTempDir(func(dir string) { 79 | ca := path.Join(dir, "badca") 80 | WithSSHAgent(func(a agent.Agent) { 81 | k, e := rsa.GenerateKey(rand.Reader, 1024) 82 | require.NoError(t, e) 83 | require.NoError(t, a.Add(agent.AddedKey{PrivateKey: k})) 84 | require.Equal(t, AuthError, authenticate(new(bytes.Buffer), 0, "", ca, nil)) 85 | }) 86 | }) 87 | } 88 | 89 | func TestAuthorize_NoKeys(t *testing.T) { 90 | WithTempDir(func(dir string) { 91 | p := map[string]struct{}{"group:t": {}} 92 | 93 | ca := path.Join(dir, "ca") 94 | k, e := rsa.GenerateKey(rand.Reader, 1024) 95 | require.NoError(t, e) 96 | pub, e := ssh.NewPublicKey(&k.PublicKey) 97 | require.NoError(t, e) 98 | e = ioutil.WriteFile(ca, ssh.MarshalAuthorizedKey(pub), 0444) 99 | 100 | WithSSHAgent(func(a agent.Agent) { 101 | r := authenticate(new(bytes.Buffer), 0, "", ca, p) 102 | require.Equal(t, AuthError, r) 103 | }) 104 | }) 105 | } 106 | 107 | func TestPamAuthorize(t *testing.T) { 108 | WithTempDir(func(dir string) { 109 | ca := path.Join(dir, "ca") 110 | caPamOpt := fmt.Sprintf("ca_file=%s", ca) 111 | principals := path.Join(dir, "principals") 112 | 113 | k, e := rsa.GenerateKey(rand.Reader, 1024) 114 | require.NoError(t, e) 115 | signer, e := ssh.NewSignerFromKey(k) 116 | require.NoError(t, e) 117 | e = ioutil.WriteFile(ca, ssh.MarshalAuthorizedKey(signer.PublicKey()), 0444) 118 | 119 | userPriv, e := rsa.GenerateKey(rand.Reader, 1024) 120 | require.NoError(t, e) 121 | userPub, e := ssh.NewPublicKey(&userPriv.PublicKey) 122 | require.NoError(t, e) 123 | c := signedCert(userPub, signer, "foober", []string{"group:foober"}) 124 | 125 | e = ioutil.WriteFile(principals, []byte("group:foober"), 0444) 126 | require.NoError(t, e) 127 | 128 | WithSSHAgent(func(a agent.Agent) { 129 | a.Add(agent.AddedKey{PrivateKey: userPriv, Certificate: c}) 130 | 131 | // test with no principal 132 | r := pamAuthenticate(new(bytes.Buffer), getUID(), "foober", []string{caPamOpt}) 133 | require.Equal(t, AuthSuccess, r, 134 | "authenticate failed when it should've succeeded") 135 | 136 | // test that the wrong principal fails 137 | r = pamAuthenticate(new(bytes.Buffer), getUID(), "duber", []string{caPamOpt}) 138 | require.Equal(t, AuthError, r) 139 | 140 | // negative test with authorized_principals pam 2option 141 | r = pamAuthenticate(new(bytes.Buffer), getUID(), "foober", []string{caPamOpt, 142 | fmt.Sprintf("authorized_principals=group:boober")}) 143 | require.Equal(t, AuthError, r) 144 | 145 | // positive test with authorized_principals_file pam option 146 | r = pamAuthenticate(new(bytes.Buffer), getUID(), "foober", []string{caPamOpt, 147 | fmt.Sprintf("authorized_principals_file=%s", principals)}) 148 | require.Equal(t, AuthSuccess, r) 149 | 150 | // negative test with a bad authorized_principals_file pam option 151 | r = pamAuthenticate(new(bytes.Buffer), getUID(), "foober", []string{caPamOpt, 152 | "authorized_principals_file=foober"}) 153 | require.Equal(t, AuthError, r) 154 | 155 | // test that a user not in the required group passes. 156 | r = pamAuthenticate(new(bytes.Buffer), getUID(), "foober", []string{caPamOpt, 157 | "group=nosuchgroup"}) 158 | require.Equal(t, AuthSuccess, r) 159 | }) 160 | }) 161 | } 162 | 163 | func signedCert(pubKey ssh.PublicKey, signer ssh.Signer, u string, p []string) *ssh.Certificate { 164 | c := &ssh.Certificate{ 165 | ValidPrincipals: []string{u}, 166 | Key: pubKey, 167 | Serial: 1, 168 | CertType: ssh.UserCert, 169 | ValidAfter: uint64(time.Now().Add(-1 * time.Minute).Unix()), 170 | ValidBefore: uint64(time.Now().Add(1 * time.Minute).Unix()), 171 | } 172 | 173 | if p != nil { 174 | c.ValidPrincipals = append(c.ValidPrincipals, p...) 175 | } 176 | 177 | if e := c.SignCert(rand.Reader, signer); e != nil { 178 | panic(e) 179 | } 180 | return c 181 | } 182 | 183 | // WithTempDir runs the func `fn` with the given temporary directory. 184 | // 'Borrowed' from cerberus. 185 | func WithTempDir(fn func(dir string)) { 186 | dir, err := ioutil.TempDir("", "ussh-test") 187 | if err != nil { 188 | panic(err) 189 | } 190 | 191 | defer os.RemoveAll(dir) 192 | cwd, err := os.Getwd() 193 | if err != nil { 194 | panic(err) 195 | } 196 | 197 | defer os.Chdir(cwd) 198 | os.Chdir(dir) 199 | 200 | fn(dir) 201 | } 202 | 203 | func WithSSHAgent(fn func(agent.Agent)) { 204 | a := agent.NewKeyring() 205 | WithTempDir(func(dir string) { 206 | newAgent := path.Join(dir, "agent") 207 | oldAgent := os.Getenv("SSH_AUTH_SOCK") 208 | os.Setenv("SSH_AUTH_SOCK", newAgent) 209 | defer os.Setenv("SSH_AUTH_SOCK", oldAgent) 210 | 211 | l, e := net.Listen("unix", newAgent) 212 | if e != nil { 213 | panic(e) 214 | } 215 | 216 | go func() { 217 | for { 218 | c, e := l.Accept() 219 | if e != nil { 220 | panic(e) 221 | } 222 | go func() { 223 | defer c.Close() 224 | agent.ServeAgent(a, c) 225 | }() 226 | } 227 | }() 228 | 229 | fn(a) 230 | }) 231 | } 232 | 233 | func TestWithWrongCA(t *testing.T) { 234 | WithTempDir(func(dir string) { 235 | ca := path.Join(dir, "ca") 236 | caPamOpt := fmt.Sprintf("ca_file=%s", ca) 237 | 238 | // The correct CA is written to file for the pamAuthenticate function 239 | correctCAKey, e := rsa.GenerateKey(rand.Reader, 1024) 240 | require.NoError(t, e) 241 | correctCAPub, e := ssh.NewPublicKey(&correctCAKey.PublicKey) 242 | require.NoError(t, e) 243 | e = ioutil.WriteFile(ca, ssh.MarshalAuthorizedKey(correctCAPub), 0444) 244 | 245 | // The wrong CA is just used for signing the certificate 246 | wrongCAKey, e := rsa.GenerateKey(rand.Reader, 1024) 247 | require.NoError(t, e) 248 | wrongSigner, e := ssh.NewSignerFromKey(wrongCAKey) 249 | require.NoError(t, e) 250 | 251 | // Generate a user keypair 252 | userPriv, e := rsa.GenerateKey(rand.Reader, 1024) 253 | require.NoError(t, e) 254 | userPub, e := ssh.NewPublicKey(&userPriv.PublicKey) 255 | require.NoError(t, e) 256 | 257 | // Sign the user keypair with the wrong CA and try to verify it 258 | c := signedCert(userPub, wrongSigner, "foober", []string{"group:foober"}) 259 | WithSSHAgent(func(a agent.Agent) { 260 | a.Add(agent.AddedKey{PrivateKey: userPriv, Certificate: c}) 261 | r := pamAuthenticate(new(bytes.Buffer), getUID(), "foober", []string{caPamOpt}) 262 | require.Equal(t, AuthError, r, "authenticate succeeded when it should have failed") 263 | }) 264 | }) 265 | } 266 | --------------------------------------------------------------------------------