QUIC handshake with mbedTLS

プロトコルを知るには書いて動かしてみれ!!という事で、mbedTLSを使ってQUICのhandshakeまで行ってみることにした。時間があるときに、ちょくちょく調べたりしてmbedTLSを使ってNginX(quic impl)と通信を行うまで確認した。
mbedTLSでQUICの公式サポートは今のところは無い。まぁ、やってみたった的な感じ。みんな色んな言語で実装されている物があったりで、何か楽しそうだなーと思って。

qlog用にrandom/handshake_traffic_secret/application_traffic_secret/exporter_master_secretを吐き出してWiresharkで復号化して見れる。

■ 参考

RFC 9000 QUIC: A UDP-Based Multiplexed and Secure Transport
RFC 9001 Using TLS to Secure QUIC

■ コード

quic 側 : mbedtls_quic
mbedtls側 : mbedtls (mbedtls-3.2.1-quic branch)
mbedTLSは現時点での最新 3.2.1をベースにした。ただし、TLS 1.3への対応はとりあえずは動く程度な感じ。TLS extensionで対応していないサーバと通信すると見事にERRORを起こしたりするし、これから感はしている。
組込みで使われる物だけど、今なら素直にwolfsslを使う方が良い気はする。すでにQUICに対応しているし、みんな大好きESP32にも組込むことが出来る(esp-idfからビルドオプションで組込める)。

■ mbedtls側の改造

mbedtls は mbedtls_config.h をいじってTLSの機能を選択利用する実装になっている。超少ないリソースで動作するから、選択的に機能を選んで組込んでいく感じ。TLS 1.3以外の機能は不要なのでオフって使うことにする。diff はこの程度。改造したポイントは次あたり…

・MBEDTLS_QUIC_TRANSPORT 機能を mbedtls_config.h に追加
 これでQUICの処理が必要な部分についての変更を全体に加える。
・Transport Parameterの実装追加
 RFC 9000 7.4. Transport Parameters
 RFC 9001 8.2. QUIC Transport Parameters Extension
・QUICパケットの暗号化処理部分をフロント側で処理させる

mbedTLSはTLS 1.3のステートマシン・鍵生成器としてだけ使って、最小限の修正に留めている。あとTLS 1.3はTCP Stream想定なのか、複数に分割されたUDPパケット(Handshake時の証明書類が送られてくるところとか)がある場合、いわゆる EAGAIN のようにフロント側にパケット要求するような処理に変更していたりする。

■ QUIC側の処理

とりあえずハンドシェイクまでで、メインのコードはこんな感じ。

#include <stdio.h>
#include "quic_common.h"
#include "quic_mbedtls.h"

int main() {
    int ret = 0;

    // init
    quic_mbedtls_ctx ctx;
    quic_mbedtls_init(&ctx, "www.hirotakaster.com", 8443);
    quic_mabedtls_handshake(&ctx);

    // ouput keys
    dump_handshake_random_byte(&ctx);
    dump_handshake_traffic_secret(&ctx);
    dump_application_traffic_secret(&ctx);
    dump_exporter_master_secret(&ctx);
    dump_resumption_master_secret(&ctx);

    //
    sleep(2);

    return 0;
}

ハンドシェイクを行ってkeylogを出力するまで。ハンドシェイクまでだからコードは2個だけ、
quic_client.c : QUIC側のヘッダ処理類
quic_mbedtls.c : 暗号化類・ネットワークIO類
quic_mbedtls_init の中ではUDPのオープン・初期化処理を行って、quic_mabedtls_handshake でQUIC/TLS 1.3のハンドシェイクを行う。流れ的には次にwrite/readの処理を入れていく感じの想定で。mbedTLSではmbedtls_ssl_handshakeでBIO(mbedtls_ssl_set_bio)でネットワークとのread/writeをハンドリングすることが出来る。
鍵類はIO用の構造体の中に(デバッグ用としても)ざっくり入れている。

