add qrcode-certficate v1

This commit is contained in:
Benjamin Dauvergne 2022-09-28 11:38:16 +02:00
parent 629bbc155b
commit 84593ccf94
7 changed files with 425 additions and 0 deletions

View File

@ -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

View File

@ -0,0 +1,20 @@
<html>
<head>
<script type="text/javascript" src="./index.min.js"></script>
<style>
body { width: 90vmin; margin: 1em auto; }
#reader { display: block; width: 80%; margin: 1ex auto;}
#verify-key { width: 100%; }
</style>
</head>
<body>
<div>
<label>VERIFY KEY</label>
<input id="verify-key" value="0dafa08f33249cfbd131340f228f358af1e7e699df50a2a0a2aaecb4a1e916c3"/>
</div>
<label>SCAN QR CODE</label>
<div id="reader"></div>
<label>RESULT</label>
<pre id="text" style="font-size: 2rem; white-space: pre-wrap;"></pre>
</body>
</html>

View File

@ -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"
}
}

View File

@ -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();
});

View File

@ -0,0 +1,8 @@
from .model import (
FORMAT_PNG,
FORMAT_SVG,
make_signed_data,
make_signed_qrcode,
make_verify_key,
verify_signed_data,
)

View File

@ -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()

View File

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