以下の気づきを残す.
- Pythonの文字列暗号化はファイル自体にも効く
- SQLite3のDBをファイルごと暗号化することは可能
文字列って掛け算できたの
背景
SQLite3には「パスワード暗号化機能」が無い.
欠点を補う方法はいくつか挙げられているが,「有料である」,「自力でのビルドが必要」など課題が多い.
万年素人からGeekへの道
SQLiteの暗号化
一方,Pythonには暗号化ライブラリpycryptoがあるため,活用の余地がある.
Pythonの暗号化において,文字列を暗号化・復号する例は多い.
Pythonで暗号化と復号化
【Python】pycryptoで投げるたけでAES暗号化復号してくれる関数作った。
Pythonでの暗号化/復号化(AEC-DES,RSA)
しかし,ファイルを暗号化する例は見られない.
(どんなファイルも表現しようとすれば文字列だろとは言えるが)
そこで本稿では,pycryptoを用いてSQLite3のDBファイル自体をパスワード暗号化・復号できることを確かめ,例を示す.
「文字しか暗号化できないとかクソ」と嘆く人がいれば,ぜひ見て頂きたい.
やったこと
- SQLite3でDBを構築し,データを確認する
- DBファイル(sample.db)をPython3のpycryptoでAES暗号化し,db.cipherとして暗号ファイル作成
- 暗号ファイル(db.cipher)をAES復号で元に戻し,sample.dbとして上書き
- 再びSQLite3で接続し,データが元通りであることを確認する
ファイル構成
- cipher.py: 一連の処理をする
- AES_Cipher_SQLite.py: 暗号化・復号処理をする
- Database.py: DB処理をする
cipher.pyを実行する.cipher.pyがAES_Cipher_SQLite.pyやDatabase.pyを呼び出しながら処理を行う(なので同じディレクトリに配置すること).
##注意点
- PyCharmではpycryptoのインストールができなかったので,(恐らく)同じ機能を持つpycryptodomeを使っている
Can't install pycrypto #263
Highly recommend NOT to use pycrypto. It is old and not maintained and contains many vulnerabilities. Use pycryptodome (https://github.com/Legrandin/pycryptodome) instead - it is compatible and up to date
- 16バイト刻みで云々という処理はpycryptoの仕様上起こっている.データ量が16バイトの倍数でないと暗号化できない
- ソースコードでは「文字数を16で割った余り」から不足文字数を割り出し,'_'で補完することで16バイトの倍数に修正している(1文字1バイトっぽい?).例えば20文字なら不足分12文字を補う
- SQLite3のDBファイルは文字コードがUTF16だった.AESのパスワードとIVはUTF8で計算しているが,DBファイルだけUTF16用の別処理をしているのはそのせい
パスワードもIVも16バイト刻みに直さなきゃいけないのクッソ面倒
##ソースコード
from AES_Cipher_SQLite import AES_Cipher_SQLite
from Database import Database
# DBファイル作成,データ取得
db = Database()
db.insertTest()
db.getAllAccounts()
# 送ったパスワードを暗号化・復号に用いる
cipherDB = AES_Cipher_SQLite('myPassWord')
# バイナリモードでDBファイル読み込み
with open("sample.db", "rb") as fileData:
contents = fileData.read() # DBファイルのバイナリ取得(エンコードはutf16)
print('===binary Data===')
print(contents)
# DBバイナリデータが16バイト刻みか確かめ,
# 不足していればバイト文字で補う
contents_shortageChars = cipherDB.getShortageChars(contents)
contents = cipherDB.get16ByteIncrementsData_fromUTF16(
contents, contents_shortageChars
)
print('===after increment===')
print(contents)
# AES暗号化
encrypted = cipherDB.AES_Encryption(contents)
print('===after Encryption===')
print(encrypted)
# バイナリモードで暗号ファイルを作成
with open("db.cipher", "wb") as fileData:
# 暗号化されたDBバイナリデータ(16バイト刻み)を書き込み
fileData.write(encrypted)
# バイナリモードで暗号ファイルを読み込み
with open("db.cipher", "rb") as fileData:
# DBファイルのバイナリ取得(エンコードはutf16)
cipher_data = fileData.read()
# AES復号
decrypted = cipherDB.AES_Decryption(cipher_data)
print('===after Decryption===')
print(decrypted)
# DBバイナリデータ(16バイト刻み)を生のDBバイナリデータに修正
# 16バイト刻みにするために補った分のバイト文字を消去
decoded = cipherDB.getRawByteData(decrypted, contents_shortageChars)
print('===after Fix===')
print(decoded)
# バイナリモードでDBファイル(復号したほう)を上書き
with open("sample.db", "wb") as fileData:
# 暗号化されたDBバイナリデータ(16バイト刻み)を書き込み
fileData.write(decrypted)
# 再びDBデータ取得
db.getAllAccounts()
↓AES_Cipher_SQLite.pyは以下のバグを修正済み.
- getShortageChars()に引数として送ったデータが元々16バイト刻みだった場合,文字を追加する必要が無いのに16文字追加していた
- getRawByteData()に0を送った場合,そもそも文字が追加されていないので何もせずデータを返すべきなのに全文字を消去していた
# -*- coding: utf-8 -*-
from Cryptodome.Cipher import AES
class AES_Cipher_SQLite:
def __init__(self, keyPhrase: str):
# AES暗号化に使うパスワード
keyPhrase_shortageChars = self.getShortageChars(keyPhrase)
self.PassWord = self.get16ByteIncrementsData(
keyPhrase, keyPhrase_shortageChars
).encode('utf8')
# AES暗号化に使うIV
ivPhrase = 'iv'
ivPhrase_shortageChars = self.getShortageChars(ivPhrase)
self.iv = self.get16ByteIncrementsData(
ivPhrase, ivPhrase_shortageChars
).encode('utf8')
# 16バイト刻みの文字数となるために何文字不足しているかを返す
def getShortageChars(self, text: str) -> int:
# 16バイト刻みなら不足数は0
if (len(text) % 16) == 0:
nearChars = len(text)
else:
nearChars = len(text) + (16 - (len(text) % 16))
return nearChars - len(text)
# 不足分したバイト数をstring文字で補い,16バイト刻みにして返す
def get16ByteIncrementsData(self, text: str, shortage: int) -> str:
return text + '_' * shortage
# 不足したバイト数をバイト文字で補い,16バイト刻みにして返す
def get16ByteIncrementsData_fromUTF16(
self, text: bytes, shortage: int) -> bytes:
return text + b'_' * shortage
# バイト文字から補った分を消去して返す
def getRawByteData(self, text: bytes, chars: int) -> bytes:
# 元々補っていなかった場合は何もしない
if chars == 0:
return text
else:
return text[:-chars]
# AESでDBバイナリデータ(16バイト刻み)を暗号化
def AES_Encryption(self, targetData: bytes) -> bytes:
obj = AES.new(self.PassWord, AES.MODE_CBC, self.iv)
encryptedData = obj.encrypt(targetData)
return encryptedData
# AESでDBバイナリデータ(16バイト刻み)を復号
def AES_Decryption(self, targetData: bytes) -> bytes:
obj = AES.new(self.PassWord, AES.MODE_CBC, self.iv)
decrypted = obj.decrypt(targetData)
return decrypted
# -*- coding: utf-8 -*-
import sqlite3
class Database:
def __init__(self):
self.dbName = 'sample.db'
self.accounts_table = 'Accounts'
con = sqlite3.connect(self.dbName)
# もしテーブルが無ければ作る
# integerはprimary keyにすると自動でauto incrementになっている
# アカウントテーブル=====================================
con.execute("create table if not exists %s("
"id integer primary key," # 連番(主キー)
"user string not null," # ユーザ名
"password string not null)" # パスワード
% self.accounts_table)
con.commit() # SQLを確定
con.close()
# 全データ表示
def getAllAccounts(self):
con = sqlite3.connect(self.dbName)
cur = con.cursor()
cur.execute("select name from sqlite_master where type='table'")
print('============Tables ============')
for row in cur:
print(row[0])
cur.execute("select * from %s" % (self.accounts_table))
print('============Values in Accounts Table ============')
for row in cur:
print(row)
con.close()
# データ挿入
def insertTest(self):
con = sqlite3.connect(self.dbName)
con.execute(
"insert into %s(user,password) values("
"'myUserFirst@mail.com',"
"'password_myUserFirst')"
% (self.accounts_table))
con.execute(
"insert into %s(user,password) values("
"'secondName',"
"'doublePass')"
% (self.accounts_table))
con.commit()
con.close()
結果
バイナリデータが長すぎるので,データ文字列は最初と最後だけ表示している.
============Tables ============
Accounts
============Values in Accounts Table ============
(1, 'myUserFirstf@mail.com', 'passwordaa_myUserFirst')
(2, 'secondName', 'doublePass')
===binary Data===
b'SQLite format 3\x00\x10...\x0079myUserFirstf@mail.compasswordaa_myUserFirst'
===after increment===
b'SQLite format 3\x00\x10...\x0079myUserFirstf@mail.compasswordaa_myUserFirst'
===after Encryption===
b'\xcd\xb6\t\x127\x00\xd3%\...\xfe&\xe7\x00\xbdOZ\x85cG|;wk\x194\x99\x01Px'
===after Decryption===
b'SQLite format 3\x00\x10...\x0079myUserFirstf@mail.compasswordaa_myUserFirst'
===after Fix===
b'SQLite format 3\x00\x10...\x0079myUserFirstf@mail.compasswordaa_myUserFirst'
============Tables ============
Accounts
============Values in Accounts Table ============
(1, 'myUserFirstf@mail.com', 'passwordaa_myUserFirst')
(2, 'secondName', 'doublePass')
- まずDBデータが表示される
- DBファイルのデータを表示(読める)
データを16バイト刻みにするため'_'を付けて補完- SQLite3のDBファイルは元々16バイト刻みだった(補完されない)
- データを暗号化して表示(読めない)
- 復号して表示(また読める)
データから補完した余計な文字を抜き取る- 元々16バイト刻みだったので変化なし
- DBデータを表示(できた)
どうやらSQLite3のDBデータは16バイト刻みを維持しているようなので,正直get16ByteIncrementsData_fromUTF16()とgetRawByteData()はいらない
#まとめ
- SQLite3の暗号化はわかりにくい
- ファイルごと暗号化すればいいんじゃね?
- Pythonの暗号化ライブラリ使用例は対象が文字列ばっかり
- ファイルごと暗号化できんじゃね?
- で き た