Python 根據id生成唯一碼

簟紋燈影發表於2022-01-19

Python 根據id生成唯一碼

最近業務中遇到需要分享某個文案,複製文案開啟APP需要提取文案中包含的id,但又不想明文暴露id,所以需要對id進行加密,很想讓前端來做,可惜多個前端協調起來不方便(就是不想做),只能後端攻克一下了。

遇到問題肯定先看看有沒有前輩已經鋪好路了,找了一圈只發現Java語言實現的,參考連線如下:

簡單的密碼學生成唯一邀請碼

基於全域性ID生成全域性唯一邀請碼

需求分析

從業務需求上來看,生成碼有以下幾個強制性的要求:

  • 不可重複
  • 唯一確定

這兩點要求首先就排除了 hash code 的可能,因為 hash code 是可以發生碰撞的。然後在強制性要求的基礎之上,我們還有一些進一步的需求:

  • 長度不能太長,6-10 位是合適的區間
  • 不容易被推測出
  • 資源消耗盡可能小

方案

閉眼寫系列

直接隨機生成,存資料庫,存之前做個校驗,但是這種方式會對資料庫進行多次訪問,無疑是對資源的過度佔用,顯然應該存在無需過資料直接在本地進行加密和解密的方法,如JWT。

其他排除的選項

列這一欄不過是提供一些其他的思路,或許可以用在其他地方。

UUID:太長(32個字元)!

時間戳:也很長,且深究起來可能會重複。當然某些場景會用到和時間相關的優化方案,如雪花演算法

標識+隨機數:好想法,但是容易被反推出來。

最終方案

為了讓字母和數字的位置不固定,將ID 作 32 進位制轉換,即把 ID 對映為一串字母+數字的組合,高位用 0 補全。

同時把隨機數生成的範圍擴大到字母和數字,這樣密文中的每一位都可能是數字和字母,規律性就不易察覺得多。

然後是使用者 ID 暴露在密文中的問題。這個問題的解決辦法是我們可以加一點。鹽的取值最好不要太小,太小缺乏隱蔽性;也不能太大,太大會佔用過多使用者 ID 的取值空間。具體的取值取決於業務需求。

最後是校驗位的問題。固定在 2 位字元承擔起對密文其它部分的校驗功能。縮短後的校驗碼就沒有辦法隔位插入,我就把它放在了密文尾部。用這一套校驗方式,理論上能保證 99.9%的誤操作可以被後臺檢測出來而不需要查詢資料庫。

根據上方的邏輯生成的唯一碼仍然是有規律的,尤其是連續的id,有很多相似的地方,這是因為低位的變化不會影響高位,密碼學對此問題的解決方法是擴散和混淆

擴散 (diffusion) 和混淆 (confusion) 是 C.E.Shannon 提出的設計密碼體制的兩種基本方法,其目的是為了抵抗對手對密碼體制的統計分析。在分組密碼的設計中,充分利用擴散和混淆,可以有效地抵抗對手從密文的統計特性推測明文或金鑰。擴散和混淆是現代分組密碼的設計基礎。

所謂擴散就是讓明文中的每一位影響密文中的許多位,或者說讓密文中的每一位受明文中的許多位的影響。這樣可以隱蔽明文的統計特性。當然,理想的情況是讓明文中的每一位影響密文中的所有位,或者說讓密文中的每一位受明文中所有位的影響。

所謂混淆就是將密文與金鑰之間的統計關係變得儘可能複雜,使得對手即使獲取了關於密文的一些統計特性,也無法推測金鑰。使用複雜的非線性代替變換可以達到比較好的混淆效果,而簡單的線性代替變換得到的混淆效果則不理想。可以用”揉麵團”來形象地比喻擴散和混淆。當然,這個”揉麵團”的過程應該是可逆的。乘積和迭代有助於實現擴散和混淆。選擇某些較簡單的受金鑰控制的密碼變換,通過乘積和迭代可以取得比較好的擴散和混淆的效果。

上面這些話都是我抄的,只怪當年沒好好聽密碼學這門課,我們還是直接看程式碼吧

程式碼實現

公共部分:

# 隨機字串,用於混淆
CHARS = ('F', 'L', 'G', 'W', '5', 'X', 'C', '3', '9', 'Z', 'M', '6', '7', 'Y', 'R', 'T', '2', 'H', 'S',
         '8', 'D', 'V', 'E', 'J', '4', 'K', 'Q', 'P', 'U', 'A', 'N', 'B')
CHARS_LENGTH = 32

# 邀請碼長度
CODE_LENGTH = 8
# 隨機資料,加鹽
SALT = 131420

# 下方資料用於擴散
# PRIME1 與 CHARS 的長度 L互質,可保證 ( id * PRIME1) % L 在 [0,L)上均勻分佈
PRIME1 = 3
# PRIME2 與 CODE_LENGTH 互質,可保證 ( index * PRIME2) % CODE_LENGTH  在 [0,CODE_LENGTH)上均勻分佈
PRIME2 = 9

加密:

def encode(num: int) -> str:
    # 擴散+加鹽
    num = num * PRIME1 + SALT
    # 下方為加密邏輯
    b = [num] + [0 for _ in range(CODE_LEN - 1)]
    for i in range(5):
        b[i + 1] = b[i] // CHARS_LEN
        b[i] = (b[i] + b[0] * i) % CHARS_LEN

    # 最後兩位起到校驗的作用
    b[5] = (b[0] + b[1] + b[2]) * PRIME1 % CHARS_LEN
    b[6] = (b[3] + b[4] + b[5]) * PRIME1 % CHARS_LEN

    code = ""
    for i in range(CODE_LEN):
        # 混淆的過程
        code += CHARS[b[(i * PRIME2) % CODE_LEN]]

    return code

解密:

def decode(code: str) -> int:
    """
    對唯一碼的解碼,返回值-1代表驗證不通過
    """

    # 長度校驗
    if len(code) != CODE_LEN:
        return -1

    num = 0
    a = [0 for _ in range(CODE_LEN)]
    b = [0 for _ in range(CODE_LEN)]

    # 反解的過程
    for i in range(CODE_LEN):
        a[(i * PRIME2) % CODE_LEN] = i

    try:
        for i in range(CODE_LEN):
            a[i] = CHARS.index(code[a[i]])
    except ValueError:
        return -1

    # 最後兩位起到校驗的作用,此處為校驗流程
    b[5] = (a[0] + a[1] + a[2]) * PRIME1 % CHARS_LEN
    b[6] = (a[3] + a[4] + a[5]) * PRIME1 % CHARS_LEN
    if a[5] != b[5] or a[6] != b[6]:
        return -1

    # 反解num
    for i in range(4, -1, -1):
        b[i] = (a[i] - a[0] * i + CHARS_LEN * i) % CHARS_LEN

    for i in range(4, 0, -1):
        num = (num + b[i]) * CHARS_LEN

    num = ((num + b[0]) - SALT) // PRIME1
    return num

以上程式碼是經過本人精簡優化後,有些地方也仍然不是很理解,只能在繼續慢慢學習了,不過我還是更喜歡把程式碼封裝下,如下是封裝後的,使用上更方便。

class UniqCode:
    """
    根據id生成字串形式的唯一碼,可用於邀請碼或分享碼的場景,加密與解密均在本地完成
    """
    # 隨機字串,用於混淆
    CHARS = ('F', 'L', 'G', 'W', '5', 'X', 'C', '3', '9', 'Z', 'M', '6', '7', 'Y', 'R', 'T', '2', 'H', 'S',
             '8', 'D', 'V', 'E', 'J', '4', 'K', 'Q', 'P', 'U', 'A', 'N', 'B')
    CHARS_LEN = 32

    # 邀請碼長度,滿足大部分業務需求
    CODE_LEN = 7
    # 隨機資料,加鹽
    SALT = 131420

    # 下方資料用於擴散
    # PRIME1 與 CHARS 的長度 L互質,可保證 ( id * PRIME1) % L 在 [0,L)上均勻分佈
    PRIME1 = 3
    # PRIME2 與 CODE_LENGTH 互質,可保證 ( index * PRIME2) % CODE_LENGTH  在 [0,CODE_LENGTH)上均勻分佈
    PRIME2 = 9

    def __new__(cls, *args, **kwargs):
        """設定單例模式"""
        if not hasattr(cls, "_instance"):
            cls._instance = super(UniqCode, cls).__new__(cls)
        return cls._instance

    @classmethod
    def encode(cls, num: int) -> str:
        # 擴散+加鹽
        num = num * cls.PRIME1 + cls.SALT
        # 下方為加密邏輯
        b = [num] + [0 for _ in range(cls.CODE_LEN - 1)]
        for i in range(5):
            b[i + 1] = b[i] // cls.CHARS_LEN
            b[i] = (b[i] + b[0] * i) % cls.CHARS_LEN

        # 最後兩位起到校驗的作用
        b[5] = (b[0] + b[1] + b[2]) * cls.PRIME1 % cls.CHARS_LEN
        b[6] = (b[3] + b[4] + b[5]) * cls.PRIME1 % cls.CHARS_LEN

        code = ""
        for i in range(cls.CODE_LEN):
            # 混淆的過程
            code += cls.CHARS[b[(i * cls.PRIME2) % cls.CODE_LEN]]

        return code

    @classmethod
    def decode(cls, code: str) -> int:
        """
        對唯一碼的解碼,返回值-1代表驗證不通過
        """

        # 長度校驗
        if len(code) != cls.CODE_LEN:
            return -1

        num = 0
        a = [0 for _ in range(cls.CODE_LEN)]
        b = [0 for _ in range(cls.CODE_LEN)]

        # 反解的過程
        for i in range(cls.CODE_LEN):
            a[(i * cls.PRIME2) % cls.CODE_LEN] = i

        try:
            for i in range(cls.CODE_LEN):
                a[i] = cls.CHARS.index(code[a[i]])
        except ValueError:
            return -1

        # 最後兩位起到校驗的作用,此處為校驗流程
        b[5] = (a[0] + a[1] + a[2]) * cls.PRIME1 % cls.CHARS_LEN
        b[6] = (a[3] + a[4] + a[5]) * cls.PRIME1 % cls.CHARS_LEN
        if a[5] != b[5] or a[6] != b[6]:
            return -1

        # 反解num
        for i in range(4, -1, -1):
            b[i] = (a[i] - a[0] * i + cls.CHARS_LEN * i) % cls.CHARS_LEN

        for i in range(4, 0, -1):
            num = (num + b[i]) * cls.CHARS_LEN

        num = ((num + b[0]) - cls.SALT) // cls.PRIME1
        return num

給自己打個廣告,我是杜高強,一個在軟體行業摸爬打滾多年,一直在自學的程式猿,如果你也對這個行業感興趣,歡迎大家一起交流。

相關文章