Skip to content

Commit

Permalink
Add End-to-end encryption support to extract-events and retrieve-cale…
Browse files Browse the repository at this point in the history
…ndar-file-proxy
  • Loading branch information
MShekow committed Nov 6, 2024
1 parent 51865b5 commit a632f6b
Show file tree
Hide file tree
Showing 7 changed files with 422 additions and 17 deletions.
68 changes: 68 additions & 0 deletions calendar_sync_helper/cryptography_utils.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
import os

from cryptography.hazmat.backends import default_backend
from cryptography.hazmat.primitives import hashes
from cryptography.hazmat.primitives import padding
from cryptography.hazmat.primitives.ciphers import Cipher, algorithms, modes
from cryptography.hazmat.primitives.kdf.pbkdf2 import PBKDF2HMAC


def encrypt(plaintext: str, password: str) -> bytes:
# Derive a key from the password
salt = os.urandom(16)
kdf = PBKDF2HMAC(
algorithm=hashes.SHA256(),
length=32,
salt=salt,
iterations=100000,
backend=default_backend()
)
key = kdf.derive(password.encode())

# Generate a random 96-bit nonce
nonce = os.urandom(12)

# Initialize AES cipher in GCM mode
cipher = Cipher(algorithms.AES(key), modes.GCM(nonce), backend=default_backend())
encryptor = cipher.encryptor()

# Pad plaintext to be a multiple of the block size (128 bits for AES)
padder = padding.PKCS7(algorithms.AES.block_size).padder()
padded_data = padder.update(plaintext.encode()) + padder.finalize()

# Encrypt the padded plaintext
ciphertext = encryptor.update(padded_data) + encryptor.finalize()

# Return the salt, nonce, ciphertext, and tag
return salt + nonce + ciphertext + encryptor.tag


def decrypt(encrypted_data: bytes, password: str) -> str:
# Extract salt, nonce, ciphertext, and tag from the encrypted data
salt = encrypted_data[:16]
nonce = encrypted_data[16:28]
tag = encrypted_data[-16:]
ciphertext = encrypted_data[28:-16]

# Derive the key from the password and salt
kdf = PBKDF2HMAC(
algorithm=hashes.SHA256(),
length=32,
salt=salt,
iterations=100000,
backend=default_backend()
)
key = kdf.derive(password.encode())

# Initialize AES cipher in GCM mode for decryption
cipher = Cipher(algorithms.AES(key), modes.GCM(nonce, tag), backend=default_backend())
decryptor = cipher.decryptor()

# Decrypt the ciphertext
padded_plaintext = decryptor.update(ciphertext) + decryptor.finalize()

# Unpad the plaintext
unpadder = padding.PKCS7(algorithms.AES.block_size).unpadder()
plaintext = unpadder.update(padded_plaintext) + unpadder.finalize()

return plaintext.decode()
55 changes: 47 additions & 8 deletions calendar_sync_helper/routers/router_v1.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,9 +4,11 @@

import httpx
import validators
from cryptography.exceptions import InvalidTag
from fastapi import Header, HTTPException, APIRouter
from fastapi.encoders import jsonable_encoder

