summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--setup.py20
-rw-r--r--src/leap/soledad/__init__.py368
-rw-r--r--src/leap/soledad/backends/__init__.py18
-rw-r--r--src/leap/soledad/backends/couch.py232
-rw-r--r--src/leap/soledad/backends/leap_backend.py115
-rw-r--r--src/leap/soledad/backends/objectstore.py173
-rw-r--r--src/leap/soledad/backends/sqlcipher.py162
-rw-r--r--src/leap/soledad/server.py107
-rw-r--r--src/leap/soledad/shared_db.py103
-rw-r--r--src/leap/soledad/tests/test_crypto.py32
-rw-r--r--src/leap/soledad/util.py225
11 files changed, 1395 insertions, 160 deletions
diff --git a/setup.py b/setup.py
index 2eb542b0..f9124a32 100644
--- a/setup.py
+++ b/setup.py
@@ -1,3 +1,21 @@
+# -*- coding: utf-8 -*-
+# setup.py
+# Copyright (C) 2013 LEAP
+#
+# This program is free software: you can redistribute it and/or modify
+# it under the terms of the GNU General Public License as published by
+# the Free Software Foundation, either version 3 of the License, or
+# (at your option) any later version.
+#
+# This program is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+# GNU General Public License for more details.
+#
+# You should have received a copy of the GNU General Public License
+# along with this program. If not, see <http://www.gnu.org/licenses/>.
+
+
from setuptools import (
setup,
find_packages
@@ -29,7 +47,7 @@ install_requirements = [
# TODO: change below so we get stable versions of modules.
dependency_links = [
'git+git://git.futeisha.org/pysqlcipher.git@develop#egg=pysqlcipher',
- 'git+ssh://code.leap.se/leap_pycommon.git@develop#egg=leap_pycommon',
+ 'git+ssh://code.leap.se/leap_pycommon.git@develop#egg=leap.common',
'http://twistedmatrix.com/Releases/Twisted/13.0/Twisted-13.0.0.tar.bz2#egg=twisted-13.0.0'
]
diff --git a/src/leap/soledad/__init__.py b/src/leap/soledad/__init__.py
index bd5a351c..baf303e3 100644
--- a/src/leap/soledad/__init__.py
+++ b/src/leap/soledad/__init__.py
@@ -1,4 +1,21 @@
# -*- coding: utf-8 -*-
+# __init__.py
+# Copyright (C) 2013 LEAP
+#
+# This program is free software: you can redistribute it and/or modify
+# it under the terms of the GNU General Public License as published by
+# the Free Software Foundation, either version 3 of the License, or
+# (at your option) any later version.
+#
+# This program is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+# GNU General Public License for more details.
+#
+# You should have received a copy of the GNU General Public License
+# along with this program. If not, see <http://www.gnu.org/licenses/>.
+
+
"""
Soledad - Synchronization Of Locally Encrypted Data Among Devices.
@@ -19,6 +36,8 @@ try:
import simplejson as json
except ImportError:
import json # noqa
+
+
from leap.soledad.backends import sqlcipher
from leap.soledad.util import GPGWrapper
from leap.soledad.backends.leap_backend import (
@@ -66,37 +85,45 @@ class Soledad(object):
'shared_db_url': '',
}
- # TODO: separate username from provider, currently in user_email.
- def __init__(self, user_email, prefix=None, gnupg_home=None,
+ def __init__(self, user, prefix=None, gnupg_home=None,
secret_path=None, local_db_path=None,
config_file=None, shared_db_url=None, auth_token=None,
bootstrap=True):
"""
Initialize configuration, cryptographic keys and dbs.
- :param user_email: Email address of the user (username@provider).
- :param prefix: Path to use as prefix for files.
- :param gnupg_home: Home directory for gnupg.
- :param secret_path: Path for storing gpg-encrypted key used for
+ @param user: Email address of the user (username@provider).
+ @type user: str
+ @param prefix: Path to use as prefix for files.
+ @type prefix: str
+ @param gnupg_home: Home directory for gnupg.
+ @type gnupg_home: str
+ @param secret_path: Path for storing gpg-encrypted key used for
symmetric encryption.
- :param local_db_path: Path for local encrypted storage db.
- :param config_file: Path for configuration file.
- :param shared_db_url: URL for shared Soledad DB for key storage and
+ @type secret_path: str
+ @param local_db_path: Path for local encrypted storage db.
+ @type local_db_path: str
+ @param config_file: Path for configuration file.
+ @type config_file: str
+ @param shared_db_url: URL for shared Soledad DB for key storage and
unauth retrieval.
- :param auth_token: Authorization token for accessing remote databases.
- :param bootstrap: True/False, should bootstrap keys?
+ @type shared_db_url: str
+ @param auth_token: Authorization token for accessing remote databases.
+ @type auth_token: str
+ @param bootstrap: True/False, should bootstrap this instance? Mostly
+ for testing purposes but can be useful for initialization control.
+ @type bootstrap: bool
"""
# TODO: allow for fingerprint enforcing.
- self._user_email = user_email
+ self._user = user
self._auth_token = auth_token
self._init_config(
- {'prefix': prefix,
- 'gnupg_home': gnupg_home,
- 'secret_path': secret_path,
- 'local_db_path': local_db_path,
- 'config_file': config_file,
- 'shared_db_url': shared_db_url,
- }
+ prefix=prefix,
+ gnupg_home=gnupg_home,
+ secret_path=secret_path,
+ local_db_path=local_db_path,
+ config_file=config_file,
+ shared_db_url=shared_db_url,
)
if bootstrap:
self._bootstrap()
@@ -107,18 +134,18 @@ class Soledad(object):
Soledad Client bootstrap is the following sequence of stages:
- Stage 0 - Local environment setup.
- - directory initialization.
- - gnupg wrapper initialization.
- Stage 1 - Keys generation/loading:
- - if keys exists locally, load them.
- - else, if keys exists in server, download them.
- - else, generate keys.
- Stage 2 - Keys synchronization:
- - if keys exist in server, confirm we have the same keys
- locally.
- - else, send keys to server.
- Stage 3 - Database initialization.
+ * Stage 0 - Local environment setup.
+ - directory initialization.
+ - gnupg wrapper initialization.
+ * Stage 1 - Keys generation/loading:
+ - if keys exists locally, load them.
+ - else, if keys exists in server, download them.
+ - else, generate keys.
+ * Stage 2 - Keys synchronization:
+ - if keys exist in server, confirm we have the same keys
+ locally.
+ - else, send keys to server.
+ * Stage 3 - Database initialization.
This method decides which bootstrap stages have already been performed
and performs the missing ones in order.
@@ -152,17 +179,21 @@ class Soledad(object):
True,
token=auth_token)
- def _init_config(self, param_conf):
+ def _init_config(self, **kwargs):
"""
Initialize configuration, with precedence order give by: instance
parameters > config file > default values.
+
+ @param kwargs: a dictionary with parameter values passed when
+ instantiating this Soledad instance.
+ @type kwargs: dict
"""
# TODO: write tests for _init_config()
- self.prefix = param_conf['prefix'] or \
+ self.prefix = kwargs['prefix'] or \
os.environ['HOME'] + '/.config/leap/soledad'
m = re.compile('.*%s.*')
for key, default_value in self.DEFAULT_CONF.iteritems():
- val = param_conf[key] or default_value
+ val = kwargs[key] or default_value
if m.match(val):
val = val % self.prefix
setattr(self, key, val)
@@ -172,7 +203,7 @@ class Soledad(object):
config.read(self.config_file)
if 'soledad-client' in config:
for key in self.DEFAULT_CONF:
- if key in config['soledad-client'] and not param_conf[key]:
+ if key in config['soledad-client'] and not kwargs[key]:
setattr(self, key, config['soledad-client'][key])
def _init_dirs(self):
@@ -199,7 +230,7 @@ class Soledad(object):
def _init_db(self):
"""
- Initialize the database for local storage .
+ Initialize the database for local storage.
"""
# instantiate u1db
# TODO: verify if secret for sqlcipher should be the same as the
@@ -222,12 +253,16 @@ class Soledad(object):
#-------------------------------------------------------------------------
# TODO: refactor the following methods to somewhere out of here
- # (SoledadCrypto, maybe?)
+ # (a new class SoledadCrypto, maybe?)
def _has_symkey(self):
"""
- Verify if secret for symmetric encryption exists in a local encrypted
+ Verify if a key for symmetric encryption exists in a local encrypted
file.
+
+ @return: whether this soledad instance has a key for symmetric
+ encryption
+ @rtype: bool
"""
# does the file exist in disk?
if not os.path.isfile(self.secret_path):
@@ -270,6 +305,12 @@ class Soledad(object):
string.digits) for x in range(self.SECRET_LENGTH)))
def _set_symkey(self, symkey):
+ """
+ Define and store the key to be used for symmetric encryption.
+
+ @param symkey: the symmetric key
+ @type symkey: str
+ """
if self._has_symkey():
raise KeyAlreadyExists("Tried to set the value of the key for "
"symmetric encryption but it already "
@@ -291,6 +332,9 @@ class Soledad(object):
def _has_privkey(self):
"""
Verify if there exists an OpenPGP keypair for this user.
+
+ @return: whether this soledad instance has a private OpenPGP key
+ @rtype: bool
"""
try:
self._load_privkey()
@@ -300,7 +344,10 @@ class Soledad(object):
def _gen_privkey(self):
"""
- Generate an OpenPGP keypair for this user.
+ Generate an OpenPGP keypair for the user.
+
+ @return: the fingerprint of the generated key
+ @rtype: str
"""
if self._has_privkey():
raise KeyAlreadyExists("Tried to generate OpenPGP keypair but it "
@@ -308,22 +355,46 @@ class Soledad(object):
params = self._gpg.gen_key_input(
key_type='RSA',
key_length=4096,
- name_real=self._user_email,
- name_email=self._user_email,
+ name_real=self._user,
+ name_email=self._user,
name_comment='Generated by LEAP Soledad.')
fingerprint = self._gpg.gen_key(params).fingerprint
return self._load_privkey(fingerprint)
def _set_privkey(self, raw_data):
+ """
+ Set private OpenPGP key as the key to be used in this soledad instance.
+
+ @param raw_data: the private key blob
+ @type raw_data: str
+
+ @return: the fingerprint of the key passed as argument
+ @rtype: str
+ """
if self._has_privkey():
- raise KeyAlreadyExists("Tried to generate OpenPGP keypair but it "
- "already exists on disk.")
+ raise KeyAlreadyExists("Tried to define an OpenPGP keypair but "
+ "it already exists on disk.")
fingerprint = self._gpg.import_keys(raw_data).fingerprints[0]
return self._load_privkey(fingerprint)
def _load_privkey(self, fingerprint=None):
"""
- Find fingerprint for this user's OpenPGP keypair.
+ Assert private key exists in local keyring and load its fingerprint to
+ memory.
+
+ This method either looks for a key with fingerprint given by the
+ parameter or searches for a key bound to the user's email address if
+ no finfgerprint is provided.
+
+ Raises a LookupError if a key (either for the given fingerprint or for
+ self._user if that was not provided) was not found.
+
+ @param fingerprint: optional fingerprint for forcing a specific key to
+ be loaded
+ @type fingerprint: str
+
+ @return: the fingerprint of the loaded key
+ @rtype: str
"""
# TODO: guarantee encrypted storage of private keys.
try:
@@ -333,16 +404,22 @@ class Soledad(object):
secret=True)['fingerprint']
else:
self._fingerprint = self._gpg.find_key_by_email(
- self._user_email,
+ self._user,
secret=True)['fingerprint']
return self._fingerprint
except LookupError:
- raise KeyDoesNotExist("Tried to load OpenPGP keypair but it does "
- "not exist on disk.")
+ raise KeyDoesNotExist('OpenPGP private key but it does not exist '
+ 'on local keyring.')
def publish_pubkey(self, keyserver):
"""
Publish OpenPGP public key to a keyserver.
+
+ @param keyserver: the keyserver url
+ @type keyserver: str
+
+ @return: whether the action succeeded
+ @rtype: bool
"""
# TODO: this has to talk to LEAP's Nickserver.
pass
@@ -352,20 +429,51 @@ class Soledad(object):
#-------------------------------------------------------------------------
def _has_keys(self):
+ """
+ Return whether this instance has both the private OpenPGP key and the
+ key for symmetric encryption.
+
+ @return: whether keys are available for this instance
+ @rtype: bool
+ """
return self._has_privkey() and self._has_symkey()
def _load_keys(self):
+ """
+ Load the OpenPGP private key and the key for symmetric encryption from
+ persistent storage.
+ """
self._load_privkey()
self._load_symkey()
def _gen_keys(self):
+ """
+ Generate an OpenPGP keypair and a key for symmetric encryption.
+ """
self._gen_privkey()
self._gen_symkey()
def _user_hash(self):
- return hmac.new(self._user_email, 'user').hexdigest()
+ """
+ Calculate a hash for storing/retrieving key material on shared
+ database, based on user's email.
+
+ @return: the hash
+ @rtype: str
+ """
+ return hmac.new(self._user, 'user').hexdigest()
def _get_keys_doc(self):
+ """
+ Retrieve the document with encrypted key material from the shared
+ database.
+
+ @return: a document with encrypted key material in its contents
+ @rtype: LeapDocument
+ """
+ # TODO: change below to raise appropriate exceptions
+ #if not hasattr(self, '_shared_db'):
+ # return None
return self._shared_db.get_doc_unauth(self._user_hash())
def _assert_server_keys(self):
@@ -373,6 +481,8 @@ class Soledad(object):
Assert our key copies are the same as server's ones.
"""
assert self._has_keys()
+ #if not hasattr(self, '_shared_db'):
+ # return
doc = self._get_keys_doc()
if doc:
remote_privkey = self.decrypt(doc.content['_privkey'],
@@ -406,6 +516,18 @@ class Soledad(object):
def encrypt(self, data, sign=None, passphrase=None, symmetric=False):
"""
Encrypt data.
+
+ @param data: the data to be encrypted
+ @type data: str
+ @param sign: the fingerprint of key to be used for signature
+ @type sign: str
+ @param passphrase: the passphrase to be used for encryption
+ @type passphrase: str
+ @param symmetric: whether the encryption scheme should be symmetric
+ @type symmetric: bool
+
+ @return: the encrypted data
+ @rtype: str
"""
return str(self._gpg.encrypt(data, self._fingerprint, sign=sign,
passphrase=passphrase,
@@ -413,7 +535,20 @@ class Soledad(object):
def encrypt_symmetric(self, doc_id, data, sign=None):
"""
- Encrypt data using symmetric secret.
+ Encrypt data using a password.
+
+ The password is derived from the document id and the secret for
+ symmetric encryption previously generated/loaded.
+
+ @param doc_id: the document id
+ @type doc_id: str
+ @param data: the data to be encrypted
+ @type data: str
+ @param sign: the fingerprint of key to be used for signature
+ @type sign: str
+
+ @return: the encrypted data
+ @rtype: str
"""
return self.encrypt(data, sign=sign,
passphrase=self._hmac_passphrase(doc_id),
@@ -422,25 +557,75 @@ class Soledad(object):
def decrypt(self, data, passphrase=None):
"""
Decrypt data.
+
+ @param data: the data to be decrypted
+ @type data: str
+ @param passphrase: the passphrase to be used for decryption
+ @type passphrase: str
+
+ @return: the decrypted data
+ @rtype: str
"""
return str(self._gpg.decrypt(data, passphrase=passphrase))
def decrypt_symmetric(self, doc_id, data):
"""
Decrypt data using symmetric secret.
+
+ @param doc_id: the document id
+ @type doc_id: str
+ @param data: the data to be decrypted
+ @type data: str
+
+ @return: the decrypted data
+ @rtype: str
"""
return self.decrypt(data, passphrase=self._hmac_passphrase(doc_id))
def _hmac_passphrase(self, doc_id):
+ """
+ Generate a passphrase for symmetric encryption.
+
+ The password is derived from the document id and the secret for
+ symmetric encryption previously generated/loaded.
+
+ @param doc_id: the document id
+ @type doc_id: str
+
+ @return: the passphrase
+ @rtype: str
+ """
return hmac.new(self._symkey, doc_id).hexdigest()
def is_encrypted(self, data):
+ """
+ Test whether some chunk of data is a cyphertext.
+
+ @param data: the data to be tested
+ @type data: str
+
+ @return: whether the data is a cyphertext
+ @rtype: bool
+ """
return self._gpg.is_encrypted(data)
def is_encrypted_sym(self, data):
+ """
+ Test whether some chunk of data was encrypted with a symmetric key.
+
+ @return: whether data is encrypted to a symmetric key
+ @rtype: bool
+ """
return self._gpg.is_encrypted_sym(data)
def is_encrypted_asym(self, data):
+ """
+ Test whether some chunk of data was encrypted to an OpenPGP private
+ key.
+
+ @return: whether data is encrypted to an OpenPGP private key
+ @rtype: bool
+ """
return self._gpg.is_encrypted_asym(data)
#-------------------------------------------------------------------------
@@ -453,18 +638,40 @@ class Soledad(object):
def put_doc(self, doc):
"""
Update a document in the local encrypted database.
+
+ @param doc: the document to update
+ @type doc: LeapDocument
+
+ @return: the new revision identifier for the document
+ @rtype: str
"""
return self._db.put_doc(doc)
def delete_doc(self, doc):
"""
Delete a document from the local encrypted database.
+
+ @param doc: the document to delete
+ @type doc: LeapDocument
+
+ @return: the new revision identifier for the document
+ @rtype: str
"""
return self._db.delete_doc(doc)
def get_doc(self, doc_id, include_deleted=False):
"""
Retrieve a document from the local encrypted database.
+
+ @param doc_id: the unique document identifier
+ @type doc_id: str
+ @param include_deleted: if True, deleted documents will be
+ returned with empty content; otherwise asking for a deleted
+ document will return None
+ @type include_deleted: bool
+
+ @return: the document object or None
+ @rtype: LeapDocument
"""
return self._db.get_doc(doc_id, include_deleted=include_deleted)
@@ -472,6 +679,16 @@ class Soledad(object):
include_deleted=False):
"""
Get the content for many documents.
+
+ @param doc_ids: a list of document identifiers
+ @type doc_ids: list
+ @param check_for_conflicts: if set False, then the conflict check will
+ be skipped, and 'None' will be returned instead of True/False
+ @type check_for_conflicts: bool
+
+ @return: iterable giving the Document object for each document id
+ in matching doc_ids order.
+ @rtype: generator
"""
return self._db.get_docs(doc_ids,
check_for_conflicts=check_for_conflicts,
@@ -480,24 +697,52 @@ class Soledad(object):
def create_doc(self, content, doc_id=None):
"""
Create a new document in the local encrypted database.
+
+ @param content: the contents of the new document
+ @type content: dict
+ @param doc_id: an optional identifier specifying the document id
+ @type doc_id: str
+
+ @return: the new document
+ @rtype: LeapDocument
"""
return self._db.create_doc(content, doc_id=doc_id)
def get_doc_conflicts(self, doc_id):
"""
Get the list of conflicts for the given document.
+
+ @param doc_id: the document id
+ @type doc_id: str
+
+ @return: a list of the document entries that are conflicted
+ @rtype: list
+
"""
return self._db.get_doc_conflicts(doc_id)
def resolve_doc(self, doc, conflicted_doc_revs):
"""
Mark a document as no longer conflicted.
+
+ @param doc: a document with the new content to be inserted.
+ @type doc: LeapDocument
+ @param conflicted_doc_revs: a list of revisions that the new content
+ supersedes.
+ @type conflicted_doc_revs: list
"""
return self._db.resolve_doc(doc, conflicted_doc_revs)
def sync(self, url):
"""
- Synchronize the local encrypted database with LEAP server.
+ Synchronize the local encrypted replica with a remote replica.
+
+ @param url: the url of the target replica to sync with
+ @type url: str
+
+ @return: the local generation before the synchronisation was
+ performed.
+ @rtype: str
"""
# TODO: create authentication scheme for sync with server.
return self._db.sync(url, creds=None, autocreate=True)
@@ -506,7 +751,7 @@ class Soledad(object):
# Recovery document export and import
#-------------------------------------------------------------------------
- def export_recovery_document(self, passphrase):
+ def export_recovery_document(self, passphrase=None):
"""
Exports username, provider, private key and key for symmetric
encryption, optionally encrypted with a password.
@@ -525,9 +770,15 @@ class Soledad(object):
- provider
- private key.
- key for symmetric encryption
+
+ @param passphrase: an optional passphrase for encrypting the document
+ @type passphrase: str
+
+ @return: the recovery document json serialization
+ @rtype: str
"""
data = json.dumps({
- 'user_email': self._user_email,
+ 'user': self._user,
'privkey': self._gpg.export_keys(self._fingerprint, secret=True),
'symkey': self._symkey,
})
@@ -537,7 +788,16 @@ class Soledad(object):
symmetric=True))
return data
- def import_recovery_document(self, data, passphrase):
+ def import_recovery_document(self, data, passphrase=None):
+ """
+ Import username, provider, private key and key for symmetric
+ encryption from a recovery document.
+
+ @param data: the recovery document json serialization
+ @type data: str
+ @param passphrase: an optional passphrase for decrypting the document
+ @type passphrase: str
+ """
if self._has_keys():
raise KeyAlreadyExists("You tried to import a recovery document "
"but secret keys are already present.")
@@ -547,7 +807,7 @@ class Soledad(object):
if passphrase:
data = str(self._gpg.decrypt(data, passphrase=passphrase))
data = json.loads(data)
- self._user_email = data['user_email']
+ self._user = data['user']
self._gpg.import_keys(data['privkey'])
self._load_privkey()
self._symkey = data['symkey']
diff --git a/src/leap/soledad/backends/__init__.py b/src/leap/soledad/backends/__init__.py
index f5e2497a..720a8118 100644
--- a/src/leap/soledad/backends/__init__.py
+++ b/src/leap/soledad/backends/__init__.py
@@ -1,3 +1,21 @@
+# -*- coding: utf-8 -*-
+# __init__.py
+# Copyright (C) 2013 LEAP
+#
+# This program is free software: you can redistribute it and/or modify
+# it under the terms of the GNU General Public License as published by
+# the Free Software Foundation, either version 3 of the License, or
+# (at your option) any later version.
+#
+# This program is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+# GNU General Public License for more details.
+#
+# You should have received a copy of the GNU General Public License
+# along with this program. If not, see <http://www.gnu.org/licenses/>.
+
+
"""
Backends that extend U1DB functionality.
"""
diff --git a/src/leap/soledad/backends/couch.py b/src/leap/soledad/backends/couch.py
index b7a77054..5407f992 100644
--- a/src/leap/soledad/backends/couch.py
+++ b/src/leap/soledad/backends/couch.py
@@ -1,42 +1,71 @@
+# -*- coding: utf-8 -*-
+# couch.py
+# Copyright (C) 2013 LEAP
+#
+# This program is free software: you can redistribute it and/or modify
+# it under the terms of the GNU General Public License as published by
+# the Free Software Foundation, either version 3 of the License, or
+# (at your option) any later version.
+#
+# This program is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+# GNU General Public License for more details.
+#
+# You should have received a copy of the GNU General Public License
+# along with this program. If not, see <http://www.gnu.org/licenses/>.
+
+
"""A U1DB backend that uses CouchDB as its persistence layer."""
# general imports
import uuid
-from base64 import b64encode, b64decode
import re
-# u1db
+try:
+ import simplejson as json
+except ImportError:
+ import json # noqa
+
+
+from base64 import b64encode, b64decode
from u1db import errors
from u1db.sync import LocalSyncTarget
from u1db.backends.inmemory import InMemoryIndex
from u1db.remote.server_state import ServerState
from u1db.errors import DatabaseDoesNotExist
-# couchdb
from couchdb.client import Server, Document as CouchDocument
from couchdb.http import ResourceNotFound
-# leap
from leap.soledad.backends.objectstore import (
ObjectStoreDatabase,
ObjectStoreSyncTarget,
)
from leap.soledad.backends.leap_backend import LeapDocument
-try:
- import simplejson as json
-except ImportError:
- import json # noqa
-
class InvalidURLError(Exception):
- """Exception raised when Soledad encounters a malformed URL."""
- pass
+ """
+ Exception raised when Soledad encounters a malformed URL.
+ """
class CouchDatabase(ObjectStoreDatabase):
- """A U1DB backend that uses Couch as its persistence layer."""
+ """
+ A U1DB backend that uses Couch as its persistence layer.
+ """
@classmethod
def open_database(cls, url, create):
- """Open a U1DB database using CouchDB as backend."""
+ """
+ Open a U1DB database using CouchDB as backend.
+
+ @param url: the url of the database replica
+ @type url: str
+ @param create: should the replica be created if it does not exist?
+ @type create: bool
+
+ @return: the database instance
+ @rtype: CouchDatabase
+ """
# get database from url
m = re.match('(^https?://[^/]+)/(.+)$', url)
if not m:
@@ -51,24 +80,39 @@ class CouchDatabase(ObjectStoreDatabase):
raise DatabaseDoesNotExist()
return cls(url, dbname)
- def __init__(self, url, database, replica_uid=None, full_commit=True,
+ def __init__(self, url, dbname, replica_uid=None, full_commit=True,
session=None):
- """Create a new Couch data container."""
+ """
+ Create a new Couch data container.
+
+ @param url: the url of the couch database
+ @type url: str
+ @param dbname: the database name
+ @type dbname: str
+ @param replica_uid: an optional unique replica identifier
+ @type replica_uid: str
+ @param full_commit: turn on the X-Couch-Full-Commit header
+ @type full_commit: bool
+ @param session: an http.Session instance or None for a default session
+ @type session: http.Session
+ """
self._url = url
self._full_commit = full_commit
self._session = session
self._server = Server(url=self._url,
full_commit=self._full_commit,
session=self._session)
- self._dbname = database
+ self._dbname = dbname
# this will ensure that transaction and sync logs exist and are
# up-to-date.
try:
- self._database = self._server[database]
+ self._database = self._server[self._dbname]
except ResourceNotFound:
- self._server.create(database)
- self._database = self._server[database]
+ self._server.create(self._dbname)
+ self._database = self._server[self._dbname]
super(CouchDatabase, self).__init__(replica_uid=replica_uid,
+ # TODO: move the factory choice
+ # away
document_factory=LeapDocument)
#-------------------------------------------------------------------------
@@ -76,7 +120,19 @@ class CouchDatabase(ObjectStoreDatabase):
#-------------------------------------------------------------------------
def _get_doc(self, doc_id, check_for_conflicts=False):
- """Get just the document content, without fancy handling."""
+ """
+ Get just the document content, without fancy handling.
+
+ @param doc_id: The unique document identifier
+ @type doc_id: str
+ @param include_deleted: If set to True, deleted documents will be
+ returned with empty content. Otherwise asking for a deleted
+ document will return None.
+ @type include_deleted: bool
+
+ @return: a Document object.
+ @type: u1db.Document
+ """
cdoc = self._database.get(doc_id)
if cdoc is None:
return None
@@ -95,7 +151,19 @@ class CouchDatabase(ObjectStoreDatabase):
return doc
def get_all_docs(self, include_deleted=False):
- """Get the JSON content for all documents in the database."""
+ """
+ Get the JSON content for all documents in the database.
+
+ @param include_deleted: If set to True, deleted documents will be
+ returned with empty content. Otherwise deleted documents will not
+ be included in the results.
+ @type include_deleted: bool
+
+ @return: (generation, [Document])
+ The current generation of the database, followed by a list of all
+ the documents in the database.
+ @rtype: tuple
+ """
generation = self._get_generation()
results = []
for doc_id in self._database:
@@ -108,7 +176,19 @@ class CouchDatabase(ObjectStoreDatabase):
return (generation, results)
def _put_doc(self, doc):
- """Store document in database."""
+ """
+ Update a document.
+
+ This is called everytime we just want to do a raw put on the db (i.e.
+ without index updates, document constraint checks, and conflict
+ checks).
+
+ @param doc: The document to update.
+ @type doc: u1db.Document
+
+ @return: The new revision identifier for the document.
+ @rtype: str
+ """
# prepare couch's Document
cdoc = CouchDocument()
cdoc['_id'] = doc.doc_id
@@ -130,12 +210,19 @@ class CouchDatabase(ObjectStoreDatabase):
def get_sync_target(self):
"""
Return a SyncTarget object, for another u1db to synchronize with.
+
+ @return: The sync target.
+ @rtype: CouchSyncTarget
"""
return CouchSyncTarget(self)
def create_index(self, index_name, *index_expressions):
"""
Create a named index, which can then be queried for future lookups.
+
+ @param index_name: A unique name which can be used as a key prefix.
+ @param index_expressions: Index expressions defining the index
+ information.
"""
if index_name in self._indexes:
if self._indexes[index_name]._definition == list(
@@ -144,7 +231,7 @@ class CouchDatabase(ObjectStoreDatabase):
raise errors.IndexNameTakenError
index = InMemoryIndex(index_name, list(index_expressions))
for doc_id in self._database:
- if doc_id == self.U1DB_DATA_DOC_ID:
+ if doc_id == self.U1DB_DATA_DOC_ID: # skip special file
continue
doc = self._get_doc(doc_id)
if doc.content is not None:
@@ -154,7 +241,12 @@ class CouchDatabase(ObjectStoreDatabase):
self._store_u1db_data()
def close(self):
- """Release any resources associated with this database."""
+ """
+ Release any resources associated with this database.
+
+ @return: True if db was succesfully closed.
+ @rtype: bool
+ """
# TODO: fix this method so the connection is properly closed and
# test_close (+tearDown, which deletes the db) works without problems.
self._url = None
@@ -165,7 +257,20 @@ class CouchDatabase(ObjectStoreDatabase):
return True
def sync(self, url, creds=None, autocreate=True):
- """Synchronize documents with remote replica exposed at url."""
+ """
+ Synchronize documents with remote replica exposed at url.
+
+ @param url: The url of the target replica to sync with.
+ @type url: str
+ @param creds: optional dictionary giving credentials.
+ to authorize the operation with the server.
+ @type creds: dict
+ @param autocreate: Ask the target to create the db if non-existent.
+ @type autocreate: bool
+
+ @return: The local generation before the synchronisation was performed.
+ @rtype: int
+ """
from u1db.sync import Synchronizer
return Synchronizer(self, CouchSyncTarget(url, creds=creds)).sync(
autocreate=autocreate)
@@ -175,8 +280,23 @@ class CouchDatabase(ObjectStoreDatabase):
#-------------------------------------------------------------------------
def _init_u1db_data(self):
+ """
+ Initialize U1DB info data structure in the couch db.
+
+ A U1DB database needs to keep track of all database transactions,
+ document conflicts, the generation of other replicas it has seen,
+ indexes created by users and so on.
+
+ In this implementation, all this information is stored in a special
+ document stored in the couch db with id equals to
+ CouchDatabse.U1DB_DATA_DOC_ID.
+
+ This method initializes the document that will hold such information.
+ """
if self._replica_uid is None:
self._replica_uid = uuid.uuid4().hex
+ # TODO: prevent user from overwriting a document with the same doc_id
+ # as this one.
doc = self._factory(doc_id=self.U1DB_DATA_DOC_ID)
doc.content = {'transaction_log': [],
'conflicts': b64encode(json.dumps({})),
@@ -186,6 +306,11 @@ class CouchDatabase(ObjectStoreDatabase):
self._put_doc(doc)
def _fetch_u1db_data(self):
+ """
+ Fetch U1DB info from the couch db.
+
+ See C{_init_u1db_data} documentation.
+ """
# retrieve u1db data from couch db
cdoc = self._database.get(self.U1DB_DATA_DOC_ID)
jsonstr = self._database.get_attachment(cdoc, 'u1db_json').getvalue()
@@ -202,6 +327,11 @@ class CouchDatabase(ObjectStoreDatabase):
self._couch_rev = cdoc['_rev']
def _store_u1db_data(self):
+ """
+ Store U1DB info in the couch db.
+
+ See C{_init_u1db_data} documentation.
+ """
doc = self._factory(doc_id=self.U1DB_DATA_DOC_ID)
doc.content = {
'transaction_log': self._transaction_log,
@@ -220,10 +350,15 @@ class CouchDatabase(ObjectStoreDatabase):
#-------------------------------------------------------------------------
def delete_database(self):
- """Delete a U1DB CouchDB database."""
+ """
+ Delete a U1DB CouchDB database.
+ """
del(self._server[self._dbname])
def _dump_indexes_as_json(self):
+ """
+ Dump index definitions as JSON string.
+ """
indexes = {}
for name, idx in self._indexes.iteritems():
indexes[name] = {}
@@ -232,6 +367,16 @@ class CouchDatabase(ObjectStoreDatabase):
return json.dumps(indexes)
def _load_indexes_from_json(self, indexes):
+ """
+ Load index definitions from JSON string.
+
+ @param indexes: A JSON serialization of a list of [('index-name',
+ ['field', 'field2'])].
+ @type indexes: str
+
+ @return: A dictionary with the index definitions.
+ @rtype: dict
+ """
dict = {}
for name, idx_dict in json.loads(indexes).iteritems():
idx = InMemoryIndex(name, idx_dict['definition'])
@@ -241,30 +386,55 @@ class CouchDatabase(ObjectStoreDatabase):
class CouchSyncTarget(ObjectStoreSyncTarget):
- pass
+ """
+ Functionality for using a CouchDatabase as a synchronization target.
+ """
class CouchServerState(ServerState):
- """Inteface of the WSGI server with the CouchDB backend."""
+ """
+ Inteface of the WSGI server with the CouchDB backend.
+ """
def __init__(self, couch_url):
self.couch_url = couch_url
def open_database(self, dbname):
- """Open a database at the given location."""
+ """
+ Open a couch database.
+
+ @param dbname: The name of the database to open.
+ @type dbname: str
+
+ @return: The CouchDatabase object.
+ @rtype: CouchDatabase
+ """
# TODO: open couch
from leap.soledad.backends.couch import CouchDatabase
return CouchDatabase.open_database(self.couch_url + '/' + dbname,
create=False)
def ensure_database(self, dbname):
- """Ensure database at the given location."""
+ """
+ Ensure couch database exists.
+
+ @param dbname: The name of the database to ensure.
+ @type dbname: str
+
+ @return: The CouchDatabase object and the replica uid.
+ @rtype: (CouchDatabase, str)
+ """
from leap.soledad.backends.couch import CouchDatabase
db = CouchDatabase.open_database(self.couch_url + '/' + dbname,
create=True)
return db, db._replica_uid
def delete_database(self, dbname):
- """Delete database at the given location."""
+ """
+ Delete couch database.
+
+ @param dbname: The name of the database to delete.
+ @type dbname: str
+ """
from leap.soledad.backends.couch import CouchDatabase
CouchDatabase.delete_database(self.couch_url + '/' + dbname)
diff --git a/src/leap/soledad/backends/leap_backend.py b/src/leap/soledad/backends/leap_backend.py
index a37f9d25..3110c662 100644
--- a/src/leap/soledad/backends/leap_backend.py
+++ b/src/leap/soledad/backends/leap_backend.py
@@ -1,21 +1,39 @@
+# -*- coding: utf-8 -*-
+# leap_backend.py
+# Copyright (C) 2013 LEAP
+#
+# This program is free software: you can redistribute it and/or modify
+# it under the terms of the GNU General Public License as published by
+# the Free Software Foundation, either version 3 of the License, or
+# (at your option) any later version.
+#
+# This program is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+# GNU General Public License for more details.
+#
+# You should have received a copy of the GNU General Public License
+# along with this program. If not, see <http://www.gnu.org/licenses/>.
+
+
"""
-A U1DB backend that encrypts data before sending to server and decrypts after
-receiving.
+A U1DB backend for encrypting data before sending to server and decrypting
+after receiving.
"""
+import uuid
try:
import simplejson as json
except ImportError:
import json # noqa
+
from u1db import Document
from u1db.remote import utils
from u1db.remote.http_target import HTTPSyncTarget
from u1db.remote.http_database import HTTPDatabase
from u1db.errors import BrokenSyncStream
-import uuid
-
class NoDefaultKey(Exception):
"""
@@ -33,7 +51,7 @@ class NoSoledadInstance(Exception):
class DocumentNotEncrypted(Exception):
"""
- Exception to signal failures in document encryption.
+ Raised for failures in document encryption.
"""
pass
@@ -45,10 +63,33 @@ class LeapDocument(Document):
LEAP Documents are standard u1db documents with cabability of returning an
encrypted version of the document json string as well as setting document
content based on an encrypted version of json string.
+
+ Also, LEAP Documents can be flagged as syncable or not, so the replicas
+ might not sync every document.
"""
def __init__(self, doc_id=None, rev=None, json='{}', has_conflicts=False,
encrypted_json=None, soledad=None, syncable=True):
+ """
+ Container for handling an encryptable document.
+
+ @param doc_id: The unique document identifier.
+ @type doc_id: str
+ @param rev: The revision identifier of the document.
+ @type rev: str
+ @param json: The JSON string for this document.
+ @type json: str
+ @param has_conflicts: Boolean indicating if this document has conflicts
+ @type has_conflicts: bool
+ @param encrypted_json: The encrypted JSON string for this document. If
+ given, the decrypted value supersedes any raw json string given.
+ @type encrypted_json: str
+ @param soledad: An instance of Soledad so we can encrypt/decrypt
+ document contents when syncing.
+ @type soledad: soledad.Soledad
+ @param syncable: Should this document be synced with remote replicas?
+ @type syncable: bool
+ """
super(LeapDocument, self).__init__(doc_id, rev, json, has_conflicts)
self._soledad = soledad
self._syncable = syncable
@@ -58,6 +99,9 @@ class LeapDocument(Document):
def get_encrypted_content(self):
"""
Return an encrypted JSON serialization of document's contents.
+
+ @return: The encrpted JSON serialization of document's contents.
+ @rtype: str
"""
if not self._soledad:
raise NoSoledadInstance()
@@ -66,16 +110,19 @@ class LeapDocument(Document):
def set_encrypted_content(self, cyphertext):
"""
- Set document's content based on an encrypted JSON serialization of
+ Decrypt C{cyphertext} and set document's content.
contents.
"""
plaintext = self._soledad.decrypt_symmetric(self.doc_id, cyphertext)
- return self.set_json(plaintext)
+ self.set_json(plaintext)
def get_encrypted_json(self):
"""
Return a valid JSON string containing document's content encrypted to
the user's public key.
+
+ @return: The encrypted JSON string.
+ @rtype: str
"""
return json.dumps({'_encrypted_json': self.get_encrypted_content()})
@@ -90,9 +137,21 @@ class LeapDocument(Document):
self.set_encrypted_content(cyphertext)
def _get_syncable(self):
+ """
+ Return whether this document is syncable.
+
+ @return: Is this document syncable?
+ @rtype: bool
+ """
return self._syncable
def _set_syncable(self, syncable=True):
+ """
+ Determine if this document should be synced with remote replicas.
+
+ @param syncable: Should this document be synced with remote replicas?
+ @type syncable: bool
+ """
self._syncable = syncable
syncable = property(
@@ -101,15 +160,28 @@ class LeapDocument(Document):
doc="Determine if document should be synced with server."
)
- # Returning the revision as string solves the following exception in
- # Twisted web:
- # exceptions.TypeError: Can only pass-through bytes on Python 2
def _get_rev(self):
+ """
+ Get the document revision.
+
+ Returning the revision as string solves the following exception in
+ Twisted web:
+ exceptions.TypeError: Can only pass-through bytes on Python 2
+
+ @return: The document revision.
+ @rtype: str
+ """
if self._rev is None:
return None
return str(self._rev)
def _set_rev(self, rev):
+ """
+ Set document revision.
+
+ @param rev: The new document revision.
+ @type rev: bytes
+ """
self._rev = rev
rev = property(
@@ -125,6 +197,18 @@ class LeapSyncTarget(HTTPSyncTarget):
"""
def __init__(self, url, creds=None, soledad=None):
+ """
+ Initialize the LeapSyncTarget.
+
+ @param url: The url of the target replica to sync with.
+ @type url: str
+ @param creds: optional dictionary giving credentials.
+ to authorize the operation with the server.
+ @type creds: dict
+ @param soledad: An instance of Soledad so we can encrypt/decrypt
+ document contents when syncing.
+ @type soledad: soledad.Soledad
+ """
super(LeapSyncTarget, self).__init__(url, creds)
self._soledad = soledad
@@ -132,6 +216,17 @@ class LeapSyncTarget(HTTPSyncTarget):
"""
Does the same as parent's method but ensures incoming content will be
decrypted.
+
+ @param data: The body of the HTTP response.
+ @type data: str
+ @param return_doc_cb: A callback to insert docs from target.
+ @type return_doc_cb: function
+ @param ensure_callback: A callback to ensure we have the correct
+ target_replica_uid, if it was just created.
+ @type ensure_callback: function
+
+ @return: The parsed sync stream.
+ @rtype: list of str
"""
parts = data.splitlines() # one at a time
if not parts or parts[0] != '[':
diff --git a/src/leap/soledad/backends/objectstore.py b/src/leap/soledad/backends/objectstore.py
index 7c5d1177..38de421f 100644
--- a/src/leap/soledad/backends/objectstore.py
+++ b/src/leap/soledad/backends/objectstore.py
@@ -1,9 +1,29 @@
+# -*- coding: utf-8 -*-
+# objectstore.py
+# Copyright (C) 2013 LEAP
+#
+# This program is free software: you can redistribute it and/or modify
+# it under the terms of the GNU General Public License as published by
+# the Free Software Foundation, either version 3 of the License, or
+# (at your option) any later version.
+#
+# This program is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+# GNU General Public License for more details.
+#
+# You should have received a copy of the GNU General Public License
+# along with this program. If not, see <http://www.gnu.org/licenses/>.
+
+
"""
Abstract U1DB backend to handle storage using object stores (like CouchDB, for
-example.
+example).
Right now, this is only used by CouchDatabase backend, but can also be
extended to implement OpenStack or Amazon S3 storage, for example.
+
+See U1DB documentation for more information on how to use databases.
"""
from u1db.backends.inmemory import (
@@ -20,9 +40,32 @@ class ObjectStoreDatabase(InMemoryDatabase):
@classmethod
def open_database(cls, url, create, document_factory=None):
+ """
+ Open a U1DB database using an object store as backend.
+
+ @param url: the url of the database replica
+ @type url: str
+ @param create: should the replica be created if it does not exist?
+ @type create: bool
+ @param document_factory: A function that will be called with the same
+ parameters as Document.__init__.
+ @type document_factory: callable
+
+ @return: the database instance
+ @rtype: CouchDatabase
+ """
raise NotImplementedError(cls.open_database)
def __init__(self, replica_uid=None, document_factory=None):
+ """
+ Initialize the object store database.
+
+ @param replica_uid: an optional unique replica identifier
+ @type replica_uid: str
+ @param document_factory: A function that will be called with the same
+ parameters as Document.__init__.
+ @type document_factory: callable
+ """
super(ObjectStoreDatabase, self).__init__(
replica_uid,
document_factory=document_factory)
@@ -36,20 +79,73 @@ class ObjectStoreDatabase(InMemoryDatabase):
#-------------------------------------------------------------------------
def _set_replica_uid(self, replica_uid):
+ """
+ Force the replica_uid to be set.
+
+ @param replica_uid: The uid of the replica.
+ @type replica_uid: str
+ """
super(ObjectStoreDatabase, self)._set_replica_uid(replica_uid)
self._store_u1db_data()
def _put_doc(self, doc):
+ """
+ Update a document.
+
+ This is called everytime we just want to do a raw put on the db (i.e.
+ without index updates, document constraint checks, and conflict
+ checks).
+
+ @param doc: The document to update.
+ @type doc: u1db.Document
+
+ @return: The new revision identifier for the document.
+ @rtype: str
+ """
raise NotImplementedError(self._put_doc)
- def _get_doc(self, doc):
+ def _get_doc(self, doc_id):
+ """
+ Get just the document content, without fancy handling.
+
+ @param doc_id: The unique document identifier
+ @type doc_id: str
+ @param include_deleted: If set to True, deleted documents will be
+ returned with empty content. Otherwise asking for a deleted
+ document will return None.
+ @type include_deleted: bool
+
+ @return: a Document object.
+ @type: u1db.Document
+ """
raise NotImplementedError(self._get_doc)
def get_all_docs(self, include_deleted=False):
+ """
+ Get the JSON content for all documents in the database.
+
+ @param include_deleted: If set to True, deleted documents will be
+ returned with empty content. Otherwise deleted documents will not
+ be included in the results.
+ @type include_deleted: bool
+
+ @return: (generation, [Document])
+ The current generation of the database, followed by a list of all
+ the documents in the database.
+ @rtype: tuple
+ """
raise NotImplementedError(self.get_all_docs)
def delete_doc(self, doc):
- """Mark a document as deleted."""
+ """
+ Mark a document as deleted.
+
+ @param doc: The document to mark as deleted.
+ @type doc: u1db.Document
+
+ @return: The new revision id of the document.
+ @type: str
+ """
old_doc = self._get_doc(doc.doc_id, check_for_conflicts=True)
if old_doc is None:
raise errors.DocumentDoesNotExist
@@ -69,22 +165,61 @@ class ObjectStoreDatabase(InMemoryDatabase):
def create_index(self, index_name, *index_expressions):
"""
- Create an named index, which can then be queried for future lookups.
+ Create a named index, which can then be queried for future lookups.
+
+ See U1DB documentation for more information.
+
+ @param index_name: A unique name which can be used as a key prefix.
+ @param index_expressions: Index expressions defining the index
+ information.
"""
raise NotImplementedError(self.create_index)
def delete_index(self, index_name):
- """Remove a named index."""
+ """
+ Remove a named index.
+
+ Here we just guarantee that the new info will be stored in the backend
+ db after update.
+
+ @param index_name: The name of the index we are removing.
+ @type index_name: str
+ """
super(ObjectStoreDatabase, self).delete_index(index_name)
self._store_u1db_data()
def _replace_conflicts(self, doc, conflicts):
+ """
+ Set new conflicts for a document.
+
+ Here we just guarantee that the new info will be stored in the backend
+ db after update.
+
+ @param doc: The document with a new set of conflicts.
+ @param conflicts: The new set of conflicts.
+ @type conflicts: list
+ """
super(ObjectStoreDatabase, self)._replace_conflicts(doc, conflicts)
self._store_u1db_data()
def _do_set_replica_gen_and_trans_id(self, other_replica_uid,
other_generation,
other_transaction_id):
+ """
+ Set the last-known generation and transaction id for the other
+ database replica.
+
+ Here we just guarantee that the new info will be stored in the backend
+ db after update.
+
+ @param other_replica_uid: The U1DB identifier for the other replica.
+ @type other_replica_uid: str
+ @param other_generation: The generation number for the other replica.
+ @type other_generation: int
+ @param other_transaction_id: The transaction id associated with the
+ generation.
+ @type other_transaction_id: str
+ """
super(ObjectStoreDatabase, self)._do_set_replica_gen_and_trans_id(
other_replica_uid,
other_generation,
@@ -96,6 +231,14 @@ class ObjectStoreDatabase(InMemoryDatabase):
#-------------------------------------------------------------------------
def _put_and_update_indexes(self, old_doc, doc):
+ """
+ Update a document and all indexes related to it.
+
+ @param old_doc: The old version of the document.
+ @type old_doc: u1db.Document
+ @param doc: The new version of the document.
+ @type doc: u1db.Document
+ """
for index in self._indexes.itervalues():
if old_doc is not None and not old_doc.is_tombstone():
index.remove_json(old_doc.doc_id, old_doc.get_json())
@@ -115,21 +258,37 @@ class ObjectStoreDatabase(InMemoryDatabase):
def _fetch_u1db_data(self):
"""
Fetch u1db configuration data from backend storage.
+
+ See C{_init_u1db_data} documentation.
"""
NotImplementedError(self._fetch_u1db_data)
def _store_u1db_data(self):
"""
- Save u1db configuration data on backend storage.
+ Store u1db configuration data on backend storage.
+
+ See C{_init_u1db_data} documentation.
"""
NotImplementedError(self._store_u1db_data)
def _init_u1db_data(self):
"""
Initialize u1db configuration data on backend storage.
+
+ A U1DB database needs to keep track of all database transactions,
+ document conflicts, the generation of other replicas it has seen,
+ indexes created by users and so on.
+
+ In this implementation, all this information is stored in a special
+ document stored in the couch db with id equals to
+ CouchDatabse.U1DB_DATA_DOC_ID.
+
+ This method initializes the document that will hold such information.
"""
NotImplementedError(self._init_u1db_data)
class ObjectStoreSyncTarget(InMemorySyncTarget):
- pass
+ """
+ Functionality for using an ObjectStore as a synchronization target.
+ """
diff --git a/src/leap/soledad/backends/sqlcipher.py b/src/leap/soledad/backends/sqlcipher.py
index ab74bab1..9e3c38c9 100644
--- a/src/leap/soledad/backends/sqlcipher.py
+++ b/src/leap/soledad/backends/sqlcipher.py
@@ -1,3 +1,21 @@
+# -*- coding: utf-8 -*-
+# sqlcipher.py
+# Copyright (C) 2013 LEAP
+#
+# This program is free software: you can redistribute it and/or modify
+# it under the terms of the GNU General Public License as published by
+# the Free Software Foundation, either version 3 of the License, or
+# (at your option) any later version.
+#
+# This program is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+# GNU General Public License for more details.
+#
+# You should have received a copy of the GNU General Public License
+# along with this program. If not, see <http://www.gnu.org/licenses/>.
+
+
"""A U1DB backend that uses SQLCipher as its persistence layer."""
import os
@@ -26,12 +44,17 @@ def open(path, password, create=True, document_factory=None, soledad=None):
Will raise u1db.errors.DatabaseDoesNotExist if create=False and the
database does not already exist.
- :param path: The filesystem path for the database to open.
- :param create: True/False, should the database be created if it doesn't
+ @param path: The filesystem path for the database to open.
+ @param type: str
+ @param create: True/False, should the database be created if it doesn't
already exist?
- :param document_factory: A function that will be called with the same
+ @param type: bool
+ @param document_factory: A function that will be called with the same
parameters as Document.__init__.
- :return: An instance of Database.
+ @type document_factory: callable
+
+ @return: An instance of Database.
+ @rtype SQLCipherDatabase
"""
return SQLCipherDatabase.open_database(
path, password, create=create, document_factory=document_factory,
@@ -54,11 +77,24 @@ class SQLCipherDatabase(sqlite_backend.SQLitePartialExpandDatabase):
def set_pragma_key(cls, db_handle, key):
db_handle.cursor().execute("PRAGMA key = '%s'" % key)
- def __init__(self, sqlite_file, password, document_factory=None,
+ def __init__(self, sqlcipher_file, password, document_factory=None,
soledad=None):
- """Create a new sqlcipher file."""
- self._check_if_db_is_encrypted(sqlite_file)
- self._db_handle = dbapi2.connect(sqlite_file)
+ """
+ Create a new sqlcipher file.
+
+ @param sqlcipher_file: The path for the SQLCipher file.
+ @type sqlcipher_file: str
+ @param password: The password that protects the SQLCipher db.
+ @type password: str
+ @param document_factory: A function that will be called with the same
+ parameters as Document.__init__.
+ @type document_factory: callable
+ @param soledad: An instance of Soledad so we can encrypt/decrypt
+ document contents when syncing.
+ @type soledad: soledad.Soledad
+ """
+ self._check_if_db_is_encrypted(sqlcipher_file)
+ self._db_handle = dbapi2.connect(sqlcipher_file)
SQLCipherDatabase.set_pragma_key(self._db_handle, password)
self._real_replica_uid = None
self._ensure_schema()
@@ -72,29 +108,55 @@ class SQLCipherDatabase(sqlite_backend.SQLitePartialExpandDatabase):
syncable=syncable, soledad=self._soledad)
self.set_document_factory(factory)
- def _check_if_db_is_encrypted(self, sqlite_file):
- if not os.path.exists(sqlite_file):
+ def _check_if_db_is_encrypted(self, sqlcipher_file):
+ """
+ Verify if loca file is an encrypted database.
+
+ @param sqlcipher_file: The path for the SQLCipher file.
+ @type sqlcipher_file: str
+
+ @return: True if the database is encrypted, False otherwise.
+ @rtype: bool
+ """
+ if not os.path.exists(sqlcipher_file):
return
else:
try:
# try to open an encrypted database with the regular u1db
# backend should raise a DatabaseError exception.
- sqlite_backend.SQLitePartialExpandDatabase(sqlite_file)
+ sqlite_backend.SQLitePartialExpandDatabase(sqlcipher_file)
raise DatabaseIsNotEncrypted()
except dbapi2.DatabaseError:
pass
@classmethod
- def _open_database(cls, sqlite_file, password, document_factory=None,
+ def _open_database(cls, sqlcipher_file, password, document_factory=None,
soledad=None):
- if not os.path.isfile(sqlite_file):
+ """
+ Open a SQLCipher database.
+
+ @param sqlcipher_file: The path for the SQLCipher file.
+ @type sqlcipher_file: str
+ @param password: The password that protects the SQLCipher db.
+ @type password: str
+ @param document_factory: A function that will be called with the same
+ parameters as Document.__init__.
+ @type document_factory: callable
+ @param soledad: An instance of Soledad so we can encrypt/decrypt
+ document contents when syncing.
+ @type soledad: soledad.Soledad
+
+ @return: The database object.
+ @rtype: SQLCipherDatabase
+ """
+ if not os.path.isfile(sqlcipher_file):
raise errors.DatabaseDoesNotExist()
tries = 2
while True:
# Note: There seems to be a bug in sqlite 3.5.9 (with python2.6)
# where without re-opening the database on Windows, it
# doesn't see the transaction that was just committed
- db_handle = dbapi2.connect(sqlite_file)
+ db_handle = dbapi2.connect(sqlcipher_file)
SQLCipherDatabase.set_pragma_key(db_handle, password)
c = db_handle.cursor()
v, err = cls._which_index_storage(c)
@@ -108,30 +170,63 @@ class SQLCipherDatabase(sqlite_backend.SQLitePartialExpandDatabase):
tries -= 1
time.sleep(cls.WAIT_FOR_PARALLEL_INIT_HALF_INTERVAL)
return SQLCipherDatabase._sqlite_registry[v](
- sqlite_file, password, document_factory=document_factory,
+ sqlcipher_file, password, document_factory=document_factory,
soledad=soledad)
@classmethod
- def open_database(cls, sqlite_file, password, create, backend_cls=None,
+ def open_database(cls, sqlcipher_file, password, create, backend_cls=None,
document_factory=None, soledad=None):
- """Open U1DB database using SQLCipher as backend."""
+ """
+ Open a SQLCipher database.
+
+ @param sqlcipher_file: The path for the SQLCipher file.
+ @type sqlcipher_file: str
+ @param password: The password that protects the SQLCipher db.
+ @type password: str
+ @param create: Should the datbase be created if it does not already
+ exist?
+ @type: bool
+ @param backend_cls: A class to use as backend.
+ @type backend_cls: type
+ @param document_factory: A function that will be called with the same
+ parameters as Document.__init__.
+ @type document_factory: callable
+ @param soledad: An instance of Soledad so we can encrypt/decrypt
+ document contents when syncing.
+ @type soledad: soledad.Soledad
+
+ @return: The database object.
+ @rtype: SQLCipherDatabase
+ """
try:
- return cls._open_database(sqlite_file, password,
+ return cls._open_database(sqlcipher_file, password,
document_factory=document_factory,
soledad=soledad)
except errors.DatabaseDoesNotExist:
if not create:
raise
+ # TODO: remove backend class from here.
if backend_cls is None:
# default is SQLCipherPartialExpandDatabase
backend_cls = SQLCipherDatabase
- return backend_cls(sqlite_file, password,
+ return backend_cls(sqlcipher_file, password,
document_factory=document_factory,
soledad=soledad)
def sync(self, url, creds=None, autocreate=True):
"""
- Synchronize encrypted documents with remote replica exposed at url.
+ Synchronize documents with remote replica exposed at url.
+
+ @param url: The url of the target replica to sync with.
+ @type url: str
+ @param creds: optional dictionary giving credentials.
+ to authorize the operation with the server.
+ @type creds: dict
+ @param autocreate: Ask the target to create the db if non-existent.
+ @type autocreate: bool
+
+ @return: The local generation before the synchronisation was performed.
+ @rtype: int
"""
from u1db.sync import Synchronizer
from leap.soledad.backends.leap_backend import LeapSyncTarget
@@ -142,17 +237,44 @@ class SQLCipherDatabase(sqlite_backend.SQLitePartialExpandDatabase):
soledad=self._soledad)).sync(autocreate=autocreate)
def _extra_schema_init(self, c):
+ """
+ Add any extra fields, etc to the basic table definitions.
+
+ @param c: The cursor for querying the database.
+ @type c: dbapi2.cursor
+ """
c.execute(
'ALTER TABLE document '
'ADD COLUMN syncable BOOL NOT NULL DEFAULT TRUE')
def _put_and_update_indexes(self, old_doc, doc):
+ """
+ Update a document and all indexes related to it.
+
+ @param old_doc: The old version of the document.
+ @type old_doc: u1db.Document
+ @param doc: The new version of the document.
+ @type doc: u1db.Document
+ """
super(SQLCipherDatabase, self)._put_and_update_indexes(old_doc, doc)
c = self._db_handle.cursor()
c.execute('UPDATE document SET syncable=? WHERE doc_id=?',
(doc.syncable, doc.doc_id))
def _get_doc(self, doc_id, check_for_conflicts=False):
+ """
+ Get just the document content, without fancy handling.
+
+ @param doc_id: The unique document identifier
+ @type doc_id: str
+ @param include_deleted: If set to True, deleted documents will be
+ returned with empty content. Otherwise asking for a deleted
+ document will return None.
+ @type include_deleted: bool
+
+ @return: a Document object.
+ @type: u1db.Document
+ """
doc = super(SQLCipherDatabase, self)._get_doc(doc_id,
check_for_conflicts)
if doc:
diff --git a/src/leap/soledad/server.py b/src/leap/soledad/server.py
index 159f4768..53bb62f0 100644
--- a/src/leap/soledad/server.py
+++ b/src/leap/soledad/server.py
@@ -1,25 +1,41 @@
# -*- coding: utf-8 -*-
+# server.py
+# Copyright (C) 2013 LEAP
+#
+# This program is free software: you can redistribute it and/or modify
+# it under the terms of the GNU General Public License as published by
+# the Free Software Foundation, either version 3 of the License, or
+# (at your option) any later version.
+#
+# This program is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+# GNU General Public License for more details.
+#
+# You should have received a copy of the GNU General Public License
+# along with this program. If not, see <http://www.gnu.org/licenses/>.
+
+
"""
-A U1DB server that stores data using couchdb.
+A U1DB server that stores data using CouchDB as its persistence layer.
This should be run with:
twistd -n web --wsgi=leap.soledad.server.application
"""
import configparser
-from wsgiref.util import shift_path_info
import httplib
try:
import simplejson as json
except ImportError:
import json # noqa
-from urlparse import parse_qs
+
+from urlparse import parse_qs
+from wsgiref.util import shift_path_info
from twisted.web.wsgi import WSGIResource
from twisted.internet import reactor
-
from u1db.remote import http_app
-
from leap.soledad.backends.couch import CouchServerState
@@ -42,11 +58,41 @@ class SoledadAuthMiddleware(object):
"""
def __init__(self, app, prefix, public_dbs=None):
+ """
+ Initialize the Soledad Authentication Middleware.
+
+ @param app: The application to run on successfull authentication.
+ @type app: u1db.remote.http_app.HTTPApp
+ @param prefix: Auth app path prefix.
+ @type prefix: str
+ @param public_dbs: List of databases that should bypass
+ authentication.
+ @type public_dbs: list
+ """
self.app = app
self.prefix = prefix
self.public_dbs = public_dbs
def _error(self, start_response, status, description, message=None):
+ """
+ Send a JSON serialized error to WSGI client.
+
+ @param start_response: Callable of the form start_response(status,
+ response_headers, exc_info=None).
+ @type start_response: callable
+ @param status: Status string of the form "999 Message here"
+ @type status: str
+ @param response_headers: A list of (header_name, header_value) tuples
+ describing the HTTP response header.
+ @type response_headers: list
+ @param description: The error description.
+ @type description: str
+ @param message: The error message.
+ @type message: str
+
+ @return: List with JSON serialized error message.
+ @rtype list
+ """
start_response("%d %s" % (status, httplib.responses[status]),
[('content-type', 'application/json')])
err = {"error": description}
@@ -55,6 +101,19 @@ class SoledadAuthMiddleware(object):
return [json.dumps(err)]
def __call__(self, environ, start_response):
+ """
+ Handle a WSGI call to the authentication application.
+
+ @param environ: Dictionary containing CGI variables.
+ @type environ: dict
+ @param start_response: Callable of the form start_response(status,
+ response_headers, exc_info=None).
+ @type start_response: callable
+
+ @return: Target application results if authentication succeeds, an
+ error message otherwise.
+ @rtype: list
+ """
if self.prefix and not environ['PATH_INFO'].startswith(self.prefix):
return self._error(start_response, 400, "bad request")
shift_path_info(environ)
@@ -83,7 +142,15 @@ class SoledadAuthMiddleware(object):
def verify_token(self, environ, token):
"""
- Verify if token is valid for authenticating this action.
+ Verify if token is valid for authenticating this request.
+
+ @param environ: Dictionary containing CGI variables.
+ @type environ: dict
+ @param token: The authentication token.
+ @type token: str
+
+ @return: Whether the token is valid for authenticating the request.
+ @rtype: bool
"""
# TODO: implement token verification
return True
@@ -94,8 +161,15 @@ class SoledadAuthMiddleware(object):
Check if action can be performed on database without authentication.
For now, just allow access to /shared/*.
+
+ @param environ: Dictionary containing CGI variables.
+ @type environ: dict
+
+ @return: Whether the requests needs authentication.
+ @rtype: bool
"""
# TODO: design unauth verification.
+ # TODO: include public_dbs here or remove it from code.
return not environ.get('PATH_INFO').startswith('/shared/')
@@ -109,6 +183,18 @@ class SoledadApp(http_app.HTTPApp):
"""
def __call__(self, environ, start_response):
+ """
+ Handle a WSGI call to the Soledad application.
+
+ @param environ: Dictionary containing CGI variables.
+ @type environ: dict
+ @param start_response: Callable of the form start_response(status,
+ response_headers, exc_info=None).
+ @type start_response: callable
+
+ @return: HTTP application results.
+ @rtype: list
+ """
return super(SoledadApp, self).__call__(environ, start_response)
@@ -117,6 +203,15 @@ class SoledadApp(http_app.HTTPApp):
#-----------------------------------------------------------------------------
def load_configuration(file_path):
+ """
+ Load server configuration from file.
+
+ @param file_path: The path to the configuration file.
+ @type file_path: str
+
+ @return: A dictionary with the configuration.
+ @rtype: dict
+ """
conf = {
'couch_url': 'http://localhost:5984',
'working_dir': '/tmp',
diff --git a/src/leap/soledad/shared_db.py b/src/leap/soledad/shared_db.py
index 275ed269..5fce8ac2 100644
--- a/src/leap/soledad/shared_db.py
+++ b/src/leap/soledad/shared_db.py
@@ -1,8 +1,23 @@
# -*- coding: utf-8 -*-
-"""
-Created on Tue Mar 5 18:46:38 2013
+# shared_db.py
+# Copyright (C) 2013 LEAP
+#
+# This program is free software: you can redistribute it and/or modify
+# it under the terms of the GNU General Public License as published by
+# the Free Software Foundation, either version 3 of the License, or
+# (at your option) any later version.
+#
+# This program is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+# GNU General Public License for more details.
+#
+# You should have received a copy of the GNU General Public License
+# along with this program. If not, see <http://www.gnu.org/licenses/>.
+
-@author: drebs
+"""
+A shared database for storing/retrieving encrypted key material.
"""
try:
@@ -10,6 +25,7 @@ try:
except ImportError:
import json # noqa
+
from u1db import errors
from u1db.remote import http_database
@@ -44,8 +60,21 @@ class SoledadSharedDatabase(http_database.HTTPDatabase):
@staticmethod
def open_database(url, create, token=None):
+ # TODO: users should not be able to create the shared database, so we
+ # have to remove this from here in the future.
"""
Open a Soledad shared database.
+
+ @param url: URL of the remote database.
+ @type url: str
+ @param create: Should the database be created if it does not already
+ exist?
+ @type create: bool
+ @param token: An authentication token for accessing the shared db.
+ @type token: str
+
+ @return: The shared database in the given url.
+ @rtype: SoledadSharedDatabase
"""
db = SoledadSharedDatabase(url, token=token)
db.open(create)
@@ -55,12 +84,27 @@ class SoledadSharedDatabase(http_database.HTTPDatabase):
def delete_database(url):
"""
Dummy method that prevents from deleting shared database.
+
+ @raise: This will always raise an Unauthorized exception.
+
+ @param url: The database URL.
+ @type url: str
"""
raise Unauthorized("Can't delete shared database.")
def __init__(self, url, document_factory=None, creds=None, token=None):
"""
Initialize database with auth token and encryption powers.
+
+ @param url: URL of the remote database.
+ @type url: str
+ @param document_factory: A factory for U1BD documents.
+ @type document_factory: u1db.Document
+ @param creds: A tuple containing the authentication method and
+ credentials.
+ @type creds: tuple
+ @param token: An authentication token for accessing the shared db.
+ @type token: str
"""
self._token = token
super(SoledadSharedDatabase, self).__init__(url, document_factory,
@@ -70,8 +114,28 @@ class SoledadSharedDatabase(http_database.HTTPDatabase):
content_type=None, auth=True):
"""
Perform token-based http request.
+
+ @param method: The HTTP method for the request.
+ @type method: str
+ @param url_parts: A list with extra parts for the URL.
+ @type url_parts: list
+ @param params: Parameters to be added as query string.
+ @type params: dict
+ @param body: The body of the request (must be JSON serializable).
+ @type body: object
+ @param content_type: The content-type of the request.
+ @type content_type: str
+ @param auth: Should the request be authenticated?
+ @type auth: bool
+
+ @raise u1db.errors.Unavailable: If response status is 503.
+ @raise u1db.errors.HTTPError: If response status is neither 200, 201
+ or 503
+
+ @return: The headers and body of the HTTP response.
+ @rtype: tuple
"""
- # add the auth-token as a request parameter
+ # add `auth-token` as a request parameter
if auth:
if not self._token:
raise NoTokenForAuth()
@@ -87,7 +151,27 @@ class SoledadSharedDatabase(http_database.HTTPDatabase):
def _request_json(self, method, url_parts, params=None, body=None,
content_type=None, auth=True):
"""
- Perform token-based http request.
+ Perform token-based http request and deserialize the JSON results.
+
+ @param method: The HTTP method for the request.
+ @type method: str
+ @param url_parts: A list with extra parts for the URL.
+ @type url_parts: list
+ @param params: Parameters to be added as query string.
+ @type params: dict
+ @param body: The body of the request (must be JSON serializable).
+ @type body: object
+ @param content_type: The content-type of the request.
+ @type content_type: str
+ @param auth: Should the request be authenticated?
+ @type auth: bool
+
+ @raise u1db.errors.Unavailable: If response status is 503.
+ @raise u1db.errors.HTTPError: If response status is neither 200, 201
+ or 503
+
+ @return: The headers and body of the HTTP response.
+ @rtype: tuple
"""
# allow for token-authenticated requests.
res, headers = self._request(method, url_parts,
@@ -98,6 +182,15 @@ class SoledadSharedDatabase(http_database.HTTPDatabase):
def get_doc_unauth(self, doc_id):
"""
Modified method to allow for unauth request.
+
+ This is the only (public) way to make an unauthenticaded request on
+ the shared database.
+
+ @param doc_id: The document id.
+ @type doc_id: str
+
+ @return: The requested document.
+ @rtype: Document
"""
db = http_database.HTTPDatabase(self._url.geturl(),
document_factory=self._factory)
diff --git a/src/leap/soledad/tests/test_crypto.py b/src/leap/soledad/tests/test_crypto.py
index a92e2c46..fdecbeef 100644
--- a/src/leap/soledad/tests/test_crypto.py
+++ b/src/leap/soledad/tests/test_crypto.py
@@ -55,7 +55,7 @@ class RecoveryDocumentTestCase(BaseSoledadTest):
rd = self._soledad.export_recovery_document(None)
self.assertEqual(
{
- 'user_email': self._soledad._user_email,
+ 'user': self._soledad._user,
'privkey': self._soledad._gpg.export_keys(
self._soledad._fingerprint,
secret=True),
@@ -70,7 +70,7 @@ class RecoveryDocumentTestCase(BaseSoledadTest):
self.assertEqual(True,
self._soledad._gpg.is_encrypted_sym(rd))
data = {
- 'user_email': self._soledad._user_email,
+ 'user': self._soledad._user,
'privkey': self._soledad._gpg.export_keys(
self._soledad._fingerprint,
secret=True),
@@ -98,8 +98,8 @@ class RecoveryDocumentTestCase(BaseSoledadTest):
s._init_dirs()
s._gpg = GPGWrapper(gnupghome=gnupg_home)
s.import_recovery_document(rd, None)
- self.assertEqual(self._soledad._user_email,
- s._user_email, 'Failed setting user email.')
+ self.assertEqual(self._soledad._user,
+ s._user, 'Failed setting user email.')
self.assertEqual(self._soledad._symkey,
s._symkey,
'Failed settinng secret for symmetric encryption.')
@@ -124,8 +124,8 @@ class RecoveryDocumentTestCase(BaseSoledadTest):
s._init_dirs()
s._gpg = GPGWrapper(gnupghome=gnupg_home)
s.import_recovery_document(rd, '123456')
- self.assertEqual(self._soledad._user_email,
- s._user_email, 'Failed setting user email.')
+ self.assertEqual(self._soledad._user,
+ s._user, 'Failed setting user email.')
self.assertEqual(self._soledad._symkey,
s._symkey,
'Failed settinng secret for symmetric encryption.')
@@ -151,9 +151,10 @@ class SoledadAuxMethods(BaseLeapTest):
def tearDown(self):
pass
- def _soledad_instance(self):
+ def _soledad_instance(self, prefix=None):
return Soledad('leap@leap.se', bootstrap=False,
- prefix=self.tempdir+'/soledad')
+ prefix=prefix or self.tempdir+'/soledad')
+
def _gpgwrapper_instance(self):
return GPGWrapper(gnupghome="%s/gnupg" % self.tempdir)
@@ -176,24 +177,26 @@ class SoledadAuxMethods(BaseLeapTest):
from leap.soledad.backends.sqlcipher import SQLCipherDatabase
self.assertIsInstance(sol._db, SQLCipherDatabase)
- def test__has_privkey(self):
+ def test__gen_privkey(self):
sol = self._soledad_instance()
sol._init_dirs()
sol._gpg = GPGWrapper(gnupghome="%s/gnupg2" % self.tempdir)
- self.assertFalse(sol._has_privkey())
+ self.assertFalse(sol._has_privkey(), 'Should not have a private key '
+ 'at this point.')
sol._set_privkey(PRIVATE_KEY)
- self.assertTrue(sol._has_privkey())
+ self.assertTrue(sol._has_privkey(), 'Could not generate privkey.')
- def test__has_symkey(self):
+ def test__gen_symkey(self):
sol = Soledad('leap@leap.se', bootstrap=False,
prefix=self.tempdir+'/soledad3')
sol._init_dirs()
sol._gpg = GPGWrapper(gnupghome="%s/gnupg3" % self.tempdir)
if not sol._has_privkey():
sol._set_privkey(PRIVATE_KEY)
- self.assertFalse(sol._has_symkey())
+ self.assertFalse(sol._has_symkey(), "Should not have a symkey at "
+ "this point")
sol._gen_symkey()
- self.assertTrue(sol._has_symkey())
+ self.assertTrue(sol._has_symkey(), "Could not generate symkey.")
def test__has_keys(self):
sol = self._soledad_instance()
@@ -204,4 +207,3 @@ class SoledadAuxMethods(BaseLeapTest):
self.assertFalse(sol._has_keys())
sol._gen_symkey()
self.assertTrue(sol._has_keys())
-
diff --git a/src/leap/soledad/util.py b/src/leap/soledad/util.py
index b8ee4cf3..47e4c78d 100644
--- a/src/leap/soledad/util.py
+++ b/src/leap/soledad/util.py
@@ -1,3 +1,21 @@
+# -*- coding: utf-8 -*-
+# util.py
+# Copyright (C) 2013 LEAP
+#
+# This program is free software: you can redistribute it and/or modify
+# it under the terms of the GNU General Public License as published by
+# the Free Software Foundation, either version 3 of the License, or
+# (at your option) any later version.
+#
+# This program is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+# GNU General Public License for more details.
+#
+# You should have received a copy of the GNU General Public License
+# along with this program. If not, see <http://www.gnu.org/licenses/>.
+
+
"""
Utilities for Soledad.
"""
@@ -18,6 +36,12 @@ class ListPackets():
"""
def __init__(self, gpg):
+ """
+ Initialize the packet listing handling class.
+
+ @param gpg: GPG object instance.
+ @type gpg: gnupg.GPG
+ """
self.gpg = gpg
self.nodata = None
self.key = None
@@ -26,6 +50,14 @@ class ListPackets():
self.userid_hint = None
def handle_status(self, key, value):
+ """
+ Handle one line of the --list-packets status message.
+
+ @param key: The status message key.
+ @type key: str
+ @param value: The status message value.
+ @type value: str
+ """
# TODO: write tests for handle_status
if key == 'NODATA':
self.nodata = True
@@ -52,6 +84,28 @@ class GPGWrapper(gnupg.GPG):
def __init__(self, gpgbinary=GNUPG_BINARY, gnupghome=GNUPG_HOME,
verbose=False, use_agent=False, keyring=None, options=None):
+ """
+ Initialize a GnuPG process wrapper.
+
+ @param gpgbinary: Name for GnuPG binary executable.
+ @type gpgbinary: C{str}
+ @param gpghome: Full pathname to directory containing the public and
+ private keyrings.
+ @type gpghome: C{str}
+ @param keyring: Name of alternative keyring file to use. If specified,
+ the default keyring is not used.
+ @param verbose: Should some verbose info be output?
+ @type verbose: bool
+ @param use_agent: Should pass `--use-agent` to GPG binary?
+ @type use_agent: bool
+ @param keyring: Path for the keyring to use.
+ @type keyring: str
+ @options: A list of additional options to pass to the GPG binary.
+ @type options: list
+
+ @raise: RuntimeError with explanation message if there is a problem
+ invoking gpg.
+ """
super(GPGWrapper, self).__init__(gnupghome=gnupghome,
gpgbinary=gpgbinary,
verbose=verbose,
@@ -63,6 +117,14 @@ class GPGWrapper(gnupg.GPG):
def find_key_by_email(self, email, secret=False):
"""
Find user's key based on their email.
+
+ @param email: Email address of key being searched for.
+ @type email: str
+ @param secret: Should we search for a secret key?
+ @type secret: bool
+
+ @return: The fingerprint of the found key.
+ @rtype: str
"""
for key in self.list_keys(secret=secret):
for uid in key['uids']:
@@ -71,6 +133,17 @@ class GPGWrapper(gnupg.GPG):
raise LookupError("GnuPG public key for email %s not found!" % email)
def find_key_by_subkey(self, subkey, secret=False):
+ """
+ Find user's key based on a subkey fingerprint.
+
+ @param email: Subkey fingerprint of the key being searched for.
+ @type email: str
+ @param secret: Should we search for a secret key?
+ @type secret: bool
+
+ @return: The fingerprint of the found key.
+ @rtype: str
+ """
for key in self.list_keys(secret=secret):
for sub in key['subkeys']:
if sub[0] == subkey:
@@ -79,6 +152,17 @@ class GPGWrapper(gnupg.GPG):
"GnuPG public key for subkey %s not found!" % subkey)
def find_key_by_keyid(self, keyid, secret=False):
+ """
+ Find user's key based on the key ID.
+
+ @param email: The key ID of the key being searched for.
+ @type email: str
+ @param secret: Should we search for a secret key?
+ @type secret: bool
+
+ @return: The fingerprint of the found key.
+ @rtype: str
+ """
for key in self.list_keys(secret=secret):
if keyid == key['keyid']:
return key
@@ -86,6 +170,17 @@ class GPGWrapper(gnupg.GPG):
"GnuPG public key for keyid %s not found!" % keyid)
def find_key_by_fingerprint(self, fingerprint, secret=False):
+ """
+ Find user's key based on the key fingerprint.
+
+ @param email: The fingerprint of the key being searched for.
+ @type email: str
+ @param secret: Should we search for a secret key?
+ @type secret: bool
+
+ @return: The fingerprint of the found key.
+ @rtype: str
+ """
for key in self.list_keys(secret=secret):
if fingerprint == key['fingerprint']:
return key
@@ -96,6 +191,24 @@ class GPGWrapper(gnupg.GPG):
passphrase=None, symmetric=False):
"""
Encrypt data using GPG.
+
+ @param data: The data to be encrypted.
+ @type data: str
+ @param recipient: The address of the public key to be used.
+ @type recipient: str
+ @param sign: Should the encrypted content be signed?
+ @type sign: bool
+ @param always_trust: Skip key validation and assume that used keys
+ are always fully trusted?
+ @type always_trust: bool
+ @param passphrase: The passphrase to be used if symmetric encryption
+ is desired.
+ @type passphrase: str
+ @param symmetric: Should we encrypt to a password?
+ @type symmetric: bool
+
+ @return: An object with encrypted result in the `data` field.
+ @rtype: gnupg.Crypt
"""
# TODO: devise a way so we don't need to "always trust".
return super(GPGWrapper, self).encrypt(data, recipient, sign=sign,
@@ -107,6 +220,18 @@ class GPGWrapper(gnupg.GPG):
def decrypt(self, data, always_trust=True, passphrase=None):
"""
Decrypt data using GPG.
+
+ @param data: The data to be decrypted.
+ @type data: str
+ @param always_trust: Skip key validation and assume that used keys
+ are always fully trusted?
+ @type always_trust: bool
+ @param passphrase: The passphrase to be used if symmetric encryption
+ is desired.
+ @type passphrase: str
+
+ @return: An object with decrypted result in the `data` field.
+ @rtype: gnupg.Crypt
"""
# TODO: devise a way so we don't need to "always trust".
return super(GPGWrapper, self).decrypt(data,
@@ -116,7 +241,17 @@ class GPGWrapper(gnupg.GPG):
def send_keys(self, keyserver, *keyids):
"""
Send keys to a keyserver
+
+ @param keyserver: The keyserver to send the keys to.
+ @type keyserver: str
+ @param keyids: The key ids to send.
+ @type keyids: list
+
+ @return: A list of keys sent to server.
+ @rtype: gnupg.ListKeys
"""
+ # TODO: write tests for this.
+ # TODO: write a SendKeys class to handle status for this.
result = self.result_map['list'](self)
gnupg.logger.debug('send_keys: %r', keyids)
data = gnupg._make_binary_stream("", self.encoding)
@@ -131,7 +266,33 @@ class GPGWrapper(gnupg.GPG):
always_trust=False, passphrase=None,
armor=True, output=None, symmetric=False,
cipher_algo=None):
- "Encrypt the message read from the file-like object 'file'"
+ """
+ Encrypt the message read from the file-like object 'file'.
+
+ @param file: The file to be encrypted.
+ @type data: file
+ @param recipient: The address of the public key to be used.
+ @type recipient: str
+ @param sign: Should the encrypted content be signed?
+ @type sign: bool
+ @param always_trust: Skip key validation and assume that used keys
+ are always fully trusted?
+ @type always_trust: bool
+ @param passphrase: The passphrase to be used if symmetric encryption
+ is desired.
+ @type passphrase: str
+ @param armor: Create ASCII armored output?
+ @type armor: bool
+ @param output: Path of file to write results in.
+ @type output: str
+ @param symmetric: Should we encrypt to a password?
+ @type symmetric: bool
+ @param cipher_algo: Algorithm to use.
+ @type cipher_algo: str
+
+ @return: An object with encrypted result in the `data` field.
+ @rtype: gnupg.Crypt
+ """
args = ['--encrypt']
if symmetric:
args = ['--symmetric']
@@ -158,22 +319,37 @@ class GPGWrapper(gnupg.GPG):
logger.debug('encrypt result: %r', result.data)
return result
- def list_packets(self, raw_data):
+ def list_packets(self, data):
+ """
+ List the sequence of packets.
+
+ @param data: The data to extract packets from.
+ @type data: str
+
+ @return: An object with packet info.
+ @rtype ListPackets
+ """
args = ["--list-packets"]
result = self.result_map['list-packets'](self)
self._handle_io(
args,
- _make_binary_stream(raw_data, self.encoding),
+ _make_binary_stream(data, self.encoding),
result,
)
return result
- def encrypted_to(self, raw_data):
+ def encrypted_to(self, data):
"""
- Return the key to which raw_data is encrypted to.
+ Return the key to which data is encrypted to.
+
+ @param data: The data to be examined.
+ @type data: str
+
+ @return: The fingerprint of the key to which data is encrypted to.
+ @rtype: str
"""
# TODO: make this support multiple keys.
- result = self.list_packets(raw_data)
+ result = self.list_packets(data)
if not result.key:
raise LookupError(
"Content is not encrypted to a GnuPG key!")
@@ -182,13 +358,40 @@ class GPGWrapper(gnupg.GPG):
except:
return self.find_key_by_subkey(result.key)
- def is_encrypted_sym(self, raw_data):
- result = self.list_packets(raw_data)
+ def is_encrypted_sym(self, data):
+ """
+ Say whether some chunk of data is encrypted to a symmetric key.
+
+ @param data: The data to be examined.
+ @type data: str
+
+ @return: Whether data is encrypted to a symmetric key.
+ @rtype: bool
+ """
+ result = self.list_packets(data)
return bool(result.need_passphrase_sym)
- def is_encrypted_asym(self, raw_data):
- result = self.list_packets(raw_data)
+ def is_encrypted_asym(self, data):
+ """
+ Say whether some chunk of data is encrypted to a private key.
+
+ @param data: The data to be examined.
+ @type data: str
+
+ @return: Whether data is encrypted to a private key.
+ @rtype: bool
+ """
+ result = self.list_packets(data)
return bool(result.key)
- def is_encrypted(self, raw_data):
+ def is_encrypted(self, data):
+ """
+ Say whether some chunk of data is encrypted to a key.
+
+ @param data: The data to be examined.
+ @type data: str
+
+ @return: Whether data is encrypted to a key.
+ @rtype: bool
+ """
self.is_encrypted_asym() or self.is_encrypted_sym()