add qrcode-certficate v1
This commit is contained in:
parent
629bbc155b
commit
84593ccf94
|
@ -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
|
|
@ -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>
|
|
@ -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"
|
||||
}
|
||||
}
|
|
@ -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();
|
||||
});
|
|
@ -0,0 +1,8 @@
|
|||
from .model import (
|
||||
FORMAT_PNG,
|
||||
FORMAT_SVG,
|
||||
make_signed_data,
|
||||
make_signed_qrcode,
|
||||
make_verify_key,
|
||||
verify_signed_data,
|
||||
)
|
|
@ -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()
|
|
@ -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'})
|
Loading…
Reference in New Issue