📷 QR Code Login POC

Proof of Concept for authenticating via a QR code.

The question here is whether we can provide a relatively-secure method of logging into a web application via scanning a QR code. A user may be too encumbered to access a keyboard but can still access a QR scanner. The proposed process hits two of the three authentication factors:

  • (i) something you know (e.g. password/personal identification number (PIN));
  • (ii) something you have (e.g., cryptographic identification device, token);
  • or (iii) something you are (e.g., biometric).

A user would enter their PIN into a device and then would be presented with a QR code to scan. The QR code would contain a time-based one-time-password (TOTP), generated from a combination (hash) of a shared secret and the user's PIN. The server would then be able to generate it's own TOTP based on a hash stored in the DB (the user would configure their PIN on the web app). The server would never need to store the PIN or the shared secret. It would only need to store the hash of the shared secret + the user's chosen PIN.

Issues to work out

I haven't come up with a good solution for how to populate the device with the shared secret (aside from setting it up for an end user). We'd want an easy way to reset that secret in case the device was ever compromised. Also, a three-character-based PIN does not provide very much entropy.

QR Authentication Flow

QR POC diagram

Device Prototype

I prototyped the functionality out on a Raspberry Pi Pico-based Badger2040.

QR Sign-In Prototype

Here is some quick-and-dirty Python:

import badger2040
import qrcode
import time
import os
import badger_os
import base64
import hashlib
import binascii
from totp import totp
from machine import Pin

secret = 'secret'
pin_string = ''
one_time_password = ()
rtc = machine.RTC()
time_remaining = 0
changed = False

def a_button_callback(pin):
    global pin_string
    changed = False
    pin_string += 'a'
    print(pin_string)

def b_button_callback(pin):
    global pin_string
    changed = False
    pin_string += 'b'
    print(pin_string)

def c_button_callback(pin):
    global pin_string
    changed = False
    pin_string += 'c'
    print(pin_string)

button_a = Pin(badger2040.BUTTON_A,Pin.IN,Pin.PULL_DOWN)
button_a.irq(trigger=Pin.IRQ_FALLING, handler=a_button_callback)
button_b = Pin(badger2040.BUTTON_B,Pin.IN,Pin.PULL_DOWN)
button_b.irq(trigger=Pin.IRQ_FALLING, handler=b_button_callback)
button_c = Pin(badger2040.BUTTON_C,Pin.IN,Pin.PULL_DOWN)
button_c.irq(trigger=Pin.IRQ_FALLING, handler=c_button_callback)

display = badger2040.Badger2040()

def measure_qr_code(size, code):
    w, h = code.get_size()
    module_size = int(size / w)
    return module_size * w, module_size


def draw_qr_code(ox, oy, size, code):
    size, module_size = measure_qr_code(size, code)
    display.pen(15)
    display.rectangle(ox, oy, size, size)
    display.pen(0)
    for x in range(size):
        for y in range(size):
            if code.get_module(x, y):
                display.rectangle(ox + x * module_size, oy + y * module_size, module_size, module_size)

def draw_button_text():
    display.pen(15)
    display.thickness(1)
    display.rectangle(
        127, # int: x coordinate of the rectangle's top left corner
        0, # int: y coordinate of the rectangle's top left corner
        170, # int: width of rectangle
        128  # int: height of rectangle
    )
    display.pen(0)
    display.text(
        'Provide PIN and press ENTER', # string: the text to draw
        128,            # int: x coordinate for the left middle of the text
        5,            # int: y coordinate for the left middle of the text
        scale=0.38,    # float: size of the text
        rotation=0.0  # float: rotation of the text in degrees
    )
    display.thickness(2)
    display.text(
        'RESET', # string: the text to draw
        250,            # int: x coordinate for the left middle of the text
        30,            # int: y coordinate for the left middle of the text
        scale=0.5,    # float: size of the text
        rotation=0.0  # float: rotation of the text in degrees
    )
    display.text(
        'ENTER', # string: the text to draw
        250,            # int: x coordinate for the left middle of the text
        95,            # int: y coordinate for the left middle of the text
        scale=0.5,    # float: size of the text
        rotation=0.0  # float: rotation of the text in degrees
    )

