misc-bdauvergne/qrcode-certificate/v1/signed_qrcode/model.py

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'})