gPass

So I wanted a mobile-ish way to manage my pass (password-store) passwords on GNOME for the Librem 5. This works pretty well.

Screenshots

1 2 3

This will go on GitLab at some point soon & be distributed via pip.

Code

#!/usr/bin/env python3.6
# -*- coding: utf-8 -*-
#
#  main.py
#  
#  Copyright 2020 3dom <https://benjamichaelson.keybase.pub>
#  
#  This program is free software; you can redistribute it and/or modify
#  it under the terms of the GNU General Public License as published by
#  the Free Software Foundation; either version 2 of the License, or
#  (at your option) any later version.
#  
#  This program is distributed in the hope that it will be useful,
#  but WITHOUT ANY WARRANTY; without even the implied warranty of
#  MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
#  GNU General Public License for more details.
#  
#  You should have received a copy of the GNU General Public License
#  along with this program; if not, write to the Free Software
#  Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston,
#  MA 02110-1301, USA.
#  
#  

import os
import gi
import gnupg
gi.require_version('Gtk', '3.0')
from gi.repository import Gtk,GLib

class MainWindow(Gtk.Window):

	def __init__(self):
		Gtk.Window.__init__(self)

		#Set variables

		#Check if password store directory has environment variable & set password store directory
		try:  
			self.pass_store_directory=os.environ['PASSWORD_STORE_DIR']
			print('Environment Variable "PASSWORD_STORE_DIR" is set. Using "%s"'%os.environ['PASSWORD_STORE_DIR'])
		except FileNotFoundError: 
			self.pass_store_directory=os.path.expanduser('~/.password-store/')
			print('Environment Variable "PASSWORD_STORE_DIR" is not set. Using default "~/.password-store/"')

		#Set the working gnupg directory (uses system gnupg directory)
		self.gpg_instance=gnupg.GPG()

		#Set current password-store directory
		self.current_pass_store_directory=self.pass_store_directory

#########################

		#Header bar
		header_bar=Gtk.HeaderBar()
		header_bar.set_show_close_button(True)
		header_bar.props.title='gPass'
		self.set_titlebar(header_bar)

		#"Create Menu" popover
		self.create_menu_popover=Gtk.Popover()

		#"Create Menu" button
		create_menu_button=Gtk.MenuButton('ADD')
		create_menu_button.connect('clicked',self.d_create_menu_clicked)
		create_menu_button.set_popover(self.create_menu_popover)
		header_bar.add(create_menu_button)

		#"Create Menu" button vbox
		self.create_menu_button_vbox=Gtk.Box(orientation=Gtk.Orientation.VERTICAL)
		self.create_menu_popover.add(self.create_menu_button_vbox)

		#"Create Menu" initial button options
		create_password_store_button=Gtk.Button('PASSWORD-STORE')
		create_password_store_button.connect('clicked',self.d_create_password_store_form)
		self.create_menu_button_vbox.pack_end(create_password_store_button,False,False,0)

		#Main layout box
		self.main_box=Gtk.Box(orientation=Gtk.Orientation.VERTICAL)
		self.add(self.main_box)

		#Status bar label
		self.status_bar_label=Gtk.Label()
		self.main_box.add(self.status_bar_label)

		#Scroll window & box
		scroll_window=Gtk.ScrolledWindow()
		scroll_window.set_policy(Gtk.PolicyType.AUTOMATIC,Gtk.PolicyType.AUTOMATIC)
		self.scroll_box=Gtk.Box(orientation=Gtk.Orientation.VERTICAL)
		scroll_window.add_with_viewport(self.scroll_box)
		self.main_box.pack_start(scroll_window,True,True,0)

		#Delete password popover
		self.password_name_delete_menu_popover=Gtk.Popover()

		#Delete password menu button vbox
		self.password_name_delete_button_vbox=Gtk.Box(orientation=Gtk.Orientation.VERTICAL)
		self.password_name_delete_menu_popover.add(self.password_name_delete_button_vbox)

		#Check for pass store
		self.d_check_directories_status()

