Skip to content

Commit 6801cb7

Browse files
authored
feat(backup): Support import decryption (#58128)
This is the follow up to #58015, adding the corresponding `--decrypt_with` flag to decrypt tarballs at import time. Closes getsentry/team-ospo#207
1 parent f0b7c8b commit 6801cb7

File tree

8 files changed

+411
-187
lines changed

8 files changed

+411
-187
lines changed

Diff for: src/sentry/backup/exports.py

+7-48
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,9 @@
11
from __future__ import annotations
22

33
import io
4-
import tarfile
54
from typing import BinaryIO, Type
65

76
import click
8-
from cryptography.fernet import Fernet
9-
from cryptography.hazmat.backends import default_backend
10-
from cryptography.hazmat.primitives import hashes, serialization
11-
from cryptography.hazmat.primitives.asymmetric import padding
127
from django.db.models.base import Model
138

149
from sentry.backup.dependencies import (
@@ -17,7 +12,7 @@
1712
get_model_name,
1813
sorted_dependencies,
1914
)
20-
from sentry.backup.helpers import Filter
15+
from sentry.backup.helpers import Filter, create_encrypted_export_tarball
2116
from sentry.backup.scopes import ExportScope
2217
from sentry.services.hybrid_cloud.import_export.model import (
2318
RpcExportError,
@@ -47,7 +42,7 @@ def __init__(self, context: RpcExportError) -> None:
4742

4843

4944
def _export(
50-
dest,
45+
dest: BinaryIO,
5146
scope: ExportScope,
5247
*,
5348
encrypt_with: BinaryIO | None = None,
@@ -151,47 +146,11 @@ def get_exporter_for_model(model: Type[Model]):
151146
dest_wrapper.detach()
152147
return
153148

154-
# Generate a new DEK (data encryption key), and use that DEK to encrypt the JSON being exported.
155-
pem = encrypt_with.read()
156-
data_encryption_key = Fernet.generate_key()
157-
backup_encryptor = Fernet(data_encryption_key)
158-
encrypted_json_export = backup_encryptor.encrypt(json.dumps(json_export).encode("utf-8"))
159-
160-
# Encrypt the newly minted DEK using symmetric public key encryption.
161-
dek_encryption_key = serialization.load_pem_public_key(pem, default_backend())
162-
sha256 = hashes.SHA256()
163-
mgf = padding.MGF1(algorithm=sha256)
164-
oaep_padding = padding.OAEP(mgf=mgf, algorithm=sha256, label=None)
165-
encrypted_dek = dek_encryption_key.encrypt(data_encryption_key, oaep_padding) # type: ignore
166-
167-
# Generate a tarball with 3 files:
168-
#
169-
# 1. The DEK we minted, name "data.key".
170-
# 2. The public key we used to encrypt that DEK, named "key.pub".
171-
# 3. The exported JSON data, encrypted with that DEK, named "export.json".
172-
#
173-
# The upshot: to decrypt the exported JSON data, you need the plaintext (decrypted) DEK. But to
174-
# decrypt the DEK, you need the private key associated with the included public key, which
175-
# you've hopefully kept in a safe, trusted location.
176-
#
177-
# Note that the supplied file names are load-bearing - ex, changing to `data.key` to `foo.key`
178-
# risks breaking assumptions that the decryption side will make on the other end!
179-
tar_buffer = io.BytesIO()
180-
with tarfile.open(fileobj=tar_buffer, mode="w") as tar:
181-
json_info = tarfile.TarInfo("export.json")
182-
json_info.size = len(encrypted_json_export)
183-
tar.addfile(json_info, fileobj=io.BytesIO(encrypted_json_export))
184-
key_info = tarfile.TarInfo("data.key")
185-
key_info.size = len(encrypted_dek)
186-
tar.addfile(key_info, fileobj=io.BytesIO(encrypted_dek))
187-
pub_info = tarfile.TarInfo("key.pub")
188-
pub_info.size = len(pem)
189-
tar.addfile(pub_info, fileobj=io.BytesIO(pem))
190-
dest.write(tar_buffer.getvalue())
149+
dest.write(create_encrypted_export_tarball(json_export, encrypt_with).getvalue())
191150

192151

193152
def export_in_user_scope(
194-
dest,
153+
dest: BinaryIO,
195154
*,
196155
encrypt_with: BinaryIO | None = None,
197156
user_filter: set[str] | None = None,
@@ -217,7 +176,7 @@ def export_in_user_scope(
217176

218177

219178
def export_in_organization_scope(
220-
dest,
179+
dest: BinaryIO,
221180
*,
222181
encrypt_with: BinaryIO | None = None,
223182
org_filter: set[str] | None = None,
@@ -244,7 +203,7 @@ def export_in_organization_scope(
244203

245204

246205
def export_in_config_scope(
247-
dest,
206+
dest: BinaryIO,
248207
*,
249208
encrypt_with: BinaryIO | None = None,
250209
indent: int = 2,
@@ -269,7 +228,7 @@ def export_in_config_scope(
269228

270229

271230
def export_in_global_scope(
272-
dest,
231+
dest: BinaryIO,
273232
*,
274233
encrypt_with: BinaryIO | None = None,
275234
indent: int = 2,

Diff for: src/sentry/backup/helpers.py

+112-1
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,21 @@
11
from __future__ import annotations
22

3+
import io
4+
import tarfile
35
from datetime import datetime, timedelta, timezone
46
from enum import Enum
57
from functools import lru_cache
6-
from typing import Generic, NamedTuple, Type, TypeVar
8+
from typing import BinaryIO, Generic, NamedTuple, Type, TypeVar
79

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
814
from django.core.serializers.json import DjangoJSONEncoder
915
from django.db import models
1016

1117
from sentry.backup.scopes import RelocationScope
18+
from sentry.utils import json
1219

1320
# Django apps we take care to never import or export from.
1421
EXCLUDED_APPS = frozenset(("auth", "contenttypes", "fixtures"))
@@ -27,6 +34,110 @@ def default(self, obj):
2734
return super().default(obj)
2835

2936

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+
30141
def get_final_derivations_of(model: Type) -> set[Type]:
31142
"""A "final" derivation of the given `model` base class is any non-abstract class for the
32143
"sentry" app with `BaseModel` as an ancestor. Top-level calls to this class should pass in

0 commit comments

Comments
 (0)