Thursday, January 13, 2022

C++ Reference Collapsing (引用折叠) 及 Forwarding Reference (轉發引用)

1. 引言
在 C++ 98 初推出的時候,C++ 並沒有 Reference of Reference 的概念 (&&),所以,開發者不但不能直接使用 &&,即使用了 typedef 將 reference type 包裝好,然後再加上 &,編譯器都一律會報錯。

後來,C++11 終於開放了對 Reference of Reference (&&) 的支援。不過由於 C++11 也同時引進了 Move Semantics (也是 &&) 的寫法,所以,Reference of Reference 就會跟 Move Semantics 有所衝突。

以下是一個例子,開發者想做的是:func(k) 要跑的,是 lvalue 的版本的 func()(第一個,單 & 的版本),而非 rvalue 的版本( &&,也就是 move semantics 的版本):

#include <iostream>

typedef int& INTR;

template<class T>
void func(T& i) {
       std::cout << __PRETTY_FUNCTION__ << std::endl;
}

template<class T>
void func(T&& i) {
        std::cout << __PRETTY_FUNCTION__ << std::endl;
}

int main() {
        int i   = 123;
        INTR j  = i;    // int&  j = i
        INTR& k = j;    // int&& k = j

        func(k);        // k is lvalue (should run T(&i))
        func(123);      // 123 is rvalue (should run T(&&i))
        return 0;
}

它的輸出應為:

void func(T&) [with T = int]
void func(T&&) [with T = int]

這裏看起來,明明 k就應該要是 int &&,但編譯器居然知道它不是指 rvalue,而是指 reference of reference。當中的原因是, C++ 11 新增了 Reference Collapsing (引用折叠) 的規則,確保編譯器在在大部分情況下(也就是使用了 typedef / decltype / template 的情況下), 都能辨認出 Reference of Reference 仍然是 "reference",而非 "rvalue" 。 

---

2. Reference Collapsing 四個規則
引用折叠有四個主要的編譯規則(其實有更多,不過不太重要,在此不述):

  • A& & becomes A&
  • A& && becomes A&
  • A&& & becomes A&
  • A&& && becomes A&&

反正重點是,編譯器會在碰到兩次 && 的時候,它才會當它是指 rvalue。其他情況的話,它會把 Reference of Reference (&&) 都降級成 Reference (&)。

---

3. Universal Reference / Forwarding Reference (轉發引用)
既然 && 會在編譯的時候,自動按情況降級成 &,那麼我們在寫 function template 的時候,是否可以寫少一個,然後讓編譯器自行將 function 變成兩個 (&& 和 &) 的版本呢?答案是可以的。

先看以下例子:

#include <iostream>

template<class T>
void function(T&& t) {
        std::cout << __PRETTY_FUNCTION__ << std::endl;
}

int main() {
        int i = 123;
        function(i);
        function(996);
        function(std::move(i));
        return 0;
}

它的執行結果為:

void function(T&&) [with T = int&]
void function(T&&) [with T = int]
void function(T&&) [with T = int]

首先,i 是 lvalue (int &),所以 T = int& = T&&& = T & ,
其餘的 996 和 std::move(i) 則因為本身是 rvalue(int &&),所以直接 T = int。

---

4. Perfect Forwarding 完美轉發
有一個情況要注意:如果在 template function 中,我們想要把變數傳到下一個 function 的話, 正常來講,我們可能會這樣做:

template<class T>
void function(T&& t) {
        subFunction(t);
}

然後,我們會期待傳入 subFunction(t) 的 t 應該是 T&& / rvalue,但其實並不一定 ...

以下有一個例子:

#include <iostream>

void subFunction(int&& i) {
        std::cout << __PRETTY_FUNCTION__ << std::endl;
}

void subFunction(int& i) {
        std::cout << __PRETTY_FUNCTION__ << std::endl;
}

template<class T>
void function(T&& t) {
        std::cout << "Value: " << t << std::endl;
        std::cout << __PRETTY_FUNCTION__ << std::endl;
        subFunction(t);
        subFunction(std::forward<T>(t));
        subFunction(static_cast<T&&>(t));
        subFunction((T&&)(t));
}

int main() {
        int i = 123;
        function(i);
        function(996);
        function(std::move(i));
        return 0;
}

它的輸出為:

Value: 123
void function(T&&) [with T = int&]
void subFunction(int&)
void subFunction(int&)
void subFunction(int&)
void subFunction(int&)

Value: 996
void function(T&&) [with T = int]
void subFunction(int&)
void subFunction(int&&)
void subFunction(int&&)
void subFunction(int&&)

