Sources are provided. HTTP server is not really interesting, it does some bookkeeping, returns ECDH-encrypted OTP when asked and the flag when called with correctly decrypted (and not stale) OTP. The actual work is in LedgerNano app; both server and client are provided, the crypto part is the following code:```#include <stdlib.h>#include <string.h>
#include "cx.h"#include "ox.h"#include "os_seed.h"
#include "crypto.h"
void get_own_privkey(cx_ecfp_private_key_t *privkey){    uint8_t privkey_data[32];    uint32_t path[5] = { 0x8000000d, 0x80000025, 0x80000000, 0, 0 };
    os_perso_derive_node_bip32(CX_CURVE_256K1, path, 5, privkey_data, NULL);    cx_ecfp_init_private_key(CX_CURVE_256K1, privkey_data, sizeof(privkey_data), privkey);    explicit_bzero(&privkey_data, sizeof(privkey_data));}
int get_own_pubkey(cx_ecfp_public_key_t *pubkey){    cx_ecfp_private_key_t privkey;    get_own_privkey(&privkey);
    cx_err_t err = cx_ecfp_generate_pair_no_throw(CX_CURVE_256K1, pubkey, &privkey, 1);    explicit_bzero(&privkey, sizeof(privkey));    if (err != CX_OK) {        return -1;    }
    return 0;}
int get_pubkey(uint8_t out[65]){    cx_ecfp_public_key_t server_pubkey;
    if (get_own_pubkey(&server_pubkey) != 0) {        return -1;    }
    size_t size = server_pubkey.W_len;    if (size > 65) {        size = 65;    }
    memcpy(out, server_pubkey.W, size);
    return (int)size;}
static int get_shared_secret(cx_ecfp_public_key_t *pubkey, uint8_t secret[32]){    cx_ecfp_private_key_t privkey;    uint8_t out[32];    cx_err_t ret;
    get_own_privkey(&privkey);    ret = cx_ecdh_no_throw(&privkey, CX_ECDH_X, pubkey->W, pubkey->W_len,                           out, sizeof(out));
    explicit_bzero(&privkey, sizeof(privkey));    if (ret != CX_OK) {        return -1;    }
    memcpy(secret, out, sizeof(secret));
    return 0;}
int encrypt_otp_helper(cx_ecfp_public_key_t *pubkey, uint8_t otp[32], uint8_t out[32], bool decrypt){    uint8_t secret[32] = { 0 };    if (get_shared_secret(pubkey, secret) != 0) {        return -10;    }
    cx_aes_key_t key;    cx_err_t err = cx_aes_init_key_no_throw(secret, sizeof(secret), &key);
    explicit_bzero(secret, sizeof(secret));    if (err != CX_OK) {        return -11;    }
    size_t out_len = 32;    int flag = CX_CHAIN_CBC | CX_LAST | ((decrypt) ? CX_DECRYPT : CX_ENCRYPT);    err = cx_aes_iv_no_throw(&key, flag, NULL, 0,                             otp, 32, out, &out_len);
    explicit_bzero(&key, sizeof(key));    if (err != CX_OK) {        return -12;    }
    return out_len;}```The rest is more bookkeeping, the server app stores public keys of all clients, the client app decrypts OTP given encrypted data and server's public key. OTP itself is 10 digits right-padded to 32 bytes with zeroes; since AES block size is 16 bytes, it means that the second block is all-zeroes, CBC-encrypting all-zeroes block gives the condition `encryptedBlock2 == AESEncrypt(key, encryptedBlock1)`. However, this has no consequences by itself (assuming AES is solid).
Can you see a bug in the code above? There is one :)
After reading all the sources for the fifth time, I noticed that there are too much `sizeof`s and too much array-to-pointer decays; in C, using `type array[number]` as a function argument actually works as `type* array` for all purposes, including `sizeof` (C++ behaves the same way on the same code, but also provides references-to-arrays `type (&array)[number]` for fixed-length arrays and `std::span` for pairs of pointer+length), so I specifically looked whether these two come together somewhere. Indeed, `get_shared_secret` takes pointer-instead-of-array `secret` and copies only `sizeof(secret)=4` bytes of the generated secret; the rest is initialized by zeroes in the caller, so there are only `2**32` possible keys, these can be bruteforced in reasonable time.
PyCryptodome that I usually use seems to be quite slow for this task, so I have resorted to plain C based on [openssl wiki example](https://wiki.openssl.org/index.php/EVP_Symmetric_Encryption_and_Decryption), based on sample encrypted OTP from the server for device #0 (checker uses the aforementioned condition, although directly checking that AESDecrypt(block1) has digits and zeroes is equally valid):```#include <openssl/conf.h>#include <openssl/evp.h>#include <openssl/err.h>#include <string.h>
void handleErrors(void){    ERR_print_errors_fp(stderr);    abort();}
int decrypt(unsigned char *ciphertext, int ciphertext_len, unsigned char *key,            unsigned char *iv, unsigned char *plaintext){    EVP_CIPHER_CTX *ctx;
    int len;
    int plaintext_len;
    /* Create and initialise the context */    if(!(ctx = EVP_CIPHER_CTX_new()))        handleErrors();
    /*     * Initialise the decryption operation. IMPORTANT - ensure you use a key     * and IV size appropriate for your cipher     * In this example we are using 256 bit AES (i.e. a 256 bit key). The     * IV size for *most* modes is the same as the block size. For AES this     * is 128 bits     */    if(1 != EVP_DecryptInit_ex(ctx, EVP_aes_256_ecb(), NULL, key, iv))        handleErrors();    if(1 != EVP_CIPHER_CTX_set_padding(ctx, 0))        handleErrors();
    /*     * Provide the message to be decrypted, and obtain the plaintext output.     * EVP_DecryptUpdate can be called multiple times if necessary.     */    if(1 != EVP_DecryptUpdate(ctx, plaintext, &len, ciphertext, ciphertext_len))        handleErrors();    plaintext_len = len;
    /*     * Finalise the decryption. Further plaintext bytes may be written at     * this stage.     */    if(1 != EVP_DecryptFinal_ex(ctx, plaintext + len, &len))        handleErrors();    plaintext_len += len;
    /* Clean up */    EVP_CIPHER_CTX_free(ctx);
    return plaintext_len;}
int main(){	static unsigned char bytes[32] = {0x6c,0x0f,0x0b,0xf1,0x15,0xb5,0x99,0x95,0xae,0x03,0xdb,0x36,0x3b,0x5e,0x84,0x8f,0x5c,0x88,0xe2,0x32,0x97,0x99,0xd4,0x04,0x1e,0xbd,0xfd,0x05,0x94,0x88,0x7f,0xe1,	};	unsigned char decrypted[16];	unsigned char key[32] = {0};	unsigned k = 0;	for (;;) {		if (k % (1 << 24) == 0) {			printf(".");			fflush(stdout);		}		memcpy(key, &k, 4);		decrypt(bytes+16, 16, key, NULL, decrypted);		if (memcmp(decrypted, bytes, 16) == 0) {			printf("%X\n", k);			break;		}		if (++k == 0)			break;	}	return 0;}```I'm not sure whether PyCryptodome is really significantly slower due to Python stuff, or plain OpenSSL was able to use hardware-assisted AES in my notebook, but OpenSSL-version turned out to be much faster and took just several minutes to find the key: `b'\x81\xDA\x03\x39'`. Decrypting OTP after those several minutes is too late, so one more request for encrypted OTP is needed, decrypted with already-known key; presented with decrypted OTP, the server responds with flag `CTF{RustFTW!}`.