[Date Prev][Date Next][Thread Prev][Thread Next][Date Index][Thread Index]
[Emacs-diffs] feature/auth-source-pass 8a904cd 2/3: auth-source-pass: In
From: |
Teodor Zlatanov |
Subject: |
[Emacs-diffs] feature/auth-source-pass 8a904cd 2/3: auth-source-pass: Integrate auth-source with password-store |
Date: |
Mon, 27 Mar 2017 13:35:36 -0400 (EDT) |
branch: feature/auth-source-pass
commit 8a904cdd83dc0484a81e6f3a53e640a83cea136a
Author: Damien Cassou <address@hidden>
Commit: Damien Cassou <address@hidden>
auth-source-pass: 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.
---
lisp/auth-source-pass.el | 255 ++++++++++++++++++++++++++++++++++++
test/lisp/auth-source-pass-tests.el | 234 +++++++++++++++++++++++++++++++++
2 files changed, 489 insertions(+)
diff --git a/lisp/auth-source-pass.el b/lisp/auth-source-pass.el
new file mode 100644
index 0000000..a9d61cf
--- /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 <address@hidden>,
+;; Nicolas Petton <address@hidden>
+;; 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 <http://www.gnu.org/licenses/>.
+
+;;; 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 "address@hidden" 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., "address@hidden"), <HOST>
+ (auth-source-pass--find-one-by-entry-name
(auth-source-pass--hostname-with-user host) user)
+ ;; otherwise, if USER is provided, search for <USER>@<HOST>
+ (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 0000000..c3586d8
--- /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 <address@hidden>
+
+;; 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 <http://www.gnu.org/licenses/>.
+
+;;; 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://address@hidden" nil)
+ "foo")))
+
+(auth-source-pass--deftest
auth-source-pass-find-match-matching-at-entry-name-with-user ()
+ '(("address@hidden"))
+ (should (equal (auth-source-pass--find-match
"https://address@hidden" nil)
+ "address@hidden")))
+
+(auth-source-pass--deftest
auth-source-pass-find-match-matching-at-entry-name-prefer-full ()
+ '(("address@hidden") ("foo"))
+ (should (equal (auth-source-pass--find-match
"https://address@hidden" nil)
+ "address@hidden")))
+
+;; 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") ("address@hidden"))
+ (should (equal (auth-source-pass--find-match
"https://address@hidden" nil)
+ "address@hidden")))
+
+(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
()
+ '(("address@hidden"))
+ (should (equal (auth-source-pass--find-match "foo.bar.com"
"someone")
+ "address@hidden")))
+
+(auth-source-pass--deftest
auth-source-pass-find-match-matching-at-entry-name-without-subdomain-with-bad-user
()
+ '(("address@hidden"))
+ (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") ("address@hidden"))
+ (should (equal (auth-source-pass--find-match "foo" "user")
+ "address@hidden"))
+ (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"
"address@hidden")
+ "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://address@hidden")
"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://address@hidden") "address@hidden")))
+
+(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