เข้ารหัส Secret ใน Git

หลายๆ คนน่าจะทราบดีว่าเราไม่ควร commit รหัสลับต่างๆ ลงไปใน Git เพื่อป้องกันไม่ให้รหัสลับนั้นหลุดออกไป

แต่จากประสบการณ์พบว่า หลายๆ ครั้งแล้วเราก็ยังจะจำเป็นต้องมี Git Repo อีกอันหนึ่งที่รวบรวมรหัสลับทั้งหมดไว้อยู่ดี หลักๆ ก็คือใช้ในการ Deploy เพื่อให้แชร์รหัสกับคนในทีมได้ ซึ่งวิธีการที่มักใช้กันอยู่ก็จะมีหลายๆ ท่า

  • ถ้าใช้ GitLab ทำ CD ท่าที่จะใช้กันก็คือใช้ Secret variable
  • ถ้าใช้ Travis ก็สามารถให้ command line มัน encrypt secret ได้
  • หรือ Ansible ก็มี Ansible Vault

แล้วถ้าเราไม่ได้ใช้ระบบพวกนี้เลยล่ะ? วิธีที่เหมาะก็คงจะต้อง encrypt file นั้นตรงๆ ซึ่งก็มีโปรแกรมตัวช่วยหลายตัวสำหรับ Git ไม่ว่าจะเป็น Blackbox ของ Stack Exchange, git-secret

สำหรับตัวที่จะแนะนำในวันนี้เป็นตัวที่ลองแล้วคิดว่าใช้งานง่ายที่สุด นั่นคือ git-crypt แต่อาจจะติดตั้งยากเล็กน้อยเพราะต้องคอมไพล์

git-crypt

ก่อนอื่นการติดตั้ง ถ้าใช้ Arch Linux ก็สามารถไปโหลดจาก AUR ได้เลย ถ้าไม่ได้ใช้ก็คอมไพล์เองได้ไม่ยาก

ถัดมาให้เราเปิดใช้ git-crypt ด้วยการรัน git-crypt init ใน repo

$ git-crypt init
Generating key...

ถัดมาเราจะต้องแนะนำตัวให้ git-crypt รู้จักโดยใช้ PGP key ของเรา ถ้ายังไม่มีก็ generate เสียก่อน

$ gpg --gen-key
gpg (GnuPG) 2.2.1; Copyright (C) 2017 Free Software Foundation, Inc.
This is free software: you are free to change and redistribute it.
There is NO WARRANTY, to the extent permitted by law.

Note: Use "gpg --full-generate-key" for a full featured key generation dialog.

GnuPG needs to construct a user ID to identify your key.

Real name: กรอกชื่อ
Email address: กรอกอีเมล
You selected this USER-ID:
    "Test key <test@example.com>"

Change (N)ame, (E)mail, or (O)kay/(Q)uit? o
We need to generate a lot of random bytes. It is a good idea to perform
some other action (type on the keyboard, move the mouse, utilize the
disks) during the prime generation; this gives the random number
generator a better chance to gain enough entropy.
We need to generate a lot of random bytes. It is a good idea to perform
some other action (type on the keyboard, move the mouse, utilize the
disks) during the prime generation; this gives the random number
generator a better chance to gain enough entropy.
gpg: key C0E4A829EAD6456C marked as ultimately trusted
gpg: revocation certificate stored as '/home/whs/.gnupg/openpgp-revocs.d/9C765CE23C4E31A9BE50BF25C0E4A829EAD6456C.rev'
public and secret key created and signed.

pub   rsa2048 2017-09-21 [SC] [expires: 2019-09-21]
      9C765CE23C4E31A9BE50BF25C0E4A829EAD6456C
uid                      Test key <test@example.com>
sub   rsa2048 2017-09-21 [E] [expires: 2019-09-21]

จะเห็นเลขฐาน 16 ยาวๆ 9C765CE23C4E31A9BE50BF25C0E4A829EAD6456C ตรงนี้คือ Key ID เต็มของเรา ซึ่งจะเรียกด้วยชื่อย่อ 8 ตัวหลังก็ได้ ก็คือ EAD6456C (ในตัวอย่างต่อๆ ไปจะใช้ key ผมคือ 5AD1E4A5)

