packages feed

dvault-0.1.0.0: src/Main.hs

{-|
Module: Main
Description: dvault command line tool.
-}
module Main where

import Generate

import Control.Monad      (void)
import Data.Default       (def)
import Data.Maybe         (fromMaybe)
import System.Directory   (createDirectoryIfMissing, listDirectory)
import System.Environment (getArgs, getEnv)
import System.Exit        (exitFailure, exitSuccess)
import System.IO          (hClose, hPutStr)
import System.Process
    ( CreateProcess(..)
    , StdStream(CreatePipe)
    , createProcess
    , proc
    , readProcess
    , waitForProcess
    )
import Text.Read          (readMaybe)

import qualified Data.ByteString as B
import qualified Data.Set        as S

-- | Get the directory in which to store our data.
getVaultDir :: IO FilePath
getVaultDir = do
    -- It's not entirely obvious to me(zenhack) what's the "right" place to
    -- put our data; the xdg base spec mentions an $XDG_DATA_HOME that falls
    -- back to $HOME/.local/share, but other programs put stuff in ~/.local
    -- outside of ~/.local/share, and ~/.local/share doesn't feel right.
    -- If this were system wide, the right place would probably be /var/lib,
    -- but there's no xdg homedir equivalent. For now, we just do ~/.local
    home <- getEnv "HOME"
    let path = home ++ "/.local/dvault"
    createDirectoryIfMissing True path
    return path

-- | @'dmenu' options@ launches the dmenu(1) command, supplying it with
-- @options@, and returning the user's choice.
dmenu :: [String] -> IO String
dmenu options = fmap stripNewline $ readProcess "dmenu" [] $ unlines options
  where
    stripNewline = takeWhile (/= '\n')

-- | @'xclip' foo@ copies the string @foo@ to both x11 clipboards.
xclip :: String -> IO ()
xclip contents = do
    xclipI ["-sel", "clip"]
    xclipI []
  where
    xclipI args = do
        -- For some reason I don't understand, just doing readProcess hangs.
        let p = (proc "xclip" ("-i":args)) { std_in = CreatePipe }
        (Just inpipe, Nothing, Nothing, pid) <- createProcess p
        hPutStr inpipe contents
        hClose inpipe
        void $ waitForProcess pid

-- | Send a desktop notification
notify :: String -> IO ()
notify summary = void $ readProcess "notify-send" ["-t", "2000", summary] ""

main = do
    args <- getArgs
    dir <- getVaultDir
    case args of
        ["-h"] -> usage >> exitSuccess
        ["--help"] -> usage >> exitSuccess
        []                -> fetchPass dir
        [arg] | arg `elem` ["-h", "--help"] -> usage >> exitSuccess
        ("gen":name:args)
            | name `elem` ["-h", "--help"] -> usage >> exitSuccess
            | otherwise -> do
                opts <- genCmd Nothing S.empty args
                newPass opts (passFilename dir name)
        _ -> usage >> exitFailure

usage = putStrLn $ unlines
    [ "Usage: dvault [ gen <tag> [ --size <size> ] [ CHARSET... ] ] | -h | --help"
    , ""
    , "where CHARSET is one of:"
    , ""
    , "    --letters"
    , "    --upper"
    , "    --lower"
    , "    --digits"
    , "    --symbols"
    , ""
    , "Note that --letters cannot be combined with --upper or --lower."
    ]

genCmd :: Maybe Int -> S.Set (S.Set Char) -> [String] -> IO Options
genCmd _ sets _ | seq sets False = undefined -- force `sets` to be evaluated.
genCmd argSize sets []
    | letter `S.member` sets &&
        (lower `S.member` sets || upper `S.member` sets)
        = do
            putStrLn $ "Error : You cannot specify both --letters and " ++
                "either --upper or --lower."
            usage >> exitFailure
    | otherwise = return
        Options
            { size = size def `fromMaybe` argSize
            , charSets = if S.null sets
                then charSets def
                else S.toList sets
            }
genCmd Nothing sets ("--size":size:args) = case readMaybe size of
    Just size' -> genCmd (Just size') sets args
    Nothing    -> usage >> exitFailure
genCmd (Just size) _ ("--size":_) = usage >> exitFailure
genCmd size sets ("--letters":args) =
    genCmd size (S.insert letter sets) args
genCmd size sets ("--upper":args) =
    genCmd size (S.insert upper sets) args
genCmd size sets ("--lower":args) =
    genCmd size (S.insert lower sets) args
genCmd size sets ("--digits":args) =
    genCmd size (S.insert digit sets) args
genCmd size sets ("--symbols":args) =
    genCmd size (S.insert symbol sets) args
genCmd _ _ ("--help":_) = usage >> exitSuccess
genCmd _ _ ("-h":_) = usage >> exitSuccess
genCmd _ _ _ = usage >> exitFailure


-- | Generate a new password and save it (encrypted) to the supplied path.
newPass :: Options -> FilePath -> IO ()
newPass opts path = do
    plaintext <- generate opts
    ciphertext <- readProcess "gpg" ["-a", "-e"] plaintext
    writeFile path ciphertext

-- | Prompt the user for a password label given the available passwords in
-- directory @dir@, then decrypt then user's choice and copy it to the
-- x11 clipboards.
fetchPass dir = do
    sites <- map (`dropSuffix` theSuffix)
                <$> filter (`endsWith` theSuffix)
                <$> listDirectory dir
    selection <- dmenu sites
    ciphertext <- readFile (dir ++ "/" ++ selection ++ theSuffix)
    plaintext <- readProcess "gpg" ["-a", "-d"] ciphertext
    xclip plaintext
    notify $ "Password for " ++ selection ++ " copied to clipboard."
  where
    str    `endsWith` suffix | str == suffix = True
    []     `endsWith` _ = False
    (c:cs) `endsWith` suffix = cs `endsWith` suffix

str    `dropSuffix` suffix | str == suffix = []
[]     `dropSuffix` _ = []
(c:cs) `dropSuffix` suffix = c:(cs `dropSuffix` suffix)

-- | Suffix to append to the password's label to form a filename.
theSuffix = ".pass.asc"

-- | @'passFilename' vaultdir label@ is the file in which the password for @label@
-- is stored, given the storage directory @vaultdir@.
passFilename :: String -> String -> FilePath
passFilename dir name = dir ++ "/" ++ name ++ theSuffix