Last active
July 23, 2023 21:19
-
-
Save a-ludi/641d5e3ca0bb9c1d8bd1 to your computer and use it in GitHub Desktop.
gpgedit lets you edit a gpg-encrypted file without much fuzz -- similar to sudoedit.
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
#!/usr/bin/python | |
# gpgedit lets you edit a gpg-encrypted file without much fuzz -- similar to | |
# sudoedit. | |
# | |
# For this to achieve, gpgedit uses gpg to decrypt your file and save the | |
# plain text in a temporary file under /dev/shm (tmpfs) which will open in | |
# an editor. After the changes have been saved and the editor closed your | |
# plain text will be encrypted and written to the original file. | |
# | |
# The following environment variables (in this order) will be searched for an | |
# editor: | |
# GPG_EDITOR, VISUAL and EDITOR | |
# The default editor is 'nano'. | |
# | |
# Thanks to Luke from StackOverflow for his answer at | |
# http://stackoverflow.com/a/12289967/346095 | |
# I took some inspiration from his script. | |
# Copyright © 2014 Arne Ludwig <arne.ludwig@posteo.de> | |
# | |
# gpgedit 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 3 of the License, or (at your option) any later | |
# version. | |
# | |
# gpgedit 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 | |
# gpgedit. If not, see <http://www.gnu.org/licenses/>. | |
from __future__ import print_function | |
from sys import version_info | |
if version_info < (2, 6): | |
raise "gpgedit: must use python 2.6 or greater" | |
import contextlib | |
import os | |
import re | |
import shutil | |
import subprocess | |
import stat | |
import sys | |
import tempfile | |
def show_usage(): | |
"""Show usage information | |
""" | |
print('''USAGE gpgedit [GPGOPTS] [OPTIONS] [--] FILE | |
gpgedit (v0.2.0) lets you edit a gpg-encrypted file without much fuzz -- | |
similar to sudoedit. | |
For this to achieve, gpgedit uses gpg to decrypt your file and save | |
the plain text in a temporary file under /dev/shm (tmpfs) which will | |
open in an editor. After the changes have been saved and the editor | |
closed your plain text will be encrypted and written to the | |
original file. | |
The following environment variables (in this order) will be searched | |
for an editor: | |
The default editor is 'nano'. | |
Options: | |
--new Create a new file. | |
--warranty, | |
--version, | |
--usage, | |
--help, -h Show this message. | |
--debug Print full error stack trace. | |
Environment Variables: | |
GPG_EDITOR, | |
VISUAL, | |
EDITOR These environment variables (in this order) will be | |
searched for an editor | |
License: | |
This 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 3 of the License, or | |
(at your option) any later version. | |
It 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 software. If not, see <https://gnu.org/licenses/>.''', | |
file=sys.stderr | |
) | |
def get_input_filename(): | |
"""Get filename to decrypt and edit | |
Exits if no file is found. | |
""" | |
filename = None | |
for arg in sys.argv: | |
if arg == '--': | |
continue | |
filename = arg | |
if filename is None: | |
if(len(sys.argv) > 0): | |
if not sys.argv[-1].startswith('-'): | |
filename = sys.argv[-1] | |
if filename is None: | |
show_usage() | |
raise Exception('missing argument FILE') | |
return filename | |
def get_editor_command(): | |
"""Get editor command | |
Try environment variable GPG_EDITOR, VISUAL and EDITOR in that | |
order; otherwise use the default value 'nano' | |
""" | |
editor = None | |
for varName in ['GPG_EDITOR', 'VISUAL', 'EDITOR']: | |
if editor is None: | |
editor = os.environ.get(varName) | |
if editor is None: | |
editor = 'nano' | |
return re.split(r'(?<!\\) ', editor) | |
def stat_has_changed(stat_before, stat_after): | |
for attr in ['st_mode', 'st_ino', 'st_dev', 'st_nlink', 'st_uid', 'st_gid', | |
'st_size', 'st_mtime', 'st_ctime']: | |
if getattr(stat_before, attr) != getattr(stat_after, attr): | |
return True | |
return False | |
def decrypt(cipherfile, plainfile, opts): | |
"""Decrypt cipherfile using gpg and write the result to plainfile | |
""" | |
cmd = ['gpg'] + list(opts) + ['--yes', '--decrypt', '--output', plainfile, | |
'--', cipherfile] | |
exit_code = subprocess.call(cmd) | |
if exit_code != 0: | |
raise Exception("gpg exited with status {}".format(exit_code)) | |
def encrypt(plainfile, cipherfile, opts): | |
"""Encrypt plainfile using gpg and write the result to cipherfile | |
""" | |
cmd = ['gpg'] + list(opts) + ['--yes', '--output', cipherfile, | |
'--', plainfile] | |
exit_code = subprocess.call(cmd) | |
if exit_code != 0: | |
raise Exception("gpg exited with status {}".format(exit_code)) | |
def edit(filename): | |
"""Edit the file with an editor | |
""" | |
editor = get_editor_command() | |
cmd = editor + [filename] | |
exit_code = subprocess.call(cmd) | |
if exit_code != 0: | |
raise Exception("{} exited with status {}".format(editor, exit_code)) | |
def get_keyid(filename): | |
cmd = ['gpg', '--list-packets', '--list-only', '--', filename] | |
proc = subprocess.Popen(cmd, stdout=subprocess.PIPE) | |
rawout = proc.communicate()[0] | |
match = re.search(b"keyid ([A-F0-9]+)", rawout) | |
if match is None or proc.returncode != 0: | |
return None | |
else: | |
return match.group(1) | |
@contextlib.contextmanager | |
def backup(filename, backupext='~'): | |
backup = filename + backupext | |
shutil.copy(filename, backup) | |
before = os.stat(backup) | |
try: | |
yield backup | |
except: | |
after = os.stat(backup) | |
if not stat_has_changed(before, after): | |
shutil.copyfile(backup, filename) | |
raise | |
finally: | |
os.remove(backup) | |
ENCRYPT_OPTS = frozenset(['-e', '--encrypt', '-c', '--symmetric']) | |
def decrypt_opts(opts): | |
newopts = list(opts) | |
for opt in ENCRYPT_OPTS | frozenset(['--new']): | |
if opt in opts: | |
newopts.remove(opt) | |
return newopts | |
NO_RECIPIENT_OPTS = frozenset(['-c', '--symmetric', '-r', '--recipient']) | |
def encrypt_opts(filename, opts): | |
newopts = list(opts) | |
opts = frozenset(opts) | |
if '--new' in opts: | |
newopts.remove('--new') | |
if opts.isdisjoint(ENCRYPT_OPTS): | |
newopts.append('--encrypt') | |
if opts.isdisjoint(NO_RECIPIENT_OPTS): | |
keyid = get_keyid(filename) | |
if keyid is not None: | |
newopts += ['--recipient', keyid] | |
else: | |
print('gpgedit: warning: could not determine keyid; please use ' + | |
'--recipient or --symmetric') | |
return newopts | |
@contextlib.contextmanager | |
def decrypted_file(filename, opts): | |
with backup(filename, '-gpgedit~'): | |
with tempfile.NamedTemporaryFile(mode='w+', prefix='gpgedit-', | |
dir='/dev/shm') as tmpfile: | |
if '--new' not in opts: | |
decrypt(filename, tmpfile.name, decrypt_opts(opts)) | |
before = os.stat(tmpfile.name) | |
yield tmpfile | |
after = os.stat(tmpfile.name) | |
if not stat_has_changed(before, after): | |
print("gpgedit: file unchanged; not writing encrypted file.") | |
else: | |
encrypt(tmpfile.name, filename, encrypt_opts(filename, opts)) | |
def gpgedit(): | |
cipherfile = get_input_filename() | |
ndrop = 1 | |
if len(sys.argv) >= 2 and sys.argv[-2] == '--': | |
ndrop += 1 | |
# Drop the script name and filename-related parts. | |
opts = sys.argv[1:-ndrop] | |
if '--new' in opts: | |
# Create empty file. | |
open(cipherfile, 'a').close() | |
with decrypted_file(cipherfile, opts) as plainfile: | |
edit(plainfile.name) | |
USAGE_OPTS = frozenset(["--help", "--usage", "--version", "--warranty", "-h"]) | |
if __name__ == '__main__': | |
opts = frozenset(sys.argv) | |
if not opts.isdisjoint(USAGE_OPTS): | |
show_usage() | |
else: | |
try: | |
gpgedit() | |
except Exception as e: | |
if '--debug' in sys.argv: | |
raise e | |
else: | |
print("gpgedit: {}".format(e), file=sys.stderr) |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment