webfinger.py 12 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362
  1. __filename__ = "webfinger.py"
  2. __author__ = "Bob Mottram"
  3. __license__ = "AGPL3+"
  4. __version__ = "1.1.0"
  5. __maintainer__ = "Bob Mottram"
  6. __email__ = "bob@freedombone.net"
  7. __status__ = "Production"
  8. import base64
  9. try:
  10. from Cryptodome.PublicKey import RSA
  11. from Cryptodome.Util import number
  12. except ImportError:
  13. from Crypto.PublicKey import RSA
  14. from Crypto.Util import number
  15. import os
  16. import urllib.parse
  17. from session import getJson
  18. from cache import storeWebfingerInCache
  19. from cache import getWebfingerFromCache
  20. from utils import loadJson
  21. from utils import loadJsonOnionify
  22. from utils import saveJson
  23. from utils import getProtocolPrefixes
  24. def parseHandle(handle: str) -> (str, str):
  25. if '.' not in handle:
  26. return None, None
  27. prefixes = getProtocolPrefixes()
  28. handleStr = handle
  29. for prefix in prefixes:
  30. handleStr = handleStr.replace(prefix, '')
  31. if '/@' in handle:
  32. domain, nickname = handleStr.split('/@')
  33. else:
  34. if '/users/' in handle:
  35. domain, nickname = handleStr.split('/users/')
  36. else:
  37. if '@' in handle:
  38. nickname, domain = handle.split('@')
  39. else:
  40. return None, None
  41. return nickname, domain
  42. def webfingerHandle(session, handle: str, httpPrefix: str,
  43. cachedWebfingers: {},
  44. fromDomain: str, projectVersion: str) -> {}:
  45. """Gets webfinger result for the given ActivityPub handle
  46. """
  47. if not session:
  48. print('WARN: No session specified for webfingerHandle')
  49. return None
  50. nickname, domain = parseHandle(handle)
  51. if not nickname:
  52. return None
  53. wfDomain = domain
  54. if ':' in wfDomain:
  55. # wfPortStr=wfDomain.split(':')[1]
  56. # if wfPortStr.isdigit():
  57. # wfPort=int(wfPortStr)
  58. # if wfPort==80 or wfPort==443:
  59. wfDomain = wfDomain.split(':')[0]
  60. wf = getWebfingerFromCache(nickname + '@' + wfDomain,
  61. cachedWebfingers)
  62. if wf:
  63. return wf
  64. url = '{}://{}/.well-known/webfinger'.format(httpPrefix, domain)
  65. par = {
  66. 'resource': 'acct:{}'.format(nickname + '@' + wfDomain)
  67. }
  68. hdr = {
  69. 'Accept': 'application/jrd+json'
  70. }
  71. try:
  72. result = \
  73. getJson(session, url, hdr, par, projectVersion,
  74. httpPrefix, fromDomain)
  75. except Exception as e:
  76. print(e)
  77. return None
  78. if result:
  79. storeWebfingerInCache(nickname + '@' + wfDomain,
  80. result, cachedWebfingers)
  81. else:
  82. print("WARN: Unable to webfinger " + url + ' ' +
  83. 'nickname: ' + str(nickname) + ' ' +
  84. 'domain: ' + str(wfDomain) + ' ' +
  85. 'headers: ' + str(hdr) + ' ' +
  86. 'params: ' + str(par))
  87. return result
  88. def generateMagicKey(publicKeyPem) -> str:
  89. """See magic_key method in
  90. https://github.com/tootsuite/mastodon/blob/
  91. 707ddf7808f90e3ab042d7642d368c2ce8e95e6f/app/models/account.rb
  92. """
  93. privkey = RSA.importKey(publicKeyPem)
  94. modBytes = number.long_to_bytes(privkey.n)
  95. mod = base64.urlsafe_b64encode(modBytes).decode("utf-8")
  96. expBytes = number.long_to_bytes(privkey.e)
  97. pubexp = base64.urlsafe_b64encode(expBytes).decode("utf-8")
  98. return f"data:application/magic-public-key,RSA.{mod}.{pubexp}"
  99. def storeWebfingerEndpoint(nickname: str, domain: str, port: int,
  100. baseDir: str, wfJson: {}) -> bool:
  101. """Stores webfinger endpoint for a user to a file
  102. """
  103. originalDomain = domain
  104. if port:
  105. if port != 80 and port != 443:
  106. if ':' not in domain:
  107. domain = domain + ':' + str(port)
  108. handle = nickname + '@' + domain
  109. wfSubdir = '/wfendpoints'
  110. if not os.path.isdir(baseDir + wfSubdir):
  111. os.mkdir(baseDir + wfSubdir)
  112. filename = baseDir + wfSubdir + '/' + handle.lower() + '.json'
  113. saveJson(wfJson, filename)
  114. if nickname == 'inbox':
  115. handle = originalDomain + '@' + domain
  116. filename = baseDir + wfSubdir + '/' + handle.lower() + '.json'
  117. saveJson(wfJson, filename)
  118. return True
  119. def createWebfingerEndpoint(nickname: str, domain: str, port: int,
  120. httpPrefix: str, publicKeyPem) -> {}:
  121. """Creates a webfinger endpoint for a user
  122. """
  123. originalDomain = domain
  124. if port:
  125. if port != 80 and port != 443:
  126. if ':' not in domain:
  127. domain = domain + ':' + str(port)
  128. personName = nickname
  129. personId = httpPrefix + "://" + domain + "/users/" + personName
  130. subjectStr = "acct:" + personName + "@" + originalDomain
  131. profilePageHref = httpPrefix + "://" + domain + "/@" + nickname
  132. if nickname == 'inbox' or nickname == originalDomain:
  133. personName = 'actor'
  134. personId = httpPrefix + "://" + domain + "/" + personName
  135. subjectStr = "acct:" + originalDomain + "@" + originalDomain
  136. profilePageHref = httpPrefix + '://' + domain + \
  137. '/about/more?instance_actor=true'
  138. actor = httpPrefix + "://" + domain + "/users/" + nickname
  139. account = {
  140. "aliases": [
  141. httpPrefix + "://" + domain + "/@" + personName,
  142. personId
  143. ],
  144. "links": [
  145. {
  146. "href": profilePageHref,
  147. "rel": "http://webfinger.net/rel/profile-page",
  148. "type": "text/html"
  149. },
  150. {
  151. "href": actor + ".atom",
  152. "rel": "http://schemas.google.com/g/2010#updates-from",
  153. "type": "application/atom+xml"
  154. },
  155. {
  156. "href": personId,
  157. "rel": "self",
  158. "type": "application/activity+json"
  159. },
  160. {
  161. "href": generateMagicKey(publicKeyPem),
  162. "rel": "magic-public-key"
  163. }
  164. ],
  165. "subject": subjectStr
  166. }
  167. return account
  168. def webfingerNodeInfo(httpPrefix: str, domainFull: str) -> {}:
  169. """ /.well-known/nodeinfo endpoint
  170. """
  171. nodeinfo = {
  172. 'links': [
  173. {
  174. 'href': httpPrefix + '://' + domainFull + '/nodeinfo/2.0',
  175. 'rel': 'http://nodeinfo.diaspora.software/ns/schema/2.0'
  176. }
  177. ]
  178. }
  179. return nodeinfo
  180. def webfingerMeta(httpPrefix: str, domainFull: str) -> str:
  181. """Return /.well-known/host-meta
  182. """
  183. metaStr = "<?xml version=’1.0' encoding=’UTF-8'?>"
  184. metaStr += "<XRD xmlns=’http://docs.oasis-open.org/ns/xri/xrd-1.0'"
  185. metaStr += " xmlns:hm=’http://host-meta.net/xrd/1.0'>"
  186. metaStr += ""
  187. metaStr += "<hm:Host>" + domainFull + "</hm:Host>"
  188. metaStr += ""
  189. metaStr += "<Link rel=’lrdd’"
  190. metaStr += " template=’" + httpPrefix + "://" + domainFull + \
  191. "/describe?uri={uri}'>"
  192. metaStr += " <Title>Resource Descriptor</Title>"
  193. metaStr += " </Link>"
  194. metaStr += "</XRD>"
  195. return metaStr
  196. def webfingerLookup(path: str, baseDir: str,
  197. domain: str, onionDomain: str,
  198. port: int, debug: bool) -> {}:
  199. """Lookup the webfinger endpoint for an account
  200. """
  201. if not path.startswith('/.well-known/webfinger?'):
  202. return None
  203. handle = None
  204. if 'resource=acct:' in path:
  205. handle = path.split('resource=acct:')[1].strip()
  206. if debug:
  207. print('DEBUG: WEBFINGER handle ' + handle)
  208. else:
  209. if 'resource=acct%3A' in path:
  210. handle = path.split('resource=acct%3A')[1]
  211. handle = urllib.parse.unquote(handle.strip())
  212. if debug:
  213. print('DEBUG: WEBFINGER handle ' + handle)
  214. if not handle:
  215. if debug:
  216. print('DEBUG: WEBFINGER handle missing')
  217. return None
  218. if '&' in handle:
  219. handle = handle.split('&')[0].strip()
  220. if debug:
  221. print('DEBUG: WEBFINGER handle with & removed ' + handle)
  222. if '@' not in handle:
  223. if debug:
  224. print('DEBUG: WEBFINGER no @ in handle ' + handle)
  225. return None
  226. if port:
  227. if port != 80 and port != 443:
  228. if ':' not in handle:
  229. handle = handle + ':' + str(port)
  230. # convert @domain@domain to inbox@domain
  231. if '@' in handle:
  232. handleDomain = handle.split('@')[1]
  233. if handle.startswith(handleDomain + '@'):
  234. handle = 'inbox@' + handleDomain
  235. # if this is a lookup for a handle using its onion domain
  236. # then swap the onion domain for the clearnet version
  237. onionify = False
  238. if onionDomain:
  239. if onionDomain in handle:
  240. handle = handle.replace(onionDomain, domain)
  241. onionify = True
  242. filename = baseDir + '/wfendpoints/' + handle.lower() + '.json'
  243. if debug:
  244. print('DEBUG: WEBFINGER filename ' + filename)
  245. if not os.path.isfile(filename):
  246. if debug:
  247. print('DEBUG: WEBFINGER filename not found ' + filename)
  248. return None
  249. if not onionify:
  250. wfJson = loadJson(filename)
  251. else:
  252. print('Webfinger request for onionified ' + handle)
  253. wfJson = loadJsonOnionify(filename, domain, onionDomain)
  254. if not wfJson:
  255. wfJson = {"nickname": "unknown"}
  256. return wfJson
  257. def webfingerUpdateFromProfile(wfJson: {}, actorJson: {}) -> bool:
  258. """Updates webfinger Email/blog/xmpp links from profile
  259. Returns true if one or more tags has been changed
  260. """
  261. if not actorJson.get('attachment'):
  262. return False
  263. changed = False
  264. webfingerPropertyName = {
  265. "xmpp": "xmpp",
  266. "matrix": "matrix",
  267. "email": "mailto",
  268. "ssb": "ssb",
  269. "tox": "toxId"
  270. }
  271. for propertyValue in actorJson['attachment']:
  272. if not propertyValue.get('name'):
  273. continue
  274. propertyName = propertyValue['name'].lower()
  275. if not (propertyName.startswith('ssb') or
  276. propertyName.startswith('xmpp') or
  277. propertyName.startswith('matrix') or
  278. propertyName.startswith('email') or
  279. propertyName.startswith('tox')):
  280. continue
  281. if not propertyValue.get('type'):
  282. continue
  283. if not propertyValue.get('value'):
  284. continue
  285. if propertyValue['type'] != 'PropertyValue':
  286. continue
  287. newValue = propertyValue['value'].strip()
  288. aliasIndex = 0
  289. found = False
  290. for alias in wfJson['aliases']:
  291. if alias.startswith(webfingerPropertyName[propertyName] + ':'):
  292. found = True
  293. break
  294. aliasIndex += 1
  295. newAlias = webfingerPropertyName[propertyName] + ':' + newValue
  296. if found:
  297. if wfJson['aliases'][aliasIndex] != newAlias:
  298. changed = True
  299. wfJson['aliases'][aliasIndex] = newAlias
  300. else:
  301. wfJson['aliases'].append(newAlias)
  302. changed = True
  303. return changed
  304. def webfingerUpdate(baseDir: str, nickname: str, domain: str,
  305. onionDomain: str,
  306. cachedWebfingers: {}) -> None:
  307. handle = nickname + '@' + domain
  308. wfSubdir = '/wfendpoints'
  309. if not os.path.isdir(baseDir + wfSubdir):
  310. return
  311. filename = baseDir + wfSubdir + '/' + handle.lower() + '.json'
  312. onionify = False
  313. if onionDomain:
  314. if onionDomain in handle:
  315. handle = handle.replace(onionDomain, domain)
  316. onionify = True
  317. if not onionify:
  318. wfJson = loadJson(filename)
  319. else:
  320. wfJson = loadJsonOnionify(filename, domain, onionDomain)
  321. if not wfJson:
  322. return
  323. actorFilename = baseDir + '/accounts/' + handle.lower() + '.json'
  324. actorJson = loadJson(actorFilename)
  325. if not actorJson:
  326. return
  327. if webfingerUpdateFromProfile(wfJson, actorJson):
  328. if saveJson(wfJson, filename):
  329. cachedWebfingers[handle] = wfJson