พอมี key แล้ว ก็ add ตัวเราเข้าไปใน git-crypt

$ git-crypt add-gpg-user 5AD1E4A5
[master 8c02850] Add 1 git-crypt collaborator
 2 files changed, 3 insertions(+)
 create mode 100644 .git-crypt/.gitattributes
 create mode 100644 .git-crypt/keys/default/0/114F08A7E2081ACA01EC7738461CCB345AD1E4A5.gpg

ตรงนี้จะใส่เป็น Key ID เต็ม หรือย่อก็ได้ หรือแม้แต่กรอกอีเมลที่ใช้สร้าง key ก็ได้เช่นกัน

ถัดมาเราจะสร้างไฟล์ .gitattributes ขึ้นมา ไฟล์นี้จะบอกว่าไฟล์ไหนบ้างที่จะ encrypt

dns/service-account.json filter=git-crypt diff=git-crypt
**/secrets.yaml filter=git-crypt diff=git-crypt

จากตัวอย่างก็คือให้เข้ารหัสไฟล์ dns/service-account.json และไฟล์ secrets.yaml ในทุกๆ folder เมื่อเขียนเสร็จแล้วก็ให้ add ไฟล์ทั้งหมดลงไปใน Git แล้ว commit ได้เลย

ทดสอบ Encryption

หลังจาก Encrypt แล้ว เพื่อความชัวร์ว่ามันเข้ารหัสจริง เราสามารถทดสอบได้ด้วยวิธีดังต่อไปนี้

วิธีแรกคือเช็ค status ด้วยคำสั่ง git-crypt status จะได้ผลลัพท์ประมาณนี้

$ git-crypt status
not encrypted: README.md
not encrypted: .git-crypt/.gitattributes
not encrypted: .git-crypt/keys/default/0/114F08A7E2081ACA01EC7738461CCB345AD1E4A5.gpg
not encrypted: .gitattributes
    encrypted: dns/service-account.json
    encrypted: kube/zipkin/secrets.yaml

แบบนี้ก็แสดงว่าเราได้ระบุการเข้ารหัสไฟล์ถูกต้องแล้ว ถ้าไฟล์ไหนที่ตั้งใจจะเข้ารหัสแต่ไม่ขึ้นว่า encrypted ก็ควรจะดู glob ใน .gitattributes ใหม่

ถัดมาเราจะทดสอบให้ git-crypt ล็อค repo เรา ด้วยคำสั่ง git-crypt lock เมื่อสั่งแล้วจะไม่ได้ผลลัพท์อะไร แต่ถ้าเปิดดูจะเห็นว่าไฟล์ข้างในจะอ่านไม่ได้ ต้องสั่ง git-crypt unlock เพื่อปลดล็อค

เข้ารหัสไฟล์เก่า

ถ้ามีไฟล์ secret ที่ไม่ได้เข้ารหัสอยู่แล้ว แล้วเพิ่ม git-crypt ไปทีหลังจะพบว่ามันไม่เข้ารหัสให้ วิธีการก็คือให้สั่ง git-crypt status -f เพื่อให้มัน add เวอร์ชั่นที่ encrypt แล้วเข้าไป เสร็จแล้วก็ Commit

$ git-crypt status -f
kube/zipkin/secrets.yaml: staged encrypted version
Staged 1 encrypted files.
Warning: if these files were previously committed, unencrypted versions still exist in the repository's history.
$ git commit -m "Encrypted secret"

ปัญหาคือ ไฟล์นี้เคยมีอยู่ใน version เก่าๆ แล้ว เราเลยจะต้องใช้โปรแกรมเข้ามาแก้ History ของ Git ลบไฟล์ออก

คำเตือน: การแก้ Git History จะต้องทำ Force push/pull อาจจะมีปัญหากับคนที่ใช้ Repo ร่วมกัน ควรแจ้งให้ทุกคนทราบก่อน

