เก็บไฟล์บัตรประชาชนยังไงดี?

ช่วงต้นปีที่ผ่านมามีข่าวครึกโครมอันหนึ่งที่บริษัทชื่อดัง ทำ bucket ที่เก็บบัตรประชาชนลูกค้าเป็นสาธารณะ และมีผู้ไปพบเข้า

หลายเดือนต่อมาผมก็ไม่คิดว่าที่ TipMe ก็จะต้องเก็บรูปบัตรประชาชนไว้เช่นกัน ก็เลยคิดว่าจะต้องเก็บให้ปลอดภัยแบบที่อ่านแล้วรู้สึกว่าแน่นหนา

Threat Model

ก่อนจะทำอะไรก็ต้องนึกถึง threat model ก่อน เพราะถ้า regulation ต้องการว่าไฟล์ถูก encrypt นั้น เนื่องจากเราใช้ Google Cloud Storage ก็จะทำ Encryption ให้อยู่แล้วโดยไม่สามารถปิดได้ (ต่างจาก AWS ที่ค่า default คือปิด)

แต่ข้อกังวลสำหรับผมไม่ใช่ regulation เพราะเรายังไม่มีใครมาตรวจส่วนนี้ สำหรับเคสนี้ผมคิดว่าปัญหาจะอยู่ที่

  1. เผลอทำ bucket หลุดไป ข้อมูลไม่ควรจะสามารถอ่านได้
  2. Application server ถูก hack แล้วใช้สิทธิ์ของ app มาอ่านได้ (เพราะ app มันต้องอ่านได้ด้วย ตอนที่ admin เข้าไปตรวจสอบ)
  3. เนื่องจากเราจะใช้ signed url มีความเสี่ยงที่ signed url จะหลุดไป
  4. admin แอบเซฟรูปไป อันนี้คิดว่าคงไม่น่าจะเป็นปัญหาในตอนนี้ เนื่องจากทีมเรามีจำกัด

Design

ปัญหาที่ผมค่อนข้างกังวลคือข้อ 2 เนื่องจากแอพเป็น monolithic จึงการันตีได้ว่าทั้งระบบปลอดภัยได้ยาก และไม่มีแผนที่จะแยกเป็น microservice ให้ยุ่งยากด้วย เพราะคนเขียนมีคนเดียวไม่จำเป็นจะต้องพยายามทำอะไรให้รองรับทีมขนาดใหญ่ได้ในตอนนี้

ลักษณะการใช้งานของข้อมูลบัตรประชาชนคือ

  1. User submit ข้อมูลมา
  2. Admin เข้าไปดูรูปและยึนยันว่าเป็นบัตรประชาชนที่ถูกต้อง อาจจะมีการอ่านข้อความที่ต้องใช้เก็บไว้
  3. หลังจากนั้นแล้วไม่มีใครควรจะดูได้อีก
  4. หากบริษัทถูกเรียกถาม ควรจะสามารถเรียกคืนมาให้ตรวจได้

จะเห็นว่าจากข้อ 3 แล้วทำให้เราจัดได้ว่าข้อมูลนี้เป็น cold storage หลังแอดมินยึนยันข้อมูลแล้ว

ระบบที่เรา Design ออกมาก็เลยเป็นดังนี้

(ขออภัยกับ UML ด้วย ไม่ค่อยชินกับ tool ที่ใช้วาดเท่าไร)

  1. พอ admin ยึนยันบัตรประชาชนแล้ว app จะ call ไปหา secure service เราเรียกมันว่า sealerd
  2. sealerd จะถือ Google Cloud service account token อีกชุดหนึ่ง แยกไปจากของ app ปกติ
  3. sealerd ทำดังต่อไปนี้
    1. Download file มาแล้ว encrypt ด้วย public key ของบริษัท
    2. แก้ไข ACL ของไฟล์นั้นใน Google Cloud Storage ให้สามารถเข้าได้เฉพาะ service account ของ sealerd (แม้แต่ผมก็เข้าไม่ได้ ต้อง assume role เป็น sealerd เท่านั้น)
  4. app จะ mark ว่าไฟล์เข้ารหัสแล้ว เพื่อประโยชน์ในการแสดงผล UI

