diff --git a/README.md b/README.md index 4caf5da..969d908 100644 --- a/README.md +++ b/README.md @@ -80,7 +80,8 @@ If you want to log in to niftyservice again, proceed as follows. At this point, pwsafe has opened the website that corresponds to the entry in your default web browser. To configure your default web browser, consult - the documentation of xdg-open and update-alternatives. + the documentation of xdg-open and update-alternatives. If you do not want + pwsafe to open your browser, please supply the -d option. 2. Switch to your browser window and navigate to the login page. @@ -91,6 +92,18 @@ If you want to log in to niftyservice again, proceed as follows. 5. pwsafe exits, and you can proceed with the login. +Using multiple accounts per service +----------------------------------- + +pwsafe allows you to add more than just one user account per service. In order +to do so, you have to supply the -A option when adding a second user account. +When you query the database, pwsafe will either copy the credentials into the +paste buffer (if there is only one account for the respective service) or write +a list of known usernames to stdout and exit with code 2. You can supply the +--user parameter when querying, so that pwsafe knows which entry in the database +you would like to have. + + Changing or deleting an entry in the database --------------------------------------------- @@ -111,11 +124,14 @@ password. user=rwDJEs5J password=XArG9R4QBDwR7ceCVjyV url=http://www.niftyservice.com + user_1=another_user + password=1=BLABLA + url=http://www.niftyservice.com -Only the "name" and "password" fields are required. "user" and "url" are -optional. If the url field is not present, pwsafe will not invoke your web -browser on query. If the user field is not present, pwsafe will only provide -the password to paste on query. +Only the "name" (i.e., section name) and "password" fields are required. "user" +and "url" are optional. If the url field is not present, pwsafe will not invoke +your web browser on query. If the user field is not present, pwsafe will only +provide the password to paste on query. Command line options @@ -123,25 +139,29 @@ Command line options Usage: pwsafe [OPTION]... - --help display this help and exit - -a URL --add=URL add a new entry to the database; the password is - always automatically generated; the username is - generated unless --user is specified - -q TERM --query=TERM lookup a password, the term must match exactly one - entry - -l[TERM] --list[=TERM] list all entries matching the given term - -e --edit invoke vim to edit the database using sensible - defaults (no backup, no swapfile etc) - --dump dump database to stdout - --lock acquire write lock for database - --unlock release write lock for database - --dbfile=FILE file where passwords are stored; - defaults to ~/.pwsafe/db - --user=USER specify a username to be used for a new entry; - this option is to be used with --add - -n NUMBER copy password n times to clipboard; - defaults to 1 - --password-only only copy password to clipboard + --help display this help and exit + -a URL --add=URL add a new entry to the database; the password + is always automatically generated; the + username is generated unless --user is + specified + -q TERM --query=TERM lookup a password, the term must match + exactly one entry + -l[TERM] --list[=TERM] list all entries matching the given term + -e --edit invoke vim to edit the database using sensible + defaults (no backup, no swapfile etc) + --dump dump database to stdout + --lock acquire write lock for database + --unlock release write lock for database + --dbfile=FILE file where passwords are stored; + defaults to ~/.pwsafe/db + --user=USER specify a username to be used for an entry; + this option is to be used with --add or -q + -n NUMBER copy password n times to clipboard; + defaults to 1 + --password-only only copy password to clipboard + -g gpgopt --gpg-option=gpgopt add a GPG option (repeat to add multiple) + -d don't open a browser when using -q + -A force add a new account Development =========== diff --git a/bash_completion.d/pwsafe b/bash_completion.d/pwsafe index c6e7a86..a102fc7 100644 --- a/bash_completion.d/pwsafe +++ b/bash_completion.d/pwsafe @@ -9,7 +9,7 @@ _pwsafe() cur="${COMP_WORDS[COMP_CWORD]}" prev="${COMP_WORDS[COMP_CWORD-1]}" - opts='--help -a --add -q --query -l --list -e --edit --dump --lock --unlock --dbfile --user --password-only' + opts='--help -a --add -q --query -l --list -e --edit --dump --lock --unlock --dbfile --user --password-only -g --gpg-option -d -A' case "${prev}" in diff --git a/pwsafe.cabal b/pwsafe.cabal index f21613f..41089ec 100644 --- a/pwsafe.cabal +++ b/pwsafe.cabal @@ -23,6 +23,7 @@ executable pwsafe , deepseq , unix , config-ng + , MissingH main-is: Main.hs diff --git a/src/Action.hs b/src/Action.hs index 08c78c0..0ae9f93 100644 --- a/src/Action.hs +++ b/src/Action.hs @@ -2,14 +2,17 @@ module Action (runAction, mkEnv, add, query, list, edit, dump) where import Prelude hiding (putStrLn, putStr) +import qualified Prelude import Control.Monad (replicateM_, unless) import Control.Monad.Trans.Reader import Control.Monad.IO.Class (liftIO) import System.IO (hPutStr, hClose) import qualified System.IO as IO +import System.Exit import Control.DeepSeq (deepseq) import Text.Printf import Data.Foldable (forM_) +import Data.Maybe (mapMaybe) import Util (nameFromUrl, run, withTempFile, match_) import Database (Database, Entry(..)) @@ -38,6 +41,10 @@ mkEnv conf cipher h = Env { , envHandle = h } +doIO :: IO a -> ActionM a +doIO action = ActionM $ do + liftIO action + liftAction :: (Env -> IO a) -> ActionM a liftAction action = ActionM $ do env <- ask @@ -70,8 +77,8 @@ saveDatabase :: Database -> ActionM () saveDatabase = encrypt . Database.render -add :: String -> Maybe String -> ActionM () -add url_ mUser = do +add :: String -> Maybe String -> Bool -> ActionM () +add url_ mUser force = do user <- maybe genUser return mUser password_ <- genPassword addEntry $ Entry {entryName = nameFromUrl url_, entryUser = Just user, entryPassword = Just password_, entryUrl = Just url_} @@ -88,24 +95,27 @@ add url_ mUser = do -- gets an error before he has to enter his password. entry `deepseq` do db <- openDatabase - case Database.addEntry db entry of + case Database.addEntry db entry force of Left err -> error err Right db_ -> saveDatabase db_ -query :: String -> Int -> Bool -> ActionM () -query kw n passwordOnly = do +query :: String -> Maybe String -> Int -> Bool -> Bool -> ActionM () +query kw mUser n passwordOnly noOpen = do db <- openDatabase - case Database.lookupEntry db kw of - Left err -> putStrLn err - Right x -> x `deepseq` do -- force pending exceptions early.. + case Database.lookupEntryUser db kw mUser of + Left err -> putStrLn err >> (doIO $ exitWith (ExitFailure 1)) + Right (x:[]) -> x `deepseq` do -- force pending exceptions early.. unless passwordOnly $ do forM_ (entryUrl x) $ \url -> do putStrLn url - open url + unless noOpen $ open url forM_ (entryUser x) copyToClipboard forM_ (entryPassword x) $ replicateM_ n . copyToClipboard + Right xs -> do + mapM_ putStrLn (mapMaybe entryUser xs) + doIO $ exitWith (ExitFailure 2) where open = liftAction1 (Config.openUrl . envConfig) diff --git a/src/Config.hs b/src/Config.hs index 108e64f..d9f918e 100644 --- a/src/Config.hs +++ b/src/Config.hs @@ -2,6 +2,9 @@ module Config (Config(..), defaultConfig) where import System.Process import Util (run) +import Control.Exception +import System.Exit +import System.IO data Config = Config { copyToClipboard :: String -> IO () @@ -18,16 +21,24 @@ defaultConfig = Config { , generatePassword = pwgen (Just 20) } +readProcess' :: FilePath -> [String] -> String -> IO String +readProcess' fp a i = handle handler $ readProcess fp a i + where + handler e = do + let err = show (e :: SomeException) + hPutStr stderr $ "Cannot execute " ++ fp ++ ". Error was: " ++ err + exitWith (ExitFailure 1) + xclip :: String -> IO () -- vimperator, for some reason, needs -l 2, pentadactyl works with -l 1 -- xclip input = readProcess "xclip" ["-l", "2", "-quiet"] input >> return () -xclip input = readProcess "xclip" ["-l", "1", "-quiet"] input >> return () +xclip input = readProcess' "xclip" ["-l", "1", "-quiet"] input >> return () xdgOpen :: String -> IO () xdgOpen url = run "xdg-open" [url] pwgen :: Maybe Int -- ^ length of generated password -> IO String -pwgen mLength = fmap init $ readProcess "pwgen" args "" +pwgen mLength = fmap init $ readProcess' "pwgen" args "" where args = "-s" : maybe [] (return . show) mLength diff --git a/src/Database.hs b/src/Database.hs index 7a6e60d..10f4300 100644 --- a/src/Database.hs +++ b/src/Database.hs @@ -1,8 +1,10 @@ -module Database (Database, empty, parse, render, addEntry, Entry(..), lookupEntry, hasEntry, entryNames) where +module Database (Database, empty, parse, render, addEntry, Entry(..), lookupEntryUser, hasEntry, entryNames) where import Prelude hiding (lookup) -import Data.List (intercalate) +import Data.List (intercalate, sort, nub) +import Data.String.Utils +import Data.Maybe import Control.DeepSeq import Text.Printf (printf) @@ -26,20 +28,58 @@ newtype Database = Database { config :: Config } empty :: Database empty = Database Config.empty -lookupEntry :: Database -> String -> Either String Entry +lookupEntry :: Database -> String -> Either String [Entry] lookupEntry db s = case match s $ entryNames db of None -> Left "no match" - Ambiguous l -> Left $ printf "ambiguous, could refer to:\n %s" $ intercalate "\n " l - Match name -> entry + Ambiguous l -> Left $ printf "ambiguous, could refer to:\n %s" $ intercalate "\n " l + Match name -> Right $ getEntries db name + +lookupEntryUser :: Database -> String -> Maybe String -> Either String [Entry] +lookupEntryUser db s mUser = + case entries of + Left _ -> entries + Right l -> Right $ maybe l (maybeToList . findUser l) mUser + where entries = lookupEntry db s + +getEntries :: Database -> String -> [Entry] +getEntries db name = + map makeEntry $ zip3 users passwords urls where - entry = do - return Entry { - entryName = name - , entryUser = lookup "user" - , entryPassword = lookup "password" - , entryUrl = lookup "url" - } - lookup k = Config.lookup name k (config db) + knownKeys = sort $ Config.keys name (config db) + isKey k x = (x == k || startswith (k ++ "_") x) + userSuffixes = map (drop 4) $ filter (isKey "user") knownKeys + passSuffixes = map (drop 8) $ filter (isKey "password") knownKeys + urlSuffixes = map (drop 3) $ filter (isKey "url") knownKeys + suffixes = nub $ sort $ userSuffixes ++ passSuffixes ++ urlSuffixes + users = map (lookup . ("user"++)) suffixes + passwords = map (lookup . ("password"++)) suffixes + urls = map (lookup . ("url"++)) suffixes + lookup k = Config.lookup name k (config db) + + makeEntry (us, pw, ur) = Entry { + entryName = name + , entryUser = us + , entryPassword = pw + , entryUrl = ur + } + +buildEntrySuffix :: Database -> String -> String +buildEntrySuffix db name = + newSuffixes !! 0 + where + knownKeys = sort $ Config.keys name (config db) + isKey k x = (x == k || startswith (k ++ "_") x) + userSuffixes = map (drop 4) $ filter (isKey "user") knownKeys + passSuffixes = map (drop 8) $ filter (isKey "password") knownKeys + urlSuffixes = map (drop 3) $ filter (isKey "url") knownKeys + suffixes = userSuffixes ++ passSuffixes ++ urlSuffixes + newSuffixes = dropWhile (`elem` suffixes) ("":map (("_" ++) . show) ([1..] :: [Int])) + +findUser :: [Entry] -> String -> Maybe Entry +findUser [] _ = Nothing +findUser (x:xs) u = if (entryUser x) == Just u + then Just x + else findUser xs u hasEntry :: String -> Database -> Bool hasEntry name = Config.hasSection name . config @@ -47,19 +87,24 @@ hasEntry name = Config.hasSection name . config entryNames :: Database -> [String] entryNames = Config.sections . config -addEntry :: Database -> Entry -> Either String Database -addEntry db entry = +addEntry :: Database -> Entry -> Bool -> Either String Database +addEntry db entry forceAdd = case hasEntry name db of - True -> Left $ printf "Entry with name \"%s\" already exists!" name - False -> Right db {config = insertEntry entry $ config db} + True -> + if forceAdd && not (isJust $ findUser (getEntries db name) (fromJust $ entryUser entry)) + then Right $ doInsert + else Left $ printf "Entry with name \"%s\" already exists!" name + False -> Right $ doInsert where - name = entryName entry - -insertEntry :: Entry -> Config -> Config -insertEntry entry = - mInsert "user" user - . mInsert "password" password - . mInsert "url" url + name = entryName entry + ni = buildEntrySuffix db name + doInsert = db {config = insertEntry entry ni $ config db} + +insertEntry :: Entry -> String-> Config -> Config +insertEntry entry ni = + mInsert ("user" ++ ni) user + . mInsert ("password" ++ ni) password + . mInsert ("url" ++ ni) url where insert = Config.insert $ entryName entry mInsert k = maybe id (insert k) diff --git a/src/Lock.hs b/src/Lock.hs index 1747e97..1f28370 100644 --- a/src/Lock.hs +++ b/src/Lock.hs @@ -2,19 +2,24 @@ module Lock (acquire, release) where import System.Posix.Files (ownerReadMode, ownerWriteMode, unionFileModes) -import System.Posix.Semaphore (semOpen, OpenSemFlags (..), semUnlink) -import qualified Control.Exception as E +import System.Posix.Semaphore (semOpen, OpenSemFlags (..), + semPost, semTryWait) -- | Try to acquire the lock, return `True` on success acquire :: IO Bool -acquire = (semOpen "/pwsafe" (OpenSemFlags True True) readWriteMode 0 >> return True) `E.catch` handlerFalse +acquire = do + s <- semOpen "/pwsafe" (OpenSemFlags True False) readWriteMode 1 + semTryWait s where readWriteMode = ownerReadMode `unionFileModes` ownerWriteMode -- | Release the lock, return `True` on success release :: IO Bool -release = (semUnlink "/pwsafe" >> return True) `E.catch` handlerFalse +release = do + s <- semOpen "/pwsafe" (OpenSemFlags True False) readWriteMode 1 + free <- semTryWait s + semPost s + return $ not free + where + readWriteMode = ownerReadMode `unionFileModes` ownerWriteMode --- | Return `False` on `E.SomeException` -handlerFalse :: E.SomeException -> IO Bool -handlerFalse = const $ return False diff --git a/src/Main.hs b/src/Main.hs index 3412377..b781d7e 100644 --- a/src/Main.hs +++ b/src/Main.hs @@ -10,4 +10,4 @@ import Run (run) main :: IO () main = do args <- getArgs - run defaultConfig (Cipher.gpgCipher []) stdout args + run defaultConfig (Cipher.gpgCipher) stdout args diff --git a/src/Options.hs b/src/Options.hs index 7a47407..faf76d0 100644 --- a/src/Options.hs +++ b/src/Options.hs @@ -16,6 +16,9 @@ data Options = Options { , userName :: Maybe String , repeatCount :: Maybe Int , passwordOnly :: Bool + , gpgOptions :: [String] + , dontOpen :: Bool + , forceAdd :: Bool } deriving Show defaultOptions :: Options @@ -25,6 +28,9 @@ defaultOptions = Options { , userName = Nothing , repeatCount = Nothing , passwordOnly = False + , gpgOptions = [] + , dontOpen = False + , forceAdd = False } options :: [OptDescr (Options -> Options)] @@ -39,10 +45,14 @@ options = [ , Option [] ["unlock"] (NoArg (\ opts -> opts { mode = ReleaseLock})) "release write lock for database" , Option [] ["dbfile"] (ReqArg (\s opts -> opts { databaseFile = s }) "FILE") "file where passwords are stored;\ndefaults to ~/.pwsafe/db" - , Option [] ["user"] (ReqArg (\s opts -> opts { userName = Just s }) "USER") "specify a username to be used for a new entry;\nthis option is to be used with --add" + , Option [] ["user"] (ReqArg (\s opts -> opts { userName = Just s }) "USER") "specify a username to be used for an entry;\nthis option is to be used with --add or -q" , Option ['n'] [] (ReqArg (\s opts -> opts { repeatCount = (Just . read) s }) "NUMBER") "copy password n times to clipboard;\ndefaults to 1" , Option [] ["password-only"] (NoArg (\ opts -> opts { passwordOnly = True})) "only copy password to clipboard" + , Option ['g'] ["--gpg-option"] + (ReqArg (\s opts -> opts { gpgOptions = (gpgOptions opts) ++ [s]}) "GPG Option") "add a GPG option (repeat to add multiple)" + , Option ['d'] [] (NoArg (\ opts -> opts { dontOpen = True })) "don't open a browser when using -q" + , Option ['A'] [] (NoArg (\ opts -> opts { forceAdd = True })) "force add a new account" ] defaultDatabaseFile :: IO String diff --git a/src/Run.hs b/src/Run.hs index 07b85d1..0552552 100644 --- a/src/Run.hs +++ b/src/Run.hs @@ -11,15 +11,15 @@ import Config (Config) import qualified Action import Cipher (Cipher) -run :: Config -> (FilePath -> Cipher) -> Handle -> [String] -> IO () +run :: Config -> ([String] -> FilePath -> Cipher) -> Handle -> [String] -> IO () run conf cipher h args = do opts <- Options.get args - let c = cipher $ Options.databaseFile opts + let c = cipher (Options.gpgOptions opts) (Options.databaseFile opts) runAction = Action.runAction (Action.mkEnv conf c h) case Options.mode opts of Help -> Options.printHelp - Add url -> withLock $ runAction $ Action.add url (Options.userName opts) - Query s -> runAction $ Action.query s (maybe 1 id $ Options.repeatCount opts) (Options.passwordOnly opts) + Add url -> withLock $ runAction $ Action.add url (Options.userName opts) (Options.forceAdd opts) + Query s -> runAction $ Action.query s (Options.userName opts) (maybe 1 id $ Options.repeatCount opts) (Options.passwordOnly opts) (Options.dontOpen opts) List p -> runAction $ Action.list p Edit -> withLock $ Action.edit c Dump -> runAction $ Action.dump diff --git a/test/ActionSpec.hs b/test/ActionSpec.hs index cb3e3cd..7f40e3f 100644 --- a/test/ActionSpec.hs +++ b/test/ActionSpec.hs @@ -40,7 +40,7 @@ pwsafe args db = do encrypt c db k <- K.newKnob "" K.withFileHandle k "knob.txt" WriteMode $ \h -> do - Run.run (conf clipboardSink) (const c) h (words args) + Run.run (conf clipboardSink) (const $ const c) h (words args) db_ <- Cipher.decrypt c out <- B.unpack `fmap` K.getContents k clipboard <- clipboardAccessor diff --git a/test/DatabaseSpec.hs b/test/DatabaseSpec.hs index 6ac11ef..f6a6b00 100644 --- a/test/DatabaseSpec.hs +++ b/test/DatabaseSpec.hs @@ -38,7 +38,7 @@ instance Arbitrary DatabaseFile where shrink (DatabaseFile _ xs) = [ dbFromList (xs \\ [x]) | x <- xs ] addEntry :: Entry -> Database -> Database -addEntry e db = either error id $ Database.addEntry db e +addEntry e db = either error id $ Database.addEntry db e False entry :: String -> String -> String -> String -> Entry entry name user password url = Database.Entry name (Just user) (Just password) (Just url) @@ -113,10 +113,10 @@ spec = do "user=foo" "password=bar" "url=http://example.com" - lookupEntry db "example.com" `shouldBe` Right (entry "example.com" "foo" "bar" "http://example.com") + lookupEntryUser db "example.com" Nothing `shouldBe` Right ([entry "example.com" "foo" "bar" "http://example.com"]) - it "works on a database with arbitrary entries" $ + it "works on a database with arbitrary unique entries" $ property $ \(DatabaseFile input xs) -> (not . null) xs ==> do x <- elements xs - return $ lookupEntry (parse input) (entryName x) == Right x + return $ lookupEntryUser (parse input) (entryName x) Nothing == Right [x] diff --git a/test/OptionsSpec.hs b/test/OptionsSpec.hs index dd815cc..bf37568 100644 --- a/test/OptionsSpec.hs +++ b/test/OptionsSpec.hs @@ -32,3 +32,10 @@ spec = do opts <- Options.get ["--add", "foo", "--help", "--query", "baz"] Options.mode opts `shouldBe` Query "baz" + it "recognizes -g" $ do + opts <- Options.get ["-g", "foo"] + Options.gpgOptions opts `shouldBe` ["foo"] + + it "accepts multiple -g options" $ do + opts <- Options.get ["-g", "foo", "-g", "bar"] + Options.gpgOptions opts `shouldBe` ["foo", "bar"]