roelderickx / connective-plugin-linux

A replacement for the Connective Plugin which is used on several websites to log in or sign documents using a card reader and an electronic identity card.
GNU General Public License v3.0
36 stars 2 forks source link

Optimize the GUI code #5

Closed roelderickx closed 2 years ago

roelderickx commented 2 years ago

@ghpille [...] you may be able to reduce the GUI code a lot. Let me give you an example:

import tkinter as tk
from tkinter import ttk

def bHit(what):
  print(what)

root = tk.Tk()
root.geometry('200x600')
root.resizable(False, True)
root.title('Button Demo')

buttons = []
for i in range(10):
  buttons.append(ttk.Button(root,text=i,command=lambda i=i: bHit(i)));

for b in buttons:
  b.pack()

exit_button = ttk.Button(    root,    text='Exit',    command=lambda: root.quit())
exit_button.pack(    ipadx=5,    ipady=5,    expand=True)

root.mainloop()
ghpille commented 2 years ago
#!/usr/bin/env python

# -*- coding: utf-8 -*-

import sys
import struct
import json
import time

import tkinter.messagebox
import tkinter as tk
import tkinter.font as font

import smartcard
import smartcard.util

DEBUG = True

APPLET_AID = [ 0x00, 0xA4, 0x04, 0x00, 0x0F, 0xA0, 0x00, 0x00, 0x00, 0x30, 0x29, 0x05,
               0x70, 0x00, 0xAD, 0x13, 0x10, 0x01, 0x01, 0xFF ]
BELPIC_AID = [ 0x00, 0xA4, 0x04, 0x0C, 0x0C, 0xA0, 0x00, 0x00, 0x01, 0x77, 0x50, 0x4B,
               0x43, 0x53, 0x2D, 0x31, 0x35 ]

if sys.platform == "darwin":
    MAX_APDU_READ_LEN = 248
else:
    MAX_APDU_READ_LEN = 252

CCID_VERIFY_START = 0x01
CCID_VERIFY_FINISH = 0x02
CCID_VERIFY_DIRECT = 0x06
CCID_CHANGE_START = 0x03
CCID_CHANGE_FINISH = 0x04
CCID_CHANGE_DIRECT = 0x07

MAX_PIN_LENGTH = 12

class NumpadWindow(tk.Frame):
    def __init__(self, master=None):
        tk.Frame.__init__(self, master)
        self.master = master
        self.pack(fill=tk.BOTH, expand=1)

        self.master.wm_title("Connective")
        self.master.resizable(False, False)

        text_large_font = font.Font(size=16)
        self.text_pincode = tk.Label(self, font=text_large_font, width=12)
        self.text_pincode.grid(row=1, column=1 ,columnspan=3)
        self.pincode = ''

        button_large_font = font.Font(size=24, weight='bold')
        buttons = []
        for i in range(10):
          if i == 0:
            button_r = 5
            button_c = 2
          else:
            button_r = (i - 1) // 3 + 2
            button_c = (i - 1) % 3 + 1
          b = tk.Button(self, text=i, font=button_large_font, \
                        width=3, height=2, \
                        command=lambda i=i: self.click_button(i))
          b.grid(row=button_r, column=button_c)
          buttons.append(b)

        button_c = tk.Button(self, text="C", font=button_large_font, width=3, height=2, \
                             command=self.click_button_c)
        button_c.grid(row=5, column=1)
        button_ok = tk.Button(self, text="Ok", font=button_large_font, width=3, height=2, \
                             command=self.click_button_ok)
        button_ok.grid(row=5, column=3)

        self.master.bind("<Key>", self.key_pressed)

    def __add_code(self, char):
        if len(self.pincode) < MAX_PIN_LENGTH and char in '1234567890':
            self.pincode += char
            self.text_pincode.config(text='*' * len(self.pincode))
        elif len(self.pincode) > 0 and char in [ chr(8), 'c', 'C' ]:
            self.pincode = self.pincode[:-1]
            self.text_pincode.config(text='*' * len(self.pincode))
        elif char in [ chr(27), 'q', 'Q' ]:
            self.pincode = ''
            self.master.destroy()
        elif char == chr(13):
            self.master.destroy()

    def click_button(self,c):
        self.__add_code(('%d' % c))

    def click_button_c(self):
        self.__add_code('c')

    def click_button_ok(self):
        self.__add_code(chr(13))

    def key_pressed(self, event):
        self.__add_code(event.char)

    def get_pincode(self):
        return self.pincode

    def get_pincode_as_hex(self):
        pincode_list = [ ]
        # add pincode in high and low nibbles
        for index, digit in enumerate(numpad.pincode):
            if index % 2 == 0:
                pincode_list.append(int(digit) * 16 + 15)
            else:
                pincode_list[int(index / 2)] += int(digit) - 15
        # pad with 0xFF
        while len(pincode_list) < 6:
            pincode_list.append(0xFF)

        return pincode_list