พูดง่ายๆ ก็คือเรามี security 2 ชั้นคือ

  1. File level access control ทั้งหมดจะถูกล้างเสมอ
  2. ถ้ามีคนเอาไฟล์ไปได้ ไฟล์ก็จะถูกเข้ารหัสด้วย key ซึ่งกุญแจถอดรหัสเราเก็บไว้ offline เว็บเราไม่สามารถถอดรหัสได้

จาก threat model แล้วก็จะพบว่า

  1. ถ้า bucket เผลอเปิดเป็น public เราจะมี encryption ป้องกันไม่ให้อ่านไฟล์ได้
  2. สิทธิ์การใช้ของ application server จะถูกยกเลิกหลังไฟล์ถูก seal (ทั้งนี้การถอนสิทธิ์ใน Google Cloud Storage เป็น eventual consistent แอพอาจจะยังอ่านได้อยู่อีกประมาณ 1 นาที)
  3. เราตั้งให้ Signed URL มีวันหมดอายุอยู่แล้ว และเนื่องจากเรายกเลิกสิทธิ์ของ Application server หลัง seal แล้ว Signed URL ที่มีจึงใช้ไม่ได้

Implementation

คำถามถัดมาคือ แล้วจะ implement อย่างไร?

ภาษา

ภาษาแรกในหัวผมที่จะใช้เขียนคือ Rust แต่หลายๆ ครั้งที่พยายามจะใช้ผมพบว่า Rust มี library ไม่ค่อยดีพอเลยยังไม่กล้าเสี่ยง

ถัดมาคือ Python กับ JavaScript ที่ใช้อยู่แล้ว แต่เนื่องจาก encryption library มักเป็น synchronous และ blocking ด้วย ทั้ง Python และ JavaScript พวกนี้มี Global Lock ก็เลยไม่น่าจะเหมาะที่จะเอามาใช้ เพราะต้องลุ้นอีกทีว่า library ที่ใช้มันไปทำข้างนอก interpreter lock หรือไม่

อีกเหตุผลคือผมอยากจะใช้ Tink ที่ยังไม่รองรับภาษาเหล่านี้

สุดท้ายคือ Go ซึ่งผมคิดว่า Tink น่าจะรองรับ ก็เลยเลือกใช้ Go

RPC

ท่า RPC ก็เป็นเรื่องที่ต้องตัดสินใจเช่นกัน ตัวเลือกก็จะมี

  1. JSON on REST (HTTP) ง่ายๆ ใครๆ ก็ใช้
  2. gRPC
  3. Cap’nproto
  4. Flatbuffer

ส่วนตัวอยากลอง Cap’nproto/Flatbuffer แต่รู้สึกว่า RPC library มันยังไม่ค่อยดี ไว้โอกาสหน้าอาจจะ port ดูเล่นๆ

เหลือแค่ gRPC กับ REST ก็เลยเลือก gRPC

เหตุผลที่เลือกคือ

  1. มันมี type ชัดเจน ใน Go จะเขียนง่าย
  2. มันเร็วมาก ที่วงในผมเขียน integration test ให้ start gRPC server ใน python แล้วยิง test request เร็วอย่างกับยิงใน process เดียวกัน
  3. ไม่ต้องกังวลเรื่อง compatibility เพราะไม่มี clientside ยิงมาอยู่แล้ว เลยไม่จำเป็นจะต้องใช้ HTTP

Encryption

Project นี้รู้สึกว่าจะพลาดที่สุดก็ตรงนี้แหละ ผมเสียเวลาไป 3-4 วันในการ design implement ระบบที่ secure ที่สุดเท่าที่จะนึกออก พอเขียนจริงเลยไม่ได้ research ละเอียดว่าภาษาอะไรที่จะใช้ implement แล้วเหมาะสม

