(Steve Dougherty)
2013-07-26: Add abstraction for WoT identities. Add abstraction for WoT identities. The WoT_ID class performs lookup from an identifier and exposes attributes. Local_WoT_ID extends WoT_ID and also exposes an insert URI. This avoids problems with being stringly typed and makes intent much clearer. Also adds a clone() to USK to avoid modifying the WoT_ID member variables.
diff --git a/infocalypse/commands.py b/infocalypse/commands.py --- a/infocalypse/commands.py +++ b/infocalypse/commands.py @@ -41,8 +41,8 @@ def infocalypse_update_repo_list(ui, **o raise util.Abort("Update which repository list? Use --wot") import wot - local_id = wot.resolve_local_identity(ui, opts['wot']) - wot.update_repo_listing(ui, local_id['Identity']) + from wot_id import Local_WoT_ID + wot.update_repo_listing(ui, Local_WoT_ID(opts['wot'])) def infocalypse_create(ui_, repo, **opts): @@ -50,46 +50,34 @@ def infocalypse_create(ui_, repo, **opts params, stored_cfg = get_config_info(ui_, opts) insert_uri = '' - attributes = None + local_id = None if opts['uri'] != '' and opts['wot'] != '': ui_.warn("Please specify only one of --uri or --wot.\n") return elif opts['uri'] != '': insert_uri = opts['uri'] elif opts['wot'] != '': - # Expecting nick_prefix/repo_name.R<redundancy num>/edition/ - nick_prefix, repo_desc = opts['wot'].split('/', 1) + # Expecting nick_prefix/repo_name.R<redundancy num>/edition + nick_prefix, repo_name, repo_edition = opts['wot'].split('/', 2) - import wot + from wot_id import Local_WoT_ID ui_.status("Querying WoT for local identities.\n") - attributes = wot.resolve_local_identity(ui_, nick_prefix) - if attributes is None: - # Something went wrong; the function already printed an error. - return + local_id = Local_WoT_ID(nick_prefix) - ui_.status('Found {0}@{1}\n'.format(attributes['Nickname'], - attributes['Identity'])) + ui_.status('Found {0}\n'.format(local_id)) - insert_uri = attributes['InsertURI'] + insert_uri = local_id.insert_uri.clone() - # LCWoT returns URIs with a "freenet:" prefix, and WoT does not. The - # rest of Infocalypse does not support the prefix. The local way to fix - # this is to remove it here, but the more flexible way that is also - # more work is to expand support to the rest of Infocalypse. - # TODO: More widespread support for "freenet:" URI prefix. - prefix = "freenet:" - if insert_uri.startswith(prefix): - insert_uri = insert_uri[len(prefix):] - - # URI is USK@key/WebOfTrust/<edition>, but we only want USK@key - insert_uri = insert_uri.split('/', 1)[0] - insert_uri += '/' + repo_desc + insert_uri.name = repo_name + insert_uri.edition = repo_edition + # Before passing along into execute_create(). + insert_uri = str(insert_uri) # Add "vcs" context. No-op if the identity already has it. msg_params = {'Message': 'AddContext', - 'Identity': attributes['Identity'], + 'Identity': local_id.identity_id, 'Context': 'vcs'} import fcp @@ -116,14 +104,13 @@ def infocalypse_create(ui_, repo, **opts if inserted_to and opts['wot']: # TODO: Would it be friendlier to include the nickname as well? # creation returns a list of request URIs; use the first. - stored_cfg.set_wot_identity(inserted_to[0], - attributes['Identity']) + stored_cfg.set_wot_identity(inserted_to[0], local_id) Config.to_file(stored_cfg) # TODO: Imports don't go out of scope, right? The variables # from the import are only visible in the function, so yes. import wot - wot.update_repo_listing(ui_, attributes['Identity']) + wot.update_repo_listing(ui_, local_id) def infocalypse_copy(ui_, repo, **opts): @@ -201,9 +188,6 @@ def infocalypse_pull(ui_, repo, **opts): truster = get_truster(ui_, repo, opts) request_uri = wot.resolve_pull_uri(ui_, opts['wot'], truster) - - if request_uri is None: - return else: request_uri = opts['uri'] @@ -221,22 +205,25 @@ def infocalypse_pull(ui_, repo, **opts): def infocalypse_pull_request(ui, repo, **opts): import wot + from wot_id import WoT_ID if not opts['wot']: raise util.Abort("Who do you want to send the pull request to? Set " "--wot.\n") wot_id, repo_name = opts['wot'].split('/', 1) - wot.send_pull_request(ui, repo, get_truster(ui, repo, opts), wot_id, - repo_name) + from_identity = get_truster(ui, repo, opts) + to_identity = WoT_ID(wot_id, from_identity) + wot.send_pull_request(ui, repo, from_identity, to_identity, repo_name) def infocalypse_check_notifications(ui, repo, **opts): import wot + from wot_id import Local_WoT_ID if not opts['wot']: raise util.Abort("What ID do you want to check for notifications? Set" " --wot.\n") - wot.check_notifications(ui, opts['wot']) + wot.check_notifications(ui, Local_WoT_ID(opts['wot'])) def infocalypse_connect(ui, repo, **opts): @@ -480,8 +467,7 @@ def infocalypse_setup(ui_, **opts): ui_.status("Skipped FMS configuration because --nofms was set.\n") if not opts['nowot']: - import wot - wot.execute_setup_wot(ui_, opts) + infocalypse_setupwot(ui_, **opts) else: ui_.status("Skipped WoT configuration because --nowot was set.\n") @@ -494,8 +480,12 @@ def infocalypse_setupfms(ui_, **opts): # TODO: Why ui with trailing underscore? Is there a global "ui" somewhere? def infocalypse_setupwot(ui_, **opts): + if not opts['truster']: + util.Abort("Specify default truster with --truster") + import wot - wot.execute_setup_wot(ui_, opts) + from wot_id import Local_WoT_ID + wot.execute_setup_wot(ui_, Local_WoT_ID(opts['truster'])) def infocalypse_setupfreemail(ui, repo, **opts): @@ -511,21 +501,22 @@ def infocalypse_setupfreemail(ui, repo, def get_truster(ui, repo, opts): """ - Return a local WoT ID. - - TODO: Check that it actually is local? Classes would be nice for this. - Being stringly typed as present requires a lot of checking and re-checking. + Return a local WoT ID - either one that published this repository or the + default. + :rtype : Local_WoT_ID """ + from wot_id import Local_WoT_ID if opts['truster']: - return opts['truster'] + return Local_WoT_ID(opts['truster']) else: cfg = Config().from_ui(ui) + # Value is identity ID. identity = cfg.get_wot_identity(cfg.get_request_uri(repo.root)) if not identity: identity = cfg.defaults['DEFAULT_TRUSTER'] - return '@' + identity + return Local_WoT_ID('@' + identity) #----------------------------------------------------------" diff --git a/infocalypse/config.py b/infocalypse/config.py --- a/infocalypse/config.py +++ b/infocalypse/config.py @@ -127,6 +127,7 @@ class Config: self.insert_usks = {} # repo id -> publisher WoT identity self.wot_identities = {} + # TODO: Should this be keyed by str(WoT_ID) ? # WoT public key hash -> Freemail password self.freemail_passwords = {} # fms_id -> (usk_hash, ...) map @@ -208,8 +209,10 @@ class Config: def set_wot_identity(self, for_usk_or_id, wot_identity): """ Set the WoT identity associated with the request USK. + :type wot_identity: WoT_ID """ - self.wot_identities[normalize(for_usk_or_id)] = wot_identity + self.wot_identities[normalize(for_usk_or_id)] =\ + wot_identity.identity_id def get_wot_identity(self, for_usk_or_id): """ @@ -225,20 +228,21 @@ class Config: """ Set the password for the given WoT identity. """ - self.freemail_passwords[wot_identity] = password + self.freemail_passwords[wot_identity.identity_id] = password def get_freemail_password(self, wot_identity): """ Return the password associated with the given WoT identity. Raise util.Abort if one is not set. + :type wot_identity: WoT_ID """ - if wot_identity in self.freemail_passwords: - return self.freemail_passwords[wot_identity] + identity_id = wot_identity.identity_id + if identity_id in self.freemail_passwords: + return self.freemail_passwords[identity_id] else: raise util.Abort("{0} does not have a Freemail password set.\n" - "Run hg fn-setupfreemail --truster {0}@{1}\n" - .format(wot_identity['Nickname'], - wot_identity['Identity'])) + "Run hg fn-setupfreemail --truster {0}\n" + .format(wot_identity)) # Hmmm... really nescessary? def get_dir_insert_uri(self, repo_dir): diff --git a/infocalypse/keys.py b/infocalypse/keys.py --- a/infocalypse/keys.py +++ b/infocalypse/keys.py @@ -17,6 +17,9 @@ class USK: elif self.key.startswith('freenet://'): self.key = self.key[len('freenet://'):] + def clone(self): + return USK(str(self)) + def __str__(self): return '%s/%s/%s' % (self.key, self.name, self.edition) diff --git a/infocalypse/wot.py b/infocalypse/wot.py --- a/infocalypse/wot.py +++ b/infocalypse/wot.py @@ -1,17 +1,15 @@ -import string import fcp from mercurial import util from config import Config import xml.etree.ElementTree as ET from defusedxml.ElementTree import fromstring import smtplib -from base64 import b32encode -from fcp.node import base64decode from keys import USK import yaml from email.mime.text import MIMEText import imaplib import threading +from wot_id import Local_WoT_ID, WoT_ID FREEMAIL_SMTP_PORT = 4025 FREEMAIL_IMAP_PORT = 4143 @@ -78,20 +76,20 @@ def connect(ui, repo): print ack -def send_pull_request(ui, repo, from_identifier, to_identifier, to_repo_name): - local_identity = resolve_local_identity(ui, from_identifier) +def send_pull_request(ui, repo, from_identity, to_identity, to_repo_name): + """ - target_identity = resolve_identity(ui, local_identity['Identity'], - to_identifier) - from_address = to_freemail_address(local_identity) - to_address = to_freemail_address(target_identity) + :type to_identity: WoT_ID + :type from_identity: Local_WoT_ID + """ + from_address = require_freemail(from_identity) + to_address = require_freemail(to_identity) cfg = Config.from_ui(ui) - password = cfg.get_freemail_password(local_identity['Identity']) + password = cfg.get_freemail_password(from_identity.identity_id) - to_repo = find_repo(ui, local_identity['Identity'], to_identifier, - to_repo_name) + to_repo = find_repo(ui, to_identity, to_repo_name) repo_context = repo['tip'] # TODO: Will there always be a request URI set in the config? What about @@ -123,7 +121,7 @@ HG: Enter pull request message here. Lin HG: The first line has "{0}" added before it in transit and is the subject. HG: The second line should be blank. HG: Following lines are the body of the message. -""".format(VCS_TOKEN), from_identifier) +""".format(VCS_TOKEN), str(from_identity)) # TODO: Save message and load later in case sending fails. source_lines = source_text.splitlines() @@ -147,14 +145,17 @@ HG: Following lines are the body of the ui.status("Pull request sent.\n") -def check_notifications(ui, sent_to_identifier): - local_identity = resolve_local_identity(ui, sent_to_identifier) - address = to_freemail_address(local_identity) +def check_notifications(ui, local_identity): + """ + + :type local_identity: Local_WoT_ID + """ + address = require_freemail(local_identity) # Log in and open inbox. cfg = Config.from_ui(ui) imap = imaplib.IMAP4(cfg.defaults['HOST'], FREEMAIL_IMAP_PORT) - imap.login(address, cfg.get_freemail_password(local_identity['Identity'])) + imap.login(address, cfg.get_freemail_password(local_identity)) imap.select() # Parenthesis to work around erroneous quotes: @@ -262,17 +263,31 @@ def read_message_yaml(ui, from_address, % (subject, request['request'])) +def require_freemail(wot_identity): + """ + Return the given identity's Freemail address. + Abort with an error message if the given identity does not have a + Freemail address / context. + :type wot_identity: WoT_ID + """ + if not wot_identity.freemail_address: + raise util.Abort("{0} is not using Freemail.\n".format(wot_identity)) + + return wot_identity.freemail_address + + def update_repo_listing(ui, for_identity): # TODO: WoT property containing edition. Used when requesting. # Version number to support possible format changes. + """ + + :type for_identity: Local_WoT_ID + """ root = ET.Element('vcs', {'version': '0'}) ui.status("Updating repo listing for '%s'\n" % for_identity) - # Key goes after @ - before is nickname. - wot_id = '@' + for_identity - - for request_uri in build_repo_list(ui, wot_id): + for request_uri in build_repo_list(ui, for_identity): repo = ET.SubElement(root, 'repository', { 'vcs': 'Infocalypse', }) @@ -281,8 +296,7 @@ def update_repo_listing(ui, for_identity # TODO: Nonstandard IP and port. node = fcp.FCPNode() - attributes = resolve_local_identity(ui, wot_id) - insert_uri = USK(attributes['InsertURI']) + insert_uri = for_identity.insert_uri.clone() # TODO: Somehow store the edition, perhaps in ~/.infocalypse. WoT # properties are apparently not appropriate. @@ -300,61 +314,50 @@ def update_repo_listing(ui, for_identity ui.status("Updated repository listing:\n{0}\n".format(uri)) -def build_repo_list(ui, wot_id): +def build_repo_list(ui, for_identity): """ Return a list of request URIs to repos for the given local identity. - TODO: Does local identities only make sense? + :type for_identity: Local_WoT_ID :param ui: to provide feedback - :param wot_id: local WoT identity to list repos for. + :param for_identity: local WoT identity to list repos for. """ - # TODO: Repetitive local ID resolution between here and repo listing - # update. This only needs key - perhaps higher-level stuff should always - # take a wot_id? - local_id = resolve_local_identity(ui, wot_id) - config = Config.from_ui(ui) repos = [] # Add request URIs associated with the given identity. for request_uri in config.request_usks.itervalues(): - if config.get_wot_identity(request_uri) == local_id['Identity']: + if config.get_wot_identity(request_uri) == for_identity.identity_id: repos.append(request_uri) return repos -def find_repo(ui, truster, wot_identifier, repo_name): +def find_repo(ui, identity, repo_name): """ Return a request URI for a repo of the given name published by an identity matching the given identifier. Raise util.Abort if unable to read repo listing or a repo by that name does not exist. + :type identity: WoT_ID """ - listing = read_repo_listing(ui, truster, wot_identifier) + listing = read_repo_listing(ui, identity) if repo_name not in listing: - # TODO: Perhaps resolve again; print full nick / key? - # TODO: Maybe print key found in the resolve_*identity? raise util.Abort("{0} does not publish a repo named '{1}'\n" - .format(wot_identifier, repo_name)) + .format(identity, repo_name)) return listing[repo_name] -def read_repo_listing(ui, truster, wot_identifier): +def read_repo_listing(ui, identity): """ Read a repo listing for a given identity. Return a dictionary of repository request URIs keyed by name. - Raise util.Abort if unable to resolve identity. + :type identity: WoT_ID """ - identity = resolve_identity(ui, truster, wot_identifier) - - ui.status("Found {0}@{1}.\n".format(identity['Nickname'], - identity['Identity'])) - - uri = USK(identity['RequestURI']) + uri = identity.request_uri.clone() uri.name = 'vcs' uri.edition = 0 @@ -383,7 +386,8 @@ def read_repo_listing(ui, truster, wot_i def resolve_pull_uri(ui, path, truster): """ Return a pull URI for the given path. - Print an error message and return None on failure. + Print an error message and abort on failure. + :type truster: Local_WoT_ID TODO: Is it appropriate to outline possible errors? Possible failures are being unable to fetch a repo list for the given identity, which may be a fetch failure or being unable to find the @@ -397,10 +401,12 @@ def resolve_pull_uri(ui, path, truster): # Expecting <id stuff>/reponame wot_id, repo_name = path.split('/', 1) + identity = WoT_ID(wot_id, truster) + # TODO: How to handle redundancy? Does Infocalypse automatically try # an R0 if an R1 fails? - return find_repo(ui, truster, wot_id, repo_name) + return find_repo(ui, identity, repo_name) def resolve_push_uri(ui, path): @@ -413,21 +419,20 @@ def resolve_push_uri(ui, path): where the identity is a local one. (Such that the insert URI is known.) """ # Expecting <id stuff>/repo_name - # TODO: Duplicate with resolve_pull wot_id, repo_name = path.split('/', 1) - local_id = resolve_local_identity(ui, wot_id) + local_id = Local_WoT_ID(wot_id) - insert_uri = USK(local_id['InsertURI']) + insert_uri = local_id.insert_uri - identifier = local_id['Nickname'] + '@' + local_id['Identity'] - - repo = find_repo(ui, local_id['Identity'], identifier, repo_name) + # TODO: find_repo should make it clearer that it returns a request URI, + # and return a USK. + repo = find_repo(ui, local_id, repo_name) # Request URI repo_uri = USK(repo) - # Maintains path, edition. + # Maintains name, edition. repo_uri.key = insert_uri.key return str(repo_uri) @@ -437,33 +442,27 @@ def resolve_push_uri(ui, path): # TODO: "cmds" suffix to module name to fit fms, arc, inf? -def execute_setup_wot(ui_, opts): +def execute_setup_wot(ui_, local_id): cfg = Config.from_ui(ui_) - response = resolve_local_identity(ui_, opts['truster']) - ui_.status("Setting default truster to {0}@{1}\n".format( - response['Nickname'], - response['Identity'])) + ui_.status("Setting default truster to {0}.\n".format(local_id)) - cfg.defaults['DEFAULT_TRUSTER'] = response['Identity'] + cfg.defaults['DEFAULT_TRUSTER'] = local_id.identity_id Config.to_file(cfg) -def execute_setup_freemail(ui, wot_identifier): +def execute_setup_freemail(ui, local_id): """ Prompt for, test, and set a Freemail password for the identity. """ - local_id = resolve_local_identity(ui, wot_identifier) - - address = to_freemail_address(local_id) + address = require_freemail(local_id) password = ui.getpass() if password is None: raise util.Abort("Cannot prompt for a password in a non-interactive " "context.\n") - ui.status("Checking password for {0}@{1}.\n".format(local_id['Nickname'], - local_id['Identity'])) + ui.status("Checking password for {0}.\n".format(local_id)) cfg = Config.from_ui(ui) @@ -479,245 +478,6 @@ def execute_setup_freemail(ui, wot_ident raise util.Abort("Could not connect to server.\nGot '{0}'\n" .format(e.smtp_error)) - cfg.set_freemail_password(local_id['Identity'], password) + cfg.set_freemail_password(local_id, password) Config.to_file(cfg) ui.status("Password set.\n") - - -def resolve_local_identity(ui, wot_identifier): - """ - Mercurial ui for error messages. - - Returns a dictionary of the nickname, insert and request URIs, - and identity that match the given criteria. - In the case of an error prints a message and returns None. - """ - nickname_prefix, key_prefix = parse_name(wot_identifier) - - node = fcp.FCPNode() - response = \ - node.fcpPluginMessage(async=False, - plugin_name="plugins.WebOfTrust.WebOfTrust", - plugin_params={'Message': - 'GetOwnIdentities'})[0] - - if response['header'] != 'FCPPluginReply' or \ - 'Replies.Message' not in response or \ - response['Replies.Message'] != 'OwnIdentities': - raise util.Abort("Unexpected reply. Got {0}\n.".format(response)) - - # Find nicknames starting with the supplied nickname prefix. - prefix = 'Replies.Nickname' - # Key: nickname, value (id_num, public key hash). - matches = {} - for key in response.iterkeys(): - if key.startswith(prefix) and \ - response[key].startswith(nickname_prefix): - - # Key is Replies.Nickname<number>, where number is used in - # the other attributes returned for that identity. - id_num = key[len(prefix):] - - nickname = response[key] - pubkey_hash = response['Replies.Identity{0}'.format(id_num)] - - matches[nickname] = (id_num, pubkey_hash) - - # Remove matching nicknames not also matching the (possibly partial) - # public key hash. - for key in matches.keys(): - # public key hash is second member of value tuple. - if not matches[key][1].startswith(key_prefix): - del matches[key] - - if len(matches) > 1: - raise util.Abort("'{0}' is ambiguous.\n".format(wot_identifier)) - - if len(matches) == 0: - raise util.Abort("No local identities match '{0}'.\n".format( - wot_identifier)) - - assert len(matches) == 1 - - # id_num is first member of value tuple. - only_key = matches.keys()[0] - id_num = matches[only_key][0] - - return read_local_identity(response, id_num) - - -def resolve_identity(ui, truster, wot_identifier): - """ - If using LCWoT, either the nickname prefix should be enough to be - unambiguous, or failing that enough of the key. - If using WoT, partial search is not supported, and the entire key must be - specified. - - Returns a dictionary of the nickname, request URI, - and identity that matches the given criteria. - In the case of an error prints a message and returns None. - - :param ui: Mercurial ui for error messages. - :param truster: Check trust list of this local identity. - :param wot_identifier: Nickname and key, delimited by @. Either half can be - omitted. - """ - nickname_prefix, key_prefix = parse_name(wot_identifier) - # TODO: Support different FCP IP / port. - node = fcp.FCPNode() - - # Test for GetIdentitiesByPartialNickname support. currently LCWoT-only. - # src/main/java/plugins/WebOfTrust/fcp/GetIdentitiesByPartialNickname.java - # TODO: LCWoT allows limiting by context, but how to make sure otherwise? - # TODO: Should this manually ensure an identity has a vcs context - # otherwise? - - # LCWoT can have * to allow a wildcard match, but a wildcard alone is not - # allowed. See Lucine Term Modifiers documentation. The nickname uses - # this syntax but the ID is inherently startswith(). - params = {'Message': 'GetIdentitiesByPartialNickname', - 'Truster': truster, - 'PartialNickname': - nickname_prefix + '*' if nickname_prefix else '', - 'PartialID': key_prefix, - 'MaxIdentities': 2, - 'Context': 'vcs'} - - response = \ - node.fcpPluginMessage(async=False, - plugin_name="plugins.WebOfTrust.WebOfTrust", - plugin_params=params)[0] - - if response['header'] != 'FCPPluginReply' or \ - 'Replies.Message' not in response: - raise util.Abort('Unexpected reply. Got {0}\n'.format(response)) - elif response['Replies.Message'] == 'Identities': - matches = response['Replies.IdentitiesMatched'] - if matches == 0: - raise util.Abort("No identities match '{0}'\n".format( - wot_identifier)) - elif matches == 1: - return read_identity(response, 0) - else: - raise util.Abort("'{0}' is ambiguous.\n".format(wot_identifier)) - - # Partial matching not supported, or unknown truster. The only difference - # in the errors is human-readable, so just try the exact match. - assert response['Replies.Message'] == 'Error' - - # key_prefix must be a complete key for the lookup to succeed. - params = {'Message': 'GetIdentity', - 'Truster': truster, - 'Identity': key_prefix} - response = \ - node.fcpPluginMessage(async=False, - plugin_name="plugins.WebOfTrust.WebOfTrust", - plugin_params=params)[0] - - if response['Replies.Message'] == 'Error': - # Searching by exact public key hash, not matching. - raise util.Abort("No such identity '{0}'.\n".format(wot_identifier)) - - # There should be only one result. - # Depends on https://bugs.freenetproject.org/view.php?id=5729 - return read_identity(response, 0) - - -def read_local_identity(message, id_num): - """ - Reads an FCP response from a WoT plugin describing a local identity and - returns a dictionary of Nickname, InsertURI, RequestURI, Identity, and - each numbered Context. - """ - result = read_identity(message, id_num) - result['InsertURI'] = message['Replies.InsertURI{0}'.format(id_num)] - return result - - -def read_identity(message, id_num): - """ - Reads an FCP response from a WoT plugin describing an identity and - returns a dictionary of Nickname, RequestURI, Identity, and Contexts. - """ - # Return properties for the selected identity. (by number) - result = {} - for item in ['Nickname', 'RequestURI', 'Identity']: - result[item] = message['Replies.{0}{1}'.format(item, id_num)] - - # LCWoT also puts these things as properties, which would be nicer to - # depend on and would allow just returning all properties for the identity. - #property_prefix = "Replies.Properties{0}".format(id_num) - - # Add contexts and other properties. - # TODO: Unflattening WoT response? Several places check for prefix like - # this. - context_prefix = "Replies.Contexts{0}.Context".format(id_num) - property_prefix = "Replies.Properties{0}.Property".format(id_num) - for key in message.iterkeys(): - if key.startswith(context_prefix): - num = key[len(context_prefix):] - result["Context{0}".format(num)] = message[key] - elif key.startswith(property_prefix) and key.endswith(".Name"): - # ".Name" is 5 characters, before which is the number. - num = key[len(property_prefix):-5] - - # Example: - # Replies.Properties1.Property1.Name = IntroductionPuzzleCount - # Replies.Properties1.Property1.Value = 10 - name = message[key] - value = message[property_prefix + num + '.Value'] - - # LCWoT returns many things with duplicates in properties, - # so this conflict is something that can happen. Checking for - # value conflict restricts the message to cases where it actually - # has an effect. - if name in result and value != result[name]: - print("WARNING: '{0}' has a different value as a property." - .format(name)) - - result[name] = value - - return result - - -def parse_name(wot_identifier): - """ - Parse identifier of the forms: nick - nick@key - @key - Return nick, key. If a part is not given return an empty string for it. - """ - split = wot_identifier.split('@', 1) - nickname_prefix = split[0] - - key_prefix = '' - if len(split) == 2: - key_prefix = split[1] - - return nickname_prefix, key_prefix - - -def to_freemail_address(identity): - """ - Return a Freemail address to contact the given identity if it has a - Freemail context. - Raise util.Abort if it does not have a Freemail context. - """ - - # Freemail addresses encode the public key hash with base32 instead of - # base64 as WoT does. This is to be case insensitive because email - # addresses are not case sensitive, so some clients may mangle case. - # See https://github.com/zidel/Freemail/blob/v0.2.2.1/docs/spec/spec.tex#L32 - - for item in identity.iteritems(): - if item[1] == 'Freemail' and item[0].startswith('Context'): - re_encode = b32encode(base64decode(identity['Identity'])) - # Remove trailing '=' padding. - re_encode = re_encode.rstrip('=') - - # Freemail addresses are lower case. - return string.lower(identity['Nickname'] + '@' + re_encode + - '.freemail') - - raise util.Abort("{0}@{1} is not using Freemail.\n".format( - identity['Nickname'], identity['Identity'])) diff --git a/infocalypse/wot_id.py b/infocalypse/wot_id.py new file mode 100644 --- /dev/null +++ b/infocalypse/wot_id.py @@ -0,0 +1,247 @@ +import fcp +from mercurial import util +import string +from keys import USK +from base64 import b32encode +from fcp.node import base64decode + + +class WoT_ID(object): + """ + Represents a WoT ID. + + TODO: Is this list appropriate to have? + * nickname - str + * request_uri - USK + * identity_id - str + * contexts - list + * properties - dict + * freemail_address - str + """ + + def __init__(self, wot_identifier, truster, id_num=0, message=None): + """ + If using LCWoT, either the nickname prefix should be enough to be + unambiguous, or failing that enough of the key. + If using WoT, partial search is not supported, and the entire key must + be specified. + + :type truster: Local_WoT_ID + :type wot_identifier: str + :param truster: Check trust list of this local identity. + :param wot_identifier: Nickname and key, delimited by @. Either half can + be omitted. + """ + # id_num and message are internal and used to allow constructing + # a WoT_ID for a Local_WoT_ID. + if not message: + message = get_identity(wot_identifier, truster) + + def get_attribute(attribute): + return message['Replies.{0}{1}'.format(attribute, id_num)] + + self.nickname = get_attribute('Nickname') + self.request_uri = USK(get_attribute('RequestURI')) + self.identity_id = get_attribute('Identity') + + # Add contexts and other properties. + # TODO: Unflattening WoT response? Several places check for prefix like + # this. + self.contexts = [] + self.properties = {} + context_prefix = "Replies.Contexts{0}.Context".format(id_num) + property_prefix = "Replies.Properties{0}.Property".format(id_num) + for key in message.iterkeys(): + if key.startswith(context_prefix): + #num = key[len(context_prefix):] + # TODO: Is there a reason to keep the numbers? (As in key it.) + #identity.contexts["Context{0}".format(num)] = message[key] + self.contexts.append(message[key]) + elif key.startswith(property_prefix) and key.endswith(".Name"): + # ".Name" is 5 characters, before which is the number. + num = key[len(property_prefix):-5] + + # Example: + # Replies.Properties1.Property1.Name = IntroductionPuzzleCount + # Replies.Properties1.Property1.Value = 10 + name = message[key] + value = message[property_prefix + num + '.Value'] + + # LCWoT returns many things with duplicates in properties, + # so this conflict is something that can happen. Checking for + # value conflict restricts the message to cases where it + # actually has an effect. + if name in self.properties and value != self.properties[name]: + print("WARNING: '{0}' has conflicting value as a property." + .format(name)) + + self.properties[name] = value + + # Freemail addresses encode the public key hash with base32 instead of + # base64 as WoT does. This is to be case insensitive because email + # addresses are not case sensitive, so some clients may mangle case. + # See: + # https://github.com/zidel/Freemail/blob/v0.2.2.1/docs/spec/spec.tex#L32 + + if not 'Freemail' in self.contexts: + self.freemail_address = None + else: + re_encode = b32encode(base64decode(self.identity_id)) + # Remove trailing '=' padding. + re_encode = re_encode.rstrip('=') + + # Freemail addresses are lower case. + self.freemail_address = string.lower(self.nickname + '@' + re_encode + + '.freemail') + + def __str__(self): + return self.nickname + '@' + self.identity_id + + +class Local_WoT_ID(WoT_ID): + """ + Represents a local WoT ID. + + * nickname - str + * request_uri - USK + * insert_uri - USK + * identity_id - str + * contexts - list + * properties - dict + """ + + def __init__(self, wot_identifier): + nickname_prefix, key_prefix = parse_name(wot_identifier) + + node = fcp.FCPNode() + response = \ + node.fcpPluginMessage(async=False, + plugin_name="plugins.WebOfTrust.WebOfTrust", + plugin_params={'Message': + 'GetOwnIdentities'})[0] + + if response['header'] != 'FCPPluginReply' or \ + 'Replies.Message' not in response or \ + response['Replies.Message'] != 'OwnIdentities': + raise util.Abort("Unexpected reply. Got {0}\n.".format(response)) + + # Find nicknames starting with the supplied nickname prefix. + prefix = 'Replies.Nickname' + # Key: nickname, value (id_num, public key hash). + matches = {} + for key in response.iterkeys(): + if key.startswith(prefix) and \ + response[key].startswith(nickname_prefix): + + # Key is Replies.Nickname<number>, where number is used in + # the other attributes returned for that identity. + id_num = key[len(prefix):] + + nickname = response[key] + pubkey_hash = response['Replies.Identity{0}'.format(id_num)] + + matches[nickname] = (id_num, pubkey_hash) + + # Remove matching nicknames not also matching the (possibly partial) + # public key hash. + for key in matches.keys(): + # public key hash is second member of value tuple. + if not matches[key][1].startswith(key_prefix): + del matches[key] + + if len(matches) > 1: + raise util.Abort("'{0}' is ambiguous.\n".format(wot_identifier)) + + if len(matches) == 0: + raise util.Abort("No local identities match '{0}'.\n".format( + wot_identifier)) + + assert len(matches) == 1 + + # id_num is first member of value tuple. + only_key = matches.keys()[0] + id_num = matches[only_key][0] + + self.insert_uri = USK(response['Replies.InsertURI{0}'.format(id_num)]) + + WoT_ID.__init__(self, None, None, id_num=id_num, message=response) + + +def get_identity(wot_identifier, truster): + nickname_prefix, key_prefix = parse_name(wot_identifier) + # TODO: Support different FCP IP / port. + node = fcp.FCPNode() + + # Test for GetIdentitiesByPartialNickname support. currently LCWoT-only. + # src/main/java/plugins/WebOfTrust/fcp/GetIdentitiesByPartialNickname + # TODO: LCWoT allows limiting by context; how to make sure otherwise? + # TODO: Should this manually ensure an identity has a vcs context + # otherwise? + + # LCWoT can have * to allow a wildcard match, but a wildcard alone is + # not allowed. See Lucine Term Modifiers documentation. The nickname + # uses this syntax but the ID is inherently startswith(). + params = {'Message': 'GetIdentitiesByPartialNickname', + 'Truster': truster.identity_id, + 'PartialNickname': + nickname_prefix + '*' if nickname_prefix else '', + 'PartialID': key_prefix, + 'MaxIdentities': 2, + 'Context': 'vcs'} + + response = \ + node.fcpPluginMessage(async=False, + plugin_name="plugins.WebOfTrust.WebOfTrust", + plugin_params=params)[0] + + if response['header'] != 'FCPPluginReply' or \ + 'Replies.Message' not in response: + raise util.Abort('Unexpected reply. Got {0}\n'.format(response)) + elif response['Replies.Message'] == 'Identities': + matches = response['Replies.IdentitiesMatched'] + if matches == 0: + raise util.Abort("No identities match '{0}'\n".format( + wot_identifier)) + elif matches == 1: + return response + else: + raise util.Abort("'{0}' is ambiguous.\n".format(wot_identifier)) + + # Partial matching not supported, or unknown truster. The only + # difference in the errors is human-readable, so just try the exact + # match. + assert response['Replies.Message'] == 'Error' + + # key_prefix must be a complete key for the lookup to succeed. + params = {'Message': 'GetIdentity', + 'Truster': truster, + 'Identity': key_prefix} + response = \ + node.fcpPluginMessage(async=False, + plugin_name="plugins.WebOfTrust.WebOfTrust", + plugin_params=params)[0] + + if response['Replies.Message'] == 'Error': + # Searching by exact public key hash, not matching. + raise util.Abort("No such identity '{0}'.\n".format(wot_identifier)) + + # There should be only one result. + # Depends on https://bugs.freenetproject.org/view.php?id=5729 + return response + + +def parse_name(wot_identifier): + """ + Parse identifier of the forms: nick + nick@key + @key + Return nick, key. If a part is not given return an empty string for it. + """ + split = wot_identifier.split('@', 1) + nickname_prefix = split[0] + + key_prefix = '' + if len(split) == 2: + key_prefix = split[1] + + return nickname_prefix, key_prefix