Skip to content

Commit 936bf29

Browse files
committed
Initial commit
1 parent b966d6e commit 936bf29

8 files changed

Lines changed: 420 additions & 2 deletions

File tree

Pipfile

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
[[source]]
2+
name = "pypi"
3+
url = "https://pypi.org/simple"
4+
verify_ssl = true
5+
6+
[dev-packages]
7+
twine = "*"
8+
9+
[packages]
10+
pynacl = "*"
11+
12+
[requires]
13+
python_version = "3.8"

Pipfile.lock

Lines changed: 218 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

README.md

Lines changed: 62 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,2 +1,62 @@
1-
# formsg-python-sdk
2-
Python SDK for integrating with FormSG
1+
# FormSG Python SDK
2+
3+
[![PyPI version](https://img.shields.io/pypi/v/formsg.svg)](https://pypi.python.org/pypi/formsg/)
4+
[![PyPI license](https://img.shields.io/pypi/l/formsg.svg)](https://pypi.python.org/pypi/formsg/)
5+
[![PyPI pyversions](https://img.shields.io/pypi/pyversions/formsg.svg)](https://pypi.python.org/pypi/formsg/)
6+
[![PyPI status](https://img.shields.io/pypi/status/formsg.svg)](https://pypi.python.org/pypi/formsg/)
7+
[![PyPI download total](https://img.shields.io/pypi/dm/formsg.svg)](https://pypi.python.org/pypi/formsg/)
8+
9+
This SDK provides convenient utilities for verifying FormSG webhooks and decrypting submissions in Python and Flask or Django.
10+
11+
## Installation
12+
13+
```bash
14+
pip install formsg
15+
```
16+
17+
## Usage
18+
19+
The SDK provides two main utility functions for handling FormSG webhook:
20+
21+
- [`verify_signature(webhook_uri, signature_header, signature_expiry_seconds=60)`](formsg/webhook.py) verifies that the incoming webhook's signature is valid based on the FormSG production public key.
22+
It raises a `nacl.exceptions.BadSignatureError` if the signature is invalid.
23+
The signature header is usually found in the `X-FormSG-Signature` header.
24+
Details on how the signature is constructed can be found [here](https://github.com/opengovsg/formsg-javascript-sdk/#verifying-signatures-manually).
25+
26+
- [`decrypt_content(body_json, secret_key)`](formsg/webhook.py) will decrypt the encrypted content using the given Base-64 encoded secret key.
27+
`body_json` is expected to be a dictionary-like object.
28+
29+
For convenience, the SDK implements a [`decrypt_django_request`](formsg/django.py) and [`decrypt_flask_request`](formsg/flask.py) which returns the decrypted FormSG content from a Django/Flask request object directly.
30+
31+
### Example with Flask
32+
33+
```python
34+
from formsg.webhook import decrypt_flask_request
35+
36+
from flask import Flask
37+
from flask import jsonify
38+
from flask import request
39+
40+
app = Flask(__name__)
41+
42+
43+
@app.route('/formsg_webhook', methods=['POST'])
44+
def formsg_webhook():
45+
decrypted = decrypt_flask_request(
46+
request,
47+
secret_key='o4qGJ/AFpmToTOpqptMyTsV3WofQjD7dX6cpVZ7RwNA=',
48+
webhook_uri='https://90da680eb8fa.ngrok.io/formsg_webhook', # we use ngrok to test our webhooks locally
49+
)
50+
51+
return jsonify(decrypted)
52+
#end def
53+
54+
55+
if __name__ == '__main__':
56+
app.run(debug=True)
57+
#end if
58+
```
59+
60+
## Contributions
61+
62+
If you find any issues or would like to contribute improvements, please feel free to raise them in this repository directly.

formsg/__init__.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
from .webhook import *
2+
from .django import *
3+
from .flask import *

formsg/django.py

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
__all__ = ['decrypt_django_request']
2+
from .webhook import decrypt_content
3+
from .webhook import verify_signature
4+
import json
5+
6+
7+
def decrypt_django_request(request, secret_key, webhook_uri=None, signature_expiry_seconds=60):
8+
if webhook_uri is None:
9+
webhook_uri = request.build_absolute_uri()
10+
11+
verify_signature(webhook_uri, request.headers['X-FormSG-Signature'], signature_expiry_seconds=signature_expiry_seconds)
12+
13+
body_json = json.loads(request.body)
14+
15+
return decrypt_content(body_json, secret_key)
16+
#end def

formsg/flask.py

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
__all__ = ['decrypt_flask_request']
2+
from .webhook import decrypt_content
3+
from .webhook import verify_signature
4+
5+
6+
def decrypt_flask_request(request, secret_key, webhook_uri=None, signature_expiry_seconds=60):
7+
if webhook_uri is None:
8+
webhook_uri = request.url
9+
10+
verify_signature(webhook_uri, request.headers['X-FormSG-Signature'], signature_expiry_seconds=signature_expiry_seconds)
11+
12+
body_json = request.get_json()
13+
14+
return decrypt_content(body_json, secret_key)
15+
#end def

formsg/webhook.py

Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,63 @@
1+
__all__ = ['verify_signature', 'decrypt_content']
2+
import json
3+
import logging
4+
import re
5+
from time import time
6+
from urllib.parse import urlparse
7+
8+
from nacl.encoding import Base64Encoder
9+
from nacl.exceptions import BadSignatureError
10+
from nacl.public import Box
11+
from nacl.public import PrivateKey
12+
from nacl.public import PublicKey
13+
from nacl.signing import VerifyKey
14+
15+
logger = logging.getLogger(__name__)
16+
17+
FORMSG_WEBHOOK_PUBLIC_KEY = VerifyKey('3Tt8VduXsjjd4IrpdCd7BAkdZl/vUCstu9UvTX84FWw=', encoder=Base64Encoder)
18+
ENCRYPTED_CONTENT_REGEX = re.compile(r'^(?P<submission_public_key>[\w\+\/\=]*)\;(?P<nonce>[\w\+\/\=]*)\:(?P<encrypted_message>[\w\+\/\=]*)$')
19+
20+
21+
def verify_signature(webhook_uri, signature_header, signature_expiry_seconds=60):
22+
# v1 is signature, s is submissionId, f is formId, t is submission epoch
23+
logger.debug(f'X-FormSG-Signature is <{signature_header}>.')
24+
formsg_signature = dict(part.split('=', 1) for part in signature_header.split(','))
25+
formsg_signature['t'] = int(formsg_signature['t'])
26+
27+
# Javascript url.href adds a trailing `/` to root domain urls
28+
# https://github.com/opengovsg/formsg-javascript-sdk/blob/master/src/webhooks.ts#L25
29+
u = urlparse(webhook_uri)
30+
if not u.path:
31+
u = u._replace(path='/')
32+
webhook_uri = u.geturl()
33+
34+
FORMSG_WEBHOOK_PUBLIC_KEY.verify(
35+
smessage=f'{webhook_uri}.{formsg_signature["s"]}.{formsg_signature["f"]}.{formsg_signature["t"]}'.encode('ascii'),
36+
signature=Base64Encoder.decode(formsg_signature['v1']),
37+
)
38+
39+
if time() - (formsg_signature['t'] / 1000) > signature_expiry_seconds:
40+
raise BadSignatureError('FormSG signature has expired.')
41+
42+
return formsg_signature
43+
#end def
44+
45+
46+
def decrypt_content(body_json, secret_key):
47+
if 'data' in body_json:
48+
encrypted_content = body_json['data']['encryptedContent']
49+
else:
50+
encrypted_content = body_json['encryptedContent'] # old version POST body
51+
#end if
52+
53+
submission_public_key, nonce, encrypted_message = ENCRYPTED_CONTENT_REGEX.match(encrypted_content).groups()
54+
55+
box = Box(
56+
PrivateKey(secret_key, encoder=Base64Encoder),
57+
PublicKey(submission_public_key, encoder=Base64Encoder),
58+
)
59+
60+
plaintext = box.decrypt(encrypted_message, Base64Encoder.decode(nonce), encoder=Base64Encoder)
61+
62+
return json.loads(plaintext)
63+
#end def

0 commit comments

Comments
 (0)