class CardReaders:
    def __init__(self):
        self.card_readers = smartcard.System.readers()

    def find_reader(self, reader_name):
        card_reader_list = [ r for r in self.card_readers if r.name == reader_name]
        return card_reader_list[0] if len(card_reader_list) > 0 else None

    def amount_readers(self):
        return len(self.card_readers)

    def __card_is_be_id(self, atr):
        # Belgium Electronic ID card or Belgian Eid virtual test card
        # ref: http://ludovic.rousseau.free.fr/softwares/pcsc-tools/smartcard_list.txt
        return atr in ['3B9894400AA503010101AD1310',
                       '3B9813400AA503010101AD1311',
                       '3B989540FFD000480101AD1321']

    def get_reader_as_json(self, index):
        card_present = False
        card_type = 0
        atr = None

        connection = self.card_readers[index].createConnection()
        try:
            connection.connect()
            card_present = True
            atr = smartcard.util.toHexString(connection.getATR()).replace(' ', '')
            if self.__card_is_be_id(atr):
                card_type = 1
            connection.disconnect()
        except smartcard.Exceptions.NoCardException:
            pass

        response = {}
        response['index'] = index
        response['library'] = '__cardcomm__' # modify to pyscard?
        response['name'] = self.card_readers[index].name
        if card_present:
            response['atr'] = atr
        response['cardPresent'] = card_present
        response['cardType'] = card_type

        return response

