pax_global_header00006660000000000000000000000064137151550500014514gustar00rootroot0000000000000052 comment=996a6a9481ce50c97069c734a91e35eabd34fca4 pyetesync-0.12.1/000077500000000000000000000000001371515505000136205ustar00rootroot00000000000000pyetesync-0.12.1/.flake8000066400000000000000000000000371371515505000147730ustar00rootroot00000000000000[flake8] max-line-length = 120 pyetesync-0.12.1/.gitignore000066400000000000000000000001111371515505000156010ustar00rootroot00000000000000Session.vim /.venv /.coverage /etesync.db __pycache__ *.egg-info .*.swp pyetesync-0.12.1/.travis.yml000066400000000000000000000002451371515505000157320ustar00rootroot00000000000000dist: xenial language: python python: - "3.5" - "3.6" - "3.7" install: - pip install -r requirements.in/requirements-dev.txt script: - ./run_unit_tests.sh pyetesync-0.12.1/ChangeLog.md000066400000000000000000000051631371515505000157760ustar00rootroot00000000000000# Changelog ## Version 0.12.1 * Change how we import scrypt from hashlib to be more pythonic ## Version 0.12.0 * Update cryptography dep ## Version 0.11.1 * Use the hashlib scrypt implementation if available, otherwise fallback to scrypt or pyscrypt ## Version 0.11.0 * Make sync faster by only fetching entries when journals have changed. * Change the scrypt dep in setup.py to scrypt (from pyscrypt, which is still supported as a fallback). * Set the user agent when making requests. ## Version 0.10.0 * Change database model to WAL which should improve concurrency ## Version 0.9.3 * Provide more explicit copyright and licensing information. ## Version 0.9.2 * Remove dev dep (coverage) from setup.py ## Version 0.9.1 * Gracefully handle entries without a UID. ## Version 0.9.0 * Fix reinit of the EteSync object (fixes tests) * Bump minor version as should have been done in the previous release. ## Version 0.8.4 * Allow users of this library to add tables to the cache database. * Make the sqlite database more strict (enforce foreign key relations). ## Version 0.8.3 * Fix typo with pytz dependency ## Version 0.8.2 * Add missing pytz dependency * Upgrade urllib3 and requests ## Version 0.8.1 * Fix pushing entries to shared journals. ## Version 0.8.0 * Add support for read only journals * Fix issue with having the same journals in the db for different users. * Journal list fetching: fix stale cache issue. ## Version 0.7.0 * Fix user info to use the correct asymmetric key format * Sync user info on every protocol sync - needed for encryption password changes. ## Version 0.6.3 * Sync: automatically create user info if doesn't exist ## Version 0.6.2 * Add shell scripts for executing tests ## Version 0.6.1 * Fix journal integrity issue when syncing more than one collection item. ## Version 0.6.0 * Add tasks support ## Version 0.5.6 * Fix broken calling to scrypt * Fix sync (was broken in some cases) and tests ## Version 0.5.5 * Automatically detect if scrypt is available. If so use it, otherwise revert to pyscript. Setup.py dep remains on pyscypt. * This is to help distros that don't package pyscrypt * Update peewee to support version 3 and up ## Version 0.5.4 * Fix peewee dep to be < 3.0.0 ## Version 0.5.3 * Change back to pyscript, because scrypt has proven very problematic * Update all the deps ## Version 0.5.2 * Don't install tests as a package ## Version 0.5.1 * Change from pyscrypt to scrypt (much faster and widely adopted) ## Version 0.5.0 * Add functions to check if the journal list or journals are dirty. * Add a nicer way to access the journal's info. * Fix an issue with caching user info * Improve tests pyetesync-0.12.1/LICENSE000066400000000000000000000167431371515505000146400ustar00rootroot00000000000000 GNU LESSER GENERAL PUBLIC LICENSE Version 3, 29 June 2007 Copyright (C) 2007 Free Software Foundation, Inc. Everyone is permitted to copy and distribute verbatim copies of this license document, but changing it is not allowed. This version of the GNU Lesser General Public License incorporates the terms and conditions of version 3 of the GNU General Public License, supplemented by the additional permissions listed below. 0. Additional Definitions. As used herein, "this License" refers to version 3 of the GNU Lesser General Public License, and the "GNU GPL" refers to version 3 of the GNU General Public License. "The Library" refers to a covered work governed by this License, other than an Application or a Combined Work as defined below. An "Application" is any work that makes use of an interface provided by the Library, but which is not otherwise based on the Library. Defining a subclass of a class defined by the Library is deemed a mode of using an interface provided by the Library. A "Combined Work" is a work produced by combining or linking an Application with the Library. The particular version of the Library with which the Combined Work was made is also called the "Linked Version". The "Minimal Corresponding Source" for a Combined Work means the Corresponding Source for the Combined Work, excluding any source code for portions of the Combined Work that, considered in isolation, are based on the Application, and not on the Linked Version. The "Corresponding Application Code" for a Combined Work means the object code and/or source code for the Application, including any data and utility programs needed for reproducing the Combined Work from the Application, but excluding the System Libraries of the Combined Work. 1. Exception to Section 3 of the GNU GPL. You may convey a covered work under sections 3 and 4 of this License without being bound by section 3 of the GNU GPL. 2. Conveying Modified Versions. If you modify a copy of the Library, and, in your modifications, a facility refers to a function or data to be supplied by an Application that uses the facility (other than as an argument passed when the facility is invoked), then you may convey a copy of the modified version: a) under this License, provided that you make a good faith effort to ensure that, in the event an Application does not supply the function or data, the facility still operates, and performs whatever part of its purpose remains meaningful, or b) under the GNU GPL, with none of the additional permissions of this License applicable to that copy. 3. Object Code Incorporating Material from Library Header Files. The object code form of an Application may incorporate material from a header file that is part of the Library. You may convey such object code under terms of your choice, provided that, if the incorporated material is not limited to numerical parameters, data structure layouts and accessors, or small macros, inline functions and templates (ten or fewer lines in length), you do both of the following: a) Give prominent notice with each copy of the object code that the Library is used in it and that the Library and its use are covered by this License. b) Accompany the object code with a copy of the GNU GPL and this license document. 4. Combined Works. You may convey a Combined Work under terms of your choice that, taken together, effectively do not restrict modification of the portions of the Library contained in the Combined Work and reverse engineering for debugging such modifications, if you also do each of the following: a) Give prominent notice with each copy of the Combined Work that the Library is used in it and that the Library and its use are covered by this License. b) Accompany the Combined Work with a copy of the GNU GPL and this license document. c) For a Combined Work that displays copyright notices during execution, include the copyright notice for the Library among these notices, as well as a reference directing the user to the copies of the GNU GPL and this license document. d) Do one of the following: 0) Convey the Minimal Corresponding Source under the terms of this License, and the Corresponding Application Code in a form suitable for, and under terms that permit, the user to recombine or relink the Application with a modified version of the Linked Version to produce a modified Combined Work, in the manner specified by section 6 of the GNU GPL for conveying Corresponding Source. 1) Use a suitable shared library mechanism for linking with the Library. A suitable mechanism is one that (a) uses at run time a copy of the Library already present on the user's computer system, and (b) will operate properly with a modified version of the Library that is interface-compatible with the Linked Version. e) Provide Installation Information, but only if you would otherwise be required to provide such information under section 6 of the GNU GPL, and only to the extent that such information is necessary to install and execute a modified version of the Combined Work produced by recombining or relinking the Application with a modified version of the Linked Version. (If you use option 4d0, the Installation Information must accompany the Minimal Corresponding Source and Corresponding Application Code. If you use option 4d1, you must provide the Installation Information in the manner specified by section 6 of the GNU GPL for conveying Corresponding Source.) 5. Combined Libraries. You may place library facilities that are a work based on the Library side by side in a single library together with other library facilities that are not Applications and are not covered by this License, and convey such a combined library under terms of your choice, if you do both of the following: a) Accompany the combined library with a copy of the same work based on the Library, uncombined with any other library facilities, conveyed under the terms of this License. b) Give prominent notice with the combined library that part of it is a work based on the Library, and explaining where to find the accompanying uncombined form of the same work. 6. Revised Versions of the GNU Lesser General Public License. The Free Software Foundation may publish revised and/or new versions of the GNU Lesser General Public License from time to time. Such new versions will be similar in spirit to the present version, but may differ in detail to address new problems or concerns. Each version is given a distinguishing version number. If the Library as you received it specifies that a certain numbered version of the GNU Lesser General Public License "or any later version" applies to it, you have the option of following the terms and conditions either of that published version or of any later version published by the Free Software Foundation. If the Library as you received it does not specify a version number of the GNU Lesser General Public License, you may choose any version of the GNU Lesser General Public License ever published by the Free Software Foundation. If the Library as you received it specifies that a proxy can decide whether future versions of the GNU Lesser General Public License shall apply, that proxy's public statement of acceptance of any version is permanent authorization for you to choose that version for the Library. pyetesync-0.12.1/MANIFEST.in000066400000000000000000000000421371515505000153520ustar00rootroot00000000000000include LICENSE include README.md pyetesync-0.12.1/README.md000066400000000000000000000057751371515505000151150ustar00rootroot00000000000000

EteSync - Secure Data Sync