#########################

	def d_create_menu_clicked(self,button):

		self.create_menu_popover.show_all()

	def d_remove_scrollbox_children(self):

		#Destory any children from passwords box
		for each_child in self.scroll_box.get_children():
			each_child.destroy()

	def d_load_passwords_clicked(self,button,directory):

		#Check that the current directory exists
		if os.path.isdir(directory):

			#Set current pass store directory
			self.current_pass_store_directory=directory

			#Change to current pass-store directory
			os.chdir(self.current_pass_store_directory)

			#Remove scroll box children
			self.d_remove_scrollbox_children()

			#Only walk in current directory
			for (directory_path,directory_names,file_names) in os.walk(self.current_pass_store_directory):

				#Populate passwords box with password-store directories
				for each_directory in sorted(directory_names):
					directory_name_button=Gtk.Button(each_directory)
					directory_name_button.connect('clicked',self.d_load_passwords_clicked,'%s%s/'%(directory_path,each_directory))
					self.scroll_box.pack_start(directory_name_button,False,False,2)

				#Populate passwords box with password-store .gpg files
				for file_name in sorted(file_names):

					#Only load gpg files
					if file_name.endswith('.gpg'):

						#Create password label and buttons
						password_name_box=Gtk.Box()
						password_name_label=Gtk.Label()
						password_name_label.set_markup("<span font_weight='bold'>%s</span>"%file_name)	
						password_name_open_button=Gtk.Button('OPEN')
						password_name_open_button.connect('clicked',self.d_open_password,str.replace('%s/%s'%(self.current_pass_store_directory.split('password-store/',1)[1],os.path.splitext(file_name)[0]),'//','/'))
						password_name_copy_button=Gtk.Button('COPY')
						password_name_copy_button.connect('clicked',self.d_copy_password,str.replace('%s/%s'%(self.current_pass_store_directory.split('password-store/',1)[1],os.path.splitext(file_name)[0]),'//','/'))
						password_name_delete_button_menu=Gtk.MenuButton('DELETE')
						password_name_delete_button_menu.connect('clicked',self.d_password_name_delete_menu_clicked,str.replace('%s/%s'%(self.current_pass_store_directory.split('password-store/',1)[1],os.path.splitext(file_name)[0]),'//','/'))
						password_name_delete_button_menu.set_popover(self.password_name_delete_menu_popover)
						password_name_box.pack_start(password_name_label,False,False,5)
						password_name_box.pack_end(password_name_delete_button_menu,False,False,5)
						password_name_box.pack_end(password_name_open_button,False,False,0)
						password_name_box.pack_end(password_name_copy_button,False,False,5)
						self.scroll_box.pack_start(password_name_box,False,False,2)

				break

			self.scroll_box.show_all()

			#Set current password-store directory as status, removing the directory prefixing password-store root directory
			self.d_set_status('password-store%s'%self.current_pass_store_directory.split('password-store',1)[1])

		#If current directory does not exist, go up a level & load directory
		else:

			if not os.path.isdir(directory):

				#Remove trailing backslash
				directory=directory[:-1]

				while not os.path.isdir(directory):
					directory=directory.rsplit('/',1)[0]
					os.chdir('..')

			self.d_load_passwords_clicked('button','%s/'%os.getcwd())

	#Reset to root password store directory
	def d_set_root_pass_store_directory(self):
		self.current_pass_store_directory=self.pass_store_directory

	#Navigate back. Might be the previous screen, or up a directory
	def d_navigate_back(self,button):

		#Check if in root password-store directory before going back
		if self.current_pass_store_directory == self.pass_store_directory:
			self.d_load_passwords_clicked('button',self.pass_store_directory)

		#Check if not in directory browser. If so, navigating back will enter the last current directory.
		elif self.status_bar_label.get_text()=='NEW PASSWORD' or self.status_bar_label.get_text()=='NEW CATEGORY' or self.status_bar_label.get_text()=='CHOOSE YOUR GPG KEY' or self.status_bar_label.get_text()=='PASSWORD DETAILS':

			self.d_load_passwords_clicked('button',self.current_pass_store_directory)

		else:

			#Enter current directory, go up a level, return current working directory
			os.chdir(self.current_pass_store_directory)
			os.chdir('..')
			self.d_load_passwords_clicked('button','%s/'%os.getcwd())

	def d_clear_screen_clicked(self,button):

		#Remove scroll box children
		self.d_remove_scrollbox_children()

		#Reset current password-store directory
		self.d_set_root_pass_store_directory()

		#Set cleared message as status
		self.d_set_status('CLEARED')

	def d_set_status(self,status_text):
		self.status_bar_label.set_markup("<span foreground='#668cff' size='large' font_weight='bold'>%s</span>"%status_text)

	def d_check_directories_status(self):

		#Check password-store exists
		if os.path.isdir(self.pass_store_directory):

			#If password-store directory found
			print('Password-Store Found In: %s'%self.pass_store_directory)

			#Validate directory exists & set current working directory
			os.chdir(self.pass_store_directory)
			self.pass_store_directory='%s/'%os.getcwd()

			#Reset current password-store directory
			self.d_set_root_pass_store_directory()

			#Add additional "Create Menu" buttons if password-store found
			self.create_password_button=Gtk.Button('PASSWORD')
			self.create_password_button.connect('clicked',self.d_create_password_form)
			self.create_password_category_button=Gtk.Button('CATEGORY')
			self.create_password_category_button.connect('clicked',self.d_create_password_category_form)
			self.create_menu_button_vbox.add(self.create_password_button)
			self.create_menu_button_vbox.add(self.create_password_category_button)

			#Add bottom buttons when password store directory exists

			#Up directory button
			self.navigate_back_button=Gtk.Button("BACK")
			self.navigate_back_button.connect('clicked',self.d_navigate_back)
			self.main_box.pack_start(self.navigate_back_button,False,False,0)

			#"Load Passwords" box
			self.bottom_button_box=Gtk.Box()
			self.main_box.pack_start(self.bottom_button_box,False,False,2)

			load_passwords_button=Gtk.Button('LOAD PASSWORDS')
			load_passwords_button.connect('clicked',self.d_load_passwords_clicked,self.pass_store_directory)
			self.bottom_button_box.pack_start(load_passwords_button,True,True,0)

			clear_screen_button=Gtk.Button('CLEAR SCREEN')
			clear_screen_button.connect('clicked',self.d_clear_screen_clicked)
			self.bottom_button_box.pack_start(clear_screen_button,True,True,0)

			#Show the main box
			self.main_box.show_all()

		else:

			#If password-store directory not found, configure message dialog
			pass_store_missing_message_dialog=Gtk.MessageDialog(self,0,Gtk.MessageType.QUESTION,Gtk.ButtonsType.OK)
			pass_store_missing_message_dialog.set_markup("<span foreground='#e67300' font_weight='heavy'>NO PASSWORD-STORE FOUND</span>")
			pass_store_missing_message_dialog.format_secondary_text('Either the Password-Store is not in the default directory, or you need to create a new store. If you need to use a custom directory, you must configure the Pass environment variable')

			#Run it
			pass_store_missing_message_dialog.run()

			#Destroy it
			pass_store_missing_message_dialog.destroy()

	#Password open screen
	def d_open_password(self,button,password_file):

		#Remove scroll box children
		self.d_remove_scrollbox_children()

		#Open password to screen
		password_open_output=os.popen('pass %s'%password_file).read()

		#Password open name label
		password_open_name_label=Gtk.Label()
		password_open_name_label.set_markup("<span foreground='#e67300' font_weight='heavy'>%s</span>"%password_file)

		#Password open textview
		self.password_open_textview=Gtk.TextView()
		self.password_open_textview.set_property('editable',False)
		self.password_open_textview.set_wrap_mode(1)

		#Set text view buffer
		password_open_textview_buffer=self.password_open_textview.get_buffer()
		password_open_textview_buffer.set_text(password_open_output)

		#Add password text to scroll box
		self.scroll_box.pack_start(password_open_name_label,False,False,5)
		self.scroll_box.pack_start(self.password_open_textview,False,False,5)

		#Set status
		self.d_set_status('PASSWORD DETAILS')

		#Show the scroll box contents
		self.scroll_box.show_all()

	#Copy password to clipboard
	def d_copy_password(self,button,password_file):

		#Copy password to clipboard
		os.popen('pass %s -c'%password_file)

	def d_password_name_delete_menu_clicked(self,button,password_file):

		#Destory any children from password name delete button vbox
		for each_child in self.password_name_delete_button_vbox.get_children():
			each_child.destroy()

		#Delete password button options
		password_name_delete_confirm_button=Gtk.Button('CONFIRM')
		password_name_delete_confirm_button.connect('clicked',self.d_delete_password,password_file)
		password_name_delete_cancel_button=Gtk.Button('CANCEL')
		password_name_delete_cancel_button.connect('clicked',self.d_delete_password,'/null')
		self.password_name_delete_button_vbox.add(password_name_delete_confirm_button)
		self.password_name_delete_button_vbox.add(password_name_delete_cancel_button)

		self.password_name_delete_menu_popover.show_all()

	#Delete password
	def d_delete_password(self,button,password_file):

		#Delete password if confirmed
		if password_file != '/null':

			#Popdown delete password menu
			self.password_name_delete_menu_popover.popdown()

			#Delete password
			os.system('pass rm %s -f'%password_file)

			#Reload current directory
			self.d_load_passwords_clicked('button',self.current_pass_store_directory)

		#Cancel delete password if /null is passed
		else:
			#Popdown delete password menu
			self.password_name_delete_menu_popover.popdown()	

	#Create password form
	def d_create_password_form(self,button):

		#Remove scroll box children & clean screen
		self.d_remove_scrollbox_children()
		self.create_menu_popover.popdown()

		#Password name box
		password_name_box=Gtk.Box()
		password_name_label=Gtk.Label()
		password_name_label.set_markup("<span foreground='#e67300' font_weight='heavy'>NAME</span>")
		self.password_name_entry=Gtk.Entry()
		password_name_box.pack_start(password_name_label,False,False,5)
		password_name_box.pack_end(self.password_name_entry,False,False,5)
		self.scroll_box.pack_start(password_name_box,False,False,5)

		#Create password input box
		password_box=Gtk.Box()
		password_label=Gtk.Label()
		password_label.set_markup("<span foreground='#e67300' font_weight='heavy'>PASSWORD</span>")
		password_length_label=Gtk.Label("Length")
		self.password_length_entry=Gtk.Entry()
		self.password_length_entry.set_text("24")
		self.password_length_entry.set_width_chars(3)
		password_generate_button=Gtk.Button('generate')
		password_generate_button.connect('clicked',self.d_generate_password)
		self.password_entry=Gtk.Entry()
		password_box.pack_start(password_label,False,False,5)
		password_box.pack_end(self.password_entry,False,False,5)
		password_box.pack_end(password_generate_button,False,False,5)
		password_box.pack_end(self.password_length_entry,False,False,5)
		password_box.pack_end(password_length_label,False,False,5)
		self.scroll_box.pack_start(password_box,False,False,5)

		#Create comment input box
		password_comment_box=Gtk.Box()
		password_comment_label=Gtk.Label()
		password_comment_label.set_markup("<span foreground='#e67300' font_weight='heavy'>COMMENT</span>")
		password_comment_textview=Gtk.TextView()
		password_comment_textview.set_wrap_mode(1)

		#Set text view buffer
		self.password_comment_textview_buffer=password_comment_textview.get_buffer()
		self.password_comment_textview_buffer.set_text("username: %semail: %surl: %scomment: %s"%(os.linesep,os.linesep,os.linesep,os.linesep))

		#Add password comment elements to the box
		password_comment_box.pack_start(password_comment_label,False,False,5)
		password_comment_box.pack_end(password_comment_textview,True,True,5)
		self.scroll_box.pack_start(password_comment_box,False,False,5)

		#Create submit button
		password_submit_button=Gtk.Button('SUBMIT')
		password_submit_button.connect('clicked',self.d_submit_create_password)
		self.scroll_box.pack_start(password_submit_button,False,False,5)

		#Set status
		self.d_set_status('NEW PASSWORD')

		#Show the scroll box contents
		self.scroll_box.show_all()

	#Generate password
	def d_generate_password(self,button):

		#Generate password command
		password_generate_output=os.popen("</dev/urandom tr -dc \'A-Za-z0-9!#$%%&\'\\'\'()*+,-./:;<=>?@[\]^_{|}~\' | head -c %s;echo -n"%self.password_length_entry.get_text()).read()

		#Set password entry text
		self.password_entry.set_text(password_generate_output)

	#Create password
	def d_submit_create_password(self,button):

		#Get password text
		password_text=self.password_entry.get_text()

		#Get textview buffer text
		startIter,endIter=self.password_comment_textview_buffer.get_bounds()
		password_comment_textview_buffer_text=self.password_comment_textview_buffer.get_text(startIter,endIter,False)

		#Issue pass new password command for current directory
		os.system('printf "%%s" "%s\n%s" | pass insert %s/%s -m'%(password_text,password_comment_textview_buffer_text,self.current_pass_store_directory.split('password-store',1)[1],self.password_name_entry.get_text()))

		#Load passwords
		self.d_load_passwords_clicked('button',self.current_pass_store_directory)

	#Create password category form
	def d_create_password_category_form(self,button):

		#Remove scroll box children & clean screen
		self.d_remove_scrollbox_children()
		self.create_menu_popover.popdown()

		#Category name field
		category_name_box=Gtk.Box()
		category_name_label=Gtk.Label()
		category_name_label.set_markup("<span foreground='#e67300' font_weight='heavy'>NAME</span>")
		self.category_name_entry=Gtk.Entry()
		category_name_box.pack_start(category_name_label,False,False,5)
		category_name_box.pack_end(self.category_name_entry,False,False,5)
		self.scroll_box.pack_start(category_name_box,False,False,5)

		#Create submit button
		category_submit_button=Gtk.Button('SUBMIT')
		category_submit_button.connect('clicked',self.d_submit_create_category)
		self.scroll_box.pack_start(category_submit_button,False,False,5)

		#Set status
		self.d_set_status('NEW CATEGORY')

		#Show the scroll box contents
		self.scroll_box.show_all()

	#Create password category
	def d_submit_create_category(self,button):
		os.mkdir('%s/%s'%(self.current_pass_store_directory,self.category_name_entry.get_text()))

		#Load passwords
		self.d_load_passwords_clicked('button','%s'%self.current_pass_store_directory)

	#Create password-store option in menu
	def d_create_password_store_form(self,button):

		#Clean screen
		self.create_menu_popover.popdown()
		self.d_remove_scrollbox_children()

		#Set status
		self.d_set_status('CHOOSE YOUR GPG KEY')

		#Only get the uid & keyid from pub keys
		for each_gpg_key in self.gpg_instance.list_keys():

			if each_gpg_key['type'] == 'pub':

				#Create gpg key containers
				gpg_key_box=Gtk.Box(orientation=Gtk.Orientation.VERTICAL)
				gpg_key_inner_box=Gtk.Box()
				gpg_key_uid_label=Gtk.Label()
				gpg_key_uid_label.set_xalign(0)
				gpg_key_box.add(gpg_key_inner_box)
				gpg_key_box.pack_start(gpg_key_uid_label,False,False,0)
				self.scroll_box.pack_start(gpg_key_box,False,False,5)

				#Create inner box widgets
				gpg_key_id_label=Gtk.Label(each_gpg_key['keyid'])
				gpg_key_select_button=Gtk.Button('SELECT')
				gpg_key_select_button.connect('clicked',self.d_submit_create_password_store,each_gpg_key['keyid'])
				gpg_key_inner_box.pack_start(gpg_key_id_label,False,False,5)
				gpg_key_inner_box.pack_end(gpg_key_select_button,False,False,0)

				#Configure markup for key uid label
				escaped_text=GLib.markup_escape_text(each_gpg_key['uids'][0],-1)
				gpg_key_uid_label.set_markup("<span foreground='#e67300' size='x-small' font_weight='heavy'>%s</span>"%escaped_text)

		#Additional info message dialog
		pass_store_creation_message_dialog=Gtk.MessageDialog(self,0,Gtk.MessageType.QUESTION,Gtk.ButtonsType.OK)
		pass_store_creation_message_dialog.set_markup("<span foreground='#e67300' font_weight='heavy'>PASSWORD-STORE CREATION</span>")
		pass_store_creation_message_dialog.format_secondary_text('This will create a new Password-Store using the selected GPG ID. If no GPG keys exist, you need to first create one using GnuPG. Selecting a different key when a Password-Store already exists, will re-encrypt existing files with the new key.')

		#Run it
		pass_store_creation_message_dialog.run()

		#Destroy it
		pass_store_creation_message_dialog.destroy()

		#Show the scroll box contents
		self.scroll_box.show_all()

	#Create password-store
	def d_submit_create_password_store(self,button,gpg_key_id):

		#Check if password-store already exists to update
		if os.path.isdir(self.pass_store_directory):

			#Remove "Create Menu" buttons
			self.create_password_button.destroy()
			self.create_password_category_button.destroy()

			#Remove bottom buttons
			self.navigate_back_button.destroy()
			self.bottom_button_box.destroy()

			#Set "Updated" as status if pass store already existed
			self.d_set_status('UPDATED PASSWORD-STORE')

		else:
			#Set "Created" as status if pass store did not exist
			self.d_set_status('CREATED NEW PASSWORD STORE')

		#Issue pass init command
		os.system('pass init %s'%gpg_key_id)

		#Remove scroll box children
		self.d_remove_scrollbox_children()

		#Check for password store
		self.d_check_directories_status()

win=MainWindow()
win.set_default_size(500,750)
win.connect("destroy", Gtk.main_quit)
win.show_all()
Gtk.main()