มารู้สึกตัวครั้งแรกคือตอนที่เขียน gRPC ให้ Go เสร็จแล้วกำลังจะเขียนส่วนที่เข้ารหัส ก็เจอกับ Surprise แรกคือ Tink ไม่มีใน Go… อ้าว!!

ก็เลยมาที่ plan B คือใช้ Sealed box เหมือนที่ผ่านๆ มา

Surprise ที่สองคือ Go มี nacl box ในตัวก็จริง แต่ไม่มี libsodium ทำให้ไม่มี sealed box…

สุดท้ายเลยต้องทำเหมือนใน JavaScript คือ implement nacl-sealed-box เสียเองเลย ซึ่งก็ไม่ยากเพราะ Go มี crypto primitive ให้หมดแล้ว แค่เอามาต่อกันให้เหมือนกับของ libsodium เป็นอันเรียบร้อย

Connect

เสร็จแล้วก็เขียนต่อจากฝั่งแอพ เนื่องจากเป็น gRPC ก็ทำให้การ implement ค่อนข้างง่าย แต่ตอน compile proto ก็อาจจะเหนื่อยหน่อย

สำหรับการ dev บนเครื่องเราก็ทำ version พิเศษที่ต่อกับ local filesystem และเพื่อความชัวร์จึงทำให้ option ในการเลือก driver นี้เป็น link time option จะได้ไม่พลาดบน production

สรุป

ในวงการ Cryptography มีกฎอยู่ข้อหนึ่งเรียกว่า Schneider’s Law บอกว่า

ใครๆ ก็สามารถคิดค้นระบบความปลอดภัยที่ที่เทพมากจนเค้าไม่สามารถคิดวิธีแหกได้
Any person can invent a security system so clever that she or he can’t think of how to break it

ผมว่าระบบอันนี้ค่อนข้าง fit กับ description นี้แล้ว คือผมคิดว่ามันแน่นหนาพอ และผมว่าถ้าไปเจอใครโม้เรื่องวิทยาการรหัสลับเสียดิบดีโดยไม่ได้มี disclaimer แบบนี้ ผมว่ามันน่ากลัวกว่านี้อีกเยอะ

จริงๆ แล้วคิดว่าในการใช้งานส่วนมากแล้วอาจจะแค่ download ไฟล์ที่อนุมัติแล้วเก็บเข้า offline cold storage ก็น่าจะปลอดภัยกว่าระบบนี้แล้ว แต่ก็จะ automate น้อยลง ความเสี่ยงในการสูญหายมากขึ้น ใน tradeoff ของระบบเราแล้ว วิธีที่อธิบายมาในบทความนี้จึงน่าจะเป็นทางเลือกที่ค่อนข้างเหมาะสม

ออก Wildcard SSL ด้วย Let’s Encrypt สำหรับ Docker

เมื่อช่วงต้นปีที่ผ่านมา Let’s Encrypt ก็ได้เปิดตัว Wildcard SSL แล้วก็เลยคิดว่าควรจะรีบย้ายไปสักที

เหตุผลหลักๆ คืออย่างที่พอทราบกันว่าใน certificate ที่ออกด้วย Let’s Encrypt นั้นจะต้อง list subdomain ทั้งหมดใน subject alternate name

ขอบคุณ srakrn.me

ซึ่งถ้าเรามี internal subdomain ก็ไม่เหมาะเท่าไรที่จะ list ในนั้น วิธีที่ผมกันคนขี้สงสัยแบบนี้ก็คือการเอา domain ภายในไปออกอีกใบนึง จะได้ไม่อยู่ในรายชื่อ

แต่ที่หลายคนไม่รู้คือการออกใบรับรอง SSL โดยผู้ให้บริการที่น่าเชื่อถือนั้นจะมีการส่งข้อมูลไปยังฐานข้อมูล Certificate Transparency ด้วย ตัวอย่างเช่น subdomain ของ whs.in.th ไม่ว่าจะเอาไปซ่อนไว้ใบไหนก็จะเห็นทั้งหมด ฉะนั้นก็เลยคิดว่าน่าจะต้องเริ่มเลิกใช้แล้ว

