From 84593ccf9473a00efe8c5cae69b779e2170341ff Mon Sep 17 00:00:00 2001 From: Benjamin Dauvergne Date: Wed, 28 Sep 2022 11:38:16 +0200 Subject: [PATCH] add qrcode-certficate v1 --- qrcode-certificate/v1/README | 25 +++ .../v1/qrcode-reader/index.html | 20 ++ .../v1/qrcode-reader/package.json | 17 ++ .../v1/qrcode-reader/src/app.js | 102 +++++++++ .../v1/signed_qrcode/__init__.py | 8 + .../v1/signed_qrcode/__main__.py | 54 +++++ qrcode-certificate/v1/signed_qrcode/model.py | 199 ++++++++++++++++++ 7 files changed, 425 insertions(+) create mode 100644 qrcode-certificate/v1/README create mode 100644 qrcode-certificate/v1/qrcode-reader/index.html create mode 100644 qrcode-certificate/v1/qrcode-reader/package.json create mode 100644 qrcode-certificate/v1/qrcode-reader/src/app.js create mode 100644 qrcode-certificate/v1/signed_qrcode/__init__.py create mode 100644 qrcode-certificate/v1/signed_qrcode/__main__.py create mode 100644 qrcode-certificate/v1/signed_qrcode/model.py 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'})