from calendar_sync_helper.cryptography_utils import decrypt, encrypt
from calendar_sync_helper.entities.entities_v1 import CalendarEventList, OutlookCalendarEvent, AbstractCalendarEvent, \
ComputeActionsInput, GoogleCalendarEvent, ComputeActionsResponse
from calendar_sync_helper.utils import is_syncblocker_event, separate_syncblocker_events, get_id_from_attendees, \
Expand All @@ -26,11 +28,15 @@
async def retrieve_calendar_file_proxy(
x_file_location: Annotated[str | None, Header()] = None,
x_auth_header_name: Annotated[str | None, Header()] = None,
x_auth_header_value: Annotated[str | None, Header()] = None
x_auth_header_value: Annotated[str | None, Header()] = None,
x_data_encryption_password: Annotated[str | None, Header()] = None
):
"""
Retrieves the real entries of a calendar stored in a file that is protected by an "Authorization" header, thus
cannot be retrieved by the "Send an HTTP request to SharePoint" action directly.
If x_data_encryption_password is set, the content at x_file_location is expected to be in binary form, and is
decrypted using the x_data_encryption_password.
"""
if not x_file_location or not x_auth_header_name or not x_auth_header_value:
raise HTTPException(status_code=400, detail="Missing required headers")
Expand All @@ -39,7 +45,6 @@ async def retrieve_calendar_file_proxy(
if not validators.url(x_file_location) or not x_file_location.startswith("http"):
raise HTTPException(status_code=400, detail="Invalid file location, must be a valid http(s) URL")

# Make HTTP request
try:
async with httpx.AsyncClient() as client:
headers = {
Expand All @@ -57,11 +62,32 @@ async def retrieve_calendar_file_proxy(
# Validate that the response content is a valid JSON
try:
await response.aread()
# Note: just calling response.json() may try to use an incorrect decoding,
# e.g. utf-8 where another one must be used
json_content = json.loads(response.text)
except Exception as e:
raise HTTPException(status_code=400, detail=f"Failed to parse JSON content: {str(e)}")
raise HTTPException(status_code=400, detail=f"Unable to read data stream from "
f"file location: {e!r}")

if x_data_encryption_password:
try:
file_as_text = decrypt(response.content, password=x_data_encryption_password)
except InvalidTag:
raise HTTPException(status_code=400, detail="Unable to decrypt data, either wrong password "
"or data was manipulated")
except Exception as e:
raise HTTPException(status_code=400, detail=f"Unable to decrypt data, unexpected error "
f"occurred: {e!r}")
else:
try:
# Note: just calling response.json() may try to use an incorrect decoding,
# e.g. utf-8 where another one must be used --> response.text takes care of the proper decoding
file_as_text = response.text
except Exception as e:
raise HTTPException(status_code=400, detail=f"Unable to decode binary response data to "
f"text: {e!r}")

try:
json_content = json.loads(file_as_text)
except Exception as e:
raise HTTPException(status_code=400, detail=f"Failed to parse JSON content: {e!r}")

return json_content
except Exception as e:
Expand All @@ -80,7 +106,8 @@ async def extract_events(
x_file_location: Annotated[str | None, Header()] = None,
x_upload_http_method: Annotated[str | None, Header()] = None,
x_auth_header_name: Annotated[str | None, Header()] = None,
x_auth_header_value: Annotated[str | None, Header()] = None
x_auth_header_value: Annotated[str | None, Header()] = None,
x_data_encryption_password: Annotated[str | None, Header()] = None
):
"""
Returns a list of the real(!) events, normalizing the data structure of the events from different calendar
Expand All @@ -97,6 +124,9 @@ async def extract_events(
If x_file_location and x_upload_http_method are set, the returned response will also be uploaded to the provided
location, using an (optional) header (x_auth_header_name, x_auth_header_value).
If x_file_location is set and an x_data_encryption_password value is provided, the data is symmetrically encrypted
with the x_data_encryption_password before uploading it to the x_file_location.
"""
if not x_unique_sync_prefix:
raise HTTPException(status_code=400, detail="You must provide the X-Unique-Sync-Prefix header")
Expand Down Expand Up @@ -144,8 +174,17 @@ async def extract_events(
headers[x_auth_header_name] = x_auth_header_value

events_as_json_dict = jsonable_encoder(events)

if x_data_encryption_password:
encrypted_content = encrypt(plaintext=json.dumps(events_as_json_dict),
password=x_data_encryption_password)
json_data = None
else:
encrypted_content = None
json_data = events_as_json_dict

response = await client.request(x_upload_http_method, url=x_file_location, headers=headers,
json=events_as_json_dict)
json=json_data, content=encrypted_content)

if response.status_code < 200 or response.status_code > 204:
raise HTTPException(status_code=400, detail=f"Failed to upload file, "
Expand Down
Loading

0 comments on commit a632f6b

Please sign in to comment.