Prerequisite

ข้อกำหนดการออก wildcard certificate ของ Let’s Encrypt คือเราจะต้องยึนยันตนผ่าน DNS ซึ่งผมเห็น guide ภาษาไทยจำนวนหนึ่งแนะนำให้ตั้งค่า DNS เอง ซึ่งอันตราย เพราะมันจะ auto renew ให้ไม่ได้แล้วเว็บจะเข้าไม่ได้หลังหมดอายุ

ฉะนั้นแล้วเราจะต้องใช้ DNS Hosting ที่สามารถ automate ได้ด้วย ซึ่งเจ้าที่ฟรีก็จะมี DigitalOcean กับ CloudFlare

สำหรับเว็บผมนั้นใช้ DNS Hosting ดังนี้

  • whs.in.th: Google Cloud DNS
  • cupco.de: CloudFlare

ที่เลือกใช้ 2 อันเพราะอยากกระจายความเสี่ยง (และ 2 เจ้านี้รองรับ DNSSEC) ส่วนที่ไม่ใช้ SSL ฟรีของ CloudFlare เพราะไม่อยากให้ CloudFlare เห็นข้อมูลของเราวิ่งผ่านจึงใช้เป็น DNS อย่างเดียวโดยไม่เปิด proxy และบาง subdomain ที่เปิด proxy นั้นก็ตั้งให้ CloudFlare verify การเชื่อมต่อระหว่าง CloudFlare กับ server ด้วยเพื่อความปลอดภัย

ขอ API Key

สำหรับ CloudFlare สามารถขอ API Key ได้ที่หน้า Profile ด้านล่างสุด เลือก Global API Key

ตอนนี้ดูเหมือนว่า CloudFlare จะยังไม่มี API key per domain หรือจำกัด role (ถึงแม้จะเป็น enterprise ก็ตาม — เค้าแนะนำให้ทำ service account มาแล้วจำกัด permission)

จากนั้นให้เอา key ไว้ในไฟล์ ini ดังนี้

dns_cloudflare_email = อีเมลที่ใช้ login
dns_cloudflare_api_key = api key ที่ได้

สำหรับ Google Cloud นั้นให้เข้าไปออก Service account

โดยตั้ง role = DNS Administrator และเลือก Furnish new private key แบบ JSON แล้วเก็บไฟล์ JSON ไว้ใน folder เดียวกับ CloudFlare API key

SSL First Time

จากนั้นเราจะออก cert ใบแรกกันครับ

$ sudo docker volume create certs
# อย่าลืมเปลี่ยน path ไป folder เก็บ account และชื่อไฟล์ account
$ sudo docker run --rm -v "/path/to/account/folder:/dns:ro" \
  -v "certs:/etc/letsencrypt:z" certbot/dns-google renew \
  --dns-google-credentials /dns/service_account.json

Saving debug log to /var/log/letsencrypt/letsencrypt.log

How would you like to authenticate with the ACME CA?
-------------------------------------------------------------------------------
1: Obtain certificates using a DNS TXT record (if you are using Google Cloud DNS
for DNS). (dns-google)
2: Spin up a temporary webserver (standalone)
3: Place files in webroot directory (webroot)
-------------------------------------------------------------------------------
Select the appropriate number [1-3] then [enter] (press 'c' to cancel): ตอบ 1
Plugins selected: Authenticator dns-google, Installer None
Enter email address (used for urgent renewal and security notices) (Enter 'c' to
cancel): กรอกอีเมล

-------------------------------------------------------------------------------
Please read the Terms of Service at
https://letsencrypt.org/documents/LE-SA-v1.2-November-15-2017.pdf. You must
agree in order to register with the ACME server at
https://acme-v02.api.letsencrypt.org/directory
-------------------------------------------------------------------------------
(A)gree/(C)ancel: a