โปรแกรมที่เราจะใช้แก้ก็คือ BFG Repo Cleaner ซึ่งเป็น Java โหลดได้จากในหน้าเว็บเลย วิธีการใช้งานก็ไม่ยาก

$ java -jar ~/Downloads/bfg-1.12.15.jar -D service-account.json    

Using repo : /home/whs/apps/repo/.git

Found 42 objects to protect
Found 4 commit-pointing refs : HEAD, refs/heads/master, refs/remotes/origin/master, refs/stash

Protected commits
-----------------

These are your protected commits, and so their contents will NOT be altered:

 * commit 5fdf16ac (protected by 'HEAD') - contains 1 dirty file : 
    - dns/service-account.json (2.3 KB)

WARNING: The dirty content above may be removed from other commits, but as
the *protected* commits still use it, it will STILL exist in your repository.

Details of protected dirty content have been recorded here :

/home/whs/apps/repo.bfg-report/2017-09-21/22-42-31/protected-dirt/

If you *really* want this content gone, make a manual commit that removes it,
and then run the BFG on a fresh copy of your repo.


Cleaning
--------

Found 72 commits
Cleaning commits:       100% (72/72)
Cleaning commits completed in 278 ms.

Updating 3 Refs
---------------

    Ref                          Before     After   
    ------------------------------------------------
    refs/heads/master          | 5fdf16ac | c62f12f6
    refs/remotes/origin/master | 4946f48a | b83dcc06
    refs/stash                 | ecafbd96 | 56772cea

Updating references:    100% (3/3)
...Ref update completed in 27 ms.

Commit Tree-Dirt History
------------------------

    Earliest                                              Latest
    |                                                          |
    DDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDm

    D = dirty commits (file tree fixed)
    m = modified commits (commit message or parents changed)
    . = clean commits (no changes to file tree)

                            Before     After   
    -------------------------------------------
    First modified commit | 2c741fa1 | 0f4fbd88
    Last dirty commit     | ecafbd96 | 56772cea

Deleted files
-------------

    Filename               Git id           
    ----------------------------------------
    service-account.json | 84044ad7 (2.3 KB)


In total, 146 object ids were changed. Full details are logged here:

    /home/whs/apps/repo.bfg-report/2017-09-21/22-42-31

BFG run is complete! When ready, run: git reflog expire --expire=now --all && git gc --prune=now --aggressive

ตัวเลือก -D จะให้เราระบุชื่อไฟล์ที่ต้องการลบ โดยไม่สามารถใส่ path ได้ (ถ้ามีไฟล์ชื่อเดียวกันหลายๆ ที่ก็จะถูกลบทั้งหมด)

ถ้าอ่านใน Log ก็จะเห็นว่า BFG จะไม่แก้ไข commit ล่าสุดของเรา ฉะนั้นไฟล์ที่เราเพิ่ง encrypt + commit ไปจะไม่หาย ส่วนไฟล์นี้ใน history จะถูกลบออกทั้งหมด เมื่อพร้อมแล้วก็ให้ลบขยะออกจาก Git ถาวรด้วยคำสั่ง git reflog expire --expire=now --all && git gc --prune=now --aggressive ที่เค้าให้มา แล้วก็ Force push ได้เลย

Backup GPG key

สุดท้ายเพื่อความปลอดภัย ควรจะ backup private key ที่เราใช้ด้วย

$ gpg -a --export-secret-keys 5AD1E4A5 > 5AD1E4A5.key

(สามารถกรอก key ID เป็นแบบสั้น แบบยาวหรืออีเมลก็ได้เหมือนเดิม)

เมื่อได้ไฟล์มาแล้วก็ควรเก็บไว้ที่ๆ ปลอดภัย เช่นที่ผมใช้คือถ้าจะเอา key นี้ไปเก็บบน cloud storage ใดๆ ก็จะต้อง encrypt ด้วยรหัสผ่านอีกครั้งหนึ่งที่คนละรหัสกับรหัสเข้า storage นั้นๆ หรือเลี่ยงได้ก็จะไม่ใช้ ก๊อปไฟล์ระหว่างเครื่องเองอย่างเดียว