diff --git a/.github/workflows/build.yaml b/.github/workflows/build.yaml new file mode 100644 index 0000000..d25d263 --- /dev/null +++ b/.github/workflows/build.yaml @@ -0,0 +1,20 @@ +name: build +on: [push] +jobs: + varuh-build: + runs-on: ubuntu-latest + steps: + - run: echo "\U0001f389 The job was automatically triggered by a ${{ github.event_name }} event." + - run: echo "\U0001f427 This job is now running on a ${{ runner.os }} server hosted by GitHub!" + - run: echo "\U0001f50e The name of your branch is ${{ github.ref }} and your repository is ${{ github.repository }}." + - name: Check out repository code + uses: actions/checkout@v3 + - run: echo "\U0001f4a1 The ${{ github.repository }} repository has been cloned to the runner." + - run: echo "\U0001f5a5\ufe0f The workflow is now ready to test your code on the runner." + - name: List files in the repository + run: | + ls ${{ github.workspace }} + - run: echo "\U0001f34f This job's status is ${{ job.status }}." + - name: Build code + run: | + cd ${{ github.workspace }} && make diff --git a/META b/META new file mode 100644 index 0000000..1907ac3 --- /dev/null +++ b/META @@ -0,0 +1,5 @@ +Package: varuh +Version: VERSION +Architecture: amd64 +Maintainer: Anand B Pillai +Description: Password manager for the command line for Unix like operating systems diff --git a/Makefile b/Makefile index cc32025..e4f7b33 100644 --- a/Makefile +++ b/Makefile @@ -15,6 +15,19 @@ install: echo "...done"; \ fi +debian: program # Run as $ make debian VERSION=0.x.y + @echo "Building debian package for version=${VERSION}" + @mkdir -p debian/varuh-${VERSION}_amd64/usr/bin/ + @mkdir -p debian/varuh-${VERSION}_amd64/DEBIAN/ + @cp varuh debian/varuh-${VERSION}_amd64/usr/bin/ + @sed 's/VERSION/${VERSION}/g' META > debian/varuh-${VERSION}_amd64/DEBIAN/control + @cd debian/ && dpkg-deb --build --root-owner-group varuh-${VERSION}_amd64/ && cd - + @if [ -f debian/varuh-${VERSION}_amd64.deb ]; then \ + echo "Build successful."; \ + else \ + echo "Build failed."; \ + fi \ + clean: @echo "Removing ${PROGRAM}" @rm -f ${PROGRAM} diff --git a/README.md b/README.md index 8626cc9..01631aa 100644 --- a/README.md +++ b/README.md @@ -6,17 +6,17 @@ Table of Contents ================= * [About](#about) -* [Why a rewrite](#why-a-rewrite) -* [Building the code](#building-the-code) +* [Install](#install) * [Usage](#usage) * [Encryption and Security](#encryption-and-security) * [Databases](#databases) * [Listing and Searching](#listing-and-searching) +* [Misc](#misc) * [Export](#export) * [Configuration](#configuration) * [License](#license) * [Feedback](#feedback) -* [Thanks](#thanks) + About ===== @@ -27,38 +27,41 @@ The name [Varuh](https://www.wordsense.eu/varuh/#Slovene) means *guardian* or *p Varuh is inspired by [ylva](https://github.com/nrosvall/ylva) but it is full re-implementation - with some major changes in the key derivation functions and ciphers. It is written in `Go` and has been tested with Go versions 1.16 and 1.17 on Debian Linux (Antix). It should work on other versions of Linux and *BSD as well. -Why a rewrite -============= -If you ask - `"Why a rewrite, why not contribute to the original repo ?"`, it is a valid question. These are the some of the reasons. +Install +======= -1. I have been a regular user of `ylva` for a while but found its usage of flags confusing. For auto-encryption one needs to keep passing `--auto-encrypt` on the command line which I always tend to forget. The flag `--force` which is an override is also a bit confusing. The fact that to see passwords I have to type `--show-passwords` was another issue (no short option). I also felt these are better handled in configuration file than as flags on the command line. -2. `ylva` does not have a proper configuration file that keeps with the freedesktop specifications. -3. The fact that ylva keeps decrypted databases on disk when in regular use without an automatic {decrypt-encrypt}-on-use option was a problem. If I encrypt the database, I have to keep decrypting it to use the program which is a problem. Hence the `encrypt_on` flag was added to `varuh` (see below). -4. C is a venerable language but this is 2021 and I would rather program (and contribute) in a modern system programming language like `Go` or `Rust` which takes care of the memory handling tasks and leaves me to focus on the application code. Also I felt it is easier to get contributors to a project if it is in one of these languages as a lot of the Gen Z programmers don't know C. You will appreciate this more if you look at an open source repo written in C/C++ and find that 30% of all code are operations allocating/de-allocating memory. -5. Support for more ciphers and crypto systems - `Varuh` already supports the `XChacha20-Poly1305` stream cipher and uses `Argon2` (Argon2i variant) instead of the older `pbkdf2` as the key derivation function. OpenPGP encryption is in the pipeline. +## Binary Release -Building the code -================= +If you are on a Debian or Debian derived system, you can directly download and install the latest version. Check out the [releases](https://github.com/pythonhacker/varuh/releases) page and use `dpkg` to install the binary. + + $ sudo dpkg -i varuh-${VERSION}_amd64.deb + +The binary will be installed in `/usr/bin` folder. + +## Building from Source You need the [Go compiler](https://golang.org/dl/) to build the code. (This can be usually installed on \*nix machines by the native package managers like *apt-get*). Install `make` by using your native package manager. Something like, - $ sudo apt install make -y + $ sudo apt install make -y should work. Then, - $ make - go: downloading github.com/kirsle/configdir v0.0.0-20170128060238-e45d2f54772f - go: downloading github.com/pborman/getopt/v2 v2.1.0 - go: downloading golang.org/x/crypto v0.0.0-20210921155107-089bfa567519 - go: downloading gorm.io/driver/sqlite v1.2.3 - ... + $ make + Building varuh + go: downloading github.com/akamensky/argparse v1.3.1 + go: downloading golang.org/x/crypto v0.0.0-20210921155107-089bfa567519 + go: downloading github.com/atotto/clipboard v0.1.4 + go: downloading github.com/kirsle/configdir v0.0.0-20170128060238-e45d2f54772f + go: downloading github.com/pythonhacker/argparse v1.3.2 + go: downloading gorm.io/driver/sqlite v1.2.3 + ... - $ sudo make install - Installing varuh...done + $ sudo make install + Installing varuh...done The binary will be installed in `/usr/local/bin` folder. @@ -67,46 +70,44 @@ Usage ===== $ varuh -h + usage: varuh [-h|--help] [-I|--init ""] [-d|--decrypt ""] + [-C|--clone ""] [-R|--remove ""] [-U|--use-db + ""] [-E|--edit ""] [-l|--list-entry ""] + [-x|--export ""] [-m|--migrate ""] [-f|--find + "" [-f|--find "" ...]] [-e|--encrypt] [-A|--add] + [-p|--path] [-a|--list-all] [-g|--genpass] [-s|--show] [-c|--copy] + [-y|--assume-yes] [-v|--version] + + Password manager for the command line for Unix like operating + systems + + Options: + + -h --help Print help information + -I --init Initialize a new database + -d --decrypt Decrypt password database + -C --clone Clone an entry with + -R --remove Remove an entry with or + -U --use-db Set as active database + -E --edit Edit entry by + -l --list-entry List entry by + -x --export Export all entries to + -m --migrate Migrate a database to latest schema + -f --find ... Search entries with terms + -e --encrypt Encrypt the current database + -A --add Add a new entry + -p --path Show current database path + -a --list-all List all entries in current database + -g --genpass Generate a strong password (length: 12 - 16) + -s --show Show passwords when listing entries + -c --copy Copy password to clipboard + -y --assume-yes Assume yes to actions requiring confirmation + -v --version Show version information and exit + + + AUTHORS + Copyright (C) 2022 Anand B Pillai - SYNOPSIS - - varuh [options] [flags] - - OPTIONS - - EDIT/CREATE ACTIONS: - - -E --edit Edit entry by id - -e --encrypt Encrypt the current database - -U --use-db Set as active database - -d --decrypt Decrypt password database - -C --copy Copy an entry - -R --remove Remove an entry - -A --add Add a new entry - -I --init Initialize a new database - - FIND/LIST ACTIONS: - - -f --find Search entries - -x --export Export all entries to - -p --path Show current database path - -a --list-all List all entries in current database - -l --list-entry List entry by id - - HELP ACTIONS: - - -v --version Show version information and exit - -h --help Print this help message and exit - - FLAGS: - - -s --show Show passwords when listing entries - - -AUTHORS - Copyright (C) 2021 Anand B Pillai - -The command line flags are grouped into `Edit/Create`, `Find/List` and `Help` actions. The first group of actions allows you to work with password databases and perform create/edit as well as encrypt/decrypt actions. The second set of actions allows you to work with an active decrypted database and view/search/list entries. Encryption and Security ======================= @@ -132,97 +133,204 @@ Databases ## Create a database - $ varuh -I mypasswds - Created new database - mypasswds - Updating active db path - /home/anand/mypasswds + $ varuh -I mypasswds + Created new database - mypasswds + Updating active db path - /home/anand/mypasswds - $ ls -lt mypasswds - -rw------- 1 anand anand 8192 Nov 9 23:06 mypasswds + $ ls -lt mypasswds + -rw------- 1 anand anand 8192 Nov 9 23:06 mypasswds The password database is created and is active now. You can start adding entries to it. ## Add an entry - $ varuh -A - Title: My Website Login - URL: mywebsite.name - Username: mememe - Password (enter to generate new): - Generating password ...done - Notes: Website uses Nginx auth - Created new entry with id: 1 + $ varuh -A + Title: My Website Login + URL: mywebsite.name + Username: mememe + Password (enter to generate new): + Generating password ...done + Tags (separated by space): testing test website + Notes: Website uses Nginx auth + Do you want to add custom fields [y/N]: + Created new entry with id: 1 You can now list the entry with one of the list options. - $ varuh -l 1 - +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ - ID: 1 - Title: My Website Login - User: mememe - URL: http://mywebsite.name - Password: **************** - Notes: Website uses Nginx auth - Modified: 2021-21-09 23:12:35 - +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ + $ varuh -l 1 + >>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>> + ID: 1 + Title: My Website Login + User: mememe + URL: http://mywebsite.name + Password: **************** + Tags: testing test website + Notes: Website uses Nginx auth + Modified: 2021-21-09 23:12:35 + >>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>> + +## Add an entry with custom fields + +From version 0.3 onwards, custom fields are supported. + + $ varuh -A + Title: Github token + URL: https://github.com/mydev/myproject + Username: mydev + Password (enter to generate new): ghp_ipQrStuVwxYz1a2b3cdEF10ghI689kLaMnOp + Tags (separated by space): token github + Notes: Never Expires + Do you want to add custom fields [y/N]: y + Field Name: Domain + Value for Domain: github.com + Field Name: Type + Value for Type: Auth Token + Field Name: + Created new entry with id: 6 + + $ varuh -l 6 + ID: 6 + Title: Github token + User: mydev + URL: https://github.com/mydev/myproject + Password: ghp_ipQrStuVwxYz1a2b3cdEF10ghI689kLaMnOp + Tags: token github + Notes: Never Expires + Domain: github.com + Type: Auth Token + Modified: 2021-21-13 00:07:18 For more on listing see the [Listing and Searching](#listing-and-searching) section below. ## Edit an entry - $ varuh -E 1 - Current Title: My Website Login - New Title: My Blog Login - Current URL: http://mywebsite.name - New URL: myblog.name - Current Username: mememe - New Username: meblog - Current Password: lTzC2z9kRppnYsYl - New Password ([y/Y] to generate new, enter will keep old one): - Current Notes: Website uses Nginx auth - New Notes: Website uses Apache - Updated entry. - - $ varuh -l 1 -s - +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ - ID: 1 - Title: My Blog Login - User: meblog - URL: http://myblog.name - Password: myblog123 - Notes: Website uses Apache - Modified: 2021-21-09 23:15:29 - +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ + $ varuh -E 1 + Current Title: My Website Login + New Title: My Blog Login + Current URL: http://mywebsite.name + New URL: myblog.name + Current Username: mememe + New Username: meblog + Current Password: lTzC2z9kRppnYsYl + New Password ([y/Y] to generate new, enter will keep old one): + Current Tags: testing test website + New Tags: + Current Notes: Website uses Nginx auth + New Notes: Website uses Apache + Do you want to add custom fields [y/N]: + Updated entry. + + $ varuh -l 1 -s + >>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>> + ID: 1 + Title: My Blog Login + User: meblog + URL: http://myblog.name + Password: myblog123 + Tags: testing test website + Notes: Website uses Apache + Modified: 2021-21-09 23:15:29 + >>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>> + +## Edit an entry with custom fields + +When you edit an entry with custom fields, you get the option to change the name of the fields or delete the fields entirely. + + $ varuh -E 6 + Current Title: Github token + New Title: + Current URL: https://github.com/mydev/myproject + New URL: + Current Username: mydev + New Username: + Current Password: ghp_ipQrStuVwxYz1a2b3cdEF10ghI689kLaMnOp + New Password ([y/Y] to generate new, enter will keep old one): + Current Tags: token github + New Tags: + Current Notes: Never Expires + New Notes: + Editing/deleting custom fields + Field Name: Domain + New Field Name (Enter to keep, "x" to delete): x + Deleting field: Domain + Field Name: Type + New Field Name (Enter to keep, "x" to delete): Token Type + Field Value: Auth Token + New Field Value (Enter to keep): + Do you want to add custom fields [y/N]: + Created 1 custom entries for entry: 21. + Updated entry. + + $ varuh -l 6 -s + ID: 6 + Title: Github token + User: mydev + URL: https://github.com/mydev/myproject + Password: ghp_ipQrStuVwxYz1a2b3cdEF10ghI689kLaMnOp + Tags: token github + Notes: Never Expires + Token Type: Auth Token + Modified: 2021-21-13 00:16:41 (*-s* turns on visible passwords) -## Copy an entry +## Clone an entry -To copy or clone an entry, +To clone (copy) an entry, - $ $ varuh -C 1 - Cloned to new entry, id: 2 + $ $ varuh -C 1 + Cloned to new entry, id: 3 ## Remove an entry - $ varuh -R 1 - Entry with id 1 was removed from the database + $ varuh -R 1 + >>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>> + Title: My Website Login + User: mememe + URL: https://mywebsite.name + Modified: 2021-21-09 23:12:35 + >>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>> + Please confirm removal [Y/n]: + Entry with id 1 was removed from the database It is an error if the id does not exist. - $ varuh -R 3 - No entry with id 3 was found + $ varuh -R 4 + No entry with id 4 was found + +## Remove a range of entries + +You can remove a range of entry ids from id1-id2 using the following command. + + $ varuh -R id1-id2 + +Example: + + $ varuh -R 1-4 + +This will remove entries from 1 to 4 inclusive, asking for confirmation from the user every time. + +## Removal without confirmation + +If you are very sure, you can avoid the confirmation prompt by passing the `-y` flag which will remove the entry without confirmation. + + $ varuh -R 2 -y + ... + ... + ... + Entry with id 2 was removed from the database ## Switch to a new database Once a database is active, creating another one automatically encrypts the current one and makes the new one the active database. The automatic encryption happens only if the configuration flag `auto_encrypt` is turned on (See section [Configuration](#configuration) below). - $ varuh -I mysecrets - Encrytping current database - /home/anand/mypasswds - Password: - Password again: - Encryption complete. - Created new database - mysecrets - Updating active db path - /home/anand/mysecrets + $ varuh -I mysecrets + Encrytping current database - /home/anand/mypasswds + Password: + Password again: + Encryption complete. + Created new database - mysecrets + Updating active db path - /home/anand/mysecrets The previous database is now encrypted with the configured block cipher using the password. Please make sure you remember the password. @@ -230,48 +338,67 @@ The previous database is now encrypted with the configured block cipher using th If you want to switch back to a previous database, you can use the `-U` option. The same process is repeated with the current database getting encrypted and the older one getting decrypted. - $ varuh -U mypasswds - Encrypting current active database - /home/anand/mysecrets - Password: - Password again: - Encryption complete. - Database /home/anand/mypasswds is encrypted, decrypting it - Password: + $ varuh -U mypasswds + Encrypting current active database - /home/anand/mysecrets + Password: + Password again: + Encryption complete. + Database /home/anand/mypasswds is encrypted, decrypting it + Password: + Decryption complete. + Switched active database successfully. + +## Database Migration + +(New in version 0.4) + +When new features are added - sometimes new fields would be required to be added in the database schema. To make sure your old databases work with the new features in such cases, the `-m/--migrate` option can be used to migrate your existing databases. + + $ ./varuh -m /home/anand/mypasswds + Password: Decryption complete. - Switched active database successfully. - + Migrating tables ... + + Encryption complete. + Migration successful. + +For migration you need to provide the database path - even for the active database. Once migrated, you can continue to use your database as before. + +NOTE: It is suggested to make a backup copy of your current active database before migration. + ## Manual encryption and decryption You can manually encrypt the current database using the `-e` option. - $ varuh -e - Password: - Password again: - Encryption complete. + $ varuh -e + Password: + Password again: + Encryption complete. Note that once you encrypt the active database, you cannot use the listings any more unless it is decrypted. - $ varuh -l 2 - No decrypted active database found. + $ varuh -l 2 + No decrypted active database found. Manually decrypt the database using `-d` option. - $ varuh -d mypasswds - Password: - Decryption complete. + $ varuh -d mypasswds + Password: + Decryption complete. Now the database is active again and you can see the listings. - $ varuh -l 2 - +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ - ID: 2 - Title: My Blog Login - User: myblog.name - URL: http://meblog - Password: ********* - Notes: Website uses Apache - Modified: 2021-21-09 23:21:32 - +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ + $ varuh -l 3 + >>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>> + ID: 2 + Title: My Blog Login + User: myblog.name + URL: http://meblog + Password: ********* + Tags: test testing website + Notes: Website uses Apache + Modified: 2021-21-09 23:21:32 + >>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>> ## Always on encryption @@ -279,20 +406,21 @@ If the config param `encrypt_on` is set to `true` along with `auto_encrypt` (def ### Example - $ varuh -f my -s - Password: - Decryption complete. - +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ - ID: 2 - Title: MY LOCAL BANK - User: banklogin - URL: https://my.localbank.com - Password: bankpass123 - Notes: - Modified: 2021-21-18 12:44:10 - +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ - - Encryption complete. + $ varuh -f my -s + Password: + Decryption complete. + >>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>> + ID: 2 + Title: MY LOCAL BANK + User: banklogin + URL: https://my.localbank.com + Password: bankpass123 + Tags: bank banking finance + Notes: + Modified: 2021-21-18 12:44:10 + >>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>> + + Encryption complete. In this mode, your data is provided maximum safety as the database remains decrypted only for a short while on the disk while the data is being read and once done is encrypted back again. @@ -303,79 +431,98 @@ Listing and Searching To list an entry using its id, - $ varuh -l 8 - +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ - ID: 8 - Title: Google account - User: anandpillai@alumni.iitm.ac.in - URL: - Password: *********** - Notes: - Modified: 2021-21-25 15:02:50 - +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ + $ varuh -l 8 + >>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>> + ID: 8 + Title: Google account + User: anandpillai@alumni.iitm.ac.in + URL: + Password: *********** + Notes: + Modified: 2021-21-25 15:02:50 + >>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>> ## To search an entry An entry can be searched on its title, username, URL or notes. Search is case-insensitive. - $ varuh -f google - +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ - ID: 8 - Title: Google account - User: anandpillai@alumni.iitm.ac.in - URL: - Password: ********** - Notes: - Modified: 2021-21-25 15:02:50 - +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ - ID: 9 - Title: Google account - User: xyz@gmail.com - URL: - Password: ******** - Notes: - Modified: 2021-21-25 15:05:36 - +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ - ID: 10 - Title: Google account - User: somethingaboutme@gmail.com - URL: - Password: *********** - Notes: - Modified: 2021-21-25 15:09:51 - +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ + $ varuh -f google + >>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>> + ID: 8 + Title: Google account + User: anandpillai@alumni.iitm.ac.in + URL: + Password: ********** + Notes: + Modified: 2021-21-25 15:02:50 + >>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>> + ID: 9 + Title: Google account + User: xyz@gmail.com + URL: + Password: ******** + Notes: + Modified: 2021-21-25 15:05:36 + >>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>> + ID: 10 + Title: Google account + User: somethingaboutme@gmail.com + URL: + Password: *********** + Notes: + Modified: 2021-21-25 15:09:51 + >>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>> + + +## To search using multiple terms + +The `-f` option supports multiple terms, so you can specify this more than one time to narrow a search down to a specific entry. + + $ varuh -f google -f anand + >>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>> + ID: 8 + Title: Google account + User: anandpillai@alumni.iitm.ac.in + URL: + Password: ********** + Notes: + Modified: 2021-21-25 15:02:50 + >>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>> + + $ varuh -f google -f priya + Entry for "google priya" not found ## To list all entries To list all entries, use the option `-a`. - $ varuh -a - +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ - ID: 1 - Title: My Bank #1 - User: myusername1 - URL: https://mysuperbank1.com - Password: *********** - Notes: - Modified: 2021-21-15 15:40:29 - +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ - ID: 2 - Title: My Digital Locker #1 - User: mylockerusername - URL: https://mysuperlocker1.com - Password: ********** - Notes: - Modified: 2021-21-18 12:44:10 - +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ - ID: 3 - Title: My Bank Login #2 - User: mybankname2 - URL: https://myaveragebank.com - Password: ********** - Notes: - Modified: 2021-21-19 14:16:33 - +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ - ... + $ varuh -a + >>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>> + ID: 1 + Title: My Bank #1 + User: myusername1 + URL: https://mysuperbank1.com + Password: *********** + Notes: + Modified: 2021-21-15 15:40:29 + >>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>> + ID: 2 + Title: My Digital Locker #1 + User: mylockerusername + URL: https://mysuperlocker1.com + Password: ********** + Notes: + Modified: 2021-21-18 12:44:10 + >>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>> + ID: 3 + Title: My Bank Login #2 + User: mybankname2 + URL: https://myaveragebank.com + Password: ********** + Notes: + Modified: 2021-21-19 14:16:33 + >>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>> + ... By default the listing is in ascending ID order. This can be changed in the configuration (see below). @@ -383,10 +530,16 @@ By default the listing is in ascending ID order. This can be changed in the conf To turn on visible passwords, modify the configuration setting (see below) or use the `-s` flag. +## Copy password to clipboard + +To copy a password to clipboard, use the `-c` or `--copy` flag. This works *only if* the result for a listing is single. For example this will work when listing an entry by id or when a search results in a single hit. It *will not work* when listing all entries or when a search results in multiple hits. + +This is useful to copy the password to a password input field in the browser for example. + ## See current active database path - $ varuh -p - /home/anand/mypasswds + $ varuh -p + /home/anand/mypasswds Export ====== @@ -400,37 +553,55 @@ Export To export use the `-x` option. The type of file is automatically figured out from the filename extension. - $ varuh -x passwds.csv - !WARNING: Passwords are stored in plain-text! - Exported 14 records to passwds.csv . - Exported to passwds.csv. + $ varuh -x passwds.csv + !WARNING: Passwords are stored in plain-text! + Exported 14 records to passwds.csv . + Exported to passwds.csv. - $ varuh -x passwds.html - Exported to passwds.html. + $ varuh -x passwds.html + Exported to passwds.html. PDF export is supported if `pandoc` is installed along with the required `pdflatex` packages. The following command (on `Debian` and derived systems) should install the required dependencies. - $ sudo apt-get install pandoc texlive-latex-base texlive-fonts-recommended texlive-fonts-extra texlive-latex-extra texlive-xetex lmodern -y + $ sudo apt-get install pandoc texlive-latex-base texlive-fonts-recommended texlive-fonts-extra texlive-latex-extra texlive-xetex lmodern -y Then, - $ varuh -x passwds.pdf - pdftk not found, PDF won't be secure! + $ varuh -x passwds.pdf + pdftk not found, PDF won't be secure! - File passwds.pdf created without password. - Exported to passwds.pdf. + File passwds.pdf created without password. + Exported to passwds.pdf. PDF files are exported in landscape mode with 150 dpi and 600 columns. To avoid the data not fitting into one page the fields `Notes` and `URL` are not exported. If `pdftk` is installed, the PDF files will be encrypted with an (optional) password. - $ sudo apt-get install pdftk -y + $ sudo apt-get install pdftk -y + + $ varuh -x passwds.pdf + PDF Encryption Password: ****** + File passwds.pdf created without password. + Added password to passwds.pdf. + Exported to passwds.pdf. + +Misc +==== + +The following miscellaneous actions are supported. - $ varuh -x passwds.pdf - PDF Encryption Password: ****** - File passwds.pdf created without password. - Added password to passwds.pdf. - Exported to passwds.pdf. +Generate a strong password of length ranging from 12 - 16. + +A `strong` password is defined as a cryptographically secure string contaning at least one upper-case letter, one punctuation character and one number. + + $ varuh -g + 7%zv/uzIgpqexJ + + By passing the `-c` option, the password is also copied to the clipboard. + + $ varuh -g -c + y6UpD$~uBI#8 + Password copied to clipboard Configuration @@ -440,19 +611,19 @@ Configuration The config file is named *config.json*. It looks as follows. - `{ - "active_db": "/home/anand/mypasswds", - "cipher": "aes", - "auto_encrypt": true, - "visible_passwords": false, - "encrypt_on": true, - "path": "/home/anand/.config/varuh/config.json", - "list_order": "id,asc", - "delimiter": "+", - "color": "default", - "bgcolor": "bgblack" - } - ` + `{ + "active_db": "/home/anand/mypasswds", + "cipher": "aes", + "auto_encrypt": true, + "visible_passwords": false, + "encrypt_on": true, + "path": "/home/anand/.config/varuh/config.json", + "list_order": "id,asc", + "delimiter": ">", + "color": "default", + "bgcolor": "bgblack" + } + ` You can modify the following variables. 1. `auto_encrypt` - Set this to true to enable automatic encryption/decryption when switching databases. Otherwise you have to do this manually. The default is `true`. @@ -465,7 +636,7 @@ You can modify the following variables. * `title` - Uses the `Title` field. * `username` - Uses the `User` field. - Always specify this configuration as `,`. Supported `` values are `asc` and `desc`. + Always specify this configuration as `,`. Supported `` values are `asc` and `desc`. 1. `delimiter` - This modifies the delimiter string when printing a listing. Only one character is allowed. 1. `color` - The foreground color of the text when printing listings. 1. `bgcolor` - The background color of the text when printing listings. @@ -483,8 +654,3 @@ Feedback ======== Please send your valuable feedback and suggestions to my email available in the program's usage listing. - -Thanks -====== - -Thanks to [Niko Rosvall](https://www.nrosvall.name/) for writing the excellent ylva and inspiring me to write this program. diff --git a/actions.go b/actions.go index 7ea2c2b..7b024b4 100644 --- a/actions.go +++ b/actions.go @@ -3,16 +3,106 @@ package main import ( "bufio" - "encoding/csv" "errors" "fmt" + "gorm.io/gorm" "os" - "os/exec" + "os/signal" "path/filepath" "strconv" "strings" + "syscall" ) +type CustomEntry struct { + fieldName string + fieldValue string +} + +// Wrappers (closures) for functions accepting strings as input for in/out encryption +func WrapperMaxKryptStringFunc(fn actionFunc) actionFunc { + + return func(inputStr string) error { + var maxKrypt bool + var defaultDB string + var encPasswd string + var err error + + maxKrypt, defaultDB = isActiveDatabaseEncryptedAndMaxKryptOn() + + // If max krypt on - then autodecrypt on call and auto encrypt after call + if maxKrypt { + err, encPasswd = decryptDatabase(defaultDB) + if err != nil { + return err + } + + sigChan := make(chan os.Signal, 1) + signal.Notify(sigChan, syscall.SIGINT, syscall.SIGTERM, syscall.SIGHUP) + + go func() { + sig := <-sigChan + fmt.Println("Received signal", sig) + // Reencrypt + encryptDatabase(defaultDB, &encPasswd) + os.Exit(1) + }() + } + + err = fn(inputStr) + + // If max krypt on - then autodecrypt on call and auto encrypt after call + if maxKrypt { + encryptDatabase(defaultDB, &encPasswd) + } + + return err + } + +} + +// Wrappers (closures) for functions accepting no input for in/out encryption +func WrapperMaxKryptVoidFunc(fn voidFunc) voidFunc { + + return func() error { + var maxKrypt bool + var defaultDB string + var encPasswd string + var err error + + maxKrypt, defaultDB = isActiveDatabaseEncryptedAndMaxKryptOn() + + // If max krypt on - then autodecrypt on call and auto encrypt after call + if maxKrypt { + err, encPasswd = decryptDatabase(defaultDB) + if err != nil { + return err + } + + sigChan := make(chan os.Signal, 1) + signal.Notify(sigChan, syscall.SIGINT, syscall.SIGTERM, syscall.SIGHUP) + + go func() { + sig := <-sigChan + fmt.Println("Received signal", sig) + // Reencrypt + encryptDatabase(defaultDB, &encPasswd) + os.Exit(1) + }() + } + + err = fn() + + // If max krypt on - then autodecrypt on call and auto encrypt after call + if maxKrypt { + encryptDatabase(defaultDB, &encPasswd) + } + + return err + } + +} + // Print the current active database path func showActiveDatabasePath() error { @@ -84,14 +174,19 @@ func setActiveDatabasePath(dbPath string) error { } if newEncrypted { - // Decrypt new database if it is encrypted - fmt.Printf("Database %s is encrypted, decrypting it\n", fullPath) - err, _ = decryptDatabase(fullPath) - if err != nil { - fmt.Printf("Decryption Error - \"%s\", not switching databases\n", err.Error()) - return err + if !settings.AutoEncrypt { + // Decrypt new database if it is encrypted + fmt.Printf("Database %s is encrypted, decrypting it\n", fullPath) + err, _ = decryptDatabase(fullPath) + if err != nil { + fmt.Printf("Decryption Error - \"%s\", not switching databases\n", err.Error()) + return err + } else { + newEncrypted = false + } } else { - newEncrypted = false + // New database is encrypted and autoencrypt is set - so keep it like that + // fmt.Printf("Database %s is already encrypted, nothing to do\n", fullPath) } } } @@ -102,7 +197,7 @@ func setActiveDatabasePath(dbPath string) error { return nil } - if newEncrypted { + if newEncrypted && !settings.AutoEncrypt { // Use should manually decrypt before switching fmt.Println("Auto-encrypt disabled, decrypt new database manually before switching.") return nil @@ -124,6 +219,84 @@ func setActiveDatabasePath(dbPath string) error { } } +// Text menu driven function to add a new entry for a card type +func addNewCardEntry() error { + + var cardHolder string + var cardName string + var cardNumber string + var cardCvv string + var cardPin string + var cardIssuer string + var cardClass string + var cardExpiry string + + var notes string + var tags string + var err error + var customEntries []CustomEntry + + if err = checkActiveDatabase(); err != nil { + return err + } + + reader := bufio.NewReader(os.Stdin) + cardNumber = readInput(reader, "Card Number") + cardClass, err = detectCardType(cardNumber) + + if err != nil { + fmt.Printf("Error - %s\n", err.Error()) + return err + } else { + fmt.Printf("\n", cardClass) + } + + cardHolder = readInput(reader, "Name on the Card") + cardExpiry = readInput(reader, "Expiry Date as mm/dd") + + // expiry has to be in the form of / + if !checkValidExpiry(cardExpiry) { + return errors.New("Invalid Expiry Date") + } + + fmt.Printf("CVV: ") + err, cardCvv = readPassword() + + if !validateCvv(cardCvv, cardClass) { + fmt.Printf("\nError - Invalid CVV for %s\n", cardClass) + return errors.New(fmt.Sprintf("Error - Invalid CVV for %s\n", cardClass)) + } + + fmt.Printf("\nCard PIN: ") + err, cardPin = readPassword() + + if !validateCardPin(cardPin) { + fmt.Printf("\n") + } + + cardIssuer = readInput(reader, "\nIssuing Bank") + cardName = readInput(reader, "A name for this Card") + // Name cant be blank + if len(cardName) == 0 { + fmt.Printf("Error - name cant be blank") + return errors.New("Empty card name") + } + + tags = readInput(reader, "\nTags (separated by space): ") + notes = readInput(reader, "Notes") + + customEntries = addCustomFields(reader) + + err = addNewDatabaseCardEntry(cardName, cardNumber, cardHolder, cardIssuer, + cardClass, cardCvv, cardPin, cardExpiry, notes, tags, customEntries) + + if err != nil { + fmt.Printf("Error adding entry - \"%s\"\n", err.Error()) + } + + return err +} + // Text menu driven function to add a new entry func addNewEntry() error { @@ -132,29 +305,21 @@ func addNewEntry() error { var url string var notes string var passwd string + var tags string var err error - var maxKrypt bool - var encPasswd string - var defaultDB string - - maxKrypt, defaultDB = isActiveDatabaseEncryptedAndMaxKryptOn() - - // If max krypt on - then autodecrypt on call and auto encrypt after call - if maxKrypt { - err, encPasswd = decryptDatabase(defaultDB) - if err != nil { - return err - } - } + var customEntries []CustomEntry if err = checkActiveDatabase(); err != nil { return err } + if settingsRider.Type == "card" { + return addNewCardEntry() + } + reader := bufio.NewReader(os.Stdin) title = readInput(reader, "Title") url = readInput(reader, "URL") - if len(url) > 0 && !strings.HasPrefix(strings.ToLower(url), "http://") && !strings.HasPrefix(strings.ToLower(url), "https://") { url = "http://" + url } @@ -166,12 +331,13 @@ func addNewEntry() error { if len(passwd) == 0 { fmt.Printf("\nGenerating password ...") - err, passwd = generateRandomPassword(16) + err, passwd = generateStrongPassword() fmt.Printf("done") } - // fmt.Printf("Password => %s\n", passwd) + // fmt.Printf("Password => %s\n", passwd) - notes = readInput(reader, "\nNotes") + tags = readInput(reader, "\nTags (separated by space): ") + notes = readInput(reader, "Notes") // Title and username/password are mandatory if len(title) == 0 { @@ -187,19 +353,159 @@ func addNewEntry() error { return errors.New("invalid input") } + customEntries = addCustomFields(reader) + // Trim spaces - err = addNewDatabaseEntry(title, userName, url, passwd, notes) + err = addNewDatabaseEntry(title, userName, url, passwd, tags, notes, customEntries) if err != nil { fmt.Printf("Error adding entry - \"%s\"\n", err.Error()) } - // If max krypt on - then autodecrypt on call and auto encrypt after call - if maxKrypt { - err = encryptDatabase(defaultDB, &encPasswd) + return err +} + +// Function to update existing custom entries and add new ones +// The bool part of the return value indicates whether to take action +func addOrUpdateCustomFields(reader *bufio.Reader, entry *Entry) ([]CustomEntry, bool) { + + var customEntries []ExtendedEntry + var editedCustomEntries []CustomEntry + var newCustomEntries []CustomEntry + var flag bool + + customEntries = getExtendedEntries(entry) + + if len(customEntries) > 0 { + + fmt.Println("Editing/deleting custom fields") + for _, customEntry := range customEntries { + var fieldName string + var fieldValue string + + fmt.Println("Field Name: " + customEntry.FieldName) + fieldName = readInput(reader, "\tNew Field Name (Enter to keep, \"x\" to delete)") + if strings.ToLower(strings.TrimSpace(fieldName)) == "x" { + fmt.Println("Deleting field: " + customEntry.FieldName) + } else { + if strings.TrimSpace(fieldName) == "" { + fieldName = customEntry.FieldName + } + + fmt.Println("Field Value: " + customEntry.FieldValue) + fieldValue = readInput(reader, "\tNew Field Value (Enter to keep)") + if strings.TrimSpace(fieldValue) == "" { + fieldValue = customEntry.FieldValue + } + + editedCustomEntries = append(editedCustomEntries, CustomEntry{fieldName, fieldValue}) + } + } } - return err + newCustomEntries = addCustomFields(reader) + + editedCustomEntries = append(editedCustomEntries, newCustomEntries...) + + // Cases where length == 0 + // 1. Existing entries - all deleted + flag = len(customEntries) > 0 || len(editedCustomEntries) > 0 + + return editedCustomEntries, flag +} + +// Function to add custom fields to an entry +func addCustomFields(reader *bufio.Reader) []CustomEntry { + + // Custom fields + var custom string + var customEntries []CustomEntry + + custom = readInput(reader, "Do you want to add custom fields [y/N]") + if strings.ToLower(custom) == "y" { + + fmt.Println("Keep entering custom field name followed by the value. Press return with no input once done.") + for true { + var customFieldName string + var customFieldValue string + + customFieldName = strings.TrimSpace(readInput(reader, "Field Name")) + if customFieldName != "" { + customFieldValue = strings.TrimSpace(readInput(reader, "Value for "+customFieldName)) + } + + if customFieldName == "" && customFieldValue == "" { + break + } + + customEntries = append(customEntries, CustomEntry{customFieldName, customFieldValue}) + } + } + + return customEntries +} + +// Edit a card entry by id +func editCurrentCardEntry(entry *Entry) error { + var klass string + var err error + var flag bool + var customEntries []CustomEntry + + reader := bufio.NewReader(os.Stdin) + + fmt.Printf("Card Title: %s\n", entry.Title) + title := readInput(reader, "New Card Title") + fmt.Printf("Name on Card: %s\n", entry.User) + name := readInput(reader, "New Name on Card") + fmt.Printf("Card Number: %s\n", entry.Url) + number := readInput(reader, "New Card Number") + if number != "" { + klass, err = detectCardType(number) + + if err != nil { + fmt.Printf("Error - %s\n", err.Error()) + return err + } else { + fmt.Printf("\n", klass) + } + } + + fmt.Printf("Card CVV: %s\n", entry.Password) + fmt.Printf("New Card CVV: ") + err, cvv := readPassword() + + if cvv != "" && !validateCvv(cvv, klass) { + fmt.Printf("\nError - Invalid CVV for %s\n", klass) + return errors.New(fmt.Sprintf("Error - Invalid CVV for %s\n", klass)) + } + fmt.Printf("\nCard PIN: %s\n", entry.Pin) + fmt.Printf("New Card PIN: ") + err, pin := readPassword() + + if pin != "" && !validateCardPin(pin) { + fmt.Printf("\n") + } + fmt.Printf("\nCard Expiry Date: %s\n", entry.ExpiryDate) + expiryDate := readInput(reader, "New Card Expiry Date (as mm/dd): ") + // expiry has to be in the form of / + if expiryDate != "" && !checkValidExpiry(expiryDate) { + return errors.New("Invalid Expiry Date") + } + tags := readInput(reader, "\nTags (separated by space): ") + notes := readInput(reader, "Notes") + + customEntries, flag = addOrUpdateCustomFields(reader, entry) + + // Update + err = updateDatabaseCardEntry(entry, title, number, name, + klass, cvv, pin, expiryDate, notes, tags, customEntries, flag) + + if err != nil { + fmt.Printf("Error adding entry - \"%s\"\n", err.Error()) + } + + return nil } // Edit a current entry by id @@ -209,24 +515,12 @@ func editCurrentEntry(idString string) error { var title string var url string var notes string + var tags string var passwd string var err error var entry *Entry - var maxKrypt bool - var defaultDB string - var encPasswd string var id int - maxKrypt, defaultDB = isActiveDatabaseEncryptedAndMaxKryptOn() - - // If max krypt on - then autodecrypt on call and auto encrypt after call - if maxKrypt { - err, encPasswd = decryptDatabase(defaultDB) - if err != nil { - return err - } - } - if err = checkActiveDatabase(); err != nil { return err } @@ -239,6 +533,10 @@ func editCurrentEntry(idString string) error { return err } + if entry.Type == "card" { + return editCurrentCardEntry(entry) + } + reader := bufio.NewReader(os.Stdin) fmt.Printf("Current Title: %s\n", entry.Title) @@ -260,24 +558,24 @@ func editCurrentEntry(idString string) error { if strings.ToLower(passwd) == "y" { fmt.Printf("\nGenerating new password ...") - err, passwd = generateRandomPassword(16) + err, passwd = generateStrongPassword() } - // fmt.Printf("Password => %s\n", passwd) + // fmt.Printf("Password => %s\n", passwd) + + fmt.Printf("\nCurrent Tags: %s\n", entry.Tags) + tags = readInput(reader, "New Tags") fmt.Printf("\nCurrent Notes: %s\n", entry.Notes) notes = readInput(reader, "New Notes") + customEntries, flag := addOrUpdateCustomFields(reader, entry) + // Update - err = updateDatabaseEntry(entry, title, userName, url, passwd, notes) + err = updateDatabaseEntry(entry, title, userName, url, passwd, tags, notes, customEntries, flag) if err != nil { fmt.Printf("Error updating entry - \"%s\"\n", err.Error()) } - // If max krypt on - then autodecrypt on call and auto encrypt after call - if maxKrypt { - err = encryptDatabase(defaultDB, &encPasswd) - } - return err } @@ -287,19 +585,6 @@ func listCurrentEntry(idString string) error { var id int var err error var entry *Entry - var maxKrypt bool - var defaultDB string - var passwd string - - maxKrypt, defaultDB = isActiveDatabaseEncryptedAndMaxKryptOn() - - // If max krypt on - then autodecrypt on call and auto encrypt after call - if maxKrypt { - err, passwd = decryptDatabase(defaultDB) - if err != nil { - return err - } - } if err = checkActiveDatabase(); err != nil { return err @@ -307,7 +592,7 @@ func listCurrentEntry(idString string) error { id, _ = strconv.Atoi(idString) - // fmt.Printf("Listing current entry - %d\n", id) + // fmt.Printf("Listing current entry - %d\n", id) err, entry = getEntryById(id) if err != nil || entry == nil { fmt.Printf("No entry found for id %d\n", id) @@ -316,9 +601,9 @@ func listCurrentEntry(idString string) error { err = printEntry(entry, true) - // If max krypt on - then autodecrypt on call and auto encrypt after call - if maxKrypt { - err = encryptDatabase(defaultDB, &passwd) + if err == nil && settingsRider.CopyPassword { + // fmt.Printf("Copying password " + entry.Password + " to clipboard\n") + copyPasswordToClipboard(entry.Password) } return err @@ -358,7 +643,8 @@ func listAllEntries() error { if err == nil { if len(entries) > 0 { - fmt.Println("=====================================================================") + fmt.Printf("%s", getColor(strings.ToLower(settings.Color))) + printDelim(settings.Delim, settings.Color) for _, entry := range entries { printEntry(&entry, false) } @@ -383,45 +669,69 @@ func findCurrentEntry(term string) error { var err error var entries []Entry - var maxKrypt bool - var defaultDB string - var passwd string - - maxKrypt, defaultDB = isActiveDatabaseEncryptedAndMaxKryptOn() - - // If max krypt on - then autodecrypt on call and auto encrypt after call - if maxKrypt { - err, passwd = decryptDatabase(defaultDB) - if err != nil { - return err - } - } + var terms []string if err = checkActiveDatabase(); err != nil { return err } - err, entries = searchDatabaseEntry(term) + terms = strings.Split(term, " ") + + err, entries = searchDatabaseEntries(terms, "AND") if err != nil || len(entries) == 0 { fmt.Printf("Entry for query \"%s\" not found\n", term) return err } else { var delim bool + var pcopy bool if len(entries) == 1 { delim = true + pcopy = true + // Single entry means copy password can be enabled } else { - fmt.Println("=====================================================================") + _, settings := getOrCreateLocalConfig(APP) + fmt.Printf("%s", getColor(strings.ToLower(settings.Color))) + printDelim(settings.Delim, settings.Color) } for _, entry := range entries { printEntry(&entry, delim) } + + if pcopy && settingsRider.CopyPassword { + // Single entry + copyPasswordToClipboard(entries[0].Password) + } } - // If max krypt on - then autodecrypt on call and auto encrypt after call - if maxKrypt { - err = encryptDatabase(defaultDB, &passwd) + return err +} + +// Remove a range of entries - say 10-14 +func removeMultipleEntries(idRangeEntry string) error { + + var err error + var idRange []string + var id1, id2 int + + idRange = strings.Split(idRangeEntry, "-") + + if len(idRange) != 2 { + fmt.Println("Invalid id range - " + idRangeEntry) + return errors.New("Invalid id range - " + idRangeEntry) + } + + id1, _ = strconv.Atoi(idRange[0]) + id2, _ = strconv.Atoi(idRange[1]) + + if id1 >= id2 { + fmt.Println("Invalid id range - " + idRangeEntry) + return errors.New("Invalid id range - " + idRangeEntry) + } + + for idNum := id1; idNum <= id2; idNum++ { + err = removeCurrentEntry(fmt.Sprintf("%d", idNum)) } return err @@ -433,24 +743,16 @@ func removeCurrentEntry(idString string) error { var err error var entry *Entry var id int - var maxKrypt bool - var defaultDB string - var passwd string - - maxKrypt, defaultDB = isActiveDatabaseEncryptedAndMaxKryptOn() - - // If max krypt on - then autodecrypt on call and auto encrypt after call - if maxKrypt { - err, passwd = decryptDatabase(defaultDB) - if err != nil { - return err - } - } + var response string if err = checkActiveDatabase(); err != nil { return err } + if strings.Contains(idString, "-") { + return removeMultipleEntries(idString) + } + id, _ = strconv.Atoi(idString) err, entry = getEntryById(id) @@ -459,15 +761,22 @@ func removeCurrentEntry(idString string) error { return err } - // Drop from the database - err = removeDatabaseEntry(entry) - if err == nil { - fmt.Printf("Entry with id %d was removed from the database\n", id) + printEntryMinimal(entry, true) + + if !settingsRider.AssumeYes { + response = readInput(bufio.NewReader(os.Stdin), "Please confirm removal [Y/n]: ") + } else { + response = "y" } - // If max krypt on - then autodecrypt on call and auto encrypt after call - if maxKrypt { - err = encryptDatabase(defaultDB, &passwd) + if strings.ToLower(response) != "n" { + // Drop from the database + err = removeDatabaseEntry(entry) + if err == nil { + fmt.Printf("Entry with id %d was removed from the database\n", id) + } + } else { + fmt.Println("Removal of entry cancelled by user.") } return err @@ -478,20 +787,10 @@ func copyCurrentEntry(idString string) error { var err error var entry *Entry - var id int - var maxKrypt bool - var defaultDB string - var passwd string - - maxKrypt, defaultDB = isActiveDatabaseEncryptedAndMaxKryptOn() + var entryNew *Entry + var exEntries []ExtendedEntry - // If max krypt on - then autodecrypt on call and auto encrypt after call - if maxKrypt { - err, passwd = decryptDatabase(defaultDB) - if err != nil { - return err - } - } + var id int if err = checkActiveDatabase(); err != nil { return err @@ -505,15 +804,22 @@ func copyCurrentEntry(idString string) error { return err } - err, _ = cloneEntry(entry) + err, entryNew = cloneEntry(entry) if err != nil { fmt.Printf("Error cloning entry: \"%s\"\n", err.Error()) return err } - // If max krypt on - then autodecrypt on call and auto encrypt after call - if maxKrypt { - err = encryptDatabase(defaultDB, &passwd) + exEntries = getExtendedEntries(entry) + + if len(exEntries) > 0 { + fmt.Printf("%d extended entries found\n", len(exEntries)) + + err = cloneExtendedEntries(entryNew, exEntries) + if err != nil { + fmt.Printf("Error cloning extended entries: \"%s\"\n", err.Error()) + return err + } } return err @@ -551,11 +857,11 @@ func encryptDatabase(dbPath string, givenPasswd *string) error { } if len(passwd) == 0 { - fmt.Printf("Password: ") + fmt.Printf("Encryption Password: ") err, passwd = readPassword() if err == nil { - fmt.Printf("\nPassword again: ") + fmt.Printf("\nEncryption Password again: ") err, passwd2 = readPassword() if err == nil { if passwd != passwd2 { @@ -571,7 +877,7 @@ func encryptDatabase(dbPath string, givenPasswd *string) error { } } - // err = encryptFileAES(dbPath, passwd) + // err = encryptFileAES(dbPath, passwd) _, settings := getOrCreateLocalConfig(APP) switch settings.Cipher { @@ -603,7 +909,7 @@ func decryptDatabase(dbPath string) (error, string) { return err, "" } - fmt.Printf("Password: ") + fmt.Printf("Decryption Password: ") err, passwd = readPassword() if err != nil { @@ -624,378 +930,61 @@ func decryptDatabase(dbPath string) (error, string) { } if err == nil { - fmt.Println("\nDecryption complete.") + fmt.Println("...decryption complete.") } return err, passwd } -// Export data to a varity of file types -func exportToFile(fileName string) error { +// Migrate an existing database to the new schema +func migrateDatabase(dbPath string) error { var err error - var maxKrypt bool - var defaultDB string + var flag bool var passwd string + var db *gorm.DB - maxKrypt, defaultDB = isActiveDatabaseEncryptedAndMaxKryptOn() - - ext := strings.ToLower(filepath.Ext(fileName)) - - if ext == ".csv" || ext == ".md" || ext == ".html" || ext == ".pdf" { - // If max krypt on - then autodecrypt on call and auto encrypt after call - if maxKrypt { - err, passwd = decryptDatabase(defaultDB) - if err != nil { - return err - } - } - } - - switch ext { - case ".csv": - err = exportToCsv(fileName) - case ".md": - err = exportToMarkdown(fileName) - case ".html": - err = exportToHTML(fileName) - case ".pdf": - err = exportToPDF(fileName) - default: - return fmt.Errorf("format %s not supported", ext) - } - - if err != nil { - fmt.Printf("Error exporting to \"%s\" - \"%s\"\n", fileName, err.Error()) + if _, err = os.Stat(dbPath); os.IsNotExist(err) { + fmt.Printf("Error - path %s does not exist\n", dbPath) return err - } else { - if _, err = os.Stat(fileName); err == nil { - fmt.Printf("Exported to %s.\n", fileName) - // Chmod 600 - os.Chmod(fileName, 0600) - - // If max krypt on - then autodecrypt on call and auto encrypt after call - if maxKrypt { - err = encryptDatabase(defaultDB, &passwd) - } - - return err - } - } - - return err -} - -// Export current database to markdown -func exportToMarkdown(fileName string) error { - - var err error - var dataArray [][]string - var fh *os.File - var maxLengths [7]int - var headers []string = []string{" ID ", " Title ", " User ", " URL ", " Password ", " Notes ", " Modified "} - - err, dataArray = entriesToStringArray(false) - - if err != nil { - fmt.Printf("Error exporting entries to string array - \"%s\"\n", err.Error()) - return err - } - - for _, record := range dataArray { - for idx, field := range record { - - if len(field) > maxLengths[idx] { - maxLengths[idx] = len(field) - } - } - } - - // fmt.Printf("%+v\n", maxLengths) - fh, err = os.Create(fileName) - if err != nil { - fmt.Printf("Cannt open \"%s\" for writing - \"%s\"\n", fileName, err.Error()) - return err - } - - defer fh.Close() - - writer := bufio.NewWriter(fh) - - // Write markdown header - for idx, length := range maxLengths { - delta := length - len(headers[idx]) - // fmt.Printf("%d\n", delta) - if delta > 0 { - for i := 0; i < delta+2; i++ { - headers[idx] += " " - } - } - } - - writer.WriteString(" |" + strings.Join(headers, "|") + "|\n") - - // Write line separator - writer.WriteString(" | ") - for _, length := range maxLengths { - - for i := 0; i < length; i++ { - writer.WriteString("-") - } - writer.WriteString(" | ") - } - writer.WriteString("\n") - - // Write records - for _, record := range dataArray { - writer.WriteString(" | ") - for _, field := range record { - writer.WriteString(field + " | ") - } - writer.WriteString("\n") } - writer.Flush() - - return nil - -} - -// This needs pandoc and pdflatex support -func exportToPDF(fileName string) error { - - var err error - var tmpFile string - var passwd string - var pdfTkFound bool - - cmd := exec.Command("which", "pandoc") - if _, err = cmd.Output(); err != nil { - return errors.New("pandoc not found") - } - - cmd = exec.Command("which", "pdftk") - if _, err = cmd.Output(); err != nil { - fmt.Printf("pdftk not found, PDF won't be secure!\n") - } else { - pdfTkFound = true - } - - if pdfTkFound { - fmt.Printf("PDF Encryption Password: ") - err, passwd = readPassword() - } - - tmpFile = randomFileName(os.TempDir(), ".tmp") - // fmt.Printf("Temp file => %s\n", tmpFile) - err = exportToMarkdownLimited(tmpFile) - - if err == nil { - var args []string = []string{"-o", fileName, "-f", "markdown", "-V", "geometry:landscape", "--columns=600", "--pdf-engine", "xelatex", "--dpi=150", tmpFile} - - cmd = exec.Command("pandoc", args...) - _, err = cmd.Output() - // Remove tmpfile - os.Remove(tmpFile) - - // If the file is generated, encrypt it if pdfTkFound - if _, err = os.Stat(fileName); err == nil { - fmt.Printf("\nFile %s created without password.\n", fileName) - - if pdfTkFound && len(passwd) > 0 { - tmpFile = randomFileName(".", ".pdf") - // fmt.Printf("pdf file => %s\n", tmpFile) - args = []string{fileName, "output", tmpFile, "user_pw", passwd} - cmd = exec.Command("pdftk", args...) - _, err = cmd.Output() - - if err == nil { - // Copy over - fmt.Printf("Added password to %s.\n", fileName) - os.Remove(fileName) - err = os.Rename(tmpFile, fileName) - } else { - fmt.Printf("Error adding password to pdf - \"%s\"\n", err.Error()) - } - } + if err, flag = isFileEncrypted(dbPath); flag { + err, passwd = decryptDatabase(dbPath) + if err != nil { + fmt.Printf("Error decrypting - %s: %s\n", dbPath, err.Error()) + return err } } - return err - -} - -// Export current database to markdown minus the long fields -func exportToMarkdownLimited(fileName string) error { - - var err error - var dataArray [][]string - var fh *os.File - var maxLengths [5]int - var headers []string = []string{" ID ", " Title ", " User ", " Password ", " Modified "} - - err, dataArray = entriesToStringArray(true) + err, db = openDatabase(dbPath) if err != nil { - fmt.Printf("Error exporting entries to string array - \"%s\"\n", err.Error()) + fmt.Printf("Error opening database path - %s: %s\n", dbPath, err.Error()) return err } - for _, record := range dataArray { - for idx, field := range record { + fmt.Println("Migrating tables ...") + err = db.AutoMigrate(&Entry{}) - if len(field) > maxLengths[idx] { - maxLengths[idx] = len(field) - } - } - } - - // fmt.Printf("%+v\n", maxLengths) - fh, err = os.Create(fileName) if err != nil { - fmt.Printf("Cannt open \"%s\" for writing - \"%s\"\n", fileName, err.Error()) + fmt.Printf("Error migrating table \"entries\" - %s: %s\n", dbPath, err.Error()) return err } - defer fh.Close() - - writer := bufio.NewWriter(fh) - - // Write markdown header - for idx, length := range maxLengths { - delta := length - len(headers[idx]) - // fmt.Printf("%d\n", delta) - if delta > 0 { - for i := 0; i < delta+2; i++ { - headers[idx] += " " - } - } - } - - writer.WriteString(" |" + strings.Join(headers, "|") + "|\n") - - // Write line separator - writer.WriteString(" | ") - for _, length := range maxLengths { - - for i := 0; i < length; i++ { - writer.WriteString("-") - } - writer.WriteString(" | ") - } - writer.WriteString("\n") - - // Write records - for _, record := range dataArray { - writer.WriteString(" | ") - for _, field := range record { - writer.WriteString(field + " | ") - } - writer.WriteString("\n") - } - - writer.Flush() - - return nil - -} - -// Export current database to html -func exportToHTML(fileName string) error { - - var err error - var dataArray [][]string - var fh *os.File - var headers []string = []string{" ID ", " Title ", " User ", " URL ", " Password ", " Notes ", " Modified "} - - err, dataArray = entriesToStringArray(false) + err = db.AutoMigrate(&ExtendedEntry{}) if err != nil { - fmt.Printf("Error exporting entries to string array - \"%s\"\n", err.Error()) + fmt.Printf("Error migrating table \"exentries\" - %s: %s\n", dbPath, err.Error()) return err } - // fmt.Printf("%+v\n", maxLengths) - fh, err = os.Create(fileName) - if err != nil { - fmt.Printf("Cannt open \"%s\" for writing - \"%s\"\n", fileName, err.Error()) - return err - } - - defer fh.Close() - - writer := bufio.NewWriter(fh) - - writer.WriteString("\n") - writer.WriteString("\n") - writer.WriteString("\n") - - for _, h := range headers { - writer.WriteString(fmt.Sprintf("", h)) - } - writer.WriteString("\n") - writer.WriteString("\n") - - // Write records - for _, record := range dataArray { - writer.WriteString("") - for _, field := range record { - writer.WriteString(fmt.Sprintf("", field)) - } - writer.WriteString("\n") - } - writer.WriteString("\n") - writer.WriteString("
%s
%s
\n") - - writer.WriteString("\n") - - writer.Flush() - - return nil - -} - -// Export current database to CSV -func exportToCsv(fileName string) error { - - var err error - var dataArray [][]string - var fh *os.File - - err, dataArray = entriesToStringArray(false) - - if err != nil { - fmt.Printf("Error exporting entries to string array - \"%s\"\n", err.Error()) - return err - } - - fh, err = os.Create(fileName) - if err != nil { - fmt.Printf("Cannt open \"%s\" for writing - \"%s\"\n", fileName, err.Error()) - return err - } - - writer := csv.NewWriter(fh) - - // Write header - writer.Write([]string{"ID", "Title", "User", "URL", "Password", "Notes", "Modified"}) - - for idx, record := range dataArray { - if err = writer.Write(record); err != nil { - fmt.Printf("Error writing record #%d to %s - \"%s\"\n", idx+1, fileName, err.Error()) - break - } - } - - writer.Flush() - - if err != nil { - return err + if flag { + // File was encrypted - encrypt it again + encryptDatabase(dbPath, &passwd) } - os.Chmod(fileName, 0600) - fmt.Printf("!WARNING: Passwords are stored in plain-text!\n") - fmt.Printf("Exported %d records to %s .\n", len(dataArray), fileName) + fmt.Println("Migration successful.") return nil } diff --git a/crypto.go b/crypto.go index af595e6..c1744b7 100644 --- a/crypto.go +++ b/crypto.go @@ -13,7 +13,9 @@ import ( "golang.org/x/crypto/pbkdf2" "io" "math/big" + "math/rand" "os" + "time" "unsafe" crand "crypto/rand" @@ -276,7 +278,7 @@ func decryptFileAES(encDbPath string, password string) error { return err } - err, origFile = rewriteBaseFile(encDbPath, plainText, 0600) + err, origFile = rewriteFile(encDbPath, plainText, 0600) if err != nil { fmt.Printf("Error writing decrypted data to %s - \"%s\"\n", origFile, err.Error()) @@ -423,8 +425,8 @@ func decryptFileXChachaPoly(encDbPath string, password string) error { return err } - // err = os.WriteFile("test.sqlite3", plainText, 0600) - err, origFile = rewriteBaseFile(encDbPath, plainText, 0600) + // err = os.WriteFile("test.sqlite3", oplainText, 0600) + err, origFile = rewriteFile(encDbPath, plainText, 0600) if err != nil { fmt.Printf("Error writing decrypted data to %s - \"%s\"\n", origFile, err.Error()) @@ -435,10 +437,10 @@ func decryptFileXChachaPoly(encDbPath string, password string) error { } // Generate a random password - for adding listings -func generateRandomPassword(length int) (error, string) { +func generatePassword(length int) (error, string) { var data []byte - const source = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789#!+$@~" + const source = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789=+_()$#@!~:/%" data = make([]byte, length) @@ -453,3 +455,97 @@ func generateRandomPassword(length int) (error, string) { return nil, string(data) } + +// Generate a "strong" password +// A strong password is defined as, +// A mix of upper and lower case alphabets +// at least one number [0-9] +// at least one upper case alphabet [A-Z] +// at least one punctuation character +// at least length 12 +func generateStrongPassword() (error, string) { + + var data []byte + var length int + + const sourceAlpha = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ" + const sourceLargeAlpha = "ABCDEFGHIJKLMNOPQRSTUVWXYZ" + const sourceNum = "0123456789" + const sourcePunct = "=+_()$#@!~:/%" + const source = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789=+_()$#@!~:/%" + + // Generate in range 12 - 16 + rand.Seed(time.Now().UnixNano()) + + length = rand.Intn(4) + 12 + + data = make([]byte, length) + + var lengthAlpha int + var i, j, k, l int + + // Alpha chars is at least length 3-5 + lengthAlpha = rand.Intn(2) + 3 + + for i = 0; i < lengthAlpha; i++ { + num, err := crand.Int(crand.Reader, big.NewInt(int64(len(sourceAlpha)))) + if err != nil { + return err, "" + } + + data[i] = sourceAlpha[num.Int64()] + } + + // Add in numbers 1 or 2 + var lengthNum int + + lengthNum = rand.Intn(2) + 1 + + for j = i; j < i+lengthNum; j++ { + num, err := crand.Int(crand.Reader, big.NewInt(int64(len(sourceNum)))) + if err != nil { + return err, "" + } + + data[j] = sourceNum[num.Int64()] + } + + // Add in punctuations 1 or 2 + var lengthPunc int + + lengthPunc = rand.Intn(2) + 1 + + for k = j; k < j+lengthPunc; k++ { + num, err := crand.Int(crand.Reader, big.NewInt(int64(len(sourcePunct)))) + if err != nil { + return err, "" + } + + data[k] = sourcePunct[num.Int64()] + } + + // Fill in the rest + var lengthRem int + + lengthRem = length - k + + if lengthRem > 0 { + for l = k; l < k+lengthRem; l++ { + num, err := crand.Int(crand.Reader, big.NewInt(int64(len(source)))) + if err != nil { + return err, "" + } + + data[l] = source[num.Int64()] + } + } + + // Shuffle a few times + for i = 0; i < 5; i++ { + rand.Shuffle(len(data), func(i, j int) { + data[i], data[j] = data[j], data[i] + }) + } + + return nil, string(data) +} diff --git a/db.go b/db.go index 9992591..37b554b 100644 --- a/db.go +++ b/db.go @@ -16,28 +16,139 @@ import ( // Structure representing an entry in the db type Entry struct { - ID int `gorm:"column:id;autoIncrement;primaryKey"` - Title string `gorm:"column:title"` - User string `gorm:"column:user"` - Url string `gorm:"column:url"` - Password string `gorm:"column:password"` + ID int `gorm:"column:id;autoIncrement;primaryKey"` + Title string `gorm:"column:title"` // For card type this -> Card Name + User string `gorm:"column:user"` // For card type this -> Card Holder Name + Url string `gorm:"column:url"` // For card type this -> Card Number + Password string `gorm:"column:password"` // For card type this -> CVV number + Pin string `gorm:"column:pin"` // For card type this -> card pin + ExpiryDate string `gorm:"colum:expiry_date"` // For card type this -> Card expiry date + Issuer string `gorm:"column:issuer"` // For card type this -> Issuing bank + Class string `gorm:"column:class"` // For card type this -> visa/mastercard/amex etc + Notes string `gorm:"column:notes"` + Tags string `gorm:"column:tags"` + Type string `gorm:"column:type"` // Entry type, default/card/ID Timestamp time.Time `gorm:"type:timestamp;default:(datetime('now','localtime'))"` // sqlite3 + + // ID int `gorm:"column:id;autoIncrement;primaryKey"` + // Type string `gorm:"column:type"` // Type of entry - password (default), card, identity etc + // Title string `gorm:"column:title"` + // Name string `gorm:"column:name"` // Card holder name/ID card name - for types cards/identity + // Company string `gorm:"column:company"` // Company name of person - for type identity and + // // Credit card company for type CC + // Number string `gorm:"column:number"` // Number type - CC number for credit cards + // // ID card number for identity types + // SecurityCode string `gorm:"security_code"` // CVV number/security code for CC type + // ExpiryMonth string `gorm:"expiry_month"` // CC or Identity document expiry month + // ExpiryDay string `gorm:"expiry_day"` // Identity document expiry day + // ExpiryYear string `gorm:"expiry_year"` // CC or Identity document expiry year + // FirstName string `gorm:"column:first_name"` // first name - for ID card types + // MiddleName string `gorm:"column:middle_name"` // middle name - for ID card types + // LastName string `gorm:"column:last_name"` // last name - for ID card types + // Email string `gorm:"email"` // Email - for ID card types + // PhoneNumber string `gorm:"phone_number"` // Phone number - for ID card types + + // Active bool `gorm:"active;default:true"` // Is the id card/CC active ? + // User string `gorm:"column:user"` + // Url string `gorm:"column:url"` + // Password string `gorm:"column:password"` + // Notes string `gorm:"column:notes"` + // Tags string `gorm:"column:tags"` + // Timestamp time.Time `gorm:"type:timestamp;default:(datetime('now','localtime'))"` // sqlite3 + } func (e *Entry) TableName() string { return "entries" } +// Structure representing an extended entry in the db - for custom fields +type ExtendedEntry struct { + ID int `gorm:"column:id;autoIncrement;primaryKey"` + FieldName string `gorm:"column:field_name"` + FieldValue string `gorm:"column:field_value"` + Timestamp time.Time `gorm:"type:timestamp;default:(datetime('now','localtime'))"` // sqlite3 + + Entry Entry `gorm:"foreignKey:EntryID"` + EntryID int +} + +func (ex *ExtendedEntry) TableName() string { + return "exentries" +} + +type Address struct { + ID int `gorm:"column:id;autoIncrement;primaryKey"` + Number string `gorm:"column:number"` // Flat or building number + Building string `gorm:"column:building"` // Apartment or building or society name + Street string `gorm:"column:street"` // Street address + Locality string `gorm:"column:locality"` // Name of the locality e.g: Whitefield + Area string `gorm:"column:area"` // Name of the larger area e.g: East Bangalore + City string `gorm:"column:city"` // Name of the city e.g: Bangalore + State string `gorm:"column:state"` // Name of the state e.g: Karnataka + Country string `gorm:"column:country"` // Name of the country e.g: India + + Landmark string `gorm:"column:landmark"` // Name of landmark if any + ZipCode string `gorm:"column:zipcode"` // PIN/ZIP code + Type string `gorm:"column:type"` // Type of address: Home/Work/Business + + Entry Entry `gorm:"foreignKey:EntryID"` + EntryID int +} + +func (ad *Address) TableName() string { + return "address" +} + // Clone an entry func (e1 *Entry) Copy(e2 *Entry) { if e2 != nil { - e1.Title = e2.Title - e1.User = e2.User - e1.Url = e2.Url - e1.Password = e2.Password - e1.Notes = e2.Notes + switch e2.Type { + case "password": + e1.Title = e2.Title + e1.User = e2.User + e1.Url = e2.Url + e1.Password = e2.Password + e1.Notes = e2.Notes + e1.Tags = e2.Tags + e1.Type = e2.Type + case "card": + e1.Title = e2.Title + e1.User = e2.User // card holder name + e1.Issuer = e2.Issuer + e1.Url = e2.Url + e1.Password = e2.Password + e1.ExpiryDate = e2.ExpiryDate + e1.Tags = e2.Tags + e1.Notes = e2.Notes + e1.Type = e2.Type + // case "identity": + // e1.Title = e2.Title + // e1.Name = e2.Name + // e1.Company = e2.Company + // e1.FirstName = e2.FirstName + // e1.LastName = e2.LastName + // e1.MiddleName = e2.MiddleName + // e1.User = e2.User + // e1.Email = e2.Email + // e1.PhoneNumber = e2.PhoneNumber + // e1.Number = e2.Number + // e1.Notes = e2.Notes + // e1.Tags = e2.Tags + // e1.Type = e2.Type + } + } +} + +// Clone an entry +func (e1 *ExtendedEntry) Copy(e2 *ExtendedEntry) { + + if e2 != nil { + e1.FieldName = e2.FieldName + e1.FieldValue = e2.FieldValue + e1.EntryID = e2.EntryID } } @@ -55,6 +166,11 @@ func createNewEntry(db *gorm.DB) error { return db.AutoMigrate(&Entry{}) } +// Create a new table for Extended Entries in the database +func createNewExEntry(db *gorm.DB) error { + return db.AutoMigrate(&ExtendedEntry{}) +} + // Init new database including tables func initNewDatabase(dbPath string) error { @@ -94,6 +210,12 @@ func initNewDatabase(dbPath string) error { return err } + err = createNewExEntry(db) + if err != nil { + fmt.Printf("Error creating schema - \"%s\"\n", err.Error()) + return err + } + fmt.Printf("Created new database - %s\n", dbPath) // Update config @@ -133,21 +255,185 @@ func openActiveDatabase() (error, *gorm.DB) { return nil, db } +// Add custom entries to a database entry +func addCustomEntries(db *gorm.DB, entry *Entry, customEntries []CustomEntry) error { + + var count int + var err error + + err = createNewExEntry(db) + if err != nil { + fmt.Printf("Error creating schema - \"%s\"\n", err.Error()) + return err + } + + for _, customEntry := range customEntries { + var exEntry ExtendedEntry + + exEntry = ExtendedEntry{FieldName: customEntry.fieldName, FieldValue: customEntry.fieldValue, + EntryID: entry.ID} + + resultEx := db.Create(&exEntry) + if resultEx.Error == nil && resultEx.RowsAffected == 1 { + count += 1 + } + } + + fmt.Printf("Created %d custom entries for entry: %d.\n", count, entry.ID) + return nil +} + +// Replace custom entries to a database entry (Drop existing and add fresh) +func replaceCustomEntries(db *gorm.DB, entry *Entry, updatedEntries []CustomEntry) error { + + var count int + var err error + var customEntries []ExtendedEntry + + err = createNewExEntry(db) + if err != nil { + fmt.Printf("Error creating schema - \"%s\"\n", err.Error()) + return err + } + + db.Where("entry_id = ?", entry.ID).Delete(&customEntries) + + for _, customEntry := range updatedEntries { + var exEntry ExtendedEntry + + exEntry = ExtendedEntry{FieldName: customEntry.fieldName, FieldValue: customEntry.fieldValue, + EntryID: entry.ID} + + resultEx := db.Create(&exEntry) + if resultEx.Error == nil && resultEx.RowsAffected == 1 { + count += 1 + } + } + + fmt.Printf("Created %d custom entries for entry: %d.\n", count, entry.ID) + return nil +} + // Add a new entry to current database -func addNewDatabaseEntry(title, userName, url, passwd, notes string) error { +func addNewDatabaseEntry(title, userName, url, passwd, tags string, + notes string, customEntries []CustomEntry) error { + + var entry Entry + var err error + var db *gorm.DB + + entry = Entry{Title: title, User: userName, Url: url, Password: passwd, Tags: strings.TrimSpace(tags), + Notes: notes} + + err, db = openActiveDatabase() + if err == nil && db != nil { + // result := db.Debug().Create(&entry) + result := db.Create(&entry) + if result.Error == nil && result.RowsAffected == 1 { + // Add custom fields if given + fmt.Printf("Created new entry with id: %d.\n", entry.ID) + if len(customEntries) > 0 { + return addCustomEntries(db, &entry, customEntries) + } + return nil + } else if result.Error != nil { + return result.Error + } + } + + return err +} + +func updateDatabaseCardEntry(entry *Entry, cardName, cardNumber, cardHolder, cardClass, + cardCvv, cardPin, cardExpiry, notes, tags string, customEntries []CustomEntry, + flag bool) error { + + var updateMap map[string]interface{} + updateMap = make(map[string]interface{}) + + keyValMap := map[string]string{ + "title": cardName, + "user": cardHolder, + "url": cardNumber, + "password": cardCvv, + "pin": cardPin, + // Issuer has to be the same + "class": cardClass, + "expiry_date": cardExpiry, + "tags": tags, + "notes": notes, + } + + for key, val := range keyValMap { + val := strings.TrimSpace(val) + if len(val) > 0 { + updateMap[key] = val + } + } + // fmt.Printf("%+v\n", updateMap) + + if len(updateMap) == 0 && !flag { + fmt.Printf("Nothing to update\n") + return nil + } + + // Update timestamp also + updateMap["timestamp"] = time.Now() + + err, db := openActiveDatabase() + + if err == nil && db != nil { + result := db.Model(entry).Updates(updateMap) + if result.Error != nil { + return result.Error + } + + if flag { + replaceCustomEntries(db, entry, customEntries) + } + fmt.Println("Updated entry.") + return nil + } + + return err +} + +// Add a new card entry to current database +func addNewDatabaseCardEntry(cardName, cardNumber, cardHolder, cardIssuer, cardClass, + cardCvv, cardPin, cardExpiry, notes, tags string, customEntries []CustomEntry) error { var entry Entry var err error var db *gorm.DB - entry = Entry{Title: title, User: userName, Url: url, Password: passwd, Notes: notes} + fields := MapString([]string{cardName, cardHolder, cardNumber, cardCvv, + cardPin, cardIssuer, cardClass, cardExpiry, tags, notes}, + strings.TrimSpace) + + entry = Entry{ + Title: fields[0], + User: fields[1], + Url: fields[2], + Password: fields[3], + Pin: fields[4], + Issuer: fields[5], + Class: fields[6], + ExpiryDate: fields[7], + Type: "card", + Tags: fields[8], + Notes: fields[9], + } err, db = openActiveDatabase() if err == nil && db != nil { - // result := db.Debug().Create(&entry) + // result := db.Debug().Create(&entry) result := db.Create(&entry) if result.Error == nil && result.RowsAffected == 1 { + // Add custom fields if given fmt.Printf("Created new entry with id: %d.\n", entry.ID) + if len(customEntries) > 0 { + return addCustomEntries(db, &entry, customEntries) + } return nil } else if result.Error != nil { return result.Error @@ -158,13 +444,20 @@ func addNewDatabaseEntry(title, userName, url, passwd, notes string) error { } // Update current database entry with new values -func updateDatabaseEntry(entry *Entry, title, userName, url, passwd, notes string) error { +func updateDatabaseEntry(entry *Entry, title, userName, url, passwd, tags string, + notes string, customEntries []CustomEntry, flag bool) error { var updateMap map[string]interface{} updateMap = make(map[string]interface{}) - keyValMap := map[string]string{"title": title, "user": userName, "url": url, "password": passwd, "notes": notes} + keyValMap := map[string]string{ + "title": title, + "user": userName, + "url": url, + "password": passwd, + "notes": notes, + "tags": tags} for key, val := range keyValMap { if len(val) > 0 { @@ -172,7 +465,7 @@ func updateDatabaseEntry(entry *Entry, title, userName, url, passwd, notes strin } } - if len(updateMap) == 0 { + if len(updateMap) == 0 && !flag { fmt.Printf("Nothing to update\n") return nil } @@ -188,6 +481,9 @@ func updateDatabaseEntry(entry *Entry, title, userName, url, passwd, notes strin return result.Error } + if flag { + replaceCustomEntries(db, entry, customEntries) + } fmt.Println("Updated entry.") return nil } @@ -225,17 +521,14 @@ func searchDatabaseEntry(term string) (error, []Entry) { err, db = openActiveDatabase() if err == nil && db != nil { - var conditions []string - var condition string - searchTerm = fmt.Sprintf("%%%s%%", term) - // Search on fields title, user, url and notes - for _, field := range []string{"title", "user", "url", "notes"} { - conditions = append(conditions, field+" like ?") + // Search on fields title, user, url and notes and tags. + query := db.Where(fmt.Sprintf("title like \"%s\"", searchTerm)) + + for _, field := range []string{"user", "url", "notes", "tags"} { + query = query.Or(fmt.Sprintf("%s like \"%s\"", field, searchTerm)) } - condition = strings.Join(conditions, " OR ") - query := db.Where(condition, searchTerm, searchTerm, searchTerm, searchTerm) res := query.Find(&entries) if res.Error != nil { @@ -249,6 +542,73 @@ func searchDatabaseEntry(term string) (error, []Entry) { } +// Union of two entry arrays +func union(entry1 []Entry, entry2 []Entry) []Entry { + + m := make(map[int]bool) + + for _, item := range entry1 { + m[item.ID] = true + } + + for _, item := range entry2 { + if _, ok := m[item.ID]; !ok { + entry1 = append(entry1, item) + } + } + + return entry1 +} + +// Intersection of two entry arrays +func intersection(entry1 []Entry, entry2 []Entry) []Entry { + + var common []Entry + + m := make(map[int]bool) + + for _, item := range entry1 { + m[item.ID] = true + } + + for _, item := range entry2 { + if _, ok := m[item.ID]; ok { + common = append(common, item) + } + } + + return common +} + +// Search database for the given terms and returns matches according to operator +func searchDatabaseEntries(terms []string, operator string) (error, []Entry) { + + var err error + var finalEntries []Entry + + for idx, term := range terms { + var entries []Entry + + err, entries = searchDatabaseEntry(term) + if err != nil { + fmt.Printf("Error searching for term: %s - \"%s\"\n", term, err.Error()) + return err, entries + } + + if idx == 0 { + finalEntries = entries + } else { + if operator == "AND" { + finalEntries = intersection(finalEntries, entries) + } else if operator == "OR" { + finalEntries = union(finalEntries, entries) + } + } + } + + return nil, finalEntries +} + // Remove a given database entry func removeDatabaseEntry(entry *Entry) error { @@ -257,11 +617,22 @@ func removeDatabaseEntry(entry *Entry) error { err, db = openActiveDatabase() if err == nil && db != nil { + var exEntries []ExtendedEntry + res := db.Delete(entry) if res.Error != nil { return res.Error } + // Delete extended entries if any + exEntries = getExtendedEntries(entry) + if len(exEntries) > 0 { + res = db.Delete(exEntries) + if res.Error != nil { + return res.Error + } + } + return nil } @@ -291,6 +662,31 @@ func cloneEntry(entry *Entry) (error, *Entry) { return err, nil } +// Clone extended entries for an entry and return error code +func cloneExtendedEntries(entry *Entry, exEntries []ExtendedEntry) error { + + var err error + var db *gorm.DB + + err, db = openActiveDatabase() + if err == nil && db != nil { + for _, exEntry := range exEntries { + var exEntryNew ExtendedEntry + + exEntryNew.Copy(&exEntry) + // Update the ID! + exEntryNew.EntryID = entry.ID + + result := db.Create(&exEntryNew) + if result.Error != nil { + return result.Error + } + } + } + + return err +} + // Return an iterator over all entries using the given order query keys func iterateEntries(orderKey string, order string) (error, []Entry) { @@ -354,3 +750,19 @@ func entriesToStringArray(skipLongFields bool) (error, [][]string) { return err, dataArray } + +// Get extended entries associated to an entry +func getExtendedEntries(entry *Entry) []ExtendedEntry { + + var err error + var db *gorm.DB + var customEntries []ExtendedEntry + + err, db = openActiveDatabase() + + if err == nil && db != nil { + db.Where("entry_id = ?", entry.ID).Find(&customEntries) + } + + return customEntries +} diff --git a/export.go b/export.go new file mode 100644 index 0000000..30442b6 --- /dev/null +++ b/export.go @@ -0,0 +1,383 @@ +package main + +import ( + "bufio" + "encoding/csv" + "errors" + "fmt" + "os" + "os/exec" + "path/filepath" + "strings" +) + +// Export data to a varity of file types +func exportToFile(fileName string) error { + + var err error + var maxKrypt bool + var defaultDB string + var passwd string + + ext := strings.ToLower(filepath.Ext(fileName)) + + maxKrypt, defaultDB = isActiveDatabaseEncryptedAndMaxKryptOn() + + if ext == ".csv" || ext == ".md" || ext == ".html" || ext == ".pdf" { + // If max krypt on - then autodecrypt on call and auto encrypt after call + if maxKrypt { + err, passwd = decryptDatabase(defaultDB) + if err != nil { + return err + } + } + } + + switch ext { + case ".csv": + err = exportToCsv(fileName) + case ".md": + err = exportToMarkdown(fileName) + case ".html": + err = exportToHTML(fileName) + case ".pdf": + err = exportToPDF(fileName) + default: + fmt.Printf("Error - extn %s not supported\n", ext) + return fmt.Errorf("format %s not supported", ext) + } + + if err != nil { + fmt.Printf("Error exporting to \"%s\" - \"%s\"\n", fileName, err.Error()) + return err + } else { + if _, err = os.Stat(fileName); err == nil { + fmt.Printf("Exported to %s.\n", fileName) + // Chmod 600 + os.Chmod(fileName, 0600) + + // If max krypt on - then autodecrypt on call and auto encrypt after call + if maxKrypt { + err = encryptDatabase(defaultDB, &passwd) + } + + return err + } + } + + return err +} + +// Export current database to markdown +func exportToMarkdown(fileName string) error { + + var err error + var dataArray [][]string + var fh *os.File + var maxLengths [7]int + var headers []string = []string{" ID ", " Title ", " User ", " URL ", " Password ", " Notes ", " Modified "} + + err, dataArray = entriesToStringArray(false) + + if err != nil { + fmt.Printf("Error exporting entries to string array - \"%s\"\n", err.Error()) + return err + } + + for _, record := range dataArray { + for idx, field := range record { + + if len(field) > maxLengths[idx] { + maxLengths[idx] = len(field) + } + } + } + + // fmt.Printf("%+v\n", maxLengths) + fh, err = os.Create(fileName) + if err != nil { + fmt.Printf("Cannt open \"%s\" for writing - \"%s\"\n", fileName, err.Error()) + return err + } + + defer fh.Close() + + writer := bufio.NewWriter(fh) + + // Write markdown header + for idx, length := range maxLengths { + delta := length - len(headers[idx]) + // fmt.Printf("%d\n", delta) + if delta > 0 { + for i := 0; i < delta+2; i++ { + headers[idx] += " " + } + } + } + + writer.WriteString(" |" + strings.Join(headers, "|") + "|\n") + + // Write line separator + writer.WriteString(" | ") + for _, length := range maxLengths { + + for i := 0; i < length; i++ { + writer.WriteString("-") + } + writer.WriteString(" | ") + } + writer.WriteString("\n") + + // Write records + for _, record := range dataArray { + writer.WriteString(" | ") + for _, field := range record { + writer.WriteString(field + " | ") + } + writer.WriteString("\n") + } + + writer.Flush() + + return nil + +} + +// This needs pandoc and pdflatex support +func exportToPDF(fileName string) error { + + var err error + var tmpFile string + var passwd string + var pdfTkFound bool + + cmd := exec.Command("which", "pandoc") + if _, err = cmd.Output(); err != nil { + return errors.New("pandoc not found") + } + + cmd = exec.Command("which", "pdftk") + if _, err = cmd.Output(); err != nil { + fmt.Printf("pdftk not found, PDF won't be secure!\n") + } else { + pdfTkFound = true + } + + if pdfTkFound { + fmt.Printf("PDF Encryption Password: ") + err, passwd = readPassword() + } + + tmpFile = randomFileName(os.TempDir(), ".tmp") + // fmt.Printf("Temp file => %s\n", tmpFile) + err = exportToMarkdownLimited(tmpFile) + + if err == nil { + var args []string = []string{"-o", fileName, "-f", "markdown", "-V", "geometry:landscape", "--columns=600", "--pdf-engine", "xelatex", "--dpi=150", tmpFile} + + cmd = exec.Command("pandoc", args...) + _, err = cmd.Output() + // Remove tmpfile + os.Remove(tmpFile) + + // If the file is generated, encrypt it if pdfTkFound + if _, err = os.Stat(fileName); err == nil { + fmt.Printf("\nFile %s created without password.\n", fileName) + + if pdfTkFound && len(passwd) > 0 { + tmpFile = randomFileName(".", ".pdf") + // fmt.Printf("pdf file => %s\n", tmpFile) + args = []string{fileName, "output", tmpFile, "user_pw", passwd} + cmd = exec.Command("pdftk", args...) + _, err = cmd.Output() + + if err == nil { + // Copy over + fmt.Printf("Added password to %s.\n", fileName) + os.Remove(fileName) + err = os.Rename(tmpFile, fileName) + } else { + fmt.Printf("Error adding password to pdf - \"%s\"\n", err.Error()) + } + } + } + } + + return err + +} + +// Export current database to markdown minus the long fields +func exportToMarkdownLimited(fileName string) error { + + var err error + var dataArray [][]string + var fh *os.File + var maxLengths [5]int + var headers []string = []string{" ID ", " Title ", " User ", " Password ", " Modified "} + + err, dataArray = entriesToStringArray(true) + + if err != nil { + fmt.Printf("Error exporting entries to string array - \"%s\"\n", err.Error()) + return err + } + + for _, record := range dataArray { + for idx, field := range record { + + if len(field) > maxLengths[idx] { + maxLengths[idx] = len(field) + } + } + } + + // fmt.Printf("%+v\n", maxLengths) + fh, err = os.Create(fileName) + if err != nil { + fmt.Printf("Cannt open \"%s\" for writing - \"%s\"\n", fileName, err.Error()) + return err + } + + defer fh.Close() + + writer := bufio.NewWriter(fh) + + // Write markdown header + for idx, length := range maxLengths { + delta := length - len(headers[idx]) + // fmt.Printf("%d\n", delta) + if delta > 0 { + for i := 0; i < delta+2; i++ { + headers[idx] += " " + } + } + } + + writer.WriteString(" |" + strings.Join(headers, "|") + "|\n") + + // Write line separator + writer.WriteString(" | ") + for _, length := range maxLengths { + + for i := 0; i < length; i++ { + writer.WriteString("-") + } + writer.WriteString(" | ") + } + writer.WriteString("\n") + + // Write records + for _, record := range dataArray { + writer.WriteString(" | ") + for _, field := range record { + writer.WriteString(field + " | ") + } + writer.WriteString("\n") + } + + writer.Flush() + + return nil + +} + +// Export current database to html +func exportToHTML(fileName string) error { + + var err error + var dataArray [][]string + var fh *os.File + var headers []string = []string{" ID ", " Title ", " User ", " URL ", " Password ", " Notes ", " Modified "} + + err, dataArray = entriesToStringArray(false) + + if err != nil { + fmt.Printf("Error exporting entries to string array - \"%s\"\n", err.Error()) + return err + } + + // fmt.Printf("%+v\n", maxLengths) + fh, err = os.Create(fileName) + if err != nil { + fmt.Printf("Cannt open \"%s\" for writing - \"%s\"\n", fileName, err.Error()) + return err + } + + defer fh.Close() + + writer := bufio.NewWriter(fh) + + writer.WriteString("\n") + writer.WriteString("\n") + writer.WriteString("\n") + + for _, h := range headers { + writer.WriteString(fmt.Sprintf("", h)) + } + writer.WriteString("\n") + writer.WriteString("\n") + + // Write records + for _, record := range dataArray { + writer.WriteString("") + for _, field := range record { + writer.WriteString(fmt.Sprintf("", field)) + } + writer.WriteString("\n") + } + writer.WriteString("\n") + writer.WriteString("
%s
%s
\n") + + writer.WriteString("\n") + + writer.Flush() + + return nil + +} + +// Export current database to CSV +func exportToCsv(fileName string) error { + + var err error + var dataArray [][]string + var fh *os.File + + err, dataArray = entriesToStringArray(false) + + if err != nil { + fmt.Printf("Error exporting entries to string array - \"%s\"\n", err.Error()) + return err + } + + fh, err = os.Create(fileName) + if err != nil { + fmt.Printf("Cannt open \"%s\" for writing - \"%s\"\n", fileName, err.Error()) + return err + } + + writer := csv.NewWriter(fh) + + // Write header + writer.Write([]string{"ID", "Title", "User", "URL", "Password", "Notes", "Modified"}) + + for idx, record := range dataArray { + if err = writer.Write(record); err != nil { + fmt.Printf("Error writing record #%d to %s - \"%s\"\n", idx+1, fileName, err.Error()) + break + } + } + + writer.Flush() + + if err != nil { + return err + } + + os.Chmod(fileName, 0600) + fmt.Printf("!WARNING: Passwords are stored in plain-text!\n") + fmt.Printf("Exported %d records to %s .\n", len(dataArray), fileName) + + return nil +} diff --git a/go.mod b/go.mod index 2b99561..7417d0e 100644 --- a/go.mod +++ b/go.mod @@ -3,8 +3,10 @@ module varuh go 1.16 require ( + github.com/atotto/clipboard v0.1.4 github.com/kirsle/configdir v0.0.0-20170128060238-e45d2f54772f - github.com/pborman/getopt/v2 v2.1.0 + github.com/polyglothacker/creditcard v0.0.0-20220814132008-214952378026 + github.com/pythonhacker/argparse v1.3.2 golang.org/x/crypto v0.0.0-20210921155107-089bfa567519 gorm.io/driver/sqlite v1.2.3 gorm.io/gorm v1.22.2 diff --git a/go.sum b/go.sum index ad8ca1d..8e58640 100644 --- a/go.sum +++ b/go.sum @@ -1,3 +1,5 @@ +github.com/atotto/clipboard v0.1.4 h1:EH0zSVneZPSuFR11BlR9YppQTVDbh5+16AmcJi4g1z4= +github.com/atotto/clipboard v0.1.4/go.mod h1:ZY9tmq7sm5xIbd9bOK4onWV4S6X0u6GY7Vn0Yu86PYI= github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= @@ -9,11 +11,14 @@ github.com/kirsle/configdir v0.0.0-20170128060238-e45d2f54772f h1:dKccXx7xA56UNq github.com/kirsle/configdir v0.0.0-20170128060238-e45d2f54772f/go.mod h1:4rEELDSfUAlBSyUjPG0JnaNGjf13JySHFeRdD/3dLP0= github.com/mattn/go-sqlite3 v1.14.9 h1:10HX2Td0ocZpYEjhilsuo6WWtUqttj2Kb0KtD86/KYA= github.com/mattn/go-sqlite3 v1.14.9/go.mod h1:NyWgC/yNuGj7Q9rpYnZvas74GogHl5/Z4A/KQRfk6bU= -github.com/pborman/getopt/v2 v2.1.0 h1:eNfR+r+dWLdWmV8g5OlpyrTYHkhVNxHBdN2cCrJmOEA= -github.com/pborman/getopt/v2 v2.1.0/go.mod h1:4NtW75ny4eBw9fO1bhtNdYTlZKYX5/tBLtsOpwKIKd0= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/polyglothacker/creditcard v0.0.0-20220814132008-214952378026 h1:UGQ0EYOPlnXlhGGTlRXIqGhKViXU7Ro+EIl+S+Ui8AY= +github.com/polyglothacker/creditcard v0.0.0-20220814132008-214952378026/go.mod h1:F7aq1XexOpEd3ipbid3ZwJkijRyBf5p1EBVU5MycFb8= +github.com/pythonhacker/argparse v1.3.2 h1:JOojnYFHk7oap+MQiFgiPAHlzvhJfqukErLneWaHR/M= +github.com/pythonhacker/argparse v1.3.2/go.mod h1:gdUstTr/g1ojhRwrF9gKFOVLwsNfwarBg8aCQRjtvo8= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4= github.com/stretchr/testify v1.7.0 h1:nwc3DEeHmmLAfoZucVR881uASk0Mfjw8xYJ99tb5CcY= github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= golang.org/x/crypto v0.0.0-20210921155107-089bfa567519 h1:7I4JAnoQBe7ZtJcBaYHi5UtiO8tQHbUSXxL+pnGRANg= @@ -27,6 +32,7 @@ golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9sn golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c h1:dUUwHk2QECo/6vqA44rthZ8ie2QXMNeKRTHCNY2nXvo= gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gorm.io/driver/sqlite v1.2.3 h1:OwKm0xRAnsZMWAl5BtXJ9BsXAZHIt802DOTVMQuzWN8= diff --git a/main.go b/main.go index 79e7e8f..0078724 100644 --- a/main.go +++ b/main.go @@ -4,21 +4,37 @@ package main import ( "fmt" - getopt "github.com/pborman/getopt/v2" + "github.com/pythonhacker/argparse" "os" + "strings" ) -const VERSION = 0.1 +const VERSION = 0.4 const APP = "varuh" -const AUTHOR_EMAIL = "Anand B Pillai " + +const AUTHOR_INFO = ` +AUTHORS + Copyright (C) 2022 Anand B Pillai +` type actionFunc func(string) error type actionFunc2 func(string) (error, string) type voidFunc func() error +type voidFunc2 func() (error, string) +type settingFunc func(string) + +// Structure to keep the options data +type CmdOption struct { + Short string + Long string + Help string + Path string + Default string +} // Print the program's usage string and exit func printUsage() error { - getopt.Usage() + // getopt.Usage() os.Exit(0) return nil @@ -32,47 +48,99 @@ func printVersionInfo() error { return nil } -// Perform an action by using the command line options map -func performAction(optMap map[string]interface{}, optionMap map[string]interface{}) { +// Command-line wrapper to generateRandomPassword +func genPass() (error, string) { + var err error + var passwd string + + err, passwd = generateStrongPassword() + + if err != nil { + fmt.Printf("Error generating password - \"%s\"\n", err.Error()) + return err, "" + } + + fmt.Println(passwd) + + if settingsRider.CopyPassword { + copyPasswordToClipboard(passwd) + fmt.Println("Password copied to clipboard") + } + + return nil, passwd +} + +// // Perform an action by using the command line options map +func performAction(optMap map[string]interface{}) { var flag bool boolActionsMap := map[string]voidFunc{ - "add": addNewEntry, + "add": WrapperMaxKryptVoidFunc(addNewEntry), "version": printVersionInfo, "help": printUsage, "path": showActiveDatabasePath, - "list-all": listAllEntries, + "list-all": WrapperMaxKryptVoidFunc(listAllEntries), "encrypt": encryptActiveDatabase, } stringActionsMap := map[string]actionFunc{ - "edit": editCurrentEntry, + "edit": WrapperMaxKryptStringFunc(editCurrentEntry), "init": initNewDatabase, - "list-entry": listCurrentEntry, - "find": findCurrentEntry, - "remove": removeCurrentEntry, - "copy": copyCurrentEntry, + "list-entry": WrapperMaxKryptStringFunc(listCurrentEntry), + "remove": WrapperMaxKryptStringFunc(removeCurrentEntry), + "clone": WrapperMaxKryptStringFunc(copyCurrentEntry), "use-db": setActiveDatabasePath, "export": exportToFile, + "migrate": migrateDatabase, + } + + stringListActionsMap := map[string]actionFunc{ + "find": WrapperMaxKryptStringFunc(findCurrentEntry), } stringActions2Map := map[string]actionFunc2{ "decrypt": decryptDatabase, } + flagsActions2Map := map[string]voidFunc2{ + "genpass": genPass, + } + flagsActionsMap := map[string]voidFunc{ - "show": setShowPasswords, + "show": setShowPasswords, + "copy": setCopyPasswordToClipboard, + "assume-yes": setAssumeYes, + } + + flagsSettingsMap := map[string]settingFunc{ + "type": setType, } // Flag actions - always done for key, mappedFunc := range flagsActionsMap { if *optMap[key].(*bool) { mappedFunc() + } + } + + // Flag 2 actions + for key, mappedFunc := range flagsActions2Map { + if *optMap[key].(*bool) { + mappedFunc() + flag = true break } } + // Settings + for key, mappedFunc := range flagsSettingsMap { + if *optMap[key].(*string) != "" { + var val = *(optMap[key].(*string)) + mappedFunc(val) + } + } + // One of bool or string actions for key, mappedFunc := range boolActionsMap { if *optMap[key].(*bool) { @@ -87,9 +155,7 @@ func performAction(optMap map[string]interface{}, optionMap map[string]interface } for key, mappedFunc := range stringActionsMap { - option := optionMap[key].(Option) - - if *optMap[key].(*string) != option.Path { + if *optMap[key].(*string) != "" { var val = *(optMap[key].(*string)) mappedFunc(val) @@ -98,15 +164,24 @@ func performAction(optMap map[string]interface{}, optionMap map[string]interface } } + for key, mappedFunc := range stringListActionsMap { + if len(*optMap[key].(*[]string)) > 0 { + + var vals = *(optMap[key].(*[]string)) + // Convert to single string + var singleVal = strings.Join(vals, " ") + mappedFunc(singleVal) + flag = true + break + } + } + if flag { return } for key, mappedFunc := range stringActions2Map { - option := optionMap[key].(Option) - - if *optMap[key].(*string) != option.Path { - + if *optMap[key].(*string) != "" { var val = *(optMap[key].(*string)) mappedFunc(val) break @@ -115,19 +190,76 @@ func performAction(optMap map[string]interface{}, optionMap map[string]interface } +func initializeCmdLine(parser *argparse.Parser) map[string]interface{} { + var optMap map[string]interface{} + + optMap = make(map[string]interface{}) + + stringOptions := []CmdOption{ + {"I", "init", "Initialize a new database", "", ""}, + {"d", "decrypt", "Decrypt password database", "", ""}, + {"C", "clone", "Clone an entry with ", "", ""}, + {"R", "remove", "Remove an entry with or ", "", ""}, + {"U", "use-db", "Set as active database", "", ""}, + {"E", "edit", "Edit entry by ", "", ""}, + {"l", "list-entry", "List entry by ", "", ""}, + {"x", "export", "Export all entries to ", "", ""}, + {"m", "migrate", "Migrate a database to latest schema", "", ""}, + {"t", "type", "Specify type when adding a new entry", "", ""}, + } + + for _, opt := range stringOptions { + optMap[opt.Long] = parser.String(opt.Short, opt.Long, &argparse.Options{Help: opt.Help, Path: opt.Path}) + } + + stringListOptions := []CmdOption{ + {"f", "find", "Search entries with terms", " ...", ""}, + } + + for _, opt := range stringListOptions { + optMap[opt.Long] = parser.StringList(opt.Short, opt.Long, &argparse.Options{Help: opt.Help, Path: opt.Path}) + } + + boolOptions := []CmdOption{ + {"e", "encrypt", "Encrypt the current database", "", ""}, + {"A", "add", "Add a new entry", "", ""}, + {"p", "path", "Show current database path", "", ""}, + {"a", "list-all", "List all entries in current database", "", ""}, + {"g", "genpass", "Generate a strong password (length: 12 - 16)", "", ""}, + {"s", "show", "Show passwords when listing entries", "", ""}, + {"c", "copy", "Copy password to clipboard", "", ""}, + {"y", "assume-yes", "Assume yes to actions requiring confirmation", "", ""}, + {"v", "version", "Show version information and exit", "", ""}, + {"h", "help", "Print this help message and exit", "", ""}, + } + + for _, opt := range boolOptions { + optMap[opt.Long] = parser.Flag(string(opt.Short), opt.Long, &argparse.Options{Help: opt.Help}) + } + + return optMap +} + // Main routine func main() { if len(os.Args) == 1 { os.Args = append(os.Args, "-h") } - optMap, optionMap := initializeCommandLine() - getopt.SetUsage(func() { - usageString(optionMap) - }) + parser := argparse.NewParser("varuh", + "Password manager for the command line for Unix like operating systems", + AUTHOR_INFO, + ) + + optMap := initializeCmdLine(parser) + + err := parser.Parse(os.Args) + + if err != nil { + fmt.Println(parser.Usage(err)) + } - getopt.Parse() getOrCreateLocalConfig(APP) - performAction(optMap, optionMap) + performAction(optMap) } diff --git a/options.go b/options.go deleted file mode 100644 index 824682b..0000000 --- a/options.go +++ /dev/null @@ -1,148 +0,0 @@ -// Managing command line options -package main - -import ( - "fmt" - "strings" - - getopt "github.com/pborman/getopt/v2" -) - -// Structure to keep the options data -type Option struct { - Short rune - Long string - Path string - Help string - Type uint8 -} - -// Usage string template -const HELP_STRING = ` -SYNOPSIS - - %s [options] [flags] - -OPTIONS - - EDIT/CREATE ACTIONS: - -%s - - FIND/LIST ACTIONS: - -%s - - HELP ACTIONS: - -%s - - FLAGS: - -%s - - -AUTHORS - Copyright (C) 2021 %s -` - -// Generate the usage string for the program -func usageString(optMap map[string]interface{}) { - - var editActions []string - var findActions []string - var helpActions []string - var flagActions []string - - var maxLen1 int - var maxLen2 int - - var usageTemplate = "%8s --%s %s %s" - - // Find max string length - for _, value := range optMap { - option := value.(Option) - - if len(option.Long) > maxLen1 { - maxLen1 = len(option.Long) - } - if len(option.Path) > maxLen2 { - maxLen2 = len(option.Path) - } - } - - for _, value := range optMap { - option := value.(Option) - - delta := maxLen1 + 5 - len(option.Long) - for i := 0; i < delta; i++ { - option.Long += " " - } - - if len(option.Path) < maxLen2 { - delta := maxLen2 - len(option.Path) - for i := 0; i < delta; i++ { - option.Path += " " - } - } - - switch option.Type { - case 0: - editActions = append(editActions, fmt.Sprintf(usageTemplate, "-"+string(option.Short), option.Long, option.Path, option.Help)) - case 1: - findActions = append(findActions, fmt.Sprintf(usageTemplate, "-"+string(option.Short), option.Long, option.Path, option.Help)) - case 2: - helpActions = append(helpActions, fmt.Sprintf(usageTemplate, "-"+string(option.Short), option.Long, option.Path, option.Help)) - case 3: - flagActions = append(flagActions, fmt.Sprintf(usageTemplate, "-"+string(option.Short), option.Long, option.Path, option.Help)) - } - } - - fmt.Println(fmt.Sprintf(HELP_STRING, APP, strings.Join(editActions, "\n"), - strings.Join(findActions, "\n"), strings.Join(helpActions, "\n"), - strings.Join(flagActions, "\n"), AUTHOR_EMAIL)) - -} - -// Set up command line options - returns two maps -func initializeCommandLine() (map[string]interface{}, map[string]interface{}) { - var optMap map[string]interface{} - var optionMap map[string]interface{} - - optMap = make(map[string]interface{}) - optionMap = make(map[string]interface{}) - - stringOptions := []Option{ - {'I', "init", "", "Initialize a new database", 0}, - {'d', "decrypt", "", "Decrypt password database", 0}, - {'C', "copy", "", "Copy an entry", 0}, - {'R', "remove", "", "Remove an entry", 0}, - {'U', "use-db", "", "Set as active database", 0}, - {'f', "find", "", "Search entries", 1}, - {'E', "edit", "", "Edit entry by id", 0}, - {'l', "list-entry", "", "List entry by id", 1}, - {'x', "export", "", "Export all entries to ", 1}, - } - - for _, opt := range stringOptions { - optMap[opt.Long] = getopt.StringLong(opt.Long, opt.Short, opt.Path, opt.Help) - optionMap[opt.Long] = opt - } - - boolOptions := []Option{ - {'e', "encrypt", "", "Encrypt the current database", 0}, - {'A', "add", "", "Add a new entry", 0}, - {'p', "path", "", "Show current database path", 1}, - {'a', "list-all", "", "List all entries in current database", 1}, - {'s', "show", "", "Show passwords when listing entries", 3}, - {'v', "version", "", "Show version information and exit", 2}, - {'h', "help", "", "Print this help message and exit", 2}, - } - - for _, opt := range boolOptions { - optMap[opt.Long] = getopt.BoolLong(opt.Long, opt.Short, opt.Help) - optionMap[opt.Long] = opt - } - - return optMap, optionMap -} diff --git a/test/testpgp.go b/test/testpgp.go index 4adb384..844fe03 100644 --- a/test/testpgp.go +++ b/test/testpgp.go @@ -3,26 +3,25 @@ package main import ( - "os" - "os/user" - "fmt" "bytes" + "fmt" + "golang.org/x/crypto/openpgp" "io/ioutil" + "os" + "os/user" "path/filepath" - "golang.org/x/crypto/openpgp" ) - func main() { currUser, _ := user.Current() secretText := "These are the nuclear launch codes - A/B/C/D" path, err := filepath.Abs(filepath.Join(currUser.HomeDir, ".gnupg/pubring.kbx")) fmt.Println(path) - + fh, _ := os.Open(path) defer fh.Close() - + entityList, err := openpgp.ReadArmoredKeyRing(fh) if err != nil { fmt.Println("1") @@ -34,7 +33,7 @@ func main() { _, err = w.Write([]byte(secretText)) if err != nil { - fmt.Println("2") + fmt.Println("2") panic(err) } @@ -44,16 +43,16 @@ func main() { } data, err := ioutil.ReadAll(buf) - if err != nil { - fmt.Println("3") + if err != nil { + fmt.Println("3") panic(err) - } - + } + // encStr := base64.StdEncoding.EncodeToString(bytes) - + err = os.WriteFile("test.gpg", data, 0644) if err != nil { - fmt.Println("4") + fmt.Println("4") panic(err) } } diff --git a/utils.go b/utils.go index aad36f2..4289065 100644 --- a/utils.go +++ b/utils.go @@ -7,12 +7,17 @@ import ( "encoding/json" "errors" "fmt" + "github.com/atotto/clipboard" "github.com/kirsle/configdir" + "github.com/polyglothacker/creditcard" "golang.org/x/crypto/ssh/terminal" "io/fs" "os" "path/filepath" + "regexp" + "strconv" "strings" + "time" ) const DELIMSIZE int = 69 @@ -20,6 +25,9 @@ const DELIMSIZE int = 69 // Over-ride settings via cmd line type SettingsOverride struct { ShowPasswords bool + CopyPassword bool + AssumeYes bool + Type string // Type of entity to add } // Settings structure for local config @@ -45,6 +53,26 @@ type Settings struct { // Global settings override var settingsRider SettingsOverride +// Map a function to an array of strings +func MapString(vs []string, f func(string) string) []string { + vsm := make([]string, len(vs)) + for i, v := range vs { + vsm[i] = f(v) + } + return vsm +} + +// Print a secret +func hideSecret(secret string) string { + var stars []string + + for i := 0; i < len(secret); i++ { + stars = append(stars, "*") + } + + return strings.Join(stars, "") +} + // Write settings to disk func writeSettings(settings *Settings, configFile string) error { @@ -102,7 +130,7 @@ func getOrCreateLocalConfig(app string) (error, *Settings) { } configFile = filepath.Join(configPath, "config.json") - // fmt.Printf("Config file, path => %s %s\n", configFile, configPath) + // fmt.Printf("Config file, path => %s %s\n", configFile, configPath) if _, err = os.Stat(configFile); err == nil { fh, err = os.Open(configFile) @@ -119,8 +147,8 @@ func getOrCreateLocalConfig(app string) (error, *Settings) { } } else { - // fmt.Printf("Creating default configuration ...") - settings = Settings{"", "aes", true, true, false, configFile, "id,asc", "+", "default", "bgblack"} + // fmt.Printf("Creating default configuration ...") + settings = Settings{"", "aes", true, true, false, configFile, "id,asc", ">", "default", "bgblack"} if err = writeSettings(&settings, configFile); err == nil { // fmt.Println(" ...done") @@ -210,6 +238,22 @@ func rewriteBaseFile(path string, contents []byte, mode fs.FileMode) (error, str return err, origFile } +// Rewrite the contents of the file with the new contents +func rewriteFile(path string, contents []byte, mode fs.FileMode) (error, string) { + + var err error + + // Overwrite it + err = os.WriteFile(path, contents, 0644) + + if err == nil { + // Chmod it + os.Chmod(path, mode) + } + + return err, path +} + // Get color codes for console colors func getColor(code string) string { @@ -276,11 +320,106 @@ func printDelim(delimChar string, color string) { fmt.Println(strings.Join(delims, "")) } +// Prettify credit/debit card numbers +func prettifyCardNumber(cardNumber string) string { + + // Amex cards are 15 digits - group as 4, 6, 5 + // Any 16 digits - group as 4/4/4/4 + var numbers []string + + // Remove spaces in between + cardNumber = strings.Join(strings.Split(cardNumber, " "), "") + if len(cardNumber) == 15 { + numbers = append(numbers, cardNumber[0:4]) + numbers = append(numbers, cardNumber[4:10]) + numbers = append(numbers, cardNumber[10:15]) + } else if len(cardNumber) == 16 { + numbers = append(numbers, cardNumber[0:4]) + numbers = append(numbers, cardNumber[4:8]) + numbers = append(numbers, cardNumber[8:12]) + numbers = append(numbers, cardNumber[12:16]) + } + + return strings.Join(numbers, " ") +} + +// Print a card entry to the console +func printCardEntry(entry *Entry, settings *Settings, delim bool) error { + + var customEntries []ExtendedEntry + + fmt.Printf("%s", getColor(strings.ToLower(settings.Color))) + if strings.HasPrefix(settings.BgColor, "bg") { + fmt.Printf("%s", getColor(strings.ToLower(settings.BgColor))) + } + + if delim { + printDelim(settings.Delim, settings.Color) + } + + fmt.Printf("[Type: card]\n") + fmt.Printf("ID: %d\n", entry.ID) + fmt.Printf("Card Name: %s\n", entry.Title) + fmt.Printf("Card Holder: %s\n", entry.User) + fmt.Printf("Card Number: %s\n", prettifyCardNumber(entry.Url)) + fmt.Printf("Card Type: %s\n", entry.Class) + + if entry.Issuer != "" { + fmt.Printf("Issuing Bank: %s\n", entry.Issuer) + } + + fmt.Println() + fmt.Printf("Expiry Date: %s\n", entry.ExpiryDate) + + passwd := strings.TrimSpace(entry.Password) + pin := strings.TrimSpace(entry.Pin) + if settings.ShowPasswords || settingsRider.ShowPasswords { + + if len(passwd) > 0 { + fmt.Printf("Card CVV: %s\n", passwd) + } + if len(pin) > 0 { + fmt.Printf("Card PIN: %s\n", pin) + } + } else { + + if len(passwd) > 0 { + fmt.Printf("Card CVV: %s\n", hideSecret(passwd)) + } + if len(pin) > 0 { + fmt.Printf("Card PIN: %s\n", hideSecret(passwd)) + } + } + + if len(entry.Tags) > 0 { + fmt.Printf("\nTags: %s\n", entry.Tags) + } + if len(entry.Notes) > 0 { + fmt.Printf("Notes: %s\n", entry.Notes) + } + // Query extended entries + customEntries = getExtendedEntries(entry) + if len(customEntries) > 0 { + for _, customEntry := range customEntries { + fmt.Printf("%s: %s\n", customEntry.FieldName, customEntry.FieldValue) + } + } + + fmt.Printf("Modified: %s\n", entry.Timestamp.Format("2006-01-02 15:04:05")) + printDelim(settings.Delim, settings.Color) + // Reset + fmt.Printf("%s", getColor("default")) + + return nil + +} + // Print an entry to the console func printEntry(entry *Entry, delim bool) error { var err error var settings *Settings + var customEntries []ExtendedEntry err, settings = getOrCreateLocalConfig(APP) @@ -289,6 +428,10 @@ func printEntry(entry *Entry, delim bool) error { return err } + if entry.Type == "card" { + return printCardEntry(entry, settings, delim) + } + fmt.Printf("%s", getColor(strings.ToLower(settings.Color))) if strings.HasPrefix(settings.BgColor, "bg") { fmt.Printf("%s", getColor(strings.ToLower(settings.BgColor))) @@ -298,6 +441,7 @@ func printEntry(entry *Entry, delim bool) error { printDelim(settings.Delim, settings.Color) } + fmt.Printf("[Type: password]\n") fmt.Printf("ID: %d\n", entry.ID) fmt.Printf("Title: %s\n", entry.Title) fmt.Printf("User: %s\n", entry.User) @@ -306,15 +450,62 @@ func printEntry(entry *Entry, delim bool) error { if settings.ShowPasswords || settingsRider.ShowPasswords { fmt.Printf("Password: %s\n", entry.Password) } else { - var asterisks []string + fmt.Printf("Password: %s\n", hideSecret(entry.Password)) + } - for i := 0; i < len(entry.Password); i++ { - asterisks = append(asterisks, "*") + if len(entry.Tags) > 0 { + fmt.Printf("Tags: %s\n", entry.Tags) + } + if len(entry.Notes) > 0 { + fmt.Printf("Notes: %s\n", entry.Notes) + } + // Query extended entries + customEntries = getExtendedEntries(entry) + + if len(customEntries) > 0 { + for _, customEntry := range customEntries { + fmt.Printf("%s: %s\n", customEntry.FieldName, customEntry.FieldValue) } - fmt.Printf("Password: %s\n", strings.Join(asterisks, "")) } - fmt.Printf("Notes: %s\n", entry.Notes) - fmt.Printf("Modified: %s\n", entry.Timestamp.Format("2006-06-02 15:04:05")) + + fmt.Printf("Modified: %s\n", entry.Timestamp.Format("2006-01-02 15:04:05")) + + printDelim(settings.Delim, settings.Color) + + // Reset + fmt.Printf("%s", getColor("default")) + + return nil + +} + +// Print an entry to the console with minimal data +func printEntryMinimal(entry *Entry, delim bool) error { + + var err error + var settings *Settings + + err, settings = getOrCreateLocalConfig(APP) + + if err != nil { + fmt.Printf("Error parsing config - \"%s\"\n", err.Error()) + return err + } + + fmt.Printf("%s", getColor(strings.ToLower(settings.Color))) + if strings.HasPrefix(settings.BgColor, "bg") { + fmt.Printf("%s", getColor(strings.ToLower(settings.BgColor))) + } + + if delim { + printDelim(settings.Delim, settings.Color) + } + + fmt.Printf("Title: %s\n", entry.Title) + fmt.Printf("User: %s\n", entry.User) + fmt.Printf("URL: %s\n", entry.Url) + fmt.Printf("Modified: %s\n", entry.Timestamp.Format("2006-01-02 15:04:05")) + printDelim(settings.Delim, settings.Color) // Reset @@ -384,14 +575,126 @@ func isActiveDatabaseEncryptedAndMaxKryptOn() (bool, string) { // (Temporarily) enable showing of passwords func setShowPasswords() error { - // fmt.Printf("Setting show passwords to true\n") + // fmt.Printf("Setting show passwords to true\n") settingsRider.ShowPasswords = true return nil } +// Copy the password to clipboard - only for single listings or single search results +func setCopyPasswordToClipboard() error { + settingsRider.CopyPassword = true + return nil +} + +func setAssumeYes() error { + settingsRider.AssumeYes = true + return nil +} + +func setType(_type string) { + settingsRider.Type = _type +} + +func copyPasswordToClipboard(passwd string) { + clipboard.WriteAll(passwd) +} + // Generate a random file name func randomFileName(folder string, suffix string) string { _, name := generateRandomBytes(16) return filepath.Join(folder, hex.EncodeToString(name)+suffix) } + +// Detect card type from card number +func detectCardType(cardNum string) (string, error) { + + var cardTypeIndex creditcard.CardType + var err error + + card := creditcard.Card{ + Type: "N/A", + Number: cardNum, + ExpiryMonth: 12, + ExpiryYear: 99, + CVV: "999", + } + + cardTypeIndex, err = card.DetermineCardType() + if err != nil { + return "", err + } + + return creditcard.CardTypeNames[cardTypeIndex], nil +} + +// Validate CVV +func validateCvv(cardCvv string, cardClass string) bool { + + var matched bool + + // Amex CVV is 4 digits, rest are 3 + if cardClass == "American Express" { + if matched, _ = regexp.Match(`^\d{4}$`, []byte(cardCvv)); matched { + return matched + } + } else { + if matched, _ = regexp.Match(`^\d{3}$`, []byte(cardCvv)); matched { + return matched + } + } + + return false +} + +func validateCardPin(cardPin string) bool { + + // A PIN is 4 digits or more + if matched, _ := regexp.Match(`^\d{4,}$`, []byte(cardPin)); matched { + return matched + } + + return false +} + +// Verify if the expiry date is in the form mm/dd +func checkValidExpiry(expiryDate string) bool { + + pieces := strings.Split(expiryDate, "/") + + if len(pieces) == 2 { + // Sofar, so good + var month int + var year int + var err error + + month, err = strconv.Atoi(pieces[0]) + if err != nil { + fmt.Printf("Error parsing month: %s: \"%s\"\n", month, err.Error()) + return false + } + year, err = strconv.Atoi(pieces[1]) + if err != nil { + fmt.Printf("Error parsing year: %s: \"%s\"\n", year, err.Error()) + return false + } + + // Month should be in range 1 -> 12 + if month < 1 || month > 12 { + fmt.Printf("Error: invalid value for month - %d!\n", month) + return false + } + // Year should be >= current year + currYear, _ := strconv.Atoi(strconv.Itoa(time.Now().Year())[2:]) + if year < currYear { + fmt.Printf("Error: year should be >= %d\n", currYear) + return false + } + + return true + } else { + fmt.Println("Error: invalid input") + return false + } + +}