blob: 767a0fcb46f491efee67b140a8c9a3851cdc4af1 [file] [log] [blame]
#!/usr/bin/env python3
"""
A little tool to encrypt/decrypt git secrets. Kinda like password-store, but
more purpose specific and portable.
It generally expects to work with directory structures as follows:
foo/bar/secrets/plain: plaintext files
/cipher: ciphertext files, with names corresponding to
plaintext files
Note: currently all plaintext/cipher files are at a single level, ie.: there
cannot be any subdirectory within a /plain or /cipher directory.
There are multiple secret 'roots' like this in hscloud, notably:
- cluster/secrets
- hswaw/kube/secrets
In the future, some repository-based configuration might exist to specify these
roots in a nicer way, possibly with different target keys per root.
This tool a bit of a swiss army knife, and can be used in the following ways:
- as a CLI tool to encrypt/decrypt files directly
- as a library for its encryption/decryption methods, and for a SecretStore
API, which allows for basic programmatic access to secrets, decrypting
things if necessary
- as a CLI tool to 'synchronize' a directory containing plain/cipher files,
which means encrypting every new plaintext file (or new ciphertext file),
and re-encrypting all files whose keys are different from the keys list
defined in this file.
"""
import argparse
import logging
import os
import sys
import subprocess
import tempfile
# Keys that are to be used to encrypt all secret roots.
keys = [
"63DFE737F078657CC8A51C00C29ADD73B3563D82", # q3k
"482FF104C29294AD1CAF827BA43890A3DE74ECC7", # inf
"F07205946C07EEB2041A72FBC60C64879534F768", # cz2
"0879F9FCA1C836677BB808C870FD60197E195C26", # implr
]
# Currently, Patryk's GPG key is expired. This hacks around that by pretending
# it's January 2021.
# TODO(q3k/patryk): remove this once Patryk updates his key.
systime = '20210101T000000'
_logger_name = __name__
if _logger_name == '__main__':
_logger_name = 'secretstore'
logger = logging.getLogger(_logger_name)
class CLIException(Exception):
pass
def encrypt(src, dst):
cmd = [
'gpg' ,
'--encrypt',
'--faked-system-time', systime,
'--trust-model', 'always',
'--armor',
'--batch', '--yes',
'--output', dst,
]
for k in keys:
cmd.append('--recipient')
cmd.append(k)
cmd.append(src)
subprocess.check_call(cmd)
def decrypt(src, dst):
cmd = ['gpg', '--decrypt', '--batch', '--yes', '--output', dst, src]
# catch stdout to make this code less chatty.
subprocess.check_output(cmd, stderr=subprocess.STDOUT)
def _encryption_key_for_fingerprint(fp):
"""
Returns the encryption key ID for a given GPG fingerprint (eg. one from the
'keys' list.
"""
cmd = ['gpg', '-k', '--faked-system-time', systime, '--keyid-format', 'long', fp]
res = subprocess.check_output(cmd).decode()
# Sample output:
# pub rsa4096/70FD60197E195C26 2014-02-22 [SC] [expires: 2021-02-05]
# 0879F9FCA1C836677BB808C870FD60197E195C26
# uid [ultimate] Bartosz Stebel <bartoszstebel@gmail.com>
# uid [ultimate] Bartosz Stebel <implr@hackerspace.pl>
# sub rsa4096/E203C94E5CEBB3EF 2014-02-22 [E] [expires: 2021-02-05]
#
# We want to extract the 'sub' key with the [E] tag.
for line in res.split('\n'):
line = line.strip()
if not line:
continue
parts = line.split()
if len(parts) < 4:
continue
if parts[0] != 'sub':
continue
if not parts[3].startswith('[') or not parts[3].endswith(']'):
continue
usages = parts[3].strip('[]')
if 'E' not in usages:
continue
# Okay, we found the encryption key.
return parts[1].split('/')[1]
raise Exception("Could not find encryption key ID for fingerprint {}".format(fp))
_encryption_keys_cache = None
def encryption_keys():
"""
Return all encryption keys associated with the keys array.
"""
global _encryption_keys_cache
if _encryption_keys_cache is None:
_encryption_keys_cache = [_encryption_key_for_fingerprint(fp) for fp in keys]
return _encryption_keys_cache
def encrypted_for(path):
"""
Return for which encryption keys is a given GPG ciphertext file encrypted.
"""
cmd = ['gpg', '--pinentry-mode', 'cancel', '--list-packets', path]
res = subprocess.check_output(cmd, stderr=subprocess.STDOUT).decode()
# Sample output:
# gpg: encrypted with 4096-bit RSA key, ID E203C94E5CEBB3EF, created 2014-02-22
# "Bartosz Stebel <bartoszstebel@gmail.com>"
# gpg: encrypted with 2048-bit RSA key, ID 5C1B6B69E9F5EABE, created 2013-01-29
# "Piotr Dobrowolski <piotr.tytus.dobrowolski@gmail.com>"
# gpg: encrypted with 2048-bit RSA key, ID 386E893E110BC55B, created 2012-01-10
# "Sergiusz Bazanski (Low Latency Consulting) <serge@lowlatency.ie>"
# gpg: public key decryption failed: Operation cancelled
# gpg: decryption failed: No secret key
# # off=0 ctb=85 tag=1 hlen=3 plen=268
# :pubkey enc packet: version 3, algo 1, keyid 386E893E110BC55B
# data: [2047 bits]
# # off=271 ctb=85 tag=1 hlen=3 plen=268
# :pubkey enc packet: version 3, algo 1, keyid 5C1B6B69E9F5EABE
# data: [2048 bits]
# # off=542 ctb=85 tag=1 hlen=3 plen=524
# :pubkey enc packet: version 3, algo 1, keyid E203C94E5CEBB3EF
# data: [4095 bits]
# # off=1069 ctb=d2 tag=18 hlen=2 plen=121 new-ctb
# :encrypted data packet:
# length: 121
# mdc_method: 2
keys = []
for line in res.split('\n'):
line = line.strip()
if not line:
continue
parts = line.split()
if len(parts) < 9:
continue
if parts[:4] != [':pubkey', 'enc', 'packet:', 'version']:
continue
if parts[7] != 'keyid':
continue
keys.append(parts[8])
# Make unique.
return list(set(keys))
class SyncAction:
"""
SyncAction is a possible action taken to synchronize some secrets.
An action is some sort of side-effect bearing OS action (ie execution of
script or file move, or...) that can also 'describe' that it's acting - ie,
just return a human readable string of what it would be doing. These
describe descriptions are used for dry-runs of the secretstore sync
functionality.
"""
def describe(self):
return ""
def act(self):
pass
class SyncActionEncrypt:
def __init__(self, src, dst, reason):
self.src = src
self.dst = dst
self.reason = reason
def describe(self):
return f'Encrypting {os.path.split(self.src)[-1]} ({self.reason})'
def act(self):
return encrypt(self.src, self.dst)
class SyncActionDecrypt:
def __init__(self, src, dst, reason):
self.src = src
self.dst = dst
self.reason = reason
def describe(self):
return f'Decrypting {os.path.split(self.src)[-1]} ({self.reason})'
def act(self):
return decrypt(self.src, self.dst)
def sync(path: str, dry: bool):
"""Synchronize (decrypt and encrypt what's needed) a given secrets directory."""
# Turn the path into an absolute path just to make things safer.
path = os.path.abspath(path)
# Trim all trailing slashes to canonicalize.
path = path.rstrip('/')
plain_path = os.path.join(path, "plain")
cipher_path = os.path.join(path, "cipher")
# Ensure that at least one of the plain/cipher paths exist.
plain_exists = os.path.exists(plain_path)
cipher_exists = os.path.exists(cipher_path)
if not plain_exists and not cipher_exists:
raise CLIException('Given directory must contain a plain/ or cipher/ subdirectory.')
# Make missing directories.
if not plain_exists:
os.mkdir(plain_path)
if not cipher_exists:
os.mkdir(cipher_path)
# List files on both sides:
plain_files = [f for f in os.listdir(plain_path) if f != '.gitignore' and os.path.isfile(os.path.join(plain_path, f))]
cipher_files = [f for f in os.listdir(cipher_path) if os.path.isfile(os.path.join(cipher_path, f))]
# Helper function to turn a short filename within a directory to a pair
# of plain/cipher full paths.
def pc(p):
return os.path.join(plain_path, p), os.path.join(cipher_path, p)
# Make a set of all file names - no matter if only available as plain, as
# cipher, or as both.
all_files = set(plain_files + cipher_files)
# We'll be making a list of actions to perform to bring up given directory
# pair to a stable state.
actions = [] # type: List[SyncAction]
# First, for every possible file (either encrypted or decrypted), figure
# out which side is fresher based on file presence and mtime.
fresher = {} # type: Dict[str, str]
for p in all_files:
# Handle the easy case when the file only exists on one side.
if p not in cipher_files:
fresher[p] = 'plain'
continue
if p not in plain_files:
fresher[p] = 'cipher'
continue
plain, cipher = pc(p)
# Otherwise, we have both the cipher and plain version.
# Check if the decrypted version matches the plaintext version. If so,
# they're both equal.
f = tempfile.NamedTemporaryFile(delete=False)
f.close()
decrypt(cipher, f.name)
with open(f.name, 'rb') as fd:
decrypted_data = fd.read()
with open(plain, 'rb') as fc:
current_data = fc.read()
if decrypted_data == current_data:
fresher[p] = 'equal'
os.unlink(f.name)
continue
os.unlink(f.name)
# The plain and cipher versions differ. Let's choose based on mtime.
mtime_plain = os.path.getmtime(plain)
mtime_cipher = os.path.getmtime(cipher)
if mtime_plain > mtime_cipher:
fresher[p] = 'plain'
elif mtime_cipher > mtime_plain:
fresher[p] = 'cipher'
else:
raise CLIException(f'cipher/plain stalemate on {p}: contents differ, but files have same mtime')
# Find all files that need to be re-encrypted for changed keys.
reencrypt = set()
for p in cipher_files:
_, cipher = pc(p)
current = set(encrypted_for(cipher))
want = set(encryption_keys())
if current != want:
reencrypt.add(p)
# Okay, now actually construct a list of actions.
# First, all fresh==cipher keys need to be decrypted.
for p, v in fresher.items():
if v != 'cipher':
continue
plain, cipher = pc(p)
actions.append(SyncActionDecrypt(cipher, plain, "cipher version is newer"))
encrypted = set()
# Then, encrypt all fresh==plain files, and make note of what those
# are.
for p, v in fresher.items():
if v != 'plain':
continue
plain, cipher = pc(p)
actions.append(SyncActionEncrypt(plain, cipher, "plain version is newer"))
encrypted.add(p)
# Finally, re-encrypt all the files that aren't already being encrypted.
for p in reencrypt.difference(encrypted):
plain, cipher = pc(p)
actions.append(SyncActionEncrypt(plain, cipher, "needs to be re-encrypted for different keys"))
if len(actions) == 0:
logger.info('Nothing to do!')
else:
if dry:
logger.info('Would perform the following:')
else:
logger.info('Running actions...')
for a in actions:
logger.info(a.describe())
if not dry:
a.act()
class SecretStoreMissing(Exception):
pass
class SecretStore(object):
def __init__(self, plain_root, cipher_root):
self.proot = plain_root
self.croot = cipher_root
def exists(self, suffix):
p = os.path.join(self.proot, suffix)
c = os.path.join(self.croot, suffix)
return os.path.exists(c) or os.path.exists(p)
def plaintext(self, suffix):
p = os.path.join(self.proot, suffix)
c = os.path.join(self.croot, suffix)
has_p = os.path.exists(p)
has_c = os.path.exists(c)
if has_c and has_p and os.path.getctime(p) < os.path.getctime(c):
logger.info("Decrypting {} ({})...".format(suffix, c))
decrypt(c, p)
return p
def open(self, suffix, mode, *a, **kw):
p = os.path.join(self.proot, suffix)
c = os.path.join(self.croot, suffix)
if 'w' in mode:
return open(p, mode, *a, **kw)
if not self.exists(suffix):
raise SecretStoreMissing("Secret {} does not exist".format(suffix))
if not os.path.exists(p) or os.path.getctime(p) < os.path.getctime(c):
logger.info("Decrypting {} ({})...".format(suffix, c))
decrypt(c, p)
return open(p, mode, *a, **kw)
def main():
parser = argparse.ArgumentParser(description='Manage hscloud git-based secrets.')
subparsers = parser.add_subparsers(dest='mode')
parser_decrypt = subparsers.add_parser('decrypt', help='decrypt a single secret file')
parser_decrypt.add_argument('input', type=str, help='encrypted file path')
parser_decrypt.add_argument('output', type=str, default='-', help='decrypted file path file path (or - for stdout)')
parser_encrypt = subparsers.add_parser('encrypt', help='encrypt a single secret file')
parser_encrypt.add_argument('input', type=str, help='plaintext file path')
parser_encrypt.add_argument('output', type=str, default='-', help='encrypted file path file path (or - for stdout)')
parser_sync = subparsers.add_parser('sync', help='Synchronize a canonically formatted secrets/{plain,cipher} directory')
parser_sync.add_argument('dir', type=str, help='Path to secrets directory to synchronize')
parser_sync.add_argument('--dry', dest='dry', action='store_true')
parser_sync.set_defaults(dry=False)
logging.basicConfig(level='INFO')
args = parser.parse_args()
if args.mode == None:
parser.print_help()
sys.exit(1)
try:
if args.mode == 'encrypt':
encrypt(args.input, args.output)
elif args.mode == 'decrypt':
decrypt(args.input, args.output)
elif args.mode == 'sync':
sync(args.dir, dry=args.dry)
else:
# ???
raise Exception('invalid mode {}'.format(args.mode))
except CLIException as e:
logger.error(e)
sys.exit(1)
if __name__ == '__main__':
sys.exit(main() or 0)