#!/usr/bin/env bash
# pinentry-wsl-ps1
#
# (c) 2018 Dale Phurrough
# Licensed under the Mozilla Public License 2.0
#
# Allows GnuPG to prompt and read passphrases by the pinentry standard
# with a GUI when running within WSL (Windows Subsystem for Linux).
# Works for all keys managed by gpg-agent (GPG, SSH, etc).
# This is a drop-in GUI alternative to pinentry-curses, pinentry-gtk-2, etc.
# https://www.gnupg.org/software/pinentry/index.html
#
# Setup:
# 1. Save this script and set its permissions to be executable
# 2. Configure gpg-agent to use this script for pinentry using
# one of the following methods:
# a) Set pinentry-program within ~/.gnupg/gpg-agent.conf
# pinentry-program /mnt/c/repos/pinentry-wsl-ps1/pinentry-wsl-ps1.sh
# b) Set the path to this script when you launch gpg-agent
# gpg-agent --pinentry-program /mnt/c/repos/pinentry-wsl-ps1/pinentry-wsl-ps1.sh
# 3. Optionally enable persistence of passwords.
# Requires https://github.com/davotronic5000/PowerShell_Credential_Manager
# Please follow instructions there to install from the Gallery or GitHub.
# Note security perspectives like https://security.stackexchange.com/questions/119765/how-secure-is-the-windows-credential-manager
# Possible values for PERSISTENCE are: "", "Session", "LocalMachine", or "Enterprise"
# 4. Optionally disable toast notification of password retrieval from Credential Manager.
# By default, this code notifies you with a toast notification every time gpg-agent
# retrieves a password from the Windows Credential Manager. Gpg-agent caches passwords
# by default (see gpg-agent settings like max-cache-ttl) so you may not see the notification
# with every usage.
# * Disable: edit the script, near the top, set NOTIFY to the value "0"
# * Enable: edit the script, near the top, set NOTIFY to the value "1"
PERSISTENCE=""
NOTIFY="1"
DEBUGLOG=""
# Do not casually edit the below values
VERSION="0.2.1"
TIMEOUT="0"
DESCRIPTION="Enter password for GPG key"
PROMPT="Password:"
TITLE="GPG Key Credentials"
CACHEPREFIX="gpgcache:"
CACHEUSER=""
KEYINFO=""
OKBUTTON="&OK"
CANCELBUTTON="&Cancel"
NOTOKBUTTON="&Do not do this"
PINERROR=""
EXTPASSCACHE="0"
REPEATPASSWORD="0"
REPEATDESCRIPTION="Confirm password for GPG key"
REPEATERROR="Error: Passwords did not match."
GRABKEYBOARD="0"
# convert Assuan protocol error into an ERR number, e.g. echo -n $(( (5 << 24) | $1 ))
assuan_result() {
case $1 in
0)
echo -n "ERR 0 no error"
;;
62)
echo -n "ERR 83886142 timeout"
;;
99)
echo -n "ERR 83886179 cancelled"
;;
114)
echo -n "ERR 83886194 not confirmed"
;;
174)
echo -n "ERR 83886254 invalid option"
;;
257)
echo -n "ERR 83886337 general error"
;;
261)
echo -n "ERR 83886341 invalid value"
;;
275)
echo -n "ERR 83886355 unknown command"
;;
esac
}
# GUI dialogs for passwords; text is dynamically set by gpg-agent via protocol
getpassword() {
if [ -n "$CACHEUSER" ]; then
local creduser="$CACHEUSER"
else
if [ -n "$KEYINFO" ]; then
local creduser="$KEYINFO"
else
local creduser="--not yet defined--"
fi
fi
local cmd_prompt=$(cat <<-DLM
\$cred = \$Host.ui.PromptForCredential("$TITLE",
"$PINERROR$DESCRIPTION",
"$creduser",
"",
"Generic",
"None,ReadOnlyUserName")
if (\$cred) {
Write-Output \$cred.GetNetworkCredential().Password
}
DLM
)
local cmd_repeat=$(cat <<-DLM
\$cred = \$Host.ui.PromptForCredential("$TITLE",
"$REPEATDESCRIPTION",
"$creduser",
"",
"Generic",
"None,ReadOnlyUserName")
if (\$cred) {
Write-Output \$cred.GetNetworkCredential().Password
}
DLM
)
local cmd_lookup=$(cat <<-DLM
\$cred = Get-StoredCredential -Target "$CACHEPREFIX$KEYINFO" -Type GENERIC
if (\$cred) {
Write-Output \$cred.GetNetworkCredential().Password
}
DLM
)
local cmd_store=$(cat <<-DLM
\$pw = \$Input | Select-Object -First 1
\$securepw = ConvertTo-SecureString \$pw -AsPlainText -Force
New-StoredCredential -Target "$CACHEPREFIX$KEYINFO" -Type GENERIC -UserName "$creduser" -SecurePassword \$securepw -Persist $PERSISTENCE |
out-null
DLM
)
# idea from http://thewindowscollege.com/display-toast-notifications-windows-10.html
# alt1: https://gist.github.com/loge5/7ec41e2e2f0e0293fdcc5155499e9072
# alt2: https://gist.github.com/Windos/9aa6a684ac583e0d38a8fa68196bc2dc
local cmd_toast=$(cat <<-DLM
[reflection.assembly]::loadwithpartialname("System.Windows.Forms")
[reflection.assembly]::loadwithpartialname("System.Drawing")
\$notify = new-object system.windows.forms.notifyicon
\$notify.icon = [System.Drawing.SystemIcons]::Information
\$notify.visible = \$true
\$notify.showballoontip(10, "GPG pinentry-wsl-ps1", "GPG password retrieved from Windows Credential Manager", [system.windows.forms.tooltipicon]::Info)
DLM
)
local credpassword
local credpasswordrepeat
local passwordfromcache=0
if [ -z "$PINERROR" ]; then
if [ "$REPEATPASSWORD" == "0" ]; then
if [ "$EXTPASSCACHE" == "1" ]; then
if [ -n "$KEYINFO" ]; then
credpassword="$(powershell.exe -nologo -noprofile -noninteractive -command "$cmd_lookup")"
if [ -n "$credpassword" ]; then
echo -e "S PASSWORD_FROM_CACHE\nD $credpassword\nOK"
if [ "$NOTIFY" == "1" ]; then
powershell.exe -nologo -noprofile -noninteractive -command "$cmd_toast" > /dev/null
fi
return
fi
fi
fi
fi
fi
PINERROR=""
credpassword="$(powershell.exe -nologo -noprofile -noninteractive -command "$cmd_prompt")"
if [ -n "$credpassword" ]; then
if [ "$REPEATPASSWORD" == "1" ]; then
credpasswordrepeat="$(powershell.exe -nologo -noprofile -noninteractive -command "$cmd_repeat")"
if [ "$credpassword" == "$credpasswordrepeat" ]; then
echo -e "S PIN_REPEATED\nD $credpassword\nOK"
else
message "$REPEATERROR" > /dev/null
echo "$(assuan_result 114)" # unsure this is the correct error
return
fi
else
echo -e "D $credpassword\nOK"
fi
if [ "$EXTPASSCACHE" == "1" ]; then
if [ -n "$KEYINFO" ]; then
# avoid setting password on visible param
# alt is to always save on the single or last-of-repeat dialog. And if the repeat fails, then immediately delete it from the cred store
builtin echo -n "$credpassword" | powershell.exe -nologo -noprofile -noninteractive -command "$cmd_store"
fi
fi
else
echo "$(assuan_result 99)"
fi
}
# remove password from persistent store
removepassword() {
if [ -z "$1" ]; then
echo "$(assuan_result 261)"
return
fi
local cmd_remove=$(cat <<-DLM
try {
Remove-StoredCredential -Target "$CACHEPREFIX$1" -Type GENERIC -ErrorAction Stop
}
catch {
Write-Output "$(assuan_result 261)"
return
}
Write-Output "OK"
DLM
)
if [ "$EXTPASSCACHE" == "1" ]; then
echo "$(powershell.exe -nologo -noprofile -noninteractive -command "$cmd_remove")"
else
echo "OK"
fi
}
# GUI dialog box with simple message and one OK button
message() {
local desc
if [ -n "$1" ]; then
desc="$1"
else
desc="$DESCRIPTION"
fi
local cmd=$(cat <<-DLM
\$wshShell = New-Object -ComObject WScript.Shell
\$options = 0x0 + 0x40 + 0x2000 + 0x10000 # 1 + 16 + 8192 + 65536
\$result = \$wshShell.Popup("$desc", $TIMEOUT, "$TITLE", \$options)
[System.Runtime.Interopservices.Marshal]::ReleaseComObject(\$wshShell) | Out-Null
DLM
)
local result="$(powershell.exe -nologo -noprofile -noninteractive -command "$cmd")" #> /dev/null
echo "OK"
}
# GUI dialog box with test and two buttons: OK, Cancel
confirm() {
PINERROR=""
if [ "$1" == "--one-button" ]; then
message
return
fi
local cmd=$(cat <<-DLM
\$wshShell = New-Object -ComObject WScript.Shell
\$options = 0x1 + 0x30 + 0x2000 + 0x10000 # 1 + 16 + 8192 + 65536
\$result = \$wshShell.Popup("$DESCRIPTION", $TIMEOUT, "$TITLE", \$options)
[System.Runtime.Interopservices.Marshal]::ReleaseComObject(\$wshShell) | Out-Null
if (\$result) {
switch(\$result) {
1 { Write-Output "OK" }
2 { Write-Output "$(assuan_result 99)" }
default { Write-Output "$(assuan_result 114)" }
}
}
else {
Write-Output "$(assuan_result 114)"
}
DLM
)
local result="$(powershell.exe -nologo -noprofile -noninteractive -command "$cmd")"
echo "$result"
}
# set a timeout value in seconds after which prompts/dialogs are automatically cancelled
# limited functionality in current codebase
# potential improvements at https://stackoverflow.com/questions/21176487/adding-a-timeout-to-batch-powershell
settimeout() {
TIMEOUT="$1"
echo "OK"
}
# helper function for decoding strings from gpg-agent into Windows-compatible format
decodegpgagentstr() {
local decode="${1//%0A/%0D%0A}" # convert hex LF into hex Windows CRLF
decode="${decode//%/\\x}" # convert hex encoding style
decode="$(echo -en "$decode")" # decode hex
echo -n "${decode//\"/\`\"}" # escape double quotes for powershell
}
# commonly used to set main text in GUI dialog boxes
# also parses for key ids to display in GUI prompts
setdescription() {
DESCRIPTION="$(decodegpgagentstr "$1")"
local searchfor='ID ([[:xdigit:]]{16})' # hack to search for first gpg key id in description
if [[ "$1" =~ $searchfor ]]; then
CACHEUSER="${BASH_REMATCH[1]}"
echo "OK"
return
fi
local searchfor='(([[:xdigit:]][[:xdigit:]]:){15}[[:xdigit:]][[:xdigit:]])' # hack to search for ssh fingerprint in description
if [[ "$1" =~ $searchfor ]]; then
CACHEUSER="${BASH_REMATCH[1]}"
echo "OK"
return
fi
echo "OK"
}
setprompt() {
PROMPT="$1"
echo "OK"
}
settitle() {
TITLE="$1"
echo "OK"
}
setpinerror() {
PINERROR="$(decodegpgagentstr "** $1 **")"$'\r'$'\n' # decode and add CRLF to separate line
echo "OK"
}
setkeyinfo() {
if [ "$1" == "--clear" ]; then
KEYINFO=""
else
KEYINFO="$1"
fi
echo "OK"
}
setrepeatpassword() {
REPEATPASSWORD="1"
REPEATDESCRIPTION="$(decodegpgagentstr "$1")"
echo "OK"
}
setrepeaterror () {
REPEATERROR="$(decodegpgagentstr "$1")"
echo "OK"
}
setokbutton() {
OKBUTTON="${1//_/&}"
echo "OK"
}
setcancelbutton() {
CANCELBUTTON="${1//_/&}"
echo "OK"
}
setnotokbutton() {
NOTOKBUTTON="${1//_/&}"
echo "OK"
}
getinfo() {
if [ "$1" == "version" ]; then
echo -e "D $VERSION\nOK"
elif [ "$1" == "pid" ]; then
echo -e "D $BASHPID\nOK"
else
echo "$(assuan_result 275)"
fi
}
# often called by gpg-agent to set default values
setoption() {
local key="$(echo "$1" | cut -d'=' -f1)"
local value="$(echo "$1" | cut -d'=' -s -f2-)"
case $key in
allow-external-password-cache)
if [ -n "$PERSISTENCE" ]; then
EXTPASSCACHE=1
fi
echo "OK"
;;
default-ok)
setokbutton "$value"
;;
default-cancel)
setcancelbutton "$value"
;;
default-notok)
setnotokbutton "$value"
;;
default-prompt)
setprompt "$value"
;;
grab)
GRABKEYBOARD="1"
echo "OK"
;;
no-grab)
GRABKEYBOARD="0"
echo "OK"
;;
*)
echo "OK"
;;
esac
}
# check that we are running within WSL
if ! cat /proc/sys/kernel/osrelease | grep -q -i Microsoft; then
echo "$(assuan_result 257)"
exit 1
fi
# main loop to read stdin and respond
echo "OK Your orders please"
while IFS= read -r line; do
if [ -n "$DEBUGLOG" ]; then
echo "$line" >> "$DEBUGLOG"
fi
action="$(echo $line | cut -d' ' -f1)"
args="$(echo $line | cut -d' ' -s -f2-)"
case $action in
BYE)
echo "OK closing connection"
exit 0
;;
GETPIN)
getpassword
;;
SETTIMEOUT)
settimeout "$args"
;;
SETDESC)
setdescription "$args"
;;
SETPROMPT)
setprompt "$args"
;;
SETTITLE)
settitle "$args"
;;
SETKEYINFO)
setkeyinfo "$args"
;;
SETOK)
setokbutton "$args"
;;
SETCANCEL)
setcancelbutton "$args"
;;
SETNOTOK)
setnotokbutton "$args"
;;
CONFIRM)
confirm "$args"
;;
MESSAGE)
message "$args"
;;
SETERROR)
setpinerror "$args"
;;
GETINFO)
getinfo "$args"
;;
OPTION)
setoption "$args"
;;
SETREPEAT)
setrepeatpassword "$args"
;;
SETREPEATERROR)
setrepeaterror "$args"
;;
CLEARPASSPHRASE)
removepassword "$args"
;;
RESET)
echo "OK"
;;
*)
echo "OK"
;;
esac
done