Initial commit

This commit is contained in:
Milan Stute
2019-05-16 11:28:33 +02:00
commit d8f7e53853
14 changed files with 4834 additions and 0 deletions
+108
View File
@@ -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/
+1082
View File
File diff suppressed because it is too large Load Diff
+7
View File
@@ -0,0 +1,7 @@
include README.md
exclude .gitignore
prune .cache
prune .git
prune build
prune dist
recursive-exclude *.egg-info *
+96
View File
@@ -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 Johns iPhone
Found index 1 ID e63138ac6ba8 name Janes 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 1416, 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.
+24
View 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__)
+22
View File
@@ -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()
+28
View File
@@ -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
View File
@@ -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
+284
View File
@@ -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')
+117
View File
@@ -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
+318
View File
@@ -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))
+293
View File
@@ -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
+2218
View File
File diff suppressed because it is too large Load Diff
+41
View File
@@ -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',
],
},
)