-------------------------------------------------------------------------------
Would you be willing to share your email address with the Electronic Frontier
Foundation, a founding partner of the Let's Encrypt project and the non-profit
organization that develops Certbot? We'd like to send you email about our work
encrypting the web, EFF news, campaigns, and ways to support digital freedom.
-------------------------------------------------------------------------------
(Y)es/(N)o: n
Please enter in your domain name(s) (comma and/or space separated)  (Enter 'c'
to cancel): กรอกโดเมนลงไป เช่น whs.in.th,*.whs.in.th
Obtaining a new certificate
Performing the following challenges:
dns-01 challenge for whs.in.th
dns-01 challenge for whs.in.th

...

IMPORTANT NOTES:
 - Congratulations! Your certificate and chain have been saved at:
   /etc/letsencrypt/live/whs.in.th/fullchain.pem
   Your key file has been saved at:
   /etc/letsencrypt/live/whs.in.th/privkey.pem
   Your cert will expire on 2018-10-02. To obtain a new or tweaked
   version of this certificate in the future, simply run certbot
   again. To non-interactively renew *all* of your certificates, run
   "certbot renew"
 - If you like Certbot, please consider supporting our work by:

   Donating to ISRG / Let's Encrypt:   https://letsencrypt.org/donate
   Donating to EFF:                    https://eff.org/donate-le

เป็นอันใช้ได้ สำหรับ CloudFlare ก็ทำคล้ายๆ กัน คือ

sudo docker run --rm -v "/path/to/account/folder:/dns:ro" \
  -v "certs:/etc/letsencrypt:z" certbot/dns-cloudflare renew \
  --dns-cloudflare-credentials /dns/cloudflare.ini

ติดตั้งลงใน nginx

เสร็จแล้วเราก็สามารถ mount volume เข้าไปใน nginx ได้เลย

sudo docker run -d --restart=always --name=nginx \
    -v nginx:/etc/nginx \
    -v certs:/etc/certs:ro,z \
    -p 80:80 -p 443:443 \
    --hostname madoka.whs.in.th nginx

สำหรับการตั้งค่า certificate นั้นแนะนำให้ตั้งตาม Mozilla SSL Generator โดยระบุดังนี้

ssl_certificate /etc/certs/live/whs.in.th/fullchain.pem;
ssl_certificate_key /etc/certs/live/whs.in.th/privkey.pem;
ssl_trusted_certificate /etc/certs/live/whs.in.th/chain.pem;

เป็นอันเรียบร้อย

Renew

สำหรับการ renew นั้นเราจะทำ shell script ไว้

#!/bin/bash

CERT_VOLUME=certs
DNS_VOLUME=/path/to/dns

docker run --rm -v "$DNS_VOLUME:/dns:ro" -v "$CERT_VOLUME:/etc/letsencrypt:z" certbot/dns-google renew \
    -n --agree-tos --dns-google-credentials /dns/service_account.json
docker run --rm -v "$DNS_VOLUME:/dns:ro" -v "$CERT_VOLUME:/etc/letsencrypt:z" certbot/dns-cloudflare renew \
    -n --agree-tos --dns-cloudflare-credentials /dns/cloudflare.ini
docker kill -s HUP nginx

จะเห็นว่าตอนท้ายนั้นเราจะยิง HUP ไปหา nginx container ด้วยเพื่อให้ reload certificate

แล้วก็ใส่ไว้ใน crontab ของ root ให้รันทุกวันเป็นอันเสร็จ

$ sudo crontab -e
0 0 * * *   /path/to/update-certs.sh > /dev/null 2>&1

(อาจจะแก้ให้มัน log ไว้เพื่อตรวจสอบก็ได้)

สรุป

หนึ่งปีครึ่งผ่านไป รู้สึกว่า Let’s Encrypt มีการพัฒนาขึ้นมาก ทั้งบริการ และ certbot ก็ตาม รอบนี้รู้สึกว่าขั้นตอนซับซ้อนน้อยกว่าเดิมมากและ user friendly

สำหรับตอนหน้าหลังจากเป็นสายฟรีมานาน ว่าจะออก SSL EV ใบละหมื่นบ้างว่าจะมีขั้นตอนอย่างไร