Skip to content

Commit b2ea1e1

Browse files
committed
Initial Checkin
0 parents  commit b2ea1e1

12 files changed

+411
-0
lines changed

.gitignore

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
bin/
2+
build/
3+
dist/
4+
include/
5+
lib/
6+
local/
7+
man/
8+
*.egg-info
9+
*.pem
10+
*.pyc
11+
.*.sw?

README.md

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
# Easy VAPID generation
2+
3+
A set of VAPID encoding libraries for popular languages.
4+
5+
***PLEASE FEEL FREE TO SUBMIT YOUR FAVORITE LANGUAGE!***
6+
7+
VAPID is a draft specification for providing self identification.
8+
see https://datatracker.ietf.org/doc/draft-thomson-webpush-vapid/
9+
for the latest specification.
10+
11+
## TL;DR:
12+
13+
In short, you create a JSON blob that contains some contact
14+
information about your WebPush feed, for instance:
15+
16+
```
17+
{
18+
"aud": "https://YourSiteHere.example",
19+
"sub": "mailto://[email protected]",
20+
"exp": 1457718878
21+
}
22+
```
23+
24+
You then convert that to a [JWT](https://tools.ietf.org/html/rfc7519) encoded
25+
with `alg = "ES256"`

js/decode.js

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
2+
function decode(str) {
3+
/* Take a URL Safe base64 string and convert to a Uint8 Byte Array.
4+
*
5+
* See https://en.wikipedia.org/wiki/Base64 for characters exchanges
6+
*/
7+
cstr = atob(str.replace('-', '+').replace('_', '/'));
8+
arr = new Uint8Array(cstr.length)
9+
for (i=0; i<cstr.length;i++) {
10+
arr[i] = cstr.charCodeAt(i);
11+
}
12+
return arr;
13+
}
14+

python/CHANGELOG.md

Whitespace-only changes.

python/README.md

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
# Easy VAPID generation
2+
3+
This minimal library contains the minimal set of functions you need to
4+
generate a VAPID key set and get the headers you'll need to sign a
5+
WebPush subscription update.
6+
7+
This can either be installed as a library or used as a stand along
8+
app.
9+
10+
## App Installation
11+
12+
You'll need `python virtualenv` Run that in the current directory.
13+
14+
Then run
15+
```
16+
bin/pip install -r requirements.txt
17+
18+
bin/python setup.py`install
19+
```
20+
## App Usage
21+
22+
Run by itself, `bin/vapid` will check and optionally create the
23+
public_key.pem and private_key.pem files.
24+
25+
`bin/vapid --sign _claims.json_` will generate a set of HTTP headers
26+
from a JSON formatted claims file. A sample `claims.json` is included
27+
with this distribution.
28+
29+
`bin/vapid --validate _token_` will generate a token response for the
30+
Mozilla WebPush dashboard.
31+
32+

python/claims.json

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
{
2+
"aud": "https://my_site.example.com",
3+
"sub": "mailto:admin@my_site.example.com"
4+
}

python/requirements.txt

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
ecdsa==0.13
2+
python-jose==0.6.1

python/setup.py

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,41 @@
1+
import io
2+
import os
3+
4+
from vapid import __version__
5+
from setuptools import setup, find_packages
6+
7+
here = os.path.abspath(os.path.dirname(__file__))
8+
with io.open(os.path.join(here, 'README.md'), encoding='utf8') as f:
9+
README = f.read()
10+
with io.open(os.path.join(here, 'CHANGELOG.md'), encoding='utf8') as f:
11+
CHANGES = f.read()
12+
13+
extra_options = {
14+
"packages": find_packages(),
15+
}
16+
setup(name="VAPID library",
17+
version=__version__,
18+
description='Simple VAPID header generation library',
19+
long_description=README + '\n\n' + CHANGES,
20+
classifiers=["Topic :: Internet :: WWW/HTTP",
21+
'Programming Language :: Python',
22+
"Programming Language :: Python :: 2",
23+
"Programming Language :: Python :: 2.7"
24+
],
25+
keywords='vapid',
26+
author="JR Conlin",
27+
author_email="[email protected]",
28+
url='http:///',
29+
license="MPL2",
30+
test_suite="nose.collector",
31+
include_package_data=True,
32+
zip_safe=False,
33+
tests_require=['nose', 'coverage', 'mock>=1.0.1'],
34+
entry_points="""
35+
[console_scripts]
36+
vapid = vapid.main:main
37+
[nose.plugins]
38+
object-tracker = autopush.noseplugin:ObjectTracker
39+
""",
40+
**extra_options
41+
)

python/test-requirements.txt

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
nose
2+
coverage
3+
mock>=1.0.1
4+
flake8

python/vapid/__init__.py

Lines changed: 113 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,113 @@
1+
# This Source Code Form is subject to the terms of the Mozilla Public
2+
# License, v. 2.0. If a copy of the MPL was not distributed with this
3+
# file, You can obtain one at http://mozilla.org/MPL/2.0/.
4+
5+
import base64
6+
import time
7+
8+
import ecdsa
9+
import logging
10+
from jose import jws
11+
12+
__version__ = "0.1"
13+
14+
15+
class VapidException(Exception):
16+
pass
17+
18+
19+
class Vapid(object):
20+
"""Minimal VAPID signature generation library. """
21+
_private_key = None
22+
_public_key = None
23+
24+
def __init__(self, private_key_file=None):
25+
"""Initialize VAPID using an optional file containing a private key
26+
in PEM format.
27+
28+
:param private_key_file: The name of the file containing the
29+
private key
30+
"""
31+
if private_key_file:
32+
try:
33+
self.private_key = ecdsa.SigningKey.from_pem(
34+
open(private_key_file).read())
35+
except Exception, exc:
36+
import pdb;pdb.set_trace()
37+
logging.error("Could not open private key file: %s", repr(exc))
38+
raise VapidException(exc)
39+
self.pubilcKey = self.private_key.get_verifying_key()
40+
41+
@property
42+
def private_key(self):
43+
if not self._private_key:
44+
raise VapidException(
45+
"No private key defined. Please import or generate a key.")
46+
return self._private_key
47+
48+
@private_key.setter
49+
def private_key(self, value):
50+
self._private_key = value
51+
52+
@property
53+
def public_key(self):
54+
if not self._public_key:
55+
self._public_key = self.private_key.get_verifying_key()
56+
return self._public_key
57+
58+
def generate_keys(self):
59+
"""Generate a valid ECDSA Key Pair."""
60+
self.private_key = ecdsa.SigningKey.generate(curve=ecdsa.NIST256p)
61+
self.public_key
62+
63+
def save_key(self, key_file):
64+
"""Save the private key to a PEM file."""
65+
file = open(key_file, "w")
66+
if not self._private_key:
67+
self.generate_keys()
68+
file.write(self._private_key.to_pem())
69+
file.close()
70+
71+
def save_public_key(self, key_file):
72+
"""Save the public key to a PEM file.
73+
74+
:param key_file: The name of the file to save the public key
75+
"""
76+
file = open(key_file, "w")
77+
file.write(self.public_key.to_pem())
78+
file.close()
79+
80+
def validate(self, token):
81+
return base64.urlsafe_b64encode(self.private_key.sign(token))
82+
83+
def sign(self, claims, crypto_key=None):
84+
"""Sign a set of claims.
85+
86+
:param claims: JSON object containing the JWT claims to use.
87+
:param crypto_key: Optional existing crypto_key header content. The
88+
vapid public key will be appended to this data.
89+
:returns result: a hash containing the header fields to use in
90+
the subscription update.
91+
"""
92+
if not claims.get('exp'):
93+
claims['exp'] = int(time.time()) + 86400
94+
if not claims.get('aud'):
95+
raise VapidException(
96+
"Missing 'aud' from claims. "
97+
"'aud' is your site's URL.")
98+
if not claims.get('sub'):
99+
raise VapidException(
100+
"Missing 'sub' from claims. "
101+
"'sub' is your admin email as a mailto: link.")
102+
sig = jws.sign(claims, self.private_key, algorithm="ES256")
103+
pkey = 'p256ecdsa='
104+
pkey += base64.urlsafe_b64encode(self.public_key.to_string())
105+
if crypto_key:
106+
crypto_key = crypto_key + ';' + pkey
107+
else:
108+
crypto_key = pkey
109+
110+
return {"Authorization": "Bearer " + sig.strip('='),
111+
"Crypto-Key": crypto_key}
112+
113+

python/vapid/main.py

Lines changed: 82 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,82 @@
1+
# This Source Code Form is subject to the terms of the Mozilla Public
2+
# License, v. 2.0. If a copy of the MPL was not distributed with this
3+
# file, You can obtain one at http://mozilla.org/MPL/2.0/.
4+
5+
import argparse
6+
import os
7+
import json
8+
9+
from vapid import Vapid
10+
11+
12+
def main():
13+
parser = argparse.ArgumentParser(description="VAPID tool")
14+
parser.add_argument('--sign', '-s', help='claims file to sign')
15+
parser.add_argument('--validate', '-v', help='dashboard token to validate')
16+
args = parser.parse_args()
17+
if not os.path.exists('private_key.pem'):
18+
print "No private_key.pem file found."
19+
answer = None
20+
while answer not in ['y', 'n']:
21+
answer = raw_input("Do you want me to create one for you? (Y/n)")
22+
if not answer:
23+
answer = 'y'
24+
answer = answer.lower()[0]
25+
if answer == 'n':
26+
print "Sorry, can't do much for you then."
27+
exit
28+
if answer == 'y':
29+
break
30+
Vapid().save_key('private_key.pem')
31+
vapid = Vapid('private_key.pem')
32+
if not os.path.exists('public_key.pem'):
33+
print "No public_key.pem file found. You'll need this to access "
34+
print "the developer dashboard."
35+
answer = None
36+
while answer not in ['y', 'n']:
37+
answer = raw_input("Do you want me to create one for you? (Y/n)")
38+
if not answer:
39+
answer = 'y'
40+
answer = answer.lower()[0]
41+
if answer == 'y':
42+
vapid.save_public_key('public_key.pem')
43+
claim_file = args.sign
44+
if claim_file:
45+
if not os.path.exists(claim_file):
46+
print "No %s file found." % claim_file
47+
print """
48+
The claims file should be a JSON formatted file that holds the
49+
information that describes you. There are three elements in the claims
50+
file you'll need:
51+
52+
"aud" This is your site's URL (e.g. "https://example.com")
53+
"sub" This is your site's admin email address
54+
(e.g. "mailto:[email protected]")
55+
"exp" This is the expiration time for the claim in seconds. If you don't
56+
have one, I'll add one that expires in 24 hours.
57+
58+
For example, a claims.json file could contain:
59+
60+
{"aud": "https://example.com", "sub": "mailto:[email protected]"}
61+
"""
62+
exit
63+
try:
64+
claims = json.loads(open(claim_file).read())
65+
result = vapid.sign(claims)
66+
except Exception, exc:
67+
print "Crap, something went wrong: %s", repr(exc)
68+
69+
print "Include the following headers in your request:\n"
70+
for key, value in result.items():
71+
print "%s: %s" % (key, value)
72+
print "\n"
73+
74+
token = args.validate
75+
if token:
76+
print "signed token for dashboard validation:\n"
77+
print vapid.validate(token)
78+
print "\n"
79+
80+
81+
if __name__ == '__main__':
82+
main()

0 commit comments

Comments
 (0)