blob: 85a316434448f022ffa1dbe0987f5f56446dd5cf [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
Sergiusz Bazanski73cef112019-04-07 00:06:23 +020052
Sergiusz Bazanski7371b722020-06-04 20:38:49 +020053_logger_name = __name__
54if _logger_name == '__main__':
55 _logger_name = 'secretstore'
56logger = logging.getLogger(_logger_name)
57
58
59class CLIException(Exception):
60 pass
Sergiusz Bazanski73cef112019-04-07 00:06:23 +020061
62
Sergiusz Bazanskide061802019-01-13 21:14:02 +010063def 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 Bazanski7371b722020-06-04 20:38:49 +020071
Sergiusz Bazanskide061802019-01-13 21:14:02 +010072def decrypt(src, dst):
Sergiusz Bazanskia9bb1d52019-04-28 17:13:12 +020073 cmd = ['gpg', '--decrypt', '--batch', '--yes', '--output', dst, src]
Sergiusz Bazanski7371b722020-06-04 20:38:49 +020074 # catch stdout to make this code less chatty.
75 subprocess.check_output(cmd, stderr=subprocess.STDOUT)
76
77
78def _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
117def 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
128def 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
181class 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
197class 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
210class 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
223def 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 Bazanskide061802019-01-13 21:14:02 +0100354
Sergiusz Bazanski73cef112019-04-07 00:06:23 +0200355
356class SecretStoreMissing(Exception):
357 pass
358
359
360class 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 Dobrowolskic10f00b2019-04-09 13:29:21 +0200371 p = os.path.join(self.proot, suffix)
372 c = os.path.join(self.croot, suffix)
373
Serge Bazanskid493ab62019-10-31 17:07:19 +0100374 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 Dobrowolskic10f00b2019-04-09 13:29:21 +0200378 logger.info("Decrypting {} ({})...".format(suffix, c))
379 decrypt(c, p)
380
381 return p
Sergiusz Bazanski73cef112019-04-07 00:06:23 +0200382
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 Dobrowolskic10f00b2019-04-09 13:29:21 +0200387 return open(p, mode, *a, **kw)
Sergiusz Bazanski73cef112019-04-07 00:06:23 +0200388
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 Bazanskia5be0d82018-12-23 01:35:07 +0100399def main():
Sergiusz Bazanski7371b722020-06-04 20:38:49 +0200400 parser = argparse.ArgumentParser(description='Manage hscloud git-based secrets.')
401 subparsers = parser.add_subparsers(dest='mode')
Serge Bazanskia5be0d82018-12-23 01:35:07 +0100402
Sergiusz Bazanski7371b722020-06-04 20:38:49 +0200403 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 Bazanskia5be0d82018-12-23 01:35:07 +0100406
Sergiusz Bazanski7371b722020-06-04 20:38:49 +0200407 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 Bazanskia5be0d82018-12-23 01:35:07 +0100437
438if __name__ == '__main__':
439 sys.exit(main() or 0)