1
1
from __future__ import annotations
2
2
3
+ import io
4
+ import tarfile
3
5
from datetime import datetime , timedelta , timezone
4
6
from enum import Enum
5
7
from functools import lru_cache
6
- from typing import Generic , NamedTuple , Type , TypeVar
8
+ from typing import BinaryIO , Generic , NamedTuple , Type , TypeVar
7
9
10
+ from cryptography .fernet import Fernet
11
+ from cryptography .hazmat .backends import default_backend
12
+ from cryptography .hazmat .primitives import hashes , serialization
13
+ from cryptography .hazmat .primitives .asymmetric import padding
8
14
from django .core .serializers .json import DjangoJSONEncoder
9
15
from django .db import models
10
16
11
17
from sentry .backup .scopes import RelocationScope
18
+ from sentry .utils import json
12
19
13
20
# Django apps we take care to never import or export from.
14
21
EXCLUDED_APPS = frozenset (("auth" , "contenttypes" , "fixtures" ))
@@ -27,6 +34,110 @@ def default(self, obj):
27
34
return super ().default (obj )
28
35
29
36
37
+ def create_encrypted_export_tarball (
38
+ json_export : json .JSONData , encrypt_with : BinaryIO
39
+ ) -> io .BytesIO :
40
+ """
41
+ Generate a tarball with 3 files:
42
+
43
+ 1. The DEK we minted, name "data.key".
44
+ 2. The public key we used to encrypt that DEK, named "key.pub".
45
+ 3. The exported JSON data, encrypted with that DEK, named "export.json".
46
+
47
+ The upshot: to decrypt the exported JSON data, you need the plaintext (decrypted) DEK. But to
48
+ decrypt the DEK, you need the private key associated with the included public key, which
49
+ you've hopefully kept in a safe, trusted location.
50
+
51
+ Note that the supplied file names are load-bearing - ex, changing to `data.key` to `foo.key`
52
+ risks breaking assumptions that the decryption side will make on the other end!
53
+ """
54
+
55
+ # Generate a new DEK (data encryption key), and use that DEK to encrypt the JSON being exported.
56
+ pem = encrypt_with .read ()
57
+ data_encryption_key = Fernet .generate_key ()
58
+ backup_encryptor = Fernet (data_encryption_key )
59
+ encrypted_json_export = backup_encryptor .encrypt (json .dumps (json_export ).encode ("utf-8" ))
60
+
61
+ # Encrypt the newly minted DEK using asymmetric public key encryption.
62
+ dek_encryption_key = serialization .load_pem_public_key (pem , default_backend ())
63
+ sha256 = hashes .SHA256 ()
64
+ mgf = padding .MGF1 (algorithm = sha256 )
65
+ oaep_padding = padding .OAEP (mgf = mgf , algorithm = sha256 , label = None )
66
+ encrypted_dek = dek_encryption_key .encrypt (data_encryption_key , oaep_padding ) # type: ignore
67
+
68
+ # Generate the tarball and write it to to a new output stream.
69
+ tar_buffer = io .BytesIO ()
70
+ with tarfile .open (fileobj = tar_buffer , mode = "w" ) as tar :
71
+ json_info = tarfile .TarInfo ("export.json" )
72
+ json_info .size = len (encrypted_json_export )
73
+ tar .addfile (json_info , fileobj = io .BytesIO (encrypted_json_export ))
74
+ key_info = tarfile .TarInfo ("data.key" )
75
+ key_info .size = len (encrypted_dek )
76
+ tar .addfile (key_info , fileobj = io .BytesIO (encrypted_dek ))
77
+ pub_info = tarfile .TarInfo ("key.pub" )
78
+ pub_info .size = len (pem )
79
+ tar .addfile (pub_info , fileobj = io .BytesIO (pem ))
80
+
81
+ return tar_buffer
82
+
83
+
84
+ def decrypt_encrypted_tarball (tarball : BinaryIO , decrypt_with : BinaryIO ) -> str :
85
+ """
86
+ A tarball encrypted by a call to `_export` with `encrypt_with` set has some specific properties (filenames, etc). This method handles all of those, and decrypts using the provided private key into an in-memory JSON string.
87
+ """
88
+
89
+ export = None
90
+ encrypted_dek = None
91
+ public_key_pem = None
92
+ private_key_pem = decrypt_with .read ()
93
+ with tarfile .open (fileobj = tarball , mode = "r" ) as tar :
94
+ for member in tar .getmembers ():
95
+ if member .isfile ():
96
+ file = tar .extractfile (member )
97
+ if file is None :
98
+ raise ValueError (f"Could not extract file for { member .name } " )
99
+
100
+ content = file .read ()
101
+ if member .name == "export.json" :
102
+ export = content .decode ("utf-8" )
103
+ elif member .name == "data.key" :
104
+ encrypted_dek = content
105
+ elif member .name == "key.pub" :
106
+ public_key_pem = content
107
+ else :
108
+ raise ValueError (f"Unknown tarball entity { member .name } " )
109
+
110
+ if export is None or encrypted_dek is None or public_key_pem is None :
111
+ raise ValueError ("A required file was missing from the temporary test tarball" )
112
+
113
+ # Compare the public and private key, to ensure that they are a match.
114
+ private_key = serialization .load_pem_private_key (
115
+ private_key_pem ,
116
+ password = None ,
117
+ backend = default_backend (),
118
+ )
119
+ generated_public_key_pem = private_key .public_key ().public_bytes (
120
+ encoding = serialization .Encoding .PEM ,
121
+ format = serialization .PublicFormat .SubjectPublicKeyInfo ,
122
+ )
123
+ if public_key_pem != generated_public_key_pem :
124
+ raise ValueError (
125
+ "The public key does not match that generated by the `decrypt_with` private key."
126
+ )
127
+
128
+ # Decrypt the DEK, then use it to decrypt the underlying JSON
129
+ decrypted_dek = private_key .decrypt ( # type: ignore
130
+ encrypted_dek ,
131
+ padding .OAEP (
132
+ mgf = padding .MGF1 (algorithm = hashes .SHA256 ()),
133
+ algorithm = hashes .SHA256 (),
134
+ label = None ,
135
+ ),
136
+ )
137
+ decryptor = Fernet (decrypted_dek )
138
+ return decryptor .decrypt (export ).decode ("utf-8" )
139
+
140
+
30
141
def get_final_derivations_of (model : Type ) -> set [Type ]:
31
142
"""A "final" derivation of the given `model` base class is any non-abstract class for the
32
143
"sentry" app with `BaseModel` as an ancestor. Top-level calls to this class should pass in
0 commit comments