This is a python client library for [EteSync](https://www.etesync.com) ![GitHub tag](https://img.shields.io/github/tag/etesync/pyetesync.svg) [![PyPI](https://img.shields.io/pypi/v/etesync.svg)](https://pypi.python.org/pypi/etesync/) [![Build Status](https://travis-ci.com/etesync/pyetesync.svg?branch=master)](https://travis-ci.com/etesync/pyetesync) [![Chat on freenode](https://img.shields.io/badge/irc.freenode.net-%23EteSync-blue.svg)](https://webchat.freenode.net/?channels=#etesync) This module provides a python API to interact with an EteSync server. It currently implements AddressBook and Calendar access, and supports two-way sync (both push and pull) to the server. It doesn't currently implement pushing raw journal entries which are needed for people implementing new EteSync journal types which will be implemented soon. To install, please run: ``` pip install etesync ``` The module works and the API is tested (see [tests/](tests/)), however there still may be some oddities, so please report if you encounter any. There is one Authenticator endpoint, and one endpoint for the rest of the API interactions. The way it works is that you run "sync", which syncs local cache with server. Afterwards you can either access the journal directly, or if you prefer, you can access a collection, for example a Calendar, and interact with the entries themselves, which are already in sync with the journal. Check out [example.py](example.py) for a basic usage example, or the tests for a more complete example. While this is stable enough for usage, it still may be subject to change, so please watch out for the changelog when updating version. Docs are currently missing but are planned. ## Running the example script You may also need to make sure you have the OpenSSL development package installed (e.g. `openssl-dev`). Check out this repository: ``` git clone git@github.com:etesync/pyetesync.git cd pyetesync ``` Setup the environment: ``` python3 -m venv venv source venv/bin/activate pip install -r requirements.in/requirements-dev.txt ``` Run `example.py` to export your data: ``` python3 example.py https://api.etesync.com ``` You may need to surround your passwords in quotes and you may need to escape special characters with a `\`. Please note, that depending on your setup, passing your passwords as command line parameters may not be completely secure, so it would be better if you manually edit the file. And all of your data will be copied to a local database located at `~/.etesync/data.db`. ## Running the tests Some to the tests are unit tests, but some are integration tests who need an actual EteSync service with a few user names set up in order for them to work. You'd need to run your local server: https://github.com/etesync/server-skeleton/ And then add two users: - test@localhost - test2@localhost Password for both: SomePassword That's it. pyetesync-0.12.1/etesync/000077500000000000000000000000001371515505000152725ustar00rootroot00000000000000pyetesync-0.12.1/etesync/__init__.py000066400000000000000000000011661371515505000174070ustar00rootroot00000000000000# Copyright © 2017 Tom Hacohen # # This program is free software: you can redistribute it and/or modify # it under the terms of the GNU Lesser General Public License as # published by the Free Software Foundation, version 3. # # This library 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 . from .api import * pyetesync-0.12.1/etesync/_version.py000066400000000000000000000011721371515505000174710ustar00rootroot00000000000000# Copyright © 2017 Tom Hacohen # # This program is free software: you can redistribute it and/or modify # it under the terms of the GNU Lesser General Public License as # published by the Free Software Foundation, version 3. # # This library 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 . __version__ = "0.12.1" pyetesync-0.12.1/etesync/api.py000066400000000000000000000441351371515505000164240ustar00rootroot00000000000000# Copyright © 2017 Tom Hacohen # # This program is free software: you can redistribute it and/or modify # it under the terms of the GNU Lesser General Public License as # published by the Free Software Foundation, version 3. # # This library 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 . import vobject import json import os import peewee from .crypto import CryptoManager, AsymmetricCryptoManager, AsymmetricKeyPair, derive_key, CURRENT_VERSION from .service import JournalManager, EntryManager, SyncEntry from . import cache, pim, service, db, exceptions API_URL = 'https://api.etesync.com/' # Expose the authenticator Authenticator = service.Authenticator class EteSync: def __init__(self, email, auth_token, remote=API_URL, cipher_key=None, db_path=None): self.email = email self.auth_token = auth_token self.remote = remote self.cipher_key = cipher_key self._init_db(db_path) def reinit(self): self._set_db(self._database) def _set_db(self, database): self._database = database db.database_proxy.initialize(database) self._init_db_tables(database) self.user, created = cache.User.get_or_create(username=self.email) def _init_db(self, db_path): from playhouse.sqlite_ext import SqliteExtDatabase if db_path is None: db_path = os.path.join(os.path.expanduser('~'), '.etesync', 'data.db') directory = os.path.dirname(db_path) if directory != '' and not os.path.exists(directory): os.makedirs(directory) database = SqliteExtDatabase(db_path, pragmas={ 'journal_mode': 'wal', 'foreign_keys': 1, }) database.connect() self._set_db(database) def _init_db_tables(self, database, additional_tables=None): CURRENT_DB_VERSION = 2 new_db = not database.table_exists('journalentity') database.create_tables([cache.Config, pim.Content, cache.User, cache.JournalEntity, cache.EntryEntity, cache.UserInfo], safe=True) if additional_tables: database.create_tables(additional_tables, safe=True) default_db_version = CURRENT_DB_VERSION if new_db else 0 config, created = cache.Config.get_or_create(defaults={'db_version': default_db_version}) if config.db_version < 1: from playhouse.migrate import SqliteMigrator, migrate # Essentially version 0 so do first migration. migrator = SqliteMigrator(database) try: migrate( migrator.add_column('journalentity', 'read_only', cache.JournalEntity.read_only), ) except peewee.OperationalError: # A hack because we don't have a db config yet. pass config.db_version = 1 config.save() if config.db_version < 2: from playhouse.migrate import SqliteMigrator, migrate migrator = SqliteMigrator(database) migrate( migrator.add_column('journalentity', 'remote_last_uid', cache.JournalEntity.remote_last_uid), ) config.db_version = 2 config.save() def get_or_create_user_info(self, force_fetch=False): user_info = None try: user_info = cache.UserInfo.get(user=self.user) except cache.UserInfo.DoesNotExist: pass if user_info is None or force_fetch: info_manager = service.UserInfoManager(self.remote, self.auth_token) remote_info = None try: remote_info = info_manager.get(self.user.username, self.cipher_key) except exceptions.HttpNotFound: pass if remote_info: remote_info.verify() else: key_pair = AsymmetricCryptoManager.generate_key_pair() crypto_manager = CryptoManager(CURRENT_VERSION, self.cipher_key, b'userInfo') remote_info = service.RawUserInfo(crypto_manager, self.user.username, key_pair.public_key) remote_info.update(key_pair.private_key) remote_info.verify() info_manager.add(remote_info) new_user_info = user_info is None user_info = cache.UserInfo(user=self.user, pubkey=remote_info.pubkey, content=remote_info.getContent()) user_info.save(force_insert=new_user_info) return user_info def sync(self): self.get_or_create_user_info(force_fetch=True) self.sync_journal_list() for journal in self.list(): self.sync_journal(journal.uid) def sync_journal_list(self): self.push_journal_list() manager = JournalManager(self.remote, self.auth_token) existing = {} for journal in self.list(): existing[journal.uid] = journal._cache_obj for entry in manager.list(self.cipher_key): entry.crypto_manager = self._get_journal_cryptomanager(entry) entry.verify() if entry.uid in existing: journal = existing[entry.uid] del existing[journal.uid] else: journal = cache.JournalEntity(local_user=self.user, uid=entry.uid, owner=entry.owner) journal.version = entry.version journal.encrypted_key = entry.encrypted_key journal.read_only = entry.read_only journal.remote_last_uid = entry.remote_last_uid journal.content = entry.getContent() journal.save() # Delete remaining for journal in existing.values(): journal.deleted = True journal.save() def _journal_list_dirty_get(self): return self.user.journals.where(cache.JournalEntity.dirty | cache.JournalEntity.new) def journal_list_is_dirty(self): changed = list(self._journal_list_dirty_get()) return len(changed) > 0 def push_journal_list(self): manager = JournalManager(self.remote, self.auth_token) changed = self._journal_list_dirty_get() for journal in changed: crypto_manager = self._get_journal_cryptomanager(journal) raw_journal = service.RawJournal(crypto_manager, uid=journal.uid) raw_journal.update(journal.content) if journal.deleted: manager.delete(raw_journal) elif journal.new: manager.add(raw_journal) journal.new = False else: manager.update(raw_journal) journal.dirty = False journal.save() def sync_journal(self, uid): # FIXME: At the moment if there's a conflict remote would win. self.pull_journal(uid) self.push_journal(uid) def _get_journal_cryptomanager(self, journal): if journal.encrypted_key is not None: # If journal is pubkey encrypted, fetch encryption key user_info = self.get_or_create_user_info() key_pair = AsymmetricKeyPair(user_info.content, user_info.pubkey) return CryptoManager.create_from_asymmetric_encryted_key( journal.version, key_pair, journal.encrypted_key) else: cipher_key = self.cipher_key return CryptoManager(journal.version, cipher_key, journal.uid.encode()) def _get_last_entry(self, journal): try: return journal.entries.order_by(cache.EntryEntity.id.desc()).get() except cache.EntryEntity.DoesNotExist: return None def pull_journal(self, uid): journal_uid = uid manager = EntryManager(self.remote, self.auth_token, journal_uid) journal = cache.JournalEntity.get(local_user=self.user, uid=journal_uid) crypto_manager = self._get_journal_cryptomanager(journal) collection = Journal._from_cache(journal).collection prev = self._get_last_entry(journal) last_uid = None if prev is None else prev.uid if (last_uid is not None) and (last_uid == journal.remote_last_uid): return for entry in manager.list(crypto_manager, last_uid): entry.verify(prev) content = entry.getContent() syncEntry = SyncEntry.from_json(content.decode()) collection.apply_sync_entry(syncEntry) cache.EntryEntity.create(uid=entry.uid, content=content, journal=journal) prev = entry def _journal_dirty_get(self, journal): return journal.content_set.where(pim.Content.new | pim.Content.dirty | pim.Content.deleted) def journal_is_dirty(self, uid): journal = cache.JournalEntity.get(local_user=self.user, uid=uid) changed = list(self._journal_dirty_get(journal)) return len(changed) > 0 def push_journal(self, uid): # FIXME: Implement pushing in chunks journal_uid = uid manager = EntryManager(self.remote, self.auth_token, journal_uid) journal = cache.JournalEntity.get(local_user=self.user, uid=journal_uid) crypto_manager = self._get_journal_cryptomanager(journal) changed_set = self._journal_dirty_get(journal) changed = list(changed_set) if len(changed) == 0: return prev = self._get_last_entry(journal) last_uid = None if prev is None else prev.uid entries = [] for pim_entry in changed: if pim_entry.deleted: action = 'DELETE' elif pim_entry.new: action = 'ADD' else: action = 'CHANGE' sync_entry = SyncEntry(action, pim_entry.content) raw_entry = service.RawEntry(crypto_manager) raw_entry.update(sync_entry.to_json().encode(), prev) entries.append(raw_entry) prev = raw_entry manager.add(entries, last_uid) # Add entries to cache for entry in entries: cache.EntryEntity.create(uid=entry.uid, content=entry.getContent(), journal=journal) # Clear dirty flags and delete deleted content pim.Content.delete().where((pim.Content.journal == journal) & pim.Content.deleted).execute() pim.Content.update(new=False, dirty=False).where( (pim.Content.journal == journal) & (pim.Content.new | pim.Content.dirty) ).execute() def derive_key(self, password): self.cipher_key = derive_key(password, self.email) return self.cipher_key # CRUD operations def list(self): for cache_obj in self.user.journals.where(~cache.JournalEntity.deleted): yield Journal._from_cache(cache_obj) def get(self, uid): try: return Journal._from_cache(self.user.journals.where( (cache.JournalEntity.uid == uid) & ~cache.JournalEntity.deleted).get()) except cache.JournalEntity.DoesNotExist as e: raise exceptions.DoesNotExist(e) class ApiObjectBase: def __init__(self): self._cache_obj = None def __repr__(self): return '<{} {}>'.format(self.__class__.__name__, self.uid) @classmethod def _from_cache(cls, cache_obj): ret = cls() ret._cache_obj = cache_obj return ret @classmethod def create(cls, journal, uid, content): cache_obj = cls._CACHE_OBJ_CLASS() cache_obj.journal = journal._cache_obj cache_obj.uid = uid cache_obj.content = content cache_obj.new = True return cls._from_cache(cache_obj) @property def uid(self): if self._cache_obj.uid is None: return None return str(self._cache_obj.uid) @uid.setter def uid(self, uid): self._cache_obj.uid = uid @property def content(self): return self._cache_obj.content @content.setter def content(self, content): self._cache_obj.content = content def save(self): try: self._cache_obj.save() except peewee.IntegrityError as e: if 'UNIQUE' in str(e): raise exceptions.AlreadyExists(e) else: raise exceptions.DoesNotExist(e) class Entry(ApiObjectBase): _CACHE_OBJ_CLASS = cache.EntryEntity class PimObject(ApiObjectBase): _CACHE_OBJ_CLASS = pim.Content @classmethod def create(cls, collection, content): if collection.get_content_class() != cls: raise exceptions.TypeMismatch('Collection "{}" does not allow "{}" children.'.format( collection.__class__.__name__, cls.__name__)) ret = super().create(collection.journal, None, None) ret.content = content if ret.uid is None: return None return ret @property def content(self): return self._cache_obj.content @content.setter def content(self, content): self._cache_obj.content = content self.uid = self.__class__.get_uid(content) def delete(self): self._cache_obj.deleted = True self._cache_obj.save() def save(self): self._cache_obj.dirty = True super().save() class Event(PimObject): @classmethod def get_uid(cls, content): vobj = vobject.readOne(content) try: return vobj.vevent.uid.value except AttributeError: return None class Contact(PimObject): @classmethod def get_uid(cls, content): vobj = vobject.readOne(content) try: return vobj.uid.value except AttributeError: return None class Task(PimObject): @classmethod def get_uid(cls, content): vobj = vobject.readOne(content) try: return vobj.vtodo.uid.value except AttributeError: return None class BaseCollection: def __init__(self, journal): self._journal = journal self._cache_obj = journal._cache_obj if self._journal.info is None: self.update_info(None) @property def display_name(self): return self._journal.info.get('displayName') @property def description(self): return self._journal.info.get('description') @property def journal(self): return self._journal def update_info(self, update_info): if update_info is None: self._journal.update_info(self._get_default_info()) else: self._journal.update_info(update_info) def _get_default_info(self): return {'type': self.__class__.TYPE, 'readOnly': False, 'selected': True} def apply_sync_entry(self, sync_entry): journal = self._cache_obj uid = self.get_content_class().get_uid(sync_entry.content) if uid is None: print("WARNING: uid not found for entry, skipping. Content:") print(sync_entry.content) return try: content = pim.Content.get(uid=uid, journal=journal) except pim.Content.DoesNotExist: content = None if sync_entry.action == 'DELETE': if content is not None: content.delete_instance() else: print("WARNING: Failed to delete " + uid) return content = pim.Content(journal=journal, uid=uid) if content is None else content content.content = sync_entry.content content.save() # CRUD def list(self): for content in self._cache_obj.content_set.where(~pim.Content.deleted): yield self.get_content_class()._from_cache(content) def get(self, uid): try: return self.get_content_class()._from_cache(self._cache_obj.content_set.where(pim.Content.uid == uid).get()) except pim.Content.DoesNotExist as e: raise exceptions.DoesNotExist(e) @classmethod def create(cls, etesync, uid, content): cache_obj = cache.JournalEntity(new=True) cache_obj.local_user = etesync.user cache_obj.uid = uid cache_obj.version = CURRENT_VERSION ret = cls(Journal._from_cache(cache_obj)) ret.update_info(content) return ret def delete(self): self._cache_obj.deleted = True self._cache_obj.dirty = True self._cache_obj.save() def save(self): self._cache_obj.dirty = True try: self._cache_obj.save() except peewee.IntegrityError as e: raise exceptions.AlreadyExists(e) class Calendar(BaseCollection): TYPE = 'CALENDAR' def get_content_class(self): return Event class TaskList(BaseCollection): TYPE = 'TASKS' def get_content_class(self): return Task class AddressBook(BaseCollection): TYPE = 'ADDRESS_BOOK' def get_content_class(self): return Contact class Journal(ApiObjectBase): @property def version(self): return self._cache_obj.version @property def read_only(self): return self._cache_obj.read_only @property def collection(self): journal_info = self.info if journal_info.get('type') == AddressBook.TYPE: return AddressBook(self) elif journal_info.get('type') == Calendar.TYPE: return Calendar(self) elif journal_info.get('type') == TaskList.TYPE: return TaskList(self) @property def info(self): if self._cache_obj.content is not None: return json.loads(self._cache_obj.content.decode()) def update_info(self, update_info): if update_info is None: raise RuntimeError("update_info can't be None.") else: journal_info = self.info if journal_info is None: journal_info = {} journal_info.update(update_info) self._cache_obj.content = json.dumps(journal_info, ensure_ascii=False).encode() # CRUD def list(self): for entry in self._cache_obj.entries: yield Entry._from_cache(entry) pyetesync-0.12.1/etesync/cache.py000066400000000000000000000037021371515505000167110ustar00rootroot00000000000000# Copyright © 2017 Tom Hacohen # # This program is free software: you can redistribute it and/or modify # it under the terms of the GNU Lesser General Public License as # published by the Free Software Foundation, version 3. # # This library 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 . import peewee as pw from . import db class Config(db.BaseModel): db_version = pw.IntegerField() class User(db.BaseModel): username = pw.CharField(unique=True, null=False) class JournalEntity(db.BaseModel): local_user = pw.ForeignKeyField(User, backref='journals') version = pw.IntegerField() uid = pw.CharField(null=False, index=True) owner = pw.CharField(null=True) encrypted_key = pw.BlobField(null=True) content = pw.BlobField() new = pw.BooleanField(null=False, default=False) dirty = pw.BooleanField(null=False, default=False) deleted = pw.BooleanField(null=False, default=False) read_only = pw.BooleanField(null=False, default=False) remote_last_uid = pw.CharField(null=True, default=None) class Meta: indexes = ( (('local_user', 'uid'), True), ) class EntryEntity(db.BaseModel): journal = pw.ForeignKeyField(JournalEntity, backref='entries') uid = pw.CharField(null=False, index=True) content = pw.BlobField() new = pw.BooleanField(null=False, default=False) class Meta: indexes = ( (('journal', 'uid'), True), ) order_by = ('id', ) class UserInfo(db.BaseModel): user = pw.ForeignKeyField(User, primary_key=True, backref='user_info') pubkey = pw.BlobField(null=False) content = pw.BlobField(null=False) pyetesync-0.12.1/etesync/crypto.py000066400000000000000000000132461371515505000171720ustar00rootroot00000000000000# Copyright © 2017 Tom Hacohen # # This program is free software: you can redistribute it and/or modify # it under the terms of the GNU Lesser General Public License as # published by the Free Software Foundation, version 3. # # This library 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 . from cryptography.hazmat.backends import default_backend from cryptography.hazmat.primitives import serialization, hashes, padding from cryptography.hazmat.primitives.asymmetric import rsa, padding as asym_padding from cryptography.hazmat.primitives.ciphers import Cipher, algorithms, modes import os import hashlib import hmac try: from hashlib import scrypt def derive_key(user_password, salt): return scrypt(password=user_password.encode(), salt=salt.encode(), n=16384, r=8, p=1, dklen=190) except ImportError: try: import scrypt def derive_key(user_password, salt): return scrypt.hash(password=user_password.encode(), salt=salt.encode(), N=16384, r=8, p=1, buflen=190) except ImportError: import pyscrypt def derive_key(user_password, salt): return pyscrypt.hash(password=user_password.encode(), salt=salt.encode(), N=16384, r=8, p=1, dkLen=190) from . import exceptions CURRENT_VERSION = 2 HMAC_SIZE = int(256 / 8) # 256bits in bytes AES_BLOCK_SIZE = int(128 / 8) # 128bits in bytes def hmac256(key, data): return hmac.new(key, data, digestmod=hashlib.sha256).digest() class AsymmetricKeyPair: def __init__(self, private_key, public_key): self.private_key = private_key self.public_key = public_key class AsymmetricCryptoManager: def __init__(self, key_pair): self.key_pair = key_pair self._padding = asym_padding.OAEP( mgf=asym_padding.MGF1(algorithm=hashes.SHA1()), algorithm=hashes.SHA1(), label=None ) @classmethod def generate_key_pair(cls): private_key = rsa.generate_private_key( public_exponent=65537, key_size=3072, backend=default_backend() ) return AsymmetricKeyPair( private_key.private_bytes(encryption_algorithm=serialization.NoEncryption(), encoding=serialization.Encoding.DER, format=serialization.PrivateFormat.PKCS8), private_key.public_key().public_bytes(encoding=serialization.Encoding.DER, format=serialization.PublicFormat.SubjectPublicKeyInfo)) def decrypt(self, ctext): private_key = serialization.load_der_private_key( self.key_pair.private_key, password=None, backend=default_backend()) return private_key.decrypt( ctext, self._padding ) def encrypt(self, public_key, clear_text): public_key = serialization.load_der_public_key( self.key_pair.public_key, backend=default_backend()) return public_key.encrypt( clear_text, self._padding ) class CryptoManager: def __init__(self, version, key, salt): if version > CURRENT_VERSION: raise exceptions.VersionTooNew("Found version is too new. Found: {} Current: {}".format( version, CURRENT_VERSION)) elif version == 1: pass else: key = hmac256(salt, key) self.version = version self._set_derived_key(key) @classmethod def create_from_asymmetric_encryted_key(cls, version, key_pair, encrypted_key): asymmetric_crypto_manager = AsymmetricCryptoManager(key_pair) derived_key = asymmetric_crypto_manager.decrypt(encrypted_key) ret = CryptoManager(version, b'', b'') ret._set_derived_key(derived_key) return ret def _set_derived_key(self, key): self.cipher_key = hmac256(b'aes', key) self.hmacKey = hmac256(b'hmac', key) def decrypt(self, ctext): iv = ctext[:AES_BLOCK_SIZE] ctext = ctext[AES_BLOCK_SIZE:] cipher = Cipher(algorithms.AES(self.cipher_key), modes.CBC(iv), backend=default_backend()) unpadder = padding.PKCS7(AES_BLOCK_SIZE * 8).unpadder() decryptor = cipher.decryptor() data = decryptor.update(ctext) + decryptor.finalize() return unpadder.update(data) + unpadder.finalize() def encrypt(self, clear_text): iv = os.urandom(AES_BLOCK_SIZE) cipher = Cipher(algorithms.AES(self.cipher_key), modes.CBC(iv), backend=default_backend()) padder = padding.PKCS7(AES_BLOCK_SIZE * 8).padder() encryptor = cipher.encryptor() padded_data = padder.update(clear_text) + padder.finalize() return iv + encryptor.update(padded_data) + encryptor.finalize() def hmac(self, data): if self.version == 1: return hmac256(self.hmacKey, data) else: return hmac256(self.hmacKey, data + bytes([self.version])) pyetesync-0.12.1/etesync/db.py000066400000000000000000000013431371515505000162320ustar00rootroot00000000000000# Copyright © 2017 Tom Hacohen # # This program is free software: you can redistribute it and/or modify # it under the terms of the GNU Lesser General Public License as # published by the Free Software Foundation, version 3. # # This library 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 . import peewee as pw database_proxy = pw.Proxy() class BaseModel(pw.Model): class Meta: database = database_proxy pyetesync-0.12.1/etesync/exceptions.py000066400000000000000000000022711371515505000200270ustar00rootroot00000000000000# Copyright © 2017 Tom Hacohen # # This program is free software: you can redistribute it and/or modify # it under the terms of the GNU Lesser General Public License as # published by the Free Software Foundation, version 3. # # This library 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 . class HttpException(Exception): pass class UnauthorizedException(HttpException): pass class UserInactiveException(HttpException): pass class ServiceUnavailableException(HttpException): pass class HttpNotFound(HttpException): pass class VersionTooNew(Exception): pass class SecurityException(Exception): pass class IntegrityException(SecurityException): pass class StorageException(Exception): pass class DoesNotExist(StorageException): pass class AlreadyExists(StorageException): pass class TypeMismatch(StorageException): pass pyetesync-0.12.1/etesync/pim.py000066400000000000000000000021011371515505000164230ustar00rootroot00000000000000# Copyright © 2017 Tom Hacohen # # This program is free software: you can redistribute it and/or modify # it under the terms of the GNU Lesser General Public License as # published by the Free Software Foundation, version 3. # # This library 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 . import peewee as pw from .cache import JournalEntity from . import db class Content(db.BaseModel): journal = pw.ForeignKeyField(JournalEntity) uid = pw.CharField(null=False, index=True) content = pw.TextField() new = pw.BooleanField(null=False, default=False) dirty = pw.BooleanField(null=False, default=False) deleted = pw.BooleanField(null=False, default=False) class Meta: indexes = ( (('journal', 'uid'), True), ) pyetesync-0.12.1/etesync/service.py000066400000000000000000000262731371515505000173160ustar00rootroot00000000000000# Copyright © 2017 Tom Hacohen # # This program is free software: you can redistribute it and/or modify # it under the terms of the GNU Lesser General Public License as # published by the Free Software Foundation, version 3. # # This library 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 . import requests import json import base64 import binascii from http import HTTPStatus from furl import furl from .crypto import CryptoManager, HMAC_SIZE from . import exceptions from ._version import __version__ API_PATH = ('api', 'v1') USER_AGENT = 'pyetesync/' + __version__ def _status_success(status_code): return status_code // 100 == 2 class Authenticator: def __init__(self, remote): self.remote = furl(remote) self.remote.path.segments.extend(('api-token-auth', '')) self.remote.path.normalize() def get_auth_token(self, username, password): headers = {'User-Agent': USER_AGENT} response = requests.post(self.remote.url, data={'username': username, 'password': password}, headers=headers) if response.status_code == HTTPStatus.BAD_REQUEST: raise exceptions.UnauthorizedException("Username or password incorrect.") elif not _status_success(response.status_code): raise exceptions.HttpException(response.status_code) data = response.json() return data['token'] class RawBase: def __init__(self, crypto_manager, content=None, uid=None): self.crypto_manager = crypto_manager self.uid = uid self.content = content @property def version(self): return self.crypto_manager.version def getContent(self): return self.crypto_manager.decrypt(self.content) def setContent(self, content): self.content = self.crypto_manager.encrypt(content) def to_simple(self): content = base64.b64encode(self.content) return {'uid': self.uid, 'content': content.decode()} def _verify_hmac(self, hmac1, hmac2): if hmac1 != hmac2: raise exceptions.IntegrityException("HMAC mismatch: {} != {}".format( binascii.hexlify(hmac1).decode(), binascii.hexlify(hmac2).decode())) class RawJournal(RawBase): def __init__(self, crypto_manager, content=None, uid=None, owner=None, encrypted_key=None, read_only=False, remote_last_uid=None): super().__init__(crypto_manager, content, uid) if content is not None: self.hmac = content[:HMAC_SIZE] self.content = content[HMAC_SIZE:] self.owner = owner self.encrypted_key = encrypted_key self.read_only = read_only self.remote_last_uid = remote_last_uid def calc_hmac(self): return self.crypto_manager.hmac(self.uid.encode() + self.content) def verify(self): self._verify_hmac(self.hmac, self.calc_hmac()) def to_simple(self): content = base64.b64encode(self.hmac + self.content) return {'uid': self.uid, 'content': content.decode(), 'version': self.version} def update(self, content): self.setContent(content) self.hmac = self.calc_hmac() class RawEntry(RawBase): def calc_hmac(self, prev): prevUid = b'' if prev is not None: prevUid = prev.uid.encode() return self.crypto_manager.hmac(prevUid + self.content) def verify(self, prev): self._verify_hmac(binascii.unhexlify(self.uid), self.calc_hmac(prev)) def update(self, content, prev): self.setContent(content) self.uid = binascii.hexlify(self.calc_hmac(prev)).decode() class RawUserInfo(RawBase): def __init__(self, crypto_manager, owner=None, pubkey=None, content=None): super().__init__(crypto_manager, content, None) self.owner = owner self.pubkey = pubkey if content is not None: self.hmac = content[:HMAC_SIZE] self.content = content[HMAC_SIZE:] def calc_hmac(self): return self.crypto_manager.hmac(self.content + self.pubkey) def verify(self): self._verify_hmac(self.hmac, self.calc_hmac()) def to_simple(self): content = base64.b64encode(self.hmac + self.content) pubkey = base64.b64encode(self.pubkey) return {'owner': self.owner, 'pubkey': pubkey.decode(), 'content': content.decode(), 'version': self.version} def update(self, content): self.setContent(content) self.hmac = self.calc_hmac() class BaseManager: def __init__(self, auth_token): headers = { 'User-Agent': USER_AGENT, 'Authorization': 'Token ' + auth_token, } self.requests = requests.Session() self.requests.headers.update(headers) def detail_url(self, uid): remote = self.remote.copy() remote.path.segments.extend((uid, '')) remote.path.normalize() return remote def _validate_response(self, response): if response.status_code == HTTPStatus.SERVICE_UNAVAILABLE: raise exceptions.ServiceUnavailableException("Service unavailable") elif response.status_code == HTTPStatus.UNAUTHORIZED: raise exceptions.UnauthorizedException("UNAUTHORIZED auth token") elif response.status_code == HTTPStatus.FORBIDDEN: data = response.json() if data.get('code') == 'service_inactive': raise exceptions.UserInactiveException(data.get('detail')) elif response.status_code == HTTPStatus.NOT_FOUND: raise exceptions.HttpNotFound(response.status_code) elif not _status_success(response.status_code): raise exceptions.HttpException(response.status_code) return response class JournalManager(BaseManager): def __init__(self, remote, auth_token): super().__init__(auth_token) self.remote = furl(remote) self.remote.path.segments.extend(API_PATH + ('journals', '')) self.remote.path.normalize() def list(self, password): response = self.requests.get(self.remote.url) self._validate_response(response) data = response.json() for j in data: uid = j['uid'] version = j['version'] content = base64.b64decode(j['content']) owner = j['owner'] key = j['key'] readOnly = j['readOnly'] last_uid = j.get('lastUid', None) encrypted_key = base64.b64decode(key) if key is not None else None crypto_manager = CryptoManager(version, password, uid.encode()) journal = RawJournal(crypto_manager=crypto_manager, content=content, uid=uid, owner=owner, encrypted_key=encrypted_key, read_only=readOnly, remote_last_uid=last_uid) yield journal def add(self, journal): data = journal.to_simple() response = self.requests.post(self.remote.url, json=data) self._validate_response(response) def delete(self, journal): remote = self.detail_url(journal.uid) response = self.requests.delete(remote.url) self._validate_response(response) def update(self, journal): remote = self.detail_url(journal.uid) data = journal.to_simple() response = self.requests.put(remote.url, json=data) self._validate_response(response) # Members def _get_member_remote(self, journal, member_user=None): remote = self.detail_url(journal.uid).copy() segments = ['members'] if member_user is not None: segments.append(member_user) segments.append('') remote.path.segments.extend(segments) remote.path.normalize() return remote def member_add(self, journal, member): remote = self._get_member_remote(journal) data = member.to_simple() response = self.requests.post(remote.url, json=data) self._validate_response(response) class EntryManager(BaseManager): def __init__(self, remote, auth_token, journalId): super().__init__(auth_token) self.remote = furl(remote) self.remote.path.segments.extend(API_PATH + ('journals', journalId, 'entries', '')) self.remote.path.normalize() def list(self, crypto_manager, last=None): remote = self.remote.copy() prev = None if last is not None: prev = RawEntry(crypto_manager, b'', last) remote.args['last'] = last response = self.requests.get(remote.url) self._validate_response(response) data = response.json() for j in data: uid = j['uid'] content = base64.b64decode(j['content']) entry = RawEntry(crypto_manager=crypto_manager, content=content, uid=uid) entry.verify(prev) prev = entry yield entry def add(self, entries, last=None): remote = self.remote.copy() if last is not None: remote.args['last'] = last data = list(map(lambda x: x.to_simple(), entries)) response = self.requests.post(remote.url, json=data) self._validate_response(response) class UserInfoManager(BaseManager): def __init__(self, remote, auth_token): super().__init__(auth_token) self.remote = furl(remote) self.remote.path.segments.extend(API_PATH + ('user', '')) self.remote.path.normalize() def get(self, owner, cipher_key): remote = self.detail_url(owner) response = self.requests.get(remote.url) self._validate_response(response) data = response.json() version = data['version'] content = base64.b64decode(data['content']) pubkey = base64.b64decode(data['pubkey']) crypto_manager = CryptoManager(version, cipher_key, b"userInfo") return RawUserInfo(crypto_manager, owner, pubkey, content) def add(self, user_info): data = user_info.to_simple() response = self.requests.post(self.remote.url, json=data) self._validate_response(response) def delete(self, user_info): remote = self.detail_url(user_info.owner) response = self.requests.delete(remote.url) self._validate_response(response) def update(self, user_info): remote = self.detail_url(user_info.owner) data = user_info.to_simple() response = self.requests.put(remote.url, json=data) self._validate_response(response) class SyncEntry: def __init__(self, action, content): self.action = action self.content = content @classmethod def from_json(cls, json_string): data = json.loads(json_string) return SyncEntry(data['action'], data['content']) def to_json(self): data = {'action': self.action, 'content': self.content} return json.dumps(data, ensure_ascii=False) class Member: def __init__(self, user, key): self.user = user self.key = key def to_simple(self): key = base64.b64encode(self.key) return {'user': self.user, 'key': key.decode()} pyetesync-0.12.1/example.py000066400000000000000000000026031371515505000156260ustar00rootroot00000000000000#!/usr/bin/env python import sys import etesync as api def printJournal(entry): print("UID {} (version: {})".format(entry.uid, entry.version)) print("CONTENT {}".format(entry.content)) print() def printEntry(entry): print("UID {}".format(entry.uid)) print("CONTENT {}".format(entry.content)) print() try: _, email, servicePassword, userPassword, remoteUrl = sys.argv except ValueError: sys.exit('Incorrect arguments!\nRequired: ' ' ') # Token should be saved intead of requested every time authToken = api.Authenticator(remoteUrl).get_auth_token(email, servicePassword) etesync = api.EteSync(email, authToken, remote=remoteUrl) print("Deriving key") # Very slow operation, should probably be securely cached etesync.derive_key(userPassword) print("Syncing") etesync.sync() print("Syncing done") if len(sys.argv) == 6: journal = etesync.get(sys.argv[5]) # Enable if you'd like to dump the journal if False: for entry in journal.list(): printEntry(entry) # Or interact with the collection print("Journal items: {}".format(len(list(journal.list())))) print("Collection items: {}".format(len(list(journal.collection.list())))) print("Collection: {}".format(list(journal.collection.list()))) else: for entry in etesync.list(): printJournal(entry) pyetesync-0.12.1/example_crud.py000066400000000000000000000067501371515505000166520ustar00rootroot00000000000000#!/usr/bin/env python import etesync as api # The EtesyncCRUD class exposes methods for each of the CRUD operations # (Create, Retrieve, Update and Delete) and for sync with the server. # It handles only one calendar # The class is initialized with user details, authToken and # EITHER the encryption password OR the cipher key. # Intended usage is that a calling program (a CLI) obtains user credentials # from terminal input or some secure storage (like a key ring) # and then creates an instance of EtesyncCRUD as follows: # # call with cipher key # crud = EtesyncCRUD(email, None, remoteUrl, uid, authToken, cipher_key) # # call with encryption password # crud = EtesyncCRUD(email, userPassword, remoteUrl, uid, authToken, None) # The CLI program can then perform CRUD operations by calling # crud.create_event, crud.retrieve_event, # crud.update_event and crud.delete_event # The CLI must explicitly call crud.sync when needed. For example: # (a) if the server has been updated from another device # (b) after any CRUD operation other than Retrieve # No exception handling is done. That is left to the CLI. class EtesyncCRUD: def __init__(self, email, userPassword, remoteUrl, uid, authToken, cipher_key=None): """Initialize Parameters ---------- email : etesync username(email) userPassword : etesync encryption password remoteUrl : url of etesync server uid : uid of calendar authToken : authentication token for etesync server """ self.etesync = api.EteSync(email, authToken, remote=remoteUrl) if cipher_key: self.etesync.cipher_key = cipher_key else: self.etesync.derive_key(userPassword) # needs to be done once on any machine # else the get on the next line fails silent or print("Syncing with server. Please wait") self.etesync.sync() silent or print("Syncing completed.") self.journal = self.etesync.get(uid) self.calendar = self.journal.collection def create_event(self, event): """Create event Parameters ---------- event : iCalendar file as a string (calendar containing one event to be added) """ ev = api.Event.create(self.journal.collection, event) ev.save() def update_event(self, event, uid): """Edit event Parameters ---------- event : iCalendar file as a string (calendar containing one event to be updated) uid : uid of event to be updated """ ev_for_change = self.calendar.get(uid) ev_for_change.content = event ev_for_change.save() def retrieve_event(self, uid): r"""Retrieve event by uid Parameters ---------- uid : uid of event to be retrieved Returns ------- iCalendar file (as a string) """ return self.calendar.get(uid).content def all_events(self): """Retrieve all events in calendar Returns ------- List of iCalendar files (as strings) """ return [e.content for e in self.calendar.list()] def delete_event(self, uid): """Delete event and sync calendar Parameters ---------- uid : uid of event to be deleted """ ev_for_deletion = self.calendar.get(uid) ev_for_deletion.delete() def sync(self): r"""Sync with server """ self.etesync.sync() pyetesync-0.12.1/icon.svg000066400000000000000000000170061371515505000152750ustar00rootroot00000000000000 image/svg+xml pyetesync-0.12.1/requirements.in/000077500000000000000000000000001371515505000167505ustar00rootroot00000000000000pyetesync-0.12.1/requirements.in/development.txt000066400000000000000000000000511371515505000220270ustar00rootroot00000000000000-r ../requirements.txt pytest pytest-cov pyetesync-0.12.1/requirements.in/requirements-dev.txt000066400000000000000000000015321371515505000230110ustar00rootroot00000000000000# # This file is autogenerated by pip-compile # To update, run: # # pip-compile --output-file=requirements.in/requirements-dev.txt requirements.in/development.txt # appdirs==1.4.3 asn1crypto==0.24.0 atomicwrites==1.3.0 # via pytest attrs==19.1.0 certifi==2019.6.16 cffi==1.12.3 chardet==3.0.4 coverage==4.5.4 # via pytest-cov cryptography==2.7 furl==2.0.0 idna==2.8 importlib-metadata==0.19 # via pluggy, pytest more-itertools==7.2.0 # via pytest orderedmultidict==1.0.1 packaging==19.1 peewee==3.10.0 pluggy==0.12.0 # via pytest py==1.8.0 pyasn1==0.4.6 pycparser==2.19 pyparsing==2.4.2 pyscrypt==1.6.2 pytest-cov==2.7.1 pytest==5.0.1 python-dateutil==2.8.0 pytz==2019.2 requests==2.22.0 six==1.12.0 urllib3==1.25.3 vobject==0.9.6.1 wcwidth==0.1.7 # via pytest zipp==0.5.2 # via importlib-metadata pyetesync-0.12.1/requirements.txt000066400000000000000000000024621371515505000171100ustar00rootroot00000000000000# # This file is autogenerated by pip-compile # To update, run: # # pip-compile --output-file=requirements.txt setup.py # appdirs==1.4.3 # via etesync (setup.py) asn1crypto==0.24.0 # via etesync (setup.py) attrs==19.1.0 # via packaging certifi==2019.6.16 # via requests cffi==1.12.3 # via cryptography, etesync (setup.py) chardet==3.0.4 # via requests cryptography==3.0 # via etesync (setup.py) furl==2.0.0 # via etesync (setup.py) idna==2.8 # via etesync (setup.py), requests orderedmultidict==1.0.1 # via etesync (setup.py), furl packaging==19.1 # via etesync (setup.py) peewee==3.10.0 # via etesync (setup.py) py==1.8.0 # via etesync (setup.py) pyasn1==0.4.6 # via etesync (setup.py) pycparser==2.19 # via cffi, etesync (setup.py) pyparsing==2.4.2 # via etesync (setup.py), packaging python-dateutil==2.8.0 # via etesync (setup.py), vobject pytz==2019.2 # via etesync (setup.py) requests==2.22.0 # via etesync (setup.py) six==1.12.0 # via cryptography, etesync (setup.py), furl, orderedmultidict, packaging, python-dateutil urllib3==1.25.3 # via requests vobject==0.9.6.1 # via etesync (setup.py) pyetesync-0.12.1/run_all_tests.sh000077500000000000000000000000541371515505000170340ustar00rootroot00000000000000#!/bin/bash EXTENDED_TESTING=1 pytest "$@" pyetesync-0.12.1/run_unit_tests.sh000077500000000000000000000000311371515505000172360ustar00rootroot00000000000000#!/bin/bash pytest "$@" pyetesync-0.12.1/setenv000066400000000000000000000000321371515505000150420ustar00rootroot00000000000000source .venv/bin/activate pyetesync-0.12.1/setup.py000066400000000000000000000020551371515505000153340ustar00rootroot00000000000000# -*- coding: utf-8 -*- from setuptools import find_packages, setup exec(open('etesync/_version.py').read()) setup( name='etesync', version=__version__, author='Tom Hacohen', author_email='tom@stosb.com', url='https://github.com/etesync/pyetesync', description='Python client library for EteSync', keywords=['etesync', 'encryption', 'sync', 'pim'], license='LGPL-3.0-only', long_description=open('README.md').read(), long_description_content_type='text/markdown', packages=find_packages(exclude=("tests",)), include_package_data=True, install_requires=[ 'appdirs>=1.4', 'asn1crypto>=0.22', 'cffi>=1.10', 'cryptography>=3.0', 'furl>=0.5', 'idna>=2.5', 'orderedmultidict>=0.7', 'packaging>=16.8', 'peewee>=3.7.0', 'py>=1.4', 'pyasn1>=0.2', 'pycparser>=2.17', 'pyparsing>=2.2', 'python-dateutil>=2.6', 'pytz>=2019.1', 'requests>=2.21', 'six>=1.10', 'vobject>=0.9', ] ) pyetesync-0.12.1/tests/000077500000000000000000000000001371515505000147625ustar00rootroot00000000000000pyetesync-0.12.1/tests/__init__.py000066400000000000000000000011431371515505000170720ustar00rootroot00000000000000# Copyright © 2017 Tom Hacohen # # This program is free software: you can redistribute it and/or modify # it under the terms of the GNU Lesser General Public License as # published by the Free Software Foundation, version 3. # # This library 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 . pyetesync-0.12.1/tests/test_collections.py000066400000000000000000000154031371515505000207140ustar00rootroot00000000000000# Copyright © 2017 Tom Hacohen # # This program is free software: you can redistribute it and/or modify # it under the terms of the GNU Lesser General Public License as # published by the Free Software Foundation, version 3. # # This library 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 . import pytest import binascii import etesync as api from etesync import exceptions from etesync.crypto import hmac256 USER_EMAIL = 'test@localhost' TEST_REMOTE = 'http://localhost:8000' TEST_DB = ':memory:' # Gets fake (consistent between tests) random numbers def get_random_uid(context): context._rand = getattr(context, '_rand', 0) context._rand = context._rand + 1 return binascii.hexlify(hmac256(b'', str(context._rand).encode())).decode() @pytest.fixture(scope="module") def etesync(): return api.EteSync(USER_EMAIL, '', remote=TEST_REMOTE, db_path=TEST_DB) class TestCollection: @pytest.fixture(autouse=True) def transact(self, request, etesync): etesync._init_db(TEST_DB) yield def test_crud(self, etesync): # Empty collections assert len(list(etesync.list())) == 0 # Create a = api.Calendar.create(etesync, get_random_uid(self), {'displayName': 'Test', 'description': 'Test desc'}) b = api.AddressBook.create(etesync, get_random_uid(self), {'displayName': 'Test 2'}) assert a is not None assert b is not None # Description is what we expect assert a.description == 'Test desc' # Still empty because we haven't saved assert len(list(etesync.list())) == 0 # Fetch before saved: with pytest.raises(exceptions.DoesNotExist): etesync.get(a.journal.uid) a.save() assert 'Test' == list(etesync.list())[0].collection.display_name assert len(list(etesync.list())) == 1 b.save() assert len(list(etesync.list())) == 2 # Get assert a.journal.uid == etesync.get(a.journal.uid).uid assert b.journal.uid == etesync.get(b.journal.uid).uid # Check version is correct assert a.journal.version > 0 # Delete a.delete() assert len(list(etesync.list())) == 1 b.delete() assert len(list(etesync.list())) == 0 # Try saving two collections with the same uid c = api.Calendar.create(etesync, a.journal.uid, {'displayName': 'Test'}) with pytest.raises(exceptions.AlreadyExists): c.save() def test_content_crud(self, etesync): a = api.Calendar.create(etesync, get_random_uid(self), {'displayName': 'Test'}) b = api.AddressBook.create(etesync, get_random_uid(self), {'displayName': 'Test 2'}) c = api.Calendar.create(etesync, get_random_uid(self), {'displayName': 'Test 3'}) ev = api.Event.create(a, 'BEGIN:VCALENDAR\r\nVERSION:2.0\r\nPRODID:+//Yo\r\nBEGIN:VEVENT\r\nDTSTAMP:20170324T164' + '747Z\r\nUID:2cd64f22-1111-44f5-bc45-53440af38cec\r\nDTSTART;VALUE\u003dDATE:20170324' + '\r\nDTEND;VALUE\u003dDATE:20170325\r\nSUMMARY:Feed cat\r\nSTATUS:CONFIRMED\r\nTRANSP:' + 'TRANSPARENT\r\nEND:VEVENT\r\nEND:VCALENDAR\r\n') # Try saving before the journal is saved with pytest.raises(exceptions.DoesNotExist): ev.save() # Create the event a.save() ev.save() assert ev.uid == a.get('2cd64f22-1111-44f5-bc45-53440af38cec').uid # Fail to create another event with the same uid ev = api.Event.create(a, 'BEGIN:VCALENDAR\r\nVERSION:2.0\r\nPRODID:+//Yo\r\nBEGIN:VEVENT\r\nDTSTAMP:20170324T164' + '747Z\r\nUID:2cd64f22-1111-44f5-bc45-53440af38cec\r\nDTSTART;VALUE\u003dDATE:20170324' + '\r\nDTEND;VALUE\u003dDATE:20170325\r\nSUMMARY:Feed cat\r\nSTATUS:CONFIRMED\r\nTRANSP:' + 'TRANSPARENT\r\nEND:VEVENT\r\nEND:VCALENDAR\r\n') with pytest.raises(exceptions.AlreadyExists): ev.save() # Trying to add an Event into an AddressBook b.save() # Wrong child in collection with pytest.raises(exceptions.TypeMismatch): api.Event.create(b, ( 'BEGIN:VCALENDAR\r\nVERSION:2.0\r\nPRODID:+//Yo\r\nBEGIN:VEVENT\r\nDTSTAMP:20170324T164' + '747Z\r\nUID:2cd64f22-2222-44f5-bc45-53440af38cec\r\nDTSTART;VALUE\u003dDATE:20170324' + '\r\nDTEND;VALUE\u003dDATE:20170325\r\nSUMMARY:Feed cat\r\nSTATUS:CONFIRMED\r\nTRANSP:' + 'TRANSPARENT\r\nEND:VEVENT\r\nEND:VCALENDAR\r\n')) # Same uid in different collections c.save() ev2 = api.Event.create(c, ev.content) ev2.save() # Check it's actually there assert len(list(c.list())) == 1 # # First is still here even after we delete the new one ev2.delete() assert len(list(c.list())) == 0 assert ev.uid == a.get('2cd64f22-1111-44f5-bc45-53440af38cec').uid # Check fetching a non-existent item with pytest.raises(exceptions.DoesNotExist): c.get('bla') # Fail creating an event without a uid. ev = api.Event.create(a, 'BEGIN:VCALENDAR\r\nVERSION:2.0\r\nPRODID:+//Yo\r\nBEGIN:VEVENT\r\nDTSTAMP:20170324T164' + '747Z\r\nDTSTART;VALUE\u003dDATE:20170324' + '\r\nDTEND;VALUE\u003dDATE:20170325\r\nSUMMARY:Feed cat\r\nSTATUS:CONFIRMED\r\nTRANSP:' + 'TRANSPARENT\r\nEND:VEVENT\r\nEND:VCALENDAR\r\n') assert ev is None def test_unicode(self, etesync): a = api.Calendar.create(etesync, get_random_uid(self), {'displayName': 'יוניקוד'}) # Create the event a.save() a2 = etesync.get(a.journal.uid) assert a.display_name == a2.collection.display_name ev = api.Event.create(a, ( 'BEGIN:VCALENDAR\r\nVERSION:2.0\r\nPRODID:+//Yo\r\nBEGIN:VEVENT\r\nDTSTAMP:20170324T164' + '747Z\r\nUID:2cd64f22-2222-44f5-bc45-53440af38cec\r\nDTSTART;VALUE\u003dDATE:20170324' + '\r\nDTEND;VALUE\u003dDATE:20170325\r\nSUMMARY:יוניקוד\r\nSTATUS:CONFIRMED\r\nTRANSP:' + 'TRANSPARENT\r\nEND:VEVENT\r\nEND:VCALENDAR\r\n')) ev.save() assert ev.content == a.get(ev.uid).content # Test repr works repr(ev) pyetesync-0.12.1/tests/test_crypto.py000066400000000000000000000071421371515505000177170ustar00rootroot00000000000000# Copyright © 2017 Tom Hacohen # # This program is free software: you can redistribute it and/or modify # it under the terms of the GNU Lesser General Public License as # published by the Free Software Foundation, version 3. # # This library 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 . import pytest from etesync import crypto, exceptions USER_EMAIL = 'test@localhost' USER_PASSWORD = 'SomePassword' TEST_REMOTE = 'http://localhost:8000' TEST_DB = ':memory:' # Derived key DERIVED_KEY = (b'\x1a\x99\xfa\x8f\xa5\x89\xff\xd2ImY\x16\x86a\x1ff9jJ\x9b9\xaf\x01\x0e\xce5\x0e;J\xea\xb9\xfb' + b'\xdb\xe2\xfbS\xe1G\xd1\x83\x1d.2+\xee\x1b\x08\xc5\xef\xff\x18\xd7=`\x94\x80\x12_\xb3\xb3\xff' + b'\x89v\x8e\xe7 \x7f\xe9@\xce\r\xa8M\x91hui\x17E\x90\x83\x98V\xbbs\xd6\xbc\xffN1"\xce\xe4\xa0Y' + b'\x94\x1f~\x11\x97\xd5\xd7[\xa5v\xfc`\xd5\x8a\x04!AO.Zz\xc6\xa3\xbfy\xb6\xeah5\x03\x1b\xb4\x1f' + b'\x11\x80\xc5\xf9\x1b+\xb1G\x9eE\xbd\xcc\x9b\x1e\x96\x01o\n\xc3"\xc2\xaf]\xc0qXQ,q\xd3T\xf3<\x9e' + b'\xab|\xa3\xbc:?[\xfc\xb6\xe1\xd0)[y\xa76\x8e\xef\xb3\n\x0b\xda\xbadf=\xdd\xab') class TestCrypto: def test_derive(self): # Just make sure we don't break derivation key = crypto.derive_key(USER_PASSWORD, USER_EMAIL) assert key == DERIVED_KEY def test_crypto_v1(self): # Just make sure we don't break derivation crypto_manager = crypto.CryptoManager(1, DERIVED_KEY, b'TestSaltShouldBeJournalId') clear_text = b'This Is Some Test Cleartext.' cipher = crypto_manager.encrypt(clear_text) assert clear_text == crypto_manager.decrypt(cipher) expected = b'/?\x87P\\\xe1\xd4wc\xc6\xe1\x9dB\xb0p\x04mH\xcct\xf4\xba\x0e\xa6;\xc7\xf0x\xf4\x9b^\xd7' assert expected == crypto_manager.hmac(b'Some test data') def test_crypto_v2(self): # Just make sure we don't break derivation crypto_manager = crypto.CryptoManager(2, DERIVED_KEY, b'TestSaltShouldBeJournalId') clear_text = b'This Is Some Test Cleartext.' cipher = crypto_manager.encrypt(clear_text) assert clear_text == crypto_manager.decrypt(cipher) expected = (b']\x0f\xc0\xd2\x07\xa7\xb4\xe6\x84\xf7\xc4}\xc37\xf7\xccB\x00\x1e>\x0e\x1fQ\x85\xf0\x9e\x02\xe8' + b'\x98\x89\xba\x9a') assert expected == crypto_manager.hmac(b'Some test data') def test_asymmetric_crypto(self): key_pair = crypto.AsymmetricCryptoManager.generate_key_pair() asymmetric_crypto_manager = crypto.AsymmetricCryptoManager(key_pair) encrypted_key = asymmetric_crypto_manager.encrypt(key_pair.public_key, DERIVED_KEY) decrypted_key = asymmetric_crypto_manager.decrypt(encrypted_key) assert DERIVED_KEY == decrypted_key crypto_manager = crypto.CryptoManager(2, DERIVED_KEY, b'TestSaltShouldBeJournalId') clear_text = b'This Is Some Test Cleartext.' cipher = (b'\x109\xc3_\x1dM\xcd\xcf\x0e>_\xcb\x10\xff7\x07\xe3\xc6/\x17Y\x94} \x04\x1f\x11g\xa3\x1e\x11\xe5' + b'\xfe#\xbb]JZm\x1dk\xb2\x97\xde\xfcdo\xd3') assert clear_text == crypto_manager.decrypt(cipher) def test_crypto_v_too_new(self): with pytest.raises(exceptions.VersionTooNew): crypto.CryptoManager(293, DERIVED_KEY, b'TestSalt') pyetesync-0.12.1/tests/test_service.py000066400000000000000000000276311371515505000200440ustar00rootroot00000000000000# Copyright © 2017 Tom Hacohen # # This program is free software: you can redistribute it and/or modify # it under the terms of the GNU Lesser General Public License as # published by the Free Software Foundation, version 3. # # This library 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 . import pytest import binascii import requests import json import os import etesync as api from etesync import exceptions from etesync.crypto import hmac256 # Not public API, used for verification from etesync.service import EntryManager, RawJournal, CryptoManager USER_EMAIL = 'test@localhost' USER_PASSWORD = 'SomePassword' USER2_EMAIL = 'test2@localhost' TEST_REMOTE = 'http://localhost:8000/' TEST_DB = ':memory:' WANT_INTEGRATION_TESTS = bool(os.environ.get('EXTENDED_TESTING', False)) # Gets fake (consistent between tests) random numbers def get_random_uid(context): context._rand = getattr(context, '_rand', 0) context._rand = context._rand + 1 return binascii.hexlify(hmac256(b'', str(context._rand).encode())).decode() def get_action(entry): return json.loads(entry._cache_obj.content).get('action') @pytest.fixture(scope="module") def etesync(): auth = api.Authenticator(TEST_REMOTE) token = auth.get_auth_token(USER_EMAIL, USER_PASSWORD) return api.EteSync(USER_EMAIL, token, remote=TEST_REMOTE, db_path=TEST_DB) @pytest.mark.skipif(not WANT_INTEGRATION_TESTS, reason='Skipping itegration tests because EXTENDED_TESTING env var is unset.') class TestService: @pytest.fixture(autouse=True) def transact(self, request, etesync): # Clear the db for this user headers = {'Authorization': 'Token ' + etesync.auth_token} response = requests.post(TEST_REMOTE + 'reset/', headers=headers, allow_redirects=False) assert response.status_code == 200 etesync._init_db(TEST_DB) yield def test_auth_token(self): auth = api.Authenticator(TEST_REMOTE) token = auth.get_auth_token(USER_EMAIL, USER_PASSWORD) assert len(token) > 0 with pytest.raises(exceptions.UnauthorizedException): token = auth.get_auth_token(USER_EMAIL, 'BadPassword') def test_sync_simple(self, etesync): a = api.Calendar.create(etesync, get_random_uid(self), {'displayName': 'Test'}) b = api.AddressBook.create(etesync, get_random_uid(self), {'displayName': 'Test 2'}) a.save() b.save() assert len(list(etesync.list())) == 2 # Make sure we detect dirty correctly assert etesync.journal_list_is_dirty() assert not etesync.journal_is_dirty(a.journal.uid) etesync.sync() # Make sure they are not dirty anymore assert not etesync.journal_list_is_dirty() assert not etesync.journal_is_dirty(a.journal.uid) # Reset the db etesync._init_db(TEST_DB) assert len(list(etesync.list())) == 0 etesync.sync() assert len(list(etesync.list())) == 2 a = etesync.get(a.journal.uid).collection b = etesync.get(b.journal.uid).collection assert a.display_name == 'Test' a.update_info({'displayName': 'Test Update'}) a.save() b.delete() etesync.sync() with pytest.raises(RuntimeError): # Hackily try and update the Journal info's directly a.journal.update_info(None) # Reset the db etesync._init_db(TEST_DB) assert len(list(etesync.list())) == 0 etesync.sync() assert len(list(etesync.list())) == 1 a = etesync.get(a.journal.uid).collection assert a.display_name == 'Test Update' def test_collection_delete_server(self, etesync): a = api.Calendar.create(etesync, get_random_uid(self), {'displayName': 'Test'}) b = api.AddressBook.create(etesync, get_random_uid(self), {'displayName': 'Test 2'}) a.save() b.save() assert len(list(etesync.list())) == 2 etesync.sync() # Reset the db etesync._init_db(TEST_DB) assert len(list(etesync.list())) == 0 etesync.sync() assert len(list(etesync.list())) == 2 b.delete() etesync.sync() # Reset the db etesync._init_db(TEST_DB) assert len(list(etesync.list())) == 0 etesync.sync() assert len(list(etesync.list())) == 1 def test_collection_journal(self, etesync): a = api.Calendar.create(etesync, get_random_uid(self), {'displayName': 'Test'}) a.save() assert len(list(etesync.list())) == 1 # A journal is only dirty if content is dirty assert not etesync.journal_is_dirty(a.journal.uid) ev = api.Event.create(a, 'BEGIN:VCALENDAR\r\nVERSION:2.0\r\nPRODID:+//Yo\r\nBEGIN:VEVENT\r\nDTSTAMP:20170324T164' + '747Z\r\nUID:2cd64f22-1111-44f5-bc45-53440af38cec\r\nDTSTART;VALUE\u003dDATE:20170324' + '\r\nDTEND;VALUE\u003dDATE:20170325\r\nSUMMARY:FÖÖBÖÖ\r\nSTATUS:CONFIRMED\r\nTRANSP:' + 'TRANSPARENT\r\nEND:VEVENT\r\nEND:VCALENDAR\r\n') ev.save() # We have new content, make sure journal is marked as dirty assert etesync.journal_is_dirty(a.journal.uid) etesync.sync() # We just synced, not dirty anymore. assert not etesync.journal_is_dirty(a.journal.uid) ev = a.get(ev.uid) assert len(list(a.journal.list())) == 1 assert get_action(list(a.journal.list())[-1]) == 'ADD' ev.content = ev.content + ' ' ev.save() etesync.sync() ev = a.get(ev.uid) assert len(list(a.journal.list())) == 2 assert get_action(list(a.journal.list())[-1]) == 'CHANGE' ev.delete() etesync.sync() assert len(list(a.journal.list())) == 3 assert get_action(list(a.journal.list())[-1]) == 'DELETE' # Reset db etesync._init_db(TEST_DB) etesync.sync() assert len(list(a.journal.list())) == 3 assert get_action(list(a.journal.list())[-1]) == 'DELETE' assert len(list(a.list())) == 0 def test_collection_unicode(self, etesync): a = api.Calendar.create(etesync, get_random_uid(self), {'displayName': 'fööböö'}) a.save() ev = api.Event.create( a, ('BEGIN:VCALENDAR\r\n' 'BEGIN:VEVENT\r\n' 'UID:test @ foo ät bar град сатану\r\n' 'SUMMARY:FÖÖBÖÖ\r\n' 'END:VEVENT\r\n' 'END:VCALENDAR\r\n') ) ev.save() etesync.sync() def test_collection_shared(self, etesync): from etesync import service, crypto a = api.Calendar.create(etesync, get_random_uid(self), {'displayName': 'fööböö'}) a.save() ev = api.Event.create( a, ('BEGIN:VCALENDAR\r\n' 'BEGIN:VEVENT\r\n' 'UID:test @ foo ät bar град сатану\r\n' 'SUMMARY:FÖÖBÖÖ\r\n' 'END:VEVENT\r\n' 'END:VCALENDAR\r\n') ) ev.save() journal_manager = service.JournalManager(etesync.remote, etesync.auth_token) etesync.sync() # Second user auth = api.Authenticator(TEST_REMOTE) token = auth.get_auth_token(USER2_EMAIL, USER_PASSWORD) etesync2 = api.EteSync(USER2_EMAIL, token, remote=TEST_REMOTE, db_path=TEST_DB) headers = {'Authorization': 'Token ' + etesync2.auth_token} response = requests.post(TEST_REMOTE + 'reset/', headers=headers, allow_redirects=False) assert response.status_code == 200 user_info = etesync2.get_or_create_user_info() key_pair = crypto.AsymmetricKeyPair(user_info.content, user_info.pubkey) asymmetric_crypto_manager = crypto.AsymmetricCryptoManager(key_pair) cipher_key = hmac256(a.journal.uid.encode(), etesync.cipher_key) encrypted_key = asymmetric_crypto_manager.encrypt(key_pair.public_key, cipher_key) member = service.Member(USER2_EMAIL, encrypted_key) journal_manager.member_add(a.journal._cache_obj, member) etesync2.sync() journal_list = list(etesync2.list()) assert len(journal_list) == 1 assert journal_list[0].uid == a.journal.uid def test_user_info_manage(self, etesync): # FIXME: Shouldn't expose and rely on service from etesync import service from etesync.crypto import CryptoManager, CURRENT_VERSION # Failed get info_manager = service.UserInfoManager(etesync.remote, etesync.auth_token) with pytest.raises(exceptions.HttpException): info_manager.get(USER_EMAIL, etesync.cipher_key) # Add crypto_manager = CryptoManager(CURRENT_VERSION, etesync.cipher_key, b"userInfo") user_info = service.RawUserInfo(crypto_manager, USER_EMAIL, b"pubkeyTest") user_info.update(b"contentTest") user_info.verify() info_manager.add(user_info) user_info2 = info_manager.get(USER_EMAIL, etesync.cipher_key) user_info2.verify() assert user_info.content == user_info2.content assert user_info.pubkey == user_info2.pubkey assert user_info.owner == user_info2.owner # Update user_info.update(b"contentTest2") info_manager.update(user_info) user_info2 = info_manager.get(USER_EMAIL, etesync.cipher_key) user_info2.verify() assert user_info.content == user_info2.content assert user_info.pubkey == user_info2.pubkey assert user_info.owner == user_info2.owner # Delete info_manager.delete(user_info) with pytest.raises(exceptions.HttpException): info_manager.get(USER_EMAIL, etesync.cipher_key) def test_collection_sync(self, etesync): a = api.Calendar.create(etesync, get_random_uid(self), {'displayName': 'Test'}) ev = api.Event.create(a, 'BEGIN:VCALENDAR\r\nVERSION:2.0\r\nPRODID:+//Yo\r\nBEGIN:VEVENT\r\nDTSTAMP:20170324T164' + '747Z\r\nUID:2cd64f22-1111-44f5-bc45-53440af38cec\r\nDTSTART;VALUE\u003dDATE:20170324' + '\r\nDTEND;VALUE\u003dDATE:20170325\r\nSUMMARY:Feed cat\r\nSTATUS:CONFIRMED\r\nTRANSP:' + 'TRANSPARENT\r\nEND:VEVENT\r\nEND:VCALENDAR\r\n') # Create the event a.save() ev.save() # Add another and then sync (check we can sync more than one) ev = api.Event.create(a, 'BEGIN:VCALENDAR\r\nVERSION:2.0\r\nPRODID:+//Yo\r\nBEGIN:VEVENT\r\nDTSTAMP:20170324T164' + '747Z\r\nUID:2cd64f22-1111-44f5-bc45-aaaaaaaaaaac\r\nDTSTART;VALUE\u003dDATE:20170324' + '\r\nDTEND;VALUE\u003dDATE:20170325\r\nSUMMARY:Feed 2\r\nSTATUS:CONFIRMED\r\nTRANSP:' + 'TRANSPARENT\r\nEND:VEVENT\r\nEND:VCALENDAR\r\n') ev.save() assert len(list(a.list())) == 2 etesync.sync() ev.delete() assert len(list(a.list())) == 1 etesync.sync() # Verify we created valid journal entries journal_uid = a.journal.uid manager = EntryManager(etesync.remote, etesync.auth_token, journal_uid) crypto_manager = CryptoManager(a.journal.version, etesync.cipher_key, journal_uid.encode()) journal = RawJournal(crypto_manager, uid=journal_uid) crypto_manager = etesync._get_journal_cryptomanager(journal) prev = None last_uid = None for entry in manager.list(crypto_manager, last_uid): entry.verify(prev) prev = entry