Value: 123
void function(T&&) [with T = int]
void subFunction(int&)
void subFunction(int&&)
void subFunction(int&&)
void subFunction(int&&)

由結果可見,如果只用  subFunction(t),編譯器會認為 t 是一個 lvalue。
在 function 的 scope 之中,t 的確是一個變數,是 T&& 的 reference (T&& & = T&)。

如果要解決這個問題,把 && 正確地傳到 subFunction,最簡單的方法,就是把 t 的型態轉強制換成 T&&。如此,傳入來的 t 就能完全不經處理地傳到 subFunction。在解 subFunction 的時候,編譯器會找回最初的 i / 996 / std::move(i),再決定執行那一個版本的 subFunction。

而 std::forward<T>,其實就是 static_cast<T&&> 的語法糖而已。

---

小結
C++11 為了讓 Reference of Reference 變得簡單,並加上對 Move Semantics 的支援,同時令編譯器做到最大程度的向後兼容(不加新 keyword + 舊程式碼可跑),似乎把事情變得更複雜了:它使用了 Reference Collapsing 將不同情況的 && 分開。但同時,這種做法也導致 template function 如果用了 &&,很大機會要在 function call 時,對 variables 加上 static_cast,才能正常地運作 。

但是,我們只要無腦採用 universal reference (兩個 &&)加上 std::forward<T>,基本上就能解決 99% 的問題。向好的方向去想:使用 move semantics 加上 universal reference,不但能加快執行時間,或許更能節省程式碼行數,加快 debug 效率。

Saturday, October 16, 2021

Device Provisioning Protocol (DPP) / Wi-Fi Easy Connect 分析

DPP / Wi-Fi Easy Connect 簡介

日常生活中,我們經常都需要連接不同的 Wi-Fi 熱點。例如:當我們去到咖啡店的時候,通常牆身都會寫上了 Wi-Fi 的 SSID 和密碼。或者當去到朋友家的時候,我們也會問朋友拿 Wi-Fi 的密碼。不過,以這種方法連接 Wi-Fi,其實會帶來兩個潛在的資安風險:「主動攻擊」和「被動攻擊」。

「主動攻擊」是指,有一些黑客,他們會主動去製造一些假的 Wi-Fi AP 基站:只要它們發出同樣的 SSID,設上同樣的密碼,用家就有可能會不小心連上了這些假 AP。只要訊號夠強,速度夠快,大部分用家根本不會發現。這樣,黑客就能發動中間人攻擊 (man-in-the-middle attacks),去盜取用家所有的訊息。

「被動攻擊」則指,黑客們只負責竊聽 (eavesdrop) 在大氣電波中的數據,並不主動發動攻擊。由於密碼都寫在牆上了,所以只要基站 (AP) 與使用者 (STA) 雙方,都沒有用上足夠安全的加密協議(例如只使用了 WEP / WPA / WPA2)的話,黑客就可以輕易將內容解密。

DPP / Wi-Fi Easy Connect 協議的出現,正正是為了解決以上的情況而設計的。簡單來說,在主動攻擊方面,DPP 透過公私鑰的交換,確保用戶只會連到真正的 AP。而在被動攻擊方面,DPP 使用了迪菲-赫爾曼密鑰交換 (Elliptic Curve Diffie-Hellman Key Exchange) 的方法,建立出獨一無二的 session key。所以,即使竊聽者擁有密碼,他們也沒辦法輕易推斷出 session key,繼而也無法解密訊息。  

這篇文章,我只會簡介 DPP 的不同步驟 (steps) 、角色 (roles) 和在 hostap 的實作。關於 Public Private Key Infrastructure (PKI)、Diffie-Hellman 的原理和每個步驟的細節,之後會再寫另一篇解釋。 


DPP 五大步驟

