Hi,

I'm in the process of reviewing performance of various security tokens
(the Yubikeys, the FST-01, Nitrokey), and I am getting somewhat
interesting (if not surprising) results:

Times are in seconds.

It looks like the Yubikey 4 is the fastest, being (only?) 10 times
slower than the CPU (i3-6100U). That slows down another order of
magnitude with 4096 keys. The NEO is as slow in 2048 as the 4 is in
4096, and of course doesn't support 4096 at all. The FST-01 is the
slowest of the bunch, taking more than a full second to kick decryption
in 2048bit RSA and 8 seconds in 4096 bits.

I'm looking for feedback on the results and the test procedure, which is
a Python script, attached. I'm aware of the limitations of the script,
namely that it treats the *whole* GPG decryption process as a blackbox,
which includes AES and all sorts of stuff. In my tests, GPG chooses
AES-256 which is why I chose a 16 bytes filesize. Since the timings
seems to be fairly consistent, I am assuming the delays are consistent.

Also, I was thinking of removing the file altogether and pipe
pseudo-random bytes in to remove possible disk contention issues, but my
test results are fairly consistent so I don't think that's necessary
either.

I'm also looking at getting my hands on Nitrokey hardware and adding
elleptic curve support to complete the test suite.

Any comments and help would of course be very welcome.

A.
-- 
Antoine Beaupré
LWN.net
#!/usr/bin/python

'''This script will test decryption performance of GPG, particularly
with smart cards. Note that it will wipe the encryption key of the
smart card you provide it, so it's a dangerous thing to do on a real
card.

Test data can be generated automatically by the script, or by
hand. The grapher expects a CSV file with 3 columns: device, algorithm
and time. The first two are strings, the latter is a float, in
seconds. Note that we count the total runtime of GPG, which yields
lower bandwidth results than what the card is probably capable of
doing.
'''

# To generate the data by hand, use the following procedure:
#
#  1. very important: work in a temporary keyring:
#
#         export GNUPGHOME=$PWD/gpgtmp
#         mkdir $GNUPGHOME; chmod 700 $GNUPGHOME
#
#  1. create a 100M file of garbage
#
#         dd if=/dev/urandom of=testfile bs=1k count=100k
#
#  2. generate a new private keypair:
#
#         gpg --batch --generate-key <<EOF
#         %transient-key
#         %no-protection
#         Key-Type: RSA
#         Key-Length: 2048
#         Key-Usage: sign
#         Subkey-Type: RSA
#         Subkey-Length: 2048
#         Subkey-Usage: encrypt
#         Name-Real: test key
#         Name-Email: t...@example.com
#         Expire-Date: 1w
#         EOF
#
#  3. encrypt stuff to the key:
#
#         gpg --encrypt -r t...@example.com testfile
#
#  4. benchmark decryption, 101 times (first will prompt for password
#     so not relevant):
#
#         $ time gpg --quiet --decrypt < testfile.gpg | pv > /dev/null
#         1.08user 0.06system 0:01.16elapsed 98%CPU (0avgtext+0avgdata 5072maxresident)k
#         0inputs+0outputs (0major+265minor)pagefaults 0swaps
#          100MiO 0:00:01 [85,5MiB/s] [     <=>                   ]
#         $ for i in $(seq 100) ; do
#                time gpg --quiet --decrypt < testfile.gpg 2>> i3-6100.txt | pv > /dev/null
#         done
#
#  5. move key to card:
#
#         gpg --edit-key t...@example.com
#         key 1
#         keytocard
#         save
#
#  6. repeat step 4 with the Yubikey NEO:
#
#         $ time gpg --quiet --decrypt < testfile.gpg | pv > /dev/null
#         1.12user 0.04system 0:06.76elapsed 17%CPU (0avgtext+0avgdata 4952maxresident)k
#         0inputs+0outputs (0major+264minor)pagefaults 0swaps
#          100MiO 0:00:06 [14,8MiB/s] [     <=>                 ]
#         $ for i in $(seq 100) ; do
#               time gpg --quiet --decrypt < testfile.gpg 2>> Yubikey-NEO.txt | pv > /dev/null
#          done
#          100MiO 0:00:01 [53,1MiB/s] [     <=>                 ]
#         [...]
#
#  7. repeat step 2-6 with the FST-01:
#
#         $ time gpg --quiet --decrypt < testfile.gpg | pv > /dev/null
#         1.14user 0.03system 0:07.03elapsed 16%CPU (0avgtext+0avgdata 4752maxresident)k
#         0inputs+0outputs (0major+258minor)pagefaults 0swaps
#          100MiO 0:00:07 [14,2MiB/s] [        <=>             ]
#         $ for i in $(seq 100) ; do
#               time gpg --quiet --decrypt < testfile.gpg 2>> FST-01.txt | pv > /dev/null
#           done
#          100MiO 0:00:02 [41,3MiB/s] [     <=>                ]
#         [...]
#
#  8. repeat step 2-6 with the Yubikey 4:
#
#         $ time gpg --quiet --decrypt < testfile.gpg | pv > /dev/null
#         1.14user 0.04system 0:03.48elapsed 33%CPU (0avgtext+0avgdata 4852maxresident)k
#         0inputs+0outputs (0major+260minor)pagefaults 0swaps
#          100MiO 0:00:03 [28,7MiB/s] [     <=>                ]
#         $ for i in $(seq 100) ; do
#               time gpg --quiet --decrypt < testfile.gpg 2>> Yubikey-4.txt | pv > /dev/null
#           done
#          100MiO 0:00:01 [76,1MiB/s] [     <=>                ]
#         [...]
#
#  8. extract elapsed time:
#
#         for f in *.txt ; do
#             sed -n "/elapsed/{s/^.* 0:\([0-9]*.[0-9]*\)elapsed.*\$/rsa2048,$(basename $f .txt),\1/;p}" "$f"
#         done > results-2048.csv
#
#  9. repeat step 2-7 with 4096 bit keys
#
#  10. merge into the CSV file:
#
#         ( echo 'algorithm,device,time' ; cat results-*.csv ) > results.csv
#
#  11. generate the graph:
#
#         python histo.py results.csv --output results.png

