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 age.

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, age might 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.

Enter age.el.

age.el uses same emacs and orgmode infrastructure that utilises GPG encrypted files, and makes it work with age. 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/ -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:

  1. 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)
  2. encrypts it with rage (because unlike age, it supports prompt for ssh key password)
  3. 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 org-roam database)
  4. opens the new encrypted file, to make it seamless for the user.

There are few edge cases handled here, with defensive checks:

  1. Org-roam does not check if the .org.age file 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.
  2. 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)))
 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/")
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)))))
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.


..and its API compatible replacement in Rust: 'rage'.


via fantastic agenix.


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 doom/delete-this-file, setq! and add-hook!. For the most part they are fairly identical to their non-! counterparts, and are drop-in replacements.


Or its cousin org-roam-capture.


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.