From: Damien Cassou Date: Sat, 4 Feb 2017 07:51:32 +0000 (+0100) Subject: Integrate auth-source with password-store X-Git-Tag: emacs-26.0.90~521^2~531 X-Git-Url: http://git.eshelyaron.com/gitweb/?a=commitdiff_plain;h=0e066efe3b84ee9ecfc0a075e0ce289311f6d160;p=emacs.git Integrate auth-source with password-store * lisp/auth-source-pass.el: auth-source backend for password-store. * test/lisp/auth-source-pass-tests.el: Tests for auth-source-pass behavior. --- diff --git a/lisp/auth-source-pass.el b/lisp/auth-source-pass.el new file mode 100644 index 00000000000..a9d61cf58c3 --- /dev/null +++ b/lisp/auth-source-pass.el @@ -0,0 +1,255 @@ +;;; auth-source-pass.el --- Integrate auth-source with password-store -*- lexical-binding: t -*- + +;; Copyright (C) 2015 Damien Cassou & Nicolas Petton + +;; Author: Damien Cassou , +;; Nicolas Petton +;; Version: 2.0.0 +;; Package-Requires: ((emacs "24.4") +;; Created: 07 Jun 2015 +;; Keywords: pass password-store auth-source username password login + +;; This file is part of GNU Emacs. + +;; GNU Emacs is free software: you can redistribute it and/or modify +;; it under the terms of the GNU General Public License as published by +;; the Free Software Foundation, either version 3 of the License, or +;; (at your option) any later version. + +;; GNU Emacs is distributed in the hope that it will be useful, +;; but WITHOUT ANY WARRANTY; without even the implied warranty of +;; MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +;; GNU General Public License for more details. + +;; You should have received a copy of the GNU General Public License +;; along with GNU Emacs. If not, see . + +;;; Commentary: + +;; Integrates password-store (http://passwordstore.org/) within +;; auth-source. + +;;; Code: + +(require 'seq) +(require 'subr-x) +(eval-when-compile + (require 'cl-lib)) +(require 'auth-source) +(require 'url-parse) + +(cl-defun auth-source-pass-search (&rest spec + &key backend type host user port + &allow-other-keys) + "Given a property list SPEC, return search matches from the :backend. +See `auth-source-search' for details on SPEC." + (cl-assert (or (null type) (eq type (oref backend type))) + t "Invalid password-store search: %s %s") + (when (listp host) + ;; Take the first non-nil item of the list of hosts + (setq host (seq-find #'identity host))) + (list (auth-source-pass--build-result host port user))) + +(defun auth-source-pass--build-result (host port user) + "Build auth-source-pass entry matching HOST, PORT and USER." + (let ((entry (auth-source-pass--find-match host user))) + (when entry + (let ((retval (list + :host host + :port (or (auth-source-pass-get "port" entry) port) + :user (or (auth-source-pass-get "user" entry) user) + :secret (lambda () (auth-source-pass-get 'secret entry))))) + (auth-source-pass--do-debug "return %s as final result (plus hidden password)" + (seq-subseq retval 0 -2)) ;; remove password + retval)))) + +;;;###autoload +(defun auth-source-pass-enable () + "Enable auth-source-password-store." + ;; To add password-store to the list of sources, evaluate the following: + (add-to-list 'auth-sources 'password-store) + ;; clear the cache (required after each change to #'auth-source-pass-search) + (auth-source-forget-all-cached)) + +(defvar auth-source-pass-backend + (auth-source-backend + (format "Password store") + :source "." ;; not used + :type 'password-store + :search-function #'auth-source-pass-search) + "Auth-source backend for password-store.") + +(defun auth-source-pass-backend-parse (entry) + "Create a password-store auth-source backend from ENTRY." + (when (eq entry 'password-store) + (auth-source-backend-parse-parameters entry auth-source-pass-backend))) + +(add-hook 'auth-source-backend-parser-functions #'auth-source-pass-backend-parse) + + +(defun auth-source-pass-get (key entry) + "Return the value associated to KEY in the password-store entry ENTRY. + +ENTRY is the name of a password-store entry. +The key used to retrieve the password is the symbol `secret'. + +The convention used as the format for a password-store file is +the following (see http://www.passwordstore.org/#organization): + +secret +key1: value1 +key2: value2" + (let ((data (auth-source-pass-parse-entry entry))) + (or (cdr (assoc key data)) + (and (string= key "user") + (cdr (assoc "username" data)))))) + +(defun auth-source-pass--read-entry (entry) + "Return a string with the file content of ENTRY." + (with-temp-buffer + (insert-file-contents (expand-file-name + (format "%s.gpg" entry) + "~/.password-store")) + (buffer-substring-no-properties (point-min) (point-max)))) + +(defun auth-source-pass-parse-entry (entry) + "Return an alist of the data associated with ENTRY. + +ENTRY is the name of a password-store entry." + (let ((file-contents (ignore-errors (auth-source-pass--read-entry entry)))) + (and file-contents + (cons `(secret . ,(auth-source-pass--parse-secret file-contents)) + (auth-source-pass--parse-data file-contents))))) + +(defun auth-source-pass--parse-secret (contents) + "Parse the password-store data in the string CONTENTS and return its secret. +The secret is the first line of CONTENTS." + (car (split-string contents "\\\n" t))) + +(defun auth-source-pass--parse-data (contents) + "Parse the password-store data in the string CONTENTS and return an alist. +CONTENTS is the contents of a password-store formatted file." + (let ((lines (split-string contents "\\\n" t "\\\s"))) + (seq-remove #'null + (mapcar (lambda (line) + (let ((pair (mapcar #'string-trim + (split-string line ":")))) + (when (> (length pair) 1) + (cons (car pair) + (mapconcat #'identity (cdr pair) ":"))))) + (cdr lines))))) + +(defun auth-source-pass--user-match-p (entry user) + "Return true iff ENTRY match USER." + (or (null user) + (string= user (auth-source-pass-get "user" entry)))) + +(defun auth-source-pass--hostname (host) + "Extract hostname from HOST." + (let ((url (url-generic-parse-url host))) + (or (url-host url) host))) + +(defun auth-source-pass--hostname-with-user (host) + "Extract hostname and user from HOST." + (let* ((url (url-generic-parse-url host)) + (user (url-user url)) + (hostname (url-host url))) + (cond + ((and user hostname) (format "%s@%s" user hostname)) + (hostname hostname) + (t host)))) + +(defun auth-source-pass--remove-directory-name (name) + "Remove directories from NAME. +E.g., if NAME is \"foo/bar\", return \"bar\"." + (replace-regexp-in-string ".*/" "" name)) + +(defun auth-source-pass--do-debug (&rest msg) + "Call `auth-source-do-debug` with MSG and a prefix." + (apply #'auth-source-do-debug + (cons (concat "auth-source-password-store: " (car msg)) + (cdr msg)))) + +(defun auth-source-pass--select-one-entry (entries user) + "Select one entry from ENTRIES by searching for a field matching USER." + (let ((number (length entries)) + (entry-with-user + (and user + (seq-find (lambda (entry) + (string-equal (auth-source-pass-get "user" entry) user)) + entries)))) + (auth-source-pass--do-debug "found %s matches: %s" number + (mapconcat #'identity entries ", ")) + (if entry-with-user + (progn + (auth-source-pass--do-debug "return %s as it contains matching user field" + entry-with-user) + entry-with-user) + (auth-source-pass--do-debug "return %s as it is the first one" (car entries)) + (car entries)))) + +(defun auth-source-pass--entry-valid-p (entry) + "Return t iff ENTRY can be opened. +Also displays a warning if not. This function is slow, don't call it too +often." + (if (auth-source-pass-parse-entry entry) + t + (auth-source-pass--do-debug "entry '%s' is not valid" entry) + nil)) + +;; TODO: add tests for that when `assess-with-filesystem' is included +;; in Emacs +(defun auth-source-pass-entries () + "Return a list of all password store entries." + (let ((store-dir (expand-file-name "~/.password-store/"))) + (mapcar + (lambda (file) (file-name-sans-extension (file-relative-name file store-dir))) + (directory-files-recursively store-dir "\.gpg$")))) + +(defun auth-source-pass--find-all-by-entry-name (name) + "Search the store for all entries matching NAME. +Only return valid entries as of `auth-source-pass--entry-valid-p'." + (seq-filter (lambda (entry) + (and + (string-equal + name + (auth-source-pass--remove-directory-name entry)) + (auth-source-pass--entry-valid-p entry))) + (auth-source-pass-entries))) + +(defun auth-source-pass--find-one-by-entry-name (name user) + "Search the store for an entry matching NAME. +If USER is non nil, give precedence to entries containing a user field +matching USER." + (auth-source-pass--do-debug "searching for '%s' in entry names (user: %s)" + name + user) + (let ((matching-entries (auth-source-pass--find-all-by-entry-name name))) + (pcase (length matching-entries) + (0 (auth-source-pass--do-debug "no match found") + nil) + (1 (auth-source-pass--do-debug "found 1 match: %s" (car matching-entries)) + (car matching-entries)) + (_ (auth-source-pass--select-one-entry matching-entries user))))) + +(defun auth-source-pass--find-match (host user) + "Return a password-store entry name matching HOST and USER. +If many matches are found, return the first one. If no match is +found, return nil." + (or + (if (url-user (url-generic-parse-url host)) + ;; if HOST contains a user (e.g., "user@host.com"), + (auth-source-pass--find-one-by-entry-name (auth-source-pass--hostname-with-user host) user) + ;; otherwise, if USER is provided, search for @ + (when (stringp user) + (auth-source-pass--find-one-by-entry-name (concat user "@" (auth-source-pass--hostname host)) user))) + ;; if that didn't work, search for HOST without it's user component if any + (auth-source-pass--find-one-by-entry-name (auth-source-pass--hostname host) user) + ;; if that didn't work, remove subdomain: foo.bar.com -> bar.com + (let ((components (split-string host "\\."))) + (when (= (length components) 3) + ;; start from scratch + (auth-source-pass--find-match (mapconcat 'identity (cdr components) ".") user))))) + +(provide 'auth-source-pass) +;;; auth-source-pass.el ends here diff --git a/test/lisp/auth-source-pass-tests.el b/test/lisp/auth-source-pass-tests.el new file mode 100644 index 00000000000..c3586d8058c --- /dev/null +++ b/test/lisp/auth-source-pass-tests.el @@ -0,0 +1,234 @@ +;;; auth-source-pass-tests.el --- Tests for auth-source-pass.el -*- lexical-binding: t; -*- + +;; Copyright (C) 2013 Damien Cassou + +;; Author: Damien Cassou + +;; This file is not part of GNU Emacs. + +;; This program is free software: you can redistribute it and/or modify +;; it under the terms of the GNU General Public License as published by +;; the Free Software Foundation, either version 3 of the License, or +;; (at your option) any later version. + +;; This program is distributed in the hope that it will be useful, +;; but WITHOUT ANY WARRANTY; without even the implied warranty of +;; MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +;; GNU General Public License for more details. + +;; You should have received a copy of the GNU General Public License +;; along with this program. If not, see . + +;;; Commentary: + +;; Tests for auth-source-pass.el + +;;; Code: + +(require 'ert) + +(require 'auth-source-pass) + +(eval-when-compile (require 'cl-macs)) + +(ert-deftest auth-source-pass-parse-simple () + (let ((content "pass\nkey1:val1\nkey2:val2\n")) + (should (equal (auth-source-pass--parse-data content) + '(("key1" . "val1") + ("key2" . "val2")))))) + +(ert-deftest auth-source-pass-parse-with-dash-line () + (let ((content "pass\n--\nkey1:val1\nkey2:val2\n")) + (should (equal (auth-source-pass--parse-data content) + '(("key1" . "val1") + ("key2" . "val2")))))) + +(ert-deftest auth-source-pass-parse-with-trailing-spaces () + (let ((content "pass\n--\nkey1 :val1 \nkey2: val2\n\n")) + (should (equal (auth-source-pass--parse-data content) + '(("key1" . "val1") + ("key2" . "val2")))))) + +(defvar auth-source-pass--debug-log nil + "Contains a list of all messages passed to `auth-source-do-debug`.") + +(defun auth-source-pass--should-have-message-containing (regexp) + "Assert that at least one `auth-source-do-debug` matched REGEXP." + (should (seq-find (lambda (message) + (string-match regexp message)) + auth-source-pass--debug-log))) + +(defun auth-source-pass--debug (&rest msg) + "Format MSG and add that to `auth-source-pass--debug-log`. +This function is intended to be set to `auth-source-debug`." + (add-to-list 'auth-source-pass--debug-log (apply #'format msg) t)) + +(defmacro auth-source-pass--deftest (name arglist store &rest body) + "Define a new ert-test NAME with ARGLIST using STORE as password-store. +BODY is a sequence of instructions that will be evaluated. + +This macro overrides `auth-source-pass-parse-entry' and `auth-source-pass-entries' to +test code without touching the file system." + (declare (indent 3)) + `(ert-deftest ,name ,arglist + (cl-letf (((symbol-function 'auth-source-pass-parse-entry) (lambda (entry) (cdr (cl-find entry ,store :key #'car :test #'string=))) ) + ((symbol-function 'auth-source-pass-entries) (lambda () (mapcar #'car ,store))) + ((symbol-function 'auth-source-pass--entry-valid-p) (lambda (_entry) t))) + (let ((auth-source-debug #'auth-source-pass--debug) + (auth-source-pass--debug-log nil)) + ,@body)))) + +(auth-source-pass--deftest auth-source-pass-find-match-matching-at-entry-name () + '(("foo")) + (should (equal (auth-source-pass--find-match "foo" nil) + "foo"))) + +(auth-source-pass--deftest auth-source-pass-find-match-matching-at-entry-name-part () + '(("foo")) + (should (equal (auth-source-pass--find-match "https://foo" nil) + "foo"))) + +(auth-source-pass--deftest auth-source-pass-find-match-matching-at-entry-name-ignoring-user () + '(("foo")) + (should (equal (auth-source-pass--find-match "https://SomeUser@foo" nil) + "foo"))) + +(auth-source-pass--deftest auth-source-pass-find-match-matching-at-entry-name-with-user () + '(("SomeUser@foo")) + (should (equal (auth-source-pass--find-match "https://SomeUser@foo" nil) + "SomeUser@foo"))) + +(auth-source-pass--deftest auth-source-pass-find-match-matching-at-entry-name-prefer-full () + '(("SomeUser@foo") ("foo")) + (should (equal (auth-source-pass--find-match "https://SomeUser@foo" nil) + "SomeUser@foo"))) + +;; same as previous one except the store is in another order +(auth-source-pass--deftest auth-source-pass-find-match-matching-at-entry-name-prefer-full-reversed () + '(("foo") ("SomeUser@foo")) + (should (equal (auth-source-pass--find-match "https://SomeUser@foo" nil) + "SomeUser@foo"))) + +(auth-source-pass--deftest auth-source-pass-find-match-matching-at-entry-name-without-subdomain () + '(("bar.com")) + (should (equal (auth-source-pass--find-match "foo.bar.com" nil) + "bar.com"))) + +(auth-source-pass--deftest auth-source-pass-find-match-matching-at-entry-name-without-subdomain-with-user () + '(("someone@bar.com")) + (should (equal (auth-source-pass--find-match "foo.bar.com" "someone") + "someone@bar.com"))) + +(auth-source-pass--deftest auth-source-pass-find-match-matching-at-entry-name-without-subdomain-with-bad-user () + '(("someoneelse@bar.com")) + (should (equal (auth-source-pass--find-match "foo.bar.com" "someone") + nil))) + +(auth-source-pass--deftest auth-source-pass-find-match-matching-at-entry-name-without-subdomain-prefer-full () + '(("bar.com") ("foo.bar.com")) + (should (equal (auth-source-pass--find-match "foo.bar.com" nil) + "foo.bar.com"))) + +(auth-source-pass--deftest auth-source-pass-dont-match-at-folder-name () + '(("foo.bar.com/foo")) + (should (equal (auth-source-pass--find-match "foo.bar.com" nil) + nil))) + +(auth-source-pass--deftest auth-source-pass-search-with-user-first () + '(("foo") ("user@foo")) + (should (equal (auth-source-pass--find-match "foo" "user") + "user@foo")) + (auth-source-pass--should-have-message-containing "Found 1 match")) + +(auth-source-pass--deftest auth-source-pass-give-priority-to-desired-user () + '(("foo") ("subdir/foo" ("user" . "someone"))) + (should (equal (auth-source-pass--find-match "foo" "someone") + "subdir/foo")) + (auth-source-pass--should-have-message-containing "Found 2 matches") + (auth-source-pass--should-have-message-containing "matching user field")) + +(auth-source-pass--deftest auth-source-pass-give-priority-to-desired-user-reversed () + '(("foo" ("user" . "someone")) ("subdir/foo")) + (should (equal (auth-source-pass--find-match "foo" "someone") + "foo")) + (auth-source-pass--should-have-message-containing "Found 2 matches") + (auth-source-pass--should-have-message-containing "matching user field")) + +(auth-source-pass--deftest auth-source-pass-return-first-when-several-matches () + '(("foo") ("subdir/foo")) + (should (equal (auth-source-pass--find-match "foo" nil) + "foo")) + (auth-source-pass--should-have-message-containing "Found 2 matches") + (auth-source-pass--should-have-message-containing "the first one")) + +(auth-source-pass--deftest auth-source-pass-make-divansantana-happy () + '(("host.com")) + (should (equal (auth-source-pass--find-match "smtp.host.com" "myusername@host.co.za") + "host.com"))) + +(ert-deftest auth-source-pass-hostname () + (should (equal (auth-source-pass--hostname "https://foo.bar") "foo.bar")) + (should (equal (auth-source-pass--hostname "http://foo.bar") "foo.bar")) + (should (equal (auth-source-pass--hostname "https://SomeUser@foo.bar") "foo.bar"))) + +(ert-deftest auth-source-pass-hostname-with-user () + (should (equal (auth-source-pass--hostname-with-user "https://foo.bar") "foo.bar")) + (should (equal (auth-source-pass--hostname-with-user "http://foo.bar") "foo.bar")) + (should (equal (auth-source-pass--hostname-with-user "https://SomeUser@foo.bar") "SomeUser@foo.bar"))) + +(defmacro auth-source-pass--deftest-build-result (name arglist store &rest body) + "Define a new ert-test NAME with ARGLIST using STORE as password-store. +BODY is a sequence of instructions that will be evaluated. + +This macro overrides `auth-source-pass-parse-entry', +`auth-source-pass-entries', and `auth-source-pass--find-match' to +ease testing." + (declare (indent 3)) + `(auth-source-pass--deftest ,name ,arglist ,store + (cl-letf (((symbol-function 'auth-source-pass-find-match) + (lambda (_host _user) + "foo"))) + ,@body))) + +(auth-source-pass--deftest-build-result auth-source-pass-build-result-return-parameters () + '(("foo")) + (let ((result (auth-source-pass--build-result "foo" 512 "user"))) + (should (equal (plist-get result :port) 512)) + (should (equal (plist-get result :user) "user")))) + +(auth-source-pass--deftest-build-result auth-source-pass-build-result-return-entry-values () + '(("foo" ("port" . 512) ("user" . "anuser"))) + (let ((result (auth-source-pass--build-result "foo" nil nil))) + (should (equal (plist-get result :port) 512)) + (should (equal (plist-get result :user) "anuser")))) + +(auth-source-pass--deftest-build-result auth-source-pass-build-result-entry-takes-precedence () + '(("foo" ("port" . 512) ("user" . "anuser"))) + (let ((result (auth-source-pass--build-result "foo" 1024 "anotheruser"))) + (should (equal (plist-get result :port) 512)) + (should (equal (plist-get result :user) "anuser")))) + +(ert-deftest auth-source-pass-only-return-entries-that-can-be-open () + (cl-letf (((symbol-function 'auth-source-pass-entries) + (lambda () '("foo.site.com" "bar.site.com"))) + ((symbol-function 'auth-source-pass--entry-valid-p) + ;; only foo.site.com is valid + (lambda (entry) (string-equal entry "foo.site.com")))) + (should (equal (auth-source-pass--find-all-by-entry-name "foo.site.com") + '("foo.site.com"))) + (should (equal (auth-source-pass--find-all-by-entry-name "bar.site.com") + '())))) + +(ert-deftest auth-source-pass-entry-is-not-valid-when-unreadable () + (cl-letf (((symbol-function 'auth-source-pass--read-entry) + (lambda (entry) + ;; only foo is a valid entry + (if (string-equal entry "foo") + "password" + nil)))) + (should (auth-source-pass--entry-valid-p "foo")) + (should-not (auth-source-pass--entry-valid-p "bar")))) + +(provide 'auth-source-pass-tests) + +;;; auth-source-pass-tests.el ends here