from __future__ import print_function

from builtins import range, input

import argparse
import datetime
import logging
import os
import psutil
import shutil
import subprocess
import sys
import tempfile

import matplotlib.pyplot as plt
import pandas
import seaborn as sns


class Timer(object):
    """this class is to track time and resources passed

    originally from bup-cron, but improved to include memory usage"""

    def __init__(self):
        """initialize the timstamp"""
        self.stamp = datetime.datetime.now()

    def times(self):
        """return a string designing resource usage"""
        return 'user %s system %s chlduser %s chldsystem %s' % os.times()[:4]

    def rss(self):
        process = psutil.Process(os.getpid())
        return process.memory_info().rss

    def memory(self):
        return 'RSS %s' % sizeof_fmt_iec(self.rss())

    def diff(self):
        """a datediff between the creation of the object and now"""
        return datetime.datetime.now() - self.stamp

    def __str__(self):
        """return a string representing the time passed and resources used"""
        return 'elasped: %s (%s %s)' % (str(self.diff()),
                                        self.times(),
                                        self.memory())


def sizeof_fmt(num, suffix='B', units=None, power=None,
               sep=' ', precision=2, sign=False):
    """format the given size as a human-readable size"""
    prefix = '+' if sign and num > 0 else ''

    for unit in units[:-1]:
        if abs(round(num, precision)) < power:
            if isinstance(num, int):
                return "{}{}{}{}{}".format(prefix, num, sep, unit, suffix)
            else:
                return "{}{:3.{}f}{}{}{}".format(prefix, num, precision,
                                                 sep, unit, suffix)
        num /= float(power)
    return "{}{:.{}f}{}{}{}".format(prefix, num, precision,
                                    sep, units[-1], suffix)


def sizeof_fmt_iec(num, suffix='B', sep=' ', precision=2, sign=False):
    return sizeof_fmt(num, suffix=suffix, power=1024,
                      units=['', 'Ki', 'Mi', 'Gi', 'Ti',
                             'Pi', 'Ei', 'Zi', 'Yi'],
                      sep=sep, precision=precision, sign=sign)