typedef struct {
    mbedtls_x509_crt cacert;
    mbedtls_entropy_context entropy;
    mbedtls_ctr_drbg_context drbg;
    mbedtls_ssl_context ssl;
    mbedtls_ssl_config conf;
    mbedtls_timing_delay_context timer;

    u_char initial_secret[32];
    u_char client_secret[32];
    u_char client_key[16];
    u_char client_iv[12];
    u_char client_hp[16];
    u_char server_secret[32];
    u_char server_key[16];
    u_char server_iv[12];
    u_char server_hp[16];

    BOOLEAN secret_initialized;
    u_char client_random_byte[32];

    u_char client_derive_secret[32];
    u_char client_derive_key[16];
    u_char client_derive_iv[12];
    u_char client_derive_hp[16];

    u_char server_derive_secret[32];
    u_char server_derive_key[16];
    u_char server_derive_iv[12];
    u_char server_derive_hp[16];

    QUIC_MBEDTLS_SOCK sock;
} quic_mbedtls_ctx;
QUIC KeySchedule : https://gist.github.com/martinthomson/c254bbc4214e8b3d4f38372b9afce18d

他のコードを参考にしないで、wiresharkとお友達になって勢いで書いたのもあって、最初にナニコレ?と思ったのが、可変長エンコーディングだった(RFC 9000 16.  Variable-Length Integer Encoding)。先頭の2ビットを見てから値が決まる感じで、パケットのいたる所でこれが利用されている。例えば、Initial Packet だと、

  RFC 9000 17.2.2. Initial Packet
  Initial Packet {
     Header Form (1) = 1,
     Fixed Bit (1) = 1,
     Long Packet Type (2) = 0,
     Reserved Bits (2),
     Packet Number Length (2),
     Version (32),
     Destination Connection ID Length (8),
     Destination Connection ID (0..160),
     Source Connection ID Length (8),
     Source Connection ID (0..160),
     Token Length (i),
     Token (..),
     Length (i),
     Packet Number (8..32),
     Packet Payload (8..),
   }

   RFC 9000 19.6. CRYPTO Frames
   CRYPTO Frame {
     Type (i) = 0x06,
     Offset (i),
     Length (i),
     Crypto Data (..),
   }

この (i) という所が可変長。Destination Connection IDとかに出ているのは、前のLengthから長さが決定されたりする。みたいな感じで、可変長がバンバン出てくる。まぁ、関数( quic_mbedtls_conv_vlie : 可変長読み込み、quic_mbedtls_set_vlie : 可変長書き込み)を用意して、あとはOKだった。

initial packetを送受信して、あれ?と思ったのが ACK だった(RFC 9000 19.3. ACK Frames)。この中にある、ack_delay で時間を測定してるんすね。そして、デバックしながらやっていると、NginX側からPINGフレームが飛んできて、生存確認も行っているのをやってみてから分かった。よく出来ているプロトコルっすねー。
あぁ…そこまでやってらんないぉ…ってことで、固定値を入れているけど、本当はちゃんとマイクロ秒でカウントしないといけないんだけど…そしてPINGを思いっきり無視&スルーしちゃっている><
以下な感じでパケット保護・復号化後のフレームを見ている。

int quic_mbedtls_decode_frame(const u_char *data, uint32_t length, QUIC_FRAME *frame) {
    int i, j = 0;

    for (i = 0; i < length;) {
        u_char d = data[i];
        if (data[i] == QUIC_FRAME_PADDING) {
            i += 1;
        } else if (data[i] == QUIC_FRAME_CRYPTO) {
            frame[j].frame = &data[i];
            i += 1;

            uint8_t o, l;
            uint64_t ov, lv;
            // get crypt frame offset
            quic_mbedtls_conv_vlie(data + i, &o, &ov);
            i += o;

            // get crypt frame length
            quic_mbedtls_conv_vlie(data + i, &l, &lv);
            i += l;
            i += lv;

            frame[j].type = QUIC_FRAME_CRYPTO;
            frame[j].length = 1 + o + l + lv;
            j += 1;
        } else if (data[i] == QUIC_FRAME_PING) {
            // とりあえず無視
            i += 1;

        } else if (data[i] == QUIC_FRAME_ACK_2 || data[i] == QUIC_FRAME_ACK_3) {
            frame[j].frame = &data[i];
            i += 1;

            uint8_t la, ad, arc, far, ar;
            uint64_t lav, adv, arcv, farv, arv;

            // get largetst ack
            quic_mbedtls_conv_vlie(data + i, &la, &lav);
            i += la;

            // get ack delay
            quic_mbedtls_conv_vlie(data + i, &ad, &adv);
            i += ad;

            // get ack range count
            quic_mbedtls_conv_vlie(data + i, &arc, &arcv);
            i += arc;

            // get first ack range
            quic_mbedtls_conv_vlie(data + i, &far, &farv);
            i += far;

            frame[j].type = QUIC_FRAME_ACK_2;
            frame[j].length = 1 + la + ad + arc + far;
            j += 1;
        }
    }
    return j;
}

