이번 시간에는 NanoPi R4S board를 가지고 PQC Wireguard 기반 초소형 VPN router를 만드는 과정을 소개해 보고자 한다. 😎
목차
1. NanoPi R4S board 소개
2. OpenWrt build하기
3. WireGuard Kernel 코드 분석하기
1. NanoPi R4S board 소개
2. OpenWrt build하기
3. WireGuard Kernel 코드 분석하기
4. CRYSTALS Kyber PQC 알고리즘 소개
5. NanoPi R4S 용 Quantum Safe WireGuard 구현하기
6. Alpine Linux 용 Quantum Safe WireGuard 구현하기
7. References
Keyword: NanoPi R4S, OpenWrt, WireGuard, PQC(Post Quantum Cryptography)
보통은 board 하나를 소개하면서 device tree & device driver 부분을 먼저 언급하는 것이 순서이겠으나, 이번에는 wireguard kernel code 분석 및 PQC 알고리즘 porting에 집중해 보고자 한다.
1. NanoPi R4S board 소개
최근에 FriendlyElec사가 개발한 NanoPi R4S 보드를 하나 구입했다. NanoPi R4S의 h/w 스펙을 확인해 보니, 놀랍게도 CPU core가 6개나 되며, ARM big.LITTLE 아키텍쳐를 사용하는 것이 눈길을 끈다.
[그림 1.2] NanoPi R4S board(2)
(big) ARM Cortex-A72(Dual core) + (LITTLE) ARM Cortex-A53(Quad core)
NanoPi R4S를 선택한 이유는, 지난 시간에 소개한 OrangePi의 경우와 마찬가지로 LAN port가 2개라서 Security Gateway 구성이 가능하기 때문인데, Orange Pi R1 Plus LTS와는 다르게 2개의 MAC이 장착된 점이 아주 마음에 든다(Cool~). 아래에 NanoPi R4S의 주요 hardware spec을 옮겨 보았으니, 확인해 보기 바란다.
___________________________________________________________________________
<NanoPi R4S 보드의 h/w 스펙>
<여기서 잠깐>
ARM big.LITTLE 아키텍쳐란 ?
ARM Holdings에서 개발하는 CPU 마이크로아키텍처인 ARM Cortex-A 시리즈가 시간이 지나면서 점차 고성능화되자, 전통적인 ARM CPU 설계에서의 가장 큰 특징이라 부를 수 있는 전력 대 성능비가 저하되고 CPU 대기 시간 동안의 누설전류 문제가 점차 증가하게 되었다. big.LITTLE은 이러한 단점을 개선하고자 개발되었다. ARM CPU의 전통에서 벗어나서 미칠 듯한 발열과 자비없는 전력 소모율을 보여주기 시작하는 ARM Cortex-A15와 ARM Cortex-A7을 하나의 칩 안에서 쓰기 위한 인터커넥트인 CCI-400이 발표되면서 big.LITTLE을 본격적으로 도입하기 시작했다. [출처 - 참고문헌 2]
[그림 1.3] ARM big.LITTLE 아키텍쳐 [출처 - 참고문헌 3]
2. OpenWrt build하기
이번 장에서는 NanoPi R4S용 openwrt code를 build해 보고, 이를 target board에 올려 보는 과정을 소개해 보기로 한다.
a) NanoPi R4S용 openwrt build 하기
$ mkdir nanopi_r4s
$ cd nanopi_r4s
$ repo init -u https://github.com/friendlyarm/friendlywrt_manifests -b master-v21.02 -m rk3399.xml --repo-url=https://github.com/friendlyarm/repo --no-clone-bundle
$ repo sync -c --no-clone-bundle
📌 정확한 명령은 위의 wiki page를 참조하기 바란다(내용이 계속 갱신되고 있는 듯 하다).
<여기서 잠깐>
repo init 시 아래와 같은 python permission 에러가 발생한다면, repo를 수동으로 설치한 후, repo init 명령을 수행해 주기 바란다.
_________________________________________
...
…
sl = self._semlock = _multiprocessing.SemLock(kind, value, maxvalue)
OSError: [Errno 13] Permission denied
_________________________________________
$ mkdir -p ~/.bin
$ PATH="${HOME}/.bin:${PATH}"
$ curl https://storage.googleapis.com/git-repo-downloads/repo > ~/.bin/repo
$ chmod a+rx ~/.bin/repo
___________________________________________________________________________
<friendlywrt source code build 하기>
$ ./build.sh nanopi_r4s.mk
...
using config device/friendlyelec/rk3399/nanopi_r4s.mk
============Start building uboot============
SRC = /home/chyi/workspace/boards/nanopi_r4s/friendlywrt-rk3399/u-boot
TARGET_ARCH = arm64
TARGET_PLAT = rk3399
TARGET_UBOOT_CONFIG=rk3399_defconfig
TARGET_OSNAME = friendlywrt
=========================================
uboot src: /home/chyi/workspace/boards/nanopi_r4s/friendlywrt-rk3399/u-boot
Reading package lists... Done
Building dependency tree
Reading state information... Done
Package android-tools-fsutils is not available, but is referred to by another package.
...
Suggest size: 25165824
Created filesystem with 11/6400 inodes and 1438/25600 blocks
-----------------------------------------
rootfs dir:
/home/chyi/workspace/boards/nanopi_r4s/friendlywrt-rk3399/scripts/sd-fuse/out/rootfs.hDG7FTWcP
boot dir:
/home/chyi/workspace/boards/nanopi_r4s/friendlywrt-rk3399/scripts/sd-fuse/out/boot.M7I0SPsyp
-----------------------------------------
Creating RAW image: out/friendlywrt_21.02_20221116_nanopi-r4s_arm64_sd.img (1000 MB)
---------------------------------
0+0 records in
0+0 records out
0 bytes copied, 2.6134e-05 s, 0.0 kB/s
----------------------------------------------------------------
[out/friendlywrt_21.02_20221116_nanopi-r4s_arm64_sd.img] capacity = 953MB, 999999488 bytes
current out/friendlywrt_21.02_20221116_nanopi-r4s_arm64_sd.img partition:
----------------------------------------------------------------
parsing ./friendlywrt21/parameter.txt:
create new GPT 9:
----------------------------------------------------------------
copy from: ./friendlywrt21 to out/friendlywrt_21.02_20221116_nanopi-r4s_arm64_sd.img
[RAW. 0]: 198 KB | ./friendlywrt21/idbloader.img > 100% : done.
[RAW. 1]: 4096 KB | ./friendlywrt21/uboot.img > 100% : done.
[RAW. 2]: 4096 KB | ./friendlywrt21/trust.img > 100% : done.
[RAW. 3]: 48 KB | ./friendlywrt21/misc.img > 100% : done.
[RAW. 4]: 1 KB | ./friendlywrt21/dtbo.img > 100% : done.
[RAW. 5]: 2560 KB | ./friendlywrt21/resource.img > 100% : done.
[RAW. 6]: 31948 KB | ./friendlywrt21/kernel.img > 100% : done.
[RAW. 7]: 7308 KB | ./friendlywrt21/boot.img > 100% : done.
[RAW. 8]: 351034 KB | ./friendlywrt21/rootfs.img > 100% : done.
[RAW. 9]: 5752 KB | ./friendlywrt21/userdata.img > 100% : done.
----------------------------------------------------------------
---------------------------------
RAW image successfully created (15:50:54).
-rw-rw-r-- 1 chyi chyi 999999488 Nov 16 15:50 out/friendlywrt_21.02_20221116_nanopi-r4s_arm64_sd.img
Tip: You can compress it to save disk space.
-----------------------------------------
Run the following command for sdcard install:
sudo dd if=out/friendlywrt_21.02_20221116_nanopi-r4s_arm64_sd.img bs=1M of=/dev/sdX
b) Image file 설치 후, 부팅하기
앞서 build한 image 파일의 압축을 푼 후, dd 명령 등을 사용하여 microSD에 설치하도록 한다.
<microSD에 image writing하기>
$ cd out
$ gzip -d friendlywrt_21.02_20221116_nanopi-r4s_arm64_sd.img.gz
$ sudo dd if=./friendlywrt_21.02_20221116_nanopi-r4s_arm64_sd.img bs=1M of=/dev/sdX
📌 당연한 거지만, /dev/sdX는 실제 인식된 값으로 수정하도록 하자. 예) sdb
📌 NanoPi R4S는 case도 함께 제공한다. 나름 촌스럽지 않고 괜찮다~
이제, 케이스 뚜껑을 연 상태에서 serial console cable을 연결하고, 부팅하는 모습을 들여다 보자.
[그림 2.2] Target board에 Serial Console 케이블 연결 후, 구동한 모습
$ minicom -b 1500000 -D /dev/ttyUSB0
📌 minicom 명령 끝에 -s option을 주어, minicom 설정 화면에서 Hardware Flow Control 설정을 No로 해 주어야 keyboard 입력이 안되는 문제가 해결될 것이다.
[그림 2.3] NanoPi R4S 부팅 모습(ROM code => u-boot => kernel 순)
📌 CPU가 6개가 맞다.
c) NanoPi R4S용 u-boot & kernel build 하기
NanoPi R4S에서는 u-boot과 kernel을 별도로 build하는 것이 가능하다. NanoPi R4S용 u-boot code와 kernel code의 특징을 파악해 보고 싶은 마음은 굴뚝같으나, (다음을 기약하며) 여기서는 각각을 build하는 선에서 마무리하고, 다음 장으로 넘어가도록 하자.
<u-boot>
$ ./build.sh uboot
<kernel>
$ ./build.sh kernel
(kernel build의 경우) 위의 script를 사용하는 대신, 당연히 아래와 같이 수동 build도 가능하다.
$ export PATH=/opt/FriendlyARM/toolchain/11.3-aarch64/bin/:$PATH
📌 kernel 4.19 이상인 경우에는 toolchain 11.3을 사용해야 한다.
$ export ARCH=arm64
$ export CROSS_COMPILE=aarch64-linux-gnu-
$ export CC=aarch64-linux-gnu-gcc
$ export LD=aarch64-linux-gnu-ld
$ make clean
$ make nanopi4_linux_defconfig
$ make menuconfig
$ make -j16
$ mkdir -p out-modules && rm -rf out-modules/*
$ make INSTALL_MOD_PATH="$PWD/out-modules" modules -j$(nproc)
$ make INSTALL_MOD_PATH="$PWD/out-modules" modules_install
____________________________________________________________________________
<여기서 잠깐>
NanoPi R4S용 device tree 파일은 어떤 파일을 살펴 보면 알 수 있을까 ?
$ cd arch/arm64/boot/dts/rockchip
rk3399.dtsi, rk3399-opp.dtsi
^
|
rk3399-nanopi4.dtsi
^
|
rk3399-nanopi-r4s.dts
____________________________________________________________________________
3. WireGuard Kernel 코드 분석하기
WireGuard kernel code 분석과 관련해서는 일전(4장 참조)에 한차례 간략히 정리했던 기억이 있다. 이번에는 지난번 분석에서 다소 미진했던 부분과 최신 kernel 5.15를 대상으로 하여 추가 분석을 진행하고자 한다. 따라서 이번에 분석하는 내용과 이전 blog posting의 내용을 같이 참조하는 것이 wireguard를 전체적으로 이해하는 데에 도움이 될 것이다. 😎
[그림 3.1] WireGuard logo
WireGuard를 제대로 파헤쳐 보자. 가즈아~ 🔍
<WireGuard code 분석 Point>
[1] wg tool을 이용힌 get/set 흐름(netlink socket) - wg0 device 및 peer 설정 흐름
[2] device, peer 초기화 과정
[3] noise handshaking 과정
[4] peer lookup 과정
[5] handshake & data packet send/receive 흐름
[6] udp tunnel(encapsulation & decapsulation)
[7] ARM NEON 기반 암호 가속(or Intel AVX2 기반 암호 가속)
WireGuard kernel code를 이해하는데 걸림돌이 되는 부분으로는 (물론 독자에 따라 편차는 있겠으나) 다음과 같은 것들이 될 수 있을 것 같다. Wireguard의 코드량은 많지 않지만, 미리 숙지해야 할 사항이 만만치 않은 것이 사실이다. 😋
<넘어야 할 장애물>
[1] Noise handshaking protocol: ECDH, HKDF, HMAC, Blake2s, ChaCha20Poly1305 AEAD ...
[2] SipHash2-4 기반의 hashtable(peer hashtable, index hashtable)
[3] netlink socket(userspace와의 interface)
[4] skb와 Linux tcp/ip stack, netdevice, udp tunnel, socket structure
[5] ptr ring(crypt queue)
[6] workqueue, timer, list, hash list 등 다양한 kernel 기능 사용
[7] 다양한 lock 함수 호출(rcu, rw semaphore, mutex, spinlock ...)
[8] kernel memory 사용 관련
[9] ...
a) WireGuard source code 위치
WireGuard가 Kernel(5.6)에 통합되면서 기본 코드는 drivers/net/wireguard 아래에 있으나, crypto 및 기타 코드는 linux kernel에서 기존에 사용하던 위치에 배치되어 있으므로 이들을 일일이 찾아내는 것도 여간 쉽지가 않다.
Crypto files
lib/crypto/chacha20poly1305.c
____________________________________________
WireGuard base codes : drivers/net/wireguard
allowedips.[ch]
- allowed ips 관련 코드, struct allowedips_node로 구성된 table을 관리한다.
cookie.[ch]
- cookie 관련 코드
device.[ch]
- net_device 관련 코드 - wg_open/wg_stop/wg_xmit/wg_setup/wg_newlink ...
main.c
- main routine - mod_init/mod_exit
netlink.[ch]
- userspace tool인 wg와의 netlink socket 통신 코드, device(net_device) 자신 및 peer에 대한 설정
noise.[ch]
- wireguard key handshaking protocol code(결국 이 부분이 핵심)
peer.[ch]
- peer 생성/삭제 관련 코드
peerlookup.[ch]
- 2개의 hash table(public key 기반 hash table, index 기반 hash table)에 대한 operation(alloc, add, remove, lookup) 정의
queueing.[ch]
- (중간 중간에) packet을 담아두는 queue(ptr_ring buffer) 관련 operation(alloc, init, free) 정의
ratelimiter.[ch]
- rate limit 관련 코드
receive.[ch]
- 패킷 수신 관련 코드(복호화 포함)
send.[ch]
- 패킷 송신 관련 코드(암호화 포함)
socket.[ch]
- send4/6( ) 함수 호출하여 패킷 송신(송신 시 udp tunnel 처리함), .encap_rcv = wg_receive 통해 udp tunnel 패킷 수신부 정의
timers.[ch]
- wireguard에서 사용하는 각종 timer 정의
SIPHASH codes
include/linux/siphash.h
UDP Tunnel codes
net/ipv4/udp_tunnel_core.c
include/net/udp_tunnel.h
Random number 생성 codes
drivers/char/random.c
lib/crypto/chacha20poly1305.c
arch/arm/crypto/curve25519-glue.c
lib/crypto/curve25519-fiat32.c
lib/crypto/curve25519-fiat32.c
arch/arm/crypto/chacha-glue.c
arch/arm/crypto/poly1305-glue.c
lib/crypto/poly1305.c
...
...
물론 이 외에도 더 있다... 찾는 대로 추가해 보도록 하자. 😝
__________________________________________________________________________
b) wg tool을 이용한 get/set 흐름(netlink socket) - wgX device 및 peer 설정 흐름
WireGuard는 아래 그림과 같이 wg라는 user space tool과 wireguard kernel module로 구성되어 있다. 이 둘은 netlink socket을 통해 연결되어 있다.
📌 아래 그림 및 실제 wireguard code를 제대로 이해하기 위해서는 linux netlink socket의 개념을 먼저 파악해 두어야 한다. Netlink socket에 관해서는 [참고 문헌 7]에 잘 정리가 되어 있으니 참조하기 바란다.
<wg tool set operation>
[그림 3.3] wg tool의 set 명령 수행시의 코드 흐름
<kernel module netlink set operation>
[그림 3.4] wg tool의 set 명령 수행시, 커널에서 받아주는 코드 흐름
📌 Userspace wg 명령으로 부터 set 명령이 netlink socket을 타고 kernel로 전달되면, kernel code에서는 netlink message를 적절히 parsing한 후, wgX interface 관련 속성을 변경해 주는 것은 물론이고, peer 관련 설정을 해주게 된다.<wg tool get operation>
[그림 3.5] wg tool의 get 명령 수행시의 코드 흐름
<kernel module netlink get operation>
[그림 3.6] wg tool의 get 명령 수행시, 커널에서 받아주는 코드 흐름
📌 Userspace wg 명령으로 부터 get 명령이 netlink socket을 타고 kernel로 전달되면, kernel code는 device(wgX) 및 peer의 정보를 netlink message에 모두 실어서 userspace로 전달해주게 된다.
wg tool과의 interface(netlink socket) 부분을 살펴 보았으니, 그 다음 순서는 wg device 초기화 및 peer 초기화 코드를 분석하는 것이다. 이 부분은 noise handshaking과 packet send/receive와 연관되는 아주 중요한 부분이라고 볼 수 있다.
c) device(wgX) 초기화 과정
Wireguard code를 보다 보면 어떤 일을 반복적으로 수행하는 코드가 눈에 많이 띈다. 이를 kernel에서 처리하는 대표적인 예로는 timer와 work queue를 생각해 볼 수 있다. Timer는 일정 시간이 경과할 때마다 지정된 작업을 수행하는 특징이 있으며, work queue는 언제 실행될지는 정확히 예측할 수는 없으나, 하고자 하는 work을 queue에 넣어두면, (scheduling이 되는 시점에) 이를 worker thread가 처리하도록 하는 특징을 갖는다. 이와 관련(Timer, Work Queue, Tasklet, Interrupts 등)해서는 일전에 작성한 아래 글(3장)을 참고하기 바란다.
📌 우리가 익히 잘 알고 있는 RTOS(예: FreeRTOS, Zephyr 등)는 하고자 하는 work을 정확히 원하는 시간에 수행하는 특징을 갖고 있기 때문에 Real Time OS라고 부른다. Linux 변형 배포판 중에는 RT-Linux라는 것도 있다.
________________________________________________________________________________
<여기서 잠깐>
Work Queue의 동작 원리에 관하여...
Work queue는 앞서 설명한 대로 deferring work(지연 처리하고 싶은 work)을 처리하기 위한 linux kernel 기능 중의 하나로 work(work function 포함), queue, worker thread의 3가지 요소로 구성되어 있다. work queue의 실체는 struct workqueue_struct의 instance로 이해하면 될 것 같다.
(1) struct work_struct(work) + (2) struct workqueue_struct(workqueue) + (3) struct worker(worker thread)
일반적인 사용 방법을 예시와 함께 간략히 정리해 보았는데, 이 개념이 이해가 되어야 wireguard의 현란한 work queue 코드를 이해하는데 부담이 없을 것으로 보인다.
1. work과 work queue를 선언한다.
struct workqueue_struct *myqueue;
struct work_struct thework;
2. work function(즉, handler 함수)를 선언한다. 여기에 지연 처리에 필요한 코드를 작성해 둔다.
void dowork(void *data) { /* work function codes here */ };
3. work queue를 초기화하고, 앞서 선언한 work function을 work에 끼워 넣는다.
myqueue = create_singlethread_workqueue("mywork"); //사용자 정의 worker thread 생성
INIT_WORK(&thework, dowork, <data-pointer>); //work과 work function을 연결
4. 적당한 시점에 work 함수가 실행되도록 schedule 함수를 호출한다.
queue_work(myqueue, &thework); //queue에 work을 넣어줌.
5. (이 부분은 필요시 호출) queue에 적재된 모든 work이 실행을 마칠 때까지 기다린다.
void flush_workqueue(struct workqueue_struct *wq);
6. workqueue를 종료한다.
cancel_work_sync() or cancel_delayed_work_sync() 함수 등을 호출한다.
________________________________________________________________________________
Wireguard에는 work queue 관련 코드가 많이 포함되어 있어, 코드를 이해하는데 어려움이 있지만, 앞서 설명한 내용과 몇가지 사실만 이해한다면 그리 어려운 것만도 아니다.
[그림 3.7] device.c 파일의 wg_newlink() 함수 중 work queue 관련 초기화 코드
device.c 파일의 wg_newlink() 함수에는 아래와 같이 3개의 workqueue 할당 코드(alloc_workqueue)와 3개의 wg_packet_queue_init 함수가 보인다.
__________________________________________________________________________
<wg_newlink() 함수>
wg->handshake_receive_wq = alloc_workqueue("wg-kex-%s", WQ_CPU_INTENSIVE | WQ_FREEZABLE, 0, dev->name);
wg->handshake_send_wq = alloc_workqueue("wg-kex-%s", WQ_UNBOUND | WQ_FREEZABLE, 0, dev->name);
wg->packet_crypt_wq = alloc_workqueue("wg-crypt-%s", WQ_CPU_INTENSIVE | WQ_MEM_RECLAIM, 0, dev->name);
ret = wg_packet_queue_init(&wg->encrypt_queue, wg_packet_encrypt_worker, MAX_QUEUED_PACKETS);
ret = wg_packet_queue_init(&wg->decrypt_queue, wg_packet_decrypt_worker, MAX_QUEUED_PACKETS);
ret = wg_packet_queue_init(&wg->handshake_queue, wg_packet_handshake_receive_worker, MAX_QUEUED_INCOMING_HANDSHAKES);
__________________________________________________________________________
먼저 handshake_receive_wg와 wg_packet_queue_init(...handshake_queue...) 함수와의 연관 관계를 먼저 따져 보는 것부터 시작해 보도록 하자.
[1] handshake_receive_wq와 wg_packet_queue_init() 함수의 상관 관계
📌 아래 내용을 보시면 아시겠지만, handshake_receive_wq의 목적은 handshake packet을 (cpu별로) 수신 처리하는 것이다.
1) work queue 할당 단계: handshake_receive_wg라는 이름의 work queue를 하나 만든다.
wg->handshake_receive_wq = alloc_workqueue("wg-kex-%s", WQ_CPU_INTENSIVE | WQ_FREEZABLE, 0, dev->name);
2) cpu 별 work 선언 및 work function 할당 단계 : 아래 함수를 이용하여 cpu 당 1개씩 필요한 work(struct work_struct)을 만들고 초기화 해 준다.
wg_packet_queue_init(&wg->handshake_queue, wg_packet_handshake_receive_worker, MAX_QUEUED_INCOMING_HANDSHAKES);
📌 이 함수에서는 실제로 아래 노란색 표시 부분을 설정해 주는 역할을 하게 된다.
___________________________________________________
//cpu별로 할당하여 사용하는 multicore_worker structure(= work queue와 work의 조합) 정의
struct multicore_worker { ... device.h
void *ptr; <= &wg->handshake_queue이 값이 할당됨.
struct work_struct work; <= 이게 cpu별 work임.
};
//
struct crypt_queue {
struct ptr_ring ring;
struct multicore_worker __percpu *worker;
int last_cpu;
};
___________________________________________________
아래에 wg_packet_queue_init( ) 함수를 펼쳐 보았다.
wg_packet_queue_init(struct crypt_queue *queue, work_func_t function, ...)
{
ptr_ring_init(&queue->ring, len, GFP_KERNEL) //ptr ring(queue)를 하나 할당한다.
queue->worker = wg_packet_percpu_multicore_worker_alloc(function, queue)
{
struct multicore_worker __percpu *worker = alloc_percpu(struct multicore_worker)
for_each_possible_cpu(cpu) {
per_cpu_ptr(worker, cpu)->ptr = ptr; //wg->handshake_queue의 주소를 대입
INIT_WORK(&per_cpu_ptr(worker, cpu)->work, function); // cpu 별로 function 즉, wg_packet_handshake_receive_worker를 work과 연결해 줌.
}
}
}
3) cpu별 해당 work 수행 요청 단계: 아래 schedule 함수를 호출하여 handshake_receive_wq work queue에 대한 work(wg_packet_handshake_receive_worker 함수)이 적당한 시점에 지정된 cpu에서 실행(schedule)되도록 해준다.
queue_work_on(cpu, wg->handshake_receive_wq, &per_cpu_ptr(wg->handshake_queue.worker, cpu)->work) ... receive.c
이 함수가 호출되면, 결국 handshake_receive_wq work queue에 대해 &per_cpu_ptr(wg->handshake_queue.worker, cpu)->work이 선택된 cpu에서 schedule 되게 된다. 좀 더 쉽게 말해 wg_packet_handshake_receive_worker() 함수가 실행된다고 보면 된다.
다음으로 handshake_send_wg와 wg_packet_queue_init(...handshake_queue...) 함수와의 연관 관계를 먼저 따져 보도록 하자.
[2] handshake_send_wq와 wg_packet_queue_init() 함수의 상관 관계
📌 handshake_send_wq의 목적은 handshake packet을 송신 처리하는 것이다.
handshake_send_wq 코드는 handshake_receive_wq code와는 약간 다른 구조로 되어 있다.
1) work queue 할당 단계: handshake_send_wg라는 이름의 work queue를 하나 만든다.
wg->handshake_send_wq = alloc_workqueue("wg-kex-%s", WQ_UNBOUND | WQ_FREEZABLE, 0, dev->name);
2) work 선언 및 work function 할당 단계: 아래 함수를 이용하여 work(struct work_struct)을 선언하고, 여기에 work 함수를 붙인다.
INIT_WORK(&peer->transmit_handshake_work, wg_packet_handshake_send_worker) ... peer.c
📌 INIT_WORK macro는 work과 work 함수를 연결시키는 역할을 한다. 그럼, 여기서 사용된 work은 어떻게 work queue와 연결될 것인가 ? 이는 다음 step을 보면 알 수 있다. 즉, queue_work( ) 함수를 이용하여 연결이 된다.
3) 해당 work 수행 요청 단계: schedule 함수를 호출하여 handshake_send_wq work queue에 대한 work(wg_packet_handshake_send_worker 함수)를 실행하도록 해준다.
queue_work(peer->device->handshake_send_wq, &peer->transmit_handshake_work))
📌 handshake_receive_wq와는 달리 _on이 뒤에 붙질 않는다. 즉, 특정 cpu를 지정하지 않고 current CPU에서 처리한다는 의미이다.
조금 복잡하지만, handshake_send_wq work queue는 transmit_handshake_work work과 연결되고, 이 work 안에는 wg_packet_handshake_send_worker 함수가 포함되어 있으므로, 최종적으로는 이 함수가 호출되게 된다.
다음으로 packet_crypt_wg work queue와 wg_packet_queue_init(...encrpyt...) 함수와의 연관 관계를 먼저 따져 보도록 하자.
[3] packet_crypt_wq와 wg_packet_queue_init(encrypt) 함수의 상관 관계
📌 packet_crypt_wg work queue의 목적은 data를 encrypt하는 것이다. 따라서 선택된 cpu에서 wg_packet_encrypt_worker 함수가 worker thread(kworker)에 의해서 수행된다.
📌 이 함수를 호출하면 아래와 같이 [wg-crypt-wg0]라는 thread가 만들어지는데, 실제로는 그 아래에 보이는 kworker thread가 cpu를 달리하면서 work function을 실행하게 된다.
root@FriendlyWrt:/proc/13916# ps w|grep wg
11690 root 0 IW< [wg-crypt-wg0]
15705 root 0 IW [kworker/3:0-wg-]
17514 root 0 IW [kworker/1:1-wg-]
17675 root 0 IW [kworker/0:0-wg-]
17756 root 0 IW [kworker/1:2-wg-]
18273 root 0 IW [kworker/1:0-wg-] // string이 잘려서 그런 것이지, 실제로는 [kworker/1:0-wg-crypt-wg0] 임.
1) work queue 할당 단계: packet_crypt_wq라는 이름의 work queue를 하나 만든다.
wg->packet_crypt_wq = alloc_workqueue("wg-crypt-%s", WQ_CPU_INTENSIVE | WQ_MEM_RECLAIM, 0, dev->name);
2) cpu 별 work 선언 및 work function 할당 단계 : 아래 함수를 이용하여 cpu 당 1개씩 필요한 work(struct work_struct)을 선언한다.
ret = wg_packet_queue_init(&wg->encrypt_queue, wg_packet_encrypt_worker, MAX_QUEUED_PACKETS);
3) cpu별 해당 work 수행 요청 단계: schedule 함수를 호출하여 packet_crypt_wq work queue에 대한 work 함수 즉, wg_packet_encrypt_worker 함수를 지정된 cpu에서 실행하도록 해준다.
wg_queue_enqueue_per_device_and_peer(&wg->encrypt_queue, &peer->tx_queue, first,
wg->packet_crypt_wq, &wg->encrypt_queue.last_cpu); ... send.c -> queueing.h
{
...
queue_work_on(cpu, wq, &per_cpu_ptr(device_queue->worker, cpu)->work)
}
마지막으로 packet_crypt_wg work queue와 wg_packet_queue_init(...decrypt...) 함수와의 연관 관계를 먼저 따져 보도록 하자.
[4] packet_crypt_wq와 wg_packet_queue_init(decrypt) 함수의 상관 관계
📌 packet_crypt_wg work queue의 목적은 data를 decrypt하는 것이다. 따라서 선택된 cpu에서 wg_packet_decrypt_worker 함수가 worker thread(kworker)에 의해서 수행된다.
1) work queue 할당 단계: packet_crypt_wq라는 이름의 work queue를 하나 만든다.
wg->packet_crypt_wq = alloc_workqueue("wg-crypt-%s", WQ_CPU_INTENSIVE | WQ_MEM_RECLAIM, 0, dev->name);
2) cpu 별 work 선언 및 work function 할당 단계 : 아래 함수를 이용하여 cpu 당 1개씩 필요한 work(struct work_struct)을 선언한다.
ret = wg_packet_queue_init(&wg->decrypt_queue, wg_packet_decrypt_worker, MAX_QUEUED_PACKETS);
3) cpu별 해당 work 수행 요청 단계: schedule 함수를 호출하여 packet_crypt_wq work queue에 대한 work 함수 즉, wg_packet_decrypt_worker 함수를 지정된 cpu에서 실행하도록 해준다.
wg_queue_enqueue_per_device_and_peer(&wg->decrypt_queue, &peer->rx_queue, skb,
wg->packet_crypt_wq, &wg->decrypt_queue.last_cpu) ... receive.c -> queueing.h
{
queue_work_on(cpu, wq, &per_cpu_ptr(device_queue->worker, cpu)->work);
}
지금까지 wireguard에서 사용하는 주요 work queue code의 동작 원리를 살펴 보았다. 이 부분이 제대로 파악이 된다면, (이후 설명할) packet send/receive 흐름도 쉽게 이해가 될 것으로 믿는다.
d) Peer 초기화 과정
peer.c의 wg_peer_create( ) 함수는 "wg set peer ..." 명령 실행시 호출되는 함수로 peer를 create하는데 필요한 각종 설정을 초기화하고, peer를 hash table에 등록하는 역할을 한다. 또한 packet을 외부로 내보내기 위한 worker를 초기화하고, napi 함수를 등록하는 일도 수행한다.
e) Noise handshaking 흐름
결국 WireGuard protocol의 핵심은 noise handshaking에 있다. Handshaking이 제대로 설명되기 위해서는 WireGuard packet의 format을 면밀히 검토해야 한다. 이와 관련해서는 이전 글에 상세히 설명해 두었으니, 여기서는 2개의 message 즉, Initiation 및 Response의 format만 보여주고 넘어가는 것으로 하겠다.
아래 그림은 참고문헌 5 및 6에서 발췌한 것으로 WireGuard handshaking 과정을 한눈에 파악할 수 있도록 해준다.
📌 사실 WireGuard protocol 자체에 InitHello, RespHello라는 표현은 없다. 위의 그림에서는 이해를 돕기 위해 이러한 표현을 사용한 것으로 보인다.
또한, 아래 2개의 그림은 위의 wireguard handshaking 과정을 Initiation과 Response 각각으로 나누어 본 것이다. 이는 실제로 noise.c 파일의 내용과도 맥을 같이 하는데, 아래 실제 코드(4개의 함수)를 분석한 내용([step 1] ~ [step 4])을 보면서 자세한 의미를 파악해 보기 바란다.
WireGuard kernel code(noise.c)에서는 Noise Handshaking 즉, Noise_IKpsk2를 아래와 같이 정의하고 있다.
/* This implements Noise_IKpsk2:
*
* <- s
* ******
* -> e, es, s, ss, {t} // Initiation Message(Initiator => Responder)
* <- e, ee, se, psk, {} // Response Message(Initator <= Responder)
*/
📌 사실 IKEv2나 (OpenVPN을 구성하는) TLS1.2/1.3 등은 (만들어진지 오래된 이유로) 대중에게 보다 널리 알려져 있지만, 상대적으로 Wireguard의 경우는 Hanshaking 과정이 많이 노출되어져 있지 않다. 하지만, 위의 내용이 wireguard handshaking의 전부이다. 결코 복잡하지 않다.
아주 간결하지만 안전한 Protocol Wireguard ~ 😋
Wireguard Handshaking 과정은 크게 보아, 아래 4개의 함수(noise.c 파일)로 설명이 가능하다.
[Step 1] Initiator: IKpsk2 initiation
wg_noise_handshake_create_initiation() : Initiator -> Responder로 Initiation Message 전송시 Initiator가 담당하는 송신 처리 routine
[Step 2] Responder: IKpsk2 initiation
wg_noise_handshake_consume_initiation() : Initiator -> Responder로 Initiation Message 전송시 Responder가 담당하는 수신 처리 routine
[그림 3.15] wg_noise_handshake_consume_initiation() 함수 내부 흐름 분석
[Step 3] Responder: IKpsk2 response
wg_noise_handshake_create_response() : Responder -> Initiator로 Response Message 전송시 Responder가 담당하는 송신 처리 routine
[그림 3.16] wg_noise_handshake_create_response() 함수 내부 흐름 분석
[Step 4] Initiator: IKpsk2 response
wg_noise_handshake_consume_response() : Responder -> Initiator로 Response Message 전송시 Initiator가 담당하는 수신 처리 routine
📌 Noise handshaking을 하는 최종 목적은 ChaCha20Poly1305 암호 함수를 돌릴 때 사용하는 대칭키(특별히 sending, receiving용 각각 1개씩 2개 존재함)를 양쪽이 모두 동일하게 공유하기 위해서이다.
이렇게 noise handshaking이 완료되고 나면, 최종적으로 양쪽 모두에서 아래 함수(wg_noise_handshake_begin_session)가 호출된다. 이 함수가 하는 일은, noise keypair buffer를 할당하고, derive_keys() 함수를 이용하여 handshake->chaining_key 값으로 부터 sending용 대칭키와 receiving용 대칭키를 유도해 내는 것이다.
____________________________________________
struct noise_keypair {
struct index_hashtable_entry entry;
struct noise_symmetric_key sending;
atomic64_t sending_counter;
struct noise_symmetric_key receiving;
struct noise_replay_counter receiving_counter;
__le32 remote_index;
bool i_am_the_initiator;
struct kref refcount;
struct rcu_head rcu;
u64 internal_id;
};
____________________________________________
<여기서 잠깐>
Wireguard에서 chaining key와 shared secret을 만드는 과정은 ?
Wireguard는 아래와 같은 과정(실제로는 더 4번보다 많이 함)을 통해 chaining key를 만들고, 이로 부터 shared secret 키 값을 유도해내게 된다.
________________________________________________________________________
f) Peer lookup 과정
Wireguard에는 (noise handshaking 과정에서 사용하기 위한) 2개의 hash table이 존재한다. 하나는 peer public key 기반의 hash table이고, 다른 하나는 index(정확하게는 receiver_index)를 기준으로 하는 hash table이 그것이다. Hash table이 사용되는 경우는 handshaking message를 수신한 후, public key or index를 이용하여 원하는 정보 즉, peer 값 or handshake에 필요한 정보를 찾을 때이다.
📌 Wireguard는 hash table을 위해서 siphash라는 방식을 사용한다. SipHash는 PRF(Pseudo Random Function)의 일종이며, 일반적인 hash table의 문제인 flooding attack을 막아주는 hash table로 이해하면 좋을 듯 하다. SipHash는 생각보다 조금 복잡하다. 관심 있는 분들은 아래 site 내용을 참조하도록 하자.
<Public key 기반의 Hash Table>
public key 기반의 hash table은 아래와 같은 모습이며, add/remove/lookup과 같은 operation 함수 등을 갖고 있다. 이중 lookup() 함수는 wg_noise_handshake_consume_initiation() 함수 즉, Initiation message를 수신 처리하는 과정에서 사용된다.
참고로, 아래 코드는 public key 기반의 hash table을 위한 pubkey_bucket() 함수의 모습이며, 이 안에서 siphash() 함수가 호출된다.
<Index 기반의 Hash Table>
한편, index 기반의 hash table은 아래와 같은 모습이며, insert/replace/remove/lookup 등과 같은 operation 함수를 갖고 있다. 이중 lookup() 함수는 wg_noise_handshake_consume_response() 함수 즉, Response message를 수신 처리하는 과정에서 사용된다.
<여기서 잠깐 !>
=> WireGuard message format을 보면 어디에도 peer의 endpoint(ip & port) 정보가 포함되어 있지 않다. 그렇다면 wireguard는 여러 peer(peer table) 중에서 정확하게 원하는 peer 하나를 어떻게 찾아 낼 수 있을까 ? .... 이 내용은 지난 blog post에 게재된 내용이다. 약간의 내용 보강을 해 보도록 하자.
그 해답은 static public key와 index(sender, receiver) 필드에 있다.
[그림 3.9, 3.10] Handshake Initiation Message Format을 보면, initiator는 첫번째 DH() 결과로 얻은 key 값 즉, ck2를 이용하여 자신의 static public key를 암호화한 후, random하게 생성된 index(local identifier) 값 등과 함께 handshake initiation message를 구성 후, 이를 responder에게 전달한다.
Message를 수신한 responder는 역시 DH()를 이용해 ck2를 얻은 후, initiator가 암호화해서 보내준 static public key를 복호화하게 되고, 이를 이용해 peer(initiator)가 누구인지를 1차적으로 알게 된다(여기에서 public key 기반 hash table lookup 과정을 수행함). 이후 responder는 initiator로 부터 받은 sender index 값을 receiver index에 넣고, 역시 자신이 random하게 생성한 sender index 값 등으로 Handshake Response Message를 만들어 initiator에게 돌려 보낸다.
Responder로 부터 handshake response message를 수신한 initiator는 receiver index 필드 값이 자신이 전에 만들어 보낸 index 값인지를 확인하게되고, 이를 통해 peer table에서 원하는 peer를 정확하게 선택할 수 있게 된다(여기에서 index 기반 hash table lookup을 수행함).
따라서, 앞서 설명한 static public key 및 sender/receiver index 값은 initiator 및 responder 각각에서 운용하는 peer table(pubkey hash table & index hash table)에서 원하는 peer를 lookup하는데 사용하는 값으로 해석될 수 있다.
_______________________________
g) Handshaking 및 data packet send/receive 흐름
Noise handshaking code 못지 않게 중요한 코드는 tunneling 처리를 위한 송/수신 패킷 handling 부분이 아닐까 싶다. 이 부분은 이전 blog post에 충분히 설명한 내용이긴 하지만, 그래도 몇가지 부족한 부분이 있어 좀 더 보충해 보고자 한다(앞서 설명한 work queue와도 밀접한 관계가 있다).
📌 참고로, send(encrypt)는 linux netdevice와 udp_tunnel.ko 코드(net/ipv4/udp_tunnel_core.c)에 대한 사전 지식이 필요하며, recv(decrypt) 코드는 socket, skb와 NAPI에 대한 어느 정도의 지식이 필요하다. 예전에는 linux tcp/ip stack 하부단 구조를 파악하기 위해 아래와 같은 책을 많이 보기도 했다. 근데, 요즘에는 이런 훌륭한 책이 안보인다.
Understanding Linux Network Internals 😍
<Handshake packet 송/수신>
1) handshake packet 송신
handshake을 시작하는 부분은 wg_open, wg_xmit, set_peer 함수 등 총 5군데가 있다. 즉, device를 초기화(open, xmit)하거나, peer를 설정하거나, handshaking이 timeout(2분 간격)되거나, keepalive packet을 내보내기 시작하는 순간에 noise handshaking은 시작될 수 있다는 뜻이다.
[그림 3.25] wireguard handshake tx flow
2) handshake packet 수신
socket.c 파일 안에는, 아래와 같이 udp tunnel packet(encapsulation packet) 수신 함수를 등록하는 부분이 나온다.
_______________________________________________
struct udp_tunnel_sock_cfg cfg = {
.sk_user_data = wg,
.encap_type = 1,
.encap_rcv = wg_receive
}
udp_sock_create(net, &port4, &new4) //wg_socket_init( ) <- wg_open( )
📌 kernel에서 udp socket create/bind/connect 호출, wireguard packet은 udp 이므로 수신시 wg_receive가 호출
setup_udp_tunnel_sock(net, new4, &cfg)
_______________________________________________
handshake packet 수신 flow는 이 함수(wg_receive)로 부터 시작한다.
[그림 3.26] wireguard handshake rx flow
<실제 data 송/수신>
1) 실제 data 송신(udp 터널 encapsulation)
Wireguard는 netdevice(wg0, wg1 ...)형태로 구성되어 있다. 따라서 packet 송신은 xmit 함수에서 부터 시작한다.
📌 netdevice는 network driver를 구현할 때 필수적으로 등장하는 개념이다. Wireguard 역시 virtual interface를 생성하고 운용하는 방식이므로 기본적으로 netdevice 형태를 띈다.
________________________________________________
static const struct net_device_ops netdev_ops = {
.ndo_open = wg_open,
.ndo_stop = wg_stop,
.ndo_start_xmit = wg_xmit,
.ndo_get_stats64 = dev_get_tstats64
};
________________________________________________
실제 data에 대한 tx flow를 task(thread) 관점에서 재 정리해 보면 다음과 같다.
2) 실제 data 수신(udp 터널 decapsulation)
실제 data에 대한 수신 flow 역시 wg_receive( ) 함수로 부터 시작한다. 역시 중간 쯤에 있는 queue_work_on( ) 함수가 수행되는 시점 이후에는 (지정된 cpu에서 수행되는) kworker worker thread에 의해 wg_packet_decrypt_worker( ) work function이 실행되는 것을 알 수 있다.
[그림 3.29] 실제 data에 대한 rx flow
__________________________________________________________________________________
<여기서 잠깐>
peer.c에 보면 wg_peer_create( ) 함수를 호출하면서 netif_napi_add( ) 함수가 호출되는데, 여기에 사용된 callback 함수 wg_packet_rx_poll( )와 앞서 설명한 wg_receive( ) 함수의 관계는 어떻게 되는가 ?
wg_peer_create() ... peer.c
|
V
netif_napi_add(wg->dev, &peer->napi, wg_packet_rx_poll, NAPI_POLL_WEIGHT)
|
V
wg_packet_rx_poll() ... receive.c
{
while ((skb = wg_prev_queue_peek(&peer->rx_queue))) // 암호화된 packet을 하나씩 처리
{
wg_packet_consume_data_done()
{
...
routed_peer = wg_allowedips_lookup_src( )
napi_gro_receive(&peer->napi, skb)
{
...
napi_skb_finish(napi, skb, dev_gro_receive(napi, skb))
|
V
dev_gro_receive()
|
V .... 여기는 좀 보잡함.
go to tcp/ip stack
}
}
}
...
napi_complete_done(napi, work_done) ... net/core/dev.c
}
즉, network device layer(= link layer)에 위치한 wg_packet_rx_poll 함수가 먼저 호출되고, socket layer에 위치한 wg_receive 함수가 한참 후에 호출되는 구조로 보아야 한다. 아래 그림은 NAPI 방식(new style)을 사용할 때와 그렇지 않을 경우(legacy style)의 linux network 하부단(link layer)의 packet 수신부를 보여준다.
[그림 3.30] linux kernel network 패킷 수신 - NAPI-aware drivers versus non-NAPI-aware devices [출처 - 참고문헌 10]
__________________________________________________________________________________
h) UDP Tunnel(Encapsulation & Decapsulation)
앞서 packet(실제 data) encrypt/decrypt 부분을 살펴 보았는데, 송신 시 udp tunnel을 만들고, 수신 시 이를 해지하는 부분이 보이질 않는다. 그렇다면 어디에서 할까 ?
📌 Handshake message는 encrypt/decrypt 대상이 아니다.
<송신 시 Encapsulation 과정>
아래 함수는 socket.c에 있는 send4( ) 함수로 encrypt packet이 wireguard code를 빠져 나가면서 마지막으로 호출하는 부분으로 이 안에서 udp header를 추가하는 과정(encapsulation)이 이루어짐을 알 수 있다.
[그림 3.31] 실제 data에 대한 rx flow
📌 Encapsulation은 정확히 말하면, 암호화 후, wireguard messge header를 붙이고, 다시 그 위에 udp header + ip header를 붙이는 과정을 말한다.
<수신 시 Decapsulation 과정>
그렇다면, 수신 시 decapsulation(ip header, udp header 및 wireguard message header를 제거)하는 과정은 어디에서 진행될까 ? 이와 관련해서는 아래 코드를 확인해 보기 바란다.
[그림 3.32] 복호화전 decapsulation 과정
이상으로 매우 세련되지 못한 방식으로 Wireguard kernel code를 분석해 보았다. 그래도 이 정도면 어느 정도는 파악은 되지 않았을까 ? 나 혼자만의 생각일까 ? 😁
시간 관계상, ChaCha20, Poly1305, Curve25519 등 h/w 기반의 코드(ARM NEON, Intel x86_64 AVX2) 분석이 안된 부분이 좀 아쉬움으로 남지만, (언제나 그렇듯) 다음을 기약하도록 하자.
끝으로, WireGuard를 만든 Jason A. Donenfeld에게 감사의 마음을 전한다~ 👍
4. CRYSTALS Kyber PQC 알고리즘 소개
To be continued...
...
7. References
[1] https://wiki.friendlyelec.com/wiki/index.php/NanoPi_R4S#Install_OS
[2] https://namu.wiki/w/ARM%20big.LITTLE%20%EC%86%94%EB%A3%A8%EC%85%98
[3] https://www.ubergizmo.com/2013/01/what-is-arm-big-little/
[4] https://www.wireguard.com/
[5] Post-quantum WireGuard, Andreas Hülsing, Kai-Chun Ning, Peter Schwabe, Florian Weber, Philip R. Zimmermann
[6] https://sar.informatik.hu-berlin.de/research/publications/SAR-PR-2020-03/SAR-PR-2020-03_.pdf
[7] https://www.minzkn.com/moniwiki/wiki.php/AboutNetLinkSocket
[8] Master’s Thesis - Analysis of the WireGuard protocol, Peter Wu
[9] https://en.wikipedia.org/wiki/SipHash
[10] Understanding Linux Network Internals, O'REILLY, Christian Benvenuti
[11] And Google~
Slowboot
nice
답글삭제