epicyon/webfinger.py

363 lines
12 KiB
Python
Raw Normal View History

2020-04-04 14:14:25 +00:00
__filename__ = "webfinger.py"
__author__ = "Bob Mottram"
__license__ = "AGPL3+"
__version__ = "1.1.0"
__maintainer__ = "Bob Mottram"
__email__ = "bob@freedombone.net"
__status__ = "Production"
2019-06-28 18:55:29 +00:00
import base64
2020-03-04 09:59:08 +00:00
try:
from Cryptodome.PublicKey import RSA
from Cryptodome.Util import number
except ImportError:
from Crypto.PublicKey import RSA
from Crypto.Util import number
2019-06-28 18:55:29 +00:00
import os
2020-04-15 11:10:30 +00:00
import urllib.parse
2019-06-28 18:55:29 +00:00
from session import getJson
2019-06-30 15:03:26 +00:00
from cache import storeWebfingerInCache
from cache import getWebfingerFromCache
2019-10-22 11:55:06 +00:00
from utils import loadJson
2020-03-02 14:35:44 +00:00
from utils import loadJsonOnionify
2019-10-22 11:55:06 +00:00
from utils import saveJson
2019-06-28 18:55:29 +00:00
2020-04-04 14:14:25 +00:00
def parseHandle(handle: str) -> (str, str):
2019-06-28 18:55:29 +00:00
if '.' not in handle:
2020-04-04 14:14:25 +00:00
return None, None
2020-06-11 12:16:45 +00:00
prefixes = ('https://', 'http://', 'dat://', 'i2p://', 'gnunet://',
'hyper://', 'gemini://', 'gopher://')
handleStr = handle
for prefix in prefixes:
handleStr = handleStr.replace(prefix, '')
2019-06-28 18:55:29 +00:00
if '/@' in handle:
2020-04-04 14:14:25 +00:00
domain, nickname = handleStr.split('/@')
2019-06-28 18:55:29 +00:00
else:
if '/users/' in handle:
2020-04-04 14:14:25 +00:00
domain, nickname = handleStr.split('/users/')
2019-06-28 18:55:29 +00:00
else:
if '@' in handle:
2020-04-04 14:14:25 +00:00
nickname, domain = handle.split('@')
2019-06-28 18:55:29 +00:00
else:
2020-04-04 14:14:25 +00:00
return None, None
return nickname, domain
2019-06-28 18:55:29 +00:00
2020-04-04 14:14:25 +00:00
def webfingerHandle(session, handle: str, httpPrefix: str,
cachedWebfingers: {},
fromDomain: str, projectVersion: str) -> {}:
"""
"""
2019-07-16 10:19:04 +00:00
if not session:
print('WARN: No session specified for webfingerHandle')
return None
2019-07-19 13:32:58 +00:00
2020-04-04 14:14:25 +00:00
nickname, domain = parseHandle(handle)
2019-07-03 09:40:27 +00:00
if not nickname:
2019-06-28 18:55:29 +00:00
return None
2020-04-04 14:14:25 +00:00
wfDomain = domain
2019-07-01 21:01:43 +00:00
if ':' in wfDomain:
2020-04-04 14:14:25 +00:00
# wfPortStr=wfDomain.split(':')[1]
# if wfPortStr.isdigit():
# wfPort=int(wfPortStr)
# if wfPort==80 or wfPort==443:
wfDomain = wfDomain.split(':')[0]
wf = getWebfingerFromCache(nickname + '@' + wfDomain,
cachedWebfingers)
2019-06-30 15:03:26 +00:00
if wf:
2020-03-22 21:16:02 +00:00
return wf
2020-04-04 14:14:25 +00:00
url = '{}://{}/.well-known/webfinger'.format(httpPrefix, domain)
par = {
'resource': 'acct:{}'.format(nickname + '@' + wfDomain)
2020-03-22 20:36:19 +00:00
}
2020-04-04 14:14:25 +00:00
hdr = {
2020-03-22 20:36:19 +00:00
'Accept': 'application/jrd+json'
}
2019-07-04 17:31:41 +00:00
try:
2020-04-04 14:14:25 +00:00
result = \
getJson(session, url, hdr, par, projectVersion,
httpPrefix, fromDomain)
except Exception as e:
print(e)
2019-07-04 17:31:41 +00:00
return None
if result:
storeWebfingerInCache(nickname + '@' + wfDomain,
result, cachedWebfingers)
2020-05-07 13:26:55 +00:00
else:
print("WARN: Unable to webfinger " + url + ' ' +
'nickname: ' + str(nickname) + ' ' +
'domain: ' + str(wfDomain) + ' ' +
'headers: ' + str(hdr) + ' ' +
'params: ' + str(par))
2019-06-28 18:55:29 +00:00
return result
2020-04-04 14:14:25 +00:00
2019-07-01 11:09:09 +00:00
def generateMagicKey(publicKeyPem) -> str:
2019-06-28 18:55:29 +00:00
"""See magic_key method in
2020-04-04 14:14:25 +00:00
https://github.com/tootsuite/mastodon/blob/
707ddf7808f90e3ab042d7642d368c2ce8e95e6f/app/models/account.rb
2019-06-28 18:55:29 +00:00
"""
2020-04-04 14:14:25 +00:00
privkey = RSA.importKey(publicKeyPem)
modBytes = number.long_to_bytes(privkey.n)
mod = base64.urlsafe_b64encode(modBytes).decode("utf-8")
expBytes = number.long_to_bytes(privkey.e)
pubexp = base64.urlsafe_b64encode(expBytes).decode("utf-8")
2019-06-28 18:55:29 +00:00
return f"data:application/magic-public-key,RSA.{mod}.{pubexp}"
2020-04-04 14:14:25 +00:00
def storeWebfingerEndpoint(nickname: str, domain: str, port: int,
baseDir: str, wfJson: {}) -> bool:
2019-06-28 18:55:29 +00:00
"""Stores webfinger endpoint for a user to a file
"""
2020-04-04 14:14:25 +00:00
originalDomain = domain
if port:
2020-04-04 14:14:25 +00:00
if port != 80 and port != 443:
if ':' not in domain:
2020-04-04 14:14:25 +00:00
domain = domain + ':' + str(port)
handle = nickname + '@' + domain
wfSubdir = '/wfendpoints'
if not os.path.isdir(baseDir + wfSubdir):
os.mkdir(baseDir + wfSubdir)
filename = baseDir + wfSubdir + '/' + handle.lower() + '.json'
saveJson(wfJson, filename)
if nickname == 'inbox':
handle = originalDomain + '@' + domain
filename = baseDir + wfSubdir + '/' + handle.lower() + '.json'
saveJson(wfJson, filename)
2019-06-28 18:55:29 +00:00
return True
2020-04-04 14:14:25 +00:00
def createWebfingerEndpoint(nickname: str, domain: str, port: int,
httpPrefix: str, publicKeyPem) -> {}:
2019-06-28 18:55:29 +00:00
"""Creates a webfinger endpoint for a user
"""
2020-04-04 14:14:25 +00:00
originalDomain = domain
if port:
2020-04-04 14:14:25 +00:00
if port != 80 and port != 443:
if ':' not in domain:
2020-04-04 14:14:25 +00:00
domain = domain + ':' + str(port)
personName = nickname
personId = httpPrefix + "://" + domain + "/users/" + personName
subjectStr = "acct:" + personName + "@" + originalDomain
profilePageHref = httpPrefix + "://" + domain + "/@" + nickname
if nickname == 'inbox' or nickname == originalDomain:
personName = 'actor'
personId = httpPrefix + "://" + domain + "/" + personName
subjectStr = "acct:" + originalDomain + "@" + originalDomain
profilePageHref = httpPrefix + '://' + domain + \
'/about/more?instance_actor=true'
actor = httpPrefix + "://" + domain + "/users/" + nickname
account = {
2019-06-28 18:55:29 +00:00
"aliases": [
2020-04-04 14:14:25 +00:00
httpPrefix + "://" + domain + "/@" + personName,
2019-08-23 20:03:06 +00:00
personId
2019-06-28 18:55:29 +00:00
],
"links": [
{
2019-08-26 15:20:14 +00:00
"href": profilePageHref,
2019-06-28 18:55:29 +00:00
"rel": "http://webfinger.net/rel/profile-page",
"type": "text/html"
},
2019-08-26 14:30:09 +00:00
{
2020-04-04 14:14:25 +00:00
"href": actor + ".atom",
2019-08-26 14:30:09 +00:00
"rel": "http://schemas.google.com/g/2010#updates-from",
"type": "application/atom+xml"
},
2019-06-28 18:55:29 +00:00
{
2019-08-23 20:03:06 +00:00
"href": personId,
2019-06-28 18:55:29 +00:00
"rel": "self",
"type": "application/activity+json"
},
{
"href": generateMagicKey(publicKeyPem),
"rel": "magic-public-key"
}
],
2019-08-23 20:05:16 +00:00
"subject": subjectStr
2019-06-28 18:55:29 +00:00
}
return account
2020-04-04 14:14:25 +00:00
def webfingerNodeInfo(httpPrefix: str, domainFull: str) -> {}:
2019-11-13 10:32:12 +00:00
""" /.well-known/nodeinfo endpoint
"""
2020-04-04 14:14:25 +00:00
nodeinfo = {
2019-11-13 10:32:12 +00:00
'links': [
{
2020-04-04 14:14:25 +00:00
'href': httpPrefix + '://' + domainFull + '/nodeinfo/2.0',
2019-11-13 10:32:12 +00:00
'rel': 'http://nodeinfo.diaspora.software/ns/schema/2.0'
}
]
}
return nodeinfo
2020-04-04 14:14:25 +00:00
def webfingerMeta(httpPrefix: str, domainFull: str) -> str:
2019-08-16 20:52:55 +00:00
"""Return /.well-known/host-meta
2019-06-28 18:55:29 +00:00
"""
2020-04-04 14:14:25 +00:00
metaStr = "<?xml version=1.0' encoding=UTF-8'?>"
metaStr += "<XRD xmlns=http://docs.oasis-open.org/ns/xri/xrd-1.0'"
metaStr += " xmlns:hm=http://host-meta.net/xrd/1.0'>"
metaStr += ""
metaStr += "<hm:Host>" + domainFull + "</hm:Host>"
metaStr += ""
metaStr += "<Link rel=lrdd"
metaStr += " template=" + httpPrefix + "://" + domainFull + \
"/describe?uri={uri}'>"
metaStr += " <Title>Resource Descriptor</Title>"
metaStr += " </Link>"
metaStr += "</XRD>"
2019-11-13 10:32:12 +00:00
return metaStr
2019-08-16 20:52:55 +00:00
2020-04-04 14:14:25 +00:00
def webfingerLookup(path: str, baseDir: str,
domain: str, onionDomain: str,
port: int, debug: bool) -> {}:
2019-06-28 18:55:29 +00:00
"""Lookup the webfinger endpoint for an account
"""
2020-03-22 21:16:02 +00:00
if not path.startswith('/.well-known/webfinger?'):
2019-06-28 18:55:29 +00:00
return None
2020-04-04 14:14:25 +00:00
handle = None
2019-06-28 18:55:29 +00:00
if 'resource=acct:' in path:
2020-04-04 14:14:25 +00:00
handle = path.split('resource=acct:')[1].strip()
2019-07-19 14:19:36 +00:00
if debug:
2020-04-04 14:14:25 +00:00
print('DEBUG: WEBFINGER handle ' + handle)
2019-06-28 18:55:29 +00:00
else:
if 'resource=acct%3A' in path:
2020-04-04 14:14:25 +00:00
handle = path.split('resource=acct%3A')[1]
2020-04-15 11:10:30 +00:00
handle = urllib.parse.unquote(handle.strip())
2019-07-19 14:19:36 +00:00
if debug:
2020-04-04 14:14:25 +00:00
print('DEBUG: WEBFINGER handle ' + handle)
2019-06-28 18:55:29 +00:00
if not handle:
2019-07-19 14:19:36 +00:00
if debug:
print('DEBUG: WEBFINGER handle missing')
2019-06-28 18:55:29 +00:00
return None
if '&' in handle:
2020-04-04 14:14:25 +00:00
handle = handle.split('&')[0].strip()
2019-07-19 14:19:36 +00:00
if debug:
2020-04-04 14:14:25 +00:00
print('DEBUG: WEBFINGER handle with & removed ' + handle)
2019-06-28 18:55:29 +00:00
if '@' not in handle:
2019-07-19 14:19:36 +00:00
if debug:
2020-04-04 14:14:25 +00:00
print('DEBUG: WEBFINGER no @ in handle ' + handle)
2019-06-28 18:55:29 +00:00
return None
if port:
2020-04-04 14:14:25 +00:00
if port != 80 and port != 443:
if ':' not in handle:
2020-04-04 14:14:25 +00:00
handle = handle + ':' + str(port)
2019-08-23 14:18:31 +00:00
# convert @domain@domain to inbox@domain
if '@' in handle:
2020-04-04 14:14:25 +00:00
handleDomain = handle.split('@')[1]
if handle.startswith(handleDomain + '@'):
handle = 'inbox@' + handleDomain
2020-03-02 14:35:44 +00:00
# if this is a lookup for a handle using its onion domain
# then swap the onion domain for the clearnet version
2020-04-04 14:14:25 +00:00
onionify = False
2020-03-02 14:35:44 +00:00
if onionDomain:
if onionDomain in handle:
2020-04-04 14:14:25 +00:00
handle = handle.replace(onionDomain, domain)
onionify = True
filename = baseDir + '/wfendpoints/' + handle.lower() + '.json'
2019-07-19 14:19:36 +00:00
if debug:
2020-04-04 14:14:25 +00:00
print('DEBUG: WEBFINGER filename ' + filename)
2019-06-28 18:55:29 +00:00
if not os.path.isfile(filename):
2019-07-19 14:19:36 +00:00
if debug:
2020-04-04 14:14:25 +00:00
print('DEBUG: WEBFINGER filename not found ' + filename)
2019-06-28 18:55:29 +00:00
return None
2020-03-02 14:35:44 +00:00
if not onionify:
2020-04-04 14:14:25 +00:00
wfJson = loadJson(filename)
2020-03-02 14:35:44 +00:00
else:
2020-04-04 14:14:25 +00:00
print('Webfinger request for onionified ' + handle)
wfJson = loadJsonOnionify(filename, domain, onionDomain)
2019-10-22 11:55:06 +00:00
if not wfJson:
2020-04-04 14:14:25 +00:00
wfJson = {"nickname": "unknown"}
2019-06-28 18:55:29 +00:00
return wfJson
def webfingerUpdateFromProfile(wfJson: {}, actorJson: {}) -> bool:
"""Updates webfinger Email/blog/xmpp links from profile
Returns true if one or more tags has been changed
"""
if not actorJson.get('attachment'):
return False
changed = False
webfingerPropertyName = {
"xmpp": "xmpp",
2020-05-04 14:10:27 +00:00
"matrix": "matrix",
"email": "mailto",
"ssb": "ssb",
"tox": "toxId"
}
for propertyValue in actorJson['attachment']:
if not propertyValue.get('name'):
continue
propertyName = propertyValue['name'].lower()
if not (propertyName.startswith('ssb') or
propertyName.startswith('xmpp') or
2020-05-04 14:10:27 +00:00
propertyName.startswith('matrix') or
propertyName.startswith('email') or
propertyName.startswith('tox')):
continue
if not propertyValue.get('type'):
continue
if not propertyValue.get('value'):
continue
if propertyValue['type'] != 'PropertyValue':
continue
newValue = propertyValue['value'].strip()
aliasIndex = 0
found = False
for alias in wfJson['aliases']:
if alias.startswith(webfingerPropertyName[propertyName] + ':'):
found = True
break
aliasIndex += 1
newAlias = webfingerPropertyName[propertyName] + ':' + newValue
if found:
if wfJson['aliases'][aliasIndex] != newAlias:
changed = True
wfJson['aliases'][aliasIndex] = newAlias
else:
wfJson['aliases'].append(newAlias)
changed = True
return changed
def webfingerUpdate(baseDir: str, nickname: str, domain: str,
onionDomain: str,
cachedWebfingers: {}) -> None:
handle = nickname + '@' + domain
wfSubdir = '/wfendpoints'
if not os.path.isdir(baseDir + wfSubdir):
return
filename = baseDir + wfSubdir + '/' + handle.lower() + '.json'
onionify = False
if onionDomain:
if onionDomain in handle:
handle = handle.replace(onionDomain, domain)
onionify = True
if not onionify:
wfJson = loadJson(filename)
else:
wfJson = loadJsonOnionify(filename, domain, onionDomain)
if not wfJson:
return
actorFilename = baseDir + '/accounts/' + handle.lower() + '.json'
actorJson = loadJson(actorFilename)
if not actorJson:
return
if webfingerUpdateFromProfile(wfJson, actorJson):
if saveJson(wfJson, filename):
cachedWebfingers[handle] = wfJson