def load_csv():
    logging.info('loading CSV file %s with pandas', args.datafile)
    data = pandas.read_csv(args.datafile)
    # expected rows format:
    # algorithm,device,time
    # string,string,float

    # convert from time to bandwidth
    data['bandwidth'] = args.size / data['time']
    return data


def render_graph():
    logging.info('rendering graph with seaborn')
    ax = sns.barplot(data=data, x='device', y=args.variable, hue='algorithm')
    ax.set_title('RSA decryption (%s)' % args.variable)
    for p in ax.patches:
        ax.annotate(s="%.2f" % p.get_height(),
                    xy=(p.get_x() + p.get_width() / 2., p.get_height()))
    if args.output == sys.stdout and \
            ('DISPLAY' in os.environ or sys.stdout.isatty()):
        logging.info("drawing on tty")
        plt.show()
    else:
        logging.info('drawing to file %s', args.output)
        plt.savefig(args.output, bbox_inches='tight')


def setup_home(algo, size):
    logging.info('killing gpg-agent so it takes into account our new HOME')
    os.system('killall gpg-agent')
    os.environ['GNUPGHOME'] = tempfile.mkdtemp(prefix='gpgbench-')

    path = os.path.join(os.environ['GNUPGHOME'], 'testfile')
    logging.info('writing %s test file in %s',
                 sizeof_fmt_iec(float(args.size)), path)

    timer = Timer()
    with open(path, 'wb') as testfile:
        testfile.write(os.urandom(args.size))
    logging.debug('wrote file in %s', timer)

    cmd = '''gpg --quiet --batch --generate-key <<EOF
%%transient-key
%%no-protection
Key-Type: %s
Key-Length: %s
Key-Usage: sign
Subkey-Type: %s
Subkey-Length: %s
Subkey-Usage: encrypt
Name-Real: test key
Name-Email: t...@example.com
Expire-Date: 1w
EOF''' % (algo, size, algo, size)
    logging.debug('keygen command: %s', cmd)
    logging.info('generating new %s %s key', algo, size)
    if not os.system(cmd) == 0:
        return False
    logging.info('encrypting test file')
    try:
        os.unlink(os.path.join(os.environ['GNUPGHOME'], 'testfile.gpg'))
    except OSError:
        pass
    return os.system('gpg --quiet --encrypt -r t...@example.com %s'
                     % os.path.join(os.environ['GNUPGHOME'], 'testfile')) == 0


def delete_home():
    logging.info('removing test gpg dir')
    shutil.rmtree(os.environ['GNUPGHOME'], ignore_errors=True)


def decrypt_file():
    return os.system('gpg --quiet --decrypt < %s > %s'
                     % (os.path.join(os.environ['GNUPGHOME'], 'testfile.gpg'),
                        '/dev/null')) == 0
                     

def move_to_keycard():
    logging.info('moving private key to keycard')
    proc = subprocess.Popen(['gpg', '--status-fd', '2', '--command-fd', '0',
                             '--batch', '--edit-key', 't...@example.com'],
                            stdin=subprocess.PIPE,
                            stdout=subprocess.PIPE,
                            stderr=subprocess.PIPE)

    class GpgDialogError(Exception):
        pass

    def wait_for_prompt(prompt='[GNUPG:] GET_LINE keyedit.prompt'):
        while True:
            line = proc.stderr.readline().strip()
            logging.debug(line)
            if line == prompt:
                break
            elif line.startswith('[GNUPG:] GET_'):
                raise GpgDialogError('unexpected prompt: %s' % line)

    success = True
    try:
        wait_for_prompt()
        proc.stdin.write("key 1\n")  # select first subkey
        logging.debug(proc.stderr.readline())  # [GNUPG:] GOT_IT
        wait_for_prompt()
        proc.stdin.write("keytocard\n")
        logging.debug("sent 'keytocard'")
        logging.debug(proc.stderr.readline())  # [GNUPG:] GOT_IT
        wait_for_prompt('[GNUPG:] GET_LINE cardedit.genkeys.storekeytype')
        proc.stdin.write("2\n")  # move to encryption slot
        logging.debug("sent 2")
        logging.debug(proc.stderr.readline())  # [GNUPG:] GOT_IT

        # XXX: known bug: this will only happen if the key is not new
        wait_for_prompt('[GNUPG:] GET_BOOL cardedit.genkeys.replace_key')
        proc.stdin.write("y\n")
        wait_for_prompt()
        proc.stdin.write("save\n")
    except GpgDialogError as e:
        logging.warn('failed to communicate with GPG: %s', e)
        success = False

    proc.stderr.close()
    proc.stdout.close()
    proc.stdin.close()
    proc.wait()
    return success and proc.returncode == 0


