././@PaxHeader0000000000000000000000000000003400000000000010212 xustar0028 mtime=1732053313.4970698 py_vapid-1.9.2/0000775000175000017500000000000014717204501013617 5ustar00jrconlinjrconlin././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1591632437.0 py_vapid-1.9.2/LICENSE0000664000175000017500000004052513667461065014647 0ustar00jrconlinjrconlinMozilla Public License Version 2.0 ================================== 1. Definitions -------------- 1.1. "Contributor" means each individual or legal entity that creates, contributes to the creation of, or owns Covered Software. 1.2. "Contributor Version" means the combination of the Contributions of others (if any) used by a Contributor and that particular Contributor's Contribution. 1.3. "Contribution" means Covered Software of a particular Contributor. 1.4. "Covered Software" means Source Code Form to which the initial Contributor has attached the notice in Exhibit A, the Executable Form of such Source Code Form, and Modifications of such Source Code Form, in each case including portions thereof. 1.5. "Incompatible With Secondary Licenses" means (a) that the initial Contributor has attached the notice described in Exhibit B to the Covered Software; or (b) that the Covered Software was made available under the terms of version 1.1 or earlier of the License, but not also under the terms of a Secondary License. 1.6. "Executable Form" means any form of the work other than Source Code Form. 1.7. "Larger Work" means a work that combines Covered Software with other material, in a separate file or files, that is not Covered Software. 1.8. "License" means this document. 1.9. "Licensable" means having the right to grant, to the maximum extent possible, whether at the time of the initial grant or subsequently, any and all of the rights conveyed by this License. 1.10. "Modifications" means any of the following: (a) any file in Source Code Form that results from an addition to, deletion from, or modification of the contents of Covered Software; or (b) any new file in Source Code Form that contains any Covered Software. 1.11. "Patent Claims" of a Contributor means any patent claim(s), including without limitation, method, process, and apparatus claims, in any patent Licensable by such Contributor that would be infringed, but for the grant of the License, by the making, using, selling, offering for sale, having made, import, or transfer of either its Contributions or its Contributor Version. 1.12. "Secondary License" means either the GNU General Public License, Version 2.0, the GNU Lesser General Public License, Version 2.1, the GNU Affero General Public License, Version 3.0, or any later versions of those licenses. 1.13. "Source Code Form" means the form of the work preferred for making modifications. 1.14. "You" (or "Your") means an individual or a legal entity exercising rights under this License. For legal entities, "You" includes any entity that controls, is controlled by, or is under common control with You. For purposes of this definition, "control" means (a) the power, direct or indirect, to cause the direction or management of such entity, whether by contract or otherwise, or (b) ownership of more than fifty percent (50%) of the outstanding shares or beneficial ownership of such entity. 2. License Grants and Conditions -------------------------------- 2.1. Grants Each Contributor hereby grants You a world-wide, royalty-free, non-exclusive license: (a) under intellectual property rights (other than patent or trademark) Licensable by such Contributor to use, reproduce, make available, modify, display, perform, distribute, and otherwise exploit its Contributions, either on an unmodified basis, with Modifications, or as part of a Larger Work; and (b) under Patent Claims of such Contributor to make, use, sell, offer for sale, have made, import, and otherwise transfer either its Contributions or its Contributor Version. 2.2. Effective Date The licenses granted in Section 2.1 with respect to any Contribution become effective for each Contribution on the date the Contributor first distributes such Contribution. 2.3. Limitations on Grant Scope The licenses granted in this Section 2 are the only rights granted under this License. No additional rights or licenses will be implied from the distribution or licensing of Covered Software under this License. Notwithstanding Section 2.1(b) above, no patent license is granted by a Contributor: (a) for any code that a Contributor has removed from Covered Software; or (b) for infringements caused by: (i) Your and any other third party's modifications of Covered Software, or (ii) the combination of its Contributions with other software (except as part of its Contributor Version); or (c) under Patent Claims infringed by Covered Software in the absence of its Contributions. This License does not grant any rights in the trademarks, service marks, or logos of any Contributor (except as may be necessary to comply with the notice requirements in Section 3.4). 2.4. Subsequent Licenses No Contributor makes additional grants as a result of Your choice to distribute the Covered Software under a subsequent version of this License (see Section 10.2) or under the terms of a Secondary License (if permitted under the terms of Section 3.3). 2.5. Representation Each Contributor represents that the Contributor believes its Contributions are its original creation(s) or it has sufficient rights to grant the rights to its Contributions conveyed by this License. 2.6. Fair Use This License is not intended to limit any rights You have under applicable copyright doctrines of fair use, fair dealing, or other equivalents. 2.7. Conditions Sections 3.1, 3.2, 3.3, and 3.4 are conditions of the licenses granted in Section 2.1. 3. Responsibilities ------------------- 3.1. Distribution of Source Form All distribution of Covered Software in Source Code Form, including any Modifications that You create or to which You contribute, must be under the terms of this License. You must inform recipients that the Source Code Form of the Covered Software is governed by the terms of this License, and how they can obtain a copy of this License. You may not attempt to alter or restrict the recipients' rights in the Source Code Form. 3.2. Distribution of Executable Form If You distribute Covered Software in Executable Form then: (a) such Covered Software must also be made available in Source Code Form, as described in Section 3.1, and You must inform recipients of the Executable Form how they can obtain a copy of such Source Code Form by reasonable means in a timely manner, at a charge no more than the cost of distribution to the recipient; and (b) You may distribute such Executable Form under the terms of this License, or sublicense it under different terms, provided that the license for the Executable Form does not attempt to limit or alter the recipients' rights in the Source Code Form under this License. 3.3. Distribution of a Larger Work You may create and distribute a Larger Work under terms of Your choice, provided that You also comply with the requirements of this License for the Covered Software. If the Larger Work is a combination of Covered Software with a work governed by one or more Secondary Licenses, and the Covered Software is not Incompatible With Secondary Licenses, this License permits You to additionally distribute such Covered Software under the terms of such Secondary License(s), so that the recipient of the Larger Work may, at their option, further distribute the Covered Software under the terms of either this License or such Secondary License(s). 3.4. Notices You may not remove or alter the substance of any license notices (including copyright notices, patent notices, disclaimers of warranty, or limitations of liability) contained within the Source Code Form of the Covered Software, except that You may alter any license notices to the extent required to remedy known factual inaccuracies. 3.5. Application of Additional Terms You may choose to offer, and to charge a fee for, warranty, support, indemnity or liability obligations to one or more recipients of Covered Software. However, You may do so only on Your own behalf, and not on behalf of any Contributor. You must make it absolutely clear that any such warranty, support, indemnity, or liability obligation is offered by You alone, and You hereby agree to indemnify every Contributor for any liability incurred by such Contributor as a result of warranty, support, indemnity or liability terms You offer. You may include additional disclaimers of warranty and limitations of liability specific to any jurisdiction. 4. Inability to Comply Due to Statute or Regulation --------------------------------------------------- If it is impossible for You to comply with any of the terms of this License with respect to some or all of the Covered Software due to statute, judicial order, or regulation then You must: (a) comply with the terms of this License to the maximum extent possible; and (b) describe the limitations and the code they affect. Such description must be placed in a text file included with all distributions of the Covered Software under this License. Except to the extent prohibited by statute or regulation, such description must be sufficiently detailed for a recipient of ordinary skill to be able to understand it. 5. Termination -------------- 5.1. The rights granted under this License will terminate automatically if You fail to comply with any of its terms. However, if You become compliant, then the rights granted under this License from a particular Contributor are reinstated (a) provisionally, unless and until such Contributor explicitly and finally terminates Your grants, and (b) on an ongoing basis, if such Contributor fails to notify You of the non-compliance by some reasonable means prior to 60 days after You have come back into compliance. Moreover, Your grants from a particular Contributor are reinstated on an ongoing basis if such Contributor notifies You of the non-compliance by some reasonable means, this is the first time You have received notice of non-compliance with this License from such Contributor, and You become compliant prior to 30 days after Your receipt of the notice. 5.2. If You initiate litigation against any entity by asserting a patent infringement claim (excluding declaratory judgment actions, counter-claims, and cross-claims) alleging that a Contributor Version directly or indirectly infringes any patent, then the rights granted to You by any and all Contributors for the Covered Software under Section 2.1 of this License shall terminate. 5.3. In the event of termination under Sections 5.1 or 5.2 above, all end user license agreements (excluding distributors and resellers) which have been validly granted by You or Your distributors under this License prior to termination shall survive termination. ************************************************************************ * * * 6. Disclaimer of Warranty * * ------------------------- * * * * Covered Software is provided under this License on an "as is" * * basis, without warranty of any kind, either expressed, implied, or * * statutory, including, without limitation, warranties that the * * Covered Software is free of defects, merchantable, fit for a * * particular purpose or non-infringing. The entire risk as to the * * quality and performance of the Covered Software is with You. * * Should any Covered Software prove defective in any respect, You * * (not any Contributor) assume the cost of any necessary servicing, * * repair, or correction. This disclaimer of warranty constitutes an * * essential part of this License. No use of any Covered Software is * * authorized under this License except under this disclaimer. * * * ************************************************************************ ************************************************************************ * * * 7. Limitation of Liability * * -------------------------- * * * * Under no circumstances and under no legal theory, whether tort * * (including negligence), contract, or otherwise, shall any * * Contributor, or anyone who distributes Covered Software as * * permitted above, be liable to You for any direct, indirect, * * special, incidental, or consequential damages of any character * * including, without limitation, damages for lost profits, loss of * * goodwill, work stoppage, computer failure or malfunction, or any * * and all other commercial damages or losses, even if such party * * shall have been informed of the possibility of such damages. This * * limitation of liability shall not apply to liability for death or * * personal injury resulting from such party's negligence to the * * extent applicable law prohibits such limitation. Some * * jurisdictions do not allow the exclusion or limitation of * * incidental or consequential damages, so this exclusion and * * limitation may not apply to You. * * * ************************************************************************ 8. Litigation ------------- Any litigation relating to this License may be brought only in the courts of a jurisdiction where the defendant maintains its principal place of business and such litigation shall be governed by laws of that jurisdiction, without reference to its conflict-of-law provisions. Nothing in this Section shall prevent a party's ability to bring cross-claims or counter-claims. 9. Miscellaneous ---------------- This License represents the complete agreement concerning the subject matter hereof. If any provision of this License is held to be unenforceable, such provision shall be reformed only to the extent necessary to make it enforceable. Any law or regulation which provides that the language of a contract shall be construed against the drafter shall not be used to construe this License against a Contributor. 10. Versions of the License --------------------------- 10.1. New Versions Mozilla Foundation is the license steward. Except as provided in Section 10.3, no one other than the license steward has the right to modify or publish new versions of this License. Each version will be given a distinguishing version number. 10.2. Effect of New Versions You may distribute the Covered Software under the terms of the version of the License under which You originally received the Covered Software, or under the terms of any subsequent version published by the license steward. 10.3. Modified Versions If you create software not governed by this License, and you want to create a new license for such software, you may create and use a modified version of this License if you rename the license and remove any references to the name of the license steward (except to note that such modified license differs from this License). 10.4. Distributing Source Code Form that is Incompatible With Secondary Licenses If You choose to distribute Source Code Form that is Incompatible With Secondary Licenses under the terms of this version of the License, the notice described in Exhibit B of this License must be attached. Exhibit A - Source Code Form License Notice ------------------------------------------- This Source Code Form is subject to the terms of the Mozilla Public License, v. 2.0. If a copy of the MPL was not distributed with this file, You can obtain one at http://mozilla.org/MPL/2.0/. If it is not possible or desirable to put the notice in a particular file, then You may include the notice in a location (such as a LICENSE file in a relevant directory) where a recipient would be likely to look for such a notice. You may add additional accurate notices of copyright ownership. Exhibit B - "Incompatible With Secondary Licenses" Notice --------------------------------------------------------- This Source Code Form is "Incompatible With Secondary Licenses", as defined by the Mozilla Public License, v. 2.0. ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1492534702.0 py_vapid-1.9.2/MANIFEST.in0000664000175000017500000000013313075442656015365 0ustar00jrconlinjrconlininclude *.md include *.txt include setup.* include LICENSE recursive-include py_vapid *.py ././@PaxHeader0000000000000000000000000000003400000000000010212 xustar0028 mtime=1732053313.4970698 py_vapid-1.9.2/PKG-INFO0000644000175000017500000001047714717204501014723 0ustar00jrconlinjrconlinMetadata-Version: 2.1 Name: py-vapid Version: 1.9.2 Summary: Simple VAPID header generation library Author-email: JR Conlin License: MPL-2.0 Project-URL: Homepage, https://github.com/mozilla-services/vapid Keywords: vapid,push,webpush Classifier: Topic :: Internet :: WWW/HTTP Classifier: Programming Language :: Python Classifier: Programming Language :: Python :: 3 Description-Content-Type: text/x-rst License-File: LICENSE Requires-Dist: cryptography>=2.5 |PyPI version py_vapid| Easy VAPID generation ===================== This minimal library contains the minimal set of functions you need to generate a VAPID key set and get the headers you’ll need to sign a WebPush subscription update. VAPID is a voluntary standard for WebPush subscription providers (sites that send WebPush updates to remote customers) to self-identify to Push Servers (the servers that convey the push notifications). The VAPID “claims” are a set of JSON keys and values. There are two required fields, one semi-optional and several optional additional fields. At a minimum a VAPID claim set should look like: :: {"sub":"mailto:YourEmail@YourSite.com","aud":"https://PushServer","exp":"ExpirationTimestamp"} A few notes: **sub** is the email address you wish to have on record for this request, prefixed with “``mailto:``”. If things go wrong, this is the email that will be used to contact you (for instance). This can be a general delivery address like “``mailto:push_operations@example.com``” or a specific address like “``mailto:bob@example.com``”. **aud** is the audience for the VAPID. This is the scheme and host you use to send subscription endpoints and generally coincides with the ``endpoint`` specified in the Subscription Info block. As example, if a WebPush subscription info contains: ``{"endpoint": "https://push.example.com:8012/v1/push/...", ...}`` then the ``aud`` would be “``https://push.example.com:8012``” While some Push Services consider this an optional field, others may be stricter. **exp** This is the UTC timestamp for when this VAPID request will expire. The maximum period is 24 hours. Setting a shorter period can prevent “replay” attacks. Setting a longer period allows you to reuse headers for multiple sends (e.g. if you’re sending hundreds of updates within an hour or so.) If no ``exp`` is included, one that will expire in 24 hours will be auto-generated for you. Claims should be stored in a JSON compatible file. In the examples below, we’ve stored the claims into a file named ``claims.json``. py_vapid can either be installed as a library or used as a stand along app, ``bin/vapid``. App Installation ---------------- You’ll need ``python virtualenv`` Run that in the current directory. Then run :: bin/pip install -r requirements.txt bin/python -m pip install -e . App Usage --------- Run by itself, ``bin/vapid`` will check and optionally create the public_key.pem and private_key.pem files. ``bin/vapid --gen`` can be used to generate a new set of public and private key PEM files. These will overwrite the contents of ``private_key.pem`` and ``public_key.pem``. ``bin/vapid --sign claims.json`` will generate a set of HTTP headers from a JSON formatted claims file. A sample ``claims.json`` is included with this distribution. ``bin/vapid --sign claims.json --json`` will output the headers in JSON format, which may be useful for other programs. ``bin/vapid --applicationServerKey`` will return the ``applicationServerKey`` value you can use to make a restricted endpoint. See https://developer.mozilla.org/en-US/docs/Web/API/PushManager/subscribe for more details. Be aware that this value is tied to the generated public/private key. If you remove or generate a new key, any restricted URL you’ve previously generated will need to be reallocated. Please note that some User Agents may require you `to decode this string into a Uint8Array `__. See ``bin/vapid -h`` for all options and commands. CHANGELOG --------- I’m terrible about updating the Changelog. Please see the ```git log`` `__ history for details. .. |PyPI version py_vapid| image:: https://badge.fury.io/py/py-vapid.svg :target: https://pypi.org/project/py-vapid/ ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1732053093.0 py_vapid-1.9.2/README.md0000664000175000017500000001005514717204145015103 0ustar00jrconlinjrconlin# Easy VAPID generation [![PyPI version py_vapid](https://badge.fury.io/py/py-vapid.svg)](https://pypi.org/project/py-vapid/) This library is available on [pypi as py-vapid](https://pypi.python.org/pypi/py-vapid). Source is available on [github](https://github.com/mozilla-services/vapid). Please note: This library was designated as a `Critical Project` by PyPi, it is currently maintained by [a single person](https://xkcd.com/2347/). I still accept PRs and Issues, but make of that what you will. This minimal library contains the minimal set of functions you need to generate a VAPID key set and get the headers you'll need to sign a WebPush subscription update. VAPID is a voluntary standard for WebPush subscription providers (sites that send WebPush updates to remote customers) to self-identify to Push Servers (the servers that convey the push notifications). The VAPID "claims" are a set of JSON keys and values. There are two required fields, one semi-optional and several optional additional fields. At a minimum a VAPID claim set should look like: ```json {"sub":"mailto:YourEmail@YourSite.com","aud":"https://PushServer","exp":"ExpirationTimestamp"} ``` A few notes: ***sub*** is the email address you wish to have on record for this request, prefixed with "`mailto:`". If things go wrong, this is the email that will be used to contact you (for instance). This can be a general delivery address like "`mailto:push_operations@example.com`" or a specific address like "`mailto:bob@example.com`". ***aud*** is the audience for the VAPID. This is the scheme and host you use to send subscription endpoints and generally coincides with the `endpoint` specified in the Subscription Info block. As example, if a WebPush subscription info contains: `{"endpoint": "https://push.example.com:8012/v1/push/...", ...}` then the `aud` would be "`https://push.example.com:8012`" While some Push Services consider this an optional field, others may be stricter. ***exp*** This is the UTC timestamp for when this VAPID request will expire. The maximum period is 24 hours. Setting a shorter period can prevent "replay" attacks. Setting a longer period allows you to reuse headers for multiple sends (e.g. if you're sending hundreds of updates within an hour or so.) If no `exp` is included, one that will expire in 24 hours will be auto-generated for you. Claims should be stored in a JSON compatible file. In the examples below, we've stored the claims into a file named `claims.json`. py_vapid can either be installed as a library or used as a stand along app, `bin/vapid`. ## App Installation You'll need `python virtualenv` Run that in the current directory. Then run ```python bin/pip install -r requirements.txt bin/python -m pip install -e . ``` ## App Usage Run by itself, `bin/vapid` will check and optionally create the public_key.pem and private_key.pem files. `bin/vapid --gen` can be used to generate a new set of public and private key PEM files. These will overwrite the contents of `private_key.pem` and `public_key.pem`. `bin/vapid --sign claims.json` will generate a set of HTTP headers from a JSON formatted claims file. A sample `claims.json` is included with this distribution. `bin/vapid --sign claims.json --json` will output the headers in JSON format, which may be useful for other programs. `bin/vapid --applicationServerKey` will return the `applicationServerKey` value you can use to make a restricted endpoint. See https://developer.mozilla.org/en-US/docs/Web/API/PushManager/subscribe for more details. Be aware that this value is tied to the generated public/private key. If you remove or generate a new key, any restricted URL you've previously generated will need to be reallocated. Please note that some User Agents may require you [to decode this string into a Uint8Array](https://github.com/GoogleChrome/push-notifications/blob/master/app/scripts/main.js). See `bin/vapid -h` for all options and commands. ## CHANGELOG I'm terrible about updating the Changelog. Please see the [`git log`](https://github.com/web-push-libs/vapid/pulls?q=is%3Apr+is%3Aclosed) history for details. ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1732053093.0 py_vapid-1.9.2/README.rst0000664000175000017500000000753114717204145015320 0ustar00jrconlinjrconlin|PyPI version py_vapid| Easy VAPID generation ===================== This minimal library contains the minimal set of functions you need to generate a VAPID key set and get the headers you’ll need to sign a WebPush subscription update. VAPID is a voluntary standard for WebPush subscription providers (sites that send WebPush updates to remote customers) to self-identify to Push Servers (the servers that convey the push notifications). The VAPID “claims” are a set of JSON keys and values. There are two required fields, one semi-optional and several optional additional fields. At a minimum a VAPID claim set should look like: :: {"sub":"mailto:YourEmail@YourSite.com","aud":"https://PushServer","exp":"ExpirationTimestamp"} A few notes: **sub** is the email address you wish to have on record for this request, prefixed with “``mailto:``”. If things go wrong, this is the email that will be used to contact you (for instance). This can be a general delivery address like “``mailto:push_operations@example.com``” or a specific address like “``mailto:bob@example.com``”. **aud** is the audience for the VAPID. This is the scheme and host you use to send subscription endpoints and generally coincides with the ``endpoint`` specified in the Subscription Info block. As example, if a WebPush subscription info contains: ``{"endpoint": "https://push.example.com:8012/v1/push/...", ...}`` then the ``aud`` would be “``https://push.example.com:8012``” While some Push Services consider this an optional field, others may be stricter. **exp** This is the UTC timestamp for when this VAPID request will expire. The maximum period is 24 hours. Setting a shorter period can prevent “replay” attacks. Setting a longer period allows you to reuse headers for multiple sends (e.g. if you’re sending hundreds of updates within an hour or so.) If no ``exp`` is included, one that will expire in 24 hours will be auto-generated for you. Claims should be stored in a JSON compatible file. In the examples below, we’ve stored the claims into a file named ``claims.json``. py_vapid can either be installed as a library or used as a stand along app, ``bin/vapid``. App Installation ---------------- You’ll need ``python virtualenv`` Run that in the current directory. Then run :: bin/pip install -r requirements.txt bin/python -m pip install -e . App Usage --------- Run by itself, ``bin/vapid`` will check and optionally create the public_key.pem and private_key.pem files. ``bin/vapid --gen`` can be used to generate a new set of public and private key PEM files. These will overwrite the contents of ``private_key.pem`` and ``public_key.pem``. ``bin/vapid --sign claims.json`` will generate a set of HTTP headers from a JSON formatted claims file. A sample ``claims.json`` is included with this distribution. ``bin/vapid --sign claims.json --json`` will output the headers in JSON format, which may be useful for other programs. ``bin/vapid --applicationServerKey`` will return the ``applicationServerKey`` value you can use to make a restricted endpoint. See https://developer.mozilla.org/en-US/docs/Web/API/PushManager/subscribe for more details. Be aware that this value is tied to the generated public/private key. If you remove or generate a new key, any restricted URL you’ve previously generated will need to be reallocated. Please note that some User Agents may require you `to decode this string into a Uint8Array `__. See ``bin/vapid -h`` for all options and commands. CHANGELOG --------- I’m terrible about updating the Changelog. Please see the ```git log`` `__ history for details. .. |PyPI version py_vapid| image:: https://badge.fury.io/py/py-vapid.svg :target: https://pypi.org/project/py-vapid/ ././@PaxHeader0000000000000000000000000000003400000000000010212 xustar0028 mtime=1732053313.4970698 py_vapid-1.9.2/py_vapid/0000775000175000017500000000000014717204501015432 5ustar00jrconlinjrconlin././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1732052696.0 py_vapid-1.9.2/py_vapid/__init__.py0000664000175000017500000003104114717203330017541 0ustar00jrconlinjrconlin# This Source Code Form is subject to the terms of the Mozilla Public # License, v. 2.0. If a copy of the MPL was not distributed with this # file, You can obtain one at http://mozilla.org/MPL/2.0/. import os import logging import binascii import time import re import copy from cryptography.hazmat.backends import default_backend from cryptography.hazmat.primitives.asymmetric import ec, utils as ecutils from cryptography.hazmat.primitives import serialization from cryptography.hazmat.primitives import hashes from cryptography.exceptions import InvalidSignature from py_vapid.utils import b64urldecode, b64urlencode from py_vapid.jwt import sign # Show compliance version. For earlier versions see previously tagged releases. VERSION = "VAPID-RFC/ECE-RFC" class VapidException(Exception): """An exception wrapper for Vapid.""" pass class Vapid01(object): """Minimal VAPID Draft 01 signature generation library. https://tools.ietf.org/html/draft-ietf-webpush-vapid-01 """ _private_key = None _public_key = None _schema = "WebPush" def __init__(self, private_key=None, conf=None): """Initialize VAPID with an optional private key. :param private_key: A private key object :type private_key: ec.EllipticCurvePrivateKey """ if conf is None: conf = {} self.conf = conf self.private_key = private_key if private_key: self._public_key = self.private_key.public_key() @classmethod def from_raw(cls, private_raw): """Initialize VAPID using a private key point in "raw" or "uncompressed" form. Raw keys consist of a single, 32 octet encoded integer. :param private_raw: A private key point in uncompressed form. :type private_raw: bytes """ key = ec.derive_private_key( int(binascii.hexlify(b64urldecode(private_raw)), 16), curve=ec.SECP256R1(), backend=default_backend()) return cls(key) @classmethod def from_raw_public(cls, public_raw): key = ec.EllipticCurvePublicKey.from_encoded_point( curve=ec.SECP256R1(), data=b64urldecode(public_raw) ) ss = cls() ss._public_key = key return ss @classmethod def from_pem(cls, private_key): """Initialize VAPID using a private key in PEM format. :param private_key: A private key in PEM format. :type private_key: bytes """ # not sure why, but load_pem_private_key fails to deserialize return cls.from_der( b''.join(private_key.splitlines()[1:-1])) @classmethod def from_der(cls, private_key): """Initialize VAPID using a private key in DER format. :param private_key: A private key in DER format and Base64-encoded. :type private_key: bytes """ key = serialization.load_der_private_key(b64urldecode(private_key), password=None, backend=default_backend()) return cls(key) @classmethod def from_file(cls, private_key_file=None): """Initialize VAPID using a file containing a private key in PEM or DER format. :param private_key_file: Name of the file containing the private key :type private_key_file: str """ if not os.path.isfile(private_key_file): logging.info("Private key not found, generating key...") vapid = cls() vapid.generate_keys() vapid.save_key(private_key_file) return vapid with open(private_key_file, 'r') as file: private_key = file.read() try: if "-----BEGIN" in private_key: vapid = cls.from_pem(private_key.encode('utf8')) else: vapid = cls.from_der(private_key.encode('utf8')) return vapid except Exception as exc: logging.error("Could not open private key file: %s", repr(exc)) raise VapidException(exc) @classmethod def from_string(cls, private_key): """Initialize VAPID using a string containing the private key. This will try to determine if the key is in RAW or DER format. :param private_key: String containing the key info :type private_key: str """ pkey = private_key.encode().replace(b"\n", b"") key = b64urldecode(pkey) if len(key) == 32: return cls.from_raw(pkey) return cls.from_der(pkey) @classmethod def verify(cls, key, auth): """Verify a VAPID authorization token. :param key: base64 serialized public key :type key: str :param auth: authorization token type key: str """ tokens = auth.rsplit(' ', 1)[1].rsplit('.', 1) kp = cls().from_raw_public(key.encode()) return kp.verify_token( validation_token=tokens[0].encode(), verification_token=tokens[1] ) @property def private_key(self): """The VAPID private ECDSA key""" if not self._private_key: raise VapidException("No private key. Call generate_keys()") return self._private_key @private_key.setter def private_key(self, value): """Set the VAPID private ECDSA key :param value: the byte array containing the private ECDSA key data :type value: ec.EllipticCurvePrivateKey """ self._private_key = value if value: self._public_key = self.private_key.public_key() @property def public_key(self): """The VAPID public ECDSA key The public key is currently read only. Set it via the `.private_key` method. This will autogenerate a public and private key if no value has been set. :returns ec.EllipticCurvePublicKey """ return self._public_key def generate_keys(self): """Generate a valid ECDSA Key Pair.""" self.private_key = ec.generate_private_key(ec.SECP256R1, default_backend()) def private_pem(self): return self.private_key.private_bytes( encoding=serialization.Encoding.PEM, format=serialization.PrivateFormat.PKCS8, encryption_algorithm=serialization.NoEncryption() ) def public_pem(self): return self.public_key.public_bytes( encoding=serialization.Encoding.PEM, format=serialization.PublicFormat.SubjectPublicKeyInfo ) def save_key(self, key_file): """Save the private key to a PEM file. :param key_file: The file path to save the private key data :type key_file: str """ with open(key_file, "wb") as file: file.write(self.private_pem()) file.close() def save_public_key(self, key_file): """Save the public key to a PEM file. :param key_file: The name of the file to save the public key :type key_file: str """ with open(key_file, "wb") as file: file.write(self.public_pem()) file.close() def verify_token(self, validation_token, verification_token): """Internally used to verify the verification token is correct. :param validation_token: Provided validation token string :type validation_token: str :param verification_token: Generated verification token :type verification_token: str :returns: Boolean indicating if verifictation token is valid. :rtype: boolean """ hsig = b64urldecode(verification_token.encode('utf8')) r = int(binascii.hexlify(hsig[:32]), 16) s = int(binascii.hexlify(hsig[32:]), 16) try: self.public_key.verify( ecutils.encode_dss_signature(r, s), validation_token, signature_algorithm=ec.ECDSA(hashes.SHA256()) ) return True except InvalidSignature: return False def _base_sign(self, claims): cclaims = copy.deepcopy(claims) if not cclaims.get('exp'): cclaims['exp'] = int(time.time()) + 86400 if not self.conf.get('no-strict', False): valid = _check_sub(cclaims.get('sub', '')) else: valid = cclaims.get('sub') is not None if not valid: raise VapidException( "Missing 'sub' from claims. " "'sub' is your admin email as a mailto: link.") if not re.match(r"^https?://[^/:]+(:\d+)?$", cclaims.get("aud", ""), re.IGNORECASE): raise VapidException( "Missing 'aud' from claims. " "'aud' is the scheme, host and optional port for this " "transaction e.g. https://example.com:8080") return cclaims def sign(self, claims, crypto_key=None): """Sign a set of claims. :param claims: JSON object containing the JWT claims to use. :type claims: dict :param crypto_key: Optional existing crypto_key header content. The vapid public key will be appended to this data. :type crypto_key: str :returns: a hash containing the header fields to use in the subscription update. :rtype: dict """ sig = sign(self._base_sign(claims), self.private_key) pkey = 'p256ecdsa=' pkey += b64urlencode( self.public_key.public_bytes( serialization.Encoding.X962, serialization.PublicFormat.UncompressedPoint )) if crypto_key: crypto_key = crypto_key + ';' + pkey else: crypto_key = pkey return {"Authorization": "{} {}".format(self._schema, sig.strip('=')), "Crypto-Key": crypto_key} class Vapid02(Vapid01): """Minimal Vapid RFC8292 signature generation library https://tools.ietf.org/html/rfc8292 """ _schema = "vapid" def sign(self, claims, crypto_key=None): """Generate an authorization token :param claims: JSON object containing the JWT claims to use. :type claims: dict :param crypto_key: Optional existing crypto_key header content. The vapid public key will be appended to this data. :type crypto_key: str :returns: a hash containing the header fields to use in the subscription update. :rtype: dict """ sig = sign(self._base_sign(claims), self.private_key) pkey = self.public_key.public_bytes( serialization.Encoding.X962, serialization.PublicFormat.UncompressedPoint ) return{ "Authorization": "{schema} t={t},k={k}".format( schema=self._schema, t=sig, k=b64urlencode(pkey) ) } @classmethod def verify(cls, auth): """Ensure that the token is correctly formatted and valid :param auth: An Authorization header :type auth: str :rtype: bool """ pref_tok = auth.rsplit(' ', 1) assert pref_tok[0].lower() == cls._schema, ( "Incorrect schema specified") parts = {} for tok in pref_tok[1].split(','): kv = tok.split('=', 1) parts[kv[0]] = kv[1] assert 'k' in parts.keys(), ( "Auth missing public key 'k' value") assert 't' in parts.keys(), ( "Auth missing token set 't' value") kp = cls().from_raw_public(parts['k'].encode()) tokens = parts['t'].rsplit('.', 1) return kp.verify_token( validation_token=tokens[0].encode(), verification_token=tokens[1] ) def _check_sub(sub): """ Check to see if the `sub` is a properly formatted `mailto:` a `mailto:` should be a SMTP mail address. Mind you, since I run YouFailAtEmail.com, you have every right to yell about how terrible this check is. I really should be doing a proper component parse and valiate each component individually per RFC5341, instead I do the unholy regex you see below. :param sub: Candidate JWT `sub` :type sub: str :rtype: bool """ pattern = ( r"^(mailto:.+@((localhost|[%\w-]+(\.[%\w-]+)+|([0-9a-f]{1,4}):+([0-9a-f]{1,4})?)))|https:\/\/(localhost|[\w-]+\.[\w\.-]+|([0-9a-f]{1,4}:+)+([0-9a-f]{1,4})?)$" # noqa ) return re.match(pattern, sub, re.IGNORECASE) is not None Vapid = Vapid02 ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1703870144.0 py_vapid-1.9.2/py_vapid/__main__.py0000664000175000017500000001076514543577300017544 0ustar00jrconlinjrconlin# This Source Code Form is subject to the terms of the Mozilla Public # License, v. 2.0. If a copy of the MPL was not distributed with this # file, You can obtain one at http://mozilla.org/MPL/2.0/. import argparse import os import json from typing import cast from cryptography.hazmat.primitives import serialization from py_vapid import Vapid01, Vapid02, b64urlencode def prompt(prompt: str) -> str: # Not sure why, but python3 throws and exception if you try to # monkeypatch for this. It's ugly, but this seems to play nicer. try: return input(prompt) except NameError: return raw_input(prompt) # noqa: F821 def main(): parser = argparse.ArgumentParser(description="VAPID tool") parser.add_argument("--sign", "-s", help="claims file to sign") parser.add_argument( "--gen", "-g", help="generate new key pairs", default=False, action="store_true" ) parser.add_argument( "--version2", "-2", help="use RFC8292 VAPID spec", default=True, action="store_true", ) parser.add_argument( "--version1", "-1", help="use VAPID spec Draft-01", default=False, action="store_true", ) parser.add_argument( "--json", help="dump as json", default=False, action="store_true" ) parser.add_argument( "--no-strict", help='Do not be strict about "sub"', default=False, action="store_true", ) parser.add_argument( "--applicationServerKey", help="show applicationServerKey value", default=False, action="store_true", ) parser.add_argument( "--private-key", "-k", help="private key pem file", default="private_key.pem" ) args = parser.parse_args() # Added to solve 2.7 => 3.* incompatibility Vapid = Vapid02 if args.version1: Vapid = Vapid01 if args.gen or not os.path.exists(args.private_key): if not args.gen: print("No private key file found.") answer = None while answer not in ["y", "n"]: answer = prompt("Do you want me to create one for you? (Y/n)") if not answer: answer = "y" answer = answer.lower()[0] if answer == "n": print("Sorry, can't do much for you then.") exit(1) vapid = Vapid(conf=args) vapid.generate_keys() print("Generating private_key.pem") vapid.save_key("private_key.pem") print("Generating public_key.pem") vapid.save_public_key("public_key.pem") vapid = Vapid.from_file(args.private_key) claim_file = args.sign result = dict() if args.applicationServerKey: raw_pub = vapid.public_key.public_bytes( serialization.Encoding.X962, serialization.PublicFormat.UncompressedPoint ) print("Application Server Key = {}\n\n".format(b64urlencode(raw_pub))) if claim_file: if not os.path.exists(claim_file): print("No {} file found.".format(claim_file)) print( """ The claims file should be a JSON formatted file that holds the information that describes you. There are three elements in the claims file you'll need: "sub" This is your site's admin email address (e.g. "mailto:admin@example.com") "exp" This is the expiration time for the claim in seconds. If you don't have one, I'll add one that expires in 24 hours. You're also welcome to add additional fields to the claims which could be helpful for the Push Service operations team to pass along to your operations team (e.g. "ami-id": "e-123456", "cust-id": "a3sfa10987"). Remember to keep these values short to prevent some servers from rejecting the transaction due to overly large headers. See https://jwt.io/introduction/ for details. For example, a claims.json file could contain: {"sub": "mailto:admin@example.com"} """ ) exit(1) try: claims = json.loads(open(claim_file).read()) result.update(vapid.sign(claims)) except Exception as exc: print("Crap, something went wrong: {}".format(repr(exc))) raise exc if args.json: print(json.dumps(result)) return print("Include the following headers in your request:\n") for key, value in result.items(): print("{}: {}\n".format(key, value)) print("\n") if __name__ == "__main__": main() ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1732052696.0 py_vapid-1.9.2/py_vapid/jwt.py0000664000175000017500000000476314717203330016621 0ustar00jrconlinjrconlinimport binascii import json from cryptography.exceptions import InvalidSignature from cryptography.hazmat.primitives.asymmetric import ec, utils from cryptography.hazmat.primitives import hashes from py_vapid.utils import b64urldecode, b64urlencode, num_to_bytes def extract_signature(auth): """Extracts the payload and signature from a JWT, converting from RFC7518 to RFC 3279 :param auth: A JWT Authorization Token. :type auth: str :return tuple containing the signature material and signature """ payload, asig = auth.encode('utf8').rsplit(b'.', 1) sig = b64urldecode(asig) if len(sig) != 64: raise InvalidSignature() encoded = utils.encode_dss_signature( s=int(binascii.hexlify(sig[32:]), 16), r=int(binascii.hexlify(sig[:32]), 16) ) return payload, encoded def decode(token, key): """Decode a web token into an assertion dictionary :param token: VAPID auth token :type token: str :param key: bitarray containing the public key :type key: str :return dict of the VAPID claims :raise InvalidSignature """ try: sig_material, signature = extract_signature(token) dkey = b64urldecode(key.encode('utf8')) pkey = ec.EllipticCurvePublicKey.from_encoded_point( ec.SECP256R1(), dkey, ) pkey.verify( signature, sig_material, ec.ECDSA(hashes.SHA256()) ) return json.loads( b64urldecode(sig_material.split(b'.')[1]).decode('utf8') ) except InvalidSignature: raise except(ValueError, TypeError, binascii.Error): raise InvalidSignature() def sign(claims, key): """Sign the claims :param claims: list of JWS claims :type claims: dict :param key: Private key for signing :type key: ec.EllipticCurvePrivateKey :param algorithm: JWT "alg" descriptor :type algorithm: str """ header = b64urlencode(b"""{"typ":"JWT","alg":"ES256"}""") # Unfortunately, chrome seems to require the claims to be sorted. claims = b64urlencode(json.dumps(claims, separators=(',', ':'), sort_keys=True).encode('utf8')) token = "{}.{}".format(header, claims) rsig = key.sign(token.encode('utf8'), ec.ECDSA(hashes.SHA256())) (r, s) = utils.decode_dss_signature(rsig) sig = b64urlencode(num_to_bytes(r, 32) + num_to_bytes(s, 32)) return "{}.{}".format(token, sig) ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1732052696.0 py_vapid-1.9.2/py_vapid/main.py0000664000175000017500000001070414717203330016731 0ustar00jrconlinjrconlin# This Source Code Form is subject to the terms of the Mozilla Public # License, v. 2.0. If a copy of the MPL was not distributed with this # file, You can obtain one at http://mozilla.org/MPL/2.0/. import argparse import os import json from cryptography.hazmat.primitives import serialization from py_vapid import Vapid01, Vapid02, b64urlencode def prompt(prompt): # Not sure why, but python3 throws and exception if you try to # monkeypatch for this. It's ugly, but this seems to play nicer. try: return input(prompt) except NameError: return raw_input(prompt) # noqa: F821 def main(): parser = argparse.ArgumentParser(description="VAPID tool") parser.add_argument('--sign', '-s', help='claims file to sign') parser.add_argument('--gen', '-g', help='generate new key pairs', default=False, action="store_true") parser.add_argument('--version2', '-2', help="use RFC8292 VAPID spec", default=True, action="store_true") parser.add_argument('--version1', '-1', help="use VAPID spec Draft-01", default=False, action="store_true") parser.add_argument('--json', help="dump as json", default=False, action="store_true") parser.add_argument('--no-strict', help='Do not be strict about "sub"', default=False, action="store_true") parser.add_argument('--applicationServerKey', help="show applicationServerKey value", default=False, action="store_true") parser.add_argument('--private-key', '-k', help='private key pem file', default="private_key.pem") args = parser.parse_args() # Added to solve 2.7 => 3.* incompatibility Vapid = Vapid02 if args.version1: Vapid = Vapid01 if args.gen or not os.path.exists(args.private_key): if not args.gen: print("No private key file found.") answer = None while answer not in ['y', 'n']: answer = prompt("Do you want me to create one for you? (Y/n)") if not answer: answer = 'y' answer = answer.lower()[0] if answer == 'n': print("Sorry, can't do much for you then.") exit(1) vapid = Vapid(conf=args) vapid.generate_keys() print("Generating private_key.pem") vapid.save_key('private_key.pem') print("Generating public_key.pem") vapid.save_public_key('public_key.pem') vapid = Vapid.from_file(args.private_key) claim_file = args.sign result = dict() if args.applicationServerKey: raw_pub = vapid.public_key.public_bytes( serialization.Encoding.X962, serialization.PublicFormat.UncompressedPoint ) print("Application Server Key = {}\n\n".format( b64urlencode(raw_pub))) if claim_file: if not os.path.exists(claim_file): print("No {} file found.".format(claim_file)) print(""" The claims file should be a JSON formatted file that holds the information that describes you. There are three elements in the claims file you'll need: "sub" This is your site's admin email address (e.g. "mailto:admin@example.com") "exp" This is the expiration time for the claim in seconds. If you don't have one, I'll add one that expires in 24 hours. You're also welcome to add additional fields to the claims which could be helpful for the Push Service operations team to pass along to your operations team (e.g. "ami-id": "e-123456", "cust-id": "a3sfa10987"). Remember to keep these values short to prevent some servers from rejecting the transaction due to overly large headers. See https://jwt.io/introduction/ for details. For example, a claims.json file could contain: {"sub": "mailto:admin@example.com"} """) exit(1) try: claims = json.loads(open(claim_file).read()) result.update(vapid.sign(claims)) except Exception as exc: print("Crap, something went wrong: {}".format(repr(exc))) raise exc if args.json: print(json.dumps(result)) return print("Include the following headers in your request:\n") for key, value in result.items(): print("{}: {}\n".format(key, value)) print("\n") if __name__ == '__main__': main() ././@PaxHeader0000000000000000000000000000003400000000000010212 xustar0028 mtime=1732053313.4970698 py_vapid-1.9.2/py_vapid/tests/0000775000175000017500000000000014717204501016574 5ustar00jrconlinjrconlin././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1732052696.0 py_vapid-1.9.2/py_vapid/tests/test_vapid.py0000664000175000017500000002424314717203330021314 0ustar00jrconlinjrconlinimport binascii import base64 import copy import os import json import unittest from cryptography.hazmat.primitives import serialization from mock import patch, Mock from py_vapid import Vapid01, Vapid02, VapidException, _check_sub from py_vapid.jwt import decode TEST_KEY_PRIVATE_DER = """ MHcCAQEEIPeN1iAipHbt8+/KZ2NIF8NeN24jqAmnMLFZEMocY8RboAoGCCqGSM49 AwEHoUQDQgAEEJwJZq/GN8jJbo1GGpyU70hmP2hbWAUpQFKDByKB81yldJ9GTklB M5xqEwuPM7VuQcyiLDhvovthPIXx+gsQRQ== """ key = dict( d=111971876876285331364078054667935803036831194031221090723024134705696601261147, # noqa x=7512698603580564493364310058109115206932767156853859985379597995200661812060, # noqa y=74837673548863147047276043384733294240255217876718360423043754089982135570501 # noqa ) # This is the same private key, in PEM form. TEST_KEY_PRIVATE_PEM = ( "-----BEGIN PRIVATE KEY-----{}" "-----END PRIVATE KEY-----\n").format(TEST_KEY_PRIVATE_DER) # This is the same private key, as a point in uncompressed form. This should # be Base64url-encoded without padding. TEST_KEY_PRIVATE_RAW = """ 943WICKkdu3z78pnY0gXw143biOoCacwsVkQyhxjxFs """.strip().encode('utf8') # This is a public key in PEM form. TEST_KEY_PUBLIC_PEM = """-----BEGIN PUBLIC KEY----- MFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAEEJwJZq/GN8jJbo1GGpyU70hmP2hb WAUpQFKDByKB81yldJ9GTklBM5xqEwuPM7VuQcyiLDhvovthPIXx+gsQRQ== -----END PUBLIC KEY----- """ # this is a public key in uncompressed form ('\x04' + 2 * 32 octets) # Remember, this should have any padding stripped. TEST_KEY_PUBLIC_RAW = ( "BBCcCWavxjfIyW6NRhqclO9IZj9oW1gFKUBSgwcigfNc" "pXSfRk5JQTOcahMLjzO1bkHMoiw4b6L7YTyF8foLEEU" ).strip('=').encode('utf8') def setup_module(self): with open('/tmp/private', 'w') as ff: ff.write(TEST_KEY_PRIVATE_PEM) with open('/tmp/public', 'w') as ff: ff.write(TEST_KEY_PUBLIC_PEM) with open('/tmp/private.der', 'w') as ff: ff.write(TEST_KEY_PRIVATE_DER) def teardown_module(self): os.unlink('/tmp/private') os.unlink('/tmp/public') class VapidTestCase(unittest.TestCase): def check_keys(self, v): assert v.private_key.private_numbers().private_value == key.get('d') assert v.public_key.public_numbers().x == key.get('x') assert v.public_key.public_numbers().y == key.get('y') def test_init(self): v1 = Vapid01.from_file("/tmp/private") self.check_keys(v1) v2 = Vapid01.from_pem(TEST_KEY_PRIVATE_PEM.encode()) self.check_keys(v2) v3 = Vapid01.from_der(TEST_KEY_PRIVATE_DER.encode()) self.check_keys(v3) v4 = Vapid01.from_file("/tmp/private.der") self.check_keys(v4) no_exist = '/tmp/not_exist' Vapid01.from_file(no_exist) assert os.path.isfile(no_exist) os.unlink(no_exist) def repad(self, data): return data + "===="[len(data) % 4:] @patch("py_vapid.Vapid01.from_pem", side_effect=Exception) def test_init_bad_read(self, mm): self.assertRaises(Exception, Vapid01.from_file, private_key_file="/tmp/private") def test_gen_key(self): v = Vapid01() v.generate_keys() assert v.public_key assert v.private_key def test_private_key(self): v = Vapid01() self.assertRaises(VapidException, lambda: v.private_key) def test_public_key(self): v = Vapid01() assert v._private_key is None assert v._public_key is None def test_save_key(self): v = Vapid01() v.generate_keys() v.save_key("/tmp/p2") os.unlink("/tmp/p2") def test_same_public_key(self): v = Vapid01() v.generate_keys() v.save_public_key("/tmp/p2") os.unlink("/tmp/p2") def test_from_raw(self): v = Vapid01.from_raw(TEST_KEY_PRIVATE_RAW) self.check_keys(v) def test_from_string(self): v1 = Vapid01.from_string(TEST_KEY_PRIVATE_DER) v2 = Vapid01.from_string(TEST_KEY_PRIVATE_RAW.decode()) self.check_keys(v1) self.check_keys(v2) def test_sign_01(self): v = Vapid01.from_string(TEST_KEY_PRIVATE_DER) claims = {"aud": "https://example.com", "sub": "mailto:admin@example.com"} result = v.sign(claims, "id=previous") assert result['Crypto-Key'] == ( 'id=previous;p256ecdsa=' + TEST_KEY_PUBLIC_RAW.decode('utf8')) pkey = binascii.b2a_base64( v.public_key.public_bytes( serialization.Encoding.X962, serialization.PublicFormat.UncompressedPoint ) ).decode('utf8').replace('+', '-').replace('/', '_').strip() items = decode(result['Authorization'].split(' ')[1], pkey) for k in claims: assert items[k] == claims[k] result = v.sign(claims) assert result['Crypto-Key'] == ( 'p256ecdsa=' + TEST_KEY_PUBLIC_RAW.decode('utf8')) # Verify using the same function as Integration # this should ensure that the r,s sign values are correctly formed assert Vapid01.verify( key=result['Crypto-Key'].split('=')[1], auth=result['Authorization'] ) def test_sign_02(self): v = Vapid02.from_file("/tmp/private") claims = {"aud": "https://example.com", "sub": "mailto:admin@example.com", "foo": "extra value"} claim_check = copy.deepcopy(claims) result = v.sign(claims, "id=previous") auth = result['Authorization'] assert auth[:6] == 'vapid ' assert ' t=' in auth assert ',k=' in auth parts = auth[6:].split(',') assert len(parts) == 2 t_val = json.loads(base64.urlsafe_b64decode( self.repad(parts[0][2:].split('.')[1]) ).decode('utf8')) k_val = binascii.a2b_base64(self.repad(parts[1][2:])) assert binascii.hexlify(k_val)[:2] == b'04' assert len(k_val) == 65 assert claims == claim_check for k in claims: assert t_val[k] == claims[k] def test_sign_02_localhost(self): v = Vapid02.from_file("/tmp/private") claims = {"aud": "http://localhost:8000", "sub": "mailto:admin@example.com", "foo": "extra value"} result = v.sign(claims, "id=previous") auth = result['Authorization'] assert auth[:6] == 'vapid ' assert ' t=' in auth assert ',k=' in auth def test_integration(self): # These values were taken from a test page. DO NOT ALTER! key = ("BDd3_hVL9fZi9Ybo2UUzA284WG5FZR30_95YeZJsiApwXKpNcF1rRPF3foI" "iBHXRdJI2Qhumhf6_LFTeZaNndIo") auth = ("eyJ0eXAiOiJKV1QiLCJhbGciOiJFUzI1NiJ9.eyJhdWQiOiJod" "HRwczovL3VwZGF0ZXMucHVzaC5zZXJ2aWNlcy5tb3ppbGxhLmNvbSIsImV" "4cCI6MTQ5NDY3MTQ3MCwic3ViIjoibWFpbHRvOnNpbXBsZS1wdXNoLWRlb" "W9AZ2F1bnRmYWNlLmNvLnVrIn0.LqPi86T-HJ71TXHAYFptZEHD7Wlfjcc" "4u5jYZ17WpqOlqDcW-5Wtx3x1OgYX19alhJ9oLumlS2VzEvNioZolQA") assert Vapid01.verify(key=key, auth="webpush {}".format(auth)) assert Vapid02.verify(auth="vapid t={},k={}".format(auth, key)) def test_bad_integration(self): # These values were taken from a test page. DO NOT ALTER! key = ("BDd3_hVL9fZi9Ybo2UUzA284WG5FZR30_95YeZJsiApwXKpNcF1rRPF3foI" "iBHXRdJI2Qhumhf6_LFTeZaNndIo") auth = ("WebPush eyJ0eXAiOiJKV1QiLCJhbGciOiJFUzI1NiJ9.eyJhdWQiOiJod" "HRwczovL3VwZGF0ZXMucHVzaC5zZXJ2aWNlcy5tb3ppbGxhLmNvbSIsImV" "4cCI6MTQ5NDY3MTQ3MCwic3ViIjoibWFpbHRvOnNpbXBsZS1wdXNoLWRlb" "W9AZ2F1bnRmYWNlLmNvLnVrIn0.LqPi86T-HJ71TXHAYFptZEHD7Wlfjcc" "4u5jYZ17WpqOlqDcW-5Wtx3x1OgYX19alhJ9oLumlS2VzEvNioZ_BAD") assert not Vapid01.verify(key=key, auth=auth) def test_bad_sign(self): v = Vapid01.from_file("/tmp/private") self.assertRaises(VapidException, v.sign, {}) self.assertRaises(VapidException, v.sign, {'sub': 'foo', 'aud': "p.example.com"}) self.assertRaises(VapidException, v.sign, {'sub': 'mailto:foo@bar.com', 'aud': "p.example.com"}) self.assertRaises(VapidException, v.sign, {'sub': 'mailto:foo@bar.com', 'aud': "https://p.example.com:8080/"}) def test_ignore_sub(self): v = Vapid02.from_file("/tmp/private") v.conf['no-strict'] = True assert v.sign({"sub": "foo", "aud": "http://localhost:8000"}) @patch('cryptography.hazmat.primitives.asymmetric' '.ec.EllipticCurvePublicNumbers') def test_invalid_sig(self, mm): from cryptography.exceptions import InvalidSignature ve = Mock() ve.verify.side_effect = InvalidSignature pk = Mock() pk.public_key.return_value = ve mm.from_encoded_point.return_value = pk self.assertRaises(InvalidSignature, decode, 'foo.bar.blat', 'aaaa') self.assertRaises(InvalidSignature, decode, 'foo.bar.a', 'aaaa') def test_sub(self): valid = [ 'mailto:me@localhost', 'mailto:me@1.2.3.4', 'mailto:me@1234::', 'mailto:me@1234::5678', 'mailto:admin@example.org', 'mailto:admin-test-case@example-test-case.test.org', 'https://localhost', 'https://exmample-test-case.test.org', 'https://8001::', 'https://8001:1000:0001', 'https://1.2.3.4' ] invalid = [ 'mailto:@foobar.com', 'mailto:example.org', 'mailto:0123:', 'mailto:::1234', 'https://somehost', 'https://xyz:123', ] for val in valid: assert _check_sub(val) is True for val in invalid: assert _check_sub(val) is False ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1732052696.0 py_vapid-1.9.2/py_vapid/utils.py0000664000175000017500000000163114717203330017144 0ustar00jrconlinjrconlinimport base64 import binascii def b64urldecode(data): """Decodes an unpadded Base64url-encoded string. :param data: data bytes to decode :type data: bytes :returns bytes """ return base64.urlsafe_b64decode(data + b"===="[len(data) % 4:]) def b64urlencode(data): """Encode a byte string into a Base64url-encoded string without padding :param data: data bytes to encode :type data: bytes :returns str """ return base64.urlsafe_b64encode(data).replace(b'=', b'').decode('utf8') def num_to_bytes(n, pad_to): """Returns the byte representation of an integer, in big-endian order. :param n: The integer to encode. :type n: int :param pad_to: Expected length of result, zeropad if necessary. :type pad_to: int :returns bytes """ h = '%x' % n r = binascii.unhexlify('0' * (len(h) % 2) + h) return b'\x00' * (pad_to - len(r)) + r ././@PaxHeader0000000000000000000000000000003400000000000010212 xustar0028 mtime=1732053313.4970698 py_vapid-1.9.2/py_vapid.egg-info/0000775000175000017500000000000014717204501017124 5ustar00jrconlinjrconlin././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1732053313.0 py_vapid-1.9.2/py_vapid.egg-info/PKG-INFO0000644000175000017500000001047714717204501020230 0ustar00jrconlinjrconlinMetadata-Version: 2.1 Name: py-vapid Version: 1.9.2 Summary: Simple VAPID header generation library Author-email: JR Conlin License: MPL-2.0 Project-URL: Homepage, https://github.com/mozilla-services/vapid Keywords: vapid,push,webpush Classifier: Topic :: Internet :: WWW/HTTP Classifier: Programming Language :: Python Classifier: Programming Language :: Python :: 3 Description-Content-Type: text/x-rst License-File: LICENSE Requires-Dist: cryptography>=2.5 |PyPI version py_vapid| Easy VAPID generation ===================== This minimal library contains the minimal set of functions you need to generate a VAPID key set and get the headers you’ll need to sign a WebPush subscription update. VAPID is a voluntary standard for WebPush subscription providers (sites that send WebPush updates to remote customers) to self-identify to Push Servers (the servers that convey the push notifications). The VAPID “claims” are a set of JSON keys and values. There are two required fields, one semi-optional and several optional additional fields. At a minimum a VAPID claim set should look like: :: {"sub":"mailto:YourEmail@YourSite.com","aud":"https://PushServer","exp":"ExpirationTimestamp"} A few notes: **sub** is the email address you wish to have on record for this request, prefixed with “``mailto:``”. If things go wrong, this is the email that will be used to contact you (for instance). This can be a general delivery address like “``mailto:push_operations@example.com``” or a specific address like “``mailto:bob@example.com``”. **aud** is the audience for the VAPID. This is the scheme and host you use to send subscription endpoints and generally coincides with the ``endpoint`` specified in the Subscription Info block. As example, if a WebPush subscription info contains: ``{"endpoint": "https://push.example.com:8012/v1/push/...", ...}`` then the ``aud`` would be “``https://push.example.com:8012``” While some Push Services consider this an optional field, others may be stricter. **exp** This is the UTC timestamp for when this VAPID request will expire. The maximum period is 24 hours. Setting a shorter period can prevent “replay” attacks. Setting a longer period allows you to reuse headers for multiple sends (e.g. if you’re sending hundreds of updates within an hour or so.) If no ``exp`` is included, one that will expire in 24 hours will be auto-generated for you. Claims should be stored in a JSON compatible file. In the examples below, we’ve stored the claims into a file named ``claims.json``. py_vapid can either be installed as a library or used as a stand along app, ``bin/vapid``. App Installation ---------------- You’ll need ``python virtualenv`` Run that in the current directory. Then run :: bin/pip install -r requirements.txt bin/python -m pip install -e . App Usage --------- Run by itself, ``bin/vapid`` will check and optionally create the public_key.pem and private_key.pem files. ``bin/vapid --gen`` can be used to generate a new set of public and private key PEM files. These will overwrite the contents of ``private_key.pem`` and ``public_key.pem``. ``bin/vapid --sign claims.json`` will generate a set of HTTP headers from a JSON formatted claims file. A sample ``claims.json`` is included with this distribution. ``bin/vapid --sign claims.json --json`` will output the headers in JSON format, which may be useful for other programs. ``bin/vapid --applicationServerKey`` will return the ``applicationServerKey`` value you can use to make a restricted endpoint. See https://developer.mozilla.org/en-US/docs/Web/API/PushManager/subscribe for more details. Be aware that this value is tied to the generated public/private key. If you remove or generate a new key, any restricted URL you’ve previously generated will need to be reallocated. Please note that some User Agents may require you `to decode this string into a Uint8Array `__. See ``bin/vapid -h`` for all options and commands. CHANGELOG --------- I’m terrible about updating the Changelog. Please see the ```git log`` `__ history for details. .. |PyPI version py_vapid| image:: https://badge.fury.io/py/py-vapid.svg :target: https://pypi.org/project/py-vapid/ ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1732053313.0 py_vapid-1.9.2/py_vapid.egg-info/SOURCES.txt0000664000175000017500000000064414717204501021014 0ustar00jrconlinjrconlinLICENSE MANIFEST.in README.md README.rst pyproject.toml requirements.txt setup.cfg test-requirements.txt py_vapid/__init__.py py_vapid/__main__.py py_vapid/jwt.py py_vapid/main.py py_vapid/utils.py py_vapid.egg-info/PKG-INFO py_vapid.egg-info/SOURCES.txt py_vapid.egg-info/dependency_links.txt py_vapid.egg-info/entry_points.txt py_vapid.egg-info/requires.txt py_vapid.egg-info/top_level.txt py_vapid/tests/test_vapid.py././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1732053313.0 py_vapid-1.9.2/py_vapid.egg-info/dependency_links.txt0000664000175000017500000000000114717204501023172 0ustar00jrconlinjrconlin ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1732053313.0 py_vapid-1.9.2/py_vapid.egg-info/entry_points.txt0000664000175000017500000000005514717204501022422 0ustar00jrconlinjrconlin[console_scripts] vapid = py_vapid.main:main ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1732053313.0 py_vapid-1.9.2/py_vapid.egg-info/requires.txt0000664000175000017500000000002214717204501021516 0ustar00jrconlinjrconlincryptography>=2.5 ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1732053313.0 py_vapid-1.9.2/py_vapid.egg-info/top_level.txt0000664000175000017500000000001114717204501021646 0ustar00jrconlinjrconlinpy_vapid ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1732053093.0 py_vapid-1.9.2/pyproject.toml0000664000175000017500000000141114717204145016534 0ustar00jrconlinjrconlin[build-system] requires = ["setuptools"] build-backend = "setuptools.build_meta" [project] name = "py-vapid" version = "1.9.2" license = {text = "MPL-2.0"} description = "Simple VAPID header generation library" readme = "README.rst" authors = [{name = "JR Conlin", email = "src+vapid@jrconlin.com"}] keywords = ["vapid", "push", "webpush"] classifiers = [ "Topic :: Internet :: WWW/HTTP", "Programming Language :: Python", "Programming Language :: Python :: 3", ] dynamic = ["dependencies"] [project.urls] Homepage = "https://github.com/mozilla-services/vapid" [project.scripts] vapid = "py_vapid.main:main" [tool.setuptools.dynamic] dependencies = {file = "requirements.txt"} [tool.setuptools.packages.find] include = ["py_vapid*"] ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1591632437.0 py_vapid-1.9.2/requirements.txt0000664000175000017500000000002213667461065017112 0ustar00jrconlinjrconlincryptography>=2.5 ././@PaxHeader0000000000000000000000000000003400000000000010212 xustar0028 mtime=1732053313.4980698 py_vapid-1.9.2/setup.cfg0000664000175000017500000000014714717204501015442 0ustar00jrconlinjrconlin[nosetests] verbose = True verbosity = 1 detailed-errors = True [egg_info] tag_build = tag_date = 0 ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1615570953.0 py_vapid-1.9.2/test-requirements.txt0000664000175000017500000000006714022724011020053 0ustar00jrconlinjrconlin-r requirements.txt pytest coverage mock>=1.0.1 flake8