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

  1. ;;; pass-secrets.el --- Sync password-store and libsecret collections -*- lexical-binding: t; -*-
  2. ;; Copyright (C) 2017 Nicolas Petton
  3. ;; Author: Nicolas Petton <nicolas@petton.fr>
  4. ;; Version: 0.1.0
  5. ;; Package-Requires: ((emacs "25.1") (f "0.19.0") (pass "1.7"))
  6. ;; GIT: https://gitlab.petton.fr/nico/pass-secrets.el.git
  7. ;; Keywords: tools
  8. ;; This program is free software; you can redistribute it and/or modify
  9. ;; it under the terms of the GNU General Public License as published by
  10. ;; the Free Software Foundation, either version 3 of the License, or
  11. ;; (at your option) any later version.
  12. ;; This program is distributed in the hope that it will be useful,
  13. ;; but WITHOUT ANY WARRANTY; without even the implied warranty of
  14. ;; MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
  15. ;; GNU General Public License for more details.
  16. ;; You should have received a copy of the GNU General Public License
  17. ;; along with this program. If not, see <http://www.gnu.org/licenses/>.
  18. ;;; Commentary:
  19. ;; Sync a libsecret collection with pass (password-store).
  20. ;; Use `pass-secrets-sync' to start the synchronization process.
  21. ;;
  22. ;; The synchronization is done both ways, never deleting any entry from pass or
  23. ;; libsecret. If an entry is deleted in one of the stores, it will be added
  24. ;; back by the synchronization as long as it is present in the other store.
  25. ;; Synchronization is done by first adding/updating libsecret entries from pass
  26. ;; entries. Then missing pass entries are created from libsecret entries. This
  27. ;; means when entries have been modified in both stores, pass always wins.
  28. ;; When `pass-secrets-use-incremental-syncs' is non-nil, only update pass
  29. ;; entries that changes since the last sync.
  30. ;;; Code:
  31. (require 'seq)
  32. (require 'map)
  33. (require 'f)
  34. (require 'subr-x)
  35. (require 'secrets)
  36. (require 'pass)
  37. (defgroup pass-secrets '()
  38. "Synchronize password-store and libsecret."
  39. :group 'password-store)
  40. (defcustom pass-secrets-collection "Login"
  41. "Libsecret collection to sync."
  42. :type 'string)
  43. (defcustom pass-secrets-use-incremental-syncs t
  44. "When non-nil, use incremental syncing."
  45. :type 'boolean)
  46. (defcustom pass-secrets-data-file (locate-user-emacs-file ".pass-secrets-sync.el")
  47. "File used to store the last sync time."
  48. :type 'string)
  49. (defvar pass-secrets-last-sync-time nil)
  50. ;;;###autoload
  51. (defun pass-secrets-sync ()
  52. "Synchronize the password-store and a libsecret collection.
  53. Synchronization is done by first adding/updating libsecret
  54. entries from pass entries. Then missing pass entries are created
  55. from libsecret entries. This means when entries have been
  56. modified in both stores, pass always wins.
  57. The libsecret collection to sync is defined by `pass-secrets-collection'."
  58. (interactive)
  59. (pass-secrets-sync-to-secrets)
  60. (pass-secrets-sync-to-pass))
  61. ;;;###autoload
  62. (defun pass-secrets-sync-to-secrets ()
  63. "Sync from pass to libsecret."
  64. (when pass-secrets-use-incremental-syncs
  65. (pass-secrets--read-data-file))
  66. (let ((entries (pass-secrets--store-entries-to-sync)))
  67. (mapc (lambda (entry)
  68. (pass-secrets--sync-pass-entry-to-secrets entry))
  69. entries))
  70. (when pass-secrets-use-incremental-syncs
  71. (setq pass-secrets-last-sync-time (time-to-seconds (current-time)))
  72. (pass-secrets--save-data-file)))
  73. (defun pass-secrets--sync-pass-entry-to-secrets (entry)
  74. "Sync the pass ENTRY with the libsecret collection."
  75. (message "Syncing pass entry %s" (car entry))
  76. (let ((name (or (map-elt (cdr entry) :libsecret:name)
  77. (password-store--file-to-entry (car entry)))))
  78. (when-let ((match (car (secrets-search-items
  79. pass-secrets-collection
  80. :pass:file
  81. (car entry)))))
  82. (secrets-delete-item pass-secrets-collection match))
  83. (apply #'secrets-create-item
  84. pass-secrets-collection
  85. name
  86. (map-elt (cdr entry) :password)
  87. (pass-secrets--alist-to-plist (map-delete (cdr entry) :password)))))
  88. (defun pass-secrets--alist-to-plist (alist)
  89. "Convert association list ALIST into the equivalent property-list form."
  90. (let (plist)
  91. (while alist
  92. (let ((el (car alist)))
  93. (setq plist (cons (cdr el) (cons (car el) plist))))
  94. (setq alist (cdr alist)))
  95. (nreverse plist)))
  96. ;;;###autoload
  97. (defun pass-secrets-sync-to-pass ()
  98. "Sync from libsecret to pass.
  99. If syncing a secrets entry would result in overriding a pass
  100. entry, skip that entry."
  101. (mapc (lambda (entry)
  102. (let* ((name (pass-secrets--escape-entry-name (car entry)))
  103. (file (password-store--entry-to-file name))
  104. (password (secrets-get-secret pass-secrets-collection (car entry)))
  105. (contents (pass-secrets--secrets-entry-to-pass entry password)))
  106. (if (f-exists-p file)
  107. (message "Will not override pass entry %s" file)
  108. (progn
  109. (message "Syncing libsecret entry %s" (car entry))
  110. (shell-command-to-string (format "echo %s | %s insert -m -f %s"
  111. (shell-quote-argument contents)
  112. password-store-executable
  113. (shell-quote-argument name)))
  114. ;; Now that we have our new pass entry, sync back from pass to
  115. ;; libsecret to add new metadata for later synchronizations.
  116. (secrets-delete-item pass-secrets-collection (car entry))
  117. (pass-secrets--sync-pass-entry-to-secrets
  118. (cons file
  119. (pass-secrets--parse-pass-file file)))))))
  120. (pass-secrets--secrets-entries-to-sync)))
  121. (defun pass-secrets--secrets-entry-to-pass (entry password)
  122. "Convert the libsecret ENTRY with PASSWORD to pass format."
  123. (with-temp-buffer
  124. (insert password)
  125. (insert "\n")
  126. (map-apply (lambda (key val)
  127. (insert (format "%s: %s\n"
  128. ;; Remove the leading `:' from the key
  129. (seq-drop (symbol-name key) 1)
  130. val)))
  131. (cdr entry))
  132. (insert (format "libsecret:name: %s\n" (car entry)))
  133. (buffer-string)))
  134. (defun pass-secrets--escape-entry-name (name)
  135. "Return a string built from NAME with all slashes replaced."
  136. (replace-regexp-in-string (regexp-quote "/") "$" name t t))
  137. (defun pass-secrets--secrets-entries-to-sync ()
  138. "Return all entries in the libsecret collection that need syncing.
  139. Entries with a `pass:marker' attribute are already present in the
  140. pass store, and are ignored."
  141. (let* ((all-entries (secrets-list-items pass-secrets-collection))
  142. (entries (seq-difference all-entries
  143. (secrets-search-items pass-secrets-collection
  144. :pass:marker "true"))))
  145. (mapcar (lambda (name)
  146. (let ((props (secrets-get-attributes pass-secrets-collection name)))
  147. (cons name
  148. props)))
  149. entries)))
  150. (defun pass-secrets--store-entries-to-sync ()
  151. "Return the entries in the password store.
  152. When `pass-secrets-use-incremental-syncs' is non-nil, return all
  153. entries added and/or modified since the last sync."
  154. (let ((all-files (pass-secrets--pass-files)))
  155. (mapcar (lambda (file)
  156. (cons file
  157. (pass-secrets--parse-pass-file file)))
  158. (if (and pass-secrets-use-incremental-syncs
  159. pass-secrets-last-sync-time)
  160. (seq-filter (lambda (file)
  161. (> (time-to-seconds
  162. (nth 5 (file-attributes file)))
  163. pass-secrets-last-sync-time))
  164. all-files)
  165. all-files))))
  166. (defun pass-secrets--parse-pass-file (file)
  167. "Parse the content of the pass FILE."
  168. (let ((visiting-buffer (find-buffer-visiting file)))
  169. (with-current-buffer (find-file-noselect file)
  170. (goto-char (point-min))
  171. (let ((password (buffer-substring-no-properties (point-at-bol)
  172. (point-at-eol)))
  173. (props (pass-secrets--parse-pass-properties)))
  174. (unless visiting-buffer
  175. (kill-buffer (current-buffer)))
  176. (append `((:password . ,password)
  177. (:pass:file . ,file)
  178. (:pass:marker . "true"))
  179. props)))))
  180. (defun pass-secrets--parse-pass-properties ()
  181. "Parse the current buffer as a pass entry.
  182. Return a plist properties (ignoring the password)."
  183. (let (props)
  184. (save-excursion
  185. (goto-char (point-min))
  186. (forward-line 1)
  187. (while (re-search-forward "^\\([^\s\n]+\\):" nil t)
  188. (setq props
  189. (cons (cons (intern (format ":%s" (match-string 1)))
  190. (string-trim (buffer-substring-no-properties
  191. (point)
  192. (point-at-eol))))
  193. props))))
  194. props))
  195. (defun pass-secrets--pass-files ()
  196. "Return a list of all pass entry files in `(password-store-dir)'."
  197. (seq-filter (lambda (entry)
  198. (string= (f-ext entry) "gpg"))
  199. (f-entries (password-store-dir) nil t)))
  200. (defun pass-secrets--read-data-file ()
  201. "Read the file containing the last sync time."
  202. (load pass-secrets-data-file t))
  203. (defun pass-secrets--save-data-file ()
  204. "Write the file containing the last sync time."
  205. (make-directory (file-name-directory pass-secrets-data-file) t)
  206. (with-temp-file pass-secrets-data-file
  207. (insert ";; This file is automatically generated by pass-secrets.")
  208. (newline)
  209. (insert (format "(setq %s '%S)" "pass-secrets-last-sync-time" pass-secrets-last-sync-time))))
  210. (provide 'pass-secrets)
  211. ;;; pass-secrets.el ends here