def gen_test_data():
    logging.info('generating test data')
    data = pandas.DataFrame()
    keycard_curr = 'CPU'
    for keycard in args.keycard:
        for algo in ['RSA']:
            for size in [2048, 4096]:
                algo_size = '%s-%s' % (algo, size)
                if not setup_home(algo, size):
                    logging.warning('failed to setup home %s',
                                    os.environ['GNUPGHOME'])
                    break
                if keycard != 'CPU':
                    if keycard != keycard_curr:
                        input("insert keycard %s and press enter to continue: "
                              % keycard)
                        keycard_curr = keycard
                    if not move_to_keycard():
                        logging.warning('failed to move to keycard')
                        break
                logging.info('first decryption test to remove password prompt')
                if not decrypt_file():
                    logging.warning('failed to decrypt file with keycard %s',
                                    keycard)
                    break
                logging.info('testing decryption, %d iterations',
                             args.iterations)
                for i in range(args.iterations):
                    timer = Timer()
                    decrypt_file()
                    s = timer.diff().total_seconds()
                    data = data.append([(keycard, algo_size, s)])
                    print("\rdecryption pass %d [%s]" % (i+1, timer), end='')
                    sys.stdout.flush()
                print()
                delete_home()
    data.columns = ['device', 'algorithm', 'time']
    # convert from time to bandwidth
    data['bandwidth'] = args.size / data['time']
    logging.debug('generated dataset: %s', data)
    if args.save:
        logging.info('dataset saved to file %s', args.save)
        data.to_csv(args.save)
    return data


if __name__ == "__main__":
    parser = argparse.ArgumentParser(epilog=__doc__,
                                     description='Benchmark cryptographic tokens')
    parser.add_argument('datafile', default=None, nargs='?',
                        help='CSV file to read instead of running the test)')
    parser.add_argument('--verbose', '-v', dest='log_level',
                        help='show informative messages',
                        action='store_const', const='INFO', default='WARNING')
    parser.add_argument('--debug', '-d', dest='log_level',
                        help='show debug messages',
                        action='store_const', const='DEBUG', default='WARNING')
    parser.add_argument('--output', '-o', type=argparse.FileType('w'),
                        default=sys.stdout, nargs='?', metavar='FILE',
                        help='save graph to FILE or stdout')
    parser.add_argument('--save', type=argparse.FileType('w'), metavar='CSV',
                        help='save generated results to CSV file')
    parser.add_argument('--iterations', '-i', metavar='N',
                        type=int, default=100,
                        help='repeat the test N times (%(default)s)')
    parser.add_argument('--keycard', '-k', nargs='+',
                        default=['CPU'], metavar='NAME',
                        help='test keycards NAME in sequence (%(default)s)')
    parser.add_argument('--size', '-s', default=16,
                        metavar='SIZE', type=int,
                        help='SIZE of sample file, in bytes (%(default)s)')
    parser.add_argument('--bandwidth', dest='variable', default='time',
                        action='store_const', const='bandwidth',
                        help='plot bandwidth instead of %(default)s')

    args = parser.parse_args()
    logging.basicConfig(format='%(asctime)s %(message)s', level=args.log_level)

    if args.datafile is not None:
        data = load_csv()
    else:
        data = gen_test_data()
    render_graph()
_______________________________________________
Gnupg-users mailing list
Gnupg-users@gnupg.org
http://lists.gnupg.org/mailman/listinfo/gnupg-users

Reply via email to