diff --git a/qrcode-certificate/v1/README b/qrcode-certificate/v1/README
new file mode 100644
index 0000000..92e07f1
--- /dev/null
+++ b/qrcode-certificate/v1/README
@@ -0,0 +1,25 @@
+QRCode certificate
+==================
+
+Create qrcode containing certificate signed with an NaCl assymetric key, the data is JSON like encoded with a MIME-like encoder or CBOR.
+
+To produce a qrcode:
+--------------------
+
+ $ python3 -m signed_qrcode --output-format png 1234123412341234123412341234123412341234123412341234123412341234 coin:bouh test.png
+ Verify key: 0dafa08f33249cfbd131340f228f358af1e7e699df50a2a0a2aaecb4a1e916c3
+
+Print test.png on a sheet of paper (or display it on your mobile phone)
+
+To read it:
+-----------
+
+* Go into qrcode-reader
+* Build the JS code:
+
+ npm install
+ npm run build
+
+* Open index.html in your browser
+* Set the verify key to: 0dafa08f33249cfbd131340f228f358af1e7e699df50a2a0a2aaecb4a1e916c3
+* Show the QRcode produced at the last paragraph to your camera
diff --git a/qrcode-certificate/v1/qrcode-reader/index.html b/qrcode-certificate/v1/qrcode-reader/index.html
new file mode 100644
index 0000000..e2af223
--- /dev/null
+++ b/qrcode-certificate/v1/qrcode-reader/index.html
@@ -0,0 +1,20 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/qrcode-certificate/v1/qrcode-reader/package.json b/qrcode-certificate/v1/qrcode-reader/package.json
new file mode 100644
index 0000000..9c9564e
--- /dev/null
+++ b/qrcode-certificate/v1/qrcode-reader/package.json
@@ -0,0 +1,17 @@
+{
+ "dependencies": {
+ "base45-js": "^1.0.2",
+ "cbor": "^8.1.0",
+ "html5-qrcode": "^2.2.1",
+ "tweetnacl": "^1.0.3",
+ "tweetnacl-util": "^0.15.1"
+ },
+ "devDependencies": {
+ "browserify": "^17.0.0",
+ "terser": "^5.14.0"
+ },
+ "scripts": {
+ "build": "browserify src/app.js -o index.js && terser --compress -o index.min.js -- index.js",
+ "clean": "rm index.js"
+ }
+}
diff --git a/qrcode-certificate/v1/qrcode-reader/src/app.js b/qrcode-certificate/v1/qrcode-reader/src/app.js
new file mode 100644
index 0000000..c5f5874
--- /dev/null
+++ b/qrcode-certificate/v1/qrcode-reader/src/app.js
@@ -0,0 +1,102 @@
+let html5qrcode = require('html5-qrcode');
+let b45 = require('base45-js');
+let nacl = require('tweetnacl');
+let cbor = require('cbor');
+
+function init() {
+ let pre = document.getElementById('text');
+ let key_input = document.getElementById('verify-key');
+ let key = Buffer.from(key_input.value, 'hex');
+ function update_key() {
+ key = Buffer.from(key_input.value, 'hex');
+ console.log('new key', key)
+ }
+ key_input.change = update_key;
+ update_key();
+
+ function onScanSuccess(c, decodedResult) {
+ pre.textContent = 'Decoding...'
+ try {
+ let signed;
+ console.log("Decoding", decodedResult);
+ try {
+ signed = b45.decode(c);
+ } catch (error) {
+ pre.textContent = 'B45 decoding failed: ' + error
+ console.log("b45 decoding error", error);
+ return true;
+ }
+ console.log('signed', signed.toString("hex"));
+
+ // Check NaCl signature
+ let opened = nacl.sign.open(signed, key)
+ if (opened == null) {
+ pre.textContent = 'Signature validation failed'
+ return true;
+ }
+ console.log('opened', opened);
+ let decoder = new TextDecoder('utf-8');
+ const decoded = decoder.decode(opened);
+ console.log('decoded', decoded);
+
+ // MIME-like decoder
+ const chunks = decoded.split('\n');
+ let k = null;
+ let v = null;
+ let data = {};
+ for (let i = 0; i < chunks.length; i++) {
+ const line = chunks[i];
+ if (line.startsWith(' ')) {
+ if (k !== null) {
+ v += '\n' + line.slice(1);
+ }
+ } else {
+ if (k !== null) {
+ data[k] = v;
+ k = null;
+ v = null;
+ }
+ if (line.indexOf(': ') != -1) {
+ const parts = line.split(': ', 2);
+ k = parts[0];
+ v = parts[1];
+ }
+ }
+ }
+ if (k !== null) {
+ data[k] = v;
+ }
+ console.log('data', data);
+ pre.textContent = JSON.stringify(data, null, ' ');
+ return true;
+ } catch (error) {
+ pre.textContent = 'Unknown error: ' + error
+ console.log("unknown error", error);
+ return true;
+ }
+ }
+ let qrboxFunction = function(viewfinderWidth, viewfinderHeight) {
+ let minEdgePercentage = 0.7; // 70%
+ let minEdgeSize = Math.min(viewfinderWidth, viewfinderHeight);
+ let qrboxSize = Math.floor(minEdgeSize * minEdgePercentage);
+ return {
+ width: qrboxSize,
+ height: qrboxSize
+ };
+ };
+ let scanner = new html5qrcode.Html5Qrcode("reader", {
+ formatsToSupport: [
+ html5qrcode.Html5QrcodeSupportedFormats.QR_CODE,
+ ],
+ });
+ scanner.start(
+ { facingMode: "environment" },
+ { fps: 10, qrbox: qrboxFunction, aspectRation: 2, disableFlip: false},
+ onScanSuccess
+ );
+}
+
+document.addEventListener('DOMContentLoaded', function (event) {
+ console.log('cbor', cbor.encode({a: 1}));
+ init();
+});
diff --git a/qrcode-certificate/v1/signed_qrcode/__init__.py b/qrcode-certificate/v1/signed_qrcode/__init__.py
new file mode 100644
index 0000000..27c1334
--- /dev/null
+++ b/qrcode-certificate/v1/signed_qrcode/__init__.py
@@ -0,0 +1,8 @@
+from .model import (
+ FORMAT_PNG,
+ FORMAT_SVG,
+ make_signed_data,
+ make_signed_qrcode,
+ make_verify_key,
+ verify_signed_data,
+)
diff --git a/qrcode-certificate/v1/signed_qrcode/__main__.py b/qrcode-certificate/v1/signed_qrcode/__main__.py
new file mode 100644
index 0000000..5306b1e
--- /dev/null
+++ b/qrcode-certificate/v1/signed_qrcode/__main__.py
@@ -0,0 +1,54 @@
+import binascii
+import re
+
+import click
+import signed_qrcode
+
+
+class KeyValueType(click.ParamType):
+ name = 'keyvalue'
+
+ def convert(self, value, param, ctx):
+ if not re.match('^[a-z][a-z0-9-]*:.*$', value):
+ return self.fail(f'exected key:value string got "{value!r}"', param, ctx)
+ return tuple(value.split(':', 1))
+
+
+class KeyType(click.ParamType):
+ name = 'keyvalue'
+
+ def convert(self, value, param, ctx):
+ if not re.match('^[a-f0-9]{64}$', value):
+ return self.fail(f'exected 32 bytes hex-string got "{value!r}"', param, ctx)
+ return binascii.unhexlify(value)
+
+
+@click.command()
+@click.argument('key', type=KeyType())
+@click.argument('keyvalue', nargs=-1, type=KeyValueType())
+@click.argument('output', type=click.File('wb'))
+@click.option(
+ '--output-format',
+ type=click.Choice(
+ [
+ signed_qrcode.FORMAT_PNG,
+ signed_qrcode.FORMAT_SVG,
+ ],
+ case_sensitive=False,
+ ),
+ default=signed_qrcode.FORMAT_PNG,
+)
+@click.option('--debug', default=False)
+def main(key, output_format, keyvalue, output, debug=False):
+ if debug:
+ signed = bytes(signed_qrcode.make_signed_data(key, dict(keyvalue)))
+ for i, d in enumerate(signed):
+ print(i, d)
+ content = signed_qrcode.make_signed_qrcode(key, dict(keyvalue), output_format)
+ with output as fd:
+ fd.write(content)
+ verify_key = signed_qrcode.make_verify_key(key)
+ print('Verify key:', binascii.hexlify(verify_key).decode())
+
+
+main()
diff --git a/qrcode-certificate/v1/signed_qrcode/model.py b/qrcode-certificate/v1/signed_qrcode/model.py
new file mode 100644
index 0000000..4fbd9e3
--- /dev/null
+++ b/qrcode-certificate/v1/signed_qrcode/model.py
@@ -0,0 +1,199 @@
+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'})