簡單來說,如果只考慮 AP 與 STA 之間的協議,DPP 有以下五大步驟:

  • Bootstrapping (起步):這個步驟是 DPP 的第一步。它指使用頻外驗證 (Out-of-band Authentication) 的方法去交換公鑰。例如將公鑰印成 QR code 貼在牆上、印在機身,或者使用藍芽或 NFC 這類近距離的方法。

  • DPP Authentication(DPP 驗證):在 bootstrapping 期間成功交換的公鑰,將會用作 HMAC 的參數,繼而產生三條不同的 session keys (k1, k2 以及 ke)。這個步驟主要用作確保雙方均在開機的狀態(又稱為 Aliveness),而且產生出來的 ke 是獨一無二的。

  • DPP Configuration(DPP 設定佈置):當 authentication 產生了 ke 後,configuration 會用這個 ke (配以 AES-SIV)來加密 Wi-Fi AP 的登入憑證 (credentials / configuration object)。例如,它會包含 SSID, WPA3/WPA2 密碼,或者是 DPP Connector 公私鑰。(如果是 DPP Connector 的話,Connector 入面還包含了 groupId 和 netRole 這些資訊,而且該 connector 會以 ECDSA 簽名,所以任何接收到 connector 的人,都不能擅自改動 connector 的內容。)

  • DPP Network Introduction / Peer Discovery(DPP 設備探索):這個步驟只適用於有收到 DPP Connector 的情況(如果是 WPA2/3,它就會離開 DPP,走傳統的連線方法)。簡單而言,不同裝置之間會在這步交換 connector,去查核對方的 groupId 和 netRole。如果對方的 netRole 正確,就會繼續產生 Pairwise Master Key (PMK)。例如,如果 AP 擁有一個 groupId="Group1" 和 netRole="ap" 的 connector,而 STA 則擁有一個 groupId="Group1" 和 netRole="sta" 的 connector 的話,因為它有相同的 groupId,而且一個是 ap,另一個是 sta,所以雙方就會同意繼續連線,並且用 connector 中的 netaccesskey 進行 ECDH 及 HKDF,產生 PMK。

  • DPP Network Access / Association(DPP 網絡接入):最後一步,就是使用在 peer discovery 中產生的 PMK 接入 Wi-Fi AP。這個步驟大致上跟 WPA2 的 PSK 相似,只是把 PSK 換成了 PMK。所以,STA 會以 Open 認證方式連上 AP,再跑 EAPOL (4-way handshake),從而由 PMK 產生一條新的 PTK。在產生過程中,使用者亦可在 DPP KDE 中表示對 PFS (Perfect Forward Secrecy) 的支援,它就會在接入的過程中,使用 AP 在 RSN IE 中廣播的 Z 公鑰,以加強 PTK 的獨特性。

 

DPP 的四大角色

在官方的文檔中,有四個角色 (Initiator / Responder / Configurator/ Enrollee) 經常出現混淆。
但其實,分類方法十分簡單:

DPP Authentication 中有兩個角色:

  • Initiator(發起者):它接收由 Responder 發出的 DPP URI,然後發出 Authentication Request。 (如果啟用了雙向驗證的話,則兩者互換 DPP URI)
  • Responder(回應者):它接收 Authentication Request,並以 Authentication Response 回應 Initiator 的請求。 

DPP Configuration 中有兩個角色:

  • Configurator(佈局者):它儲存了所有憑證 (credentials) 相關的資訊,也會在別人發出 Configuration Request 時,以 Configuration Response 回應它的請求。它也有一條 Certificate Signing Key (又稱 csign key), 用作簽發 DPP connector 之用。
  • Enrollee(登記者):所有希望接收憑證 (credentials) 的人都是 Enrollee。它們會發出 Configuration Request,等待 Configurator 的回應。

 

這四個角色,可以隨著不同的情況完全自由配搭,例如:

  • AP (Initiator + Configurator) 配搭 STA (Responder + Enrollee)
    • AP 通過掃瞄 STA 的 QR code,令 STA 可以連上 AP
    • 這種情況,AP 是路由器的 App,STA 則是 IoT 設備

  • STA (Initiator + Configurator) 配搭 AP (Responder + Enrollee)
    • STA 掃瞄 AP 的 QR code,然後設置路由器的 WPA2/3 密碼
    • 比較少見,但理論上可行

  • STA (Initiator + Configurator) 配搭 STA (Responder + Enrollee)
  • AP (Responder + Configurator) 配搭 STA (Initiator + Enrollee)
    • AP 產生一個 QR code 貼在牆上,其他 STA 一掃就能連上。
    • AP 是路由器,STA 是手機

  • STA (Responder + Configurator) 配搭 STA (Initiator + Enrollee) 
    • 兩部手機之間互相分享 WPA2/WPA3 密碼
    • Android STA 已部分支援這個功能 (Initiator + Enrollee)

反正就是,只要你想到的,就可以做到,協議中並沒有限制角色配搭的可能性。

 

在 HostAP 中實作

現時,hostap 2.9 只支援大部分的 DPP 1.0,以及少部分的 DPP 2.0。
而在最新的 commit 中(截稿前是這個),大部分 DPP 2.0 的實作已經做好。

