一、前言
對于一些圖檔,文章,或者使用者首頁等,需要構造URL提供給外部。
對外釋出URL時,通常是 “域名/路徑/資源ID”,
其中,路徑是可選項,比如生成短連結時可能就是直接“域名/資源ID”。
舉例:
掘金的URL格式 :
https://juejin.im/user/59abef0af265da246c4a3de1 https://juejin.im/post/5d8ab56df265da5bb252d67c簡書的 URL格式:
https://www.jianshu.com/u/11d3f06afbcd https://www.jianshu.com/p/3df395d8a6bc這些連結最後的資源ID部分是怎麼構造的呢?
雖然無法确切知曉,但猜測一下也無妨。
掘金的資源ID,六進制字編碼,32位元組,可能時UUID(去掉分隔線)或者MD5。
無論是UUID還是MD5,都是有随機性的,是以不擔心被找規律,由于取值範圍有128bit, 發生沖突的機率也微乎其微。
簡書的資源ID,十六進制編碼,12位元組,也就是48bit,取值範圍兩百多萬億,夠配置設定了,可讀性也較好。
但是48bit的取值範圍,就不然像UUID一樣取随機值了,否則容易沖突,
是以,猜測原始ID是通過分段ID或者自增ID構造,自增ID的機率比較大,因為像snowflake算法這種需要的較長的有效位(bit數)。
但是自增ID的話,會呈現規律性(前面的字元不變),其他人就可以連續做幾個請求,算出其自增的step,然後就可以窮舉其所有資源了。
是以,在擷取增長ID之後,十六進制編碼之前,可能有一次加密。
那麼,如何做加密呢?
以下讨論就不說“猜測”了,而是如果讓我來做,我會如何設計。
二、對ID做加密
加密結果需要滿足以下幾點:
1、難以推測原ID;
2、加密後的ID和原ID一一映射,以避免重複;
3、對整數ID(64bit, 有效位<=64) 加密,加密後長度不變。
-
第1點:
MD5,SHA,AES,DES等都可以;
-
第2點:
MD5等摘要算法就不滿足了,不同的原文可能計算出相同的哈希;
要滿足一一映射,需要函數可逆。
-
第3點:
AES,DES也不滿足。
AES加密結果最短也有16位元組。
DES對小于8位元組的原文加密,密文為8位元組;對8位元組的原文加密,密文為16位元組。
綜上,我們需要一個對整數的加解密,且密文不會“膨脹”的函數。
不妨去看下AES是怎麼做的,抄個作業。
三、 AES 算法
接下來以AES128為例,看下AES的加密步驟。
部分圖文來自文章
《碼算法詳解——AES》和
《AES算法描述及C語言實作》,侵删。
3.1 整體流程
AES128的核心運算是對16位元組(128bit)的内容加解密。
若原文長于16位元組,則進行分組,16位元組一組,如果最後一組不足16位元組,則補齊到16位元組。
AES128要經曆10輪運算,其中1-9輪是相同的,第10輪稍有一點差別。
每輪運算涉及位元組替代、行移位、列混淆、輪密鑰加等四個子運算;
每個子運算都有逆運算,用相反順序逆運算可解密出原文。
3.2 位元組替代(SubBytes)
位元組替代需要用到S盒,S盒有兩個數組,且命名為:SBOX, INV_SBOX。
S盒有這樣的特點:
若y = SBOX[x],則x = INV_SBOX[y];
換個寫法: x = INV_SBOX[SBOX[x]]。
舉個栗子, 一個簡單的2階S盒:
int[] s_box = new int[]{2, 0, 3, 1};
int[] inv_s_box = new int[]{1, 3, 0, 2};
for (int x = 0; x < s_box.length; x++) {
System.out.print(inv_s_box[s_box[x]] + " ");
}
System.out.println();
for (int x = 0; x < s_box.length; x++) {
System.out.print(s_box[inv_s_box[x]] + " ");
}
輸出:
0 1 2 3
0 1 2 3
AES的S盒是8階的(8 x 8), 剛好取盡一個位元組的範圍(0-255),至于怎麼構造S盒本文就不展開了。
位元組替代運算:
for (int j = 0; j < 16; j++) {
state[j] = SBOX[state[j]];
}
逆向位元組替代:
for (int j = 0; j < 16; j++) {
state[j] = INV_S_BOX[state[j]];
}
位元組替代運算提供了算法的混淆性。
和代碼混淆一樣,原文本來具備可讀性,混淆後就變得不可讀了;
但是混淆後模式沒有改變,比如原來方法foo()混淆後變為a(), 則混淆後所有調用foo()的地方都是a()。
也就是,位元組替代後仍然存在統計特征。
3.3 行位移(ShiftRows)
行位移比較簡單,就是将16位元組作為一個4行4列的矩陣,
其中1、2、3行分别左移(逆運算為右移)1、2、3個位元組。
3.4 列混淆(MixColumns)
列混淆是所有子運算中最複雜的。
和行位移一樣,将16位元組劃分為4行4列;
不同的是,列混淆是分别對每一列的4個位元組做運算(和一個4x4的矩陣左乘)。
解密時也是左乘矩陣,解密矩陣是加密矩陣的逆矩陣。
某一列的運算代碼如下:
static inline uint8_t mul2(uint8_t a) {
return (a & 0x80) ? ((a << 1) ^ 0x1b) : (a << 1);
}
/*
* 左乘置換矩陣
* [d0] [02 03 01 01] [b0]
* [d1] = [01 02 03 01] . [b1]
* [d2] [01 01 02 03] [b2]
* [d3] [03 01 01 02] [b3]
*/
void multiply(uint8_t *b, uint8_t *d) {
uint8_t t = b[0] ^ b[1] ^ b[2] ^ b[3];
// d0 = (b0 << 1) + (b1 << 1) + b1 + b2 + b3 = ((b0 + b1) << 1) - b0 + t
d[0] = mul2(b[0] ^ b[1]) ^ b[0] ^ t;
d[1] = mul2(b[1] ^ b[2]) ^ b[1] ^ t;
d[2] = mul2(b[2] ^ b[3]) ^ b[2] ^ t;
d[3] = mul2(b[3] ^ b[0]) ^ b[3] ^ t;
}
矩陣運算需要進行加法運算和乘法運算,計算機的直接整數相加和直接整數相乘可能會溢出,進而丢失資訊,不可逆;
是以AES引入了“伽羅華域(也叫伽羅瓦域)”運算,感興趣的讀者可以閱讀文章《
伽羅華域運算及C語言實作》了解一下。
行位移和列混淆共同提供了算法的擴散性。
擴散的目的是讓明文中的單個數字影響密文中的多個數字,進而使明文的統計特征在密文中消失。
最理想的情況是達到
雪崩效應的效果:
當輸入發生最微小的改變(例如,反轉一個二進制位)時,也會導緻輸出的劇變(如,輸出中一半的二進制位發生反轉)。
如果隻有列混淆運算,則最終的效果是 [0,3] , [4,7], [8,11], [12,15] 四個分組分别擴充;
加上了行位移,才可以達到[0, 15]的位元組的擴散效果(明文一個位元組改變,密文16個位元組全都會随機變化)。
當然,需要經曆多輪運算才會有雪崩效應的效果,隻做一輪運算是不夠的。
3.5 輪密鑰加(AddRoundKey)
輪密鑰加是四個字運算中最簡單的,具體就是16個位元組分别和輪密鑰做異或運算。
輪密鑰是通過原始密鑰計算得來的,AES128一共要做11次輪密鑰加。
結合密鑰的運算,提供了算法的保密性。
如果沒有密鑰運算,就好比門沒上鎖,再堅固的防盜門也是形同虛設。
四、整數加密方案
AES是典型SP型密碼, SP型密碼網絡結構非常清晰,S被稱為混淆層(非線性層),主要起混淆作用,P被稱為擴散層,主要起擴散作用。
在AES算法中,
1、使用S盒做位元組替代操作來達到混淆效果;
2、通過在伽羅瓦域做矩陣運算(MixColumns),對分組中的4個位元組進行擴散;
3、同時結合行位移來交錯MixColumns的子分組,進而實作整個分組的擴散;
4、最後混入“密鑰”,實作算法的保密性。
值得注意的是,
AES128的分組大小是16位元組,可以作為4x4的矩陣;
AES256的分組大小是32位元組,可以作為4x8的矩陣。
MixColumns運算,可以看作時用4x4的矩陣左乘4xN的矩陣。
對于一個8位元組的整數,我們也可以将其看作4x2的矩陣來做MixColumns運算。
是以,我們可以仿照AES的結構和運算,對一個Long類型的數值做加密運算:
public class LongEncoder {
private static final int ROUND = 8;
private static final byte[] S_BOX = {
99, 124, 119, 123, -14, 107, 111, -59
......
};
private static final byte[] KEY = {
-14, 40, 52, -119, -126, -47, 74, 73,
......
};
private static byte mul2(byte a) {
return (byte) (((a & 0x80) != 0) ? ((a << 1) ^ 0x1b) : (a << 1));
}
public static void mix_column(byte[] s, int i) {
byte t = (byte) (s[i] ^ s[1 + i] ^ s[2 + i] ^ s[3 + i]);
byte b0 = (byte) (mul2((byte) (s[i] ^ s[1 + i])) ^ s[i] ^ t);
byte b1 = (byte) (mul2((byte) (s[1 + i] ^ s[2 + i])) ^ s[1 + i] ^ t);
byte b2 = (byte) (mul2((byte) (s[2 + i] ^ s[3 + i])) ^ s[2 + i] ^ t);
byte b3 = (byte) (mul2((byte) (s[3 + i] ^ s[i])) ^ s[3 + i] ^ t);
s[i] = b0;
s[1 + i] = b1;
s[2 + i] = b2;
s[3 + i] = b3;
}
private static void shift_rows(byte[] state) {
byte t1 = state[7];
byte t0 = state[6];
state[7] = state[5];
state[6] = state[4];
state[5] = state[3];
state[4] = state[2];
state[3] = state[1];
state[2] = state[0];
state[1] = t1;
state[0] = t0;
}
public static long encode64(long value) {
byte[] state = long2Bytes(value);
for (int i = 0; i < ROUND; i++) {
for (int j = 0; j < 8; j++) {
int m = ((i << 3) + j);
// AddRoundKey and SubBytes
state[j] = S_BOX[(state[j] ^ KEY[m]) & 0xFF];
}
shift_rows(state);
mix_column(state, 0);
mix_column(state, 4);
}
for (int j = 0; j < 8; j++) {
state[j] ^= KEY[(ROUND << 3) + j];
}
return bytes2Long(state);
}
......
}
事實上,輸入輸出的長度(位元組)不一定非要是4的倍數。
比如,對于6個位元組的輸入,可以這麼處理:
public static long encode48(long value) {
byte[] state = long2Bytes(value);
for (int i = 0; i < ROUND; i++) {
for (int j = 0; j < 6; j++) {
int m = ((i << 3) + j);
// AddRoundKey and SubBytes
state[j] = S_BOX[(state[j] ^ KEY[m]) & 0xFF];
}
// 對于48bit的輸入而言,就不需要ShiftRows了
// 因為先後對[0,3], [2,5]進行MixColumns已經可以對整個輸入擴散了
mix_column(state, 0);
mix_column(state, 2);
}
for (int j = 0; j < 6; j++) {
state[j] ^= KEY[(ROUND << 3) + j];
}
// 輸出的Long,高位的兩個位元組沒有變
// 是以如果輸入時小于2^48的數值,則輸出也是小于2^48的數組
return bytes2Long(state);
}
再将加密後的數值進行進制轉換,即可輸出字元串形式的ID:
private static final byte[] HEX_DIGITS = {
'0', '1', '2', '3', '4', '5', '6', '7', '8', '9',
'a', 'b', 'c', 'd', 'e', 'f'};
/**
* 小于2^48的long數值轉十六進制字元串
* @param n long類型整數
* @return 12位元組的字元串(十六進制)
*/
public static String long48ToHex(long n) {
if((n >>> 48) > 0){
throw new IllegalArgumentException(n + " is bigger than 2^48");
}
byte[] buf = new byte[12];
for (int i = 5; i >= 0; i--) {
int b = (int) n;
int index = i << 1;
buf[index] = HEX_DIGITS[(b >> 4) & 0xF];
buf[index + 1] = HEX_DIGITS[b & 0xF];
n = n >>> 8;
}
return new String(buf);
}
至此,我們便完成了對整型ID的加密和編碼。
至于整型ID的生成,網上有很多“ID生成方案”,我們這裡就不作展開了。
五、總結
本文從網站URL入手,對資源ID做了簡要的分析,文章用了大量篇幅講AES的算法原理,稍有喧賓奪主的意味。
但就好比做包子,餡比面皮投入多也是正常的。
好吃的餡不單可以用來做包子,也可用于其他用途。
比如前些時候筆者寫的一篇文章:
《漫談唯一裝置ID》,文中提到,需要根據主鍵ID計算出另一個的ID(作為UDID), 傳回給用戶端。
用本文的方法即可滿足需求。
完整代碼已上傳github,
位址:
https://github.com/No89757/LongEncrypt參考資料
[1]
分組密碼[2]
[3]
碼算法詳解——AES[4]
AES算法描述及C語言實作[5]
[6]
漫談唯一裝置ID