diff --git a/doc/GPG.md b/doc/GPG.md index 8f432b91..f71a5d91 100644 --- a/doc/GPG.md +++ b/doc/GPG.md @@ -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) @@ -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 diff --git a/leiningen-core/src/leiningen/core/user.clj b/leiningen-core/src/leiningen/core/user.clj index a6bde804..8abd4731 100644 --- a/leiningen-core/src/leiningen/core/user.clj +++ b/leiningen-core/src/leiningen/core/user.clj @@ -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) @@ -104,9 +103,14 @@ [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)) @@ -114,10 +118,15 @@ (.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 @@ -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")] @@ -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)))) diff --git a/src/leiningen/deploy.clj b/src/leiningen/deploy.clj index 8a6cafff..512fdfc4 100644 --- a/src/leiningen/deploy.clj +++ b/src/leiningen/deploy.clj @@ -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") diff --git a/test/leiningen/test/deploy.clj b/test/leiningen/test/deploy.clj index b9fccb56..7af98fb3 100644 --- a/test/leiningen/test/deploy.clj +++ b/test/leiningen/test/deploy.clj @@ -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]])) @@ -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"}}]))