# ref https://github.com/Fedict/eid-mw/blob/master/doc/sdk/documentation/Applet%201.7%20eID%20Cards/Public_Belpic_Applet_v1%207_Ref_Manual%20-%20A01.pdf
class BeIdCard:
    def __init__(self, card_reader):
        self.card_reader = card_reader
        self.connection = None
        self.__connect()
        self.applet_selected = self.__select_applet()
        self.get_instance = self.__get_instance()

        # Card data
        self.__serialnr = None
        self.__appletversion = None
        self.__6c_delay = 0
        self.card_data = self.__get_card_data()

        if self.card_data:
            log('Card serial nr: %s' % smartcard.util.toHexString(self.__serialnr).replace(' ', ''))
            log('Card applet version: %x' % self.__appletversion)
            log('Card 0x6C delay required: %d ms' % self.__6c_delay)

        # Card reader ioctls, to be detected
        self.__ioctls_detected = False
        self.__ioctl_verify_start = None
        self.__ioctl_verify_finish = None
        self.__ioctl_verify_direct = None
        self.__ioctl_change_start = None
        self.__ioctl_change_finish = None
        self.__ioctl_change_direct = None

    def __del__(self):
        if self.connection:
            self.connection.disconnect()

    def __connect(self):
        if self.card_reader:
            try:
                self.connection = self.card_reader.createConnection()
                self.connection.connect()
            except smartcard.Exceptions.NoCardException:
                self.connection = None

    def __send_apdu(self, apdu):
        data, sw1, sw2 = self.connection.transmit(apdu)
        if len(data) == 0:
            if sw1 == 0x61:
                while sw1 == 0x61:
                    extra_data, sw1, sw2 = self.connection.transmit([ 0x00, 0xC0, 0x00, 0x00, sw2 ])
                    data.extend(extra_data)
            if sw1 == 0x6C:
                time.sleep(self.__6c_delay / 1000)
                data, sw1, sw2 = self.connection.transmit(apdu[0:4] + [ sw2 ] + apdu[5:])
        return data, sw1, sw2

    def __select_applet(self):
        if self.connection:
            data, sw1, sw2 = self.connection.transmit(APPLET_AID)
            if sw1 in [ 0x61, 0x90 ] and sw2 == 0x00:
                return True
            else:
                return False
        else:
            return False

    def __get_instance(self):
        if self.connection:
            data, sw1, sw2 = self.__send_apdu(BELPIC_AID)
            if sw1 == 0x6A and sw2 in [ 0x82, 0x86 ]:
                # Perhaps the applet is no longer selected
                self.applet_selected = self.__select_applet()
                if self.applet_selected:
                    data, sw1, sw2 = self.__send_apdu(BELPIC_AID)
            if sw1 == 0x90 and sw2 == 0x00:
                return True
            else:
                return False
        else:
            return False

    def __get_card_data(self):
        if self.connection:
            # Get Card Data (compatible with all applets)
            data, sw1, sw2 = self.__send_apdu([ 0x80, 0xE4, 0x00, 0x00, 0x1C ])
            if sw1 == 0x90 and sw2 == 0x00 and len(data) > 23:
                self.__serialnr = data[0:16]
                self.__appletversion = data[21]

                if self.__appletversion >= 0x18:
                    # Use applet 1.8-specific extended card data
                    data, sw1, sw2 = self.__send_apdu([ 0x80, 0xE4, 0x00, 0x01, 0x1F ])

                if data[22] == 0x00 and data[23] == 0x01:
                    self.__6c_delay = 50
            else:
                return None

            return data
        else:
            return None

    def select_file(self, file_id):
        '''
        Selects the file at absolute path file_id in preparation of a call to read_selected_file()
        '''
        bin_file_id = smartcard.util.toBytes(file_id)
        request_data = [ 0x00, 0xA4, 0x08, 0x0C, len(bin_file_id) ] + bin_file_id
        data, sw1, sw2 = self.__send_apdu(request_data)
        if sw1 == 0x90 and sw2 == 0x00:
            return True
        else:
            return False

    def read_selected_file(self):
        '''
        Read the contents of the selected file. select_file() should have been called before.
        '''
        file_contents = []
        offset = 0
        is_eof = False
        while not is_eof:
            request_data = [ 0x00, 0xB0, int(offset / 256), offset % 256, MAX_APDU_READ_LEN ]
            data, sw1, sw2 = self.__send_apdu(request_data)
            if sw1 == 0x90 and sw2 == 0x00:
                file_contents.extend(data)
                offset += len(data)
            elif sw1 == 0x6B and sw2 == 0x00:
                # offset beyond eof
                is_eof = True
            else:
                # general error
                is_eof = True
                file_contents = None

            if len(data) < MAX_APDU_READ_LEN:
                is_eof = True
        return file_contents

    def __verify_feature(self, feature, feature_ccid, ioctl_value):
        if feature[0] == feature_ccid:
            return 256 * (256 * ((256 * feature[2]) + feature[3]) + feature[4]) + feature[5]
        else:
            return ioctl_value

    def __get_reader_features(self):
        if self.__ioctls_detected:
            return

        features = self.connection.control(smartcard.scard.SCARD_CTL_CODE(3400), [])
        i = 0
        while i < len(features):
            feature = features[i:i+6]
            self.__ioctl_verify_start = \
                self.__verify_feature(feature, CCID_VERIFY_START, self.__ioctl_verify_start)
            self.__ioctl_verify_finish = \
                self.__verify_feature(feature, CCID_VERIFY_FINISH, self.__ioctl_verify_finish)
            self.__ioctl_verify_direct = \
                self.__verify_feature(feature, CCID_VERIFY_DIRECT, self.__ioctl_verify_direct)
            self.__ioctl_change_start = \
                self.__verify_feature(feature, CCID_CHANGE_START, self.__ioctl_change_start)
            self.__ioctl_change_finish = \
                self.__verify_feature(feature, CCID_CHANGE_FINISH, self.__ioctl_change_finish)
            self.__ioctl_change_direct = \
                self.__verify_feature(feature, CCID_CHANGE_DIRECT, self.__ioctl_change_direct)
            i += 6

        self.__ioctls_detected = True

    def authenticate_pin(self):
        '''
        This function requests the user to authenticate using their PIN code.
        Result is a tuple containing a boolean indicating succesful authentication and an integer
        containing the amount of retries left.
        '''
        self.__get_reader_features()

        control_request = []
        control_request.append(0x1E) # timeout in seconds (0: default timeout)
        control_request.append(0x1E) # timeout in seconds after first key stroke
        control_request.append(0x89) # formatting options
        control_request.append(0x47) # length (in bytes) of the PIN block
        control_request.append(0x04) # where (if needed) to put the PIN length
        control_request.append(0x0C) # max number of PIN digits
        control_request.append(0x04) # min number of PIN digits
        control_request.append(0x02) # e.g. 0x02: "OK" button pressed
        control_request.append(0x01) # number of messages to display
        control_request.append(0x04) # LANG_ID code (english = 0x0409)
        control_request.append(0x09)
        control_request.append(0x00) # Message index (should be 00)
        control_request.append(0x00) # T=1 block prologue field to use (fill with 00)
        control_request.append(0x00)
        control_request.append(0x00)
        control_request.append(0x0D) # length of the following field
        control_request.append(0x00)
        control_request.append(0x00)
        control_request.append(0x00)
        # APDU to send to the card (to be completed by the reader)
        control_request += [ 0x00, 0x20, 0x00, 0x01, 0x08, 0x20, 0xFF,
                             0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF ]

        sw1 = 0x00
        sw2 = 0x00
        if self.__ioctl_verify_direct:
            tkinter.messagebox.showinfo(title='Connective', \
                                        message='The Connective Browser Plugin requests your ' + \
                                                'eID PIN code on the secure pinpad reader [%s]' \
                                                % self.card_reader.name)

            data = self.connection.control(self.__ioctl_verify_direct, control_request)
            sw1 = data[0]
            sw2 = data[1]
        elif self.__ioctl_verify_start and self.__ioctl_verify_finish:
            tkinter.messagebox.showinfo(title='Connective', \
                                        message='The Connective Browser Plugin requests your ' + \
                                                'eID PIN code on the secure pinpad reader [%s]' \
                                                % self.card_reader.name)

            data = self.connection.control(self.__ioctl_verify_start, control_request)
            data = self.connection.control(self.__ioctl_verify_finish, [ ])
            sw1 = data[0]
            sw2 = data[1]
        else:
            root = tk.Tk()
            numpad = NumpadWindow(root)
            root.mainloop()

            if len(numpad.pincode) > 0:
                # verify pincode
                data, sw1, sw2 = self.__send_apdu([ 0x00, 0x20, 0x00, 0x01, 0x08,
                                                    0x20 + len(numpad.pincode) ] + \
                                                    numpad.get_pincode_as_hex() + [ 0xFF ])

        if sw1 == 0x63 and sw2 >= 0xC0 and sw2 <= 0xCF:
            # PIN incorrect - untested
            return (False, sw2 - 0xC0)
        elif sw1 == 0x69 and sw2 == 0x83:
            # PIN blocked - untested
            return (False, 0)
        elif sw1 == 0x90 and sw2 == 0x00:
            # PIN correct
            return (True, -1)
        else:
            # Card reader or card returned any other error - unrecoverable
            return (False, 0)

    def sign(self, data_to_sign):
        '''
        Signs the given data using the connected card. A call to authenticate_pin() is required
        first to fulfill the access conditions.
        '''
        # select RSASSA-PKCS1_v15 SHA256 algorithm (0x08) and authentication once (0x82)
        data, sw1, sw2 = self.__send_apdu([ 0x00, 0x22, 0x41, 0xB6, 0x05,
                                            0x04, 0x80, 0x08, 0x84, 0x82 ])
        if sw1 == 0x90 and sw2 == 0x00:
            bin_data_to_sign = smartcard.util.toBytes(data_to_sign)
            request_data = [ 0x00, 0x2A, 0x9E, 0x9A, len(bin_data_to_sign) ] + \
                             bin_data_to_sign + [ 0x00 ]
            data, sw1, sw2 = self.__send_apdu(request_data)
            if sw1 == 0x90 and sw2 == 0x00:
                return smartcard.util.toHexString(data).replace(' ', '')
            else:
                return None
        else:
            return None

    def log_off(self):
        data, sw1, sw2 = self.__send_apdu([ 0x80, 0xE6, 0x00, 0x00 ])
        if sw1 == 0x90 and sw2 == 0x00:
            return True
        else:
            return False

