Merge pull request #2279 from gonewest818/unattended-signatures

invoke GPG unattended with passphrase
This commit is contained in:
Phil Hagelberg 2018-01-30 19:28:37 -08:00 committed by GitHub
commit c3de05b51f
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
4 changed files with 137 additions and 26 deletions

View file

@ -16,12 +16,14 @@
- [How Leiningen uses GPG](#how-leiningen-uses-gpg) - [How Leiningen uses GPG](#how-leiningen-uses-gpg)
- [Signing a file](#signing-a-file) - [Signing a file](#signing-a-file)
- [Overriding the gpg defaults](#overriding-the-gpg-defaults) - [Overriding the gpg defaults](#overriding-the-gpg-defaults)
- [Setting the gpg passphrase for unattended deploys](#setting-the-gpg-passphrase-for-unattended-deploys)
- [Troubleshooting](#troubleshooting) - [Troubleshooting](#troubleshooting)
- [Debian based distributions](#debian-based-distributions-1) - [Debian based distributions](#debian-based-distributions-1)
- [gpg: can't query passphrase in batch mode](#gpg-cant-query-passphrase-in-batch-mode) - [gpg: can't query passphrase in batch mode](#gpg-cant-query-passphrase-in-batch-mode)
- [Mac OSX](#mac-osx) - [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) - [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 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) - [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 --> <!-- END doctoc generated TOC please keep comment here to allow auto update -->
@ -250,6 +252,33 @@ repository specification in your project definition:
["snapshots" "https://blueant.com/archiva/internal/snapshots"]] ["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 ## Troubleshooting
### Debian based distributions ### Debian based distributions

View file

@ -2,7 +2,6 @@
"Functions exposing user-level configuration." "Functions exposing user-level configuration."
(:require [clojure.java.io :as io] (:require [clojure.java.io :as io]
[clojure.string :as str] [clojure.string :as str]
[clojure.java.shell :as shell]
[leiningen.core.utils :as utils]) [leiningen.core.utils :as utils])
(:import (com.hypirion.io Pipe) (:import (com.hypirion.io Pipe)
(org.apache.commons.io.output TeeOutputStream) (org.apache.commons.io.output TeeOutputStream)
@ -104,9 +103,14 @@
[env] [env]
(into-array String (map (fn [[k v]] (str (name k) "=" v)) env))) (into-array String (map (fn [[k v]] (str (name k) "=" v)) env)))
(defn gpg (defn gpg-with-passphrase
"Shells out to (gpg-program) with the given arguments" "Shells out to (gpg-program) with the given arguments and, if
[& args] 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 (try
(let [proc-env (as-env-strings (get-english-env)) (let [proc-env (as-env-strings (get-english-env))
proc-args (into-array String (concat [(gpg-program)] args)) proc-args (into-array String (concat [(gpg-program)] args))
@ -114,10 +118,15 @@
(.addShutdownHook (Runtime/getRuntime) (.addShutdownHook (Runtime/getRuntime)
(Thread. (fn [] (.destroy proc)))) (Thread. (fn [] (.destroy proc))))
(with-open [out (.getInputStream proc) (with-open [out (.getInputStream proc)
err (.getErrorStream proc)
err-output (ByteArrayOutputStream.)] err-output (ByteArrayOutputStream.)]
(let [pump-err (doto (Pipe. (.getErrorStream proc) (if passphrase
(TeeOutputStream. System/err err-output)) (with-open [in (.getOutputStream proc)]
.start)] (io/copy passphrase in)))
(let [pump-err (doto (Pipe. err
(TeeOutputStream. System/err
err-output))
.start)]
(.join pump-err) (.join pump-err)
(let [exit-code (.waitFor proc)] (let [exit-code (.waitFor proc)]
{:exit exit-code {:exit exit-code
@ -126,11 +135,26 @@
(catch java.io.IOException e (catch java.io.IOException e
{:exit 1 :out "" :err (.getMessage 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? (defn gpg-available?
"Verifies (gpg-program) exists" "Verifies (gpg-program) exists"
[] []
(zero? (:exit (gpg "--version")))) (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 (defn credentials-fn
"Decrypt map from credentials.clj.gpg in Leiningen home if present." "Decrypt map from credentials.clj.gpg in Leiningen home if present."
([] (let [cred-file (io/file (leiningen-home) "credentials.clj.gpg")] ([] (let [cred-file (io/file (leiningen-home) "credentials.clj.gpg")]
@ -156,25 +180,37 @@
(re-find re? (:url settings)))] (re-find re? (:url settings)))]
cred)))) 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 (defn- resolve-credential
"Resolve key-value pair from result into a credential, updating result." "Resolve key-value pair from result into a credential, updating result."
[source-settings result [k v]] [source-settings result [k v]]
(letfn [(resolve [v] (letfn [(resolve [v]
(cond (= :env v) (or (resolve-env-keyword k v)
(getenv (str "LEIN_" (str/upper-case (name k)))) (resolve-gpg-keyword source-settings k v)
(if (coll? v) ;; collection of places to look
(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
(->> (map resolve v) (->> (map resolve v)
(remove nil?) (remove nil?)
first) first))
v))]
:else v))]
(if (#{:username :password :passphrase :private-key-file} k) (if (#{:username :password :passphrase :private-key-file} k)
(assoc result k (resolve v)) (assoc result k (resolve v))
(assoc result k v)))) (assoc result k v))))

View file

@ -74,16 +74,37 @@
(add-auth-interactively)))) (add-auth-interactively))))
(defn signing-args (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] [file opts]
(let [key-spec (if-let [key (:gpg-key opts)] (let [key-args (concat
["--default-key" key])] (if-let [key (:gpg-key opts)]
`["--yes" "-ab" ~@key-spec "--" ~file])) ["--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 (defn sign
"Create a detached signature and return the signature file name." "Create a detached signature and return the signature file name."
[file opts] [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) (when-not (zero? exit)
(main/abort "Could not sign" (main/abort "Could not sign"
(str file "\n" err (if err "\n") (str file "\n" err (if err "\n")

View file

@ -2,6 +2,7 @@
(:use [clojure.test] (:use [clojure.test]
[clojure.java.io :only [file]] [clojure.java.io :only [file]]
[leiningen.deploy] [leiningen.deploy]
[leiningen.core.user :as user]
[leiningen.test.helper :only [delete-file-recursively [leiningen.test.helper :only [delete-file-recursively
tmp-dir sample-project tmp-dir sample-project
sample-deploy-project]])) sample-deploy-project]]))
@ -66,7 +67,31 @@
(is (= (signing-args "foo.jar" nil) (is (= (signing-args "foo.jar" nil)
["--yes" "-ab" "--" "foo.jar"])) ["--yes" "-ab" "--" "foo.jar"]))
(is (= (signing-args "foo.jar" {:gpg-key "123456"}) (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" (testing "Key selection"
(is (= (:gpg-key (signing-opts {:signing {:gpg-key "key-project"}} (is (= (:gpg-key (signing-opts {:signing {:gpg-key "key-project"}}
["repo" {:signing {:gpg-key "key-repo"}}])) ["repo" {:signing {:gpg-key "key-repo"}}]))