|
1 | 1 | """Service to retrieve file and bucket information from a file provider.""" |
2 | 2 |
|
| 3 | +import base64 |
| 4 | +import binascii |
3 | 5 | from abc import ABC, abstractmethod |
| 6 | +from io import BytesIO |
4 | 7 |
|
5 | 8 | import aioboto3 |
6 | 9 | import botocore.exceptions |
7 | 10 | import ujson |
| 11 | +from crypt4gh.keys import c4gh |
| 12 | +from crypt4gh.lib import encrypt |
8 | 13 | from pydantic import BaseModel, RootModel |
9 | 14 |
|
| 15 | +from ...conf.c4gh import c4gh_config |
10 | 16 | from ...conf.s3 import s3_config |
11 | 17 | from ...helpers.logger import LOG |
12 | 18 | from ...services.admin_service import AdminServiceHandler |
@@ -389,7 +395,6 @@ async def _verify_bucket_policy(self, bucket: str) -> bool: |
389 | 395 | return False |
390 | 396 |
|
391 | 397 |
|
392 | | -# WIP: to be possibly used for writing XML file to SDA S3 Inbox. |
393 | 398 | class S3InboxSDAService(FileProviderService): |
394 | 399 | """Service to manage S3 buckets in NeIC SDA S3 Inbox.""" |
395 | 400 |
|
@@ -433,30 +438,85 @@ async def check_files_exist(self, user_id: str, files: list[SubmissionFile]) -> |
433 | 438 | inbox_paths = [inbox_file.get("inboxPath", "") for inbox_file in inbox_files] |
434 | 439 | return [file.path for file in files if not any(file.path in inbox_path for inbox_path in inbox_paths)] |
435 | 440 |
|
| 441 | + async def _load_crypt4gh_keys(self) -> tuple[object, object]: |
| 442 | + """Load Crypt4GH sender secret and recipient public keys from env variables.""" |
| 443 | + conf = c4gh_config() |
| 444 | + try: |
| 445 | + sender_key_pem = base64.b64decode(conf.CRYPT4GH_PRIVATE_KEY).decode("utf-8") |
| 446 | + recipient_key_pem = base64.b64decode(conf.CRYPT4GH_PUBLIC_KEY).decode("utf-8") |
| 447 | + except (binascii.Error, UnicodeDecodeError) as ex: |
| 448 | + raise SystemException("Invalid base64 in C4GH key environment variables.") from ex |
| 449 | + |
| 450 | + try: |
| 451 | + sender_lines = [line.strip().encode("utf-8") for line in sender_key_pem.splitlines() if line.strip()] |
| 452 | + recipient_lines = [line.strip().encode("utf-8") for line in recipient_key_pem.splitlines() if line.strip()] |
| 453 | + |
| 454 | + private_data = base64.b64decode(b"".join(sender_lines[1:-1])) |
| 455 | + public_data = base64.b64decode(b"".join(recipient_lines[1:-1])) |
| 456 | + |
| 457 | + private_stream = BytesIO(private_data) |
| 458 | + if private_data.startswith(c4gh.MAGIC_WORD): |
| 459 | + private_stream.seek(len(c4gh.MAGIC_WORD)) |
| 460 | + |
| 461 | + sender_secret_key = c4gh.parse_private_key(private_stream, lambda: conf.CRYPT4GH_PRIVATE_KEY_PASSPHRASE) |
| 462 | + recipient_public_key = public_data |
| 463 | + return sender_secret_key, recipient_public_key |
| 464 | + except Exception as ex: |
| 465 | + raise SystemException("Failed to load Crypt4GH keys for Bigpicture metadata encryption.") from ex |
| 466 | + |
| 467 | + async def _encrypt_file(self, file: bytes, sender_secret_key: object, recipient_public_key: object) -> bytes: |
| 468 | + """Encrypt file bytes using crypt4gh and return encrypted payload bytes.""" |
| 469 | + infile = BytesIO(file) |
| 470 | + outfile = BytesIO() |
| 471 | + encrypt([(0, sender_secret_key, recipient_public_key)], infile, outfile) |
| 472 | + return outfile.getvalue() |
| 473 | + |
436 | 474 | async def _add_file_to_bucket( |
437 | | - self, bucket_name: str, object_key: str, access_key: str, secret_key: str, session_token: str |
| 475 | + self, |
| 476 | + bucket_name: str, |
| 477 | + object_key: str, |
| 478 | + access_key: str, |
| 479 | + secret_key: str, |
| 480 | + session_token: str, |
| 481 | + body: bytes = b"", |
438 | 482 | ) -> None: |
439 | | - """Add a new object to S3 bucket using provided credentials. |
| 483 | + """Put a C4GH encrypted object to S3 bucket using provided credentials. |
440 | 484 |
|
441 | 485 | :param bucket_name: name of the bucket |
442 | 486 | :param object_key: key for the object to be added |
443 | 487 | :param access_key: S3 access key ID |
444 | 488 | :param secret_key: S3 secret access key |
445 | 489 | :param session_token: S3 session token |
| 490 | + :param body: unencrypted object bytes |
446 | 491 | """ |
447 | | - # TODO(improve): Add file content as body instead of empty file. |
448 | | - session = aioboto3.Session() |
449 | | - async with session.client( |
450 | | - "s3", |
451 | | - endpoint_url=self.endpoint, |
452 | | - aws_access_key_id=access_key, |
453 | | - aws_secret_access_key=secret_key, |
454 | | - aws_session_token=session_token, # equivalent to s3cmd access_token |
455 | | - region_name=self.region, |
456 | | - ) as s3: |
457 | | - await s3.put_object( |
458 | | - Bucket=bucket_name, |
459 | | - Key=object_key, |
460 | | - Body="", |
461 | | - ContentType="application/json", |
| 492 | + sender_secret_key, recipient_public_key = await self._load_crypt4gh_keys() |
| 493 | + encrypted_file = await self._encrypt_file(body, sender_secret_key, recipient_public_key) |
| 494 | + |
| 495 | + try: |
| 496 | + session = aioboto3.Session() |
| 497 | + async with session.client( |
| 498 | + "s3", |
| 499 | + endpoint_url=self.endpoint, |
| 500 | + aws_access_key_id=access_key, |
| 501 | + aws_secret_access_key=secret_key, |
| 502 | + aws_session_token=session_token, # equivalent to s3cmd access_token |
| 503 | + region_name=self.region, |
| 504 | + ) as s3: |
| 505 | + await s3.put_object( |
| 506 | + Bucket=bucket_name, |
| 507 | + Key=object_key, |
| 508 | + Body=encrypted_file, |
| 509 | + ContentType="application/octet-stream", |
| 510 | + ) |
| 511 | + except botocore.exceptions.ClientError as ex: |
| 512 | + err = ex.response.get("Error", {}) |
| 513 | + code = err.get("Code") |
| 514 | + msg = err.get("Message") |
| 515 | + LOG.exception( |
| 516 | + "Failed to upload encrypted file to SDA inbox bucket '%s' key '%s': %s - %s", |
| 517 | + bucket_name, |
| 518 | + object_key, |
| 519 | + code, |
| 520 | + msg, |
462 | 521 | ) |
| 522 | + raise SystemException("Failed to upload encrypted file to SDA inbox.") from ex |
0 commit comments