def load_profiles(self) -> RetVal: ''' Loads profile information from the specified JSON file stored in the top level of the profile folder. Returns: "error" : string "profiles" : list ''' profile_list_path = os.path.join(self.profile_folder, 'profiles.json') if os.path.exists(profile_list_path): profile_data = list() try: with open(profile_list_path, 'r') as fhandle: profile_data = json.load(fhandle) except Exception: return RetVal(BadProfileList) profiles = list() for item in profile_data: profile = Profile( os.path.join(self.profile_folder, item['name'])) profile.set_from_dict(item) profiles.append(profile) if profile.isdefault: self.default_profile = profile.name self.profiles = profiles return RetVal()
def activate_profile(self, name: str) -> RetVal: ''' Activates the specified profile. Returns: "error" : string "wid" : string "host" : string "port" : integer ''' if self.active_index >= 0: self.profiles[self.active_index].deactivate() self.active_index = -1 if not name: return RetVal(BadParameterValue, "BUG: name may not be empty") name_squashed = name.casefold() active_index = self.__index_for_profile(name_squashed) if active_index < 0: return RetVal(ResourceNotFound, "%s doesn't exist" % name_squashed) self.profile_id = name_squashed self.active_index = active_index self.profiles[self.active_index].activate() out = RetVal() out.set_values({ 'wid': self.profiles[active_index].wid, 'host': self.profiles[active_index].domain, 'port': self.profiles[active_index].port }) return out
def connect(self, address: str, port: int) -> RetVal: '''Creates a connection to the server.''' try: sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM) # Set a short timeout in case the server doesn't respond immediately, # which is the expectation as soon as a client connects. sock.settimeout(10.0) except Exception as e: return RetVal(ExceptionThrown, e) try: sock.connect((address, port)) # absorb the hello string _ = sock.recv(8192) except Exception as e: sock.close() return RetVal(ExceptionThrown, e) # Set a timeout of 30 minutes sock.settimeout(1800.0) self.socket = sock return RetVal()
def login(conn: ServerConnection, wid: str, serverkey: CryptoString) -> RetVal: '''Starts the login process by sending the requested workspace ID.''' if not utils.validate_uuid(wid): return RetVal(BadParameterValue) challenge = b85encode(secrets.token_bytes(32)) ekey = PublicKey(serverkey) status = ekey.encrypt(challenge) if status.error(): return status conn.send_message({ 'Action' : "LOGIN", 'Data' : { 'Workspace-ID' : wid, 'Login-Type' : 'PLAIN', 'Challenge' : status['data'] } }) response = conn.read_response(server_response) if response.error(): return response if response['Code'] != 100: return wrap_server_error(response) if response['Data']['Response'] != challenge.decode(): return RetVal(ServerError, 'server failed to decrypt challenge') return RetVal()
def unregister(conn: ServerConnection, pwhash: str, wid: str) -> RetVal: '''Deletes the online account at the specified server.''' if wid and not utils.validate_uuid(wid): return RetVal(BadParameterValue, 'bad workspace id') request = { 'Action' : 'UNREGISTER', 'Data' : { 'Password-Hash' : pwhash } } if wid: request['Data']['Workspace-ID'] = wid status = conn.send_message(request) if status.error(): return status response = conn.read_response(server_response) if response['Code'] == 202: return RetVal() # This particular command is very simple: make a request, because the server will return # one of three possible types of responses: success, pending (for private/moderated # registration modes), or an error. In all of those cases there isn't anything else to do. return wrap_server_error(response)
def iscurrent(conn: ServerConnection, index: int, wid='') -> RetVal: '''Finds out if an entry index is current. If wid is empty, the index is checked for the organization.''' if wid and not utils.validate_uuid(wid): return RetVal(AnsBadRequest).set_value('status', 400) request = { 'Action' : 'ISCURRENT', 'Data' : { 'Index' : str(index) } } if wid: request['Data']['Workspace-ID'] = wid conn.send_message(request) response = conn.read_response(server_response) if response.error(): return response if response['Code'] != 200: return wrap_server_error(response) if 'Is-Current' not in response['Data']: return RetVal(ServerError, 'server did not return an answer') return RetVal().set_value('iscurrent', bool(response['Data']['Is-Current'] == 'YES'))
def is_timestamp_valid(self) -> RetVal: '''Checks the validity of the timestamp. As a side effect, it checks the validity of the expiration date field, but it does not check if the entry is actually expired''' m = re.match(r'^([0-9]{4})([0-9]{2})([0-9]{2})$', self.fields['Expires']) if not m or not _is_valid_date(int(m[2]), int(m[3]), int(m[1])): return RetVal(BadData, 'bad expiration date') expire_time = datetime.datetime(int(m[1]), int(m[2]), int(m[3]), tzinfo=datetime.timezone( datetime.timedelta(hours=0))) m = re.match( r'^([0-9]{4})([0-9]{2})([0-9]{2})T([0-9]{2})([0-9]{2})([0-9]{2})Z$', self.fields['Timestamp']) if not m or not _is_valid_date(int(m[2]), int(m[3]), int(m[1]), int(m[4]), int(m[5]), int(m[6])): return RetVal(BadData, 'bad timestamp') timestamp_time = datetime.datetime(int(m[1]), int(m[2]), int(m[3]), int(m[4]), int(m[5]), int(m[6]), tzinfo=datetime.timezone( datetime.timedelta(hours=0))) if timestamp_time > expire_time: return RetVal(BadData, 'bad timestamp') return RetVal()
def set_default_profile(self, name: str) -> RetVal: ''' Sets the default profile. If there is only one profile -- or none at all -- this call has no effect. ''' if not name: return RetVal(BadParameterValue, "BUG: name may not be empty") if len(self.profiles) == 1: if self.profiles[0].isdefault: return RetVal() self.profiles[0].isdefault = True return self.save_profiles() oldindex = -1 for i in range(0, len(self.profiles)): if self.profiles[i].isdefault: oldindex = i name_squashed = name.casefold() newindex = self.__index_for_profile(name_squashed) if newindex < 0: return RetVal(ResourceNotFound, "New profile %s not found" % name_squashed) if oldindex >= 0: if name_squashed == self.profiles[oldindex].name: return RetVal() self.profiles[oldindex].isdefault = False self.profiles[newindex].isdefault = True return self.save_profiles()
def chain(self, key: CryptoString, rotate_optional: bool) -> RetVal: '''Appends a new entry to the chain, optionally rotating keys which aren't required to be changed. This method requires that the root entry already exist. Note that user cards will not have all the required signatures when the call returns''' if len(self.entries) < 1: return RetVal(ResourceNotFound, 'missing root entry') # Just in case we get some squirrelly non-Org, non-User card type chain_method = getattr(self.entries[-1], "chain", None) if not chain_method or not callable(chain_method): return RetVal(FeatureNotAvailable, "entry doesn't support chaining") chaindata = self.entries[-1].chain(key, rotate_optional) if chaindata.error(): return chaindata new_entry = chaindata['entry'] skeystring = CryptoString() status = skeystring.set(chaindata['sign.private']) if status.error(): return status if new_entry.type == 'User': status = new_entry.sign(skeystring, 'User') else: status = new_entry.sign(skeystring, 'Organization') if status.error(): return status chaindata['entry'] = new_entry self.entries.append(new_entry) return chaindata
def is_compliant(self) -> RetVal: '''Checks the fields to ensure that it meets spec requirements. If a field causes it to be noncompliant, the noncompliant field is also returned''' status = self.is_data_compliant() if status.error(): return status # Ensure signature compliance for info in self.signature_info: if info['type'] == SIGINFO_HASH: if not self.hash: return RetVal(SignatureMissing, 'Hash') else: continue if info['optional']: # Optional signatures, if present, may not be empty if info['name'] in self.signatures and not self.signatures[ info['name']]: return RetVal(SignatureMissing, '%s-Signature' % info['name']) else: if info['name'] not in self.signatures or not self.signatures[ info['name']]: return RetVal(SignatureMissing, '%s-Signature' % info['name']) return RetVal()
def preregister_account(self, port_str: str, uid: str) -> RetVal: '''Create a new account on the local server. This is a simple command because it is not meant to create a local profile.''' if port_str: try: port = int(port_str) except: return RetVal(BadParameterValue, 'Bad port number') else: port = 2001 if port < 0 or port > 65535: return RetVal(BadParameterValue, 'Bad port number') if '"' in uid or '/' in uid: return RetVal(BadParameterValue, "User ID can't contain \" or /") status = self.conn.connect('127.0.0.1', port) if status.error(): return status regdata = serverconn.preregister(self.conn, '', uid, '') if regdata.error(): return regdata self.conn.disconnect() if regdata['status'] != 200: return regdata if 'wid' not in regdata or 'regcode' not in regdata: return RetVal(InternalError, 'BUG: bad data from serverconn.preregister()') \ .set_value('status', 300) return regdata
def create_profile(self, name) -> RetVal: ''' Creates a profile with the specified name. Profile names are not case-sensitive. Returns: RetVal error state also contains a copy of the created profile as "profile" ''' if not name: return RetVal(BadParameterValue, "BUG: name may not be empty") name_squashed = name.casefold() if self.__index_for_profile(name_squashed) >= 0: return RetVal(ResourceExists, name) profile = Profile(os.path.join(self.profile_folder, name_squashed)) profile.make_id() self.profiles.append(profile) if len(self.profiles) == 1: profile.isdefault = True self.default_profile = name status = self.save_profiles() if status.error(): return status status.set_value("profile", profile) return status
def delete_profile(self, name) -> RetVal: ''' Deletes the named profile and all files on disk contained in it. ''' if name == 'default': return RetVal(BadParameterValue, "'default' is reserved") if not name: return RetVal(BadParameterValue, "BUG: name may not be empty") name_squashed = name.casefold() itemindex = self.__index_for_profile(name_squashed) if itemindex < 0: return RetVal(ResourceNotFound, "%s doesn't exist" % name) profile = self.profiles.pop(itemindex) if os.path.exists(profile.path): try: shutil.rmtree(profile.path) except Exception as e: return RetVal(ExceptionThrown, e.__str__()) if profile.isdefault: if self.profiles: self.profiles[0].isdefault = True return self.save_profiles()
def getwid(conn: ServerConnection, uid: str, domain: str) -> RetVal: '''Looks up a wid based on the specified user ID and optional domain''' if re.findall(r'[\\\/\s"]', uid) or len(uid) >= 64: return RetVal(BadParameterValue, 'user id') if domain: m = re.match(r'([a-zA-Z0-9]+\.)+[a-zA-Z0-9]+', domain) if not m or len(domain) >= 64: return RetVal(BadParameterValue, 'bad domain value') request = { 'Action' : 'GETWID', 'Data' : { 'User-ID': uid } } if domain: request['Data']['Domain'] = domain status = conn.send_message(request) if status.error(): return status response = conn.read_response(server_response) if response.error(): return response if response['Code'] != 200: return wrap_server_error(response) return RetVal().set_value('Workspace-ID', response['Data']['Workspace-ID'])
def connect(self, host: str, port) -> RetVal: '''Creates a connection to the server.''' try: self.__sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM) # Set a short timeout in case the server doesn't respond immediately, # which is the expectation as soon as a client connects. self.__sock.settimeout(10.0) except: return RetVal(NetworkError, "Couldn't create a socket") out_data = RetVal() out_data.set_value('socket', self.__sock) try: self.ip = socket.gethostbyname(host) except socket.gaierror: self.disconnect() return RetVal(ResourceNotFound, "Couldn't locate host %s" % host) try: self.__sock.connect((self.ip, port)) self.port = port status = self.read_msg(pyanselus.rpc_schemas.greeting) if not status.error(): self.version = status['msg']['version'].strip() except Exception as exc: self.disconnect() return RetVal(NetworkError, f"Couldn't connect to host {host}: {exc}") # Set a timeout of 30 minutes self.__sock.settimeout(1800.0) return out_data
def get_key(db: sqlite3.Connection, keyid: str) -> RetVal: '''Gets the specified key. Parameters: keyid : uuid Returns: 'error' : string 'key' : CryptoKey object ''' cursor = db.cursor() cursor.execute( ''' SELECT address,type,category,private,public,algorithm FROM keys WHERE keyid=?''', (keyid, )) results = cursor.fetchone() if not results or not results[0]: return RetVal(ResourceNotFound) if results[1] == 'asymmetric': public = base64.b85decode(results[4]) private = base64.b85decode(results[3]) key = encryption.EncryptionPair(public, private) return RetVal().set_value('key', key) if results[1] == 'symmetric': private = base64.b85decode(results[3]) key = encryption.SecretKey(private) return RetVal().set_value('key', key) return RetVal(BadParameterValue, "Key must be 'asymmetric' or 'symmetric'")
def add_key(db: sqlite3.Connection, key: encryption.CryptoKey, address: str) -> RetVal: '''Adds an encryption key to a workspace. Parameters: key: CryptoKey from encryption module address: full Anselus address, i.e. wid + domain Returns: error : string ''' cursor = db.cursor() cursor.execute("SELECT keyid FROM keys WHERE keyid=?", (key.get_id(), )) results = cursor.fetchone() if results: return RetVal(ResourceExists) if key.enctype == 'XSALSA20': cursor.execute( '''INSERT INTO keys(keyid,address,type,category,private,algorithm) VALUES(?,?,?,?,?,?)''', (key.get_id(), address, 'symmetric', '', key.get_key(), key.enctype)) db.commit() return RetVal() if key.enctype == 'CURVE25519': cursor.execute( '''INSERT INTO keys(keyid,address,type,category,private,public,algorithm) VALUES(?,?,?,?,?,?,?)''', (key.get_id(), address, 'asymmetric', '', key.private.as_string(), key.public.as_string(), key.enctype)) db.commit() return RetVal() return RetVal(BadParameterValue, "Key must be 'asymmetric' or 'symmetric'")
def get_session_private_key(db: sqlite3.Connection, address: str) -> RetVal: '''Returns the private key for the device for a session''' cursor = db.cursor() cursor.execute("SELECT private_key FROM sessions WHERE address=?", (address, )) results = cursor.fetchone() if not results or not results[0]: return RetVal(ResourceNotFound) return RetVal().set_value('key', results[0])
def generate(self, userid: str, server: str, wid: str, pw: encryption.Password) -> RetVal: '''Creates all the data needed for an individual workspace account''' self.uid = userid self.wid = wid self.domain = server # Add workspace status = self.add_to_db(pw) if status.error(): return status address = '/'.join([wid, server]) # Generate user's encryption keys keys = { 'identity': encryption.EncryptionPair(), 'conrequest': encryption.EncryptionPair(), 'broadcast': encryption.SecretKey(), 'folder': encryption.SecretKey() } # Add encryption keys for key in keys.values(): out = auth.add_key(self.db, key, address) if out.error(): status = self.remove_workspace_entry(wid, server) if status.error(): return status # Add folder mappings foldermap = encryption.FolderMapping() folderlist = [ 'messages', 'contacts', 'events', 'tasks', 'notes', 'files', 'files attachments' ] for folder in folderlist: foldermap.MakeID() foldermap.Set(address, keys['folder'].get_id(), folder, 'root') self.add_folder(foldermap) # Create the folders themselves try: self.path.mkdir(parents=True, exist_ok=True) except Exception as e: self.remove_from_db() return RetVal(ExceptionThrown, e.__str__()) self.path.joinpath('files').mkdir(exist_ok=True) self.path.joinpath('files', 'attachments').mkdir(exist_ok=True) self.set_userid(userid) return RetVal()
def addentry(conn: ServerConnection, entry: EntryBase, ovkey: CryptoString, spair: SigningPair) -> RetVal: '''Handles the process to upload an entry to the server.''' conn.send_message({ 'Action' : "ADDENTRY", 'Data' : { 'Base-Entry' : entry.make_bytestring(0).decode() } }) response = conn.read_response(server_response) if response['Code'] != 100: return wrap_server_error(response) for field in ['Organization-Signature', 'Hash', 'Previous-Hash'] : if field not in response['Data']: return RetVal(ServerError, f"Server did not return required field {field}") entry.signatures['Organization'] = response['Data']['Organization-Signature'] # A regular client will check the entry cache, pull updates to the org card, and get the # verification key. Because this is just an integration test, we skip all that and just use # the known verification key from earlier in the test. status = entry.verify_signature(ovkey, 'Organization') if status.error(): return status entry.prev_hash = response['Data']['Previous-Hash'] entry.hash = response['Data']['Hash'] status = entry.verify_hash() if status.error(): return status # User sign and verify status = entry.sign(spair.private, 'User') if status.error(): return status status = entry.verify_signature(spair.public, 'User') if status.error(): return status status = entry.is_compliant() if status.error(): return status conn.send_message({ 'Action' : "ADDENTRY", 'Data' : { 'User-Signature' : entry.signatures['User'] } }) response = conn.read_response(server_response) if response['Code'] != 200: return wrap_server_error(response) return RetVal()
def split_address(address): '''Splits an Anselus numeric address into its two parts.''' parts = address.split('/') if len(parts) != 2 or \ not parts[0] or \ not parts[1] or \ not validate_uuid(parts[0]): return RetVal(BadParameterValue, 'Bad workspace address') out = RetVal() out.set_value('wid', parts[0]) out.set_value('domain', parts[1]) return out
def verify_hash(self) -> RetVal: '''Checks that the entry's actual hash matches that in the hash field''' current_hash = CryptoString(self.hash) if not current_hash.is_valid(): return RetVal(InvalidHash, f"{self.hash} is not a valid CryptoString") status = self.get_hash(current_hash.prefix) if status.error(): return status return RetVal()
def devkey(conn: ServerConnection, devid: str, oldpair: EncryptionPair, newpair: EncryptionPair): '''Replaces the specified device's key stored on the server''' if not utils.validate_uuid(devid): return RetVal(AnsBadRequest, 'Invalid device ID').set_value('status', 400) conn.send_message({ 'Action' : "DEVKEY", 'Data' : { 'Device-ID': devid, 'Old-Key': oldpair.public.as_string(), 'New-Key': newpair.public.as_string() } }) # Receive, decrypt, and return the server challenge response = conn.read_response(server_response) if response.error(): return response if response['Code'] != 100: return wrap_server_error(response) if 'Challenge' not in response['Data'] or 'New-Challenge' not in response['Data']: return RetVal(ServerError, 'server did not return both device challenges') status = oldpair.decrypt(response['Data']['Challenge']) if status.error(): cancel(conn) return RetVal(DecryptionFailure, 'failed to decrypt device challenge for old key') request = { 'Action' : "DEVKEY", 'Data' : { 'Response' : status['data'] } } status = newpair.decrypt(response['Data']['New-Challenge']) if status.error(): cancel(conn) return RetVal(DecryptionFailure, 'failed to decrypt device challenge for new key') request['Data']['New-Response'] = status['data'] conn.send_message(request) response = conn.read_response(None) if response.error(): return response if response['Code'] == 200: return RetVal() return wrap_server_error(response)
def remove_device_session(db, devid: str) -> RetVal: ''' Removes an authorized device from the workspace. Returns a boolean success code. ''' cursor = db.cursor() cursor.execute("SELECT devid FROM sessions WHERE devid=?", (devid, )) results = cursor.fetchone() if not results or not results[0]: return RetVal(ResourceNotFound) cursor.execute("DELETE FROM sessions WHERE devid=?", (devid, )) db.commit() return RetVal()
def decrypt(self, data : str) -> RetVal: '''Decrypt the passed data using the private key and return the raw data in the field 'data'. Base85 decoding of the data is optional, but enabled by default.''' if not isinstance(data, str): return RetVal(BadParameterType, 'string expected') try: sealedbox = nacl.public.SealedBox(nacl.public.PrivateKey(self.private.raw_data())) decrypted_data = sealedbox.decrypt(data.encode(), Base85Encoder) except Exception as e: return RetVal(ExceptionThrown, str(e)) return RetVal().set_value('data', decrypted_data.decode())
def encrypt(self, data : bytes) -> RetVal: '''Encrypt the passed data using the public key and return the Base85-encoded data in the field 'data'.''' if not isinstance(data, bytes): return RetVal(BadParameterType, 'bytes expected') try: sealedbox = nacl.public.SealedBox(nacl.public.PublicKey(self.public.raw_data())) encrypted_data = sealedbox.encrypt(data, Base85Encoder).decode() except Exception as e: return RetVal(ExceptionThrown, str(e)) return RetVal().set_value('data', encrypted_data)
def sign(self, data : bytes) -> RetVal: '''Return a Base85-encoded signature for the supplied data in the field 'signature'.''' if not isinstance(data, bytes): return RetVal(BadParameterType, 'bytes expected for data') key = nacl.signing.SigningKey(self.private.raw_data()) try: signed = key.sign(data, Base85Encoder) except Exception as e: return RetVal(ExceptionThrown, e) return RetVal().set_value('signature', 'ED25519:' + signed.signature.decode())
def write(self, text: str) -> RetVal: '''Sends a string over a socket''' if not self.socket: return RetVal(NetworkError, 'Invalid connection') try: self.socket.send(text.encode()) except Exception as exc: self.socket.close() return RetVal(ExceptionThrown, exc.__str__()) return RetVal()
def load_encryptionpair(path: str) -> RetVal: '''Instantiates a keypair from a file''' if not path: return RetVal(BadParameterValue, 'path may not be empty') if not os.path.exists(path): return RetVal(ResourceNotFound, '%s exists' % path) indata = None try: with open(path, "r") as fhandle: indata = json.load(fhandle) except Exception as e: return RetVal(ExceptionThrown, e) if not isinstance(indata, dict): return RetVal(BadData, 'File does not contain an Anselus JSON keypair') try: jsonschema.validate(indata, __encryption_pair_schema) except jsonschema.ValidationError: return RetVal(BadData, "file data does not validate") except jsonschema.SchemaError: return RetVal(InternalError, "BUG: invalid EncryptionPair schema") public_key = CryptoString(indata['PublicKey']) private_key = CryptoString(indata['PrivateKey']) if not public_key.is_valid() or not private_key.is_valid(): return RetVal(BadData, 'Failure to base85 decode key data') return RetVal().set_value('keypair', EncryptionPair(public_key, private_key))
def load_secretkey(path: str) -> RetVal: '''Instantiates a secret key from a file''' if not path: return RetVal(BadParameterValue, 'path may not be empty') if not os.path.exists(path): return RetVal(ResourceNotFound, '%s exists' % path) indata = None try: with open(path, "r") as fhandle: indata = json.load(fhandle) except Exception as e: return RetVal(ExceptionThrown, e) if not isinstance(indata, dict): return RetVal(BadData, 'File does not contain an Anselus JSON secret key') try: jsonschema.validate(indata, __secret_key_schema) except jsonschema.ValidationError: return RetVal(BadData, "file data does not validate") except jsonschema.SchemaError: return RetVal(InternalError, "BUG: invalid SecretKey schema") key = CryptoString(indata['SecretKey']) if not key.is_valid(): return RetVal(BadData, 'Failure to base85 decode key data') return RetVal().set_value('key', SecretKey(key))