def log(message):
    if DEBUG:
        sys.stderr.write(message + '\n')

def read_native_message():
    text_length_bytes = sys.stdin.buffer.read(4)

    if len(text_length_bytes) == 0:
        return None

    text_length = struct.unpack('@I', text_length_bytes)[0]
    text = str(sys.stdin.buffer.read(text_length), 'utf-8')
    log('IN ' + text)

    return text

def send_native_message(response):
    log('OUT ' + response)
    response_bytes = bytes(response, 'utf-8')
    sys.stdout.buffer.write(struct.pack('@I', len(response_bytes)))
    sys.stdout.buffer.write(response_bytes)
    sys.stdout.buffer.flush()

def get_error(error_code, message):
    response = {}
    response['error'] = {}
    response['error']['code'] = error_code
    response['error']['id'] = error_code
    response['error']['message'] = message
    return response

def process_get_info():
    response = {}
    response['version'] = '2.0.2'
    response['binVersion'] = '2.0.9'
    return response

def process_get_readers():
    card_readers = CardReaders()
    if card_readers.amount_readers() == 0:
        return get_error(2, 'Error getting readers (Comm 0x80100001) (0)')
    else:
        response = {}
        response['readerList'] = []
        for index in range(card_readers.amount_readers()):
            response['readerList'].append(card_readers.get_reader_as_json(index))
        return response

