comparison libervia/backend/plugins/plugin_sec_pubsub_signing.py @ 4071:4b842c1fb686

refactoring: renamed `sat` package to `libervia.backend`
author Goffi <goffi@goffi.org>
date Fri, 02 Jun 2023 11:49:51 +0200
parents sat/plugins/plugin_sec_pubsub_signing.py@524856bd7b19
children 0d7bb4df2343
comparison
equal deleted inserted replaced
4070:d10748475025 4071:4b842c1fb686
1 #!/usr/bin/env python3
2
3 # Libervia plugin for Pubsub Items Signature
4 # Copyright (C) 2009-2022 Jérôme Poisson (goffi@goffi.org)
5
6 # This program is free software: you can redistribute it and/or modify
7 # it under the terms of the GNU Affero General Public License as published by
8 # the Free Software Foundation, either version 3 of the License, or
9 # (at your option) any later version.
10
11 # This program is distributed in the hope that it will be useful,
12 # but WITHOUT ANY WARRANTY; without even the implied warranty of
13 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
14 # GNU Affero General Public License for more details.
15
16 # You should have received a copy of the GNU Affero General Public License
17 # along with this program. If not, see <http://www.gnu.org/licenses/>.
18
19 import base64
20 import time
21 from typing import Any, Dict, List, Optional
22
23 from lxml import etree
24 import shortuuid
25 from twisted.internet import defer
26 from twisted.words.protocols.jabber import jid, xmlstream
27 from twisted.words.xish import domish
28 from wokkel import disco, iwokkel
29 from wokkel import pubsub
30 from zope.interface import implementer
31
32 from libervia.backend.core import exceptions
33 from libervia.backend.core.constants import Const as C
34 from libervia.backend.core.core_types import SatXMPPEntity
35 from libervia.backend.core.i18n import _
36 from libervia.backend.core.log import getLogger
37 from libervia.backend.tools import utils
38 from libervia.backend.tools.common import data_format
39
40 from .plugin_xep_0373 import VerificationFailed
41
42
43 log = getLogger(__name__)
44
45 IMPORT_NAME = "pubsub-signing"
46
47 PLUGIN_INFO = {
48 C.PI_NAME: "Pubsub Signing",
49 C.PI_IMPORT_NAME: IMPORT_NAME,
50 C.PI_TYPE: C.PLUG_TYPE_XEP,
51 C.PI_MODES: C.PLUG_MODE_BOTH,
52 C.PI_PROTOCOLS: [],
53 C.PI_DEPENDENCIES: ["XEP-0060", "XEP-0373", "XEP-0470"],
54 C.PI_MAIN: "PubsubSigning",
55 C.PI_HANDLER: "yes",
56 C.PI_DESCRIPTION: _(
57 """Pubsub Signature can be used to strongly authenticate a pubsub item"""
58 ),
59 }
60 NS_PUBSUB_SIGNING = "urn:xmpp:pubsub-signing:0"
61 NS_PUBSUB_SIGNING_OPENPGP = "urn:xmpp:pubsub-signing:openpgp:0"
62
63
64 class PubsubSigning:
65 namespace = NS_PUBSUB_SIGNING
66
67 def __init__(self, host):
68 log.info(_("Pubsub Signing plugin initialization"))
69 host.register_namespace("pubsub-signing", NS_PUBSUB_SIGNING)
70 self.host = host
71 self._p = host.plugins["XEP-0060"]
72 self._ox = host.plugins["XEP-0373"]
73 self._a = host.plugins["XEP-0470"]
74 self._a.register_attachment_handler(
75 "signature", NS_PUBSUB_SIGNING, self.signature_get, self.signature_set
76 )
77 host.trigger.add("XEP-0060_publish", self._publish_trigger)
78 host.bridge.add_method(
79 "ps_signature_check",
80 ".plugin",
81 in_sign="sssss",
82 out_sign="s",
83 method=self._check,
84 async_=True,
85 )
86
87 def get_handler(self, client):
88 return PubsubSigning_Handler()
89
90 def get_data_to_sign(
91 self,
92 item_elt: domish.Element,
93 to_jid: jid.JID,
94 timestamp: float,
95 signer: str,
96 ) -> bytes:
97 """Generate the wrapper element, normalize, serialize and return it"""
98 # we remove values which must not be in the serialised data
99 item_id = item_elt.attributes.pop("id")
100 item_publisher = item_elt.attributes.pop("publisher", None)
101 item_parent = item_elt.parent
102
103 # we need to be sure that item element namespace is right
104 item_elt.uri = item_elt.defaultUri = pubsub.NS_PUBSUB
105
106 sign_data_elt = domish.Element((NS_PUBSUB_SIGNING, "sign-data"))
107 to_elt = sign_data_elt.addElement("to")
108 to_elt["jid"] = to_jid.userhost()
109 time_elt = sign_data_elt.addElement("time")
110 time_elt["stamp"] = utils.xmpp_date(timestamp)
111 sign_data_elt.addElement("signer", content=signer)
112 sign_data_elt.addChild(item_elt)
113 # FIXME: xml_tools.domish_elt_2_et_elt must be used once implementation is
114 # complete. For now serialisation/deserialisation is more secure.
115 # et_sign_data_elt = xml_tools.domish_elt_2_et_elt(sign_data_elt, True)
116 et_sign_data_elt = etree.fromstring(sign_data_elt.toXml())
117 to_sign = etree.tostring(
118 et_sign_data_elt,
119 method="c14n2",
120 with_comments=False,
121 strip_text=True
122 )
123 # the data to sign is serialised, we cna restore original values
124 item_elt["id"] = item_id
125 if item_publisher is not None:
126 item_elt["publisher"] = item_publisher
127 item_elt.parent = item_parent
128 return to_sign
129
130 def _check(
131 self,
132 service: str,
133 node: str,
134 item_id: str,
135 signature_data_s: str,
136 profile_key: str,
137 ) -> defer.Deferred:
138 d = defer.ensureDeferred(
139 self.check(
140 self.host.get_client(profile_key),
141 jid.JID(service),
142 node,
143 item_id,
144 data_format.deserialise(signature_data_s)
145 )
146 )
147 d.addCallback(data_format.serialise)
148 return d
149
150 async def check(
151 self,
152 client: SatXMPPEntity,
153 service: jid.JID,
154 node: str,
155 item_id: str,
156 signature_data: Dict[str, Any],
157 ) -> Dict[str, Any]:
158 items, __ = await self._p.get_items(
159 client, service, node, item_ids=[item_id]
160 )
161 if not items != 1:
162 raise exceptions.NotFound(
163 f"target item not found for {item_id!r} at {node!r} for {service}"
164 )
165 item_elt = items[0]
166 timestamp = signature_data["timestamp"]
167 signers = signature_data["signers"]
168 if not signers:
169 raise ValueError("we must have at least one signer to check the signature")
170 if len(signers) > 1:
171 raise NotImplemented("multiple signers are not supported yet")
172 signer = jid.JID(signers[0])
173 signature = base64.b64decode(signature_data["signature"])
174 verification_keys = {
175 k for k in await self._ox.import_all_public_keys(client, signer)
176 if client.gpg_provider.can_sign(k)
177 }
178 signed_data = self.get_data_to_sign(item_elt, service, timestamp, signer.full())
179 try:
180 client.gpg_provider.verify_detached(signed_data, signature, verification_keys)
181 except VerificationFailed:
182 validated = False
183 else:
184 validated = True
185
186 trusts = {
187 k.fingerprint: (await self._ox.get_trust(client, k, signer)).value.lower()
188 for k in verification_keys
189 }
190 return {
191 "signer": signer.full(),
192 "validated": validated,
193 "trusts": trusts,
194 }
195
196 def signature_get(
197 self,
198 client: SatXMPPEntity,
199 attachments_elt: domish.Element,
200 data: Dict[str, Any],
201 ) -> None:
202 try:
203 signature_elt = next(
204 attachments_elt.elements(NS_PUBSUB_SIGNING, "signature")
205 )
206 except StopIteration:
207 pass
208 else:
209 time_elts = list(signature_elt.elements(NS_PUBSUB_SIGNING, "time"))
210 if len(time_elts) != 1:
211 raise exceptions.DataError("only a single <time/> element is allowed")
212 try:
213 timestamp = utils.parse_xmpp_date(time_elts[0]["stamp"])
214 except (KeyError, exceptions.ParsingError):
215 raise exceptions.DataError(
216 "invalid time element: {signature_elt.toXml()}"
217 )
218
219 signature_data: Dict[str, Any] = {
220 "timestamp": timestamp,
221 "signers": [
222 str(s) for s in signature_elt.elements(NS_PUBSUB_SIGNING, "signer")
223 ]
224 }
225 # FIXME: only OpenPGP signature is available for now, to be updated if and
226 # when more algorithms are available.
227 sign_elt = next(
228 signature_elt.elements(NS_PUBSUB_SIGNING_OPENPGP, "sign"),
229 None
230 )
231 if sign_elt is None:
232 log.warning(
233 "no known signature profile element found, ignoring signature: "
234 f"{signature_elt.toXml()}"
235 )
236 return
237 else:
238 signature_data["signature"] = str(sign_elt)
239
240 data["signature"] = signature_data
241
242 async def signature_set(
243 self,
244 client: SatXMPPEntity,
245 attachments_data: Dict[str, Any],
246 former_elt: Optional[domish.Element]
247 ) -> Optional[domish.Element]:
248 signature_data = attachments_data["extra"].get("signature")
249 if signature_data is None:
250 return former_elt
251 elif signature_data:
252 item_elt = signature_data.get("item_elt")
253 service = jid.JID(attachments_data["service"])
254 if item_elt is None:
255 node = attachments_data["node"]
256 item_id = attachments_data["id"]
257 items, __ = await self._p.get_items(
258 client, service, node, item_ids=[item_id]
259 )
260 if not items != 1:
261 raise exceptions.NotFound(
262 f"target item not found for {item_id!r} at {node!r} for {service}"
263 )
264 item_elt = items[0]
265
266 signer = signature_data.get("signer") or client.jid.userhost()
267 timestamp = time.time()
268 timestamp_xmpp = utils.xmpp_date(timestamp)
269 to_sign = self.get_data_to_sign(item_elt, service, timestamp, signer)
270
271 signature_elt = domish.Element(
272 (NS_PUBSUB_SIGNING, "signature"),
273 )
274 time_elt = signature_elt.addElement("time")
275 time_elt["stamp"] = timestamp_xmpp
276 signature_elt.addElement("signer", content=signer)
277
278 sign_elt = signature_elt.addElement((NS_PUBSUB_SIGNING_OPENPGP, "sign"))
279 signing_keys = {
280 k for k in self._ox.list_secret_keys(client)
281 if client.gpg_provider.can_sign(k.public_key)
282 }
283 # the base64 encoded signature itself
284 sign_elt.addContent(
285 base64.b64encode(
286 client.gpg_provider.sign_detached(to_sign, signing_keys)
287 ).decode()
288 )
289 return signature_elt
290 else:
291 return None
292
293 async def _publish_trigger(
294 self,
295 client: SatXMPPEntity,
296 service: jid.JID,
297 node: str,
298 items: Optional[List[domish.Element]],
299 options: Optional[dict],
300 sender: jid.JID,
301 extra: Dict[str, Any]
302 ) -> bool:
303 if not items or not extra.get("signed"):
304 return True
305
306 for item_elt in items:
307 # we need an ID to find corresponding attachment node, and so to sign an item
308 if not item_elt.hasAttribute("id"):
309 item_elt["id"] = shortuuid.uuid()
310 await self._a.set_attachements(
311 client,
312 {
313 "service": service.full(),
314 "node": node,
315 "id": item_elt["id"],
316 "extra": {
317 "signature": {
318 "item_elt": item_elt,
319 "signer": sender.userhost(),
320 }
321 }
322 }
323 )
324
325 return True
326
327
328 @implementer(iwokkel.IDisco)
329 class PubsubSigning_Handler(xmlstream.XMPPHandler):
330
331 def getDiscoInfo(self, requestor, service, nodeIdentifier=""):
332 return [disco.DiscoFeature(NS_PUBSUB_SIGNING)]
333
334 def getDiscoItems(self, requestor, service, nodeIdentifier=""):
335 return []