ペイロードの暗号化・復号化は1個の関数で処理していて、ヘッダの保護を設定・解除したあとに、この関数で処理するようにしている。
ヘッダの保護については、鍵スケジュールで得られるkey/iv/hp類を利用。

int quic_mbedtls_crypt_frame(u_int8_t enc_dec,
            unsigned char *key, size_t keylen,
            unsigned char *iv, size_t ivsize,
            unsigned char *ad, size_t adsize,
            unsigned char *from, size_t fromsize,
            unsigned char *to, size_t tosize, size_t *olen) {
    int ret;
    mbedtls_gcm_context ctx;
    mbedtls_cipher_id_t cipher = MBEDTLS_CIPHER_ID_AES;
    int output_len;
    unsigned char tag_output[16];
    size_t tag_len = sizeof(tag_output);
    size_t tmpolen;

    mbedtls_gcm_init(&ctx);
    ret = mbedtls_gcm_setkey(&ctx, cipher, key,  keylen);

    ret = mbedtls_gcm_starts(&ctx, enc_dec, iv, ivsize); 
    ret = mbedtls_gcm_update_ad(&ctx, ad, adsize);
    ret = mbedtls_gcm_update(&ctx, from, fromsize, to, tosize, olen);
    ret = mbedtls_gcm_finish(&ctx, NULL, 0, &tmpolen, tag_output, sizeof(tag_output) );

    if (enc_dec == MBEDTLS_GCM_ENCRYPT) {
        memcpy(to + *olen, tag_output, sizeof(tag_output));
        *olen = *olen + 16;
    }

    mbedtls_gcm_free(&ctx);
    return ret;
}

// 暗号化
quic_mbedtls_crypt_frame(MBEDTLS_GCM_ENCRYPT,
                ctx->client_key, sizeof(ctx->client_key)*8,
                nonce, sizeof(nonce),
                send_buffer, plane_header_total_len,
                payload, payload_len,
                enc_payload, sizeof(enc_payload), &olen);

// 復号化
quic_mbedtls_crypt_frame(MBEDTLS_GCM_DECRYPT,
    (QUIC_FRAME_LONG_PACKET_TYPE_MASK(quic_mbedtls_recv_buffer[0]) == QUIC_PACKET_INITIAL ? ctx->server_key : ctx->server_derive_key),
    (QUIC_FRAME_LONG_PACKET_TYPE_MASK(quic_mbedtls_recv_buffer[0]) == QUIC_PACKET_INITIAL ? sizeof(ctx->server_key)*8 : sizeof(ctx->server_derive_key)*8),
            dec_nonce, sizeof(dec_nonce),
            quic_mbedtls_recv_buffer, packet.header_len,
            quic_mbedtls_recv_buffer + packet.header_len,
            packet.length - packet.packet_number_length,
            dec_payload, sizeof(dec_payload), &dec_olen);

■やっみて…

あー、QUIC面白いっすねーという感想だった。とりあえずQUIC/TLS 1.3ハンドシェイクする所までやってみるか…という勢いでやってみたのだけど、コードはぐしゃっと書いたからカヲっているw


組込み向けということで、サイズは731216byte(714kbyte弱)になってしまった…TLS 1.2をガッサリ消してもこのサイズというのは、地味に心が折れてしまう…orz
何とか200Kbyte以下くらいまでには抑えたいのだけど。

あと、mbedTLSの実装も一通り見ながら出来たのは良かったかも。なーんとなく、mbedTLSのオフィシャルにQUIC入るのかなぁ…というのは、ちょっぴり疑問な気はした。直撃でmbedTLSのコードセット類を大改造して、TLS1.3/UDP(QUIC)を両立させるには、かなりの内部修正が必要というか、API・関数類が思いっきり新規になるじゃないかなーと。その場合、何個かワイ的な感じでコードを追加して、mbedTLS用QUICみたいな実装がポコポコ出てきちゃったりするのかしら…みたいな気がしたり。
次はショートパケットでHTTP3でNginXをおしゃべりする所くらいまではやってみよかなと。

コメントを残す

メールアドレスが公開されることはありません。 が付いている欄は必須項目です

このサイトはスパムを低減するために Akismet を使っています。コメントデータの処理方法の詳細はこちらをご覧ください