hopenpgp-tools-0.25.1: HOpenPGP/Tools/Hokey/InjectSSHAgent.hs
-- InjectSSHAgent.hs: hOpenPGP key tool inject-ssh-agent subcommand
-- Copyright © 2013-2026 Clint Adams
--
-- vim: softtabstop=4:shiftwidth=4:expandtab
--
-- This program is free software: you can redistribute it and/or modify
-- it under the terms of the GNU Affero General Public License as
-- published by the Free Software Foundation, either version 3 of the
-- License, or (at your option) any later version.
--
-- This program is distributed in the hope that it will be useful,
-- but WITHOUT ANY WARRANTY; without even the implied warranty of
-- MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
-- GNU Affero General Public License for more details.
--
-- You should have received a copy of the GNU Affero General Public License
-- along with this program. If not, see <http://www.gnu.org/licenses/>.
{-# LANGUAGE GADTs #-}
module HOpenPGP.Tools.Hokey.InjectSSHAgent
(
doInjectSSHAgent
) where
import Codec.Encryption.OpenPGP.Fingerprint (fingerprint)
import Codec.Encryption.OpenPGP.Ontology
( isKUF
, isSKBindingSig
)
import Codec.Encryption.OpenPGP.Serialize ()
import Codec.Encryption.OpenPGP.Types
import Control.Exception (bracket)
import qualified Crypto.PubKey.RSA as RSA
import Data.Binary (get)
import Data.Binary.Get (getWord32be, runGet)
import Data.Binary.Put (Put, putByteString, putLazyByteString, putWord32be, putWord8, runPut)
import Data.Bits ((.&.), shiftR, testBit)
import qualified Data.ByteString as B
import qualified Data.ByteString.Base16 as Base16
import qualified Data.ByteString.Char8 as BC8
import qualified Data.ByteString.Lazy as BL
import Data.Conduit ((.|), runConduitRes)
import qualified Data.Conduit.Binary as CB
import qualified Data.Conduit.List as CL
import Data.Conduit.OpenPGP.Keyring (AuthSecretSubkeyAtTime, authSecretSubkeyPrimaryUID, authSecretSubkeyValue, conduitToAuthSecretSubkeysAt, conduitToSecretTKs)
import Data.Conduit.Serialization.Binary (conduitGet)
import Data.Foldable (find)
import Data.List (intercalate, sortOn)
import Data.Maybe (fromMaybe, listToMaybe, mapMaybe)
import Data.Ord (Down(..))
import qualified Data.Set as Set
import Data.Text (Text)
import qualified Data.Text as T
import Data.Time.Clock.POSIX (POSIXTime, getPOSIXTime, posixSecondsToUTCTime)
import HOpenPGP.Tools.Common.Common (renderFingerprint)
import Network.Socket (Family(AF_UNIX), SockAddr(..), Socket, SocketType(Stream), close, connect, defaultProtocol, socket)
import qualified Network.Socket.ByteString as NSB
import System.Environment (lookupEnv)
import System.Exit (exitFailure)
import HOpenPGP.Tools.Hokey.Options (InjectSSHAgentOptions(..))
import System.IO
( hPutStrLn
, stderr
, stdin
)
doInjectSSHAgent :: InjectSSHAgentOptions -> IO ()
doInjectSSHAgent opts = do
socketPath <- resolveSSHAgentSocketPath (injectSSHAgentSocket opts)
input <- readInjectedSecretKeyMaterial opts
cpt <- getPOSIXTime
authCandidates <-
runConduitRes $
CL.sourceList (BL.toChunks input) .| conduitGet get .| conduitToSecretTKs .|
conduitToAuthSecretSubkeysAt (posixSecondsToUTCTime cpt) .|
CL.consume
injectableCandidates <-
if null authCandidates
then inferInjectableAuthSubkeys cpt input
else pure (map candidateFromAuthSecretSubkey authCandidates)
whenEmpty injectableCandidates "inject-ssh-agent: no authentication-capable secret subkey found"
selectedRequests <-
selectInjectableAuthSubkeys
(injectSSHAgentComment opts)
injectableCandidates
mapM_
(\(selected, request) -> do
sendAddIdentityToSSHAgent socketPath request
hPutStrLn stderr $
"inject-ssh-agent: added authentication subkey " ++
renderFingerprint (fingerprint (injectableAuthSubkeyPKP selected)) ++
" to ssh-agent")
selectedRequests
candidateFromAuthSecretSubkey :: AuthSecretSubkeyAtTime -> InjectableAuthSubkey
candidateFromAuthSecretSubkey authSubkey =
InjectableAuthSubkey
{ injectableAuthSubkeyPKP = authSecretSubkeyPKP authSubkey
, injectableAuthSubkeySKA = authSecretSubkeySKA authSubkey
, injectableAuthSubkeyPrimaryUID = authSecretSubkeyPrimaryUID authSubkey
}
inferInjectableAuthSubkeys ::
POSIXTime -> BL.ByteString -> IO [InjectableAuthSubkey]
inferInjectableAuthSubkeys _cpt input = do
tks <-
runConduitRes $
CL.sourceList (BL.toChunks input) .| conduitGet get .| conduitToSecretTKs .|
CL.consume
pure (concatMap inferFromTK tks)
where
inferFromTK tk =
let mPrimaryUID = fst <$> listToMaybe (_tkUIDs tk)
in mapMaybe (inferFromSubkey mPrimaryUID) (_tkSubs tk)
inferFromSubkey :: Maybe Text -> (KeyPkt k, [SignaturePayload]) -> Maybe InjectableAuthSubkey
inferFromSubkey mPrimaryUID (KeyPktSecretSubkey pkp ska, sigs)
| hasAuthCapability sigs =
Just
InjectableAuthSubkey
{ injectableAuthSubkeyPKP = pkp
, injectableAuthSubkeySKA = ska
, injectableAuthSubkeyPrimaryUID = mPrimaryUID
}
inferFromSubkey _ _ = Nothing
hasAuthCapability sigs =
any
(Set.member AuthKey)
(mapMaybe signatureKeyFlags (newestWithUsageFlags (filter isSKBindingSig sigs)))
newestWithUsageFlags =
take 1 . sortOn (Down . take 1 . sigCreationTimes) . filter (any isKUF . signatureHashedSubpackets)
sigCreationTimes = mapMaybe sigCreationTimeFromSubpacket . signatureHashedSubpackets
sigCreationTimeFromSubpacket (SigSubPacket _ (SigCreationTime ct)) = Just ct
sigCreationTimeFromSubpacket _ = Nothing
signatureHashedSubpackets (SigV4 _ _ _ hasheds _ _ _) = hasheds
signatureHashedSubpackets (SigV6 _ _ _ _ hasheds _ _ _) = hasheds
signatureHashedSubpackets _ = []
signatureKeyFlags sig = do
sp <- find isKUF (signatureHashedSubpackets sig)
case sp of
SigSubPacket _ (KeyFlags flags) -> Just flags
_ -> Nothing
resolveSSHAgentSocketPath :: Maybe String -> IO String
resolveSSHAgentSocketPath (Just path) = pure path
resolveSSHAgentSocketPath Nothing = do
envPath <- lookupEnv "SSH_AUTH_SOCK"
case envPath of
Just path -> pure path
Nothing ->
failInject
"inject-ssh-agent: SSH_AUTH_SOCK is not set; use --ssh-agent-socket"
readInjectedSecretKeyMaterial :: InjectSSHAgentOptions -> IO BL.ByteString
readInjectedSecretKeyMaterial opts = do
chunks <-
case injectSSHAgentFromFD opts of
Nothing -> runConduitRes $ CB.sourceHandle stdin .| CL.consume
Just fd
| fd < 0 ->
failInject "inject-ssh-agent: --from-fd must be a non-negative integer"
| otherwise ->
runConduitRes $ CB.sourceFile ("/dev/fd/" ++ show fd) .| CL.consume
let input = BL.fromChunks chunks
if BL.null input
then
failInject
"inject-ssh-agent: no secret key bytes were provided on the selected input stream"
else pure input
selectInjectableAuthSubkeys ::
Maybe String -> [InjectableAuthSubkey] -> IO [(InjectableAuthSubkey, BL.ByteString)]
selectInjectableAuthSubkeys mComment candidates
| null selectedRequests =
failInject
("inject-ssh-agent: auth-capable subkeys were found, but none are supported for ssh-agent injection: " ++
intercalate "; " (reverse errs))
| otherwise = pure (reverse selectedRequests)
where
(errs, selectedRequests) = foldl' pick ([], []) candidates
pick (accErrs, accSelected) candidate =
case
sshAddIdentityRequest
(fromMaybe (defaultSSHComment candidate) mComment)
(injectableAuthSubkeyPKP candidate)
(injectableAuthSubkeySKA candidate) of
Left err -> (err : accErrs, accSelected)
Right request -> (accErrs, (candidate, request) : accSelected)
defaultSSHComment candidate =
case injectableAuthSubkeyPrimaryUID candidate of
Just uid -> T.unpack uid
Nothing -> "openpgp:" ++ BC8.unpack (Base16.encode (BL.toStrict (unFingerprint (fingerprint (injectableAuthSubkeyPKP candidate)))))
sshAddIdentityRequest ::
String -> SomePKPayload -> SKAddendum -> Either String BL.ByteString
sshAddIdentityRequest comment subkeyPKP subkeySKA =
case subkeySKA of
SUUnencrypted (RSAPrivateKey (RSA_PrivateKey rsaPrivateKey)) _ ->
Right $ frameSSHAgentRequest (rsaAddIdentityPayload (BC8.pack comment) rsaPrivateKey)
SUUnencrypted (EdDSAPrivateKey Ed25519 secretSeed) _ ->
frameSSHAgentRequest <$>
ed25519AddIdentityPayload (BC8.pack comment) subkeyPKP secretSeed
SUUnencrypted (UnknownSKey rawSecret) _
| isEd25519PKA (_pkalgo subkeyPKP) ->
frameSSHAgentRequest <$>
ed25519AddIdentityPayload
(BC8.pack comment)
subkeyPKP
(BL.toStrict rawSecret)
SUUnencrypted (EdDSAPrivateKey Ed448 _) _ ->
Left
("subkey " ++ renderFingerprint (fingerprint subkeyPKP) ++
" uses Ed448, which is not supported by ssh-agent add-identity")
SUUnencrypted _ _ ->
Left
("subkey " ++ renderFingerprint (fingerprint subkeyPKP) ++
" uses an unsupported key algorithm for ssh-agent injection")
_ ->
Left
("subkey " ++ renderFingerprint (fingerprint subkeyPKP) ++
" is encrypted; decrypt it before injection")
authSecretSubkeyPKP :: AuthSecretSubkeyAtTime -> SomePKPayload
authSecretSubkeyPKP = keyPktPKPayload . authSecretSubkeyValue
authSecretSubkeySKA :: AuthSecretSubkeyAtTime -> SKAddendum
authSecretSubkeySKA = secretKeyPktSKAddendum . authSecretSubkeyValue
rsaAddIdentityPayload :: B.ByteString -> RSA.PrivateKey -> BL.ByteString
rsaAddIdentityPayload comment privateKey =
runPut $ do
putWord8 17
putSSHString (BC8.pack "ssh-rsa")
putSSHMpint (RSA.public_n (RSA.private_pub privateKey))
putSSHMpint (RSA.public_e (RSA.private_pub privateKey))
putSSHMpint (RSA.private_d privateKey)
putSSHMpint (RSA.private_qinv privateKey)
putSSHMpint (RSA.private_p privateKey)
putSSHMpint (RSA.private_q privateKey)
putSSHString comment
ed25519AddIdentityPayload ::
B.ByteString -> SomePKPayload -> B.ByteString -> Either String BL.ByteString
ed25519AddIdentityPayload comment pkp rawSecret = do
publicKey <- ed25519PublicPoint pkp
secretSeed <- normalizeEd25519Secret rawSecret
pure $
runPut $ do
putWord8 17
putSSHString (BC8.pack "ssh-ed25519")
putSSHString publicKey
putSSHString (secretSeed <> publicKey)
putSSHString comment
ed25519PublicPoint :: SomePKPayload -> Either String B.ByteString
ed25519PublicPoint pkp =
case _pubkey pkp of
EdDSAPubKey Ed25519 point ->
maybe
(Left ("invalid Ed25519 public point for subkey " ++ renderFingerprint (fingerprint pkp)))
Right
(edPointToRawBytes point)
_ -> Left ("subkey " ++ renderFingerprint (fingerprint pkp) ++ " does not have an Ed25519 public key")
edPointToRawBytes :: EdPoint -> Maybe B.ByteString
edPointToRawBytes (NativeEPoint (EPoint i)) = integerToFixedBytes 32 i
edPointToRawBytes (PrefixedNativeEPoint (EPoint i)) = do
prefixed <- integerToFixedBytes 33 i
case B.uncons prefixed of
Just (0x40, raw) -> Just raw
_ -> Nothing
normalizeEd25519Secret :: B.ByteString -> Either String B.ByteString
normalizeEd25519Secret rawSecret
| B.length rawSecret == 32 = Right rawSecret
| otherwise =
Left
("expected 32-byte Ed25519 secret seed, got " ++ show (B.length rawSecret) ++ " bytes")
putSSHString :: B.ByteString -> Put
putSSHString bs = putWord32be (fromIntegral (B.length bs)) >> putByteString bs
putSSHMpint :: Integer -> Put
putSSHMpint n
| n <= 0 = putWord32be 0
| otherwise = putSSHString encoded
where
raw = integerToUnsignedBytes n
encoded =
case B.uncons raw of
Just (firstByte, _)
| testBit firstByte 7 -> B.cons 0x00 raw
_ -> raw
integerToFixedBytes :: Int -> Integer -> Maybe B.ByteString
integerToFixedBytes width n
| n < 0 = Nothing
| B.length raw > width = Nothing
| otherwise = Just (B.replicate (width - B.length raw) 0x00 <> raw)
where
raw =
if n == 0
then B.singleton 0x00
else integerToUnsignedBytes n
integerToUnsignedBytes :: Integer -> B.ByteString
integerToUnsignedBytes n =
B.reverse $
B.unfoldr
(\value ->
if value == 0
then Nothing
else Just (fromIntegral (value .&. 0xff), value `shiftR` 8))
n
isEd25519PKA :: PubKeyAlgorithm -> Bool
isEd25519PKA pka = fromFVal pka == 27
frameSSHAgentRequest :: BL.ByteString -> BL.ByteString
frameSSHAgentRequest body =
runPut $ putWord32be (fromIntegral (BL.length body)) >> putLazyByteString body
sendAddIdentityToSSHAgent :: FilePath -> BL.ByteString -> IO ()
sendAddIdentityToSSHAgent socketPath request =
bracket
(socket AF_UNIX Stream defaultProtocol)
close
(\sock -> do
connect sock (SockAddrUnix socketPath)
NSB.sendAll sock (BL.toStrict request)
response <- readSSHAgentPacket sock
case B.uncons response of
Just (6, _) -> pure ()
Just (5, _) ->
failInject "inject-ssh-agent: ssh-agent rejected the supplied key"
Just (code, _) ->
failInject
("inject-ssh-agent: ssh-agent returned unexpected response type " ++
show code)
Nothing ->
failInject
"inject-ssh-agent: ssh-agent returned an empty response packet")
readSSHAgentPacket :: Socket -> IO B.ByteString
readSSHAgentPacket sock = do
lenPrefix <- recvExact sock 4
let packetLen = fromIntegral (runGet getWord32be (BL.fromStrict lenPrefix))
recvExact sock packetLen
recvExact :: Socket -> Int -> IO B.ByteString
recvExact _ 0 = pure B.empty
recvExact sock remaining = go B.empty remaining
where
go acc 0 = pure acc
go acc bytesRemaining = do
chunk <- NSB.recv sock bytesRemaining
if B.null chunk
then
failInject
"inject-ssh-agent: ssh-agent socket closed while reading response"
else go (acc <> chunk) (bytesRemaining - B.length chunk)
whenEmpty :: [a] -> String -> IO ()
whenEmpty [] msg = failInject msg
whenEmpty _ _ = pure ()
failInject :: String -> IO a
failInject msg = hPutStrLn stderr msg >> exitFailure
data InjectableAuthSubkey =
InjectableAuthSubkey
{ injectableAuthSubkeyPKP :: SomePKPayload
, injectableAuthSubkeySKA :: SKAddendum
, injectableAuthSubkeyPrimaryUID :: Maybe Text
}