200 lines
5.4 KiB
Python
200 lines
5.4 KiB
Python
import io
|
|
import re
|
|
|
|
import nacl.exceptions
|
|
import nacl.signing
|
|
import qrcode
|
|
import qrcode.constants
|
|
import qrcode.image.pil
|
|
import qrcode.image.svg
|
|
|
|
|
|
class Error(Exception):
|
|
pass
|
|
|
|
|
|
class CryptoError(Error):
|
|
pass
|
|
|
|
|
|
class SigningError(CryptoError):
|
|
pass
|
|
|
|
|
|
class VerifyError(CryptoError):
|
|
pass
|
|
|
|
|
|
class EncodeError(Error):
|
|
pass
|
|
|
|
|
|
class DecodeError(VerifyError):
|
|
pass
|
|
|
|
|
|
class MimeLikeSerializer:
|
|
KEY_RE = re.compile('^[a-z][a-z0-9-]*$')
|
|
|
|
@classmethod
|
|
def encode(cls, data):
|
|
msg = ''
|
|
for key, value in data.items():
|
|
if not cls.KEY_RE.match(key):
|
|
raise ValueError(f'expected identifier key, got "{key!r}"')
|
|
if not isinstance(value, str):
|
|
raise ValueError(f'expected str value, got "{value!r}"')
|
|
msg += '%s: %s\n' % (key, value.replace('\n', '\n '))
|
|
return msg.encode()
|
|
|
|
@classmethod
|
|
def decode(cls, msg):
|
|
data = {}
|
|
key = None
|
|
value = ''
|
|
for line in msg.decode().splitlines():
|
|
if line.startswith(' '):
|
|
if not key:
|
|
continue
|
|
value += '\n' + line[1:]
|
|
else:
|
|
if key:
|
|
data[key] = value
|
|
value = ''
|
|
if ': ' in line:
|
|
key, value = line.split(': ', 1)
|
|
if not cls.KEY_RE.match(key):
|
|
key = None
|
|
value = ''
|
|
else:
|
|
continue
|
|
if key:
|
|
data[key] = value
|
|
return data
|
|
|
|
|
|
class CBORSerializer:
|
|
default = None
|
|
tag_hook = None
|
|
object_hook = None
|
|
date_as_datetime = False
|
|
date_as_timestamp = True
|
|
|
|
def encode(self, data):
|
|
import cbor2
|
|
|
|
return cbor2.dumps(
|
|
data,
|
|
default=self.default,
|
|
date_as_datetime=self.date_as_datetime,
|
|
datetime_as_timestamp=self.datetime_as_timestamp,
|
|
)
|
|
|
|
def decode(self, msg):
|
|
import cbor2
|
|
|
|
return cbor2.loads(msg, tag_hook=self.tag_hook, object_hook=self.object_hook)
|
|
|
|
|
|
def make_signed_data(key, data, serializer=MimeLikeSerializer):
|
|
try:
|
|
msg = serializer.encode(data)
|
|
except Exception as e:
|
|
raise EncodeError(e)
|
|
try:
|
|
signing_key = nacl.signing.SigningKey(seed=key)
|
|
signed = signing_key.sign(msg)
|
|
except nacl.exceptions.CryptoError as e:
|
|
raise SigningError(e)
|
|
return signed
|
|
|
|
|
|
def verify_signed_data(key, signed, serializer=MimeLikeSerializer):
|
|
try:
|
|
verify_key = nacl.signing.VerifyKey(key)
|
|
msg = verify_key.verify(signed)
|
|
except nacl.exceptions.CryptoError as e:
|
|
raise VerifyError(e)
|
|
try:
|
|
return serializer.decode(msg)
|
|
except Exception as e:
|
|
raise DecodeError(e)
|
|
|
|
|
|
BASE45_CHARSET = "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZ $%*+-./:"
|
|
BASE45_DICT = {v: i for i, v in enumerate(BASE45_CHARSET)}
|
|
|
|
|
|
# Use Base45 encoding to stay inside regular encoding alphabet of QR-codes as
|
|
# byte mode is not well supported by QR-code decoding libraries.
|
|
def b45encode(buf: bytes) -> bytes:
|
|
"""Convert bytes to base45-encoded string"""
|
|
res = ""
|
|
buflen = len(buf)
|
|
for i in range(0, buflen & ~1, 2):
|
|
x = (buf[i] << 8) + buf[i + 1]
|
|
e, x = divmod(x, 45 * 45)
|
|
d, c = divmod(x, 45)
|
|
res += BASE45_CHARSET[c] + BASE45_CHARSET[d] + BASE45_CHARSET[e]
|
|
if buflen & 1:
|
|
d, c = divmod(buf[-1], 45)
|
|
res += BASE45_CHARSET[c] + BASE45_CHARSET[d]
|
|
return res.encode()
|
|
|
|
|
|
FORMAT_SVG = 'svg'
|
|
FORMAT_PNG = 'png'
|
|
|
|
DEFAULT_SERIALIZER = MimeLikeSerializer
|
|
|
|
|
|
def make_verify_key(key):
|
|
return nacl.signing.SigningKey(seed=key).verify_key.encode()
|
|
|
|
|
|
def make_signed_qrcode(key, data, qrcode_format, serializer=None):
|
|
serializer = serializer or DEFAULT_SERIALIZER
|
|
signed = make_signed_data(key, data, serializer=serializer)
|
|
if qrcode_format not in (FORMAT_SVG, FORMAT_PNG):
|
|
raise ValueError(f'invalid qrcode_format "{qrcode_format}"')
|
|
if qrcode_format == 'svg':
|
|
image_factory = qrcode.image.svg.SvgPathFillImage
|
|
else:
|
|
image_factory = qrcode.image.pil.PilImage
|
|
qr = qrcode.QRCode(image_factory=image_factory, error_correction=qrcode.ERROR_CORRECT_Q)
|
|
qr.add_data(b45encode(signed).decode())
|
|
qr.make(fit=True)
|
|
img = qr.make_image(fill_color='black', back_color='white')
|
|
fd = io.BytesIO()
|
|
img.save(fd)
|
|
return fd.getvalue()
|
|
|
|
|
|
class TestSignedQRCode:
|
|
key = b'1234' * 8
|
|
verify_key = nacl.signing.SigningKey(seed=key).verify_key.encode()
|
|
data = {
|
|
'immatriculation': 'AZ-1234-EU',
|
|
'nom': 'Jean Dupond',
|
|
'date': '2022-06-10',
|
|
}
|
|
|
|
def test_make_signed_data(self):
|
|
assert verify_signed_data(self.verify_key, make_signed_data(self.key, self.data)) == self.data
|
|
|
|
def test_make_signed_qrcode(self):
|
|
assert len(make_signed_qrcode(self.key, self.data, FORMAT_SVG)) > 0
|
|
assert len(make_signed_qrcode(self.key, self.data, FORMAT_PNG)) > 0
|
|
|
|
def test_verify_signed_data_failure(self):
|
|
import pytest
|
|
|
|
with pytest.raises(VerifyError):
|
|
verify_signed_data(self.verify_key, b'xxx') is None
|
|
with pytest.raises(VerifyError):
|
|
verify_signed_data(b'123', b'xxx') is None
|
|
with pytest.raises(EncodeError):
|
|
make_signed_data(b'11', b'xxx')
|
|
with pytest.raises(SigningError):
|
|
make_signed_data(b'11', {'a': '1'})
|