packages feed

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
    }