Sync password-store and libsecret collections
You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
 
 

245 lines
8.6 KiB

;;; pass-secrets.el --- Sync password-store and libsecret collections -*- lexical-binding: t; -*-
;; Copyright (C) 2017 Nicolas Petton
;; Author: Nicolas Petton <nicolas@petton.fr>
;; Version: 0.1.0
;; Package-Requires: ((emacs "25.1") (f "0.19.0") (pass "1.7"))
;; GIT: https://gitlab.petton.fr/nico/pass-secrets.el.git
;; Keywords: tools
;; 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:
;; Sync a libsecret collection with pass (password-store).
;; Use `pass-secrets-sync' to start the synchronization process.
;;
;; The synchronization is done both ways, never deleting any entry from pass or
;; libsecret. If an entry is deleted in one of the stores, it will be added
;; back by the synchronization as long as it is present in the other store.
;; Synchronization is done by first adding/updating libsecret entries from pass
;; entries. Then missing pass entries are created from libsecret entries. This
;; means when entries have been modified in both stores, pass always wins.
;; When `pass-secrets-use-incremental-syncs' is non-nil, only update pass
;; entries that changes since the last sync.
;;; Code:
(require 'seq)
(require 'map)
(require 'f)
(require 'subr-x)
(require 'secrets)
(require 'pass)
(defgroup pass-secrets '()
"Synchronize password-store and libsecret."
:group 'password-store)
(defcustom pass-secrets-collection "Login"
"Libsecret collection to sync."
:type 'string)
(defcustom pass-secrets-use-incremental-syncs t
"When non-nil, use incremental syncing."
:type 'boolean)
(defcustom pass-secrets-data-file (locate-user-emacs-file ".pass-secrets-sync.el")
"File used to store the last sync time."
:type 'string)
(defvar pass-secrets-last-sync-time nil)
;;;###autoload
(defun pass-secrets-sync ()
"Synchronize the password-store and a libsecret collection.
Synchronization is done by first adding/updating libsecret
entries from pass entries. Then missing pass entries are created
from libsecret entries. This means when entries have been
modified in both stores, pass always wins.
The libsecret collection to sync is defined by `pass-secrets-collection'."
(interactive)
(pass-secrets-sync-to-secrets)
(pass-secrets-sync-to-pass))
;;;###autoload
(defun pass-secrets-sync-to-secrets ()
"Sync from pass to libsecret."
(when pass-secrets-use-incremental-syncs
(pass-secrets--read-data-file))
(let ((entries (pass-secrets--store-entries-to-sync)))
(mapc (lambda (entry)
(pass-secrets--sync-pass-entry-to-secrets entry))
entries))
(when pass-secrets-use-incremental-syncs
(setq pass-secrets-last-sync-time (time-to-seconds (current-time)))
(pass-secrets--save-data-file)))
(defun pass-secrets--sync-pass-entry-to-secrets (entry)
"Sync the pass ENTRY with the libsecret collection."
(message "Syncing pass entry %s" (car entry))
(let ((name (or (map-elt (cdr entry) :libsecret:name)
(password-store--file-to-entry (car entry)))))
(when-let ((match (car (secrets-search-items
pass-secrets-collection
:pass:file
(car entry)))))
(secrets-delete-item pass-secrets-collection match))
(apply #'secrets-create-item
pass-secrets-collection
name
(map-elt (cdr entry) :password)
(pass-secrets--alist-to-plist (map-delete (cdr entry) :password)))))
(defun pass-secrets--alist-to-plist (alist)
"Convert association list ALIST into the equivalent property-list form."
(let (plist)
(while alist
(let ((el (car alist)))
(setq plist (cons (cdr el) (cons (car el) plist))))
(setq alist (cdr alist)))
(nreverse plist)))
;;;###autoload
(defun pass-secrets-sync-to-pass ()
"Sync from libsecret to pass.
If syncing a secrets entry would result in overriding a pass
entry, skip that entry."
(mapc (lambda (entry)
(let* ((name (pass-secrets--escape-entry-name (car entry)))
(file (password-store--entry-to-file name))
(password (secrets-get-secret pass-secrets-collection (car entry)))
(contents (pass-secrets--secrets-entry-to-pass entry password)))
(if (f-exists-p file)
(message "Will not override pass entry %s" file)
(progn
(message "Syncing libsecret entry %s" (car entry))
(shell-command-to-string (format "echo %s | %s insert -m -f %s"
(shell-quote-argument contents)
password-store-executable
(shell-quote-argument name)))
;; Now that we have our new pass entry, sync back from pass to
;; libsecret to add new metadata for later synchronizations.
(secrets-delete-item pass-secrets-collection (car entry))
(pass-secrets--sync-pass-entry-to-secrets
(cons file
(pass-secrets--parse-pass-file file)))))))
(pass-secrets--secrets-entries-to-sync)))
(defun pass-secrets--secrets-entry-to-pass (entry password)
"Convert the libsecret ENTRY with PASSWORD to pass format."
(with-temp-buffer
(insert password)
(insert "\n")
(map-apply (lambda (key val)
(insert (format "%s: %s\n"
;; Remove the leading `:' from the key
(seq-drop (symbol-name key) 1)
val)))
(cdr entry))
(insert (format "libsecret:name: %s\n" (car entry)))
(buffer-string)))
(defun pass-secrets--escape-entry-name (name)
"Return a string built from NAME with all slashes replaced."
(replace-regexp-in-string (regexp-quote "/") "$" name t t))
(defun pass-secrets--secrets-entries-to-sync ()
"Return all entries in the libsecret collection that need syncing.
Entries with a `pass:marker' attribute are already present in the
pass store, and are ignored."
(let* ((all-entries (secrets-list-items pass-secrets-collection))
(entries (seq-difference all-entries
(secrets-search-items pass-secrets-collection
:pass:marker "true"))))
(mapcar (lambda (name)
(let ((props (secrets-get-attributes pass-secrets-collection name)))
(cons name
props)))
entries)))
(defun pass-secrets--store-entries-to-sync ()
"Return the entries in the password store.
When `pass-secrets-use-incremental-syncs' is non-nil, return all
entries added and/or modified since the last sync."
(let ((all-files (pass-secrets--pass-files)))
(mapcar (lambda (file)
(cons file
(pass-secrets--parse-pass-file file)))
(if (and pass-secrets-use-incremental-syncs
pass-secrets-last-sync-time)
(seq-filter (lambda (file)
(> (time-to-seconds
(nth 5 (file-attributes file)))
pass-secrets-last-sync-time))
all-files)
all-files))))
(defun pass-secrets--parse-pass-file (file)
"Parse the content of the pass FILE."
(let ((visiting-buffer (find-buffer-visiting file)))
(with-current-buffer (find-file-noselect file)
(goto-char (point-min))
(let ((password (buffer-substring-no-properties (point-at-bol)
(point-at-eol)))
(props (pass-secrets--parse-pass-properties)))
(unless visiting-buffer
(kill-buffer (current-buffer)))
(append `((:password . ,password)
(:pass:file . ,file)
(:pass:marker . "true"))
props)))))
(defun pass-secrets--parse-pass-properties ()
"Parse the current buffer as a pass entry.
Return a plist properties (ignoring the password)."
(let (props)
(save-excursion
(goto-char (point-min))
(forward-line 1)
(while (re-search-forward "^\\([^\s\n]+\\):" nil t)
(setq props
(cons (cons (intern (format ":%s" (match-string 1)))
(string-trim (buffer-substring-no-properties
(point)
(point-at-eol))))
props))))
props))
(defun pass-secrets--pass-files ()
"Return a list of all pass entry files in `(password-store-dir)'."
(seq-filter (lambda (entry)
(string= (f-ext entry) "gpg"))
(f-entries (password-store-dir) nil t)))
(defun pass-secrets--read-data-file ()
"Read the file containing the last sync time."
(load pass-secrets-data-file t))
(defun pass-secrets--save-data-file ()
"Write the file containing the last sync time."
(make-directory (file-name-directory pass-secrets-data-file) t)
(with-temp-file pass-secrets-data-file
(insert ";; This file is automatically generated by pass-secrets.")
(newline)
(insert (format "(setq %s '%S)" "pass-secrets-last-sync-time" pass-secrets-last-sync-time))))
(provide 'pass-secrets)
;;; pass-secrets.el ends here