Skip to content

Instantly share code, notes, and snippets.

@matthewdowney
Last active May 17, 2020 20:46
Show Gist options
  • Star 1 You must be signed in to star a gist
  • Fork 0 You must be signed in to fork a gist
  • Save matthewdowney/d5d816a0274ea2d1fd5e9eab4a933e57 to your computer and use it in GitHub Desktop.
Save matthewdowney/d5d816a0274ea2d1fd5e9eab4a933e57 to your computer and use it in GitHub Desktop.
Lightweight key management with client-side encryption in Clojure applications.

For a more detailed explanation of what and why this is, check out this blog post.

To make use of these utilities for key management in a Clojure project, require [buddy/buddy-core "1.6.0"] and add a lein alias (or the equivalent to be able to run the code from the terminal) pointed at client-encrypt/edit-keys!.

Here's how mine looks:

:aliases {"edit-keys" ["run" "-m" "my.project.client-encrypt/edit-keys!"]}

Then, run it to initialize your encrypted keyfile at conf/keys.edn & establish a password.

$ lein trampoline edit-keys

You will need to $ apt install moreutils if your system doesn't have vipe installed.

Then use your favorite editor to write out a clojure map of keys, formatted however you want:

{:api-0 {:key "a", :secret "b"}
 :api-1 {:key "c", :secret "d"}}

As soon as you save & close the editor, the contents are encrypted to disk with the password that you supplied.

Then, call (client-encrypt/read-keys!) once at the entry point of your application, right when it starts up. It will prompt you for a password and return your map of keys.

????

Profit!!

(ns client-encrypt
"Utilities to for encrypting credentials (chiefly API keys),
storing them on disk, and editing them."
(:require [buddy.core.codecs :as codecs]
[buddy.core.nonce :as nonce]
[buddy.core.crypto :as crypto]
[buddy.core.kdf :as kdf]
[clojure.java.io :as io]
[clojure.java.shell :as sh]
[clojure.pprint :as pprint]
[clojure.string :as string])
(:import (java.util Base64)))
(set! *warn-on-reflection* true)
(defn bytes->b64 [^bytes b] (String. (.encode (Base64/getEncoder) b)))
(defn b64->bytes [^String s] (.decode (Base64/getDecoder) (.getBytes s)))
(defn slow-key-stretch-with-pbkdf2 [weak-text-key n-bytes]
(kdf/get-bytes
(kdf/engine {:key weak-text-key
:salt (b64->bytes "j3gT0zoPJos=")
:alg :pbkdf2
:digest :sha512
:iterations 1e5}) ;; target O(100ms) on commodity hardware
n-bytes))
(defn encrypt
"Encrypt and return a {:data <b64>, :iv <b64>} that can be decrypted with the
same `password`.
Performs pbkdf2 key stretching with quite a few iterations on `password`."
[clear-text password]
(let [initialization-vector (nonce/random-bytes 16)]
{:data (bytes->b64
(crypto/encrypt
(codecs/to-bytes clear-text)
(slow-key-stretch-with-pbkdf2 password 64)
initialization-vector
{:algorithm :aes256-cbc-hmac-sha512}))
:iv (bytes->b64 initialization-vector)}))
(defn decrypt
"Decrypt and return the clear text for some output of `encrypt` given the
same `password` used during encryption."
[{:keys [data iv]} password]
(codecs/bytes->str
(crypto/decrypt
(b64->bytes data)
(slow-key-stretch-with-pbkdf2 password 64)
(b64->bytes iv)
{:algorithm :aes256-cbc-hmac-sha512})))
(comment
;; Sufficiently slow to protect against brute force
(time (encrypt "some clear text" "my password"))
; "Elapsed time: 245.778331 msecs"
;=> {:data "2jttNkz8Uk2kQ7kyRMIyPSIYZRRyAa/+ACtjP+8M4w64Bp4tE2pyVNQV299EFsSJ",
; :iv "m8r6cuQICvlWjobE6sE7XQ=="}
(time (decrypt *1 "my password"))
; "Elapsed time: 188.044263 msecs"
;=> "some clear text"
)
(defn read-password
([] (read-password nil))
([prompt]
(if-let [console (System/console)]
(do
(when prompt (print prompt) (flush))
(String. (.readPassword console)))
(do
(println "[WARN] No secure console available, reading via plaintext.")
(when prompt (print prompt) (flush))
(read-line)))))
(defn encrypt-to-disk
[edn-compliant-data {:keys [password path]}]
(let [f (io/file path)]
(io/make-parents f)
(let [encrypted (encrypt (pr-str edn-compliant-data) password)]
(spit f (prn-str encrypted))
encrypted)))
(defn decrypt-from-disk
[{:keys [password path]}]
(let [f (io/file path)]
(if-not (.isFile f)
{}
(-> (slurp f)
(read-string)
(decrypt password)
(read-string)))))
;;; Helpers that assume you want to store your keys at kpath
(def ^:private kpath "conf/keys.edn")
(defn- read-keys []
(if (.isFile (io/file kpath))
(let [p (read-password "Password: ")]
{:data (decrypt-from-disk {:password p :path "conf/keys.edn"})
:password p})
{:data {}
:password nil}))
(defn- update-password? [{:keys [password] :as x}]
(let [change? (or
(nil? password)
(do (print "Encrypt with a new password? [y/N] ")
(flush)
(= (string/lower-case (read-line)) "y")))]
(if change?
(let [p (read-password "Set password: ")
p' (read-password "Confirm password: ")]
(if (= p p')
(assoc x :password p)
(do
(println "Passwords didn't match.")
(recur (assoc x :password nil)))))
x)))
(defn- edit-keys [{:keys [data] :as x}]
(let [pprinted (with-out-str (pprint/pprint data))
{:keys [exit out err] :as data}
(try
(sh/sh "vipe" :in pprinted)
(catch Exception e
(assoc (ex-data e)
:exit 1 :err (ex-message e))))]
(when-not (= exit 0)
(println "Failed to open decrypted keys with `vipe`!")
(println " Maybe try $ apt install moreutils")
(println err)
(throw (ex-info err data)))
(assoc x :data (read-string out))))
(defn- write-keys [{:keys [data password]}]
(print "Encrypting data for writing...")
(flush)
(let [enc (encrypt-to-disk data {:password password :path kpath})]
(println " Done.")
(println "Wrote" (count (.getBytes (prn-str enc))) "bytes.")
enc))
(defn edit-keys!
"Run from a terminal (maybe via a lein alias) to edit
the encrypted API keys with the default text editor."
[]
(-> (read-keys)
(update-password?)
(edit-keys)
(write-keys))
(System/exit 0))
(defn read-keys!
"Read & decrypt the API keys from disk.
Call this once at the top level of the application, right
when it starts up."
[]
(:data (read-keys)))
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment