Skip to content

Commit

Permalink
Merge pull request #2279 from gonewest818/unattended-signatures
Browse files Browse the repository at this point in the history
invoke GPG unattended with passphrase
  • Loading branch information
technomancy committed Jan 31, 2018
2 parents dd019de + 8eff187 commit c3de05b
Show file tree
Hide file tree
Showing 4 changed files with 137 additions and 26 deletions.
29 changes: 29 additions & 0 deletions doc/GPG.md
Expand Up @@ -16,12 +16,14 @@
- [How Leiningen uses GPG](#how-leiningen-uses-gpg)
- [Signing a file](#signing-a-file)
- [Overriding the gpg defaults](#overriding-the-gpg-defaults)
- [Setting the gpg passphrase for unattended deploys](#setting-the-gpg-passphrase-for-unattended-deploys)
- [Troubleshooting](#troubleshooting)
- [Debian based distributions](#debian-based-distributions-1)
- [gpg: can't query passphrase in batch mode](#gpg-cant-query-passphrase-in-batch-mode)
- [Mac OSX](#mac-osx)
- [Unable to get GPG installed via Homebrew and OSX Keychain to work](#unable-to-get-gpg-installed-via-homebrew-and-osx-keychain-to-work)
- [GPG doesn't ask for a passphrase](#gpg-doesnt-ask-for-a-passphrase)
- [gpg: decryption failed: secret key not available](#gpg-decryption-failed-secret-key-not-available)
- [GPG prompts for passphrase but does not work with Leiningen](#gpg-prompts-for-passphrase-but-does-not-work-with-leiningen)

<!-- END doctoc generated TOC please keep comment here to allow auto update -->
Expand Down Expand Up @@ -250,6 +252,33 @@ repository specification in your project definition:
["snapshots" "https://blueant.com/archiva/internal/snapshots"]]
...)

### Setting the gpg passphrase for unattended deploys

It's also possible to provide the passphrase required to unlock your
keyring. This is meant only for unattended deploys, for example in a
continuous integration system like Travis CI or CircleCI or Jenkins.

Passphrase can be configured in the environment:

(defproject ham-biscuit "0.1.0"
...
:signing {:gpg-key "bob@bobsons.net"
:gpg-passphrase :env/gpgpass} ;; looks up GPGPASS from env
...)

In your CI service your gpg keyring will need to be encrypted and
injected into the build, and the passphrase likewise encrypted such that
the environment variable is visible only to the build.

For testing purposes the pasphrase can also be set as a string literal
but this is strongly discouraged in any production usage.

(defproject ham-biscuit "0.1.0"
...
:signing {:gpg-key "bob@bobsons.net"
:gpg-passphrase "my-passphrase-in-the-clear"}
...)

## Troubleshooting

### Debian based distributions
Expand Down
76 changes: 56 additions & 20 deletions leiningen-core/src/leiningen/core/user.clj
Expand Up @@ -2,7 +2,6 @@
"Functions exposing user-level configuration."
(:require [clojure.java.io :as io]
[clojure.string :as str]
[clojure.java.shell :as shell]
[leiningen.core.utils :as utils])
(:import (com.hypirion.io Pipe)
(org.apache.commons.io.output TeeOutputStream)
Expand Down Expand Up @@ -104,20 +103,30 @@
[env]
(into-array String (map (fn [[k v]] (str (name k) "=" v)) env)))

(defn gpg
"Shells out to (gpg-program) with the given arguments"
[& args]
(defn gpg-with-passphrase
"Shells out to (gpg-program) with the given arguments and, if
passphrase is not nil, sends the passphrase on stdin for unattended
operations such as signing artifacts for deployment. When a passphrase
is provided the caller must include the following args
[\"--passphrase-fd\" \"0\" \"--pinentry-mode\" \"loopback\"]
along with whatever other args are needed for the gpg command."
[passphrase & args]
(try
(let [proc-env (as-env-strings (get-english-env))
proc-args (into-array String (concat [(gpg-program)] args))
proc (.exec (Runtime/getRuntime) proc-args proc-env)]
(.addShutdownHook (Runtime/getRuntime)
(Thread. (fn [] (.destroy proc))))
(with-open [out (.getInputStream proc)
err (.getErrorStream proc)
err-output (ByteArrayOutputStream.)]
(let [pump-err (doto (Pipe. (.getErrorStream proc)
(TeeOutputStream. System/err err-output))
.start)]
(if passphrase
(with-open [in (.getOutputStream proc)]
(io/copy passphrase in)))
(let [pump-err (doto (Pipe. err
(TeeOutputStream. System/err
err-output))
.start)]
(.join pump-err)
(let [exit-code (.waitFor proc)]
{:exit exit-code
Expand All @@ -126,11 +135,26 @@
(catch java.io.IOException e
{:exit 1 :out "" :err (.getMessage e)})))

(defn gpg
"Shells out to (gpg-program) with the given arguments"
[& args]
(apply gpg-with-passphrase nil args))

(defn gpg-available?
"Verifies (gpg-program) exists"
[]
(zero? (:exit (gpg "--version"))))

(defn gpg-version
"parse and return the version of gpg available"
[]
(let [pattern #"gpg\s+\(GnuPG\)\s+(\d+)\.(\d+)\.(\d+)"]
(if-let [[_ major minor patch]
(re-find pattern (:out (gpg "--version")))]
{:major (Integer/parseInt major)
:minor (Integer/parseInt minor)
:patch (Integer/parseInt patch)})))

(defn credentials-fn
"Decrypt map from credentials.clj.gpg in Leiningen home if present."
([] (let [cred-file (io/file (leiningen-home) "credentials.clj.gpg")]
Expand All @@ -156,25 +180,37 @@
(re-find re? (:url settings)))]
cred))))

(defn resolve-env-keyword
"Resolve usage of :env and :env/foo in project.clj"
[k v]
(cond (= :env v)
(getenv (str "LEIN_"
(-> (name k)
(str/upper-case)
(str/replace "-" "_"))))

(and (keyword? v) (= "env" (namespace v)))
(getenv (str/upper-case (name v)))

:else nil))

(defn- resolve-gpg-keyword
"Resolve usage of :gpg in project.clj"
[source-settings k v]
(cond (= :gpg v)
(get (match-credentials source-settings (credentials)) k)))

(defn- resolve-credential
"Resolve key-value pair from result into a credential, updating result."
[source-settings result [k v]]
(letfn [(resolve [v]
(cond (= :env v)
(getenv (str "LEIN_" (str/upper-case (name k))))

(and (keyword? v) (= "env" (namespace v)))
(getenv (str/upper-case (name v)))

(= :gpg v)
(get (match-credentials source-settings (credentials)) k)

(coll? v) ;; collection of places to look
(or (resolve-env-keyword k v)
(resolve-gpg-keyword source-settings k v)
(if (coll? v) ;; collection of places to look
(->> (map resolve v)
(remove nil?)
first)

:else v))]
first))
v))]
(if (#{:username :password :passphrase :private-key-file} k)
(assoc result k (resolve v))
(assoc result k v))))
Expand Down
31 changes: 26 additions & 5 deletions src/leiningen/deploy.clj
Expand Up @@ -74,16 +74,37 @@
(add-auth-interactively))))

(defn signing-args
"Produce GPG arguments for signing a file."
"Produce GPG arguments for signing a file, taking the version of gpg
into account as necessary."
[file opts]
(let [key-spec (if-let [key (:gpg-key opts)]
["--default-key" key])]
`["--yes" "-ab" ~@key-spec "--" ~file]))
(let [key-args (concat
(if-let [key (:gpg-key opts)]
["--default-key" key])
(if (:gpg-passphrase opts)
(let [{:keys [major minor patch]} (user/gpg-version)
version (+ major (/ minor 10.))]
(if (> version 2.0)
; gpg 2.1 and newer
["--passphrase-fd" "0"
"--pinentry-mode" "loopback"]
; gpg 2.0 and older
["--passphrase-fd" "0" "--batch"]))))]
`["--yes" "-ab" ~@key-args "--" ~file]))

(defn signing-passphrase
"Produce GPG passphrase if specified in project"
[opts]
(if-let [pp (:gpg-passphrase opts)]
(or (user/resolve-env-keyword :gpg-passphrase pp)
pp)))

(defn sign
"Create a detached signature and return the signature file name."
[file opts]
(let [{:keys [err exit]} (apply user/gpg (signing-args file opts))]
(let [pass (signing-passphrase opts)
args (signing-args file opts)
_ (main/info "Signing: gpg " args)
{:keys [err exit]} (apply user/gpg-with-passphrase (cons pass args))]
(when-not (zero? exit)
(main/abort "Could not sign"
(str file "\n" err (if err "\n")
Expand Down
27 changes: 26 additions & 1 deletion test/leiningen/test/deploy.clj
Expand Up @@ -2,6 +2,7 @@
(:use [clojure.test]
[clojure.java.io :only [file]]
[leiningen.deploy]
[leiningen.core.user :as user]
[leiningen.test.helper :only [delete-file-recursively
tmp-dir sample-project
sample-deploy-project]]))
Expand Down Expand Up @@ -66,7 +67,31 @@
(is (= (signing-args "foo.jar" nil)
["--yes" "-ab" "--" "foo.jar"]))
(is (= (signing-args "foo.jar" {:gpg-key "123456"})
["--yes" "-ab" "--default-key" "123456" "--" "foo.jar"])))
["--yes" "-ab" "--default-key" "123456" "--" "foo.jar"]))
(with-redefs [user/gpg-version (fn [] {:major 2 :minor 1 :patch 0})]
(is (= (signing-args "foo.jar" {:gpg-key "123456" :gpg-passphrase "abc"})
["--yes" "-ab" "--default-key" "123456"
"--passphrase-fd" "0" "--pinentry-mode" "loopback"
"--" "foo.jar"]))
(is (= (signing-args "foo.jar" {:gpg-passphrase "abc"})
["--yes" "-ab"
"--passphrase-fd" "0" "--pinentry-mode" "loopback"
"--" "foo.jar"])))
(with-redefs [user/gpg-version (fn [] {:major 1 :minor 4 :patch 0})]
(is (= (signing-args "foo.jar" {:gpg-key "123456" :gpg-passphrase "abc"})
["--yes" "-ab" "--default-key" "123456"
"--passphrase-fd" "0" "--batch"
"--" "foo.jar"]))
(is (= (signing-args "foo.jar" {:gpg-passphrase "abc"})
["--yes" "-ab"
"--passphrase-fd" "0" "--batch"
"--" "foo.jar"])))
(is (= (signing-passphrase {}) nil))
(is (= (signing-passphrase {:gpg-passphrase "abc"}) "abc"))
(with-redefs [user/getenv (fn [v] (if (= v "LEIN_GPG_PASSPHRASE") "abc" nil))]
(is (= (signing-passphrase {:gpg-passphrase :env}) "abc")))
(with-redefs [user/getenv (fn [v] (if (= v "MYPASS") "abc" nil))]
(is (= (signing-passphrase {:gpg-passphrase :env/mypass}) "abc"))))
(testing "Key selection"
(is (= (:gpg-key (signing-opts {:signing {:gpg-key "key-project"}}
["repo" {:signing {:gpg-key "key-repo"}}]))
Expand Down

0 comments on commit c3de05b

Please sign in to comment.