diff --git a/desktop_client.py b/desktop_client.py index 9958ff934..cf2ac8e5c 100644 --- a/desktop_client.py +++ b/desktop_client.py @@ -35,6 +35,7 @@ from announce import sendAnnounceViaServer from pgp import pgpDecrypt from pgp import hasLocalPGPkey from pgp import pgpEncryptToActor +from pgp import pgpPublicKeyUpload def _desktopHelp() -> None: @@ -850,8 +851,21 @@ def runDesktopClient(baseDir: str, proxyType: str, httpPrefix: str, newRepliesExist = False newDMsExist = False currPostId = '' + pgpKeyUpload = False while (1): session = createSession(proxyType) + + if not pgpKeyUpload: + pgpKey = \ + pgpPublicKeyUpload(baseDir, session, + nickname, password, + domain, port, httpPrefix, + cachedWebfingers, personCache, + debug, False) + if pgpKey: + print('PGP public key uploaded') + pgpKeyUpload = True + notifyJson = None speakerJson = \ getSpeakerFromServer(baseDir, session, nickname, password, diff --git a/epicyon.py b/epicyon.py index b43388621..0b51d50f5 100644 --- a/epicyon.py +++ b/epicyon.py @@ -48,6 +48,7 @@ from follow import sendUnfollowRequestViaServer from tests import testPostMessageBetweenServers from tests import testFollowBetweenServers from tests import testClientToServer +from tests import testUpdateActor from tests import runAllTests from auth import storeBasicCredentials from auth import createPassword @@ -531,6 +532,7 @@ if args.testsnetwork: testPostMessageBetweenServers() testFollowBetweenServers() testClientToServer() + testUpdateActor() print('All tests succeeded') sys.exit() @@ -2121,7 +2123,7 @@ if args.testdata: testFollowersOnly = False testSaveToFile = True - testClientToServer = False + testC2S = False testCommentsEnabled = True testAttachImageFilename = None testMediaType = None @@ -2131,7 +2133,7 @@ if args.testdata: "like this is totally just a #test man", testFollowersOnly, testSaveToFile, - testClientToServer, + testC2S, testCommentsEnabled, testAttachImageFilename, testMediaType, testImageDescription) @@ -2139,7 +2141,7 @@ if args.testdata: "Zoiks!!!", testFollowersOnly, testSaveToFile, - testClientToServer, + testC2S, testCommentsEnabled, testAttachImageFilename, testMediaType, testImageDescription) @@ -2147,7 +2149,7 @@ if args.testdata: "Hey scoob we need like a hundred more #milkshakes", testFollowersOnly, testSaveToFile, - testClientToServer, + testC2S, testCommentsEnabled, testAttachImageFilename, testMediaType, testImageDescription) @@ -2155,7 +2157,7 @@ if args.testdata: "Getting kinda spooky around here", testFollowersOnly, testSaveToFile, - testClientToServer, + testC2S, testCommentsEnabled, testAttachImageFilename, testMediaType, testImageDescription, @@ -2165,7 +2167,7 @@ if args.testdata: "if it wasn't for those pesky hackers", testFollowersOnly, testSaveToFile, - testClientToServer, + testC2S, testCommentsEnabled, 'img/logo.png', 'image/png', 'Description of image') @@ -2173,7 +2175,7 @@ if args.testdata: "man these centralized sites are like the worst!", testFollowersOnly, testSaveToFile, - testClientToServer, + testC2S, testCommentsEnabled, testAttachImageFilename, testMediaType, testImageDescription) @@ -2181,7 +2183,7 @@ if args.testdata: "another mystery solved #test", testFollowersOnly, testSaveToFile, - testClientToServer, + testC2S, testCommentsEnabled, testAttachImageFilename, testMediaType, testImageDescription) @@ -2189,7 +2191,7 @@ if args.testdata: "let's go bowling", testFollowersOnly, testSaveToFile, - testClientToServer, + testC2S, testCommentsEnabled, testAttachImageFilename, testMediaType, testImageDescription) diff --git a/follow.py b/follow.py index 6404eda2a..6da40f642 100644 --- a/follow.py +++ b/follow.py @@ -1028,7 +1028,7 @@ def sendFollowRequestViaServer(baseDir: str, session, postJson(session, newFollowJson, [], inboxUrl, headers, 30, True) if not postResult: if debug: - print('DEBUG: POST announce failed for c2s to ' + inboxUrl) + print('DEBUG: POST follow failed for c2s to ' + inboxUrl) return 5 if debug: diff --git a/outbox.py b/outbox.py index 7726cf6fb..038e069eb 100644 --- a/outbox.py +++ b/outbox.py @@ -21,6 +21,8 @@ from utils import removeIdEnding from utils import getDomainFromActor from utils import dangerousMarkup from utils import isFeaturedWriter +from utils import loadJson +from utils import saveJson from blocking import isBlockedDomain from blocking import outboxBlock from blocking import outboxUndoBlock @@ -42,6 +44,119 @@ from shares import outboxShareUpload from shares import outboxUndoShareUpload +def _outboxPersonReceiveUpdate(recentPostsCache: {}, + baseDir: str, httpPrefix: str, + nickname: str, domain: str, port: int, + messageJson: {}, debug: bool) -> None: + """ Receive an actor update from c2s + For example, setting the PGP key from the desktop client + """ + # these attachments are updatable via c2s + updatableAttachments = ('PGP', 'OpenPGP', 'Email') + + if not messageJson.get('type'): + return + print("messageJson['type'] " + messageJson['type']) + if messageJson['type'] != 'Update': + return + if not messageJson.get('object'): + return + if not isinstance(messageJson['object'], dict): + if debug: + print('DEBUG: c2s actor update object is not dict') + return + if not messageJson['object'].get('type'): + if debug: + print('DEBUG: c2s actor update - no type') + return + if messageJson['object']['type'] != 'Person': + if debug: + print('DEBUG: not a c2s actor update') + return + if not messageJson.get('to'): + if debug: + print('DEBUG: c2s actor update has no "to" field') + return + if not messageJson.get('actor'): + if debug: + print('DEBUG: c2s actor update has no actor field') + return + if not messageJson.get('id'): + if debug: + print('DEBUG: c2s actor update has no id field') + return + actor = \ + httpPrefix + '://' + getFullDomain(domain, port) + '/users/' + nickname + if len(messageJson['to']) != 1: + if debug: + print('DEBUG: c2s actor update - to does not contain one actor ' + + messageJson['to']) + return + if messageJson['to'][0] != actor: + if debug: + print('DEBUG: c2s actor update - to does not contain actor ' + + messageJson['to'] + ' ' + actor) + return + if not messageJson['id'].startswith(actor + '#updates/'): + if debug: + print('DEBUG: c2s actor update - unexpected id ' + + messageJson['id']) + return + updatedActorJson = messageJson['object'] + # load actor from file + actorFilename = baseDir + '/accounts/' + nickname + '@' + domain + '.json' + if not os.path.isfile(actorFilename): + print('actorFilename not found: ' + actorFilename) + return + actorJson = loadJson(actorFilename) + if not actorJson: + return + actorChanged = False + # update fields within actor + if 'attachment' in updatedActorJson: + for newPropertyValue in updatedActorJson['attachment']: + if not newPropertyValue.get('name'): + continue + if newPropertyValue['name'] not in updatableAttachments: + continue + if not newPropertyValue.get('type'): + continue + if not newPropertyValue.get('value'): + continue + if newPropertyValue['type'] != 'PropertyValue': + continue + if 'attachment' in actorJson: + found = False + for propertyValue in actorJson['attachment']: + if propertyValue != 'PropertyValue': + continue + if propertyValue['name'] == newPropertyValue['name']: + if propertyValue['value'] != \ + newPropertyValue['value']: + propertyValue['value'] = \ + newPropertyValue['value'] + actorChanged = True + found = True + break + if not found: + actorJson['attachment'].append({ + "name": newPropertyValue['name'], + "type": "PropertyValue", + "value": newPropertyValue['value'] + }) + actorChanged = True + # save actor to file + if actorChanged: + saveJson(actorJson, actorFilename) + if debug: + print('actor saved: ' + actorFilename) + if debug: + print('New attachment: ' + str(actorJson['attachment'])) + messageJson['object'] = actorJson + if debug: + print('DEBUG: actor update via c2s - ' + nickname + '@' + domain) + + def postMessageToOutbox(session, translate: {}, messageJson: {}, postToNickname: str, server, baseDir: str, httpPrefix: str, @@ -408,6 +523,13 @@ def postMessageToOutbox(session, translate: {}, postToNickname, domain, port, messageJson, debug) + if debug: + print('DEBUG: handle actor updates from c2s') + _outboxPersonReceiveUpdate(recentPostsCache, + baseDir, httpPrefix, + postToNickname, domain, port, + messageJson, debug) + if debug: print('DEBUG: sending c2s post to named addresses') if messageJson.get('to'): diff --git a/person.py b/person.py index 2d6be5a4d..a9b855423 100644 --- a/person.py +++ b/person.py @@ -1106,6 +1106,8 @@ def getActorJson(handle: str, http: bool, gnunet: bool, debug: bool, quiet=False) -> {}: """Returns the actor json """ + if debug: + print('getActorJson for ' + handle) originalActor = handle if '/@' in handle or \ '/users/' in handle or \ @@ -1117,8 +1119,8 @@ def getActorJson(handle: str, http: bool, gnunet: bool, handle = handle.replace(prefix, '') handle = handle.replace('/@', '/users/') if not hasUsersPath(handle): - if not quiet: - print('Expected actor format: ' + + if not quiet or debug: + print('getActorJson: Expected actor format: ' + 'https://domain/@nick or https://domain/users/nick') return None if '/users/' in handle: @@ -1145,13 +1147,13 @@ def getActorJson(handle: str, http: bool, gnunet: bool, # format: @nick@domain if '@' not in handle: if not quiet: - print('Syntax: --actor nickname@domain') + print('getActorJson Syntax: --actor nickname@domain') return None if handle.startswith('@'): handle = handle[1:] if '@' not in handle: if not quiet: - print('Syntax: --actor nickname@domain') + print('getActorJsonSyntax: --actor nickname@domain') return None nickname = handle.split('@')[0] domain = handle.split('@')[1] @@ -1168,7 +1170,10 @@ def getActorJson(handle: str, http: bool, gnunet: bool, httpPrefix = 'gnunet' proxyType = 'gnunet' else: - httpPrefix = 'https' + if '127.0.' not in domain and '192.168.' not in domain: + httpPrefix = 'https' + else: + httpPrefix = 'http' session = createSession(proxyType) if nickname == 'inbox': nickname = domain @@ -1179,12 +1184,12 @@ def getActorJson(handle: str, http: bool, gnunet: bool, None, __version__, debug) if not wfRequest: if not quiet: - print('Unable to webfinger ' + handle) + print('getActorJson Unable to webfinger ' + handle) return None if not isinstance(wfRequest, dict): if not quiet: - print('Webfinger for ' + handle + ' did not return a dict. ' + - str(wfRequest)) + print('getActorJson Webfinger for ' + handle + + ' did not return a dict. ' + str(wfRequest)) return None if not quiet: @@ -1192,11 +1197,13 @@ def getActorJson(handle: str, http: bool, gnunet: bool, personUrl = None if wfRequest.get('errors'): - if not quiet: - print('wfRequest error: ' + str(wfRequest['errors'])) + if not quiet or debug: + print('getActorJson wfRequest error: ' + str(wfRequest['errors'])) if hasUsersPath(handle): personUrl = originalActor else: + if debug: + print('No users path in ' + handle) return None profileStr = 'https://www.w3.org/ns/activitystreams' @@ -1228,8 +1235,9 @@ def getActorJson(handle: str, http: bool, gnunet: bool, getJson(session, personUrl, asHeader, None, debug, __version__, httpPrefix, None, 20, quiet) if personJson: - if not quiet: + if not quiet or debug: pprint(personJson) + return personJson else: profileStr = 'https://www.w3.org/ns/activitystreams' asHeader = { @@ -1238,8 +1246,9 @@ def getActorJson(handle: str, http: bool, gnunet: bool, personJson = \ getJson(session, personUrl, asHeader, None, debug, __version__, httpPrefix, None) - if not quiet: + if not quiet or debug: if personJson: + print('getActorJson returned actor') pprint(personJson) else: print('Failed to get ' + personUrl) diff --git a/pgp.py b/pgp.py index 5724127a4..f369727c2 100644 --- a/pgp.py +++ b/pgp.py @@ -7,11 +7,18 @@ __email__ = "bob@freedombone.net" __status__ = "Production" import os +import time import subprocess from pathlib import Path from person import getActorJson from utils import containsPGPPublicKey from utils import isPGPEncrypted +from utils import getFullDomain +from utils import getStatusNumber +from webfinger import webfingerHandle +from posts import getPersonBox +from auth import createBasicAuthHeader +from session import postJson def getEmailAddress(actorJson: {}) -> str: @@ -395,3 +402,201 @@ def pgpDecrypt(content: str, fromHandle: str) -> str: return content decryptResult = decryptResult.decode('utf-8').strip() return decryptResult + + +def _pgpLocalPublicKeyId() -> str: + """Gets the local pgp public key ID + """ + cmdStr = \ + "gpgconf --list-options gpg | " + \ + "awk -F: '$1 == \"default-key\" {print $10}'" + proc = subprocess.Popen([cmdStr], + stdout=subprocess.PIPE, shell=True) + (result, err) = proc.communicate() + if err: + return None + if not result: + return None + if len(result) < 5: + return None + return result.replace('"', '').strip() + + +def _pgpLocalPublicKey() -> str: + """Gets the local pgp public key + """ + keyId = _pgpLocalPublicKey() + if not keyId: + return None + cmdStr = "gpg --armor --export " + keyId + proc = subprocess.Popen([cmdStr], + stdout=subprocess.PIPE, shell=True) + (result, err) = proc.communicate() + if err: + return None + if not result: + return None + return extractPGPPublicKey(result) + + +def pgpPublicKeyUpload(baseDir: str, session, + nickname: str, password: str, + domain: str, port: int, + httpPrefix: str, + cachedWebfingers: {}, personCache: {}, + debug: bool, test: str) -> {}: + if debug: + print('pgpPublicKeyUpload') + + if not session: + if debug: + print('WARN: No session for pgpPublicKeyUpload') + return None + + if not test: + if debug: + print('Getting PGP public key') + PGPpubKey = _pgpLocalPublicKey() + if not PGPpubKey: + return None + PGPpubKeyId = _pgpLocalPublicKeyId() + else: + if debug: + print('Testing with PGP public key ' + test) + PGPpubKey = test + PGPpubKeyId = None + + domainFull = getFullDomain(domain, port) + if debug: + print('PGP test domain: ' + domainFull) + + handle = nickname + '@' + domainFull + + if debug: + print('Getting actor for ' + handle) + + actorJson = getActorJson(handle, False, False, True) + if not actorJson: + if debug: + print('No actor returned for ' + handle) + return None + + if debug: + print('Actor for ' + handle + ' obtained') + + actor = httpPrefix + '://' + domainFull + '/users/' + nickname + handle = actor.replace('/users/', '/@') + + # check that this looks like the correct actor + if not actorJson.get('id'): + if debug: + print('Actor has no id') + return None + if not actorJson.get('url'): + if debug: + print('Actor has no url') + return None + if not actorJson.get('type'): + if debug: + print('Actor has no type') + return None + if actorJson['id'] != actor: + if debug: + print('Actor id is not ' + actor + + ' instead is ' + actorJson['id']) + return None + if actorJson['url'] != handle: + if debug: + print('Actor url is not ' + handle) + return None + if actorJson['type'] != 'Person': + if debug: + print('Actor type is not Person') + return None + + # set the pgp details + if PGPpubKeyId: + setPGPfingerprint(actorJson, PGPpubKeyId) + else: + if debug: + print('No PGP key Id. Continuing anyway.') + + if debug: + print('Setting PGP key within ' + actor) + setPGPpubKey(actorJson, PGPpubKey) + + # create an actor update + statusNumber, published = getStatusNumber() + actorUpdate = { + '@context': 'https://www.w3.org/ns/activitystreams', + 'id': actor + '#updates/' + statusNumber, + 'type': 'Update', + 'actor': actor, + 'to': [actor], + 'cc': [], + 'object': actorJson + } + if debug: + print('actor update is ' + str(actorUpdate)) + + # lookup the inbox for the To handle + wfRequest = \ + webfingerHandle(session, handle, httpPrefix, cachedWebfingers, + domain, __version__, debug) + if not wfRequest: + if debug: + print('DEBUG: pgp actor update webfinger failed for ' + + handle) + return None + if not isinstance(wfRequest, dict): + if debug: + print('WARN: Webfinger for ' + handle + + ' did not return a dict. ' + str(wfRequest)) + return None + + postToBox = 'outbox' + + # get the actor inbox for the To handle + (inboxUrl, pubKeyId, pubKey, + fromPersonId, sharedInbox, avatarUrl, + displayName) = getPersonBox(baseDir, session, wfRequest, personCache, + __version__, httpPrefix, nickname, + domain, postToBox, 52025) + + if not inboxUrl: + if debug: + print('DEBUG: No ' + postToBox + ' was found for ' + handle) + return None + if not fromPersonId: + if debug: + print('DEBUG: No actor was found for ' + handle) + return None + + authHeader = createBasicAuthHeader(nickname, password) + + headers = { + 'host': domain, + 'Content-type': 'application/json', + 'Authorization': authHeader + } + tries = 0 + quiet = not debug + while tries < 4: + postResult = \ + postJson(session, actorUpdate, [], inboxUrl, + headers, 30, quiet) + if postResult: + break + time.sleep(2) + tries += 1 + + if postResult is None: + if debug: + print('DEBUG: POST pgp actor update failed for c2s to ' + + inboxUrl) + return None + + if debug: + print('DEBUG: c2s POST pgp actor update success') + + return actorUpdate diff --git a/tests.py b/tests.py index a3ba76b63..6a232dc71 100644 --- a/tests.py +++ b/tests.py @@ -53,6 +53,7 @@ from utils import getFollowersOfPerson from utils import removeHtml from utils import dangerousMarkup from pgp import extractPGPPublicKey +from pgp import pgpPublicKeyUpload from utils import containsPGPPublicKey from follow import followerOfPerson from follow import unfollowAccount @@ -1574,7 +1575,7 @@ def testClientToServer(): sessionAlice = createSession(proxyType) followersOnly = False - attachedImageFilename = baseDir+'/img/logo.png' + attachedImageFilename = baseDir + '/img/logo.png' mediaType = getAttachmentMediaType(attachedImageFilename) attachedImageDescription = 'Logo' isArticle = False @@ -3447,6 +3448,122 @@ def testExtractPGPPublicKey(): assert result == pubKey +def testUpdateActor(): + print('Testing update of actor properties') + + global testServerAliceRunning + testServerAliceRunning = False + + httpPrefix = 'http' + proxyType = None + federationList = [] + + baseDir = os.getcwd() + if os.path.isdir(baseDir + '/.tests'): + shutil.rmtree(baseDir + '/.tests') + os.mkdir(baseDir + '/.tests') + + # create the server + aliceDir = baseDir + '/.tests/alice' + aliceDomain = '127.0.0.11' + alicePort = 61792 + aliceSendThreads = [] + bobAddress = '127.0.0.84:6384' + + global thrAlice + if thrAlice: + while thrAlice.is_alive(): + thrAlice.stop() + time.sleep(1) + thrAlice.kill() + + thrAlice = \ + threadWithTrace(target=createServerAlice, + args=(aliceDir, aliceDomain, alicePort, bobAddress, + federationList, False, False, + aliceSendThreads), + daemon=True) + + thrAlice.start() + assert thrAlice.is_alive() is True + + # wait for server to be running + ctr = 0 + while not testServerAliceRunning: + time.sleep(1) + ctr += 1 + if ctr > 60: + break + print('Alice online: ' + str(testServerAliceRunning)) + + print('\n\n*******************************************************') + print('Alice updates her PGP key') + + sessionAlice = createSession(proxyType) + cachedWebfingers = {} + personCache = {} + password = 'alicepass' + outboxPath = aliceDir + '/accounts/alice@' + aliceDomain + '/outbox' + actorFilename = aliceDir + '/accounts/' + 'alice@' + aliceDomain + '.json' + assert os.path.isfile(actorFilename) + assert len([name for name in os.listdir(outboxPath) + if os.path.isfile(os.path.join(outboxPath, name))]) == 0 + pubKey = \ + '-----BEGIN PGP PUBLIC KEY BLOCK-----\n\n' + \ + 'mDMEWZBueBYJKwYBBAHaRw8BAQdAKx1t6wL0RTuU6/' + \ + 'IBjngMbVJJ3Wg/3UW73/PV\n' + \ + 'I47xKTS0IUJvYiBNb3R0cmFtIDxib2JAZnJlZWRvb' + \ + 'WJvbmUubmV0PoiQBBMWCAA4\n' + \ + 'FiEEmruCwAq/OfgmgEh9zCU2GR+nwz8FAlmQbngCG' + \ + 'wMFCwkIBwMFFQoJCAsFFgID\n' + \ + 'AQACHgECF4AACgkQzCU2GR+nwz/9sAD/YgsHnVszH' + \ + 'Nz1zlVc5EgY1ByDupiJpHj0\n' + \ + 'XsLYk3AbNRgBALn45RqgD4eWHpmOriH09H5Rc5V9i' + \ + 'N4+OiGUn2AzJ6oHuDgEWZBu\n' + \ + 'eBIKKwYBBAGXVQEFAQEHQPRBG2ZQJce475S3e0Dxe' + \ + 'b0Fz5WdEu2q3GYLo4QG+4Ry\n' + \ + 'AwEIB4h4BBgWCAAgFiEEmruCwAq/OfgmgEh9zCU2G' + \ + 'R+nwz8FAlmQbngCGwwACgkQ\n' + \ + 'zCU2GR+nwz+OswD+JOoyBku9FzuWoVoOevU2HH+bP' + \ + 'OMDgY2OLnST9ZSyHkMBAMcK\n' + \ + 'fnaZ2Wi050483Sj2RmQRpb99Dod7rVZTDtCqXk0J\n' + \ + '=gv5G\n' + \ + '-----END PGP PUBLIC KEY BLOCK-----' + actorUpdate = \ + pgpPublicKeyUpload(aliceDir, sessionAlice, + 'alice', password, + aliceDomain, alicePort, + httpPrefix, + cachedWebfingers, personCache, + True, pubKey) + print('actor update result: ' + str(actorUpdate)) + assert actorUpdate + + # load alice actor + print('Loading actor: ' + actorFilename) + actorJson = loadJson(actorFilename) + assert actorJson + if len(actorJson['attachment']) == 0: + print("actorJson['attachment'] has no contents") + assert len(actorJson['attachment']) > 0 + propertyFound = False + for propertyValue in actorJson['attachment']: + if propertyValue['name'] == 'PGP': + print('PGP property set within attachment') + assert pubKey in propertyValue['value'] + propertyFound = True + assert propertyFound + + # stop the server + thrAlice.kill() + thrAlice.join() + assert thrAlice.is_alive() is False + + os.chdir(baseDir) + if os.path.isdir(baseDir + '/.tests'): + shutil.rmtree(baseDir + '/.tests') + + def runAllTests(): print('Running tests...') testFunctions()