原理與盲索引設(shè)計(jì):從數(shù)據(jù)庫(kù)字段到工程實(shí)現(xiàn))
簡(jiǎn)介這款“密文檢索系統(tǒng)源碼項(xiàng)目說(shuō)明數(shù)據(jù)庫(kù)基于AES加密”壓縮包面向計(jì)算機(jī)、數(shù)學(xué)、電子信息等專業(yè)學(xué)生尤其適合正在準(zhǔn)備課程設(shè)計(jì)、期末大作業(yè)或畢業(yè)設(shè)計(jì)的同學(xué)作為參考資料借鑒。包內(nèi)源碼完整涵蓋基于AES算法的密文檢索核心業(yè)務(wù)包含22個(gè)Java源文件、18個(gè)JSP頁(yè)面、26個(gè)依賴jar包、12個(gè)XML配置文件及1個(gè)SQL數(shù)據(jù)庫(kù)腳本并配有編譯后的class文件與war包方便直接導(dǎo)入IDE部署調(diào)試。項(xiàng)目采用典型分層架構(gòu)從數(shù)據(jù)模型、Hibernate持久層、業(yè)務(wù)控制邏輯到前端頁(yè)面均有清晰實(shí)現(xiàn)可幫助讀者理解密文加解密與數(shù)據(jù)庫(kù)檢索結(jié)合的具體寫法整體共135個(gè)文件壓縮包僅36.14MB。已有106人學(xué)習(xí)瀏覽對(duì)于想借鑒完整項(xiàng)目結(jié)構(gòu)、快速上手加密檢索系統(tǒng)開(kāi)發(fā)的初學(xué)者或畢業(yè)生具備不錯(cuò)的參考價(jià)值。1. 密文檢索系統(tǒng)源碼包到底解決了什么問(wèn)題“密文檢索系統(tǒng)源碼項(xiàng)目說(shuō)明數(shù)據(jù)庫(kù)基于AES加密.zip”這類壓縮包最常見(jiàn)的出現(xiàn)場(chǎng)景是數(shù)據(jù)庫(kù)課程設(shè)計(jì)答辯其次是數(shù)據(jù)安全項(xiàng)目里的敏感字段加密演示。密文檢索系統(tǒng)的核心矛盾是數(shù)據(jù)庫(kù)里存的是 AES 密文應(yīng)用還要按手機(jī)號(hào)、身份證號(hào)、訂單號(hào)等條件把記錄找出來(lái)。如果每次查詢都先把整張表讀回內(nèi)存再解密數(shù)據(jù)量一上去就不可用如果直接在密文列上寫 LIKE又根本匹配不到。解決思路是在業(yè)務(wù)表里增加一個(gè)不可逆的派生列用它承載等值匹配。下面把這套系統(tǒng)背后的原理、字段拆分、最小代碼和風(fēng)險(xiǎn)檢查按工程順序講清楚。對(duì)要做數(shù)據(jù)庫(kù)課程設(shè)計(jì)、或在給存量系統(tǒng)補(bǔ)“加密后仍可查詢”能力的工程師都有直接參考價(jià)值。2. AES 加密下的密文檢索原理與選型在真正動(dòng)手寫代碼之前需要先明確一個(gè)邊界AES 加密后密文的每一位都近似隨機(jī)原始 SQL 里那把等值匹配、范圍匹配、排序能力全部失效。密文檢索做的不是在密文上直接計(jì)算而是把“檢索條件”變成另一個(gè)值讓它和數(shù)據(jù)庫(kù)索引天然兼容。所以最穩(wěn)定的落地點(diǎn)不是同態(tài)加密而是“盲索引”。2.1 盲索引是“已知才能查”的等值檢索盲索引的建立不需要全同態(tài)加密。常見(jiàn)做法是取檢索字段的值拼上 salt做一次 HMAC-SHA256然后把 32 字節(jié)結(jié)果寫進(jìn)獨(dú)立列。查詢時(shí)應(yīng)用端同樣計(jì)算這個(gè) HMAC 值用WHERE blind_index ?去匹配。它回答的問(wèn)題只有一個(gè)“這一列里哪些行的原始值是同一個(gè)”。對(duì)應(yīng)到工程上就是等值檢索。檢索類型常用實(shí)現(xiàn)對(duì)數(shù)據(jù)庫(kù)索引的影響泄露程度等值HMAC 盲索引列普通 B 樹(shù)索引相同值有相同索引值可被統(tǒng)計(jì)頻次范圍保序加密或分桶等值多次掃描需要有序列或改為范圍桶掃描順序關(guān)系、桶大小可見(jiàn)模糊/全文分詞后逐詞盲索引額外維護(hù)分詞表詞頻與同義詞關(guān)系被暴露任意計(jì)算全同態(tài)加密數(shù)據(jù)庫(kù)只存儲(chǔ)計(jì)算在應(yīng)用層相對(duì)最低但開(kāi)銷慢數(shù)個(gè)量級(jí)如果一個(gè)源碼包里只出現(xiàn) AES沒(méi)有提到 OPE 或全同態(tài)加密那這個(gè)系統(tǒng)的檢索能力就應(yīng)該收斂到等值。答辯時(shí)不要說(shuō)它支持密文模糊查詢因?yàn)?AES 密文的隨機(jī)性讓 LIKE 在數(shù)學(xué)上不成立。2.2 從數(shù)據(jù)庫(kù)字段設(shè)計(jì)看可搜索加密的實(shí)際形態(tài)一個(gè)常見(jiàn)的實(shí)現(xiàn)是字段拆列敏感字段的密文單獨(dú)存盲索引單獨(dú)存非敏感的業(yè)務(wù)字段繼續(xù)保留明文。下面的 DDL 就是密文檢索系統(tǒng)的典型主體。CREATE TABLE t_customer ( id INTEGER PRIMARY KEY, region TEXT NOT NULL, phone_enc BLOB NOT NULL, phone_hash BLOB NOT NULL, email_enc BLOB, email_hash BLOB, created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP ); CREATE INDEX idx_phone_hash ON t_customer(phone_hash);phone_enc保存 AES-GCM 加密后的字節(jié)串格式為nonce ciphertext tagphone_hash保存用于檢索的 HMAC-SHA256 值。加密列不建索引盲索引列建普通 B 樹(shù)索引。更新和刪除同理WHERE條件使用id或盲索引列不針對(duì)密文列操作。這就是數(shù)據(jù)庫(kù)增刪改查在密文場(chǎng)景下的最小結(jié)構(gòu)增和查走盲索引改和刪用主鍵定位后再替換密文與索引。2.3 AES-GCM 的參數(shù)選型與安全邊界選型上AES-GCM 優(yōu)先于 ECB 和 CBC。GCM 是 AEAD 模式加密后自動(dòng)帶認(rèn)證標(biāo)簽?zāi)馨l(fā)現(xiàn)密文被篡改ECB 會(huì)讓相同明文生成相同密文手機(jī)上號(hào)段、身份證區(qū)域碼一多很容易形成可讀的密文圖案。參數(shù)建議取值說(shuō)明密鑰長(zhǎng)度256 位滿足多數(shù)合規(guī)要求nonce / IV12 字節(jié)隨機(jī)數(shù)每次加密都重新生成不固定tag 長(zhǎng)度16 字節(jié)GCM 默認(rèn)完整 tag不要截?cái)嗝荑€派生PBKDF2-HMAC-SHA256從口令派生密鑰時(shí)使用迭代次數(shù)不少于 10 萬(wàn)索引派生HMAC-SHA256 原生 32 字節(jié)不作為密文單獨(dú)使用獨(dú)立密鑰盲索引的密鑰必須和加密密鑰分開(kāi)。如果兩個(gè)密鑰相同拿到索引列的人可以拿查詢接口當(dāng)“加密預(yù)言機(jī)”通過(guò)大量猜測(cè)值反推其他記錄的明文。工程上一般把密鑰鹽放后端配置或 KMS不放在前端腳本里也不寫進(jìn)數(shù)據(jù)庫(kù) dump。import hmac import hashlib INDEX_KEY bindependent-index-key def hash_for_query(value: str, month: str ) - bytes: # month 參數(shù)為空時(shí)是等值檢索傳入月份時(shí)是分桶檢索 message f{month}|{value}.encode(utf-8) return hmac.new(INDEX_KEY, message, hashlib.sha256).digest()month參數(shù)在這里是為后面的時(shí)間窗口盲索引預(yù)留的。傳入2025-03時(shí)同一個(gè)手機(jī)號(hào)的索引值只在該月內(nèi)有效不傳則退化成全局等值檢索。3. 用 Python 和 SQLite 把密文檢索系統(tǒng)最小實(shí)現(xiàn)跑通如果你是拿這個(gè)源碼包交數(shù)據(jù)庫(kù)課程設(shè)計(jì)不建議上來(lái)就解整套 Web先做一個(gè)能跑的最小路徑加密寫入盲索引查詢。以下代碼使用 Python 標(biāo)準(zhǔn)庫(kù)加 cryptography 庫(kù)數(shù)據(jù)庫(kù)用 SQLite。它能驗(yàn)證一件事密文入庫(kù)后查詢只需要一條普通 SQL不會(huì)觸發(fā)全表解密。3.1 建表與生成盲索引的最小函數(shù)集在已有表結(jié)構(gòu)基礎(chǔ)上先寫兩個(gè)基礎(chǔ)函數(shù)。import os import sqlite3 import hmac import hashlib from cryptography.hazmat.primitives.ciphers.aead import AESGCM # 實(shí)際項(xiàng)目應(yīng)從 KMS 或環(huán)境變量讀取不要寫在源碼里 ENC_KEY AESGCM.generate_key(bit_length256) INDEX_KEY bindependent-index-key def hash_for_query(value: str) - bytes: return hmac.new(INDEX_KEY, value.encode(utf-8), hashlib.sha256).digest() def encrypt_value(value: str, nonce: bytes) - bytes: aesgcm AESGCM(ENC_KEY) # 輸出格式固定為 nonce ciphertext tag方便入庫(kù)和出庫(kù) return nonce aesgcm.encrypt(nonce, value.encode(utf-8), None)encrypt_value的返回結(jié)構(gòu)是密文檢索系統(tǒng)里比較重要的約定前 12 字節(jié)是 nonce后面是密文和 16 字節(jié)認(rèn)證標(biāo)簽。hash_for_query返回原始 bytes不是十六進(jìn)制字符串SQLite 會(huì)將其存為 BLOB。這樣WHERE phone_hash ?能直接命中索引不會(huì)出現(xiàn)“應(yīng)用層比較 hex 字符串與 BLOB 不一致”的常見(jiàn)錯(cuò)誤。3.2 寫入流程先算盲索引再 AES 加密插入一條數(shù)據(jù)時(shí)加密和盲索引必須同時(shí)生成。def insert_customer(conn, region, phone, emailNone): nonce os.urandom(12) phone_enc encrypt_value(phone, nonce) email_enc encrypt_value(email, nonce) if email else None conn.execute( INSERT INTO t_customer(region, phone_enc, phone_hash, email_enc, email_hash) VALUES(?, ?, ?, ?, ?), (region, phone_enc, hash_for_query(phone), email_enc, hash_for_query(email) if email else None), ) conn.commit()順序不能反先對(duì)明文做 HMAC再對(duì)明文做 AES-GCM。如果把密文當(dāng)成 HMAC 輸入因?yàn)?GCM 每次 nonce 都不同密文不同得到的盲索引也會(huì)不同查詢時(shí)永遠(yuǎn)匹配不上。另外更新手機(jī)號(hào)時(shí)不能用舊索引直接定位一般先用主鍵定位再更新對(duì)應(yīng)編碼列。3.3 查詢流程SQL 里只有盲索引條件查詢時(shí)不允許把數(shù)據(jù)庫(kù)記錄全部讀出來(lái)。正確做法是先在應(yīng)用端計(jì)算盲索引再把它當(dāng)成普通條件。def find_by_phone(conn, phone): digest hash_for_query(phone) row conn.execute( SELECT id, phone_enc, email_enc, region FROM t_customer WHERE phone_hash ?, (digest,), ).fetchone() if not row: return None id_, phone_enc, email_enc, region row aesgcm AESGCM(ENC_KEY) # 前 12 字節(jié)是 nonce其余是 ciphertext tag phone_plain aesgcm.decrypt(phone_enc[:12], phone_enc[12:], None).decode() email_plain None if email_enc: email_plain aesgcm.decrypt(email_enc[:12], email_enc[12:], None).decode() return {id: id_, region: region, phone: phone_plain, email: email_plain}WHERE phone_hash ?走的是 idx_phone_hash 索引數(shù)據(jù)庫(kù)只回表讀取滿足條件的那一行。如果 AESGCM 在 decrypt 時(shí)拋InvalidTag說(shuō)明密鑰不匹配或數(shù)據(jù)庫(kù)里的密文被改過(guò)這類異常必須記日志不能靜默吞掉。整個(gè)過(guò)程沒(méi)有解密無(wú)關(guān)行這是密文檢索系統(tǒng)最重要的性能特征。3.4 現(xiàn)成數(shù)據(jù)庫(kù)導(dǎo)入后要核對(duì)的三類指標(biāo)源碼包里如果已經(jīng)帶了一個(gè).db或 SQL dump導(dǎo)入后先不要接業(yè)務(wù)頁(yè)面用最基礎(chǔ)的命令核對(duì)結(jié)構(gòu)。sqlite3 cipher.db .schema sqlite3 cipher.db SELECT length(phone_enc), length(phone_hash) FROM t_customer LIMIT 5; sqlite3 cipher.db SELECT COUNT(*) FROM t_customer;這三句話分別看表結(jié)構(gòu)、密文長(zhǎng)度和記錄規(guī)模。隨后對(duì)照下面這張表判斷數(shù)據(jù)是否健康。檢查項(xiàng)數(shù)據(jù)特征風(fēng)險(xiǎn)與處置密文列長(zhǎng)度每行基本固定且不是 16 的整數(shù)倍更像 GCM 密文健康密文列長(zhǎng)度固定為 16 的整數(shù)倍可疑可能用了 ECB 或 CBC 無(wú)隨機(jī) IV索引列長(zhǎng)度固定為 32 字節(jié)BLOB 類型正常索引列長(zhǎng)度固定為 64 字節(jié)存成了 hex 字符串索引體積大一倍業(yè)務(wù)明文鍵創(chuàng)建時(shí)間、區(qū)域是明文合理可以減少密文列數(shù)量檢查完再進(jìn)入查詢聯(lián)調(diào)。這一步能避免“代碼沒(méi)改但導(dǎo)入的庫(kù)和代碼不是同一套密鑰”這種課程設(shè)計(jì)里經(jīng)常發(fā)生的尷尬。4. 解壓源碼包后優(yōu)先檢查的 4 類風(fēng)險(xiǎn)點(diǎn)這類源碼包通常會(huì)帶上項(xiàng)目說(shuō)明、數(shù)據(jù)庫(kù) dump、前端頁(yè)面和幾個(gè)工具類。換到新機(jī)器上先不要急著啟動(dòng)。舊項(xiàng)目最常見(jiàn)的問(wèn)題不是功能缺而是密鑰、數(shù)據(jù)庫(kù)連接串和加密參數(shù)都跟著 zip 一起泄露了。下面 4 個(gè)位置按風(fēng)險(xiǎn)從高到低排花十分鐘檢查完再談功能。4.1 項(xiàng)目說(shuō)明中的軟件版本與實(shí)際建庫(kù)腳本項(xiàng)目說(shuō)明文檔里如果有版本記錄一定要當(dāng)成約束條件例如“數(shù)據(jù)庫(kù) MySQL 5.7”“Python 3.8”。建庫(kù)腳本里的ENGINEInnoDB DEFAULT CHARSETutf8mb4和本地環(huán)境不匹配時(shí)優(yōu)先以腳本為準(zhǔn)。不要直接在 MySQL 8 上執(zhí)行老 dump 后報(bào)錯(cuò)再回來(lái)改表數(shù)據(jù)庫(kù)版本差異主要影響排序規(guī)則和時(shí)間類型。如果壓縮包里同時(shí)給了.sql和.db兩份數(shù)據(jù)先比較兩者表數(shù)量和時(shí)間范圍避免數(shù)據(jù)庫(kù)課程設(shè)計(jì)答辯時(shí)用了錯(cuò)誤的那份。4.2 硬編碼密鑰、鹽和數(shù)據(jù)庫(kù)口令的排查方法解壓后先跑一輪文本搜索把密鑰和口令列出來(lái)。grep -rInE (aes.?key|secret|salt|password|passwd|jdbc:mysql|jdbc:postgresql|sqlite) \ --include*.py --include*.java --include*.js \ --include*.properties --include*.yml --include*.xml . grep -rInE (ENC_KEY|INDEX_KEY|SECRET) --include*.py . strings database.db | grep -iE secret|key|password | head -20第一組命令排查源碼與配置文件第二組排查數(shù)據(jù)庫(kù)二進(jìn)制文件中的明文關(guān)鍵字。如果發(fā)現(xiàn)key 1234567890abcdef這種十六進(jìn)制字符串不要繼續(xù)用重新生成密鑰并回填數(shù)據(jù)。鹽的位置也應(yīng)固定在后端配置里前端 JS 里出現(xiàn) salt 等同密鑰泄露。4.3 AES 實(shí)現(xiàn)參數(shù)里最容易翻車的三個(gè)位置源碼包來(lái)自不同作者時(shí)最容易出現(xiàn)兩邊算法一致、參數(shù)不一致的互讀問(wèn)題。檢查點(diǎn)錯(cuò)誤示范正確處置nonce / IV固定常量每個(gè)字段復(fù)用每次加密用os.urandom(12)nonce 隨密文存儲(chǔ)存儲(chǔ)編碼有的模塊返回 hex有的返回 Base64統(tǒng)一由同一個(gè)函數(shù)讀寫庫(kù)中只存 BLOB密鑰與口令把 16 位密碼字符串直接當(dāng) key用 KDF 派生 256 位密鑰或直接由 KMS 生成分組模式AES/ECB/PKCS5PaddingAES-256-GCM不額外做 PKCS#7 填充GCM 已經(jīng)是完整的 AEAD 方案不需要先 PKCS#7 填充再加密。源碼里如果看到CBC加PKCS5Padding至少要能回答“誰(shuí)來(lái)校驗(yàn)完整性”這個(gè)問(wèn)題。不能給出答案時(shí)建議換 GCM。4.4 用 EXPLAIN 驗(yàn)證密文檢索是否走了索引功能跑通后還要驗(yàn)證查詢計(jì)劃真的用了索引。EXPLAIN QUERY PLAN SELECT id FROM t_customer WHERE phone_hash x0A0B...;在應(yīng)用代碼里參數(shù)是直接綁定的 bytes不需要手動(dòng)寫成十六進(jìn)制字面量。SQLite 的預(yù)期結(jié)果是SEARCH t_customer USING INDEX idx_phone_hash如果結(jié)果變成SCAN t_customer說(shuō)明索引沒(méi)建在盲索引列上或者查詢條件寫成了phone_enc又或者列類型與傳入值不匹配導(dǎo)致隱式轉(zhuǎn)換。密文檢索的性能瓶頸不在 AES 速度而在這一步有沒(méi)有真正走索引。5. 最終驗(yàn)證與一個(gè)帶時(shí)間窗口的盲索引技巧功能正確不等于檢索安全。寫完整套系統(tǒng)后最后一步應(yīng)該是一條回歸斷言固定密鑰和輸入驗(yàn)證“密文每次不同索引每次相同”。# 同一個(gè)輸入索引必須相同不同輸入索引必須不同 assert hash_for_query(13800000000) hash_for_query(13800000000) assert hash_for_query(13800000000) ! hash_for_query(13900000000) # 同一個(gè)明文兩次 AES 密文必須不同因?yàn)?nonce 每次都換 nonce_1, nonce_2 os.urandom(12), os.urandom(12) assert encrypt_value(A, nonce_1) ! encrypt_value(A, nonce_2)這條斷言應(yīng)該進(jìn)入 CI。如果有人把 GCM 的 nonce 固定成常量第二組斷言會(huì)失敗如果有人把盲索引改成對(duì)密文做哈希第一組斷言也會(huì)失敗。用兩個(gè)斷言把密文隨機(jī)性和索引確定性鎖住后續(xù)修改加密邏輯時(shí)能第一時(shí)間發(fā)現(xiàn)問(wèn)題。第二個(gè)技巧是日期分桶鍵。全局等值盲索引最大的問(wèn)題是相同手機(jī)號(hào)無(wú)論在哪個(gè)月查詢索引值永遠(yuǎn)一樣長(zhǎng)期積累后頻次統(tǒng)計(jì)會(huì)暴露活動(dòng)周期??梢栽诮ū頃r(shí)增加一個(gè)phone_month_hash列寫入時(shí)把手機(jī)號(hào)和當(dāng)月月份拼起來(lái)做 HMAC。def hash_for_month(value: str, month: str) - bytes: # month 格式固定為 YYYY-MM在應(yīng)用層做白名單校驗(yàn) return hmac.new(INDEX_KEY, f{month}|{value}.encode(), hashlib.sha256).digest()查詢時(shí)用戶只能傳入受控的月份后端對(duì)月份做re.fullmatch(r\d{4}-\d{2}, month)白名單限制后再計(jì)算盲索引。跨月查詢就變成多個(gè)月份桶的 UNION復(fù)雜度是 O(月份數(shù))。這個(gè)方案犧牲了一點(diǎn)查詢便利換來(lái)的是“某個(gè)月份的密文只能在該份對(duì)應(yīng)桶里被檢索”而且舊桶可以隨歸檔一起刪除。純 AES 密文本身不提供范圍、排序、模糊能力用分桶和審計(jì)把泄露面壓到可控范圍才是把課程設(shè)計(jì)變成生產(chǎn)系統(tǒng)時(shí)最值得多花時(shí)間的地方。本文還有配套的精品資源點(diǎn)擊獲取