微博的和諧太厲害了,有時候髮色圖加了反色還是會被和諧,於是我就想寫一個簡單的程序用來自動加密解密圖片
GitHub 庫在這裏:weibo-img-crypto
添加加密解密處理
加密
我的目的是在上傳圖片時自動加密,但是上傳時的處理函數是在閉包裏的,JS 好像沒有辦法訪問閉包裏沒有導出的東西,所以無法動手腳。經過逆向分析,微博上傳圖片時調用了 FileReader.readAsDataURL
將圖片二進制數據轉爲 Base64,然後再上傳,所以可以 hook 這個函數加入加密圖片的處理:
let canvas = document.createElement('canvas')
let ctx = canvas.getContext('2d')
// Hook FileReader.readAsDataURL
let originalReadAsDataURL = window.FileReader.prototype.readAsDataURL
window.FileReader.prototype.readAsDataURL = function (file) {
if (file.type.startsWith('image/') && file.type !== 'image/gif') { // 暫時不支持GIF
// Hook onloadend
let originalOnloadend = this.onloadend
this.onloadend = () => {
let img = new window.Image()
img.onload = () => {
[canvas.width, canvas.height] = [img.width, img.height]
ctx.drawImage(img, 0, 0)
// 加密
let imgData = ctx.getImageData(0, 0, img.width, img.height)
encrypt(imgData.data)
ctx.putImageData(imgData, 0, 0)
// 替換上傳的圖片
originalOnloadend({target: {result: canvas.toDataURL()}})
}
img.src = this.result
}
}
originalReadAsDataURL.call(this, file)
}
其中 encrypt()
就是加密圖片的函數了,這個之後再介紹
解密
我打算做成在圖片上點擊鼠標右鍵就解密,這個可以直接抄我之前寫的反色圖片的代碼。只是涉及到圖片的跨域,沒什麼好講的
// 監聽右鍵菜單
document.addEventListener('contextmenu', event => {
if (event.target instanceof window.Image) {
// event.preventDefault() // 爲了右鍵保存圖片這裏先註釋掉了
let originImg = event.target
if (!(originImg instanceof window.Image)) {
return
}
// 跨域
let img = new window.Image()
img.crossOrigin = 'anonymous'
img.onerror = () => window.alert('載入圖片失敗,可能是跨域問題?')
img.onload = () => {
[canvas.width, canvas.height] = [img.width, img.height]
ctx.drawImage(img, 0, 0)
// 解密
let imgData = ctx.getImageData(0, 0, canvas.width, canvas.height)
decrypt(imgData.data)
ctx.putImageData(imgData, 0, 0)
originImg.src = canvas.toDataURL()
}
if (!originImg.src.startsWith('data:')) { // 如果是'data:'開頭說明已經解密過了
// 防緩存
img.src = originImg.src + (originImg.src.indexOf('?') === -1 ? '?_t=' : '&_t=') + new Date().getTime()
}
}
})
加密解密算法
我最初試了一下把 RGB 值異或一個數來加密,但是因爲新浪用了 JPEG 有損壓縮,解密後的圖片有嚴重的雜色。後來想了個把 RGB 數據隨機移動到一個新的位置的方法
僞隨機數生成
JS 的僞隨機數不能自己設置隨機種子,所以我把谷歌 V8 引擎的隨機數算法抄過來,改成可以設置隨機種子:
// 從谷歌V8引擎抄來的 https://github.com/v8/v8/blob/dae6dfe08ba9810abbe7eee81f7c58e999ae8525/src/math.js#L144
class Random {
constructor (seed) {
if (seed === undefined) {
seed = new Date().getTime()
}
this._rngstate = [seed & 0x0000FFFF, seed >>> 16]
}
// 返回[0, 1)
random () {
let r0 = (Math.imul(18030, this._rngstate[0] & 0xFFFF) + (this._rngstate[0] >>> 16)) | 0
this._rngstate[0] = r0
let r1 = (Math.imul(36969, this._rngstate[1] & 0xFFFF) + (this._rngstate[1] >>> 16)) | 0
this._rngstate[1] = r1
let x = ((r0 << 16) + (r1 & 0xFFFF)) | 0
// Division by 0x100000000 through multiplication by reciprocal.
return (x < 0 ? (x + 0x100000000) : x) * 2.3283064365386962890625e-10
}
// 返回[min, max]的整數
randint (min, max) {
return Math.floor(min + this.random() * (max - min + 1))
}
}
然後我們要把 RGB 數據移動到一個新的位置,這個位置不能和之前的重複,所以寫了一個生成不重複的隨機序列的類。算法是先初始化數組成員爲 0 到 length - 1,每次取隨機數時取索引在 nextMin 到 length - 1 之間的成員,然後把取到的數和 nextMin 的數交換位置,nextMin + 1
// 生成[0, length)的隨機序列,每次調用next()返回和之前不重複的值,直到[0, length)用完
class RandomSequence {
constructor (length, seed) {
this._rng = new Random(seed)
this._list = new Array(length)
for (let i = 0; i < length; i++) {
this._list[i] = i
}
this._nextMin = 0
}
next () {
if (this._nextMin >= this._list.length) {
this._nextMin = 0
}
let index = this._rng.randint(this._nextMin, this._list.length - 1)
let result = this._list[index]
this._list[index] = this._list[this._nextMin]
this._list[this._nextMin] = result
this._nextMin++
return result
}
}
加密解密
最後是加密解密函數了,算法很簡單,先分配一個 buffer,然後把 data 的 RGB 數據放到 buffer 的新位置,最後把 buffer 複製回 data。注意這裏 data 是每個像素 4 字節(Alpha 通道沒用到),而 buffer 是每個像素 3 字節
這裏用戶可以通過 window.randomSeed
設置隨機種子,只有加密和解密的種子一樣才能解密,默認種子是 114514 (不要問我什麼意思)
const DEFAULT_SEED = 114514
function encrypt (data) {
let nRgbs = data.length / 4 * 3
let seq = new RandomSequence(nRgbs, window.randomSeed || DEFAULT_SEED)
let buffer = new Uint8ClampedArray(nRgbs)
// 每一個RGB值放到新的位置
for (let i = 0; i < data.length; i += 4) {
buffer[seq.next()] = data[i]
buffer[seq.next()] = data[i + 1]
buffer[seq.next()] = data[i + 2]
}
for (let i = 0, j = 0; i < data.length; i += 4, j += 3) {
data[i] = buffer[j]
data[i + 1] = buffer[j + 1]
data[i + 2] = buffer[j + 2]
}
}
function decrypt (data) {
let nRgbs = data.length / 4 * 3
let buffer = new Uint8ClampedArray(nRgbs)
for (let i = 0, j = 0; i < data.length; i += 4, j += 3) {
buffer[j] = data[i]
buffer[j + 1] = data[i + 1]
buffer[j + 2] = data[i + 2]
}
let seq = new RandomSequence(nRgbs, window.randomSeed || DEFAULT_SEED)
// 取新的位置,放回原來的位置
for (let i = 0; i < data.length; i += 4) {
data[i] = buffer[seq.next()]
data[i + 1] = buffer[seq.next()]
data[i + 2] = buffer[seq.next()]
}
}
效果
由於有 JPEG 有損壓縮,解密後的圖片有高頻噪聲,不過可以被人眼自動過濾。理論上如果數據無損,解密後的圖片和原圖一樣
加密後:
解密後:
原圖: