packages feed

hopenpgp-tools-0.10: hkt.hs

-- hkt.hs: hOpenPGP key tool
-- Copyright © 2013-2014  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/>.

import HOpenPGP.Tools.Common (banner, versioner, warranty, keyMatchesFingerprint, keyMatchesEightOctetKeyId, keyMatchesUIDSubString)
import HOpenPGP.Tools.ExpressionParsing (pPE)
import Codec.Encryption.OpenPGP.Fingerprint (fingerprint, eightOctetKeyID)
import Codec.Encryption.OpenPGP.KeyInfo (pubkeySize, pkalgoAbbrev)
import Codec.Encryption.OpenPGP.KeySelection (parseEightOctetKeyId, parseFingerprint)
import Codec.Encryption.OpenPGP.Serialize ()
import Codec.Encryption.OpenPGP.Signatures (verifyTKWith, verifySigWith, verifyAgainstKeyring)
import Codec.Encryption.OpenPGP.Types
import Control.Applicative ((<$>),(<*>), optional, (<|>), pure)
import Control.Arrow ((&&&))
import Control.Lens ((^.), _1, _2, (^..))
import Control.Monad.Trans.Resource (runResourceT, MonadResource)
import qualified Control.Monad.Trans.State.Lazy as S
import Control.Monad.Trans.Writer.Lazy (execWriter, tell)
import qualified Data.Attoparsec.Text as A
import qualified Data.ByteString as B
import Data.Conduit (($=),($$), Source)
import qualified Data.Conduit.Binary as CB
import Data.Conduit.Cereal (conduitGet)
import qualified Data.Conduit.List as CL
import Data.Conduit.OpenPGP.Filter (Expr(..), PKPPredicate(..), PKPOp(..), PKPVar(..), PKPValue(..))
import Data.Conduit.OpenPGP.Keyring (conduitToTKsDropping, sinkKeyringMap)
import Data.Data.Lens (biplate)
import Data.Either (rights)
import qualified Data.IxSet as IxSet
import Data.Graph.Inductive.Graph (Graph(mkGraph), emap)
import Data.Graph.Inductive.PatriciaTree (Gr)
import Data.Graph.Inductive.Query.SP (sp)
import Data.GraphViz (graphToDot, nonClusteredParams)
import Data.GraphViz.Types (printDotGraph)
import Data.HashMap.Lazy (HashMap)
import qualified Data.HashMap.Lazy as HashMap
import Data.List (nub, sort)
import Data.Maybe (fromMaybe, mapMaybe, listToMaybe)
import Data.Monoid ((<>))
import Data.Serialize (get, put, runPut)
import qualified Data.Text as T
import qualified Data.Text.Lazy.IO as TLIO
import Data.Time.Clock.POSIX (getPOSIXTime, posixSecondsToUTCTime)
import Data.Traversable (traverse)
import Data.Tuple (swap)
import System.Directory (getHomeDirectory)

import Options.Applicative.Builder (argument, auto, command, footer, header, help, info, long, metavar, option, prefs, progDesc, showDefault, showHelpOnError, str, strOption, subparser, switch, value)
import Options.Applicative.Extra (customExecParser, helper)
import Options.Applicative.Types (Parser)

import System.IO (Handle, hFlush, hPutStrLn, stderr, hSetBuffering, BufferMode(..))

grabMatchingKeysConduit :: MonadResource m => FilePath -> Bool -> String -> Source m TK
grabMatchingKeysConduit fp filt srch = CB.sourceFile fp $= conduitGet get $= conduitToTKsDropping $= CL.filter (if filt then filterMatch else matchAny)
    where
        matchAny tk = either (const False) id $ fmap (keyMatchesFingerprint True tk) efp <|> fmap (keyMatchesEightOctetKeyId True tk . Right) eeok <|> return (keyMatchesUIDSubString srch tk)
        filterMatch tk = eval pkpEval (either error id (A.parseOnly pPE (T.pack srch))) (tk^.tkKey._1)
        efp = parseFingerprint . T.pack $ srch
        eeok = parseEightOctetKeyId . T.pack $ srch

grabMatchingKeys :: FilePath -> Bool -> String -> IO [TK]
grabMatchingKeys fp filt srch = runResourceT $ grabMatchingKeysConduit fp filt srch $$ CL.consume

grabMatchingKeysKeyring :: FilePath -> Bool -> String -> IO Keyring
grabMatchingKeysKeyring fp filt srch = runResourceT $ grabMatchingKeysConduit fp filt srch $$ sinkKeyringMap

showKey :: TK -> IO ()
showKey key = putStrLn . unlines . execWriter $ do
    tell [ "pub   " ++ either (const "unknown") show (pubkeySize (key^.tkKey._1.pubkey)) ++ pkalgoAbbrev (key^.tkKey._1.pkalgo) ++ "/0x" ++ (either (const "unknown") show . eightOctetKeyID $ key^.tkKey._1 ) ]
    tell $ map (\(x,_) -> "uid                            " ++ x) (key^.tkUIDs)
    tell $ map (\(PublicSubkeyPkt x,_) -> "sub   " ++ either (const "unknown") show (pubkeySize (x^.pubkey)) ++ pkalgoAbbrev (x^.pkalgo) ++ "/0x" ++ (either (const "unknown") show . eightOctetKeyID $ x)) (key^.tkSubs)

data Options = Options {
    keyring :: String
  , graphOutputFormat :: GraphOutputFormat
  , targetIsFilter :: Bool
  , target1 :: String
  , target2 :: String
  , target3 :: String
}

data Command = CmdList Options | CmdExportPubkeys Options | CmdGraph Options | CmdFindPaths Options

data GraphOutputFormat = GraphViz
    deriving (Eq, Read, Show)

listO :: String -> Parser Options
listO homedir = Options
    <$> (fromMaybe (homedir ++ "/.gnupg/pubring.gpg") <$> optional (strOption
        ( long "keyring"
       <> metavar "FILE"
       <> help "file containing keyring" )))
    <*> pure GraphViz -- unused
    <*> switch ( long "filter" <> help "treat target as filter" )
    <*> argument str ( metavar "TARGET" )
    <*> pure ""
    <*> pure ""

graphO :: String -> Parser Options
graphO homedir = Options
    <$> (fromMaybe (homedir ++ "/.gnupg/pubring.gpg") <$> optional (strOption
        ( long "keyring"
       <> metavar "FILE"
       <> help "file containing keyring" )))
    <*> option auto
        ( long "output-format"
       <> metavar "FORMAT"
       <> value GraphViz
       <> showDefault
       <> help "output format" )
    <*> switch ( long "filter" <> help "treat target as filter" )
    <*> argument str ( metavar "TARGET" )
    <*> pure ""
    <*> pure ""

findPathsO :: String -> Parser Options
findPathsO homedir = Options
    <$> (fromMaybe (homedir ++ "/.gnupg/pubring.gpg") <$> optional (strOption
        ( long "keyring"
       <> metavar "FILE"
       <> help "file containing keyring" )))
    <*> option auto
        ( long "output-format"
       <> metavar "FORMAT"
       <> value GraphViz
       <> showDefault
       <> help "output format" )
    <*> switch ( long "filter" <> help "treat targets as filter" )
    <*> argument str ( metavar "TARGET-SET" )
    <*> argument str ( metavar "FROM-KEYS" )
    <*> argument str ( metavar "TO-KEYS" )

dispatch :: Command -> IO ()
dispatch (CmdList o) = banner' stderr >> hFlush stderr >> doList o
dispatch (CmdExportPubkeys o) = banner' stderr >> hFlush stderr >> doExportPubkeys o
dispatch (CmdGraph o) = banner' stderr >> hFlush stderr >> doGraph o
dispatch (CmdFindPaths o) = banner' stderr >> hFlush stderr >> doFindPaths o

main :: IO ()
main = do
    hSetBuffering stderr LineBuffering
    homedir <- getHomeDirectory
    customExecParser (prefs showHelpOnError) (info (helper <*> versioner <*> cmd homedir) (header (banner "hkt") <> progDesc "hOpenPGP Keyring Tool" <> footer (warranty "hkt"))) >>= dispatch

cmd :: String -> Parser Command
cmd homedir = subparser
    ( command "export-pubkeys" (info ( CmdExportPubkeys <$> listO homedir) ( progDesc "export matching keys to stdout" ))
   <> command "findpaths" (info ( CmdFindPaths <$> findPathsO homedir) ( progDesc "find short paths between keys" ))
   <> command "graph" (info ( CmdGraph <$> graphO homedir) ( progDesc "graph certifications" ))
   <> command "list" (info ( CmdList <$> listO homedir) ( progDesc "list matching keys" ))
   )

banner' :: Handle -> IO ()
banner' h = hPutStrLn h (banner "hkt" ++ "\n" ++ warranty "hkt")

doList :: Options -> IO ()
doList o = do
    keys <- grabMatchingKeys (keyring o) (targetIsFilter o) (target1 o)
    mapM_ showKey keys

doExportPubkeys :: Options -> IO ()
doExportPubkeys o = do
    keys <- grabMatchingKeys (keyring o) (targetIsFilter o) (target1 o)
    mapM_ (B.putStr . putTK') keys
    where
        putTK' key = runPut $ do
            put (PublicKey (key^.tkKey._1))
            mapM_ (put . Signature) (_tkRevs key)
            mapM_ putUid' (_tkUIDs key)
            mapM_ putUat' (_tkUAts key)
            mapM_ putSub' (_tkSubs key)
        putUid' (u, sps) = put (UserId u) >> mapM_ (put . Signature) sps
        putUat' (us, sps) = put (UserAttribute us) >> mapM_ (put . Signature) sps
        putSub' (p, sps) = put p >> mapM_ (put . Signature) sps

doGraph :: Options -> IO ()
doGraph o = do
    cpt <- getPOSIXTime
    kr <- grabMatchingKeysKeyring (keyring o) (targetIsFilter o) (target1 o)
    TLIO.putStrLn . printDotGraph $ graphToDot nonClusteredParams (buildKeyGraph ((buildMaps &&& id) (rights (map (verifyTKWith (verifySigWith (verifyAgainstKeyring kr)) (Just (posixSecondsToUTCTime cpt))) (IxSet.toList kr)))))

buildMaps :: [TK] -> (KeyMaps, Int)
buildMaps ks = S.execState (mapM_ mapsInsertions ks) (KeyMaps HashMap.empty HashMap.empty HashMap.empty, 0)

-- FIXME: this presumes no keyID collisions in the input
data KeyMaps = KeyMaps {
    _k2f :: HashMap EightOctetKeyId TwentyOctetFingerprint
  , _f2i :: HashMap TwentyOctetFingerprint Int
  , _i2f :: HashMap Int TwentyOctetFingerprint
}

mapsInsertions :: TK -> S.State (KeyMaps, Int) ()
mapsInsertions tk = do
    (KeyMaps k2f f2i i2f, i) <- S.get
    let fp = fingerprint (tk^.tkKey._1)
        keyids = rights . map eightOctetKeyID $ (tk ^.. biplate :: [PKPayload])
        i' = i + 1
        k2f' = foldr (\k m -> HashMap.insert k fp m) k2f keyids
        f2i' = HashMap.insert fp i' f2i
        i2f' = HashMap.insert i' fp i2f
    S.put (KeyMaps k2f' f2i' i2f', i')

buildKeyGraph :: ((KeyMaps, Int), [TK]) -> Gr TwentyOctetFingerprint HashAlgorithm
buildKeyGraph ((KeyMaps k2f f2i _, _), ks) = mkGraph nodes edges
    where
        nodes = map swap . HashMap.toList $ f2i
        edges = filter (not . samesies) . nub . sort . concatMap tkToEdges $ ks
        tkToEdges tk = map (\(ha, i) -> (source i, target tk, ha)) (mapMaybe (fakejoin . (hashAlgo &&& sigissuer)) (sigs tk))
        target tk = fromMaybe (error "Epic fail") (HashMap.lookup (fingerprint (tk^.tkKey._1)) f2i)
        source i = fromMaybe (-1) (HashMap.lookup i k2f >>= flip HashMap.lookup f2i)
        fakejoin (x, y) = fmap ((,) x) y
        sigs tk = concat ((tk^..tkUIDs.traverse._2) ++ (tk^..tkUAts.traverse._2))
        samesies (x,y,_) = x == y

doFindPaths :: Options -> IO ()
doFindPaths o = do
    cpt <- getPOSIXTime
    kr <- grabMatchingKeysKeyring (keyring o) (targetIsFilter o) (target1 o)
    let keys1 = filter (if targetIsFilter o then filterMatch (target2 o) else matchAny (target2 o)) (IxSet.toList kr)
        keys2 = filter (if targetIsFilter o then filterMatch (target3 o) else matchAny (target3 o)) (IxSet.toList kr)
        ((KeyMaps k2f f2i i2f, i), ks) = (buildMaps &&& id) (rights (map (verifyTKWith (verifySigWith (verifyAgainstKeyring kr)) (Just (posixSecondsToUTCTime cpt))) (IxSet.toList kr)))
        keygraph = buildKeyGraph ((KeyMaps k2f f2i i2f, i), ks)
        keysToIs = mapMaybe (\x -> HashMap.lookup (fingerprint (x^.tkKey._1)) f2i)
        froms = keysToIs keys1
        tos = keysToIs keys2
        combos = froms >>= \f -> tos >>= \t -> return (f,t)
        paths = map (\(x,y) -> sp x y (emap (const (1.0 :: Double)) keygraph)) combos
    putStrLn . unlines $ map (show . ((,) =<< length)) paths
    putStrLn . unlines $ map (\x -> maybe (show x) show $ HashMap.lookup x i2f >>= \y -> return (x, y)) (nub (sort (concat paths)))
    where  -- FIXME: deduplicate this
        matchAny srch tk = either (const False) id $ fmap (keyMatchesFingerprint True tk) (efp srch) <|> fmap (keyMatchesEightOctetKeyId True tk . Right) (eeok srch) <|> return (keyMatchesUIDSubString srch tk)
        filterMatch srch tk = eval pkpEval (either error id (A.parseOnly pPE (T.pack srch))) (tk^.tkKey._1)
        efp srch = parseFingerprint . T.pack $ srch
        eeok srch = parseEightOctetKeyId . T.pack $ srch

-- FIXME: deduplicate the following code
eval :: (a -> v -> Bool) -> Expr a -> v -> Bool
eval t e v = ev e
  where
        ev EAny = True
        ev (EAnd e1 e2) = ev e1 && ev e2
        ev (EOr e1 e2) =  ev e1 || ev e2
        ev (ENot e1) = (not . ev) e1
        ev (E e') = t e' v

pkpEval :: PKPPredicate -> PKPayload -> Bool
pkpEval (PKPPredicate lhs o rhs) pkp = uncurry (opreduce o) (vreduce (lhs,pkp),rhs)
    where
        opreduce PKEquals = (==)
        opreduce PKLessThan = (<)
        opreduce PKGreaterThan = (>)
        vreduce (PKPVVersion, p) = PKPInt (kv (_keyVersion p))
        vreduce (PKPVPKA, p) = PKPPKA (_pkalgo p)
        vreduce (PKPVKeysize, p) = PKPInt (either (const 0) id . pubkeySize . _pubkey $ p) -- FIXME: this should be smarter
        vreduce (PKPVTimestamp, p) = PKPInt (fromIntegral (_timestamp p))
        vreduce (PKPVEOKI, p) = PKPEOKI (eightOctetKeyID p)
        vreduce (PKPVTOF, p) = PKPTOF (fingerprint p)
        kv DeprecatedV3 = 3
        kv V4 = 4

sigissuer :: SignaturePayload -> Maybe EightOctetKeyId
getIssuer :: SigSubPacketPayload -> Maybe EightOctetKeyId
hashAlgo :: SignaturePayload -> HashAlgorithm

sigissuer (SigVOther 2 _) = Nothing
sigissuer (SigV3 {}) = Nothing
sigissuer (SigV4 _ _ _ ys xs _ _) = listToMaybe . mapMaybe (getIssuer . _sspPayload) $ (ys++xs) -- FIXME: what should this be if there are multiple matches?
sigissuer (SigVOther _ _) = error "We're in the future." -- FIXME

getIssuer (Issuer i) = Just i
getIssuer _ = Nothing

hashAlgo (SigV4 _ _ x _ _ _ _) = x
hashAlgo _ = error "V3 sig not supported here"