Age.el
Transparent age encryption support for Emacs modeled after EPG/EPA
Install / Use
/learn @anticomputer/Age.elREADME
[[https://melpa.org/#/age][file:https://melpa.org/packages/age-badge.svg]]
- age.el: age encryption support for Emacs
#+html:<p align="center"><img src="img/emacs-age.png"/></p>
age.el provides transparent [[https://github.com/FiloSottile/age][Age]] file encryption and decryption in Emacs. It is based on the Emacs [[http://epg.osdn.jp/][EasyPG]] code and offers similar Emacs file handling for [[https://github.com/C2SP/C2SP/blob/main/age.md][Age encrypted files]].
Using age.el you can, for example, maintain ~.org.age~ encrypted Org files, provide Age encrypted authentication information out of ~.authinfo.age~, and open/edit/save Age encrypted files via TRAMP.
NOTE: a full featured [[https://github.com/FiloSottile/passage][passage]] Emacs package that functions with age.el is available [[https://github.com/anticomputer/passage.el][here]].
- Usage
Age is available on [[https://melpa.org/#/age][melpa]] and you can install it from there:
#+begin_src emacs-lisp (use-package age :ensure t :demand t :config (age-file-enable)) #+end_src
Alternatively, put ~age.el~ in your ~load-path~ and:
#+begin_src emacs-lisp (require 'age) (age-file-enable) #+end_src
You can now open, edit, and save Age encryted files from Emacs as long as they end with the ~.age~ file extension. You can also ~find-file~ new Age files and they will be encrypted to the ~age-default-recipient~ on first save.
Identities (private keys) and recipients (public keys) are maintained via the customizable ~age-default-identity~ and ~age-default-recipient~ variables. By default they are set to =~/.ssh/id_rsa= and =~/.ssh/id_rsa.pub= respectively.
age.el tries to remain composable with the core philosophy of age itself and as such does not try to provide a kitchen sink worth of features.
- Example configuration
You can find my current configuration for age.el below. I am using [[https://github.com/str4d/age-plugin-yubikey][age-yubikey-plugin]] to supply an age identity off of a yubikey PIV slot. The slot is configured to require a touch (with a 15 second cache) for every age client query against the identity stored in that slot.
This means that every age.el decrypt requires a physical touch for confirmation. The cache makes it such that e.g. decrypting a series of age encrypted org files in sequence only requires a single touch confirmation.
This limits the amount of actively accessible encrypted data inside Emacs to only the things I physically confirm, and only for 15 second windows, but without having to type a passphrase at any point. This excludes any open buffers that have decrypted data in memory of course.
The key scheme I employ encrypts against the public keys of two main identities. My aforementioned yubikey identity as well as a disaster recovery identity, who's private key is passphrase encrypted and kept in cold storage.
You'll note that I've set ~age-default-identity~ and ~age-default-recipient~ to be lists. These two variables can be file paths, key strings, or lists that contain a mix of both. This allows you to easily encrypt to a series of identities in whatever way you choose to store and manage them.
Note that I'm using [[https://github.com/str4d/rage][rage]] as opposed to [[https://github.com/FiloSottile/age][age]] as my age client. This is due the aforementioned lack of pinentry support in the reference age implemention, which rage does support.
#+begin_src emacs-lisp (use-package age :quelpa (age :fetcher github :repo "anticomputer/age.el") :ensure t :demand t :custom ;; you should customize these and not just setq them ;; while it won't break anything, age.el checks for ;; variable customizations to supersede auto configs ;; this only becomes an issue if you e.g. have both ;; rage and age installed on a system and want to ;; ensure that age-program is actually what is used ;; as opposed to the first found compatible version ;; of a supported Age client (age-program "rage") (age-default-identity "~/.ssh/age_yubikey") (age-default-recipient '("~/.ssh/age_yubikey.pub" "~/.ssh/age_recovery.pub")) :config (age-file-enable)) #+end_src
I use the above configuration in combination with a version of ~org-roam~ that has the following patches applied:
https://patch-diff.githubusercontent.com/raw/org-roam/org-roam/pull/2302.patch
This patch enables ~.org.age~ discoverability in ~org-roam~ and beyond that everything just works the same as you're used to with ~.org.gpg~ files. This patch was merged into org-roam ~main~ on Dec 31, 2022, so any org-roam release post that date should provide you with age support out of the box.
- Other fun examples
** Encrypting a file to a given GitHub username's ssh keys
#+begin_src emacs-lisp (defun my/age-github-keys-for (username) "Turn GitHub USERNAME into a list of ssh public keys." (let* ((res (shell-command-to-string (format "curl -s https://api.github.com/users/%s/keys" (shell-quote-argument username)))) (json (json-parse-string res :object-type 'alist))) (cl-assert (arrayp json)) (cl-loop for alist across json for key = (cdr (assoc 'key alist)) when (and (stringp key) (string-match-p "^\(ssh-rsa\|ssh-ed25519\) AAAA" key)) collect key)))
(defun my/age-save-with-github-recipient (username) "Encrypt an age file to the public keys of GitHub USERNAME." (interactive "MGitHub username: ") (cl-letf (((symbol-value 'age-default-recipient) (append (if (listp age-default-recipient) age-default-recipient (list age-default-recipient)) (my/age-github-keys-for username)))) (save-buffer))) #+end_src
** Visual indicators of encryption and decryption in progress
Since I use a yubikey touch controlled age identity I find it useful to have a visual indication of when age.el is performing operations that might require me to touch the yubikey. The following advice adds visual notifications to ~age-start-decrypt~ and ~age-start-encrypt~.
I'm also using this as a way to get a good feel for just how much Emacs is interacting with my encrypted data.
#+begin_src emacs-lisp (require 'notifications)
(defun my/age-notify (msg &optional simple) (cond (simple (message (format "%s" msg))) ((eq system-type 'gnu/linux) (notifications-notify :title "age.el" :body (format "%s" msg) :urgency 'low :timeout 800)) ((eq system-type 'darwin) (do-applescript (format "display notification "%s" with title "age.el"" msg))) (t (message (format "%s" msg)))))
(defun my/age-notify-decrypt (&rest args) (cl-destructuring-bind (context cipher) args (my/age-notify (format "Decrypting %s" (age-data-file cipher)) t)))
(defun my/age-notify-encrypt (&rest args) (cl-destructuring-bind (context plain recipients) args (my/age-notify (format "Encrypting %s" (age-data-file plain)) t)))
(defun my/age-toggle-decrypt-notifications () (interactive) (cond ((advice-member-p #'my/age-notify-decrypt #'age-start-decrypt) (advice-remove #'age-start-decrypt #'my/age-notify-decrypt) (message "Disabled age decrypt notifications.")) (t (advice-add #'age-start-decrypt :before #'my/age-notify-decrypt) (message "Enabled age decrypt notifications."))))
(defun my/age-toggle-encrypt-notifications () (interactive) (cond ((advice-member-p #'my/age-notify-encrypt #'age-start-encrypt) (advice-remove #'age-start-encrypt #'my/age-notify-encrypt) (message "Disabled age encrypt notifications.")) (t (advice-add #'age-start-encrypt :before #'my/age-notify-encrypt) (message "Enabled age encrypt notifications."))))
;; we only care about decrypt notifications really (my/age-toggle-decrypt-notifications) (my/age-toggle-encrypt-notifications) #+end_src
- Known issues
** Lack of pinentry support in age reference implementation
The [[https://github.com/FiloSottile/age][age reference implementation]] does not support pinentry by design. Users are encouraged to use identity (private) keys and recipient (public) keys, and manage those secrets accordingly.
*** Workaround: pinentry support through rage
You can work around this by using [[https://github.com/str4d/rage][rage]] instead of age, which is a Rust based implementation of the [[https://github.com/C2SP/C2SP/blob/main/age.md][Age spec]] which does support pinentry by default. age.el will work with rage as well. An example rage config may look like:
#+begin_src emacs-lisp (use-package age :ensure t :demand t :custom (age-program "rage") :config (age-file-enable)) #+end_src
You will now be able to use passphrase protected Age identities and files.
#+html:<p align="center"><img src="img/emacs-rage.png"/></p>
**** Rage pinentry troubleshooting
If you find that you are having trouble with rage's ability to decrypt pass phrase encrypted age identities or files, please ensure that the ~pinentry~ program in your PATH is actually the one you intend to use and that it is compatible with your Emacs workflow. If you have multiple pinentry programs available and want to ensure rage uses a particular one, you can set its ~PINENTRY_PROGRAM~ environment variable accordingly.
For example, if you would like to ensure rage is using ~pinentry-something~ you can set ~PINENTRY_PROGRAM~ in your age.el configuration:
#+begin_src emacs-lisp (use-package age :ensure t :demand t :custom (age-program "rage") :config (setenv "PINENTRY_PROGRAM" "pinentry-something") (age-file-enable)) #+end_src
Likewise, it is wise to check that whichever pinentry solution you decide on is actually available to and compatible with your Emacs environment.
*** Tip: configuring pinentry-emacs for minibuffer passphrase entry
If you'd like to keep y
Related Skills
openhue
351.4kControl Philips Hue lights and scenes via the OpenHue CLI.
sag
351.4kElevenLabs text-to-speech with mac-style say UX.
weather
351.4kGet current weather and forecasts via wttr.in or Open-Meteo
tweakcc
1.6kCustomize Claude Code's system prompts, create custom toolsets, input pattern highlighters, themes/thinking verbs/spinners, customize input box & user message styling, support AGENTS.md, unlock private/unreleased features, and much more. Supports both native/npm installs on all platforms.