def draw_intro_text():
    display.pen(15)
    display.thickness(2)
    display.rectangle(
        0, # int: x coordinate of the rectangle's top left corner
        0, # int: y coordinate of the rectangle's top left corner
        296, # int: width of rectangle
        128  # int: height of rectangle
    )
    display.pen(0)
    display.text(
        'Provide PIN and press ENTER', # string: the text to draw
        1,            # int: x coordinate for the left middle of the text
        5,            # int: y coordinate for the left middle of the text
        scale=0.5,    # float: size of the text
        rotation=0.0  # float: rotation of the text in degrees
    )
    display.text(
        'RESET', # string: the text to draw
        250,            # int: x coordinate for the left middle of the text
        30,            # int: y coordinate for the left middle of the text
        scale=0.5,    # float: size of the text
        rotation=0.0  # float: rotation of the text in degrees
    )
    display.text(
        'ENTER', # string: the text to draw
        250,            # int: x coordinate for the left middle of the text
        95,            # int: y coordinate for the left middle of the text
        scale=0.5,    # float: size of the text
        rotation=0.0  # float: rotation of the text in degrees
    )
    display.text(
        'A', # string: the text to draw
        40,            # int: x coordinate for the left middle of the text
        120,            # int: y coordinate for the left middle of the text
        scale=0.5,    # float: size of the text
        rotation=0.0  # float: rotation of the text in degrees
    )
    display.text(
        'B', # string: the text to draw
        145,            # int: x coordinate for the left middle of the text
        120,            # int: y coordinate for the left middle of the text
        scale=0.5,    # float: size of the text
        rotation=0.0  # float: rotation of the text in degrees
    )
    display.text(
        'C', # string: the text to draw
        250,            # int: x coordinate for the left middle of the text
        120,            # int: y coordinate for the left middle of the text
        scale=0.5,    # float: size of the text
        rotation=0.0  # float: rotation of the text in degrees
    )


# wake = not badger2040.woken_by_button()
draw_intro_text()
display.update()
year, month, day, wd, hour, minute, second, _ = rtc.datetime()
last_second = second

while True:
    year, month, day, wd, hour, minute, second, _ = rtc.datetime()

    if display.pressed(badger2040.BUTTON_UP):
        pin_string = ''
        display.update_speed(badger2040.UPDATE_NORMAL)
        display.clear()
        draw_intro_text()
        display.update()
        time.sleep_ms(250)

    if display.pressed(badger2040.BUTTON_DOWN):
        print(str(month) + '/' + str(day) + '/' + str(year) + ' ' + str(hour) + ':' + str(minute) + ':' + str(second))
        changed = True

    if second != last_second and time_remaining > 0:
        last_second = second
        time_remaining = time_remaining - 1
        display.update_speed(badger2040.UPDATE_TURBO)
        display.thickness(3)
        display.pen(15)
        display.rectangle(130, 48, 64, 32)
        display.pen(0)
        display.text(
            str(time_remaining), # string: the text to draw
            145,            # int: x coordinate for the left middle of the text
            64,            # int: y coordinate for the left middle of the text
            scale=0.75,    # float: size of the text
            rotation=0.0  # float: rotation of the text in degrees
        )
        display.partial_update(
            130,  # int: x coordinate of the update region
            48,  # int: y coordinate of the update region (must be a multiple of 8)
            64,  # int: width of the update region
            32   # int: height of the 4update region (must be a multiple of 8)
        )

    if time_remaining <= 0:
        # Halt the Badger to save power, it will wake up if any of the front buttons are pressed
        display.halt()

    if changed and pin_string:
        display.update_speed(badger2040.UPDATE_NORMAL)
        display.thickness(1)

        print(pin_string)

        s_and_p = secret + pin_string
        print(s_and_p)
        secret_and_pin_hashed = binascii.hexlify(hashlib.sha256(str(secret + pin_string).encode()).digest())
        print(secret_and_pin_hashed)
        secret_and_pin_hashed_and_encoded = base64.b32encode(secret_and_pin_hashed).decode('utf-8')
        print(secret_and_pin_hashed_and_encoded)
        one_time_password = totp(time.time(), secret_and_pin_hashed_and_encoded, step_secs=60, digits=8)
        print(one_time_password)

        code = qrcode.QRCode()
        code.set_text("LOGIN+" + one_time_password[0])

        time_remaining = one_time_password[1]

        display.clear()

        max_size = min(128, 128)

        size, module_size = measure_qr_code(max_size, code)
        left = int((128 // 2) - (size // 2))
        top = int((128 // 2) - (size // 2))
        draw_qr_code(left, top, max_size, code)

        draw_button_text()

        display.update()
        pin_string = ''

        changed = False

    time.sleep(0.01)

You can find the rest of the POC code here: https://github.com/arippberger/qr-login-poc