Serge Bazanski | a5be0d8 | 2018-12-23 01:35:07 +0100 | [diff] [blame] | 1 | #!/usr/bin/env python3 |
| 2 | |
Sergiusz Bazanski | 7371b72 | 2020-06-04 20:38:49 +0200 | [diff] [blame] | 3 | """ |
| 4 | A little tool to encrypt/decrypt git secrets. Kinda like password-store, but |
| 5 | more purpose specific and portable. |
Serge Bazanski | a5be0d8 | 2018-12-23 01:35:07 +0100 | [diff] [blame] | 6 | |
Sergiusz Bazanski | 7371b72 | 2020-06-04 20:38:49 +0200 | [diff] [blame] | 7 | It generally expects to work with directory structures as follows: |
| 8 | |
| 9 | foo/bar/secrets/plain: plaintext files |
| 10 | /cipher: ciphertext files, with names corresponding to |
| 11 | plaintext files |
| 12 | |
| 13 | Note: currently all plaintext/cipher files are at a single level, ie.: there |
| 14 | cannot be any subdirectory within a /plain or /cipher directory. |
| 15 | |
| 16 | There are multiple secret 'roots' like this in hscloud, notably: |
| 17 | |
| 18 | - cluster/secrets |
| 19 | - hswaw/kube/secrets |
| 20 | |
| 21 | In the future, some repository-based configuration might exist to specify these |
| 22 | roots in a nicer way, possibly with different target keys per root. |
| 23 | |
| 24 | This tool a bit of a swiss army knife, and can be used in the following ways: |
| 25 | |
| 26 | - as a CLI tool to encrypt/decrypt files directly |
| 27 | - as a library for its encryption/decryption methods, and for a SecretStore |
| 28 | API, which allows for basic programmatic access to secrets, decrypting |
| 29 | things if necessary |
| 30 | - as a CLI tool to 'synchronize' a directory containing plain/cipher files, |
| 31 | which means encrypting every new plaintext file (or new ciphertext file), |
| 32 | and re-encrypting all files whose keys are different from the keys list |
| 33 | defined in this file. |
| 34 | |
| 35 | """ |
| 36 | |
| 37 | import argparse |
Sergiusz Bazanski | 73cef11 | 2019-04-07 00:06:23 +0200 | [diff] [blame] | 38 | import logging |
| 39 | import os |
Serge Bazanski | a5be0d8 | 2018-12-23 01:35:07 +0100 | [diff] [blame] | 40 | import sys |
| 41 | import subprocess |
Sergiusz Bazanski | 7371b72 | 2020-06-04 20:38:49 +0200 | [diff] [blame] | 42 | import tempfile |
Serge Bazanski | a5be0d8 | 2018-12-23 01:35:07 +0100 | [diff] [blame] | 43 | |
Sergiusz Bazanski | 7371b72 | 2020-06-04 20:38:49 +0200 | [diff] [blame] | 44 | # Keys that are to be used to encrypt all secret roots. |
Serge Bazanski | a5be0d8 | 2018-12-23 01:35:07 +0100 | [diff] [blame] | 45 | keys = [ |
Sergiusz Bazanski | 711c4a9 | 2019-01-13 00:02:10 +0100 | [diff] [blame] | 46 | "63DFE737F078657CC8A51C00C29ADD73B3563D82", # q3k |
| 47 | "482FF104C29294AD1CAF827BA43890A3DE74ECC7", # inf |
Gitea | d600ebb | 2020-05-31 17:39:31 +0200 | [diff] [blame] | 48 | "F07205946C07EEB2041A72FBC60C64879534F768", # cz2 |
Sergiusz Bazanski | 29afb4c | 2019-05-19 03:10:25 +0200 | [diff] [blame] | 49 | "0879F9FCA1C836677BB808C870FD60197E195C26", # implr |
Serge Bazanski | a5be0d8 | 2018-12-23 01:35:07 +0100 | [diff] [blame] | 50 | ] |
| 51 | |
Sergiusz Bazanski | 73cef11 | 2019-04-07 00:06:23 +0200 | [diff] [blame] | 52 | |
Sergiusz Bazanski | 7371b72 | 2020-06-04 20:38:49 +0200 | [diff] [blame] | 53 | _logger_name = __name__ |
| 54 | if _logger_name == '__main__': |
| 55 | _logger_name = 'secretstore' |
| 56 | logger = logging.getLogger(_logger_name) |
| 57 | |
| 58 | |
| 59 | class CLIException(Exception): |
| 60 | pass |
Sergiusz Bazanski | 73cef11 | 2019-04-07 00:06:23 +0200 | [diff] [blame] | 61 | |
| 62 | |
Sergiusz Bazanski | de06180 | 2019-01-13 21:14:02 +0100 | [diff] [blame] | 63 | def encrypt(src, dst): |
| 64 | cmd = ['gpg' , '--encrypt', '--armor', '--batch', '--yes', '--output', dst] |
| 65 | for k in keys: |
| 66 | cmd.append('--recipient') |
| 67 | cmd.append(k) |
| 68 | cmd.append(src) |
| 69 | subprocess.check_call(cmd) |
| 70 | |
Sergiusz Bazanski | 7371b72 | 2020-06-04 20:38:49 +0200 | [diff] [blame] | 71 | |
Sergiusz Bazanski | de06180 | 2019-01-13 21:14:02 +0100 | [diff] [blame] | 72 | def decrypt(src, dst): |
Sergiusz Bazanski | a9bb1d5 | 2019-04-28 17:13:12 +0200 | [diff] [blame] | 73 | cmd = ['gpg', '--decrypt', '--batch', '--yes', '--output', dst, src] |
Sergiusz Bazanski | 7371b72 | 2020-06-04 20:38:49 +0200 | [diff] [blame] | 74 | # catch stdout to make this code less chatty. |
| 75 | subprocess.check_output(cmd, stderr=subprocess.STDOUT) |
| 76 | |
| 77 | |
| 78 | def _encryption_key_for_fingerprint(fp): |
| 79 | """ |
| 80 | Returns the encryption key ID for a given GPG fingerprint (eg. one from the |
| 81 | 'keys' list. |
| 82 | """ |
| 83 | cmd = ['gpg', '-k', '--keyid-format', 'long', fp] |
| 84 | res = subprocess.check_output(cmd).decode() |
| 85 | |
| 86 | # Sample output: |
| 87 | # pub rsa4096/70FD60197E195C26 2014-02-22 [SC] [expires: 2021-02-05] |
| 88 | # 0879F9FCA1C836677BB808C870FD60197E195C26 |
| 89 | # uid [ultimate] Bartosz Stebel <bartoszstebel@gmail.com> |
| 90 | # uid [ultimate] Bartosz Stebel <implr@hackerspace.pl> |
| 91 | # sub rsa4096/E203C94E5CEBB3EF 2014-02-22 [E] [expires: 2021-02-05] |
| 92 | # |
| 93 | # We want to extract the 'sub' key with the [E] tag. |
| 94 | for line in res.split('\n'): |
| 95 | line = line.strip() |
| 96 | if not line: |
| 97 | continue |
| 98 | parts = line.split() |
| 99 | if len(parts) < 4: |
| 100 | continue |
| 101 | if parts[0] != 'sub': |
| 102 | continue |
| 103 | |
| 104 | if not parts[3].startswith('[') or not parts[3].endswith(']'): |
| 105 | continue |
| 106 | usages = parts[3].strip('[]') |
| 107 | if 'E' not in usages: |
| 108 | continue |
| 109 | |
| 110 | # Okay, we found the encryption key. |
| 111 | return parts[1].split('/')[1] |
| 112 | |
| 113 | raise Exception("Could not find encryption key ID for fingerprint {}".format(fp)) |
| 114 | |
| 115 | |
| 116 | _encryption_keys_cache = None |
| 117 | def encryption_keys(): |
| 118 | """ |
| 119 | Return all encryption keys associated with the keys array. |
| 120 | """ |
| 121 | global _encryption_keys_cache |
| 122 | if _encryption_keys_cache is None: |
| 123 | _encryption_keys_cache = [_encryption_key_for_fingerprint(fp) for fp in keys] |
| 124 | |
| 125 | return _encryption_keys_cache |
| 126 | |
| 127 | |
| 128 | def encrypted_for(path): |
| 129 | """ |
| 130 | Return for which encryption keys is a given GPG ciphertext file encrypted. |
| 131 | """ |
| 132 | cmd = ['gpg', '--pinentry-mode', 'cancel', '--list-packets', path] |
| 133 | res = subprocess.check_output(cmd, stderr=subprocess.STDOUT).decode() |
| 134 | |
| 135 | # Sample output: |
| 136 | # gpg: encrypted with 4096-bit RSA key, ID E203C94E5CEBB3EF, created 2014-02-22 |
| 137 | # "Bartosz Stebel <bartoszstebel@gmail.com>" |
| 138 | # gpg: encrypted with 2048-bit RSA key, ID 5C1B6B69E9F5EABE, created 2013-01-29 |
| 139 | # "Piotr Dobrowolski <piotr.tytus.dobrowolski@gmail.com>" |
| 140 | # gpg: encrypted with 2048-bit RSA key, ID 386E893E110BC55B, created 2012-01-10 |
| 141 | # "Sergiusz Bazanski (Low Latency Consulting) <serge@lowlatency.ie>" |
| 142 | # gpg: public key decryption failed: Operation cancelled |
| 143 | # gpg: decryption failed: No secret key |
| 144 | # # off=0 ctb=85 tag=1 hlen=3 plen=268 |
| 145 | # :pubkey enc packet: version 3, algo 1, keyid 386E893E110BC55B |
| 146 | # data: [2047 bits] |
| 147 | # # off=271 ctb=85 tag=1 hlen=3 plen=268 |
| 148 | # :pubkey enc packet: version 3, algo 1, keyid 5C1B6B69E9F5EABE |
| 149 | # data: [2048 bits] |
| 150 | # # off=542 ctb=85 tag=1 hlen=3 plen=524 |
| 151 | # :pubkey enc packet: version 3, algo 1, keyid E203C94E5CEBB3EF |
| 152 | # data: [4095 bits] |
| 153 | # # off=1069 ctb=d2 tag=18 hlen=2 plen=121 new-ctb |
| 154 | # :encrypted data packet: |
| 155 | # length: 121 |
| 156 | # mdc_method: 2 |
| 157 | |
| 158 | keys = [] |
| 159 | for line in res.split('\n'): |
| 160 | line = line.strip() |
| 161 | if not line: |
| 162 | continue |
| 163 | |
| 164 | parts = line.split() |
| 165 | if len(parts) < 9: |
| 166 | continue |
| 167 | |
| 168 | |
| 169 | if parts[:4] != [':pubkey', 'enc', 'packet:', 'version']: |
| 170 | continue |
| 171 | |
| 172 | if parts[7] != 'keyid': |
| 173 | continue |
| 174 | |
| 175 | keys.append(parts[8]) |
| 176 | |
| 177 | # Make unique. |
| 178 | return list(set(keys)) |
| 179 | |
| 180 | |
| 181 | class SyncAction: |
| 182 | """ |
| 183 | SyncAction is a possible action taken to synchronize some secrets. |
| 184 | |
| 185 | An action is some sort of side-effect bearing OS action (ie execution of |
| 186 | script or file move, or...) that can also 'describe' that it's acting - ie, |
| 187 | just return a human readable string of what it would be doing. These |
| 188 | describe descriptions are used for dry-runs of the secretstore sync |
| 189 | functionality. |
| 190 | """ |
| 191 | def describe(self): |
| 192 | return "" |
| 193 | |
| 194 | def act(self): |
| 195 | pass |
| 196 | |
| 197 | class SyncActionEncrypt: |
| 198 | def __init__(self, src, dst, reason): |
| 199 | self.src = src |
| 200 | self.dst = dst |
| 201 | self.reason = reason |
| 202 | |
| 203 | def describe(self): |
| 204 | return f'Encrypting {os.path.split(self.src)[-1]} ({self.reason})' |
| 205 | |
| 206 | def act(self): |
| 207 | return encrypt(self.src, self.dst) |
| 208 | |
| 209 | |
| 210 | class SyncActionDecrypt: |
| 211 | def __init__(self, src, dst, reason): |
| 212 | self.src = src |
| 213 | self.dst = dst |
| 214 | self.reason = reason |
| 215 | |
| 216 | def describe(self): |
| 217 | return f'Decrypting {os.path.split(self.src)[-1]} ({self.reason})' |
| 218 | |
| 219 | def act(self): |
| 220 | return encrypt(self.src, self.dst) |
| 221 | |
| 222 | |
| 223 | def sync(path: str, dry: bool): |
| 224 | """Synchronize (decrypt and encrypt what's needed) a given secrets directory.""" |
| 225 | |
| 226 | # Turn the path into an absolute path just to make things safer. |
| 227 | path = os.path.abspath(path) |
| 228 | # Trim all trailing slashes to canonicalize. |
| 229 | path = path.rstrip('/') |
| 230 | |
| 231 | plain_path = os.path.join(path, "plain") |
| 232 | cipher_path = os.path.join(path, "cipher") |
| 233 | |
| 234 | # Ensure that at least one of the plain/cipher paths exist. |
| 235 | plain_exists = os.path.exists(plain_path) |
| 236 | cipher_exists = os.path.exists(cipher_path) |
| 237 | if not plain_exists and not cipher_exists: |
| 238 | raise CLIException('Given directory must contain a plain/ or cipher/ subdirectory.') |
| 239 | |
| 240 | # Make missing directories. |
| 241 | if not plain_exists: |
| 242 | os.mkdir(plain_path) |
| 243 | if not cipher_exists: |
| 244 | os.mkdir(cipher_path) |
| 245 | |
| 246 | # List files on both sides: |
| 247 | plain_files = [f for f in os.listdir(plain_path) if f != '.gitignore' and os.path.isfile(os.path.join(plain_path, f))] |
| 248 | cipher_files = [f for f in os.listdir(cipher_path) if os.path.isfile(os.path.join(cipher_path, f))] |
| 249 | |
| 250 | # Helper function to turn a short filename within a directory to a pair |
| 251 | # of plain/cipher full paths. |
| 252 | def pc(p): |
| 253 | return os.path.join(plain_path, p), os.path.join(cipher_path, p) |
| 254 | |
| 255 | # Make a set of all file names - no matter if only available as plain, as |
| 256 | # cipher, or as both. |
| 257 | all_files = set(plain_files + cipher_files) |
| 258 | |
| 259 | # We'll be making a list of actions to perform to bring up given directory |
| 260 | # pair to a stable state. |
| 261 | actions = [] # type: List[SyncAction] |
| 262 | |
| 263 | # First, for every possible file (either encrypted or decrypted), figure |
| 264 | # out which side is fresher based on file presence and mtime. |
| 265 | fresher = {} # type: Dict[str, str] |
| 266 | for p in all_files: |
| 267 | # Handle the easy case when the file only exists on one side. |
| 268 | if p not in cipher_files: |
| 269 | fresher[p] = 'plain' |
| 270 | continue |
| 271 | if p not in plain_files: |
| 272 | fresher[p] = 'cipher' |
| 273 | continue |
| 274 | |
| 275 | plain, cipher = pc(p) |
| 276 | |
| 277 | # Otherwise, we have both the cipher and plain version. |
| 278 | # Check if the decrypted version matches the plaintext version. If so, |
| 279 | # they're both equal. |
| 280 | |
| 281 | f = tempfile.NamedTemporaryFile(delete=False) |
| 282 | f.close() |
| 283 | decrypt(cipher, f.name) |
| 284 | |
| 285 | with open(f.name, 'rb') as fd: |
| 286 | decrypted_data = fd.read() |
| 287 | with open(plain, 'rb') as fc: |
| 288 | current_data = fc.read() |
| 289 | |
| 290 | if decrypted_data == current_data: |
| 291 | fresher[p] = 'equal' |
| 292 | os.unlink(f.name) |
| 293 | continue |
| 294 | |
| 295 | os.unlink(f.name) |
| 296 | |
| 297 | # The plain and cipher versions differ. Let's choose based on mtime. |
| 298 | mtime_plain = os.path.getmtime(plain) |
| 299 | mtime_cipher = os.path.getmtime(cipher) |
| 300 | |
| 301 | if mtime_plain > mtime_cipher: |
| 302 | fresher[p] = 'plain' |
| 303 | elif mtime_cipher > mtime_plain: |
| 304 | fresher[p] = 'cipher' |
| 305 | else: |
| 306 | raise CLIException(f'cipher/plain stalemate on {p}: contents differ, but files have same mtime') |
| 307 | |
| 308 | # Find all files that need to be re-encrypted for changed keys. |
| 309 | reencrypt = set() |
| 310 | for p in cipher_files: |
| 311 | _, cipher = pc(p) |
| 312 | current = set(encrypted_for(cipher)) |
| 313 | want = set(encryption_keys()) |
| 314 | |
| 315 | if current != want: |
| 316 | reencrypt.add(p) |
| 317 | |
| 318 | # Okay, now actually construct a list of actions. |
| 319 | # First, all fresh==cipher keys need to be decrypted. |
| 320 | for p, v in fresher.items(): |
| 321 | if v != 'cipher': |
| 322 | continue |
| 323 | |
| 324 | plain, cipher = pc(p) |
| 325 | actions.append(SyncActionDecrypt(cipher, plain, "cipher version is newer")) |
| 326 | |
| 327 | encrypted = set() |
| 328 | # Then, encrypt all fresh==plain files, and make note of what those |
| 329 | # are. |
| 330 | for p, v in fresher.items(): |
| 331 | if v != 'plain': |
| 332 | continue |
| 333 | |
| 334 | plain, cipher = pc(p) |
| 335 | actions.append(SyncActionEncrypt(plain, cipher, "plain version is newer")) |
| 336 | encrypted.add(p) |
| 337 | |
| 338 | # Finally, re-encrypt all the files that aren't already being encrypted. |
| 339 | for p in reencrypt.difference(encrypted): |
| 340 | plain, cipher = pc(p) |
| 341 | actions.append(SyncActionEncrypt(plain, cipher, "needs to be re-encrypted for different keys")) |
| 342 | |
| 343 | if len(actions) == 0: |
| 344 | logger.info('Nothing to do!') |
| 345 | else: |
| 346 | if dry: |
| 347 | logger.info('Would perform the following:') |
| 348 | else: |
| 349 | logger.info('Running actions...') |
| 350 | for a in actions: |
| 351 | logger.info(a.describe()) |
| 352 | if not dry: |
| 353 | a.act() |
Sergiusz Bazanski | de06180 | 2019-01-13 21:14:02 +0100 | [diff] [blame] | 354 | |
Sergiusz Bazanski | 73cef11 | 2019-04-07 00:06:23 +0200 | [diff] [blame] | 355 | |
| 356 | class SecretStoreMissing(Exception): |
| 357 | pass |
| 358 | |
| 359 | |
| 360 | class SecretStore(object): |
| 361 | def __init__(self, plain_root, cipher_root): |
| 362 | self.proot = plain_root |
| 363 | self.croot = cipher_root |
| 364 | |
| 365 | def exists(self, suffix): |
| 366 | p = os.path.join(self.proot, suffix) |
| 367 | c = os.path.join(self.croot, suffix) |
| 368 | return os.path.exists(c) or os.path.exists(p) |
| 369 | |
| 370 | def plaintext(self, suffix): |
Piotr Dobrowolski | c10f00b | 2019-04-09 13:29:21 +0200 | [diff] [blame] | 371 | p = os.path.join(self.proot, suffix) |
| 372 | c = os.path.join(self.croot, suffix) |
| 373 | |
Serge Bazanski | d493ab6 | 2019-10-31 17:07:19 +0100 | [diff] [blame] | 374 | has_p = os.path.exists(p) |
| 375 | has_c = os.path.exists(c) |
| 376 | |
| 377 | if has_c and has_p and os.path.getctime(p) < os.path.getctime(c): |
Piotr Dobrowolski | c10f00b | 2019-04-09 13:29:21 +0200 | [diff] [blame] | 378 | logger.info("Decrypting {} ({})...".format(suffix, c)) |
| 379 | decrypt(c, p) |
| 380 | |
| 381 | return p |
Sergiusz Bazanski | 73cef11 | 2019-04-07 00:06:23 +0200 | [diff] [blame] | 382 | |
| 383 | def open(self, suffix, mode, *a, **kw): |
| 384 | p = os.path.join(self.proot, suffix) |
| 385 | c = os.path.join(self.croot, suffix) |
| 386 | if 'w' in mode: |
Piotr Dobrowolski | c10f00b | 2019-04-09 13:29:21 +0200 | [diff] [blame] | 387 | return open(p, mode, *a, **kw) |
Sergiusz Bazanski | 73cef11 | 2019-04-07 00:06:23 +0200 | [diff] [blame] | 388 | |
| 389 | if not self.exists(suffix): |
| 390 | raise SecretStoreMissing("Secret {} does not exist".format(suffix)) |
| 391 | |
| 392 | if not os.path.exists(p) or os.path.getctime(p) < os.path.getctime(c): |
| 393 | logger.info("Decrypting {} ({})...".format(suffix, c)) |
| 394 | decrypt(c, p) |
| 395 | |
| 396 | return open(p, mode, *a, **kw) |
| 397 | |
| 398 | |
Serge Bazanski | a5be0d8 | 2018-12-23 01:35:07 +0100 | [diff] [blame] | 399 | def main(): |
Sergiusz Bazanski | 7371b72 | 2020-06-04 20:38:49 +0200 | [diff] [blame] | 400 | parser = argparse.ArgumentParser(description='Manage hscloud git-based secrets.') |
| 401 | subparsers = parser.add_subparsers(dest='mode') |
Serge Bazanski | a5be0d8 | 2018-12-23 01:35:07 +0100 | [diff] [blame] | 402 | |
Sergiusz Bazanski | 7371b72 | 2020-06-04 20:38:49 +0200 | [diff] [blame] | 403 | parser_decrypt = subparsers.add_parser('decrypt', help='decrypt a single secret file') |
| 404 | parser_decrypt.add_argument('input', type=str, help='encrypted file path') |
| 405 | parser_decrypt.add_argument('output', type=str, default='-', help='decrypted file path file path (or - for stdout)') |
Serge Bazanski | a5be0d8 | 2018-12-23 01:35:07 +0100 | [diff] [blame] | 406 | |
Sergiusz Bazanski | 7371b72 | 2020-06-04 20:38:49 +0200 | [diff] [blame] | 407 | parser_encrypt = subparsers.add_parser('encrypt', help='encrypt a single secret file') |
| 408 | parser_encrypt.add_argument('input', type=str, help='plaintext file path') |
| 409 | parser_encrypt.add_argument('output', type=str, default='-', help='encrypted file path file path (or - for stdout)') |
| 410 | |
| 411 | parser_sync = subparsers.add_parser('sync', help='Synchronize a canonically formatted secrets/{plain,cipher} directory') |
| 412 | parser_sync.add_argument('dir', type=str, help='Path to secrets directory to synchronize') |
| 413 | parser_sync.add_argument('--dry', dest='dry', action='store_true') |
| 414 | parser_sync.set_defaults(dry=False) |
| 415 | |
| 416 | logging.basicConfig(level='INFO') |
| 417 | |
| 418 | args = parser.parse_args() |
| 419 | |
| 420 | if args.mode == None: |
| 421 | parser.print_help() |
| 422 | sys.exit(1) |
| 423 | |
| 424 | try: |
| 425 | if args.mode == 'encrypt': |
| 426 | encrypt(args.input, args.output) |
| 427 | elif args.mode == 'decrypt': |
| 428 | decrypt(args.input, args.output) |
| 429 | elif args.mode == 'sync': |
| 430 | sync(args.dir, dry=args.dry) |
| 431 | else: |
| 432 | # ??? |
| 433 | raise Exception('invalid mode {}'.format(args.mode)) |
| 434 | except CLIException as e: |
| 435 | logger.error(e) |
| 436 | sys.exit(1) |
Serge Bazanski | a5be0d8 | 2018-12-23 01:35:07 +0100 | [diff] [blame] | 437 | |
| 438 | if __name__ == '__main__': |
| 439 | sys.exit(main() or 0) |