如何加密Long類型數值

一、前言

對於一些圖片,文章,或者用戶主頁等,需要構造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字節。

綜上,我們需要一個Long類型數值的加解密,且密文不會“膨脹”的函數。
不妨去看下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生成方案”,我們這裏就不作展開了。

五、總結

對Long類型數值進行加密,不單可以用來構造資源的ID,還可以用於別的用途。
比如前些時候筆者寫的一篇文章: 《漫談唯一設備ID》,文中提到,需要根據主鍵ID計算出另一個的ID(作爲UDID), 返回給客戶端。
用本文的方法即可滿足需求。

完整代碼已上傳github,
地址:https://github.com/No89757/LongEncrypt

參考資料
[1] 分組密碼
[2] 伽羅華域運算及C語言實現
[3] 碼算法詳解——AES
[4] AES算法描述及C語言實現
[5] 雪崩效應
[6] 漫談唯一設備ID

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章