|
;;; 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
|