以下的 hostap 指令例子,是以 AP (Initiator + Configurator) + STA (Responder + Enrollee) 為基礎的。你也可以把它想像成這個 use case :

  • hostapd 是路由器
  • hostapd_cli 是路由器的手機界面
  • wpa_supplicant 和 wpa_cli 是沒有按鈕,只顯示著 QR code 畫面的 IoT 裝置

 

Step 1: STA - 放以下的 config (wpa_dpp.conf):

ctrl_interface=DIR=/var/run/wpa_supplicant
ctrl_interface_group=0
update_config=1
pmf=2
dpp_config_processing=2

 

Step 2. STA - 跑以下的命令,開啟 wpa_supplicant:

./wpa_supplicant -d -Dnl80211 -iwlp1s0 -cwpa_dpp.conf

 

Step 3. STA - 跑以下的命令,產生一條新的 DPP URI QR code,然後等待連線:

./wpa_cli dpp_bootstrap_gen type=qrcode chan=81/1
dpp_bootstrap_get_uri 1
dpp_listen 2412

將產生的 QR code 拷貝下來,在 Step 6 會用到。

 

Step 4. AP - 放以下的 config (hostapd_dpp.conf):

interface=wlp0s20f3
driver=nl80211
ctrl_interface=/var/run/hostapd
ssid=test
channel=1
wpa=2
wpa_key_mgmt=DPP
ieee80211w=1
wpa_pairwise=CCMP
rsn_pairwise=CCMP

 

Step 5. AP - 跑以下的命令,開啟 hostapd:

./hostapd -d hostapd_dpp.conf

 

Step 6.  AP - 打開 hostapd_cli,並貼上剛拷貝的 DPP URI 到以下的命令中

./hostapd_cli
dpp_qr_code <YOUR COPIED DPP URI>

 

Step 7. AP - 在 hostpad 中加一個 configurator。這個 configurator 將會用來簽發所有裝置(包括 ap 自己)的 DPP connectors :

dpp_configurator_add

 

Step 8. AP - 用 configurator 為 ap 自己,簽發一個 connector。在 Peer Discovery 中,每一個裝置都是用 connector 去交換身分資料,所以,ap 自己也需要一個 connector:

dpp_configurator_sign conf=ap-dpp ssid=74657374 configurator=1

 

Step 9. AP - 把剛剛 configurator 產生的 connector 套用到 ap 本身身上。這樣,當有 peer discovery request 的時候,ap 就可以把自己的 connector 傳出去,而且也懂得用套用上的 csign public key 驗證對方的 connector:

set dpp_connector <CONNECTOR VALUE IN TERMINAL>
set dpp_csign <CSIGN VALUE IN TERMINAL>
set dpp_netaccesskey <NETACCESSKEY VALUE IN CONSOLE>

注意這裏的 connector 是已經簽好的 base64 字串,裏面包含了 groupId, netRole, netaccesskey 的public key 和一個 ECDSA 簽名。這個 csign key 則是 public key (csign key 的 private key 是藏在 configurator 裏面的)。而最後,這個 netaccesskey 則是 private key,用作給 ap 在 peer discovery 時產生 PMK 之用。

 

Step 10. AP - 因為 AP 是 initiator 及 configurator,所以要跑以下命令,發起 DPP authentication request。這個命令也要求 configurator 在 configuration response 期間,產生一個 netrole=sta, ssid=test 的 dpp connector 回傳給對方 :

dpp_auth_init peer=1 conf=sta-dpp ssid=74657374 configurator=1

 

小結

DPP (Device Provisioning Protocol,又名 Wi-Fi Easy Connect)是近年來比較新的 Wi-Fi 驗證方法。雖然它仍然需要 out-of-band 的方法去做第一步,而且步驟繁瑣,但至少也算是提供了一個可行的做法。

它的設計邏輯,其實跟 USB Type C 也有點類似:它提供了同一樣的接口,讓兩個裝置至少溝通得上,但實際支援與否,則要看它在交換的過程中,對方的 role 與自己是否配搭。

作為 protocol designer,我認為 DPP 的 signalling messages 實在有點多,而且對 WPA2 的 backward compatibility 可能會令所有事情功虧一簣。但另一方面,正正因為它把步驟都拆分得很細,它也可以在耗用系統資源較少的情況下,及早辨認出 DoS flooding 的封包。這一點,對於未來「萬物皆互聯」的世界,似乎對系統穩定性也有幫助。

但是,如果文檔附有 formal verification 的話,我相信說服力應該會再大一點。