def process_read_file(request_json):
    request_reader = request_json['reader'] if 'reader' in request_json else None
    request_file_id = request_json['fileId'] if 'fileId' in request_json else None
    if not request_reader or not request_file_id:
        return get_error(99, 'No request received after 10 seconds')

    card_readers = CardReaders()
    card_reader = card_readers.find_reader(request_reader)
    beid_card = BeIdCard(card_reader)
    if not beid_card.card_reader:
        return get_error(0, 'Card reader %s not found' % request_reader)
    elif not beid_card.connection or not beid_card.applet_selected or not beid_card.get_instance:
        return get_error(99, 'error calling SCardConnect (0x80100069) (0x0)')
    elif not beid_card.select_file(request_file_id):
        return get_error(5, 'Error reading file (Comm 0x6a87) (0xa4080c)')
    else:
        data = beid_card.read_selected_file()
        if data:
            response = {}
            response['data'] = smartcard.util.toHexString(data).replace(' ', '')
            return response
        else:
            return get_error(5, 'Error reading file (Comm 0x6a87) (0xa4080c)')

def process_compute_authentication(request_json):
    request_reader = request_json['reader'] if 'reader' in request_json else None
    request_hash = request_json['hash'] if 'hash' in request_json else None
    if not request_reader or not request_hash:
        return get_error(99, 'No request received after 10 seconds')

    card_readers = CardReaders()
    card_reader = card_readers.find_reader(request_reader)
    beid_card = BeIdCard(card_reader)
    if not beid_card.card_reader:
        return get_error(0, 'Card reader %s not found' % request_reader)
    elif not beid_card.connection or not beid_card.applet_selected or not beid_card.get_instance:
        return get_error(99, 'error calling SCardConnect (0x80100069) (0x0)')
    else:
        (is_authenticated, retries_left) = beid_card.authenticate_pin()
        signature = None
        if is_authenticated:
            signature = beid_card.sign(request_hash)
            ignore_result = beid_card.log_off()

        # TODO not sure about this section, not tested with wrong or blocked PIN code
        response = {}
        response['pinRemainingAttempts'] = retries_left
        response['pinValid'] = is_authenticated
        if signature:
            response['valid'] = True
            response['signature'] = signature
        else:
            response['valid'] = False

        return response

request = read_native_message()
response_json = {}

try:
    request_json = json.loads(request)

    if 'activationToken' in request_json:
        # TODO request_json['activationToken'] must be checked, but it is not clear what it
        # contains. If it is not correct the application should stop here immediately and send
        # response_json = get_error(10, 'Activation required')
        pass

    if 'cmd' not in request_json:
        # the browser extension blocks this case
        response_json = get_error(99, 'No request received after 10 seconds')
    elif request_json['cmd'] == 'GET_INFO':
        response_json = process_get_info()
    elif request_json['cmd'] == 'GET_READERS':
        response_json = process_get_readers()
    elif request_json['cmd'] == 'READ_FILE':
        response_json = process_read_file(request_json)
    elif request_json['cmd'] == 'COMPUTE_AUTHENTICATION':
        response_json = process_compute_authentication(request_json)
    else:
        response_json = get_error(99, 'Error handling JSON message [%s]. Unknown command [%s]' \
                                                        % (request, request_json['cmd']))
except json.decoder.JSONDecodeError:
    response_json = get_error(99, 'No request received after 10 seconds')
except Exception as e:
    log(str(e))
    # any other exception - exit gracefully
    response_json = get_error(99, 'No request received after 10 seconds')

send_native_message(json.dumps(response_json))
roelderickx commented 2 years ago

I tested the class in a separate script and it works (of course, as I expected), so I pushed your modification to the main branch. Thanks again!