comparison sat/plugins/plugin_sec_pubsub_signing.py @ 3956:3cb9ade2ab84

plugin pubsub signing: pubsub items signature implementation: - this is based on a protoXEP, not yet an official XEP: https://github.com/xsf/xeps/pull/1228 - XEP-0470: `set` attachment handler can now be async rel 381
author Goffi <goffi@goffi.org>
date Fri, 28 Oct 2022 18:47:17 +0200
parents
children a15c171836bb
comparison
equal deleted inserted replaced
3955:323017a4e4d2 3956:3cb9ade2ab84
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 sat.core import exceptions
33 from sat.core.constants import Const as C
34 from sat.core.core_types import SatXMPPEntity
35 from sat.core.i18n import _
36 from sat.core.log import getLogger
37 from sat.tools import utils
38 from sat.tools.common import data_format
39
40 from .plugin_xep_0373 import get_gpg_provider, 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.registerNamespace("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.addMethod(
79 "psSignatureCheck",
80 ".plugin",
81 in_sign="sssss",
82 out_sign="s",
83 method=self._check,
84 async_=True,
85 )
86
87 def getHandler(self, client):
88 return PubsubSigning_Handler()
89
90 async def profileConnecting(self, client):
91 self.gpg_provider = get_gpg_provider(self.host, client)
92
93 def get_data_to_sign(
94 self,
95 item_elt: domish.Element,
96 to_jid: jid.JID,
97 timestamp: float,
98 signer: str,
99 ) -> bytes:
100 """Generate the wrapper element, normalize, serialize and return it"""
101 # we remove values which must not be in the serialised data
102 item_id = item_elt.attributes.pop("id")
103 item_publisher = item_elt.attributes.pop("publisher", None)
104 item_parent = item_elt.parent
105
106 # we need to be sure that item element namespace is right
107 item_elt.uri = item_elt.defaultUri = pubsub.NS_PUBSUB
108
109 sign_data_elt = domish.Element((NS_PUBSUB_SIGNING, "sign-data"))
110 to_elt = sign_data_elt.addElement("to")
111 to_elt["jid"] = to_jid.userhost()
112 time_elt = sign_data_elt.addElement("time")
113 time_elt["stamp"] = utils.xmpp_date(timestamp)
114 sign_data_elt.addElement("signer", content=signer)
115 sign_data_elt.addChild(item_elt)
116 # FIXME: xml_tools.domish_elt_2_et_elt must be used once implementation is
117 # complete. For now serialisation/deserialisation is more secure.
118 # et_sign_data_elt = xml_tools.domish_elt_2_et_elt(sign_data_elt, True)
119 et_sign_data_elt = etree.fromstring(sign_data_elt.toXml())
120 to_sign = etree.tostring(
121 et_sign_data_elt,
122 method="c14n2",
123 with_comments=False,
124 strip_text=True
125 )
126 # the data to sign is serialised, we cna restore original values
127 item_elt["id"] = item_id
128 if item_publisher is not None:
129 item_elt["publisher"] = item_publisher
130 item_elt.parent = item_parent
131 return to_sign
132
133 def _check(
134 self,
135 service: str,
136 node: str,
137 item_id: str,
138 signature_data_s: str,
139 profile_key: str,
140 ) -> defer.Deferred:
141 d = defer.ensureDeferred(
142 self.check(
143 self.host.getClient(profile_key),
144 jid.JID(service),
145 node,
146 item_id,
147 data_format.deserialise(signature_data_s)
148 )
149 )
150 d.addCallback(data_format.serialise)
151 return d
152
153 async def check(
154 self,
155 client: SatXMPPEntity,
156 service: jid.JID,
157 node: str,
158 item_id: str,
159 signature_data: Dict[str, Any],
160 ) -> Dict[str, Any]:
161 items, __ = await self._p.getItems(
162 client, service, node, item_ids=[item_id]
163 )
164 if not items != 1:
165 raise exceptions.NotFound(
166 f"target item not found for {item_id!r} at {node!r} for {service}"
167 )
168 item_elt = items[0]
169 timestamp = signature_data["timestamp"]
170 signers = signature_data["signers"]
171 if not signers:
172 raise ValueError("we must have at least one signer to check the signature")
173 if len(signers) > 1:
174 raise NotImplemented("multiple signers are not supported yet")
175 signer = jid.JID(signers[0])
176 signature = base64.b64decode(signature_data["signature"])
177 verification_keys = {
178 k for k in await self._ox.import_all_public_keys(client, signer)
179 if self.gpg_provider.can_sign(k)
180 }
181 signed_data = self.get_data_to_sign(item_elt, service, timestamp, signer.full())
182 try:
183 self.gpg_provider.verify_detached(signed_data, signature, verification_keys)
184 except VerificationFailed:
185 validated = False
186 else:
187 validated = True
188
189 trusts = {
190 k.fingerprint: (await self._ox.get_trust(client, k, signer)).value.lower()
191 for k in verification_keys
192 }
193 return {
194 "signer": signer.full(),
195 "validated": validated,
196 "trusts": trusts,
197 }
198
199 def signature_get(
200 self,
201 client: SatXMPPEntity,
202 attachments_elt: domish.Element,
203 data: Dict[str, Any],
204 ) -> None:
205 try:
206 signature_elt = next(
207 attachments_elt.elements(NS_PUBSUB_SIGNING, "signature")
208 )
209 except StopIteration:
210 pass
211 else:
212 time_elts = list(signature_elt.elements(NS_PUBSUB_SIGNING, "time"))
213 if len(time_elts) != 1:
214 raise exceptions.DataError("only a single <time/> element is allowed")
215 try:
216 timestamp = utils.parse_xmpp_date(time_elts[0]["stamp"])
217 except (KeyError, exceptions.ParsingError):
218 raise exceptions.DataError(
219 "invalid time element: {signature_elt.toXml()}"
220 )
221
222 signature_data: Dict[str, Any] = {
223 "timestamp": timestamp,
224 "signers": [
225 str(s) for s in signature_elt.elements(NS_PUBSUB_SIGNING, "signer")
226 ]
227 }
228 # FIXME: only OpenPGP signature is available for now, to be updated if and
229 # when more algorithms are available.
230 sign_elt = next(
231 signature_elt.elements(NS_PUBSUB_SIGNING_OPENPGP, "sign"),
232 None
233 )
234 if sign_elt is None:
235 log.warning(
236 "no known signature profile element found, ignoring signature: "
237 f"{signature_elt.toXml()}"
238 )
239 return
240 else:
241 signature_data["signature"] = str(sign_elt)
242
243 data["signature"] = signature_data
244
245 async def signature_set(
246 self,
247 client: SatXMPPEntity,
248 attachments_data: Dict[str, Any],
249 former_elt: Optional[domish.Element]
250 ) -> Optional[domish.Element]:
251 signature_data = attachments_data["extra"].get("signature")
252 if signature_data is None:
253 return former_elt
254 elif signature_data:
255 item_elt = signature_data.get("item_elt")
256 service = jid.JID(attachments_data["service"])
257 if item_elt is None:
258 node = attachments_data["node"]
259 item_id = attachments_data["id"]
260 items, __ = await self._p.getItems(
261 client, service, node, items_ids=[item_id]
262 )
263 if not items != 1:
264 raise exceptions.NotFound(
265 f"target item not found for {item_id!r} at {node!r} for {service}"
266 )
267 item_elt = items[0]
268
269 signer = signature_data["signer"]
270 timestamp = time.time()
271 timestamp_xmpp = utils.xmpp_date(timestamp)
272 to_sign = self.get_data_to_sign(item_elt, service, timestamp, signer)
273
274 signature_elt = domish.Element(
275 (NS_PUBSUB_SIGNING, "signature"),
276 )
277 time_elt = signature_elt.addElement("time")
278 time_elt["stamp"] = timestamp_xmpp
279 signature_elt.addElement("signer", content=signer)
280
281 sign_elt = signature_elt.addElement((NS_PUBSUB_SIGNING_OPENPGP, "sign"))
282 signing_keys = {
283 k for k in self._ox.list_secret_keys(client)
284 if self.gpg_provider.can_sign(k.public_key)
285 }
286 # the base64 encoded signature itself
287 sign_elt.addContent(
288 base64.b64encode(
289 self.gpg_provider.sign_detached(to_sign, signing_keys)
290 ).decode()
291 )
292 return signature_elt
293 else:
294 return None
295
296 async def _publish_trigger(
297 self,
298 client: SatXMPPEntity,
299 service: jid.JID,
300 node: str,
301 items: Optional[List[domish.Element]],
302 options: Optional[dict],
303 sender: jid.JID,
304 extra: Dict[str, Any]
305 ) -> bool:
306 if not items or not extra.get("signed"):
307 return True
308
309 for item_elt in items:
310 # we need an ID to find corresponding attachment node, and so to sign an item
311 if not item_elt.hasAttribute("id"):
312 item_elt["id"] = shortuuid.uuid()
313 await self._a.set_attachements(
314 client,
315 {
316 "service": service.full(),
317 "node": node,
318 "id": item_elt["id"],
319 "extra": {
320 "signature": {
321 "item_elt": item_elt,
322 "signer": sender.userhost(),
323 }
324 }
325 }
326 )
327
328 return True
329
330
331 @implementer(iwokkel.IDisco)
332 class PubsubSigning_Handler(xmlstream.XMPPHandler):
333
334 def getDiscoInfo(self, requestor, service, nodeIdentifier=""):
335 return [disco.DiscoFeature(NS_PUBSUB_SIGNING)]
336
337 def getDiscoItems(self, requestor, service, nodeIdentifier=""):
338 return []