---

引用

[1] - Wi-Fi Easy Connect Spec 2.0 - https://www.wi-fi.org/download.php?file=/sites/default/files/private/Wi-Fi_Easy_Connect_Specification_v2.0.pdf

[2] - AOSP Wi-Fi Easy Connect - https://source.android.com/devices/tech/connect/wifi-easy-connect


Sunday, April 11, 2021

使用 minicom 及 expect 自動化控制 TTY 設備

簡介
傳統來講,使用 expect script 可以幫助我們自動化一些繁瑣的工作,例如以互動方式登入 SSH,或者進行自動化軟件安裝及部署。那麼,如果我們想要用 expect 去控制 TTY 裝置(例如 UART to USB 開發板、數據機等等),又如何操作呢?以下介紹兩種方法,希望可以幫助大家,輕鬆透過 minicom,進行簡單的自動化。

有人會問,為什麼一定要用 expect 呢?我能先自行 echo command 去 /dev/ttyUSB0,然後再用一個 while loop 去觀察 /dev/ttyUSB0 的輸出,不就可以了嗎?但我發現,這種方法,將會製造一個「空窗期」:如果 tty 硬件十分快,它就有可能在 echo command 執行完成之後,以及在 while loop 開始執行之前,已經將結果輸出。這樣的話,之後才開始的 while loop,就會一直讀不到有 output,然後跌入 infinite loop 之中。理論上,我們可以透過 multi-thread,邊讀 TTY 內容,邊寫 TTY command 去解決這件事,但我覺得,這樣又實在太過小題大做。所以,使用 expect 就是最簡單的方法。

本來,使用 expect 去做 interactive scripting 是一件非常簡單的事情, 但奈何 expect 的文檔十分殘缺,而且上述問題又實在太多人忽略掉,單靠搜查文檔,其實很難去理解這件事。所以,我決定在此記下,希望對其他人也有所幫助。

---

方法一:使用 expect script 直接操控 minicom
這種方法最為直覺,你只需要使用 expect,就可以直接與 minicom 連接:

#!/usr/bin/expect
set timeout 10
exec bash -c "echo Hello World"
spawn minicom -D /dev/ttyUSB0 -b 9600 -C /tmp/minicom.log
send "ps"
expect "#"
send_to_user "completed ps command to ttyUSB0"

使用這種方法,expect 會持續監察 spawn 出來的 minicom 中的 stdout,並且會把 send 的內容傳送回 minicom 的 stdin 中,所以,它就能與 minicom 直接進行互動。然後,使用 exec 加上 bash,就能執行各種各樣非 expect 的 commands。這種情況,minicom 負責所有與 tty 裝置的內容收發。 

不過,如果你打算運行很多非 expect 的 commands 的話,這種方法就會變得很慢,並且難以理解。因為每次運行 bash commands,都要先 exec bash,然後再在 bash 中運行 command。

---

方法二:使用 bash 開啟 minicom,並透過查看 minicom 的 log 與 tty 裝置互動
這種方式較為間接,使用 bash 作為主力,expect 則成為其中一個 bash command:

#!/bin/bash
echo "Hello World"
minicom -D /dev/ttyUSB0 -b 9600 -C /tmp/minicom.log" &
PID_MINICOM=$#
expect -c '
    timeout 10
    spawn tail -Fn0 /tmp/minicom.log
    exec bash -c "echo ps > /dev/ttyUSB0"
    expect "#"
    send_to_user "completed ps command to ttyUSB0"
'
kill $PID_MINICOM

以上的代碼,bash 會先在背景開啟 minicom。接著,bash 會開啟一個 expect process,並且持續監察 minicom 的 log 輸出。如果 expect 想要與 tty 互動,由於 tail 的 stdin 並沒有任何作用,它將不能使用 send。所以,你需要把內容自行用 exec bash,再用 echo 把  command 內容 redirect (>) 進 /dev/ttyUSB0。

這種做法,雖然看起來比較複雜,但如果你打算使用 bash 作為為最主要的語言,它所產生的系統開銷會少很多(因為不需要頻繁開新 bash)。但反的來說,如果你的 script 打算主力運行 expect,並需要與 TTY 有很多互動的話,這種方法就比較緩慢:因為每一次 send TTY command,它也需要 exec 一個新的 bash。

---

小結
使用 expect 能使用非常短的語法,解決與 TTY device 互動所產生的各種問題。是次我分別介紹了以 expect 為主,以及用 bash 為主的兩種 expect script 寫法,以滿足不同情況的需要。