Easy automatic encryption for orgmode with age.el
Update 2023-05-20 : Explain use of SSH keys by age and modify elisp code to accommodate how org-roam generates daily notes.
Emacs and orgmode support encryption out-of-the-box, using GnuPG.
Like most mortals, I got scared of GPG after staring at its setup instructions in one afternoon1. After couple of years, I discovered 'age'2 for managing secrets in NixOS3. Age was easy enough to setup in single afternoon, and solid enough to keep running in background for more than a year without any intervention on my part. My kind of tech!
For background, instead of having to create and manage new public-private keys for GPG,
age can (re)use existing ssh keys. Most programmers are familiar and used to managing SSH keys, and since this is something we already have to, its nice to include another use case with them. I also find SSH keys more easy to reason about, with simple straightforward interactions. This is why I like and use
Please be aware, that
age, while being convenient and seeing increasing adoption, still hasn't had an independent audit. If your threat model requires higher scrutiny,
agemight not cut it.
So, when I found out that same ol'
age can be used to manage, encrypt, and decrypt my orgmode notes on the fly, I wanted it.
age.el uses same emacs and orgmode infrastructure that utilises GPG encrypted files, and makes it work with
It also integrated with
org-roam out-of-the-box, and pretty much Just Works™.
The only drawback I found was that a file must be encrypted before opening it.
age.el does not encrypt a plaintext file by itself, on the first run.
So, I wrote a tiny emacs-lisp function to do the same:
1(defun bhankas-org-age-encrypt-and-replace () 2 "Replace current org file with age-encrypted version" 3 (interactive) 4 (let* ((current-file-name (buffer-file-name)) 5 (encr-file-name (-> (buffer-file-name) 6 (string-trim) 7 (concat ".age"))) 8 (encr-file-exists nil)) 9 (when (string-suffix-p ".org" current-file-name) 10 (if (file-exists-p encr-file-name) 11 (progn 12 (message "Using existing encrypted version instead of overwriting") 13 (setq encr-file-exists t)) 14 (progn 15 (message "Encrypting file %s" current-file-name) 16 (shell-command (concat "rage -R ~/.ssh/some_key.pub -e " current-file-name " -o " encr-file-name)) 17 (when (file-exists-p encr-file-name) 18 (setq encr-file-exists t)))) 19 (when encr-file-exists 20 (doom/delete-this-file current-file-name t) 21 (find-file encr-file-name)))))
Now, this function:
- checks if the current file is an org-mode file (we don't want to accidently encrypt anything else, more on that in a moment)
- encrypts it with
age, it supports prompt for ssh key password)
- deletes the existing, unencrypted file (because whats the use of encryption if you're just going to keep the unencrypted version around.. And also to avoid conflicts in
- opens the new encrypted file, to make it seamless for the user.
There are few edge cases handled here, with defensive checks:
- Org-roam does not check if the
.org.agefile exists for daily notes. This is checked here, and the daily node is automatically removed in favor of existing encrypted version instead of overwriting it.
- After encryption command is run, the existence of encrypted version is checked before removing original plaintext file before removing it, so as to avoid data loss4.
This required some tiny modifications in rest of the config to make it even more seamless:
1(use-package! org-roam 2 :after (org age) 3 :init 4 (add-to-list 'auto-mode-alist '("\\.org\\.age" . org-mode))) 5 6(use-package! age 7 :after (org) 8 :commands (age-file-enable) 9 :init 10 (setq! age-program "rage" 11 age-default-identity "~/.ssh/some_key" 12 age-default-recipient "~/.ssh/some_key.pub") 13 (age-file-enable))
This takes care of dependency and loading order.5
However, this still requires manually doing
M-x bhankas-org-age-encrypt-and-replace. That is fine and desired, even, for existing nodes, but I wanted further automation of notes being encrypted. 100% of my org documents are created via
org-capture6, so the natural place and time to encrypt notes are right when they are created. But, I also wanted this to happen for only specific capture templates7.
With bit of fiddling8, this is also done:
1(defun bhankas-org-encrypt-for-template () 2 "Age-encrypt currently captured org-template" 3 (let ((desc (plist-get org-capture-plist :description))) 4 (if (or (string= desc "daily") 5 (string= desc "secret")) 6 (progn 7 (message "encrypting org document for template %s" desc) 8 (save-buffer) 9 (bhankas-org-age-encrypt-and-replace))))) 10 11(use-package! org-roam 12 :init 13 (add-hook! '(org-capture-after-finalize-hook org-roam-dailies-find-file-hook) 14 :append 15 #'bhankas-org-encrypt-for-template))
And that's it9!
The setup itself is not that intimidating, but the prospect of managing those keys forever going forward… nope.
I did lose couple of daily notes before I wised up to what was happening. The defensive coding might seem excessive, but automation should enhance, and not cause more anxiety.
Please note that
use-package! (notice the
!) macro comes from fantastic
doom-emacs. Non-doom-users can simply replace it with
use-package which is included in Emacs 29+. There are some other functions/macros used in this code that also come from doom-emacs, such as
add-hook!. For the most part they are fairly identical to their non-! counterparts, and are drop-in replacements.
like daily notes and some specific, personal notes, but not the generic topic based notes, those can be public and I want to be able to access them as plaintext docs.
And ChatGPT ;)
Note that while this works very well, org-roam still makes few assumptions that cause unexpected behavior. In particular, capturing to daily notes does not work, because org-roam does not recognise presence of
org.age extension in daily notes. I've tried
(setq org-roam-file-extenstions '("org" "org.age")) without luck. I might just open a bug report, but want to be sure first.