A zig based one-time-password (OTP) implementation for QMK able to generate keys for many 2FA challenges.
Find a file
2026-07-01 19:41:49 +02:00
src Added implementation of the time-based-one-time-password algorithm and on-device encryption 2026-07-01 19:41:49 +02:00
.gitignore Added implementation of the time-based-one-time-password algorithm and on-device encryption 2026-07-01 19:41:49 +02:00
build.zig Added implementation of the time-based-one-time-password algorithm and on-device encryption 2026-07-01 19:41:49 +02:00
build.zig.zon Added implementation of the time-based-one-time-password algorithm and on-device encryption 2026-07-01 19:41:49 +02:00
README.md Added implementation of the time-based-one-time-password algorithm and on-device encryption 2026-07-01 19:41:49 +02:00

QMK Time-based one-time password

This repository implements a binary and a static library linkable to QMK that allow the user to solve TOTP based challenges on any reasonably powerfull microcontroller which includes most comporary keyboards that run QMK.

The keyboard itself stores an encrypted version of the TOTP secrets. The shipped binary must be invoked to decrypt the secrets when the keyboard is plugged in.

Usage

Extract the secret from the URL that is given by the authentification service that wishes to establish 2FA (either use an QR reader or directly get the secret if provided). The secret must be base32 (RFC 4648) encoded which is the current standard.

Before compiling anything adapt the build.zig, l.14-19 set the correct configuration for your microcontroller in the keyboard. FOR RP2040 the configuration is correct already, if that is not your controller look up the correct model and architecture.

# zig version 0.16.0 is needed to compile this code. You can get it at https://ziglang.org/learn/getting-started/
zig build --release=fast
zig-out/bin/totp add [SECRET]
# example output: {KEY,6,pD4DeoiAzCQKkHPjd6O8keDU3Q}
# Put the output into the keymaps.c as shown in the later example (adapt key)
cp zig-out/lib/libtotp.a /your_qmk_firmware_path/lib

Put something like this in your keymap.c QMK firmware configuration:

// Hook up the external static library 
extern bool decode_secret_and_generate_2fa(const char* hashed_pw, const char* secret_encoded, uint64_t counter, uint8_t length, char *output);
extern void sha256(const char *input, char *output);

char hashed_pw[32];
uint64_t time_since_epoch = 0;

struct TwoFAEntry {
    char trigger;
    unsigned int length;
    const char *encoding;
};
// Expand this by all entries needed
#define NUM_ENTRIES 1
struct TwoFAEntry entries[NUM_ENTRIES] = {
    // For example Codeberg
    {KEY_C,6,pD4DeoiAzCQKkHPjd6O8keDU3Q},
};

enum custom_keycodes {
  CKC_TRIGGER_2FA = SAFE_RANGE
};

bool g_catch_next_key = false;

bool process_record_user(uint16_t keycode, keyrecord_t *record) {
    // Check when the prefix key was pressed which entry the user wants to access
    if(g_catch_next_key && record->event.pressed) {
        g_catch_next_key = false;
        char output[32];
        for (int i = 0; i < NUM_ENTRIES; ++i) {
            if (entries[i].trigger == keycode) {
                if(decode_secret_and_generate_2fa(hashed_pw, entries[i].encoding,time_since_epoch+(uint64_t)(timer_read32()/1000), entries[i].length, output)) {
                    SEND_STRING(output);
                }
                else {
                    // Could not decode, the user entered wrong password
                    SEND_STRING("errx");
                }
                // No need to handle the key anymore
                return false;
            }
        }
        // Unkown key, tell the user (could also be implemented by flashing RBG LEDs, however this is the most compatible)
        SEND_STRING("k-un");
        return false;
    }
    switch (keycode) {
        case CKC_TRIGGER_2FA:
            if (record->event.pressed) {
                if (time_since_epoch != 0) {
                    g_catch_next_key = true;
                }
                else {
                    // Tell the user that no password was received to decrypt anything yet
                    SEND_STRING("pw-m");
                }
                return false;
            }
        break;
    }

    return true;
}

void raw_hid_receive(uint8_t *data, uint8_t length) {
    // Read time since epoch and the password (store only the hashed version)
    time_since_epoch = (*((uint64_t*)data)) - (uint64_t)(timer_read32()/1000);
    sha256((const char*)&data[8], &hashed_pw[0]);
}

Do not forget to set RAW_ENABLE = yes in rules.mk since we need the HID feature.

Compilation/Flashing in QMK can be achieved by prefixing the command with:

EXTRALDFLAGS="-L/absolute_qmk_firmware_path/lib -ltotp" qmk compile ...

After flashing pressing the key CKC_TRIGGER_2FA outputs pw-m, password missing, to remedy that we need to "unlock" the keyboard by providing the password that was used to encrypt the secrets. Run:

lsusb

Find the vendor identifier and device identifier on my system the output looks like this:

Bus 003 Device 013: ID beeb:0002 beekeeb piantor_pro

The vendor id is beeb and the device id 0002. Now run

zig-out/bin/totp hid beeb 0002

to unlock the keyboard. This must be repeated after every loss of power for the keyboard.