Secure File Transfer Across Monitored Network#

在特殊情况下, 你可能必须要在不可信的机器上需要传送文件. 而这个机器所在的网络可能会有监控软件能对你的网络流量抓包分析, 从而看到内容. 又或者通过防火墙限制了一些传输文件的网络服务. 那这个情况下要怎么做才安全呢?

我的解决案有以下几个要点:

  1. 利用 AWS S3 作为中转媒介. 它支持 encryption at transit and at rest. 在网络传输过程和存储中都是加密的.

  2. 使用 windtalker Python 库做客户端加密, 保证文件在发送之前就已经被加密了.

  3. 密钥在命令行输入, 不留记录. 本地机器的网络监控只知道你把文件上传到 S3 了, 但并不知道内容是什么, 截取了内容也无法解密. AWS S3 本身就算拿到数据也不知道压缩包的内容是什么.

Secure file transfer app Python library app.py
  1# -*- coding: utf-8 -*-
  2
  3"""
  4This is an importable library can securely transfer file / folder across
  5monitored network. It uses AWS S3 to store the encrypted data and use symmetric
  6encryption algorithm to encrypt / decrypt the data.
  7
  8Requirements:
  9
 10- Python >= 3.7
 11- Dependencies::
 12
 13    boto3
 14    boto_session_manager>=1.7.2,<2.0.0
 15    s3pathlib>=2.1.2,<3.0.0
 16    windtalker>=1.0.1,<2.0.0
 17"""
 18
 19import typing as T
 20import shutil
 21import dataclasses
 22
 23from pathlib_mate import Path, T_PATH_ARG
 24from boto_session_manager import BotoSesManager
 25from s3pathlib import S3Path
 26from windtalker.api import SymmetricCipher
 27
 28
 29@dataclasses.dataclass
 30class App:
 31    """
 32    Secure file transfer app.
 33    """
 34
 35    bsm: BotoSesManager = dataclasses.field()
 36    bucket: str = dataclasses.field()
 37    folder: str = dataclasses.field()
 38    _dir_tmp: Path = dataclasses.field()
 39    _cipher: SymmetricCipher = dataclasses.field()
 40    _s3dir: S3Path = dataclasses.field()
 41
 42    @classmethod
 43    def new(
 44        cls,
 45        bsm: BotoSesManager,
 46        bucket: str,
 47        folder: str,
 48        password: T.Optional[str] = None,
 49        dir_tmp: T.Optional[T_PATH_ARG] = None,
 50    ):
 51        """
 52        Create a new instance of App.
 53
 54        :param bsm: the boto3 session manager you want to use
 55        :param bucket: which s3 bucket to store the encrypted data
 56        :param folder: which folder in the s3 bucket to store the encrypted data
 57        :param password: the password you want to use to encrypt the data
 58        :param dir_tmp: the temp dir you want to use to store the temporary data,
 59            by default, it is ``${HOME}/tmp``.
 60        """
 61        if dir_tmp is None:
 62            dir_tmp = Path.home().joinpath("tmp")
 63            dir_tmp.mkdir_if_not_exists()
 64        else:
 65            dir_tmp = Path(dir_tmp)
 66        return cls(
 67            bsm=bsm,
 68            bucket=bucket,
 69            folder=folder,
 70            _cipher=SymmetricCipher(password=password),
 71            _s3dir=S3Path(bucket, folder).to_dir(),
 72            _dir_tmp=dir_tmp,
 73        )
 74
 75    def upload(
 76        self,
 77        archive_name: str,
 78        path: T_PATH_ARG,
 79        overwrite: bool = True,
 80    ) -> S3Path:
 81        """
 82
 83        :param archive_name: a unique name for the archive.
 84        :param path: which file / dir you want to transfer.
 85        :param overwrite: if True, overwrite existing file in AWS S3.
 86        """
 87        path = Path(path).absolute()
 88        s3path = self._s3dir.joinpath(archive_name, "encrypted")
 89
 90        # --- file logic
 91        if path.is_file():
 92            if overwrite is False:  # pragma: no cover
 93                if s3path.exists(bsm=self.bsm) is True:
 94                    raise FileExistsError(f"{s3path.uri} already exists.")
 95
 96            s3path.write_bytes(
 97                data=self._cipher.encrypt_binary(path.read_bytes()),
 98                metadata={
 99                    "type": "file",
100                    "filename": path.basename,
101                },
102                bsm=self.bsm,
103            )
104            return s3path
105
106        # --- dir logic
107        elif path.is_dir():
108            dir_tmp_archive = self._dir_tmp.joinpath(archive_name)
109            try:
110                dir_tmp_archive.mkdir_if_not_exists()
111
112                path_output = dir_tmp_archive.joinpath(path.basename)
113                self._cipher.encrypt_dir(
114                    path=path,
115                    output_path=path_output,
116                    overwrite=overwrite,
117                )
118                path_output_zip = dir_tmp_archive.joinpath(path.basename + ".zip")
119                path_output.make_zip_archive(
120                    dst=path_output_zip,
121                    overwrite=overwrite,
122                    include_dir=True,
123                )
124                s3path.upload_file(
125                    path_output_zip,
126                    extra_args=dict(
127                        Metadata={
128                            "type": "dir",
129                            "dirname": path.basename,
130                        }
131                    ),
132                    overwrite=overwrite,
133                )
134            except Exception as e:
135                shutil.rmtree(dir_tmp_archive.abspath)
136                raise e
137            return s3path
138        else:  # pragma: no cover
139            raise NotImplementedError
140
141    def download(
142        self,
143        archive_name: str,
144        path: T.Optional[T_PATH_ARG] = None,
145        overwrite: bool = True,
146    ):
147        """
148
149        :param archive_name: a unique name for the archive.
150        :param path: where you want to store the downloaded file / dir.
151            if None, download to current working directory.
152        :param overwrite: if True, overwrite existing file / dir.
153        """
154        s3path = self._s3dir.joinpath(archive_name, "encrypted")
155
156        # --- file logic
157        if s3path.metadata["type"] == "file":
158            if path is None:
159                path = Path.cwd().joinpath(s3path.metadata["filename"])
160            else:
161                path = Path(path).absolute()
162            if path.exists() and overwrite is False:
163                raise FileExistsError(f"{path} already exists.")
164            path.write_bytes(self._cipher.decrypt_binary(s3path.read_bytes()))
165
166        # --- dir logic
167        elif s3path.metadata["type"] == "dir":
168            dir_tmp_archive = self._dir_tmp.joinpath(archive_name)
169            try:
170                dir_tmp_archive.mkdir_if_not_exists()
171                path_tmp_zip = dir_tmp_archive.joinpath(
172                    s3path.metadata["dirname"] + ".zip"
173                )
174                self.bsm.s3_client.download_file(
175                    Bucket=s3path.bucket,
176                    Key=s3path.key,
177                    Filename=path_tmp_zip.abspath,
178                )
179                shutil.unpack_archive(path_tmp_zip.abspath, dir_tmp_archive.abspath)
180                dir_extracted = dir_tmp_archive.joinpath(s3path.metadata["dirname"])
181
182                if path is None:
183                    dir_here = Path.cwd()
184                    self._cipher.decrypt_dir(
185                        path=dir_extracted,
186                        output_path=dir_here.joinpath(s3path.metadata["dirname"]),
187                        overwrite=overwrite,
188                    )
189                else:
190                    self._cipher.decrypt_dir(
191                        path=dir_extracted,
192                        output_path=path,
193                        overwrite=overwrite,
194                    )
195
196            except Exception as e:
197                shutil.rmtree(dir_tmp_archive.abspath)
198                raise e
Example usage of app.py
 1# -*- coding: utf-8 -*-
 2
 3from pathlib_mate import Path
 4from app import App, BotoSesManager
 5
 6dir_here = Path.dir_here(__file__)
 7
 8app = App.new(
 9    bsm=BotoSesManager(profile_name="bmt_app_dev_us_east_1"),
10    bucket="bmt-app-dev-us-east-1-data",
11    folder="projects/secure-file-transfer-across-monitored-network",
12    password="mypassword",
13)
14
15# ------------------------------------------------------------------------------
16# File
17# ------------------------------------------------------------------------------
18# --- upload
19# s3path = app.upload(archive_name="archive_test_file", path="test-file.txt")
20# print(s3path.console_url)
21
22# --- download
23# app.download(archive_name="archive_test_file", path="tmp/downloaded-test-file.txt")
24
25# ------------------------------------------------------------------------------
26# Folder
27# ------------------------------------------------------------------------------
28# --- upload
29# s3path = app.upload(archive_name="archive_test_dir", path=dir_here.joinpath("test-dir"))
30# print(s3path.console_url)
31
32# --- download
33# app.download(archive_name="archive_test_dir", path=dir_here.joinpath("tmp", "downloaded-test-dir"))