Mercurial > libervia-backend
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 [] |