Common Lisp 實現的 RSA 非對稱加密玩具庫
Common Lisp 實現的 RSA 非對稱加密玩具庫
之前看過李永樂老師的講課,感覺 RSA 加密的核心演算法挺簡單的,就想自己實現看看。感興趣的請移步B站觀看。
開始寫程式碼以後發現,RSA 的核心演算法確實不是難點,大概5,6句話就能講清楚,難點反而是在於加密與解密演算法的周邊。比如:金鑰生成,資訊分段加密,以及解密後的重新組裝,等等。
被這些周邊問題卡住以後,捏著鼻子用某度找了一圈,不客氣地說,所找到的基本都是垃圾,搜尋結果中靠前的都是些不懂裝懂的垃圾部落格。
作為非專業人士,我斗膽把實現過程寫出來,如果沒有誤導他人,還能給不小心點進來的某人一點幫助,也算是做了件好事。
核心演算法
-
先準備兩個素數
p
q
,為了防禦別人破解,請選擇兩個比較大的素數,比如 83 和 97 [笑] -
將
p
和q
相乘,得到n
。要注意囉,這裡的得到的n
會成為金鑰的一部分,將會參與加密與解密運算。 -
通過
(p-1) * (q-1)
得到一個數,這個就是所謂的尤拉函式,這裡記作 φ(n) -
選一個數作為公鑰指數
e
,一般都選擇 65537 -
通過選定的公鑰指數
e
和 φ(n) 計算得出私鑰指數d
。d
必須滿足條件e * d % φ(n) = 1
。
這裡總過5步,難點在最後一步 d
的計算。稍後再詳細講。
得到 e
, d
, 和 n
之後,把 e
和 n
放在一起,叫做公鑰,用於加密;把 d
n
放在一起,叫做私鑰,用於解密。
加密和解密的計算過程完全一致,不同的只是傳遞的指數不同。
設 m
為明文,通過乘方模運算就得到密文 c:
m ^ e % n => c
反過來
c ^ d % n => m
可以看到,演算法全一樣,完全可以實現為單個函式,區別只在於你傳遞給它的是公鑰還是私鑰。
所以,下面就是所謂的核心演算法的實現:
(defun euler (p q) (* (- p 1) (- q 1))) (defstruct rsa-key name bits type n exponent) (defun gen-keys (name &optional (bits 2048)) (let* ((p (make-prime (floor bits 2))) (q (make-prime (floor bits 2))) (n (* p q)) (e #x10001) (m (euler p q)) (d (modinv e m))) (values (make-rsa-key :name name :bits bits :type :public :n n :exponent e) (make-rsa-key :name name :bits bits :type :private :n n :exponent d)))) ;; 這裡是入口,加密解密都靠它,區別只在於傳遞給它的引數 密文 or 明文 | 公鑰 or 私鑰 (defun enc/dec-number (n key) (expmod n (rsa-key-exponent key) (rsa-key-n key)))
這段程式碼並不能執行,因為還差一些東西。很明顯,make-prime
和 modinv
還沒有實現。
難點一:大素數生成
憑直覺,你可以從2開始往後列舉:2,3,5,7,9,11,13... 再往後呢?
寫一個巢狀迴圈來試除?對不起,這種蠢辦法的複雜度是指數級的,就是你家有超算,也會很快就算不動了。
就如同大數的質因數分解是個難題一樣,大數的素性檢測同樣也是難題。好在目前存在一些非確定性的基於概率的檢測演算法,可以將複雜度優化到對數級。這就是成功的路徑。
看過 《SICP》人應該知道還記得,SICP 上面提到過一種叫做米勒拉賓測試的演算法。如果認真做作業的話,很可能已經實現過了。所以,這不是問題。幾年前我就已經用 Scheme 寫過了,現在不過是用 Lisp 再寫一次。
(defun check-nontrivial-sqrt (n m)
(let ((x (mod (square n) m)))
(if (and (= x 1)
(not (= n 1))
(not (= n (- m 1))))
0
x)))
(defun exp-mod (base exp m)
(cond ((= exp 0) 1)
((evenp exp)
(check-nontrivial-sqrt (exp-mod base (/ exp 2) m) m))
(t (mod (* base (exp-mod base (- exp 1) m)) m))))
(defun miller-rabin-test (n base)
(= (exp-mod base (- n 1) n) 1))
(defun make-random-list (n count)
(if (= count 0)
nil
(cons (+ 1 (random (- n 1)))
(make-random-list n (- count 1)))))
(defun test-queue (n test-list)
(or (null test-list)
(and (miller-rabin-test n (car test-list))
(test-queue n (cdr test-list)))))
(defun primep (n)
(or (= n 2)
(and (> n 1)
(oddp n)
(test-queue n (make-random-list n 20)))))
test-queue
並非必要的,在測試中遇到非素數時,有極高的概率在很少的幾次迭代中發現真相,從而不需要跑完指定的迭代次數。不過我就想寫成這樣,因為上面的程式碼足以在幾毫秒內判斷一個天文數字大小的整數是不是素數。
為了方便 RSA 相關函式呼叫,還需要幾個輔助函式:
(defun next-prime (n)
(labels ((iter (n)
(if (primep n)
n
(iter (+ n 2)))))
(if (oddp n)
(iter n)
(iter (+ n 1)))))
(eval-when (:load-toplevel)
(setf *random-state* (make-random-state t)))
(defun make-prime (&optional (bits 1024))
(let ((hex-bits (floor bits 4)) ;; 1 hex digit is equal to 4 binary digits
(hex-string (make-array 0 :element-type 'base-char :fill-pointer 0 :adjustable t))
(*print-base* 16)) ;; 這就是讓教授們掩鼻的動態變數的神奇用法之一,你可以偽造一個全域性變數來欺騙某個函式
(with-output-to-string (s hex-string)
(let (;; 確保最高的兩位設定為 1
(first-digit (logior (random 16) #b1100)))
(format s "~A" first-digit)
(dotimes (i (- hex-bits 1))
(format s "~A" (random 16)))))
(let ((n (parse-integer hex-string :radix 16)))
(next-prime n))))
呼叫的入口是make-prime
,預設生成 1024 位的隨機素數。如果一次生成一個比較大的隨機數,再以該數為起點尋找素數的話可以更快,但是隨機數的位數無法控制。因此,這裡的演算法是迭代 bits / 4 次,每次迭代生成一位隨機的 16 進位制數,剛好對應 4 位二進位制數。因為生成金鑰對不是經常執行的任務,所以這個代價是可以接受的。就算是極度優化的 OpenSSL,在生成足夠長的金鑰對時也是需要等待的。
難點二,計算私鑰指數 d
d
必須滿足的條件是 e * d % φ(n) = 1
這個的演算法是現成的,網上程式碼滿天飛,難點在於抄對。
下面就是我從 Python 翻譯過來的實現:
(defun egcd (a b)
(if (= a 0)
(values b 0 1)
(multiple-value-bind (g y x)
(egcd (mod b a) a)
(values g (- x (* (floor b a) y)) y))))
(defun modinv (a m)
(multiple-value-bind (g x y)
(egcd a m)
(declare (ignore y))
(unless (= g 1)
;;(error "modular inverse does not exists")
0)
(mod x m)))
不是難點 expmod
這個不是難點,是從 SICP 上直接抄下來的。
(defun expmod (base exp m)
(cond ((= exp 0) 1)
((evenp exp)
(mod (square (expmod base (/ exp 2) m)) m))
(t (mod (* base (expmod base (- exp 1) m)) m))))
前面的素數判斷的程式碼中的 exp-mod
函式僅僅是在它的基礎上多加了一個check-nontrivial-sqrt
判斷。因為我不確定加了這個判斷對最終的結果正確性會不會有影響,所以還是把原始版本給抄了上來。
作為演示性的實現,到此就已經完整了。至少把 demo 跑起來是沒問題的。接下來如果要賦予它實用性的話,還要處理一些棘手的問題。比如,如何將待加密的資料切成片,分別加密後再組裝在一起。至於解密方,又要如何從一些二進位制位中正確地將加密單元切出來,分別解密,再組裝成原始的檔案。目前我還在和一些 BUG 搏鬥,程式碼就不放出來了。