diff --git a/ChangeLog.md b/ChangeLog.md
new file mode 100644
--- /dev/null
+++ b/ChangeLog.md
@@ -0,0 +1,22 @@
+# Changelog
+All notable changes to this project will be documented in this file.
+
+The format is based on [Keep a Changelog](http://keepachangelog.com/en/1.0.0/).
+
+## [Unreleased]
+
+## [1.0.0] - 2019-07-16
+- Major reimplementation based on polysemy
+
+## [0.2.0] - 2018-06-07
+
+### Added
+- Support for secrets in non-canonical format (https://github.com/rkaippully/gamgee/issues/2)
+
+## [0.1.0] - 2018-06-05
+
+### Added
+- First version.
+- Supports TOTP based tokens
+- Secure storage of tokens via AES256 encryption
+- Added installation and usage instructions to README
diff --git a/LICENSE b/LICENSE
new file mode 100644
--- /dev/null
+++ b/LICENSE
@@ -0,0 +1,373 @@
+Mozilla Public License Version 2.0
+==================================
+
+1. Definitions
+--------------
+
+1.1. "Contributor"
+    means each individual or legal entity that creates, contributes to
+    the creation of, or owns Covered Software.
+
+1.2. "Contributor Version"
+    means the combination of the Contributions of others (if any) used
+    by a Contributor and that particular Contributor's Contribution.
+
+1.3. "Contribution"
+    means Covered Software of a particular Contributor.
+
+1.4. "Covered Software"
+    means Source Code Form to which the initial Contributor has attached
+    the notice in Exhibit A, the Executable Form of such Source Code
+    Form, and Modifications of such Source Code Form, in each case
+    including portions thereof.
+
+1.5. "Incompatible With Secondary Licenses"
+    means
+
+    (a) that the initial Contributor has attached the notice described
+        in Exhibit B to the Covered Software; or
+
+    (b) that the Covered Software was made available under the terms of
+        version 1.1 or earlier of the License, but not also under the
+        terms of a Secondary License.
+
+1.6. "Executable Form"
+    means any form of the work other than Source Code Form.
+
+1.7. "Larger Work"
+    means a work that combines Covered Software with other material, in
+    a separate file or files, that is not Covered Software.
+
+1.8. "License"
+    means this document.
+
+1.9. "Licensable"
+    means having the right to grant, to the maximum extent possible,
+    whether at the time of the initial grant or subsequently, any and
+    all of the rights conveyed by this License.
+
+1.10. "Modifications"
+    means any of the following:
+
+    (a) any file in Source Code Form that results from an addition to,
+        deletion from, or modification of the contents of Covered
+        Software; or
+
+    (b) any new file in Source Code Form that contains any Covered
+        Software.
+
+1.11. "Patent Claims" of a Contributor
+    means any patent claim(s), including without limitation, method,
+    process, and apparatus claims, in any patent Licensable by such
+    Contributor that would be infringed, but for the grant of the
+    License, by the making, using, selling, offering for sale, having
+    made, import, or transfer of either its Contributions or its
+    Contributor Version.
+
+1.12. "Secondary License"
+    means either the GNU General Public License, Version 2.0, the GNU
+    Lesser General Public License, Version 2.1, the GNU Affero General
+    Public License, Version 3.0, or any later versions of those
+    licenses.
+
+1.13. "Source Code Form"
+    means the form of the work preferred for making modifications.
+
+1.14. "You" (or "Your")
+    means an individual or a legal entity exercising rights under this
+    License. For legal entities, "You" includes any entity that
+    controls, is controlled by, or is under common control with You. For
+    purposes of this definition, "control" means (a) the power, direct
+    or indirect, to cause the direction or management of such entity,
+    whether by contract or otherwise, or (b) ownership of more than
+    fifty percent (50%) of the outstanding shares or beneficial
+    ownership of such entity.
+
+2. License Grants and Conditions
+--------------------------------
+
+2.1. Grants
+
+Each Contributor hereby grants You a world-wide, royalty-free,
+non-exclusive license:
+
+(a) under intellectual property rights (other than patent or trademark)
+    Licensable by such Contributor to use, reproduce, make available,
+    modify, display, perform, distribute, and otherwise exploit its
+    Contributions, either on an unmodified basis, with Modifications, or
+    as part of a Larger Work; and
+
+(b) under Patent Claims of such Contributor to make, use, sell, offer
+    for sale, have made, import, and otherwise transfer either its
+    Contributions or its Contributor Version.
+
+2.2. Effective Date
+
+The licenses granted in Section 2.1 with respect to any Contribution
+become effective for each Contribution on the date the Contributor first
+distributes such Contribution.
+
+2.3. Limitations on Grant Scope
+
+The licenses granted in this Section 2 are the only rights granted under
+this License. No additional rights or licenses will be implied from the
+distribution or licensing of Covered Software under this License.
+Notwithstanding Section 2.1(b) above, no patent license is granted by a
+Contributor:
+
+(a) for any code that a Contributor has removed from Covered Software;
+    or
+
+(b) for infringements caused by: (i) Your and any other third party's
+    modifications of Covered Software, or (ii) the combination of its
+    Contributions with other software (except as part of its Contributor
+    Version); or
+
+(c) under Patent Claims infringed by Covered Software in the absence of
+    its Contributions.
+
+This License does not grant any rights in the trademarks, service marks,
+or logos of any Contributor (except as may be necessary to comply with
+the notice requirements in Section 3.4).
+
+2.4. Subsequent Licenses
+
+No Contributor makes additional grants as a result of Your choice to
+distribute the Covered Software under a subsequent version of this
+License (see Section 10.2) or under the terms of a Secondary License (if
+permitted under the terms of Section 3.3).
+
+2.5. Representation
+
+Each Contributor represents that the Contributor believes its
+Contributions are its original creation(s) or it has sufficient rights
+to grant the rights to its Contributions conveyed by this License.
+
+2.6. Fair Use
+
+This License is not intended to limit any rights You have under
+applicable copyright doctrines of fair use, fair dealing, or other
+equivalents.
+
+2.7. Conditions
+
+Sections 3.1, 3.2, 3.3, and 3.4 are conditions of the licenses granted
+in Section 2.1.
+
+3. Responsibilities
+-------------------
+
+3.1. Distribution of Source Form
+
+All distribution of Covered Software in Source Code Form, including any
+Modifications that You create or to which You contribute, must be under
+the terms of this License. You must inform recipients that the Source
+Code Form of the Covered Software is governed by the terms of this
+License, and how they can obtain a copy of this License. You may not
+attempt to alter or restrict the recipients' rights in the Source Code
+Form.
+
+3.2. Distribution of Executable Form
+
+If You distribute Covered Software in Executable Form then:
+
+(a) such Covered Software must also be made available in Source Code
+    Form, as described in Section 3.1, and You must inform recipients of
+    the Executable Form how they can obtain a copy of such Source Code
+    Form by reasonable means in a timely manner, at a charge no more
+    than the cost of distribution to the recipient; and
+
+(b) You may distribute such Executable Form under the terms of this
+    License, or sublicense it under different terms, provided that the
+    license for the Executable Form does not attempt to limit or alter
+    the recipients' rights in the Source Code Form under this License.
+
+3.3. Distribution of a Larger Work
+
+You may create and distribute a Larger Work under terms of Your choice,
+provided that You also comply with the requirements of this License for
+the Covered Software. If the Larger Work is a combination of Covered
+Software with a work governed by one or more Secondary Licenses, and the
+Covered Software is not Incompatible With Secondary Licenses, this
+License permits You to additionally distribute such Covered Software
+under the terms of such Secondary License(s), so that the recipient of
+the Larger Work may, at their option, further distribute the Covered
+Software under the terms of either this License or such Secondary
+License(s).
+
+3.4. Notices
+
+You may not remove or alter the substance of any license notices
+(including copyright notices, patent notices, disclaimers of warranty,
+or limitations of liability) contained within the Source Code Form of
+the Covered Software, except that You may alter any license notices to
+the extent required to remedy known factual inaccuracies.
+
+3.5. Application of Additional Terms
+
+You may choose to offer, and to charge a fee for, warranty, support,
+indemnity or liability obligations to one or more recipients of Covered
+Software. However, You may do so only on Your own behalf, and not on
+behalf of any Contributor. You must make it absolutely clear that any
+such warranty, support, indemnity, or liability obligation is offered by
+You alone, and You hereby agree to indemnify every Contributor for any
+liability incurred by such Contributor as a result of warranty, support,
+indemnity or liability terms You offer. You may include additional
+disclaimers of warranty and limitations of liability specific to any
+jurisdiction.
+
+4. Inability to Comply Due to Statute or Regulation
+---------------------------------------------------
+
+If it is impossible for You to comply with any of the terms of this
+License with respect to some or all of the Covered Software due to
+statute, judicial order, or regulation then You must: (a) comply with
+the terms of this License to the maximum extent possible; and (b)
+describe the limitations and the code they affect. Such description must
+be placed in a text file included with all distributions of the Covered
+Software under this License. Except to the extent prohibited by statute
+or regulation, such description must be sufficiently detailed for a
+recipient of ordinary skill to be able to understand it.
+
+5. Termination
+--------------
+
+5.1. The rights granted under this License will terminate automatically
+if You fail to comply with any of its terms. However, if You become
+compliant, then the rights granted under this License from a particular
+Contributor are reinstated (a) provisionally, unless and until such
+Contributor explicitly and finally terminates Your grants, and (b) on an
+ongoing basis, if such Contributor fails to notify You of the
+non-compliance by some reasonable means prior to 60 days after You have
+come back into compliance. Moreover, Your grants from a particular
+Contributor are reinstated on an ongoing basis if such Contributor
+notifies You of the non-compliance by some reasonable means, this is the
+first time You have received notice of non-compliance with this License
+from such Contributor, and You become compliant prior to 30 days after
+Your receipt of the notice.
+
+5.2. If You initiate litigation against any entity by asserting a patent
+infringement claim (excluding declaratory judgment actions,
+counter-claims, and cross-claims) alleging that a Contributor Version
+directly or indirectly infringes any patent, then the rights granted to
+You by any and all Contributors for the Covered Software under Section
+2.1 of this License shall terminate.
+
+5.3. In the event of termination under Sections 5.1 or 5.2 above, all
+end user license agreements (excluding distributors and resellers) which
+have been validly granted by You or Your distributors under this License
+prior to termination shall survive termination.
+
+************************************************************************
+*                                                                      *
+*  6. Disclaimer of Warranty                                           *
+*  -------------------------                                           *
+*                                                                      *
+*  Covered Software is provided under this License on an "as is"       *
+*  basis, without warranty of any kind, either expressed, implied, or  *
+*  statutory, including, without limitation, warranties that the       *
+*  Covered Software is free of defects, merchantable, fit for a        *
+*  particular purpose or non-infringing. The entire risk as to the     *
+*  quality and performance of the Covered Software is with You.        *
+*  Should any Covered Software prove defective in any respect, You     *
+*  (not any Contributor) assume the cost of any necessary servicing,   *
+*  repair, or correction. This disclaimer of warranty constitutes an   *
+*  essential part of this License. No use of any Covered Software is   *
+*  authorized under this License except under this disclaimer.         *
+*                                                                      *
+************************************************************************
+
+************************************************************************
+*                                                                      *
+*  7. Limitation of Liability                                          *
+*  --------------------------                                          *
+*                                                                      *
+*  Under no circumstances and under no legal theory, whether tort      *
+*  (including negligence), contract, or otherwise, shall any           *
+*  Contributor, or anyone who distributes Covered Software as          *
+*  permitted above, be liable to You for any direct, indirect,         *
+*  special, incidental, or consequential damages of any character      *
+*  including, without limitation, damages for lost profits, loss of    *
+*  goodwill, work stoppage, computer failure or malfunction, or any    *
+*  and all other commercial damages or losses, even if such party      *
+*  shall have been informed of the possibility of such damages. This   *
+*  limitation of liability shall not apply to liability for death or   *
+*  personal injury resulting from such party's negligence to the       *
+*  extent applicable law prohibits such limitation. Some               *
+*  jurisdictions do not allow the exclusion or limitation of           *
+*  incidental or consequential damages, so this exclusion and          *
+*  limitation may not apply to You.                                    *
+*                                                                      *
+************************************************************************
+
+8. Litigation
+-------------
+
+Any litigation relating to this License may be brought only in the
+courts of a jurisdiction where the defendant maintains its principal
+place of business and such litigation shall be governed by laws of that
+jurisdiction, without reference to its conflict-of-law provisions.
+Nothing in this Section shall prevent a party's ability to bring
+cross-claims or counter-claims.
+
+9. Miscellaneous
+----------------
+
+This License represents the complete agreement concerning the subject
+matter hereof. If any provision of this License is held to be
+unenforceable, such provision shall be reformed only to the extent
+necessary to make it enforceable. Any law or regulation which provides
+that the language of a contract shall be construed against the drafter
+shall not be used to construe this License against a Contributor.
+
+10. Versions of the License
+---------------------------
+
+10.1. New Versions
+
+Mozilla Foundation is the license steward. Except as provided in Section
+10.3, no one other than the license steward has the right to modify or
+publish new versions of this License. Each version will be given a
+distinguishing version number.
+
+10.2. Effect of New Versions
+
+You may distribute the Covered Software under the terms of the version
+of the License under which You originally received the Covered Software,
+or under the terms of any subsequent version published by the license
+steward.
+
+10.3. Modified Versions
+
+If you create software not governed by this License, and you want to
+create a new license for such software, you may create and use a
+modified version of this License if you rename the license and remove
+any references to the name of the license steward (except to note that
+such modified license differs from this License).
+
+10.4. Distributing Source Code Form that is Incompatible With Secondary
+Licenses
+
+If You choose to distribute Source Code Form that is Incompatible With
+Secondary Licenses under the terms of this version of the License, the
+notice described in Exhibit B of this License must be attached.
+
+Exhibit A - Source Code Form License Notice
+-------------------------------------------
+
+  This Source Code Form is subject to the terms of the Mozilla Public
+  License, v. 2.0. If a copy of the MPL was not distributed with this
+  file, You can obtain one at http://mozilla.org/MPL/2.0/.
+
+If it is not possible or desirable to put the notice in a particular
+file, then You may include the notice in a location (such as a LICENSE
+file in a relevant directory) where a recipient would be likely to look
+for such a notice.
+
+You may add additional accurate notices of copyright ownership.
+
+Exhibit B - "Incompatible With Secondary Licenses" Notice
+---------------------------------------------------------
+
+  This Source Code Form is "Incompatible With Secondary Licenses", as
+  defined by the Mozilla Public License, v. 2.0.
diff --git a/README.md b/README.md
new file mode 100644
--- /dev/null
+++ b/README.md
@@ -0,0 +1,79 @@
+[![Build Status](https://travis-ci.org/rkaippully/gamgee.svg?branch=master)](https://travis-ci.org/rkaippully/gamgee)
+[![Release](https://img.shields.io/github/release/rkaippully/gamgee.svg)](https://github.com/rkaippully/gamgee/releases)
+
+# About
+Gamgee is your sidekick for managing multi-factor authentication
+tokens. It is a command-line tool that can be used as a drop-in
+replacement for Google Authenticator app or any other similar app
+providing multi-factor authentication using Time-based One-Time
+Password (TOTP) algorithm.
+
+Gamgee implements the TOTP algorithm specified in
+[RFC6238](https://tools.ietf.org/html/rfc6238)
+
+# Installation
+On MacOS, install using homebrew:
+
+```
+brew install rkaippully/tools/gamgee
+```
+
+See other releases here:
+https://github.com/rkaippully/gamgee/releases/latest
+
+# Usage
+Gamgee is a replacement for apps like Google Authenticator or Authy
+that are used to generate TOTP tokens. Typically, such tools scan a QR
+code to create an account. Gamgee cannot scan a QR code (yet), but you
+can instead get a secret token from your service and add that to
+Gamgee.
+
+1. Go to the multi-factor authentication setup page of your service
+   (such as Github, Gmail, etc).  You will see a QR code in that page
+   and instructions to scan it with an app.
+2. Look for a link in the page that gives alternative installation
+   instructions. For example, many services include a "Can't scan
+   barcode?" or "Enter this text code" link next to the QR code.
+   Follow that link.
+3. Note down the secret code mentioned in the page. Once you get the
+   secret, create a token with this command:
+
+```
+gamgee add -l "<token-label>" -s "<secret-code>"
+```
+
+`token-label` is a convenient label that you choose for this token.
+
+Now you are ready to generate an OTP with your token:
+
+```
+gamgee <token-label>
+```
+
+This will generate an OTP and copy it to your clipboard. You can paste
+it into your authentication service. Alternatively, you can write the
+OTP to standard output with:
+
+```
+gamgee <token-label> --stdout
+```
+
+You can find a list of all your tokens with:
+
+```
+gamgee list
+```
+
+You can also remove a token if it is no longer needed. Please remember
+that this cannot be undone:
+
+```
+gamgee delete -l "<token-label>"
+```
+
+# License
+Gamgee is distributed under Mozilla Public License 2.0. See the file
+LICENSE for details.
+
+This Source Code Form is "Incompatible With Secondary Licenses", as
+defined by the Mozilla Public License, v. 2.0.
diff --git a/Setup.hs b/Setup.hs
new file mode 100644
--- /dev/null
+++ b/Setup.hs
@@ -0,0 +1,2 @@
+import Distribution.Simple
+main = defaultMain
diff --git a/app/Gamgee/Program/CommandLine.hs b/app/Gamgee/Program/CommandLine.hs
new file mode 100644
--- /dev/null
+++ b/app/Gamgee/Program/CommandLine.hs
@@ -0,0 +1,80 @@
+{-# LANGUAGE DerivingStrategies #-}
+{-# LANGUAGE NoImplicitPrelude  #-}
+{-# LANGUAGE OverloadedStrings  #-}
+
+-- | Command line argument parsing
+module Gamgee.Program.CommandLine
+    ( Command (..)
+    , OutputMode (..)
+    , getCommand
+    , versionOption
+    ) where
+
+import qualified Data.Version        as Version
+import qualified Gamgee.Token        as Token
+import           Options.Applicative
+import           Paths_gamgee        (version)
+import           Relude
+
+
+data OutputMode = OutputStdOut
+                | OutputClipboard
+                deriving stock (Eq)
+
+data Command = AddToken Token.TokenSpec
+             | DeleteToken Token.TokenIdentifier
+             | ListTokens
+             | GetOTP Token.TokenIdentifier OutputMode
+
+getCommand :: Parser Command
+getCommand = hsubparser (
+    command "list" (info listTokens $ progDesc "List the names of all known tokens")
+    <> command "add" (info addToken $ progDesc "Add a new token")
+    <> command "delete" (info deleteToken $ progDesc "Delete a token")
+  )
+  <|> getOTPOperation
+
+listTokens :: Parser Command
+listTokens = pure ListTokens
+
+addToken :: Parser Command
+addToken = AddToken <$> tokenSpec
+
+tokenSpec :: Parser Token.TokenSpec
+tokenSpec = Token.TokenSpec
+            <$> pure Token.TOTP
+            <*> strOption (long "label" <> short 'l'
+                           <> help "Label of the token")
+            <*> (Token.TokenSecretPlainText <$> strOption (long "secret" <> short 's'
+                                                           <> help "Secret of the token"))
+            <*> strOption (long "issuer" <> short 'i' <> value (Token.TokenIssuer "")
+                           <> help "Issuer of the token")
+            <*> option (eitherReader algoReader) (long "algorithm" <> short 'a' <> value Token.AlgorithmSHA1
+                                                  <> help "HMAC algorithm - SHA1 (default), SHA256, or SHA512")
+            <*> option (eitherReader digitsReader) (long "digits" <> short 'd' <> value Token.Digits6
+                                                    <> help "Number of digits in the OTP - 6 (default) or 8")
+            <*> (Token.TokenPeriod <$> option auto (long "period" <> short 'p' <> value 30
+                                                    <> help "OTP validity in seconds. Default - 30."))
+
+deleteToken :: Parser Command
+deleteToken = DeleteToken <$> strOption (long "label" <> short 'l' <> help "Label of the token")
+
+algoReader :: String -> Either String Token.TokenAlgorithm
+algoReader "SHA1"   = Right Token.AlgorithmSHA1
+algoReader "SHA256" = Right Token.AlgorithmSHA256
+algoReader "SHA512" = Right Token.AlgorithmSHA512
+algoReader s        = Left $ "Invalid algorithm: " ++ s
+
+digitsReader :: String -> Either String Token.TokenDigits
+digitsReader "6" = Right Token.Digits6
+digitsReader "8" = Right Token.Digits8
+digitsReader s   = Left $ "Invalid number of digits: " ++ s ++ ". Must be 6 or 8."
+
+versionOption :: Parser (a -> a)
+versionOption = infoOption (Version.showVersion version) (long "version" <> help "Show version")
+
+getOTPOperation :: Parser Command
+getOTPOperation = GetOTP
+                  <$> strArgument (help "Get a one-time password" <> metavar "TOKEN-LABEL")
+                  <*> flag OutputClipboard OutputStdOut
+                      (long "stdout" <> help "Send the OTP to stdout instead of clipboard")
diff --git a/app/Gamgee/Program/Effects.hs b/app/Gamgee/Program/Effects.hs
new file mode 100644
--- /dev/null
+++ b/app/Gamgee/Program/Effects.hs
@@ -0,0 +1,130 @@
+{-# LANGUAGE DataKinds           #-}
+{-# LANGUAGE DerivingStrategies  #-}
+{-# LANGUAGE FlexibleContexts    #-}
+{-# LANGUAGE GADTs               #-}
+{-# LANGUAGE LambdaCase          #-}
+{-# LANGUAGE NoImplicitPrelude   #-}
+{-# LANGUAGE OverloadedStrings   #-}
+{-# LANGUAGE PolyKinds           #-}
+{-# LANGUAGE ScopedTypeVariables #-}
+{-# LANGUAGE TypeFamilies        #-}
+{-# LANGUAGE TypeOperators       #-}
+
+{-# OPTIONS_GHC -fno-warn-orphans #-}
+
+module Gamgee.Program.Effects
+  ( runM_
+  , runGamgeeByteStoreIO
+  , runOutputStdOut
+  , runOutputClipboard
+  , runErrorStdErr
+  ) where
+
+import           Control.Exception.Safe (catch)
+import qualified Data.Text.IO           as TIO
+import qualified Gamgee.Effects         as Eff
+import qualified Gamgee.Token           as Token
+import           Polysemy               (Lift, Member, Members, Sem)
+import qualified Polysemy               as P
+import qualified Polysemy.Error         as P
+import qualified Polysemy.Output        as P
+import           Relude
+import qualified System.Directory       as Dir
+import           System.FilePath        ((</>))
+import qualified System.Hclip           as Clip
+import qualified System.IO.Error        as IO
+import qualified System.Posix.Files     as Files
+
+
+-- | A version of runM that ignores its result
+runM_ :: Monad m => Sem '[Lift m] a -> m ()
+runM_ = void . P.runM
+
+----------------------------------------------------------------------------------------------------
+-- Interpret Output by writing it to stdout or clipboard
+----------------------------------------------------------------------------------------------------
+
+runOutputStdOut :: Member (Lift IO) r => Sem (P.Output Text : r) a -> Sem r a
+runOutputStdOut = P.interpret $ \case
+  P.Output s -> P.sendM $ putTextLn s
+
+runOutputClipboard :: Member (Lift IO) r => Sem (P.Output Text : r) a -> Sem r a
+runOutputClipboard = P.interpret $ \case
+  P.Output s -> P.sendM $ Clip.setClipboard $ toString s
+
+
+----------------------------------------------------------------------------------------------------
+-- Interpret Error by writing it to stderr
+----------------------------------------------------------------------------------------------------
+
+instance ToText Eff.EffError where
+  toText (Eff.AlreadyExists ident)        = "A token named '" <> Token.unTokenIdentifier ident <> "' already exists."
+  toText (Eff.NoSuchToken ident)          = "No such token: '" <> Token.unTokenIdentifier ident <> "'"
+  toText (Eff.CryptoError ce)             = show ce
+  toText (Eff.CorruptIV _)                = "Internal Error: Unable to decode initial vector, your config is probably corrupt"
+  toText (Eff.CorruptBase64Encoding msg)  = msg
+  toText (Eff.SecretDecryptError _)       = "Error decrypting token. Did you provide an incorrect password?"
+  toText (Eff.InvalidTokenPeriod tp)      = "Unsupported token period: " <> show (Token.unTokenPeriod tp)
+  toText (Eff.UnsupportedConfigVersion v) = "Internal Error: Unsupported config version: " <> show v
+  toText (Eff.JSONDecodeError msg)        = "Internal Error: Could not decode Gamgee config file: " <> msg
+
+data ByteStoreError = ReadError IO.IOError
+                    | WriteError IO.IOError
+
+instance ToText ByteStoreError where
+  toText (ReadError e)  = "Internal Error: Error reading configuration file: " <> show e
+  toText (WriteError e) = "Internal Error: Error saving configuration file: " <> show e
+
+runErrorStdErr :: Member (Lift IO) r => Sem (P.Error Eff.EffError : P.Error ByteStoreError : r) a -> Sem r (Maybe a)
+runErrorStdErr = fmap join . runToTextError . runToTextError
+
+runToTextError :: (Member (Lift IO) r, ToText e) => Sem (P.Error e : r) a -> Sem r (Maybe a)
+runToTextError a = P.runError a >>= either (printError . toText) (return . Just)
+  where
+    printError :: Member (Lift IO) r => Text -> Sem r (Maybe a)
+    printError msg = P.sendM (TIO.hPutStrLn stderr msg) $> Nothing
+
+
+----------------------------------------------------------------------------------------------------
+-- Interpret ByteStore using a file
+----------------------------------------------------------------------------------------------------
+
+runByteStoreFile :: ( Members [Lift IO, P.Error e] r
+                    , Exception e1
+                    , Exception e2)
+                 => FilePath
+                 -> (e1 -> Either e (Maybe LByteString)) -- ^ Function to handle read errors
+                 -> (e2 -> Maybe e)                      -- ^ Function to handle write errors
+                 -> Sem (Eff.ByteStore : r) a
+                 -> Sem r a
+runByteStoreFile file handleReadError handleWriteError = P.interpret $ \case
+  Eff.ReadByteStore        -> do
+    res <- P.sendM $ (Right . Just <$> readFileLBS file) `catch` (return . handleReadError)
+    either P.throw return res
+  Eff.WriteByteStore bytes -> do
+    res <- P.sendM $ (writeFileLBS file bytes $> Nothing) `catch` (return . handleWriteError)
+    whenJust res P.throw
+    P.sendM $ Files.setFileMode file $ Files.ownerReadMode `Files.unionFileModes` Files.ownerWriteMode
+
+runGamgeeByteStoreIO :: Members [Lift IO, P.Error ByteStoreError] r
+                     => Sem (Eff.ByteStore : r) a
+                     -> Sem r a
+runGamgeeByteStoreIO prog = do
+  file <- P.sendM configFilePath
+  runByteStoreFile file handleReadError handleWriteError prog
+
+  where
+    handleReadError :: IO.IOError -> Either ByteStoreError (Maybe LByteString)
+    handleReadError e = if IO.isDoesNotExistError e
+                        then Right Nothing
+                        else Left $ ReadError e
+
+    handleWriteError :: IO.IOError -> Maybe ByteStoreError
+    handleWriteError e = Just $ WriteError e
+
+-- | Path under which tokens are stored - typically ~/.config/gamgee/tokens.json
+configFilePath :: IO FilePath
+configFilePath = do
+  dir <- Dir.getXdgDirectory Dir.XdgConfig "gamgee"
+  Dir.createDirectoryIfMissing True dir
+  return $ dir </> "tokens.json"
diff --git a/app/Main.hs b/app/Main.hs
new file mode 100644
--- /dev/null
+++ b/app/Main.hs
@@ -0,0 +1,74 @@
+{-# LANGUAGE DataKinds           #-}
+{-# LANGUAGE FlexibleContexts    #-}
+{-# LANGUAGE GADTs               #-}
+{-# LANGUAGE NoImplicitPrelude   #-}
+{-# LANGUAGE PolyKinds           #-}
+{-# LANGUAGE ScopedTypeVariables #-}
+{-# LANGUAGE TypeOperators       #-}
+
+module Main where
+
+import qualified Data.Time.Clock.POSIX      as Clock
+import qualified Gamgee.Effects             as Eff
+import qualified Gamgee.Operation           as Operation
+import qualified Gamgee.Program.CommandLine as Cmd
+import qualified Gamgee.Program.Effects     as Eff
+import qualified Gamgee.Token               as Token
+import qualified Options.Applicative        as Options
+import           Relude
+
+main :: IO ()
+main = do
+  command <- Options.execParser parserInfo
+  case command of
+    Cmd.AddToken spec -> runAddToken spec
+    Cmd.DeleteToken t -> runDeleteToken t
+    Cmd.ListTokens    -> runListTokens
+    Cmd.GetOTP t mode -> runGetOTP t mode
+
+parserInfo :: Options.ParserInfo Cmd.Command
+parserInfo = Options.info (Options.helper
+                           <*> Cmd.versionOption
+                           <*> Cmd.getCommand) Options.fullDesc
+
+runAddToken :: Token.TokenSpec -> IO ()
+runAddToken spec = Eff.runM_
+                   $ Eff.runErrorStdErr
+                   $ Eff.runCryptoRandomIO
+                   $ Eff.runCrypto
+                   $ Eff.runSecretInputIO
+                   $ Eff.runGamgeeByteStoreIO
+                   $ Eff.runGamgeeJSONStore
+                   $ Eff.runStateJSON
+                   $ Operation.addToken spec
+
+runDeleteToken :: Token.TokenIdentifier -> IO ()
+runDeleteToken t = Eff.runM_
+                   $ Eff.runErrorStdErr
+                   $ Eff.runGamgeeByteStoreIO
+                   $ Eff.runGamgeeJSONStore
+                   $ Eff.runStateJSON
+                   $ Operation.deleteToken t
+
+runListTokens :: IO ()
+runListTokens = Eff.runM_
+                $ Eff.runErrorStdErr
+                $ Eff.runOutputStdOut
+                $ Eff.runGamgeeByteStoreIO
+                $ Eff.runGamgeeJSONStore
+                $ Eff.runStateJSON Operation.listTokens
+
+runGetOTP :: Token.TokenIdentifier -> Cmd.OutputMode -> IO ()
+runGetOTP t o = do
+  now <- Clock.getPOSIXTime
+  Eff.runM_
+    $ Eff.runErrorStdErr
+    $ (if o == Cmd.OutputStdOut then Eff.runOutputStdOut else Eff.runOutputClipboard)
+    $ Eff.runCryptoRandomIO
+    $ Eff.runCrypto
+    $ Eff.runSecretInputIO
+    $ Eff.runGamgeeByteStoreIO
+    $ Eff.runGamgeeJSONStore
+    $ Eff.runStateJSON
+    $ Eff.runTOTP
+    $ Operation.getOTP t now
diff --git a/gamgee.cabal b/gamgee.cabal
new file mode 100644
--- /dev/null
+++ b/gamgee.cabal
@@ -0,0 +1,113 @@
+cabal-version: >=1.10
+name: gamgee
+version: 1.0.0
+license: MPL-2.0
+license-file: LICENSE
+copyright: 2018 Raghu Kaippully
+maintainer: rkaippully@gmail.com
+author: Raghu Kaippully
+homepage: https://github.com/rkaippully/gamgee#readme
+bug-reports: https://github.com/rkaippully/gamgee/issues
+synopsis: Tool for generating TOTP MFA tokens.
+description:
+    Tool for generating TOTP MFA tokens. Please see the README on GitHub at <https://github.com/rkaippully/gamgee#readme>
+category: Authentication, Command Line
+build-type: Simple
+extra-source-files:
+    ChangeLog.md
+    README.md
+    test/data/golden/getOTPTest.txt
+
+source-repository head
+    type: git
+    location: https://github.com/rkaippully/gamgee
+
+library
+    exposed-modules:
+        Gamgee.Operation
+        Gamgee.Token
+        Gamgee.Effects
+        Gamgee.Effects.Error
+        Gamgee.Effects.Crypto
+        Gamgee.Effects.CryptoRandom
+        Gamgee.Effects.SecretInput
+        Gamgee.Effects.TOTP
+        Gamgee.Effects.JSONStore
+        Gamgee.Effects.ByteStore
+    hs-source-dirs: src
+    default-language: Haskell2010
+    ghc-options: -Wall -Wcompat -Wredundant-constraints
+                 -Wincomplete-record-updates -Wincomplete-uni-patterns
+                 -fplugin=Polysemy.Plugin
+    build-depends:
+        aeson >=1.4.4.0 && <1.5,
+        base >=4.12.0.0 && <4.13,
+        relude >=0.5.0 && <0.6,
+        base64-bytestring >=1.0.0.2 && <1.1,
+        bytestring >=0.10.8.2 && <0.11,
+        cryptonite ==0.25.*,
+        text >=1.2.3.1 && <1.3,
+        memory >=0.14.18 && <0.15,
+        time >=1.8.0.2 && <1.9,
+        safe-exceptions >=0.1.7.0 && <0.2,
+        polysemy >=0.5.0.1 && <0.6,
+        polysemy-plugin >=0.2.1.1 && <0.3
+
+executable gamgee
+    main-is: Main.hs
+    hs-source-dirs: app
+    other-modules:
+        Gamgee.Program.CommandLine
+        Gamgee.Program.Effects
+        Paths_gamgee
+    default-language: Haskell2010
+    ghc-options: -Wall -Wcompat -Wredundant-constraints
+                 -Wincomplete-record-updates -Wincomplete-uni-patterns -threaded
+                 -rtsopts -with-rtsopts=-N -fplugin=Polysemy.Plugin
+    build-depends:
+        gamgee -any,
+        base >=4.12.0.0 && <4.13,
+        relude >=0.5.0 && <0.6,
+        optparse-applicative >=0.14.3.0 && <0.15,
+        polysemy >=0.5.0.1 && <0.6,
+        polysemy-plugin >=0.2.1.1 && <0.3,
+        safe-exceptions >=0.1.7.0 && <0.2,
+        directory >=1.3.3.0 && <1.4,
+        filepath >=1.4.2.1 && <1.5,
+        aeson >=1.4.4.0 && <1.5,
+        unix >=2.7.2.2 && <2.8,
+        text >=1.2.3.1 && <1.3,
+        time >=1.8.0.2 && <1.9,
+        Hclip >=3.0.0.4 && <3.1
+
+test-suite gamgee-test
+    type: exitcode-stdio-1.0
+    main-is: Main.hs
+    hs-source-dirs: test
+    other-modules:
+        Gamgee.Test.Property
+        Gamgee.Test.Effects
+        Gamgee.Test.Operation
+        Gamgee.Test.Golden
+    default-language: Haskell2010
+    ghc-options: -Wall -Wcompat -Wredundant-constraints
+                 -Wincomplete-record-updates -Wincomplete-uni-patterns
+                 -fplugin=Polysemy.Plugin
+    build-depends:
+        gamgee -any,
+        base >=4.12.0.0 && <4.13,
+        relude >=0.5.0 && <0.6,
+        text >=1.2.3.1 && <1.3,
+        bytestring >=0.10.8.2 && <0.11,
+        memory >=0.14.18 && <0.15,
+        aeson >=1.4.4.0 && <1.5,
+        tasty >=1.2.3 && <1.3,
+        tasty-golden >=2.3.2 && <2.4,
+        tasty-quickcheck >=0.10.1 && <0.11,
+        QuickCheck >=2.13.1 && <2.14,
+        quickcheck-instances >=0.3.21 && <0.4,
+        polysemy >=0.5.0.1 && <0.6,
+        polysemy-plugin >=0.2.1.1 && <0.3,
+        filepath >=1.4.2.1 && <1.5,
+        cryptonite ==0.25.*,
+        time >=1.8.0.2 && <1.9
diff --git a/src/Gamgee/Effects.hs b/src/Gamgee/Effects.hs
new file mode 100644
--- /dev/null
+++ b/src/Gamgee/Effects.hs
@@ -0,0 +1,37 @@
+{-# LANGUAGE DataKinds     #-}
+{-# LANGUAGE GADTs         #-}
+{-# LANGUAGE LambdaCase    #-}
+{-# LANGUAGE TypeOperators #-}
+
+module Gamgee.Effects
+  ( module Gamgee.Effects.Crypto
+  , module Gamgee.Effects.CryptoRandom
+  , module Gamgee.Effects.JSONStore
+  , module Gamgee.Effects.ByteStore
+  , module Gamgee.Effects.SecretInput
+  , module Gamgee.Effects.TOTP
+  , module Gamgee.Effects.Error
+
+  , runStateJSON
+  ) where
+
+import           Gamgee.Effects.ByteStore
+import           Gamgee.Effects.Crypto
+import           Gamgee.Effects.CryptoRandom
+import           Gamgee.Effects.Error
+import           Gamgee.Effects.JSONStore
+import           Gamgee.Effects.SecretInput
+import           Gamgee.Effects.TOTP
+import           Polysemy                    (Sem)
+import qualified Polysemy                    as P
+import qualified Polysemy.State              as P
+
+
+----------------------------------------------------------------------------------------------------
+-- Reinterpret State backed by a JSON store
+----------------------------------------------------------------------------------------------------
+
+runStateJSON :: Sem (P.State o : r) a -> Sem (JSONStore o : r) a
+runStateJSON = P.reinterpret $ \case
+  P.Get   -> jsonDecode
+  P.Put s -> jsonEncode s
diff --git a/src/Gamgee/Effects/ByteStore.hs b/src/Gamgee/Effects/ByteStore.hs
new file mode 100644
--- /dev/null
+++ b/src/Gamgee/Effects/ByteStore.hs
@@ -0,0 +1,32 @@
+{-# LANGUAGE DataKinds           #-}
+{-# LANGUAGE FlexibleContexts    #-}
+{-# LANGUAGE GADTs               #-}
+{-# LANGUAGE NoImplicitPrelude   #-}
+{-# LANGUAGE OverloadedStrings   #-}
+{-# LANGUAGE PolyKinds           #-}
+{-# LANGUAGE ScopedTypeVariables #-}
+{-# LANGUAGE TemplateHaskell     #-}
+
+module Gamgee.Effects.ByteStore
+  ( -- * Effect
+    ByteStore (..)
+
+    -- * Actions
+  , readByteStore
+  , writeByteStore
+
+  ) where
+
+import qualified Polysemy as P
+import           Relude
+
+
+----------------------------------------------------------------------------------------------------
+-- A store for saving and reading bytes
+----------------------------------------------------------------------------------------------------
+
+data ByteStore m a where
+  ReadByteStore :: ByteStore m (Maybe LByteString)
+  WriteByteStore :: LByteString -> ByteStore m ()
+
+P.makeSem ''ByteStore
diff --git a/src/Gamgee/Effects/Crypto.hs b/src/Gamgee/Effects/Crypto.hs
new file mode 100644
--- /dev/null
+++ b/src/Gamgee/Effects/Crypto.hs
@@ -0,0 +1,149 @@
+{-# LANGUAGE DataKinds           #-}
+{-# LANGUAGE FlexibleContexts    #-}
+{-# LANGUAGE GADTs               #-}
+{-# LANGUAGE LambdaCase          #-}
+{-# LANGUAGE NoImplicitPrelude   #-}
+{-# LANGUAGE OverloadedStrings   #-}
+{-# LANGUAGE PolyKinds           #-}
+{-# LANGUAGE ScopedTypeVariables #-}
+{-# LANGUAGE TemplateHaskell     #-}
+{-# LANGUAGE TypeOperators       #-}
+
+-- | Cryptographic effect for securing tokens
+module Gamgee.Effects.Crypto
+    ( -- * Effect
+      Crypto(..)
+
+      -- * Programs
+    , encryptSecret
+    , decryptSecret
+
+      -- * Interpretations
+    , runCrypto
+    ) where
+
+import           Crypto.Cipher.AES           (AES256)
+import qualified Crypto.Cipher.Types         as CT
+import qualified Crypto.Error                as CE
+import qualified Data.ByteArray              as BA
+import qualified Data.ByteString.Base64      as B64
+import qualified Data.ByteString.Lazy        as LBS
+import qualified Data.Text                   as Text
+import qualified Gamgee.Effects.CryptoRandom as CR
+import qualified Gamgee.Effects.Error        as Err
+import qualified Gamgee.Effects.SecretInput  as SI
+import qualified Gamgee.Token                as Token
+import           Polysemy                    (Member, Members, Sem)
+import qualified Polysemy                    as P
+import qualified Polysemy.Error              as P
+import           Relude
+
+
+----------------------------------------------------------------------------------------------------
+-- Effect
+----------------------------------------------------------------------------------------------------
+
+-- | Effect for encrypting and decrypting secrets
+data Crypto m a where
+  -- | Encrypts a secret with an optional password
+  Encrypt :: Text                        -- ^ The secret to encrypt
+          -> Text                        -- ^ The password
+          -> Crypto m Token.TokenSecret
+  -- | Decrypt a secret with an optional password
+  Decrypt :: Text              -- ^ Base64 encoded IV
+          -> Text              -- ^ Base64 encoded encrypted secret
+          -> Text              -- ^ The password for decryption
+          -> Crypto m Text     -- ^ Decrypted secret
+
+P.makeSem ''Crypto
+
+
+----------------------------------------------------------------------------------------------------
+-- Programs
+----------------------------------------------------------------------------------------------------
+
+encryptSecret :: Members [SI.SecretInput Text, Crypto] r => Token.TokenSpec -> Sem r Token.TokenSpec
+encryptSecret spec =
+  case Token.tokenSecret spec of
+    -- Secret is already encrypted
+    Token.TokenSecretAES256 _ _ -> return spec
+    Token.TokenSecretPlainText plainSecret -> do
+      -- Ask the user for a password
+      password <- SI.secretInput "Password to encrypt (leave blank to skip encryption): "
+
+      if Text.null password
+      then return spec
+      else do
+        -- Sometimes the secret may contain extraneous chars - '=', '-', space etc. Clear those.
+        let secret = (Text.toUpper . Text.dropWhileEnd (== '=') . Text.replace " " "" . Text.replace "-" "" . Text.strip) plainSecret
+
+        secret' <- encrypt secret password
+        return spec { Token.tokenSecret = secret' }
+
+decryptSecret :: Members [SI.SecretInput Text, Crypto] r => Token.TokenSpec -> Sem r Text
+decryptSecret spec =
+  case Token.tokenSecret spec of
+    Token.TokenSecretPlainText plainSecret  -> return plainSecret
+    Token.TokenSecretAES256 encIV encSecret -> do
+      password <- SI.secretInput "Password: "
+      decrypt encIV encSecret password
+
+
+----------------------------------------------------------------------------------------------------
+-- Interpretations
+----------------------------------------------------------------------------------------------------
+
+runCrypto :: Members [CR.CryptoRandom, P.Error Err.EffError] r => Sem (Crypto : r) a -> Sem r a
+runCrypto = P.interpret $ \case
+  Encrypt secret password -> do
+    iv <- genRandomIV
+    toTokenSecret iv secret password
+
+  Decrypt encIV encSecret password -> fromTokenSecret encIV encSecret password
+
+-- | Generate a random initialization vector
+genRandomIV :: Members [P.Error Err.EffError, CR.CryptoRandom] r => Sem r (CT.IV AES256)
+genRandomIV = do
+  bytes <- CR.randomBytes $ CT.blockSize (error "Internal Error: This shouldn't be evaluated" :: AES256)
+  case CT.makeIV (bytes :: ByteString) of
+    Just iv -> return iv
+    Nothing -> error "Internal Error: Unable to generate random initial vector"
+
+-- | Generate an encrypted TokenSecret from an iv, a secret text and password
+toTokenSecret :: Member (P.Error Err.EffError) r
+              => CT.IV AES256
+              -> Text                    -- ^ Secret
+              -> Text                    -- ^ Password
+              -> Sem r Token.TokenSecret
+toTokenSecret iv secret password = do
+  cipher <- CE.onCryptoFailure (P.throw . Err.CryptoError) return $ CT.cipherInit (passwordToKey password)
+  return Token.TokenSecretAES256 {
+    Token.tokenSecretAES256IV = toBase64 $ BA.convert iv
+    , Token.tokenSecretAES256Data = toBase64 $ CT.ctrCombine cipher iv (encodeUtf8 secret)
+    }
+
+-- | Extract the secret text from a TokenSecret given its password
+fromTokenSecret :: Member (P.Error Err.EffError) r
+                => Text       -- ^ Base64 encoded IV
+                -> Text       -- ^ Base64 encoded encrypted secret
+                -> Text       -- ^ The password
+                -> Sem r Text -- ^ IV and secret
+fromTokenSecret encIV encSecret password = do
+  iv <- fromBase64 encIV
+  case CT.makeIV iv of
+    Nothing  -> P.throw $ Err.CorruptIV iv
+    Just iv' -> do
+      secret <- fromBase64 encSecret
+      cipher <- CE.onCryptoFailure (P.throw . Err.CryptoError) return $ CT.cipherInit (passwordToKey password)
+      return $ decodeUtf8 $ CT.ctrCombine (cipher :: AES256) iv' secret
+
+passwordToKey :: Text -> ByteString
+passwordToKey password = toStrict $ LBS.take 32 $ LBS.cycle $ encodeUtf8 password
+
+toBase64 :: ByteString -> Text
+toBase64 = decodeUtf8 . B64.encode
+
+fromBase64 :: Member (P.Error Err.EffError) r
+           => Text
+           -> Sem r ByteString
+fromBase64 = either (P.throw . Err.CorruptBase64Encoding . toText) return .  B64.decode . encodeUtf8
diff --git a/src/Gamgee/Effects/CryptoRandom.hs b/src/Gamgee/Effects/CryptoRandom.hs
new file mode 100644
--- /dev/null
+++ b/src/Gamgee/Effects/CryptoRandom.hs
@@ -0,0 +1,48 @@
+{-# LANGUAGE DataKinds           #-}
+{-# LANGUAGE FlexibleContexts    #-}
+{-# LANGUAGE GADTs               #-}
+{-# LANGUAGE LambdaCase          #-}
+{-# LANGUAGE NoImplicitPrelude   #-}
+{-# LANGUAGE OverloadedStrings   #-}
+{-# LANGUAGE PolyKinds           #-}
+{-# LANGUAGE ScopedTypeVariables #-}
+{-# LANGUAGE TemplateHaskell     #-}
+{-# LANGUAGE TypeOperators       #-}
+
+module Gamgee.Effects.CryptoRandom
+    ( -- * Effect
+      CryptoRandom(..)
+
+      -- * Action
+    , randomBytes
+
+      -- * Interpretations
+    , runCryptoRandomIO
+    ) where
+
+import qualified Crypto.Random.Types as CRT
+import qualified Data.ByteArray      as BA
+import           Polysemy            (Lift, Member, Sem)
+import qualified Polysemy            as P
+import           Relude
+
+
+----------------------------------------------------------------------------------------------------
+-- Effects
+----------------------------------------------------------------------------------------------------
+
+-- | An effect capable of providing random bytes for use with cryptonite
+data CryptoRandom m a where
+  -- ^ Generate random bytes
+  RandomBytes :: BA.ByteArray b => Int -> CryptoRandom m b
+
+P.makeSem ''CryptoRandom
+
+
+----------------------------------------------------------------------------------------------------
+-- Interpretations
+----------------------------------------------------------------------------------------------------
+
+runCryptoRandomIO :: Member (Lift IO) r => Sem (CryptoRandom : r) a -> Sem r a
+runCryptoRandomIO = P.interpret $ \case
+  RandomBytes count -> P.sendM $ CRT.getRandomBytes count
diff --git a/src/Gamgee/Effects/Error.hs b/src/Gamgee/Effects/Error.hs
new file mode 100644
--- /dev/null
+++ b/src/Gamgee/Effects/Error.hs
@@ -0,0 +1,23 @@
+{-# LANGUAGE DerivingStrategies #-}
+{-# LANGUAGE NoImplicitPrelude  #-}
+
+module Gamgee.Effects.Error
+  ( EffError(..)
+  ) where
+
+import qualified Crypto.Error as CE
+import qualified Gamgee.Token as Token
+import           Relude
+
+
+-- | Errors returned by Gamgee effects
+data EffError = AlreadyExists Token.TokenIdentifier
+              | NoSuchToken Token.TokenIdentifier
+              | CryptoError CE.CryptoError
+              | CorruptIV ByteString
+              | CorruptBase64Encoding Text
+              | SecretDecryptError Text
+              | InvalidTokenPeriod Token.TokenPeriod
+              | UnsupportedConfigVersion Word32
+              | JSONDecodeError Text
+              deriving stock (Show, Eq)
diff --git a/src/Gamgee/Effects/JSONStore.hs b/src/Gamgee/Effects/JSONStore.hs
new file mode 100644
--- /dev/null
+++ b/src/Gamgee/Effects/JSONStore.hs
@@ -0,0 +1,102 @@
+{-# LANGUAGE DataKinds           #-}
+{-# LANGUAGE DeriveAnyClass      #-}
+{-# LANGUAGE DeriveGeneric       #-}
+{-# LANGUAGE DerivingStrategies  #-}
+{-# LANGUAGE FlexibleContexts    #-}
+{-# LANGUAGE GADTs               #-}
+{-# LANGUAGE LambdaCase          #-}
+{-# LANGUAGE NoImplicitPrelude   #-}
+{-# LANGUAGE OverloadedStrings   #-}
+{-# LANGUAGE PolyKinds           #-}
+{-# LANGUAGE ScopedTypeVariables #-}
+{-# LANGUAGE TemplateHaskell     #-}
+{-# LANGUAGE TypeOperators       #-}
+
+module Gamgee.Effects.JSONStore
+  ( -- * Effect
+    JSONStore (..)
+
+    -- * Actions
+  , jsonEncode
+  , jsonDecode
+
+    -- * Interpretation
+  , runGamgeeJSONStore
+  ) where
+
+import qualified Data.Aeson               as Aeson
+import qualified Gamgee.Effects.ByteStore as BS
+import qualified Gamgee.Effects.Error     as Err
+import qualified Gamgee.Token             as Token
+import           Polysemy                 (Member, Sem)
+import qualified Polysemy                 as P
+import qualified Polysemy.Error           as P
+import           Relude
+
+
+
+----------------------------------------------------------------------------------------------------
+-- An abstract JSON store effect that encodes and decodes JSON objects
+----------------------------------------------------------------------------------------------------
+
+data JSONStore o m a where
+  JsonEncode :: o -> JSONStore o m ()
+  JsonDecode :: JSONStore o m o
+
+P.makeSem ''JSONStore
+
+
+----------------------------------------------------------------------------------------------------
+-- Gamgee Configuration
+----------------------------------------------------------------------------------------------------
+
+data Config = Config {
+  configVersion  :: Word32
+  , configTokens :: Token.Tokens
+  }
+  deriving stock    (Generic)
+  deriving anyclass (Aeson.FromJSON, Aeson.ToJSON)
+
+currentConfigVersion :: Word32
+currentConfigVersion = 1
+
+initialConfig :: Config
+initialConfig = Config {
+  configVersion = currentConfigVersion
+  , configTokens = fromList []
+  }
+
+
+----------------------------------------------------------------------------------------------------
+-- Interpret JSONStore backed by a ByteStore
+----------------------------------------------------------------------------------------------------
+
+runJSONStore :: (o -> Sem (BS.ByteStore : r) Config) -- ^ Function to map object to a JSON serializable value
+             -> (Config -> Sem (BS.ByteStore : r) o) -- ^ Function to map a JSON value to the object
+             -> (String -> Sem (BS.ByteStore : r) o) -- ^ Function to handle errors in decoding
+             -> Sem (JSONStore o : r) a
+             -> Sem (BS.ByteStore : r) a
+runJSONStore convertE convertD handleError =
+  P.reinterpret $ \case
+    JsonEncode o -> do
+      j <- convertE o
+      BS.writeByteStore $ Aeson.encode j
+    JsonDecode   -> do
+      bytes <- BS.readByteStore
+      let
+        config = maybe (Right initialConfig) Aeson.eitherDecode' bytes
+      either handleError convertD config
+
+runGamgeeJSONStore :: Member (P.Error Err.EffError) r => Sem (JSONStore Token.Tokens : r) a -> Sem (BS.ByteStore : r) a
+runGamgeeJSONStore = runJSONStore tokensToConfig jsonToTokens handleDecodeError
+  where
+    tokensToConfig :: Token.Tokens -> Sem r Config
+    tokensToConfig ts = return $ Config { configVersion = currentConfigVersion, configTokens = ts }
+
+    jsonToTokens :: Member (P.Error Err.EffError) r => Config -> Sem r Token.Tokens
+    jsonToTokens cfg = if configVersion cfg == currentConfigVersion
+                       then return (configTokens cfg)
+                       else P.throw $ Err.UnsupportedConfigVersion $ configVersion cfg
+
+    handleDecodeError :: Member (P.Error Err.EffError) r => String -> Sem r a
+    handleDecodeError msg = P.throw $ Err.JSONDecodeError $ toText msg
diff --git a/src/Gamgee/Effects/SecretInput.hs b/src/Gamgee/Effects/SecretInput.hs
new file mode 100644
--- /dev/null
+++ b/src/Gamgee/Effects/SecretInput.hs
@@ -0,0 +1,65 @@
+{-# LANGUAGE DataKinds           #-}
+{-# LANGUAGE FlexibleContexts    #-}
+{-# LANGUAGE GADTs               #-}
+{-# LANGUAGE LambdaCase          #-}
+{-# LANGUAGE NoImplicitPrelude   #-}
+{-# LANGUAGE OverloadedStrings   #-}
+{-# LANGUAGE PolyKinds           #-}
+{-# LANGUAGE ScopedTypeVariables #-}
+{-# LANGUAGE TemplateHaskell     #-}
+{-# LANGUAGE TypeOperators       #-}
+
+module Gamgee.Effects.SecretInput
+    ( -- * Effect
+      SecretInput(..)
+
+      -- * Actions
+    , secretInput
+
+      -- * Interpretations
+    , runSecretInputIO
+    ) where
+
+import           Control.Exception.Safe (bracket_)
+import           Polysemy               (Lift, Member, Sem)
+import qualified Polysemy               as P
+import           Relude
+import qualified System.IO              as IO
+
+
+
+----------------------------------------------------------------------------------------------------
+-- Effect
+----------------------------------------------------------------------------------------------------
+
+-- | An effect that provides input to the application. Intended to be
+-- used in contexts where the input is a secret such as
+-- passwords. Interpretations may chose to "protect" the input
+-- appropriately. For example, an IO interpretation may chose not to
+-- echo the input to the console.
+data SecretInput i m a where
+  -- ^ Retrieve a secret input
+  SecretInput :: Text              -- ^ A prompt
+              -> SecretInput i m i
+
+P.makeSem ''SecretInput
+
+
+----------------------------------------------------------------------------------------------------
+-- Interpretations
+----------------------------------------------------------------------------------------------------
+
+runSecretInputIO :: (Member (Lift IO) r) => Sem (SecretInput Text : r) a -> Sem r a
+runSecretInputIO = P.interpret $ \case
+  SecretInput prompt -> P.sendM $ do
+    putText prompt
+    IO.hFlush stdout
+    i <- withoutEcho getLine
+    IO.putChar '\n'
+    return i
+
+    where
+      withoutEcho :: IO a -> IO a
+      withoutEcho f = do
+        old <- IO.hGetEcho stdin
+        bracket_ (IO.hSetEcho stdin False) (IO.hSetEcho stdin old) f
diff --git a/src/Gamgee/Effects/TOTP.hs b/src/Gamgee/Effects/TOTP.hs
new file mode 100644
--- /dev/null
+++ b/src/Gamgee/Effects/TOTP.hs
@@ -0,0 +1,90 @@
+{-# LANGUAGE DataKinds           #-}
+{-# LANGUAGE FlexibleContexts    #-}
+{-# LANGUAGE GADTs               #-}
+{-# LANGUAGE LambdaCase          #-}
+{-# LANGUAGE NoImplicitPrelude   #-}
+{-# LANGUAGE OverloadedStrings   #-}
+{-# LANGUAGE PolyKinds           #-}
+{-# LANGUAGE ScopedTypeVariables #-}
+{-# LANGUAGE TemplateHaskell     #-}
+{-# LANGUAGE TypeOperators       #-}
+
+module Gamgee.Effects.TOTP
+    ( -- * Effects
+      TOTP (..)
+
+      -- * Actions
+    , getTOTP
+
+      -- * Interpretations
+    , runTOTP
+    ) where
+
+import qualified Crypto.Hash.Algorithms     as HashAlgos
+import qualified Crypto.OTP                 as OTP
+import qualified Data.ByteArray.Encoding    as Encoding
+import qualified Data.Time.Clock.POSIX      as Clock
+import           Gamgee.Effects.Crypto      (Crypto)
+import qualified Gamgee.Effects.Crypto      as Crypto
+import qualified Gamgee.Effects.Error       as Err
+import           Gamgee.Effects.SecretInput (SecretInput)
+import qualified Gamgee.Token               as Token
+import           Polysemy                   (Member, Members, Sem)
+import qualified Polysemy                   as P
+import qualified Polysemy.Error             as P
+import           Relude
+import qualified Text.Printf                as Printf
+
+
+----------------------------------------------------------------------------------------------------
+-- Effects
+----------------------------------------------------------------------------------------------------
+
+data TOTP m a where
+  GetTOTP :: Token.TokenSpec -> Clock.POSIXTime -> TOTP m Text
+
+P.makeSem ''TOTP
+
+
+----------------------------------------------------------------------------------------------------
+-- Interpret TOTP
+----------------------------------------------------------------------------------------------------
+
+runTOTP :: Members [SecretInput Text, Crypto, P.Error Err.EffError] r => Sem (TOTP : r) a -> Sem r a
+runTOTP = P.interpret $ \case
+  GetTOTP spec time -> do
+    secret <- Crypto.decryptSecret spec
+    case Encoding.convertFromBase Encoding.Base32 (encodeUtf8 secret :: ByteString) of
+      Left msg  -> P.throw $ Err.SecretDecryptError $ toText msg
+      Right key -> computeTOTP spec key time
+
+computeTOTP :: Member (P.Error Err.EffError) r => Token.TokenSpec -> ByteString -> Clock.POSIXTime -> Sem r Text
+computeTOTP spec key time =
+  case Token.tokenAlgorithm spec of
+    Token.AlgorithmSHA1   -> makeOTP <$> makeParams HashAlgos.SHA1
+    Token.AlgorithmSHA256 -> makeOTP <$> makeParams HashAlgos.SHA256
+    Token.AlgorithmSHA512 -> makeOTP <$> makeParams HashAlgos.SHA512
+
+  where
+    period :: Token.TokenPeriod
+    period = Token.tokenPeriod spec
+
+    digits :: OTP.OTPDigits
+    digits = case Token.tokenDigits spec of
+               Token.Digits6 -> OTP.OTP6
+               Token.Digits8 -> OTP.OTP8
+
+    makeParams :: (Member (P.Error Err.EffError) r, HashAlgos.HashAlgorithm h) => h -> Sem r (OTP.TOTPParams h)
+    makeParams alg = either (const (P.throw $ Err.InvalidTokenPeriod period))
+                            return $
+                            OTP.mkTOTPParams alg 0 (Token.unTokenPeriod period) digits OTP.NoSkew
+
+    makeOTP :: (HashAlgos.HashAlgorithm h) => OTP.TOTPParams h -> Text
+    makeOTP p = format $ OTP.totp p key $ floor time
+
+    format :: OTP.OTP -> Text
+    format otp =
+      let (base, size) = case Token.tokenDigits spec of
+                           Token.Digits6 -> (1000000, "6")
+                           Token.Digits8 -> (100000000, "8")
+      in fromString $ Printf.printf ("%0" ++ size ++ "d") (otp `mod` base)
diff --git a/src/Gamgee/Operation.hs b/src/Gamgee/Operation.hs
new file mode 100644
--- /dev/null
+++ b/src/Gamgee/Operation.hs
@@ -0,0 +1,67 @@
+{-# LANGUAGE DataKinds         #-}
+{-# LANGUAGE FlexibleContexts  #-}
+{-# LANGUAGE NoImplicitPrelude #-}
+
+module Gamgee.Operation
+  ( addToken
+  , deleteToken
+  , listTokens
+  , getOTP
+  ) where
+
+
+import qualified Data.Time.Clock.POSIX as Clock
+import qualified Gamgee.Effects        as Eff
+import qualified Gamgee.Token          as Token
+import           Polysemy              (Members, Sem)
+import qualified Polysemy.Error        as P
+import qualified Polysemy.Output       as P
+import qualified Polysemy.State        as P
+import           Relude
+import qualified Relude.Extra.Map      as Map
+
+
+addToken :: Members [ P.State Token.Tokens
+                    , Eff.Crypto
+                    , Eff.SecretInput Text
+                    , P.Error Eff.EffError ] r
+         => Token.TokenSpec
+         -> Sem r ()
+addToken spec = do
+  let ident = Token.getIdentifier spec
+  tokens <- P.get
+  if ident `Map.member` tokens
+  then P.throw $ Eff.AlreadyExists ident
+  else do
+    spec' <- Eff.encryptSecret spec
+    P.put $ Map.insert ident spec' tokens
+
+deleteToken :: Members [ P.State Token.Tokens
+                       , P.Error Eff.EffError ] r
+            => Token.TokenIdentifier
+            -> Sem r ()
+deleteToken ident = do
+  tokens <- P.get
+  case Map.lookup ident tokens of
+    Nothing -> P.throw $ Eff.NoSuchToken ident
+    Just _  -> P.put $ Map.delete ident tokens
+
+listTokens :: Members [ P.State Token.Tokens
+                      , P.Output Text ] r
+           => Sem r ()
+listTokens = do
+  tokens <- P.get
+  mapM_ (P.output . Token.unTokenIdentifier . Token.getIdentifier) (tokens :: Token.Tokens)
+
+getOTP :: Members [ P.State Token.Tokens
+                  , P.Error Eff.EffError
+                  , P.Output Text
+                  , Eff.TOTP ] r
+       => Token.TokenIdentifier
+       -> Clock.POSIXTime
+       -> Sem r ()
+getOTP ident time = do
+  tokens <- P.get
+  case Map.lookup ident tokens of
+    Nothing   -> P.throw $ Eff.NoSuchToken ident
+    Just spec -> Eff.getTOTP spec time >>= P.output
diff --git a/src/Gamgee/Token.hs b/src/Gamgee/Token.hs
new file mode 100644
--- /dev/null
+++ b/src/Gamgee/Token.hs
@@ -0,0 +1,123 @@
+{-# LANGUAGE DeriveAnyClass             #-}
+{-# LANGUAGE DeriveGeneric              #-}
+{-# LANGUAGE DerivingStrategies         #-}
+{-# LANGUAGE GeneralizedNewtypeDeriving #-}
+{-# LANGUAGE MultiWayIf                 #-}
+{-# LANGUAGE NoImplicitPrelude          #-}
+{-# LANGUAGE OverloadedStrings          #-}
+
+-- | Data structures to define and manipulate tokens
+module Gamgee.Token
+    ( TokenType (..)
+    , TokenLabel (..)
+    , TokenSecret (..)
+    , TokenIssuer (..)
+    , TokenAlgorithm (..)
+    , TokenDigits (..)
+    , TokenPeriod (..)
+    , TokenSpec (..)
+    , TokenIdentifier (..)
+    , Tokens
+    , getIdentifier
+    ) where
+
+import qualified Data.Aeson as Aeson
+import qualified Data.Text  as Text
+import           Relude
+
+
+-- | Type of token TOTP or HOTP (not supported yet)
+data TokenType = TOTP
+  deriving stock Show
+
+instance Aeson.FromJSON TokenType where
+  parseJSON (Aeson.String "totp") = return TOTP
+  parseJSON invalid               = fail $ "Invalid token type: " ++ show invalid
+
+instance Aeson.ToJSON TokenType where
+  toJSON TOTP = Aeson.String "totp"
+
+-- | Label of the token
+newtype TokenLabel = TokenLabel {
+  unTokenLabel :: Text
+  }
+  deriving newtype (Show, IsString, Aeson.FromJSON, Aeson.ToJSON)
+
+-- | Secret used to generate OTPs
+data TokenSecret = TokenSecretPlainText Text
+                 | TokenSecretAES256 {
+                     tokenSecretAES256IV     :: Text
+                     , tokenSecretAES256Data :: Text
+                     }
+                 deriving stock    (Show, Generic)
+                 deriving anyclass (Aeson.FromJSON, Aeson.ToJSON)
+
+-- | Optional issuer of this token
+newtype TokenIssuer = TokenIssuer {
+  unTokenIssuer :: Text
+  }
+  deriving newtype (Show, IsString, Aeson.FromJSON, Aeson.ToJSON)
+
+data TokenAlgorithm = AlgorithmSHA1
+                    | AlgorithmSHA256
+                    | AlgorithmSHA512
+                    deriving stock    (Show, Generic)
+                    deriving anyclass (Aeson.FromJSON, Aeson.ToJSON)
+
+data TokenDigits = Digits6
+                 | Digits8
+                 deriving stock Show
+
+instance Aeson.FromJSON TokenDigits where
+  parseJSON (Aeson.Number 6) = return Digits6
+  parseJSON (Aeson.Number 8) = return Digits8
+  parseJSON invalid          = fail $ "Invalid number of digits: " ++ show invalid ++ ". Must be 6 or 8."
+
+instance Aeson.ToJSON TokenDigits where
+  toJSON Digits6 = Aeson.Number 6
+  toJSON Digits8 = Aeson.Number 8
+
+-- | Refresh interval of the token in seconds
+newtype TokenPeriod = TokenPeriod {
+  unTokenPeriod :: Word16
+  }
+  deriving newtype (Eq, Ord, Enum, Num, Real, Integral, Show, Aeson.FromJSON, Aeson.ToJSON)
+
+data TokenSpec = TokenSpec {
+  -- ^ TOTP/HOTP token
+  tokenType        :: TokenType
+  -- ^ A short unique label for this token used to identify it
+  , tokenLabel     :: TokenLabel
+  -- ^ The secret provided by the issuer to generate tokens
+  , tokenSecret    :: TokenSecret
+  -- ^ The name of the issuer
+  , tokenIssuer    :: TokenIssuer
+  -- ^ SHA algorithm used to generate tokens
+  , tokenAlgorithm :: TokenAlgorithm
+  -- ^ Number of digits in the token - 6 or 8
+  , tokenDigits    :: TokenDigits
+  -- ^ Refresh interval of the token - typically 30 sec
+  , tokenPeriod    :: TokenPeriod
+  }
+  deriving stock    (Generic, Show)
+  deriving anyclass (Aeson.FromJSON, Aeson.ToJSON)
+
+-- An identifier for a token. This is derived from the label and issuer
+newtype TokenIdentifier = TokenIdentifier {
+  unTokenIdentifier :: Text
+  }
+  deriving newtype (Eq, Show, Hashable, IsString, Semigroup, ToString
+                   , Aeson.FromJSON, Aeson.ToJSON
+                   , Aeson.FromJSONKey, Aeson.ToJSONKey)
+
+getIdentifier :: TokenSpec -> TokenIdentifier
+getIdentifier spec =
+  let
+    TokenLabel label = tokenLabel spec
+    TokenIssuer issuer = tokenIssuer spec
+  in
+    TokenIdentifier $ if | Text.null issuer                      -> label
+                         | Text.isPrefixOf (issuer <> ":") label -> label
+                         | otherwise                             -> issuer <> ":" <> label
+
+type Tokens = HashMap TokenIdentifier TokenSpec
diff --git a/test/Gamgee/Test/Effects.hs b/test/Gamgee/Test/Effects.hs
new file mode 100644
--- /dev/null
+++ b/test/Gamgee/Test/Effects.hs
@@ -0,0 +1,71 @@
+{-# LANGUAGE DataKinds          #-}
+{-# LANGUAGE DerivingStrategies #-}
+{-# LANGUAGE FlexibleContexts   #-}
+{-# LANGUAGE GADTs              #-}
+{-# LANGUAGE LambdaCase         #-}
+{-# LANGUAGE NoImplicitPrelude  #-}
+{-# LANGUAGE OverloadedStrings  #-}
+{-# LANGUAGE TypeOperators      #-}
+
+module Gamgee.Test.Effects
+  ( runOutputPure
+  , runListSecretInput
+  , runCryptoRandom
+  , runByteStoreST
+  ) where
+
+import           Control.Monad.ST    (ST)
+import qualified Crypto.Random       as CR
+import qualified Crypto.Random.Types as CRT
+import           Data.STRef          (STRef)
+import qualified Data.STRef          as STRef
+import qualified Gamgee.Effects      as Eff
+import           Polysemy            (Lift, Member, Sem)
+import qualified Polysemy            as P
+import qualified Polysemy.Output     as P
+import qualified Polysemy.State      as P
+import           Relude
+
+
+----------------------------------------------------------------------------------------------------
+-- Interpret Output by accumulating it in a list
+----------------------------------------------------------------------------------------------------
+
+runOutputPure :: Sem (P.Output o : r) a -> Sem r ([o], a)
+runOutputPure = P.runFoldMapOutput one
+
+
+----------------------------------------------------------------------------------------------------
+-- Interpret SecretInput by reading from a list
+----------------------------------------------------------------------------------------------------
+
+runListSecretInput :: [i] -> Sem (Eff.SecretInput i : r) a -> Sem r a
+runListSecretInput is = fmap snd . P.runState is . P.reinterpret
+  (\case
+      Eff.SecretInput _ -> do
+        s <- P.gets uncons
+        whenJust s (P.put . snd)
+        maybe
+          (error "Ran out of input in SecretInput")
+          return
+          (fst <$> s)
+  )
+
+
+----------------------------------------------------------------------------------------------------
+-- Interpret CryptoRandom with a DRG
+----------------------------------------------------------------------------------------------------
+
+runCryptoRandom :: CR.DRG gen => gen -> Sem (Eff.CryptoRandom : r) a -> Sem r a
+runCryptoRandom gen = P.interpret $ \case
+  Eff.RandomBytes count -> return (fst $ CR.withDRG gen (CRT.getRandomBytes count))
+
+
+----------------------------------------------------------------------------------------------------
+-- Interpret ByteStore using the ST monad
+----------------------------------------------------------------------------------------------------
+
+runByteStoreST :: Member (Lift (ST s)) r => STRef s (Maybe LByteString) -> Sem (Eff.ByteStore : r) a -> Sem r a
+runByteStoreST ref = P.interpret $ \case
+  Eff.ReadByteStore        -> P.sendM $ STRef.readSTRef ref
+  Eff.WriteByteStore bytes -> P.sendM $ STRef.writeSTRef ref (Just bytes)
diff --git a/test/Gamgee/Test/Golden.hs b/test/Gamgee/Test/Golden.hs
new file mode 100644
--- /dev/null
+++ b/test/Gamgee/Test/Golden.hs
@@ -0,0 +1,44 @@
+{-# LANGUAGE NoImplicitPrelude #-}
+{-# LANGUAGE OverloadedStrings #-}
+{-# LANGUAGE TypeApplications  #-}
+
+module Gamgee.Test.Golden
+  ( goldenTests
+  ) where
+
+import qualified Crypto.Random         as CR
+import qualified Gamgee.Test.Operation as Op
+import qualified Gamgee.Token          as Token
+import           Relude
+import           System.FilePath       ((</>))
+import qualified Test.Tasty            as T
+import qualified Test.Tasty.Golden     as T
+
+
+goldenTests :: T.TestTree
+goldenTests = T.testGroup "Golden Tests" [ getOTPTest ]
+
+goldenTest :: Show a => T.TestName -> IO a -> T.TestTree
+goldenTest name action = T.goldenVsString name (goldenDir </> name ++ ".txt") (encodeUtf8 @String . show <$> action)
+  where
+    goldenDir = "test" </> "data" </> "golden"
+
+
+----------------------------------------------------------------------------------------------------
+-- getOTP tests
+----------------------------------------------------------------------------------------------------
+
+getOTPTest :: T.TestTree
+getOTPTest = goldenTest "getOTPTest" $ do
+  drg <- CR.getSystemDRG
+  return $ Op.runTest Nothing $ \cfg -> do
+    let spec = Token.TokenSpec {
+                 Token.tokenType        = Token.TOTP
+                 , Token.tokenLabel     = "test"
+                 , Token.tokenSecret    = Token.TokenSecretPlainText "K5RHSRDNJZRSCTDHKM4VSYLN"
+                 , Token.tokenIssuer    = Token.TokenIssuer ""
+                 , Token.tokenAlgorithm = Token.AlgorithmSHA1
+                 , Token.tokenDigits    = Token.Digits6
+                 , Token.tokenPeriod    = Token.TokenPeriod 30 }
+    Right () <- Op.addToken drg cfg ["nicepassword"] spec
+    mapM (Op.getOTP drg cfg ["nicepassword"] (Token.getIdentifier spec) . fromInteger) [312 + i*30 | i <- [0..4]]
diff --git a/test/Gamgee/Test/Operation.hs b/test/Gamgee/Test/Operation.hs
new file mode 100644
--- /dev/null
+++ b/test/Gamgee/Test/Operation.hs
@@ -0,0 +1,89 @@
+{-# LANGUAGE NoImplicitPrelude #-}
+{-# LANGUAGE RankNTypes        #-}
+
+module Gamgee.Test.Operation
+  ( OutputMessage
+  , runTest
+  , addToken
+  , deleteToken
+  , listTokens
+  , getOTP
+  ) where
+
+import           Control.Monad.ST      (ST, runST)
+import qualified Crypto.Random         as CR
+import           Data.STRef            (STRef)
+import qualified Data.STRef            as STRef
+import qualified Data.Time.Clock.POSIX as Clock
+import qualified Gamgee.Effects        as Eff
+import qualified Gamgee.Operation      as Operation
+import qualified Gamgee.Test.Effects   as Eff
+import qualified Gamgee.Token          as Token
+import qualified Polysemy              as P
+import qualified Polysemy.Error        as P
+import           Relude
+
+
+----------------------------------------------------------------------------------------------------
+-- All operations supported by gamgee run using STRefs
+----------------------------------------------------------------------------------------------------
+
+type TestStore s = STRef s (Maybe LByteString)
+type OutputMessage = Text
+
+runTest :: Maybe LByteString -> (forall s. TestStore s -> ST s a) -> a
+runTest store action = runST $ STRef.newSTRef store >>= action
+
+addToken :: CR.DRG gen
+         => gen
+         -> TestStore s
+         -> [Text]
+         -> Token.TokenSpec
+         -> ST s (Either Eff.EffError ())
+addToken drg store input spec = P.runM
+                                $ P.runError
+                                $ Eff.runCryptoRandom drg
+                                $ Eff.runCrypto
+                                $ Eff.runListSecretInput input
+                                $ Eff.runByteStoreST store
+                                $ Eff.runGamgeeJSONStore
+                                $ Eff.runStateJSON
+                                $ Operation.addToken spec
+
+deleteToken :: TestStore s -> Token.TokenIdentifier -> ST s (Either Eff.EffError ())
+deleteToken store t = P.runM
+                      $ P.runError
+                      $ Eff.runByteStoreST store
+                      $ Eff.runGamgeeJSONStore
+                      $ Eff.runStateJSON
+                      $ Operation.deleteToken t
+
+listTokens :: TestStore s -> ST s (Either Eff.EffError [OutputMessage])
+listTokens store = fmap fst
+                   <$> P.runM
+                       (P.runError
+                        $ Eff.runOutputPure
+                        $ Eff.runByteStoreST store
+                        $ Eff.runGamgeeJSONStore
+                        $ Eff.runStateJSON Operation.listTokens)
+
+getOTP :: CR.DRG gen
+       => gen
+       -> TestStore s
+       -> [Text]
+       -> Token.TokenIdentifier
+       -> Clock.POSIXTime
+       -> ST s (Either Eff.EffError [OutputMessage])
+getOTP drg store input tok time =
+  fmap fst
+  <$> P.runM
+      (P.runError
+       $ Eff.runOutputPure
+       $ Eff.runCryptoRandom drg
+       $ Eff.runCrypto
+       $ Eff.runListSecretInput input
+       $ Eff.runByteStoreST store
+       $ Eff.runGamgeeJSONStore
+       $ Eff.runStateJSON
+       $ Eff.runTOTP
+       $ Operation.getOTP tok time)
diff --git a/test/Gamgee/Test/Property.hs b/test/Gamgee/Test/Property.hs
new file mode 100644
--- /dev/null
+++ b/test/Gamgee/Test/Property.hs
@@ -0,0 +1,156 @@
+{-# LANGUAGE DeriveAnyClass             #-}
+{-# LANGUAGE DeriveGeneric              #-}
+{-# LANGUAGE DerivingStrategies         #-}
+{-# LANGUAGE GeneralizedNewtypeDeriving #-}
+{-# LANGUAGE LambdaCase                 #-}
+{-# LANGUAGE NoImplicitPrelude          #-}
+{-# LANGUAGE OverloadedStrings          #-}
+{-# LANGUAGE PolyKinds                  #-}
+{-# LANGUAGE StandaloneDeriving         #-}
+{-# LANGUAGE TemplateHaskell            #-}
+{-# LANGUAGE TypeApplications           #-}
+
+{-# OPTIONS_GHC -fno-warn-orphans #-}
+
+-- | Property based tests
+module Gamgee.Test.Property
+  ( propertyTests
+  ) where
+
+import           Control.Monad.ST          (ST)
+import qualified Crypto.Random             as CR
+import qualified Data.ByteArray.Encoding   as BE
+import qualified Data.ByteString           as BS
+import qualified Data.Text                 as Text
+import qualified Gamgee.Effects            as Eff
+import qualified Gamgee.Test.Operation     as Op
+import qualified Gamgee.Token              as Token
+import           Relude
+import qualified Relude.Extra.Map          as Map
+import           Test.QuickCheck           (allProperties)
+import           Test.QuickCheck.Instances ()
+import qualified Test.QuickCheck.Monadic   as T
+import qualified Test.Tasty                as T
+import qualified Test.Tasty.QuickCheck     as T
+
+
+deriving newtype instance T.Arbitrary Token.TokenIdentifier
+deriving newtype instance T.Arbitrary Token.TokenLabel
+deriving newtype instance T.Arbitrary Token.TokenIssuer
+
+instance T.Arbitrary Token.TokenSecret where
+  arbitrary = Token.TokenSecretPlainText . decodeUtf8 <$> base32String
+    where
+      base32String :: T.Gen ByteString
+      base32String = BE.convertToBase BE.Base32 <$> T.arbitrary `T.suchThat` (\s -> BS.length s `mod` 10 == 0)
+
+instance T.Arbitrary Token.TokenAlgorithm where
+  arbitrary = T.oneof [ pure Token.AlgorithmSHA1
+                      , pure Token.AlgorithmSHA256
+                      , pure Token.AlgorithmSHA512
+                      ]
+
+instance T.Arbitrary Token.TokenDigits where
+  arbitrary = T.oneof [ pure Token.Digits6
+                      , pure Token.Digits8
+                      ]
+
+instance T.Arbitrary Token.TokenPeriod where
+  arbitrary = Token.TokenPeriod <$> T.choose (10, 120)
+
+instance T.Arbitrary Token.TokenSpec where
+  arbitrary = Token.TokenSpec
+              <$> pure Token.TOTP
+              <*> T.arbitrary
+              <*> T.arbitrary
+              <*> T.arbitrary
+              <*> T.arbitrary
+              <*> T.arbitrary
+              <*> T.arbitrary
+
+-- | Ensures that an effect succeeded, fail the ST monad otherwise
+ensureSuccess :: ST s (Either Eff.EffError a) -> ST s a
+ensureSuccess action = do
+  res <- action
+  either (fail . show) return res
+
+withSystemDRG :: (T.Testable a) => (CR.SystemDRG -> T.PropertyM IO a) -> T.Property
+withSystemDRG f = T.monadicIO $ do
+  drg <- liftIO CR.getSystemDRG
+  f drg
+
+prop_addThenRemove :: Token.TokenSpec -> T.Property
+prop_addThenRemove spec = withSystemDRG $ \drg -> T.assert (addThenRemove drg == Right [])
+  where
+    addThenRemove :: CR.SystemDRG -> Either Eff.EffError [Op.OutputMessage]
+    addThenRemove drg = Op.runTest Nothing $ \store -> do
+      ensureSuccess $ Op.addToken drg store [""] spec
+      ensureSuccess $ Op.deleteToken store (Token.getIdentifier spec)
+      Op.listTokens store
+
+prop_addThenList :: [Token.TokenSpec] -> T.Property
+prop_addThenList specList = withSystemDRG $ \drg -> T.assert (addThenList drg == Right (Token.unTokenIdentifier <$> Map.keys specs))
+  where
+    specs :: HashMap Token.TokenIdentifier Token.TokenSpec
+    specs = fromList $ map (\spec -> (Token.getIdentifier spec, spec)) specList
+
+    addThenList :: CR.SystemDRG -> Either Eff.EffError [Op.OutputMessage]
+    addThenList drg = Op.runTest Nothing $ \store -> do
+      mapM_ (ensureSuccess . Op.addToken drg store [""]) $ Map.elems specs
+      Op.listTokens store
+
+prop_addThenGetOTP :: Token.TokenSpec -> T.Property
+prop_addThenGetOTP spec = withSystemDRG $ \drg ->
+  forM_ (addThenGetOTP drg) $ \case
+    Left err  -> fail $ "Expected an OTP but found " <> show err
+    Right otp -> case Token.tokenDigits spec of
+                   Token.Digits6 -> T.assert $ (Text.length <$> otp) == [6]
+                   Token.Digits8 -> T.assert $ (Text.length <$> otp) == [8]
+
+  where
+    ident :: Token.TokenIdentifier
+    ident = Token.getIdentifier spec
+
+    addThenGetOTP :: CR.SystemDRG -> [Either Eff.EffError [Op.OutputMessage]]
+    addThenGetOTP drg = Op.runTest Nothing $ \store -> do
+      ensureSuccess $ Op.addToken drg store [""] spec
+      mapM (Op.getOTP drg store [""] ident . fromInteger) [100 + i*30 | i <- [0..4]]
+
+prop_doubleAddFails :: Token.TokenSpec -> T.Property
+prop_doubleAddFails spec = withSystemDRG $
+  \drg -> T.assert (doubleAdd drg == Left (Eff.AlreadyExists $ Token.getIdentifier spec))
+
+  where
+    doubleAdd :: CR.SystemDRG -> Either Eff.EffError ()
+    doubleAdd drg = Op.runTest Nothing $ \store -> do
+      ensureSuccess $ Op.addToken drg store [""] spec
+      Op.addToken drg store [""] spec
+
+prop_doubleDeleteFails :: Token.TokenSpec -> T.Property
+prop_doubleDeleteFails spec = withSystemDRG $
+  \drg -> T.assert (doubleDelete drg == Left (Eff.NoSuchToken ident))
+
+  where
+    ident :: Token.TokenIdentifier
+    ident = Token.getIdentifier spec
+
+    doubleDelete :: CR.SystemDRG -> Either Eff.EffError ()
+    doubleDelete drg = Op.runTest Nothing $ \store -> do
+      ensureSuccess $ Op.addToken drg store [""] spec
+      ensureSuccess $ Op.deleteToken store ident
+      Op.deleteToken store ident
+
+prop_deleteOnEmptyFails :: Token.TokenIdentifier -> T.Property
+prop_deleteOnEmptyFails ident = T.monadicIO $
+  T.assert (deleteOnEmpty == Left (Eff.NoSuchToken ident))
+
+  where
+    deleteOnEmpty :: Either Eff.EffError ()
+    deleteOnEmpty = Op.runTest Nothing $ \store -> Op.deleteToken store ident
+
+
+-- Template Haskell hack to make the following $allProperties work
+return []
+
+propertyTests :: T.TestTree
+propertyTests = T.testProperties "Property Tests" $allProperties
diff --git a/test/Main.hs b/test/Main.hs
new file mode 100644
--- /dev/null
+++ b/test/Main.hs
@@ -0,0 +1,14 @@
+{-# LANGUAGE NoImplicitPrelude #-}
+{-# LANGUAGE OverloadedStrings #-}
+
+module Main where
+
+import qualified Gamgee.Test.Golden   as G
+import qualified Gamgee.Test.Property as P
+import           Relude
+import qualified Test.Tasty           as T
+
+
+main :: IO ()
+main = T.defaultMain $ T.testGroup "All Tests" [ G.goldenTests
+                                               , P.propertyTests ]
diff --git a/test/data/golden/getOTPTest.txt b/test/data/golden/getOTPTest.txt
new file mode 100644
--- /dev/null
+++ b/test/data/golden/getOTPTest.txt
@@ -0,0 +1,1 @@
+[Right ["621420"],Right ["451175"],Right ["884220"],Right ["760248"],Right ["310300"]]
