mirror of
https://github.com/seemoo-lab/opendrop.git
synced 2026-09-14 15:16:11 -04:00
Initial commit
This commit is contained in:
+108
@@ -0,0 +1,108 @@
|
||||
# Created by https://www.gitignore.io/api/python,visualstudiocode
|
||||
|
||||
### Python ###
|
||||
# Byte-compiled / optimized / DLL files
|
||||
__pycache__/
|
||||
*.py[cod]
|
||||
*$py.class
|
||||
|
||||
# C extensions
|
||||
*.so
|
||||
|
||||
# Distribution / packaging
|
||||
.Python
|
||||
build/
|
||||
develop-eggs/
|
||||
dist/
|
||||
downloads/
|
||||
eggs/
|
||||
.eggs/
|
||||
lib/
|
||||
lib64/
|
||||
parts/
|
||||
sdist/
|
||||
var/
|
||||
wheels/
|
||||
*.egg-info/
|
||||
.installed.cfg
|
||||
*.egg
|
||||
|
||||
# PyInstaller
|
||||
# Usually these files are written by a python script from a template
|
||||
# before PyInstaller builds the exe, so as to inject date/other infos into it.
|
||||
*.manifest
|
||||
*.spec
|
||||
|
||||
# Installer logs
|
||||
pip-log.txt
|
||||
pip-delete-this-directory.txt
|
||||
|
||||
# Unit test / coverage reports
|
||||
htmlcov/
|
||||
.tox/
|
||||
.coverage
|
||||
.coverage.*
|
||||
.cache
|
||||
.pytest_cache/
|
||||
nosetests.xml
|
||||
coverage.xml
|
||||
*.cover
|
||||
.hypothesis/
|
||||
|
||||
# Translations
|
||||
*.mo
|
||||
*.pot
|
||||
|
||||
# Flask stuff:
|
||||
instance/
|
||||
.webassets-cache
|
||||
|
||||
# Scrapy stuff:
|
||||
.scrapy
|
||||
|
||||
# Sphinx documentation
|
||||
docs/_build/
|
||||
|
||||
# PyBuilder
|
||||
target/
|
||||
|
||||
# Jupyter Notebook
|
||||
.ipynb_checkpoints
|
||||
|
||||
# pyenv
|
||||
.python-version
|
||||
|
||||
# celery beat schedule file
|
||||
celerybeat-schedule.*
|
||||
|
||||
# SageMath parsed files
|
||||
*.sage.py
|
||||
|
||||
# Environments
|
||||
.env
|
||||
.venv
|
||||
env/
|
||||
venv/
|
||||
ENV/
|
||||
env.bak/
|
||||
venv.bak/
|
||||
|
||||
# Spyder project settings
|
||||
.spyderproject
|
||||
.spyproject
|
||||
|
||||
# Rope project settings
|
||||
.ropeproject
|
||||
|
||||
# mkdocs documentation
|
||||
/site
|
||||
|
||||
# mypy
|
||||
.mypy_cache/
|
||||
|
||||
### VisualStudioCode ###
|
||||
.vscode/*
|
||||
.history
|
||||
|
||||
### IDEA ###
|
||||
.idea/
|
||||
@@ -0,0 +1,7 @@
|
||||
include README.md
|
||||
exclude .gitignore
|
||||
prune .cache
|
||||
prune .git
|
||||
prune build
|
||||
prune dist
|
||||
recursive-exclude *.egg-info *
|
||||
@@ -0,0 +1,96 @@
|
||||
# OpenDrop: an Open Source AirDrop Implementation
|
||||
|
||||
*OpenDrop* is a command-line tool that allows sharing files between devices directly over Wi-Fi. Its unique feature is that it is protocol-compatible with Apple AirDrop which allows to share files with Apple devices running iOS and macOS.
|
||||
Currently (and probably also for the foreseeable future), OpenDrop only supports sending to Apple devices that are discoverable by *everybody* as the default *contacts only* mode requires [Apple-signed certificates](https://www.apple.com/certificateauthority/pdf/Apple_AAI_CPS_v6.1.pdf).
|
||||
|
||||
|
||||
## Disclaimer
|
||||
|
||||
OpenDrop is experimental software and is the result of reverse engineering efforts by the [Open Wireless Link](https://owlink.org) project.
|
||||
Therefore, it does not support all features of AirDrop or might be incompatible with future AirDrop versions.
|
||||
OpenDrop is not affiliated with or endorsed by Apple Inc. Use this code at your own risk.
|
||||
|
||||
|
||||
## Requirements
|
||||
|
||||
To achieve compatibility with Apple AirDrop, OpenDrop requires the target platform to support a specific Wi-Fi link layer as well as several libraries.
|
||||
|
||||
**Apple Wireless Direct Link.**
|
||||
As AirDrop exclusively runs over Apple Wireless Direct Link (AWDL), OpenDrop is only supported on macOS or on Linux systems running
|
||||
an open re-implementation of AWDL.
|
||||
|
||||
**Libraries.**
|
||||
OpenDrop relies on current versions of [OpenSSL](https://www.openssl.org) and [libarchive](https://www.libarchive.org).
|
||||
macOS ships with rather old versions of the two, so you will need to install newer version, for example, via [Homebrew](https://brew.sh).
|
||||
In any case, you will need to set the two environmental variables `LIBARCHIVE` and `LIBCRYPTO` accordingly.
|
||||
For example, use `brew` to install the libraries:
|
||||
```bash
|
||||
brew install libarchive openssl@1.1
|
||||
```
|
||||
Then set environmental variables:
|
||||
```bash
|
||||
export LIBARCHIVE=/usr/local/opt/libarchive/lib/libarchive.dylib
|
||||
export LIBCRYPTO=/usr/local/opt/openssl@1.1/lib/libcrypto.dylib
|
||||
```
|
||||
Linux distributions should ship with more up-to-date versions, so this won't be necessary.
|
||||
|
||||
|
||||
## Installation
|
||||
|
||||
Installation of the python package is straight forward.
|
||||
After cloning this repository to `<PATH>`, install via `pip3`:
|
||||
```
|
||||
pip3 install <PATH>
|
||||
```
|
||||
|
||||
|
||||
## Usage
|
||||
|
||||
We briefly explain how to send and receive files using `opendrop`.
|
||||
To see all command line options, run `opendrop -h`.
|
||||
|
||||
### Sending a File
|
||||
|
||||
Sending a file is typically a two-step procedure. You first discover devices in proximity using the `find` command.
|
||||
Stop the process once you have found the receiver.
|
||||
```
|
||||
$ opendrop find
|
||||
Looking for receivers. Press enter to stop ...
|
||||
Found index 0 ID eccb2f2dcfe7 name John’s iPhone
|
||||
Found index 1 ID e63138ac6ba8 name Jane’s MacBook Pro
|
||||
```
|
||||
You can then `send` a file using
|
||||
```
|
||||
$ opendrop send -r 0 -f /path/to/some/file
|
||||
Asking receiver to accept ...
|
||||
Receiver accepted
|
||||
Uploading file ...
|
||||
Uploading has been successful
|
||||
```
|
||||
Instead of the `index`, you can also use `ID` or `name`.
|
||||
OpenDrop will try to interpret the input in the order (1) `index`, (2) `ID`, and (3) `name` and fail if no match was found.
|
||||
|
||||
### Receiving Files
|
||||
|
||||
Receiving is much easier. Simply use the `receive` command. OpenDrop will accept all incoming files automatically and put received files in the current directory.
|
||||
```
|
||||
$ opendrop receive
|
||||
```
|
||||
|
||||
|
||||
## Related Papers
|
||||
|
||||
* Milan Stute, Sashank Narain, Alex Mariotto, Alexander Heinrich, David Kreitschmann, Guevara Noubir, and Matthias Hollick. **A Billion Open Interfaces for Eve and Mallory: MitM, DoS, and Tracking Attacks on iOS and macOS Through Apple Wireless Direct Link.** *28th USENIX Security Symposium (USENIX Security ’19)*, August 14–16, 2019, Santa Clara, CA, USA. [Link](https://www.usenix.org/conference/usenixsecurity19/presentation/stute)
|
||||
|
||||
|
||||
## Authors
|
||||
|
||||
* **Milan Stute** ([email](mailto:mstute@seemoo.tu-darmstadt.de), [web](https://seemoo.de/mstute))
|
||||
* **Alexander Heinrich**
|
||||
|
||||
|
||||
## License
|
||||
|
||||
OpenDrop is licensed under the **GNU General Public License v3.0**.
|
||||
We use a modified version of the [`python-zeroconf`](https://pypi.org/project/zeroconf/) package (essentially adding rudimentary IPv6 and AWDL support) which is licensed under the **GNU Lesser General Public License v2.1**.
|
||||
Both licenses are found in the `COPYING` file.
|
||||
@@ -0,0 +1,24 @@
|
||||
"""
|
||||
OpenDrop: an open source AirDrop implementation
|
||||
Copyright (C) 2018 Milan Stute
|
||||
Copyright (C) 2018 Alexander Heinrich
|
||||
|
||||
This program is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU General Public License as published by
|
||||
the Free Software Foundation, either version 3 of the License, or
|
||||
(at your option) any later version.
|
||||
|
||||
This program is distributed in the hope that it will be useful,
|
||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
GNU General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU General Public License
|
||||
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
"""
|
||||
|
||||
import logging
|
||||
|
||||
__version__ = '0.10'
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
@@ -0,0 +1,22 @@
|
||||
"""
|
||||
OpenDrop: an open source AirDrop implementation
|
||||
Copyright (C) 2018 Milan Stute
|
||||
Copyright (C) 2018 Alexander Heinrich
|
||||
|
||||
This program is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU General Public License as published by
|
||||
the Free Software Foundation, either version 3 of the License, or
|
||||
(at your option) any later version.
|
||||
|
||||
This program is distributed in the hope that it will be useful,
|
||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
GNU General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU General Public License
|
||||
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
"""
|
||||
|
||||
from opendrop import cli
|
||||
|
||||
cli.main()
|
||||
@@ -0,0 +1,28 @@
|
||||
-----BEGIN CERTIFICATE-----
|
||||
MIIEuzCCA6OgAwIBAgIBAjANBgkqhkiG9w0BAQUFADBiMQswCQYDVQQGEwJVUzET
|
||||
MBEGA1UEChMKQXBwbGUgSW5jLjEmMCQGA1UECxMdQXBwbGUgQ2VydGlmaWNhdGlv
|
||||
biBBdXRob3JpdHkxFjAUBgNVBAMTDUFwcGxlIFJvb3QgQ0EwHhcNMDYwNDI1MjE0
|
||||
MDM2WhcNMzUwMjA5MjE0MDM2WjBiMQswCQYDVQQGEwJVUzETMBEGA1UEChMKQXBw
|
||||
bGUgSW5jLjEmMCQGA1UECxMdQXBwbGUgQ2VydGlmaWNhdGlvbiBBdXRob3JpdHkx
|
||||
FjAUBgNVBAMTDUFwcGxlIFJvb3QgQ0EwggEiMA0GCSqGSIb3DQEBAQUAA4IBDwAw
|
||||
ggEKAoIBAQDkkakJH5HbHkdQ6wXtXnmELes2oldMVeyLGYne+Uts9QerIjAC6Bg+
|
||||
+FAJ039BqJj50cpmnCRrEdCju+QbKsMflZ56DKRHi1vUFjczy8QPTc4UadHJGXL1
|
||||
XQ7Vf1+b8iUDulWPTV0N8WQ1IxVLFVkds5T39pyez1C6wVhQZ48ItCD3y6wsIG9w
|
||||
tj8BMIy3Q88PnT3zK0koGsj+zrW5DtleHNbLPbU6rfQPDgCSC7EhFi501TwN22IW
|
||||
q6NxkkdTVcGvL0Gz+PvjcM3mo0xFfh9Ma1CWQYnEdGILEINBhzOKgbEwWOxaBDKM
|
||||
aLOPHd5lc/9nXmW8Sdh2nzMUZaF3lMktAgMBAAGjggF6MIIBdjAOBgNVHQ8BAf8E
|
||||
BAMCAQYwDwYDVR0TAQH/BAUwAwEB/zAdBgNVHQ4EFgQUK9BpR5R2Cf70a40uQKb3
|
||||
R01/CF4wHwYDVR0jBBgwFoAUK9BpR5R2Cf70a40uQKb3R01/CF4wggERBgNVHSAE
|
||||
ggEIMIIBBDCCAQAGCSqGSIb3Y2QFATCB8jAqBggrBgEFBQcCARYeaHR0cHM6Ly93
|
||||
d3cuYXBwbGUuY29tL2FwcGxlY2EvMIHDBggrBgEFBQcCAjCBthqBs1JlbGlhbmNl
|
||||
IG9uIHRoaXMgY2VydGlmaWNhdGUgYnkgYW55IHBhcnR5IGFzc3VtZXMgYWNjZXB0
|
||||
YW5jZSBvZiB0aGUgdGhlbiBhcHBsaWNhYmxlIHN0YW5kYXJkIHRlcm1zIGFuZCBj
|
||||
b25kaXRpb25zIG9mIHVzZSwgY2VydGlmaWNhdGUgcG9saWN5IGFuZCBjZXJ0aWZp
|
||||
Y2F0aW9uIHByYWN0aWNlIHN0YXRlbWVudHMuMA0GCSqGSIb3DQEBBQUAA4IBAQBc
|
||||
NplMLXi37Yyb3PN3m/J20ncwT8EfhYOFG5k9RzfyqZtAjizUsZAS2L70c5vu0mQP
|
||||
y3lPNNiiPvl4/2vIB+x9OYOLUyDTOMSxv5pPCmv/K/xZpwUJfBdAVhEedNO3iyM7
|
||||
R6PVbyTi69G3cN8PReEnyvFteO3ntRcXqNx+IjXKJdXZD9Zr1KIkIxH3oayPc4Fg
|
||||
xhtbCS+SsvhESPBgOJ4V9T0mZyCKM2r3DYLP3uujL/lTaltkwGMzd/c6ByxW69oP
|
||||
IQ7aunMZT7XZNn/Bh1XZp5m5MkL72NVxnn6hUrcbvZNCJBIqxw8dtk2cXmPIS4AX
|
||||
UKqK1drk/NAJBzewdXUh
|
||||
-----END CERTIFICATE-----
|
||||
+196
@@ -0,0 +1,196 @@
|
||||
"""
|
||||
OpenDrop: an open source AirDrop implementation
|
||||
Copyright (C) 2018 Milan Stute
|
||||
Copyright (C) 2018 Alexander Heinrich
|
||||
|
||||
This program is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU General Public License as published by
|
||||
the Free Software Foundation, either version 3 of the License, or
|
||||
(at your option) any later version.
|
||||
|
||||
This program is distributed in the hope that it will be useful,
|
||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
GNU General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU General Public License
|
||||
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
"""
|
||||
|
||||
import time
|
||||
|
||||
import ipaddress
|
||||
import logging
|
||||
import argparse
|
||||
import sys
|
||||
import json
|
||||
import os
|
||||
import threading
|
||||
|
||||
from .client import AirDropBrowser, AirDropClient
|
||||
from .config import AirDropConfig, AirDropReceiverFlags
|
||||
from .server import AirDropServer
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def main():
|
||||
AirDropCli(sys.argv[1:])
|
||||
|
||||
|
||||
class AirDropCli:
|
||||
|
||||
def __init__(self, args):
|
||||
parser = argparse.ArgumentParser()
|
||||
parser.add_argument('action', choices=['receive', 'find', 'send'])
|
||||
parser.add_argument('-f', '--file', help='File to be sent')
|
||||
parser.add_argument('-r', '--receiver', help='Peer to send file to (can be index, ID, or hostname)')
|
||||
parser.add_argument('-e', '--email', nargs='*', help='User\'s email addresses (currently unused)')
|
||||
parser.add_argument('-p', '--phone', nargs='*', help='User\'s phone numbers (currently unused)')
|
||||
parser.add_argument('-l', '--legacy', help='Enable legacy mode', action='store_true')
|
||||
parser.add_argument('-d', '--debug', help='Enable debug mode', action='store_true')
|
||||
parser.add_argument('-i', '--interface', help='Which AWDL interface to use', default='awdl0')
|
||||
args = parser.parse_args(args)
|
||||
|
||||
if args.debug:
|
||||
logging.basicConfig(level=logging.DEBUG, format='%(asctime)s %(levelname)-8s %(name)s: %(message)s')
|
||||
else:
|
||||
logging.basicConfig(level=logging.INFO, format='%(message)s')
|
||||
|
||||
# TODO put emails and phone in canonical form (lower case, no '+' sign, etc.)
|
||||
|
||||
self.config = AirDropConfig(email=args.email, phone=args.phone, legacy=args.legacy,
|
||||
debug=args.debug, interface=args.interface)
|
||||
self.server = None
|
||||
self.client = None
|
||||
self.browser = None
|
||||
self.sending_started = False
|
||||
self.discover = []
|
||||
self.lock = threading.Lock()
|
||||
|
||||
try:
|
||||
if args.action == 'receive':
|
||||
self.receive()
|
||||
elif args.action == 'find':
|
||||
self.find()
|
||||
else: # args.action == 'send'
|
||||
if args.file is None:
|
||||
parser.error('Need -f,--file when using send')
|
||||
if not os.path.isfile(args.file):
|
||||
parser.error('File in -f,--file not found')
|
||||
self.file = args.file
|
||||
if args.receiver is None:
|
||||
parser.error('Need -r,--receiver when using send')
|
||||
self.receiver = args.receiver
|
||||
self.send()
|
||||
except KeyboardInterrupt:
|
||||
if self.browser is not None:
|
||||
self.browser.stop()
|
||||
if self.server is not None:
|
||||
self.server.stop()
|
||||
|
||||
def find(self):
|
||||
logger.info('Looking for receivers. Press enter to stop ...')
|
||||
self.browser = AirDropBrowser(self.config)
|
||||
self.browser.start(callback_add=self._found_receiver)
|
||||
try:
|
||||
input()
|
||||
except KeyboardInterrupt:
|
||||
pass
|
||||
finally:
|
||||
self.browser.stop()
|
||||
logger.debug('Save discovery results to {}'.format(self.config.discovery_report))
|
||||
with open(self.config.discovery_report, 'w') as f:
|
||||
json.dump(self.discover, f)
|
||||
|
||||
def _found_receiver(self, info):
|
||||
thread = threading.Thread(target=self._send_discover, args=(info,))
|
||||
thread.start()
|
||||
|
||||
def _send_discover(self, info):
|
||||
try:
|
||||
address = ipaddress.ip_address(info.address).compressed
|
||||
except ValueError:
|
||||
return # not a valid address
|
||||
id = info.name.split('.')[0]
|
||||
hostname = info.server
|
||||
port = int(info.port)
|
||||
logger.debug('AirDrop service found: {}, {}:{}, ID {}'.format(hostname, address, port, id))
|
||||
client = AirDropClient(self.config, (address, int(port)))
|
||||
flags = int(info.properties[b'flags'])
|
||||
|
||||
if flags & AirDropReceiverFlags.SUPPORTS_DISCOVER_MAYBE:
|
||||
try:
|
||||
receiver_name = client.send_discover()
|
||||
except TimeoutError:
|
||||
pass
|
||||
else:
|
||||
receiver_name = None
|
||||
discoverable = receiver_name is not None
|
||||
|
||||
index = len(self.discover)
|
||||
node_info = {
|
||||
'name': receiver_name,
|
||||
'address': address,
|
||||
'port': port,
|
||||
'id': id,
|
||||
'flags': flags,
|
||||
'discoverable': discoverable,
|
||||
}
|
||||
self.lock.acquire()
|
||||
self.discover.append(node_info)
|
||||
if discoverable:
|
||||
logger.info('Found index {} ID {} name {}'.format(index, id, receiver_name))
|
||||
self.lock.release()
|
||||
|
||||
def receive(self):
|
||||
self.server = AirDropServer(self.config)
|
||||
self.server.start_service()
|
||||
self.server.start_server()
|
||||
|
||||
def send(self):
|
||||
info = self._get_receiver_info()
|
||||
if info is None:
|
||||
return
|
||||
self.client = AirDropClient(self.config, (info['address'], info['port']))
|
||||
logger.info('Asking receiver to accept ...')
|
||||
if not self.client.send_ask(self.file):
|
||||
logger.warning('Receiver declined')
|
||||
return
|
||||
logger.info('Receiver accepted')
|
||||
logger.info('Uploading file ...')
|
||||
if not self.client.send_upload(self.file):
|
||||
logger.warning('Uploading has failed')
|
||||
return
|
||||
logger.info('Uploading has been successful')
|
||||
|
||||
def _get_receiver_info(self):
|
||||
if not os.path.exists(self.config.discovery_report):
|
||||
logger.error('No discovery report exists, please run \'opendrop find\' first')
|
||||
return None
|
||||
age = time.time() - os.path.getmtime(self.config.discovery_report)
|
||||
if age > 60: # warn if report is older than a minute
|
||||
logger.warning('Old discovery report (%.1f seconds), consider running \'opendrop find\' again', age)
|
||||
with open(self.config.discovery_report, 'r') as f:
|
||||
infos = json.load(f)
|
||||
|
||||
# (1) try 'index'
|
||||
try:
|
||||
self.receiver = int(self.receiver)
|
||||
return infos[self.receiver]
|
||||
except ValueError:
|
||||
pass
|
||||
except IndexError:
|
||||
pass
|
||||
# (2) try 'id'
|
||||
if len(self.receiver) is 12:
|
||||
for info in infos:
|
||||
if info['id'] == self.receiver:
|
||||
return info
|
||||
# (3) try hostname
|
||||
for info in infos:
|
||||
if info['name'] == self.receiver:
|
||||
return info
|
||||
# (fail)
|
||||
logger.error('Receiver does not exist (check -r,--receiver format or try \'opendrop find\' again')
|
||||
return None
|
||||
@@ -0,0 +1,284 @@
|
||||
"""
|
||||
OpenDrop: an open source AirDrop implementation
|
||||
Copyright (C) 2018 Milan Stute
|
||||
Copyright (C) 2018 Alexander Heinrich
|
||||
|
||||
This program is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU General Public License as published by
|
||||
the Free Software Foundation, either version 3 of the License, or
|
||||
(at your option) any later version.
|
||||
|
||||
This program is distributed in the hope that it will be useful,
|
||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
GNU General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU General Public License
|
||||
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
"""
|
||||
|
||||
import fleep
|
||||
import http
|
||||
import ipaddress
|
||||
import logging
|
||||
import os
|
||||
import io
|
||||
import libarchive
|
||||
import platform
|
||||
import plistlib
|
||||
import socket
|
||||
from http import client
|
||||
|
||||
from .util import AirDropUtil, AbsArchiveWrite
|
||||
from .zeroconf import ServiceBrowser, Zeroconf
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class AirDropBrowser:
|
||||
|
||||
def __init__(self, config):
|
||||
self.legacy_mode = config.legacy
|
||||
if self.legacy_mode:
|
||||
self.useIPv6 = False
|
||||
else:
|
||||
self.useIPv6 = True
|
||||
self.ip_interface_name = config.interface
|
||||
|
||||
self.ip_addr, self.byte_address = AirDropUtil.get_ip_for_interface(self.ip_interface_name, ipv6=self.useIPv6)
|
||||
|
||||
if self.ip_addr is None:
|
||||
raise RuntimeError('Interface {} does not have IP(v6) address'.format(self.ip_interface_name))
|
||||
|
||||
if self.legacy_mode:
|
||||
self.zeroconf = Zeroconf()
|
||||
else:
|
||||
self.zeroconf = Zeroconf(interfaces=[self.ip_addr], ipv6_interface_name=self.ip_interface_name)
|
||||
|
||||
self.callback_add = None
|
||||
self.callback_remove = None
|
||||
self.browser = None
|
||||
|
||||
def start(self, callback_add=None, callback_remove=None):
|
||||
"""
|
||||
Start the AirDropBrowser to discover other AirDrop devices
|
||||
"""
|
||||
if self.browser is not None:
|
||||
return # already started
|
||||
self.callback_add = callback_add
|
||||
self.callback_remove = callback_remove
|
||||
self.browser = ServiceBrowser(self.zeroconf, '_airdrop._tcp.local.', self)
|
||||
|
||||
def stop(self):
|
||||
self.browser.cancel()
|
||||
self.browser = None
|
||||
self.zeroconf.close()
|
||||
|
||||
def add_service(self, zeroconf, type, name):
|
||||
info = zeroconf.get_service_info(type, name)
|
||||
logger.debug('Add service {}'.format(name))
|
||||
if self.callback_add is not None:
|
||||
self.callback_add(info)
|
||||
|
||||
def remove_service(self, zeroconf, type, name):
|
||||
info = zeroconf.get_service_info(type, name)
|
||||
logger.debug('Remove service {}'.format(name))
|
||||
if self.callback_remove is not None:
|
||||
self.callback_remove(info)
|
||||
|
||||
|
||||
class AirDropClient:
|
||||
|
||||
def __init__(self, config, receiver):
|
||||
self.config = config
|
||||
self.receiver_host = receiver[0]
|
||||
self.receiver_port = receiver[1]
|
||||
self.http_conn = None
|
||||
|
||||
def send_POST(self, url, body, headers=None):
|
||||
logger.debug('Send {} request'.format(url))
|
||||
|
||||
AirDropUtil.write_debug(self.config, body, 'send_{}_request.plist'.format(url.lower().strip('/')))
|
||||
|
||||
_headers = self._get_headers()
|
||||
if headers is not None:
|
||||
for key, val in headers.items():
|
||||
_headers[key] = val
|
||||
if self.http_conn is None:
|
||||
# Use single connection
|
||||
self.http_conn = HTTPSConnectionAWDL(self.receiver_host, self.receiver_port,
|
||||
interface_name=self.config.interface,
|
||||
context=self.config.get_ssl_context())
|
||||
self.http_conn.request('POST', url, body=body, headers=_headers)
|
||||
http_resp = self.http_conn.getresponse()
|
||||
|
||||
response_bytes = http_resp.read()
|
||||
AirDropUtil.write_debug(self.config, response_bytes, 'send_{}_response.plist'.format(url.lower().strip('/')))
|
||||
|
||||
if http_resp.status != 200:
|
||||
status = False
|
||||
logger.debug('{} request failed: {}'.format(url, http_resp.status))
|
||||
else:
|
||||
status = True
|
||||
logger.debug('{} request successful'.format(url))
|
||||
return status, response_bytes
|
||||
|
||||
def send_discover(self):
|
||||
discover_body = {}
|
||||
if self.config.record_data:
|
||||
discover_body['SenderRecordData'] = self.config.record_data
|
||||
|
||||
discover_plist_binary = plistlib.dumps(discover_body, fmt=plistlib.FMT_BINARY)
|
||||
success, response_bytes = self.send_POST('/Discover', discover_plist_binary)
|
||||
response = plistlib.loads(response_bytes)
|
||||
|
||||
# if name is returned, then receiver is discoverable
|
||||
return response.get('ReceiverComputerName')
|
||||
|
||||
def send_ask(self, file_path, icon=None):
|
||||
ask_body = {
|
||||
'SenderComputerName': self.config.computer_name,
|
||||
'BundleID': 'com.apple.finder',
|
||||
'SenderModelName': self.config.computer_model,
|
||||
'SenderID': self.config.service_id,
|
||||
'ConvertMediaFormats': False,
|
||||
}
|
||||
if self.config.legacy:
|
||||
ask_body['SenderEmailHash'] = AirDropUtil.doubleSHA1Hash(self.config.email)
|
||||
ask_body['SenderPhoneHash'] = AirDropUtil.doubleSHA1Hash(self.config.phone)
|
||||
if self.config.record_data:
|
||||
ask_body['SenderRecordData'] = self.config.record_data
|
||||
|
||||
if isinstance(file_path, str):
|
||||
file_path = [file_path]
|
||||
|
||||
# generate icon for first file
|
||||
with open(file_path[0], 'rb') as f:
|
||||
file_header = f.read(128)
|
||||
flp = fleep.get(file_header)
|
||||
if not icon and len(flp.mime) > 0 and 'image' in flp.mime[0]:
|
||||
icon = AirDropUtil.generate_file_icon(f.name)
|
||||
if icon:
|
||||
ask_body['FileIcon'] = icon
|
||||
|
||||
def file_entries(files):
|
||||
for file in files:
|
||||
file_name = os.path.basename(file)
|
||||
file_entry = {
|
||||
'FileName': file_name,
|
||||
'FileType': AirDropUtil.get_uti_type(flp),
|
||||
'FileBomPath': os.path.join('.', file_name),
|
||||
'FileIsDirectory': os.path.isdir(file_name),
|
||||
'ConvertMediaFormats': 0
|
||||
}
|
||||
yield file_entry
|
||||
ask_body['Files'] = [e for e in file_entries(file_path)]
|
||||
ask_body['Items'] = []
|
||||
|
||||
ask_binary = plistlib.dumps(ask_body, fmt=plistlib.FMT_BINARY)
|
||||
success, _ = self.send_POST('/Ask', ask_binary)
|
||||
|
||||
return success
|
||||
|
||||
def send_upload(self, file_path):
|
||||
"""
|
||||
Send a file to a receiver.
|
||||
"""
|
||||
headers = {
|
||||
'Content-Type': 'application/x-cpio',
|
||||
}
|
||||
|
||||
# Create archive in memory ...
|
||||
stream = io.BytesIO()
|
||||
with libarchive.custom_writer(stream.write, 'cpio', filter_name='gzip',
|
||||
archive_write_class=AbsArchiveWrite) as archive:
|
||||
for f in [file_path]:
|
||||
ff = os.path.basename(f)
|
||||
archive.add_abs_file(f, os.path.join('.', ff))
|
||||
stream.seek(0)
|
||||
|
||||
# ... then send in chunked mode
|
||||
success, _ = self.send_POST('/Upload', stream, headers=headers)
|
||||
|
||||
# TODO better: write archive chunk whenever send_POST does a read to avoid having the whole archive in memory
|
||||
|
||||
return success
|
||||
|
||||
def _get_headers(self):
|
||||
"""
|
||||
Get the headers for requests sent
|
||||
"""
|
||||
headers = {
|
||||
'Content-Type': 'application/octet-stream',
|
||||
'Connection': 'keep-alive',
|
||||
'Accept': '*/*',
|
||||
'User-Agent': 'AirDrop/1.0',
|
||||
'Accept-Language': 'en-us',
|
||||
'Accept-Encoding': 'br, gzip, deflate'
|
||||
}
|
||||
return headers
|
||||
|
||||
|
||||
class HTTPSConnectionAWDL(http.client.HTTPSConnection):
|
||||
"""
|
||||
This class allows to bind the HTTPConnection to a specific network interface
|
||||
"""
|
||||
|
||||
def __init__(self, host, port=None, key_file=None, cert_file=None, timeout=None, source_address=None,
|
||||
*, context=None, check_hostname=None, interface_name=None):
|
||||
|
||||
if interface_name is not None:
|
||||
if '%' not in host:
|
||||
if isinstance(ipaddress.ip_address(host), ipaddress.IPv6Address):
|
||||
host = host + '%' + interface_name
|
||||
|
||||
if timeout is None:
|
||||
timeout = socket.getdefaulttimeout()
|
||||
|
||||
super(HTTPSConnectionAWDL, self).__init__(host=host, port=port, key_file=key_file, cert_file=cert_file,
|
||||
timeout=timeout, source_address=source_address, context=context,
|
||||
check_hostname=check_hostname)
|
||||
|
||||
self.interface_name = interface_name
|
||||
self._create_connection = self.create_connection_awdl
|
||||
|
||||
def create_connection_awdl(self, address, timeout=socket.getdefaulttimeout(), source_address=None):
|
||||
"""Connect to *address* and return the socket object.
|
||||
|
||||
Convenience function. Connect to *address* (a 2-tuple ``(host,
|
||||
port)``) and return the socket object. Passing the optional
|
||||
*timeout* parameter will set the timeout on the socket instance
|
||||
before attempting to connect. If no *timeout* is supplied, the
|
||||
global default timeout setting returned by :func:`getdefaulttimeout`
|
||||
is used. If *source_address* is set it must be a tuple of (host, port)
|
||||
for the socket to bind as a source address before making the connection.
|
||||
A host of '' or port 0 tells the OS to use the default.
|
||||
"""
|
||||
|
||||
host, port = address
|
||||
err = None
|
||||
for res in socket.getaddrinfo(host, port, 0, socket.SOCK_STREAM):
|
||||
af, socktype, proto, canonname, sa = res
|
||||
sock = None
|
||||
try:
|
||||
sock = socket.socket(af, socktype, proto)
|
||||
if timeout is not socket.getdefaulttimeout():
|
||||
sock.settimeout(timeout)
|
||||
if self.interface_name == 'awdl0' and platform.system() == 'Darwin':
|
||||
sock.setsockopt(socket.SOL_SOCKET, 0x1104, 1)
|
||||
if source_address:
|
||||
sock.bind(source_address)
|
||||
sock.connect(sa)
|
||||
# Break explicitly a reference cycle
|
||||
err = None
|
||||
return sock
|
||||
|
||||
except socket.error as _:
|
||||
err = _
|
||||
if sock is not None:
|
||||
sock.close()
|
||||
|
||||
if err is not None:
|
||||
raise err
|
||||
else:
|
||||
raise socket.error('getaddrinfo returns an empty list')
|
||||
@@ -0,0 +1,117 @@
|
||||
"""
|
||||
OpenDrop: an open source AirDrop implementation
|
||||
Copyright (C) 2018 Milan Stute
|
||||
Copyright (C) 2018 Alexander Heinrich
|
||||
|
||||
This program is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU General Public License as published by
|
||||
the Free Software Foundation, either version 3 of the License, or
|
||||
(at your option) any later version.
|
||||
|
||||
This program is distributed in the hope that it will be useful,
|
||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
GNU General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU General Public License
|
||||
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
"""
|
||||
|
||||
import os
|
||||
import logging
|
||||
from pkg_resources import resource_filename
|
||||
import socket
|
||||
import ssl
|
||||
import random
|
||||
import subprocess
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class AirDropReceiverFlags:
|
||||
"""
|
||||
Recovered from sharingd`receiverSupportsX methods.
|
||||
A valid node needs to either have SUPPORTS_PIPELINING or SUPPORTS_MIXED_TYPES
|
||||
according to sharingd`[SDBonjourBrowser removeInvalidNodes:].
|
||||
"""
|
||||
SUPPORTS_URL = 0x01
|
||||
SUPPORTS_DVZIP = 0x02
|
||||
SUPPORTS_PIPELINING = 0x04
|
||||
SUPPORTS_MIXED_TYPES = 0x08
|
||||
SUPPORTS_UNKNOWN1 = 0x10
|
||||
SUPPORTS_UNKNOWN2 = 0x20
|
||||
SUPPORTS_IRIS = 0x40
|
||||
SUPPORTS_DISCOVER_MAYBE = 0x80 # Probably indicates that server supports /Discover URL
|
||||
|
||||
|
||||
class AirDropConfig:
|
||||
|
||||
def __init__(self, host_name=None, computer_name=None, computer_model='OpenDrop', server_port=8771,
|
||||
airdrop_dir='~/.opendrop', service_id=None,
|
||||
email=None, phone=None, legacy=False, debug=False, interface=None):
|
||||
self.airdrop_dir = os.path.expanduser(airdrop_dir)
|
||||
|
||||
self.discovery_report = os.path.join(self.airdrop_dir, 'discover.last.json')
|
||||
|
||||
if host_name is None:
|
||||
host_name = socket.gethostname()
|
||||
self.host_name = host_name
|
||||
if computer_name is None:
|
||||
computer_name = host_name
|
||||
self.computer_name = computer_name
|
||||
self.computer_model = computer_model
|
||||
self.port = server_port
|
||||
|
||||
if service_id is None:
|
||||
service_id = '{0:0{1}x}'.format(random.randint(0, 0xffffffffffff), 12) # random 6-byte string in base16
|
||||
self.service_id = service_id
|
||||
|
||||
self.debug = debug
|
||||
self.debug_dir = os.path.join(self.airdrop_dir, 'debug')
|
||||
|
||||
self.legacy = legacy
|
||||
|
||||
if interface is None:
|
||||
interface = 'awdl0' if not self.legacy else 'en0'
|
||||
self.interface = interface
|
||||
|
||||
if email is None:
|
||||
email = []
|
||||
self.email = email
|
||||
if phone is None:
|
||||
phone = []
|
||||
self.phone = phone
|
||||
|
||||
# Bare minimum, we currently do not support anything else
|
||||
self.flags = AirDropReceiverFlags.SUPPORTS_MIXED_TYPES | AirDropReceiverFlags.SUPPORTS_DISCOVER_MAYBE
|
||||
|
||||
self.root_ca_file = resource_filename('opendrop', 'certs/apple_root_ca.pem')
|
||||
if not os.path.exists(self.root_ca_file):
|
||||
raise FileNotFoundError('Need Apple root CA certificate: {}'.format(self.root_ca_file))
|
||||
|
||||
self.key_dir = os.path.join(self.airdrop_dir, 'keys')
|
||||
self.cert_file = os.path.join(self.key_dir, 'certificate.pem')
|
||||
self.key_file = os.path.join(self.key_dir, 'key.pem')
|
||||
|
||||
if not os.path.exists(self.cert_file) or not os.path.exists(self.key_file):
|
||||
logger.info('Key file or certificate does not exist')
|
||||
self.create_default_key()
|
||||
|
||||
# TODO extract record data from a sample exchange
|
||||
self.record_data = None
|
||||
|
||||
def create_default_key(self):
|
||||
logger.info('Create new self-signed certificate in {}'.format(self.key_dir))
|
||||
if not os.path.exists(self.key_dir):
|
||||
os.makedirs(self.key_dir)
|
||||
subprocess.run(['openssl', 'req', '-newkey', 'rsa:2048', '-nodes', '-keyout', 'key.pem',
|
||||
'-x509', '-days', '365', '-out', 'certificate.pem',
|
||||
'-subj', '/CN={}'.format(self.computer_name)], cwd=self.key_dir,
|
||||
stdout=subprocess.PIPE, stderr=subprocess.PIPE)
|
||||
|
||||
def get_ssl_context(self):
|
||||
sslctxt = ssl.SSLContext()
|
||||
sslctxt.load_cert_chain(self.cert_file, keyfile=self.key_file)
|
||||
sslctxt.load_verify_locations(cafile=self.root_ca_file)
|
||||
sslctxt.verify_mode = ssl.CERT_NONE # we accept self-signed certificates as does Apple
|
||||
return sslctxt
|
||||
@@ -0,0 +1,318 @@
|
||||
"""
|
||||
OpenDrop: an open source AirDrop implementation
|
||||
Copyright (C) 2018 Milan Stute
|
||||
Copyright (C) 2018 Alexander Heinrich
|
||||
|
||||
This program is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU General Public License as published by
|
||||
the Free Software Foundation, either version 3 of the License, or
|
||||
(at your option) any later version.
|
||||
|
||||
This program is distributed in the hope that it will be useful,
|
||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
GNU General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU General Public License
|
||||
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
"""
|
||||
import io
|
||||
|
||||
import logging
|
||||
import platform
|
||||
import plistlib
|
||||
import socket
|
||||
from http.server import HTTPServer, BaseHTTPRequestHandler
|
||||
import json
|
||||
import libarchive
|
||||
import libarchive.extract
|
||||
import libarchive.read
|
||||
from .zeroconf import Zeroconf, ServiceInfo
|
||||
import time
|
||||
|
||||
from .util import AirDropUtil
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class AirDropServer:
|
||||
"""
|
||||
Announces an HTTPS AirDrop server in the local network via mDNS.
|
||||
"""
|
||||
|
||||
def __init__(self, config):
|
||||
self.config = config
|
||||
|
||||
if self.config.legacy:
|
||||
self.useIPv6 = False
|
||||
else:
|
||||
self.useIPv6 = True
|
||||
self.ip_interface_name = self.config.interface
|
||||
|
||||
if self.useIPv6:
|
||||
self.address_family = socket.AF_INET6
|
||||
self.serveraddress = ('::', self.config.port)
|
||||
self.ServerClass = HTTPServerV6
|
||||
else:
|
||||
self.address_family = socket.AF_INET
|
||||
self.serveraddress = ('0.0.0.0', self.config.port)
|
||||
self.ServerClass = HTTPServer
|
||||
|
||||
self.ServerClass.allow_reuse_address = False
|
||||
|
||||
self.ip_addr, self.byte_address = AirDropUtil.get_ip_for_interface(self.ip_interface_name, ipv6=self.useIPv6)
|
||||
|
||||
self.Handler = AirDropServerHandler
|
||||
self.Handler.config = self.config
|
||||
|
||||
if self.config.legacy:
|
||||
self.zeroconf = Zeroconf()
|
||||
else:
|
||||
self.zeroconf = Zeroconf(interfaces=[self.ip_addr], ipv6_interface_name=self.ip_interface_name,
|
||||
apple_mdns=True)
|
||||
|
||||
self.http_server = self._init_server()
|
||||
self.service_info = self._init_service()
|
||||
|
||||
def _init_service(self):
|
||||
properties = self.get_properties()
|
||||
server = self.config.host_name + '.local.'
|
||||
service_name = self.config.service_id + '._airdrop._tcp.local.'
|
||||
info = ServiceInfo(
|
||||
'_airdrop._tcp.local.', service_name,
|
||||
self.byte_address, self.config.port, 0, 0, properties, server)
|
||||
return info
|
||||
|
||||
def start_service(self):
|
||||
logger.info('Announcing service: host {}, address {}, port {}'.format(self.config.host_name,
|
||||
self.ip_addr, self.config.port))
|
||||
self.zeroconf.register_service(self.service_info)
|
||||
|
||||
def _init_server(self):
|
||||
try:
|
||||
httpd = self.ServerClass(self.serveraddress, self.Handler)
|
||||
except OSError:
|
||||
# Address in use. Change port
|
||||
self.config.port = self.config.port + 1
|
||||
self.serveraddress = (self.serveraddress[0], self.config.port)
|
||||
httpd = self.ServerClass(self.serveraddress, self.Handler)
|
||||
|
||||
# Adapt socket for awdl0
|
||||
if self.ip_interface_name == 'awdl0' and platform.system() == 'Darwin':
|
||||
httpd.socket.setsockopt(socket.SOL_SOCKET, 0x1104, 1)
|
||||
|
||||
httpd.socket = self.config.get_ssl_context().wrap_socket(sock=httpd.socket, server_side=True)
|
||||
|
||||
return httpd
|
||||
|
||||
def start_server(self):
|
||||
logger.info('Starting HTTPS server')
|
||||
self.http_server.serve_forever()
|
||||
|
||||
def stop(self):
|
||||
self.zeroconf.unregister_all_services()
|
||||
self.http_server.shutdown()
|
||||
|
||||
def get_properties(self):
|
||||
properties = {b'flags': str(self.config.flags).encode('utf-8')}
|
||||
if self.config.legacy:
|
||||
properties[b'phash'] = AirDropUtil.doubleSHA1Hash(self.config.phone).encode('utf-8')
|
||||
properties[b'nhash'] = False
|
||||
properties[b'ehash'] = AirDropUtil.doubleSHA1Hash(self.config.email).encode('utf-8')
|
||||
properties[b'cname'] = self.config.computer_name.encode('utf-8')
|
||||
return properties
|
||||
|
||||
|
||||
class HTTPServerV6(HTTPServer):
|
||||
address_family = socket.AF_INET6
|
||||
|
||||
|
||||
class AirDropServerHandler(BaseHTTPRequestHandler):
|
||||
"""
|
||||
Server which responds to AirDrop HTTP POST requests
|
||||
"""
|
||||
protocol_version = 'HTTP/1.1'
|
||||
config = None
|
||||
|
||||
def _set_response(self, content_length):
|
||||
"""
|
||||
Setting the default values for a successful response
|
||||
"""
|
||||
self.send_response(200)
|
||||
self.send_header('Content-Length', content_length)
|
||||
self.end_headers()
|
||||
|
||||
def do_HEAD(self):
|
||||
"""
|
||||
Answer head requests
|
||||
"""
|
||||
self.send_response(200)
|
||||
self.send_header('Content-type', 'text/html')
|
||||
self.end_headers()
|
||||
|
||||
def do_GET(self):
|
||||
"""
|
||||
Answer get requests
|
||||
"""
|
||||
logger.debug('GET request at {}'.format(self.path))
|
||||
body = '\n'.encode('utf-8')
|
||||
self._set_response(len(body))
|
||||
self.wfile.write(body)
|
||||
|
||||
def handle_discover(self):
|
||||
content_length = int(self.headers['Content-Length'])
|
||||
post_data = self.rfile.read(content_length)
|
||||
|
||||
AirDropUtil.write_debug(self.config, post_data, 'receive_discover_request.plist')
|
||||
|
||||
# sample media capabilities as recorded from macOS 10.13.3
|
||||
media_capabilities = {
|
||||
'Version': 1,
|
||||
# don't advertise any codec/container support so we receive legacy file formats (JPEG instead of HEIF, etc.)
|
||||
# 'Codecs': {
|
||||
# 'hvc1': {
|
||||
# 'Profiles': {
|
||||
# 'VTPerProfileSupport': {
|
||||
# '1': {'VTMaxPlaybackLevel': 120},
|
||||
# '2': {'VTMaxPlaybackLevel': 120},
|
||||
# '3': {}
|
||||
# },
|
||||
# 'VTSupportedProfiles': [1, 2, 3]
|
||||
# }
|
||||
# }
|
||||
# },
|
||||
# 'ContainerFormats': {
|
||||
# 'public.heif-standard': {
|
||||
# 'HeifSubtypes': ['public.avci', 'public.heic', 'public.heif']
|
||||
# }
|
||||
# },
|
||||
# 'Vendor': {
|
||||
# 'com.apple': {
|
||||
# 'OSVersion': [10, 13, 3],
|
||||
# 'OSBuildVersion': '17D102',
|
||||
# 'LivePhotoFormatVersion': '1'
|
||||
# }
|
||||
# }
|
||||
}
|
||||
media_capabilities_json = json.JSONEncoder().encode(media_capabilities)
|
||||
media_capabilities_binary = media_capabilities_json.encode('utf-8')
|
||||
discover_answer = {
|
||||
'ReceiverMediaCapabilities': media_capabilities_binary,
|
||||
'ReceiverComputerName': self.config.computer_name,
|
||||
'ReceiverModelName': self.config.computer_model,
|
||||
}
|
||||
if self.config.record_data:
|
||||
discover_answer['ReceiverRecordData'] = self.config.record_data
|
||||
|
||||
discover_answer_binary = plistlib.dumps(discover_answer, fmt=plistlib.FMT_BINARY)
|
||||
|
||||
AirDropUtil.write_debug(self.config, discover_answer_binary, 'receive_discover_response.plist')
|
||||
|
||||
# Change to actual length
|
||||
self._set_response(len(discover_answer_binary))
|
||||
self.wfile.write(discover_answer_binary)
|
||||
|
||||
def handle_ask(self):
|
||||
content_length = int(self.headers['Content-Length'])
|
||||
post_data = self.rfile.read(content_length)
|
||||
|
||||
AirDropUtil.write_debug(self.config, post_data, 'receive_ask_request.plist')
|
||||
|
||||
ask_response = {'ReceiverModelName': self.config.computer_model,
|
||||
'ReceiverComputerName': self.config.computer_name}
|
||||
ask_resp_binary = plistlib.dumps(ask_response, fmt=plistlib.FMT_BINARY)
|
||||
|
||||
AirDropUtil.write_debug(self.config, ask_resp_binary, 'receive_ask_response.plist')
|
||||
|
||||
self._set_response(len(ask_resp_binary))
|
||||
self.wfile.write(ask_resp_binary)
|
||||
|
||||
def handle_upload(self):
|
||||
if self.headers.get('content-type', '').lower() != 'application/x-cpio':
|
||||
logger.warning('Unsupported content-type: {}'.format(self.headers.get('content-type')))
|
||||
self.send_response(406) # Unprocessable Entity
|
||||
self.send_header('Content-Type', 'application/x-cpio')
|
||||
self.send_header('Content-Length', 0)
|
||||
self.send_header('Connection', 'close')
|
||||
self.end_headers()
|
||||
return
|
||||
|
||||
# If pipelining is not support, 'Expect: 100-continue' is sent to which we need to respond
|
||||
if self.headers.get('expect', '').lower() == '100-continue':
|
||||
self.send_response(100)
|
||||
self.send_header('Content-Length', 0)
|
||||
self.end_headers()
|
||||
|
||||
if self.headers.get('transfer-encoding', '').lower() != 'chunked':
|
||||
logger.warning('Expect chunked transfer encoding')
|
||||
self.send_response(400) # Bad Request
|
||||
self.send_header('Transfer-Encoding', 'Chunked')
|
||||
self.send_header('Content-Length', 0)
|
||||
self.send_header('Connection', 'close')
|
||||
self.end_headers()
|
||||
return
|
||||
|
||||
class HTTPChunkedReader(io.RawIOBase):
|
||||
def __init__(self, rfile, *args, **kwargs):
|
||||
super().__init__(*args, **kwargs)
|
||||
self.rfile = rfile
|
||||
self.chunk = None
|
||||
self.total = 0
|
||||
|
||||
def _next_chunk(self):
|
||||
if self.chunk is None or len(self.chunk) is 0:
|
||||
length = int(self.rfile.readline().rstrip(), 16)
|
||||
self.chunk = self.rfile.read(length)
|
||||
self.rfile.readline() # strip trailing \n\r
|
||||
|
||||
def readinto(self, buf):
|
||||
self._next_chunk()
|
||||
l = min(len(self.chunk), len(buf))
|
||||
buf[:l] = self.chunk[:l]
|
||||
self.chunk = self.chunk[l:]
|
||||
self.total += l
|
||||
return l
|
||||
|
||||
def extract_stream(stream, flags=0):
|
||||
"""
|
||||
Extracts an archive from memory into the current directory.
|
||||
"""
|
||||
|
||||
with libarchive.read.stream_reader(stream) as archive:
|
||||
libarchive.extract.extract_entries(archive, flags)
|
||||
|
||||
logger.info('Receiving file(s) ...')
|
||||
start = time.time()
|
||||
reader = HTTPChunkedReader(self.rfile)
|
||||
extract_stream(reader)
|
||||
|
||||
transferred = reader.total / 1024.0 / 1024.0
|
||||
speed = transferred / (time.time() - start)
|
||||
logger.info('File(s) received (size {:.02f} MB, speed {:.02f} MB/s)'.format(transferred, speed))
|
||||
|
||||
self.send_response(200)
|
||||
self.send_header('Content-Length', 0)
|
||||
self.send_header('Connection', 'close')
|
||||
self.end_headers()
|
||||
|
||||
def do_POST(self):
|
||||
"""
|
||||
Handle post requests
|
||||
"""
|
||||
|
||||
logger.debug('POST request at {}'.format(self.path))
|
||||
logger.debug('Headers\n{}'.format(self.headers))
|
||||
|
||||
if self.path == '/Discover':
|
||||
self.handle_discover()
|
||||
elif self.path == '/Ask':
|
||||
self.handle_ask()
|
||||
elif self.path == '/Upload':
|
||||
self.handle_upload()
|
||||
else:
|
||||
answer = 'POST request for {}'.format(self.path).encode('utf-8')
|
||||
self._set_response(len(answer))
|
||||
self.wfile.write(answer)
|
||||
|
||||
def log_message(self, format, *args):
|
||||
logger.debug('{} - - [{}] {}'.format(self.client_address[0], self.log_date_time_string(), format % args))
|
||||
@@ -0,0 +1,293 @@
|
||||
"""
|
||||
OpenDrop: an open source AirDrop implementation
|
||||
Copyright (C) 2018 Milan Stute
|
||||
Copyright (C) 2018 Alexander Heinrich
|
||||
|
||||
This program is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU General Public License as published by
|
||||
the Free Software Foundation, either version 3 of the License, or
|
||||
(at your option) any later version.
|
||||
|
||||
This program is distributed in the hope that it will be useful,
|
||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
GNU General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU General Public License
|
||||
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
"""
|
||||
|
||||
import netifaces
|
||||
|
||||
import base64
|
||||
import datetime
|
||||
import io
|
||||
import ipaddress
|
||||
import os
|
||||
import platform
|
||||
import plistlib
|
||||
import socket
|
||||
from Crypto.Hash import SHA, SHA256
|
||||
from PIL import Image, ExifTags
|
||||
from libarchive import ffi
|
||||
from libarchive.entry import new_archive_entry, ArchiveEntry
|
||||
from libarchive.ffi import (
|
||||
ARCHIVE_EOF,
|
||||
entry_sourcepath,
|
||||
entry_clear,
|
||||
read_next_header2,
|
||||
read_disk_descend,
|
||||
write_header,
|
||||
write_data,
|
||||
write_finish_entry,
|
||||
)
|
||||
from libarchive.write import ArchiveWrite, new_archive_read_disk
|
||||
if platform.system() == 'Darwin' and os.getenv('LIBCRYPTO') is not None:
|
||||
import ctypescrypto
|
||||
from ctypes import CDLL, c_uint64, c_void_p
|
||||
ctypescrypto.__libname__ = os.environ['LIBCRYPTO']
|
||||
ctypescrypto.libcrypto = CDLL(ctypescrypto.__libname__)
|
||||
if hasattr(ctypescrypto.libcrypto,'OPENSSL_init_crypto'):
|
||||
ctypescrypto.libcrypto.OPENSSL_init_crypto.argtypes = (c_uint64,c_void_p)
|
||||
ctypescrypto.libcrypto.OPENSSL_init_crypto(2+4+8+0x40,None)
|
||||
strings_loaded = True
|
||||
else:
|
||||
ctypescrypto.libcrypto.OPENSSL_add_all_algorithms_conf()
|
||||
strings_loaded = False
|
||||
from ctypescrypto import cms, x509, pkey, oid
|
||||
|
||||
|
||||
class AirDropUtil:
|
||||
"""
|
||||
This class contains a set of utility functions that support the opendrop implementation
|
||||
They have been moved, because the opendrop files tend to get too long
|
||||
"""
|
||||
|
||||
@staticmethod
|
||||
def get_uti_type(flp) -> str:
|
||||
"""
|
||||
Get the Apple conform UTI Type from a flp instance which has been used on the data which should be sent
|
||||
|
||||
:param flp: fleep object
|
||||
"""
|
||||
|
||||
# Default UTI Type
|
||||
uti_type = 'public.content'
|
||||
if len(flp.mime) == 0 or len(flp.type) == 0:
|
||||
return uti_type
|
||||
|
||||
mime = flp.mime[0]
|
||||
f_type = flp.type[0]
|
||||
if 'image' in mime:
|
||||
uti_type = 'public.image'
|
||||
|
||||
if 'jpg' in mime:
|
||||
uti_type = 'public.jpeg'
|
||||
elif 'jp2' in mime:
|
||||
uti_type = 'public.jpeg-2000'
|
||||
elif 'gif' in mime:
|
||||
uti_type = 'com.compuserve.gif'
|
||||
elif 'png' in mime:
|
||||
uti_type = 'public.png'
|
||||
elif 'raw' in mime or 'raw' in f_type:
|
||||
uti_type = 'public.camera-raw-image'
|
||||
elif 'audio' in f_type:
|
||||
uti_type = 'public.audio'
|
||||
elif 'video' in f_type:
|
||||
uti_type = 'public.video'
|
||||
elif 'archive' in f_type:
|
||||
uti_type = 'public.data'
|
||||
|
||||
if 'gzip' in mime:
|
||||
uti_type = 'org.gnu.gnu-zip-archive'
|
||||
if 'zip' in mime:
|
||||
uti_type = 'public.zip-archive'
|
||||
|
||||
return uti_type
|
||||
|
||||
@staticmethod
|
||||
def record_data(config, tls_cert, sign_cert, key):
|
||||
"""
|
||||
This method generates the sender record data and will sign it using the CMS format.
|
||||
|
||||
This code serves documentation purposes only and is UNTESTED. To be accepted by Apple clients, we would need the
|
||||
Apple-owned private key of the signing certificate.
|
||||
|
||||
:param tls_cert: path to certificate used for AirDrop TLS connections
|
||||
:param sign_cert: path to signing certificate
|
||||
:param key: path to private key to the signing certificate
|
||||
"""
|
||||
|
||||
valid_date = datetime.datetime.now() - datetime.timedelta(days=3)
|
||||
valid_date_string = valid_date.strftime('%Y-%m-%dT%H:%M:%SZ')
|
||||
|
||||
emails_hashed = [SHA256.new(email.encode('utf-8')).hexdigest() for email in config.email]
|
||||
phone_numbers_hashed = [SHA256.new(phone_number.encode('utf-8')).hexdigest() for phone_number in config.phone]
|
||||
|
||||
# Get the common name of the TLS certificate
|
||||
with open(tls_cert, 'rb') as cert_file:
|
||||
cert = x509.X509(cert_file.read())
|
||||
cn = cert.subject[oid.Oid('2.5.4.3')]
|
||||
encDsID = cn.replace('com.apple.idms.appleid.prd.', '')
|
||||
|
||||
# Construct record data
|
||||
record_data = {
|
||||
'Version': 2,
|
||||
'encDsID': encDsID, # Common name suffix of the certificate
|
||||
'altDsID': encDsID, # Same as encDsID
|
||||
'SuggestValidDuration': 30 * 24 * 60 * 60, # in seconds
|
||||
'ValidAsOf': valid_date_string, # 3 days before now
|
||||
'ValidatedEmailHashes': emails_hashed,
|
||||
'ValidatedPhoneHashes': phone_numbers_hashed,
|
||||
}
|
||||
record_data_plist = plistlib.dumps(record_data, fmt=plistlib.FMT_XML)
|
||||
|
||||
with open(sign_cert, 'rb') as sign_cert_file:
|
||||
with open(key, 'rb') as key_file:
|
||||
cert = x509.X509(sign_cert_file.read())
|
||||
key = pkey.PKey(privkey=key_file.read())
|
||||
# possibly need to add intermediate certs
|
||||
cms_signed = cms.SignedData.create(record_data_plist, cert=cert, pkey=key, certs=None,
|
||||
flags=cms.Flags.PARTIAL)
|
||||
signed_data = AirDropUtil.pem2der(cms_signed.pem())
|
||||
|
||||
return signed_data
|
||||
|
||||
@staticmethod
|
||||
def doubleSHA1Hash(toHash):
|
||||
"""
|
||||
This method gets an array of strings as input and creates a double SHA-1 Hash formatted in BASE64 from it.
|
||||
It will return a comma seperated list of SHA-1 hashes in BASE64
|
||||
|
||||
:param toHash: An iterable which contains one or many str
|
||||
"""
|
||||
|
||||
single_hashed = [SHA.new(to_hash.encode('utf-8')).digest() for to_hash in toHash]
|
||||
double_hashed = [SHA.new(single).digest() for single in single_hashed]
|
||||
|
||||
double_hashed_base64 = [base64.b64encode(h).decode('utf-8') for h in double_hashed]
|
||||
hash_string = ','.join(double_hashed_base64)
|
||||
|
||||
return hash_string
|
||||
|
||||
@staticmethod
|
||||
def pem2der(s):
|
||||
"""
|
||||
Create DER Formatted bytes from a PEM Base64 String
|
||||
|
||||
:param s: PEM formatted string
|
||||
"""
|
||||
start = s.find('-----\n')
|
||||
finish = s.rfind('\n-----END')
|
||||
data = s[start + 6:finish]
|
||||
return base64.b64decode(data)
|
||||
|
||||
@staticmethod
|
||||
def generate_file_icon(file_path):
|
||||
"""
|
||||
Generates a small and a big thumbnail of an image
|
||||
This will make it possible to preview the sent file
|
||||
|
||||
:param file_path: The path to the image
|
||||
"""
|
||||
im = Image.open(file_path)
|
||||
|
||||
# rotate according to EXIF tags
|
||||
try:
|
||||
exif = dict((ExifTags.TAGS[k], v) for k, v in im._getexif().items() if k in ExifTags.TAGS)
|
||||
angles = {3: 180, 6: 270, 8: 90}
|
||||
orientation = exif['Orientation']
|
||||
if orientation in angles.keys():
|
||||
im = im.rotate(angles[orientation], expand=True)
|
||||
except AttributeError:
|
||||
pass # no EXIF data available
|
||||
|
||||
# Big image
|
||||
im.thumbnail((540, 540), Image.ANTIALIAS)
|
||||
imgByteArr = io.BytesIO()
|
||||
im.save(imgByteArr, format='JPEG2000')
|
||||
file_icon = imgByteArr.getvalue()
|
||||
|
||||
# Small image
|
||||
#im.thumbnail((64, 64), Image.ANTIALIAS)
|
||||
#imgByteArr = io.BytesIO()
|
||||
#im.save(imgByteArr, format='JPEG2000')
|
||||
#small_file_icon = imgByteArr.getvalue()
|
||||
|
||||
return file_icon
|
||||
|
||||
|
||||
@staticmethod
|
||||
def get_ip_for_interface(interface_name, ipv6=False):
|
||||
"""
|
||||
Get the ip address in IPv4 or IPv6 for a specific network interface
|
||||
|
||||
:param str interace_name: declares the network interface name for which the ip should be accessed
|
||||
:param bool ipv6: Boolean indicating if the ipv6 address should be rertrieved
|
||||
:return: (str ipaddress, byte ipaddress_bytes) returns a tuple with the ip address as a string and in bytes
|
||||
"""
|
||||
addresses = netifaces.ifaddresses(interface_name)
|
||||
|
||||
if netifaces.AF_INET6 in addresses and ipv6:
|
||||
# Use the normal ipv6 address
|
||||
addr = addresses[netifaces.AF_INET6][0]['addr'].split('%')[0]
|
||||
bytes_addr = ipaddress.IPv6Address(addr).packed
|
||||
elif netifaces.AF_INET in addresses and not ipv6:
|
||||
addr = addresses[netifaces.AF_INET][0]['addr']
|
||||
bytes_addr = socket.inet_aton(addr)
|
||||
else:
|
||||
addr = None
|
||||
bytes_addr = None
|
||||
|
||||
return addr, bytes_addr
|
||||
|
||||
@staticmethod
|
||||
def write_debug(config, data, file_name):
|
||||
if not config.debug:
|
||||
return
|
||||
if not os.path.exists(config.debug_dir):
|
||||
os.makedirs(config.debug_dir)
|
||||
debug_file_path = os.path.join(config.debug_dir, file_name)
|
||||
with open(debug_file_path, 'wb') as file:
|
||||
if hasattr(data, 'read'):
|
||||
file.write(data.read())
|
||||
data.seek(0) # reset cursor position
|
||||
else: # assume bytes-like
|
||||
file.write(data)
|
||||
|
||||
|
||||
class AbsArchiveWrite(ArchiveWrite):
|
||||
def add_abs_file(self, path, store_path):
|
||||
"""
|
||||
Read the given paths from disk and add them to the archive.
|
||||
"""
|
||||
write_p = self._pointer
|
||||
|
||||
block_size = ffi.write_get_bytes_per_block(write_p)
|
||||
if block_size <= 0:
|
||||
block_size = 10240 # pragma: no cover
|
||||
|
||||
with new_archive_entry() as entry_p:
|
||||
entry = ArchiveEntry(None, entry_p)
|
||||
with new_archive_read_disk(path) as read_p:
|
||||
while 1:
|
||||
r = read_next_header2(read_p, entry_p)
|
||||
if r == ARCHIVE_EOF:
|
||||
break
|
||||
entry.pathname = store_path
|
||||
read_disk_descend(read_p)
|
||||
write_header(write_p, entry_p)
|
||||
try:
|
||||
with open(entry_sourcepath(entry_p), 'rb') as f:
|
||||
while 1:
|
||||
data = f.read(block_size)
|
||||
if not data:
|
||||
break
|
||||
write_data(write_p, data, len(data))
|
||||
except IOError as e:
|
||||
if e.errno != 21:
|
||||
raise # pragma: no cover
|
||||
write_finish_entry(write_p)
|
||||
entry_clear(entry_p)
|
||||
if os.path.isdir(path):
|
||||
break
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,41 @@
|
||||
from opendrop import __version__
|
||||
from codecs import open
|
||||
from os.path import abspath, dirname, join
|
||||
from setuptools import find_packages, setup
|
||||
|
||||
this_dir = abspath(dirname(__file__))
|
||||
with open(join(this_dir, 'README.md'), encoding='utf-8') as file:
|
||||
long_description = file.read()
|
||||
|
||||
setup(
|
||||
name='opendrop',
|
||||
version=__version__,
|
||||
description='An Open Source AirDrop Implementation',
|
||||
long_description=long_description,
|
||||
url='https://owlink.org',
|
||||
author='Milan Stute, Alexander Heinrich',
|
||||
classifiers=[
|
||||
'Intended Audience :: Developers',
|
||||
'Topic :: Utilities',
|
||||
'License :: Public Domain',
|
||||
'Natural Language :: English',
|
||||
'Operating System :: OS Independent',
|
||||
'Programming Language :: Python :: 3',
|
||||
'Programming Language :: Python :: 3.2',
|
||||
'Programming Language :: Python :: 3.3',
|
||||
'Programming Language :: Python :: 3.6.3',
|
||||
],
|
||||
keywords='cli',
|
||||
packages=find_packages(exclude=['docs']),
|
||||
package_data={
|
||||
'opendrop': ['certs/*.pem']
|
||||
},
|
||||
|
||||
install_requires=['pycrypto', 'requests', 'fleep', 'netifaces', 'Pillow',
|
||||
'requests_toolbelt', 'ctypescrypto', 'libarchive-c'],
|
||||
entry_points={
|
||||
'console_scripts': [
|
||||
'opendrop=opendrop.cli:main',
|
||||
],
|
||||
},
|
||||
)
|
||||
Reference in New Issue
Block a user