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