JavaScript でファイル暗号化を試みる

WebCrypto API は、暗号化/平文化、署名、などできる API がそろっている。
なので、「ファイルの暗号化ツールができそうだな」とふと思い、トライして挫折して別の方法にたどり着くまでの記録。

参考

webCrypt API で暗号化 失敗

今回は、RSA-OAEP を使用した暗号化と複合を試みます。

手始めにキー生成

暗号化鍵になる公開鍵、複合鍵になる秘密鍵それぞれを文字列化して .env へ出力するスクリプト。
出力先が .env なのは後で.env を参照して読み込みたいから。

generate_key_rsa_oaep.ts
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
import { encode } from "https://deno.land/std@0.97.0/encoding/base64.ts";

let keyPair = await window.crypto.subtle.generateKey(
{
name: "RSA-OAEP",
modulusLength: 4096,
publicExponent: new Uint8Array([1, 0, 1]),
hash: "SHA-256",
},
true,
["encrypt", "decrypt"]
);

const publicKeyBuffer = await window.crypto.subtle.exportKey(
"spki",
keyPair.publicKey
);

const PrivateKeyBuffer = await window.crypto.subtle.exportKey(
"pkcs8",
keyPair.privateKey
);

const publicKeyText = encode(publicKeyBuffer);
const PrivateKeyText = encode(PrivateKeyBuffer);

const text = `PUBLIC_KEY=${publicKeyText}
PRIVATE_KEY=${PrivateKeyText}
`;

Deno.writeTextFileSync(".env", text);
1
$ deno run --allow-write=./.env generate_key_rsa_oaep.ts

一旦文字列の暗号化/複合化

本題のファイルの暗号化をする前に、一旦文字列の暗号化を試みます。

encrypt_text.ts
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
import "https://deno.land/std@0.150.0/dotenv/load.ts";
import {
decode,
encode,
} from "https://deno.land/std@0.97.0/encoding/base64.ts";

const keyData = decode(Deno.env.get("PUBLIC_KEY"));

const src = Deno.args[0];

if (!src) {
console.error("%cnot set source text!%c", "color:red;", "");
Deno.exit();
}

const publicKey = await crypto.subtle.importKey(
"spki",
keyData,
{
name: "RSA-OAEP",
hash: "SHA-256",
},
true,
["encrypt"]
);

const encryptedText = await crypto.subtle.encrypt(
{
name: "RSA-OAEP",
},
publicKey,
new TextEncoder().encode(src)
);

console.log(encode(encryptedText));
decrypt_text.ts
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
import "https://deno.land/std@0.150.0/dotenv/load.ts";
import { decode } from "https://deno.land/std@0.97.0/encoding/base64.ts";

const keyData = decode(Deno.env.get("PRIVATE_KEY"));

const src = Deno.args[0];

if (!src) {
console.error("%cnot set source text!%c", "color:red;", "");
Deno.exit();
}

const privateKey = await crypto.subtle.importKey(
"pkcs8",
keyData,
{
name: "RSA-OAEP",
hash: "SHA-256",
},
true,
["decrypt"]
);

const decryptedBuffer = await crypto.subtle.decrypt(
{
name: "RSA-OAEP",
},
privateKey,
decode(src).buffer
);

console.log(new TextDecoder().decode(decryptedBuffer));
1
2
3
4
5
6
7
8
## 暗号化
$ deno run --allow-read=./.env,./.env.defaults --allow-env encrypt_text.ts test-message
TElactWjLcvAhxEVwU3M0x--長すぎるので省略--xumb6YGz5xA4D3ujPTw6A=


## 複合
$ deno run --allow-read=./.env,./.env.defaults --allow-env decrypt_text.ts TElactWjLcvAhxEVwU3M0x--長すぎるので省略--xumb6YGz5xA4D3ujPTw6A=
test-message

と、このように、文字列の暗号化処理スクリプト、複合処理スクリプトが作成できる。

画像ファイルの暗号化(失敗する)

というところで本題、画像ファイルの暗号化をしてみる。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
import "https://deno.land/std@0.150.0/dotenv/load.ts";
import {
decode,
encode,
} from "https://deno.land/std@0.97.0/encoding/base64.ts";

const keyData = decode(Deno.env.get("PUBLIC_KEY"));

const src = Deno.args[0];

if (!src) {
console.error("%cnot set source text!%c", "color:red;", "");
Deno.exit();
}

let inputFileBuffer = null;

try {
const inputFile = Deno.readFileSync(src);
inputFileBuffer = inputFile.buffer;
} catch (e) {
console.error(e);
console.error("%cfile is not exist!%c", "color:red;", "");
Deno.exit();
}

const publicKey = await crypto.subtle.importKey(
"spki",
keyData,
{
name: "RSA-OAEP",
hash: "SHA-256",
},
true,
["encrypt"]
);

const encryptedText = await crypto.subtle.encrypt(
{
name: "RSA-OAEP",
},
publicKey,
inputFileBuffer
);

Deno.writeTextFile(`${src}.en`, encode(encryptedText));

実行してみると次のようになる。

1
2
3
4
5
6
7
8
$  deno run --allow-read=./.env,./.env.defaults,./ --allow-env encrypt_image_file.ts image.
jpg
error: Uncaught (in promise) OperationError: Encryption failed
const encryptedText = await crypto.subtle.encrypt(
^
at async encrypt (deno:ext/crypto/00_crypto.js:3745:28)
at async SubtleCrypto.encrypt (deno:ext/crypto/00_crypto.js:507:14)
at async file:///usr/src/app/encrypt_image_file.ts:39:23

というところでエラー。
mdn のにはこんな記載。

OperationError DOMException
Raised when the operation failed for an operation-specific reason (e.g. algorithm parameters of invalid sizes, or AES-GCM plaintext longer than 2³⁹−256 bytes).

あまり詳しいことは書かれていない。が、「操作固有の理由」ということで理由を探りたい。

失敗したので探ってみる

とりあえず、変換元のソースの長さを疑ってみる。

encrypt_text_try_length.ts
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
import "https://deno.land/std@0.150.0/dotenv/load.ts";
import {
decode,
encode,
} from "https://deno.land/std@0.97.0/encoding/base64.ts";

const keyData = decode(Deno.env.get("PUBLIC_KEY"));

const publicKey = await crypto.subtle.importKey(
"spki",
keyData,
{
name: "RSA-OAEP",
hash: "SHA-256",
},
true,
["encrypt"]
);

async function tryEncrypt(src: string) {
await crypto.subtle.encrypt(
{
name: "RSA-OAEP",
},
publicKey,
new TextEncoder().encode(src)
);
}

let r = 2;
while (r < 1000000) {
r += r;
console.log(r);
const src = "a".repeat(r);
await tryEncrypt(src);
}

実行してみると、256~512 文字の間に、限界がありそう。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
$ deno run --allow-read=./.env,./.env.defaults --allow-env encrypt_text_try_length.ts
4
8
16
32
64
128
256
512
error: Uncaught (in promise) OperationError: Encryption failed
await crypto.subtle.encrypt(
^
at async encrypt (deno:ext/crypto/00_crypto.js:3745:28)
at async SubtleCrypto.encrypt (deno:ext/crypto/00_crypto.js:507:14)
at async tryEncrypt (file:///usr/src/app/encrypt_text_try_length.ts:21:3)
at async file:///usr/src/app/encrypt_text_try_length.ts:35:3

256~512 文字の間で試験してみます。

encrypt_text_try_length.ts(改)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
import "https://deno.land/std@0.150.0/dotenv/load.ts";
import {
decode,
encode,
} from "https://deno.land/std@0.97.0/encoding/base64.ts";

const keyData = decode(Deno.env.get("PUBLIC_KEY"));

const publicKey = await crypto.subtle.importKey(
"spki",
keyData,
{
name: "RSA-OAEP",
hash: "SHA-256",
},
true,
["encrypt"]
);

async function tryEncrypt(src: string) {
await crypto.subtle.encrypt(
{
name: "RSA-OAEP",
},
publicKey,
new TextEncoder().encode(src)
);
}

let r = 255;
while (r < 511) {
r += 1;
console.log(r);
const src = "a".repeat(r);
await tryEncrypt(src);
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
$ deno run --allow-read=./.env,./.env.defaults --allow-env encrypt_text_try_length.ts
256
257
258
-- 省略 --
445
446
447
error: Uncaught (in promise) OperationError: Encryption failed
await crypto.subtle.encrypt(
^
at async encrypt (deno:ext/crypto/00_crypto.js:3745:28)
at async SubtleCrypto.encrypt (deno:ext/crypto/00_crypto.js:507:14)
at async tryEncrypt (file:///usr/src/app/encrypt_text_try_length.ts:21:3)
at async file:///usr/src/app/encrypt_text_try_length.ts:43:3

447 文字で失敗しているというところで、446 文字まで成功。
ためしに、 で文字列を埋めると 149 文字で失敗よって 148 文字まで成功。
が、3 バイト文字として取り扱いされているのでおおむね計算が合う。

1
2
3
4
5
6
new TextEncoder().encode("a").byteLength;
// => 1
new TextEncoder().encode("®").byteLength; // <== 〇で囲んだR
// => 2
new TextEncoder().encode("あ").byteLength;
// => 3

ということで、おそらく受け入れてくれる文字列長の制限を受けた可能性が高いようです。
とても画像ファイルを受け入れられる容量ではないので、WebCrypt API での画像暗号化はこのタイミングであきらめました。

crypt.js を試してみる。

JavaScript ファイル暗号化 でググると、hat.sh crypt.js などが出てきます。

npm 提供されているもので試したかったので、crypt.js を試します。
(対象暗号アルゴリズムでの暗号化となります。)

encrypt_image_file_by_cryptjs.ts
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
import { encode } from "https://deno.land/std@0.97.0/encoding/base64.ts";
import * as crypto from "https://esm.sh/crypto-js";

const src = Deno.args[0];
const key = Deno.args[1];

if (!src) {
console.error("%cnot set source text!%c", "color:red;", "");
Deno.exit();
}
if (!key) {
console.error("%cnot set key text!%c", "color:red;", "");
Deno.exit();
}

let inputFile = null;

try {
inputFile = Deno.readFileSync(src);
} catch (e) {
console.error(e);
console.error("%cfile is not exist!%c", "color:red;", "");
Deno.exit();
}

const srcText = encode(inputFile);

const encryptedText = crypto.default.AES.encrypt(srcText, key).toString();

Deno.writeFileSync(`${src}.en`, new TextEncoder().encode(encryptedText));
decrypt_image_file_by_cryptjs.ts
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
import { decode } from "https://deno.land/std@0.97.0/encoding/base64.ts";
import * as crypto from "https://esm.sh/crypto-js";

const src = Deno.args[0];
const exp = Deno.args[1];
const key = Deno.args[2];

if (!src) {
console.error("%cnot set source text!%c", "color:red;", "");
Deno.exit();
}
if (!exp) {
console.error("%cnot set export target text!%c", "color:red;", "");
Deno.exit();
}

if (!key) {
console.error("%cnot set key text!%c", "color:red;", "");
Deno.exit();
}

let inputFile = null;

try {
inputFile = Deno.readFileSync(src);
} catch (e) {
console.error(e);
console.error("%cfile is not exist!%c", "color:red;", "");
Deno.exit();
}

const srcText = new TextDecoder().decode(inputFile);

const decryptedText = crypto.default.AES.decrypt(srcText, key).toString(
crypto.default.enc.Utf8
);

Deno.writeFileSync(`${exp}`, decode(decryptedText));

次のように、実行できる。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
$ ls |grep i.jpg
i.jpg

$ deno run -A encrypt_image_file_by_cryptjs.ts i.jpg password1
$ ls |grep i.jpg
i.jpg
i.jpg.en

$ deno run -A decrypt_image_file_by_cryptjs.ts i.jpg.en iout.jpg password1
$ ls |grep iout.jpg
iout.jpg

$ deno run -A decrypt_image_file_by_cryptjs.ts i.jpg.en iout.jpg password2 # <== パスワードを間違うとエラー
error: Uncaught Error: Malformed UTF-8 data
at Object.stringify (https://esm.sh/v89/crypto-js@4.1.1/deno/crypto-js.js:2:3819)
at p.init.toString (https://esm.sh/v89/crypto-js@4.1.1/deno/crypto-js.js:2:2578)
at file:///usr/src/app/decrypt_image_file_by_cryptjs.ts:37:3

JavaScript で、ファイルの暗号化、複合化ができました。

面白いので、暗号化してファイルの分割してみる

もうただの趣味です。暗号化ファイルを 2 つに分割して複合してみます。
(「暗号化された複数のファイルを全部集めないと解読できない」なんてのはある意味でロマン)

encrypt_to_files.ts
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
import { encode } from "https://deno.land/std@0.97.0/encoding/base64.ts";
import * as crypto from "https://esm.sh/crypto-js";

const src = Deno.args[0];
const key = Deno.args[1];

if (!src) {
console.error("%cnot set source text!%c", "color:red;", "");
Deno.exit();
}
if (!key) {
console.error("%cnot set key text!%c", "color:red;", "");
Deno.exit();
}

let inputFile = null;

try {
inputFile = Deno.readFileSync(src);
} catch (e) {
console.error(e);
console.error("%cfile is not exist!%c", "color:red;", "");
Deno.exit();
}

const srcText = encode(inputFile);

const encryptedText = crypto.default.AES.encrypt(srcText, key).toString();

const encryptedTextArr = encryptedText.split("");
const encryptedTextA = encryptedTextArr
.filter((_: string, i: number) => i % 2 == 0)
.join("");
const encryptedTextB = encryptedTextArr
.filter((_: string, i: number) => i % 2 == 1)
.join("");

Deno.writeFileSync(`${src}.en.a`, new TextEncoder().encode(encryptedTextA));
Deno.writeFileSync(`${src}.en.b`, new TextEncoder().encode(encryptedTextB));
decrypt_from_files.ts
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
import { decode } from "https://deno.land/std@0.97.0/encoding/base64.ts";
import * as crypto from "https://esm.sh/crypto-js";

const src = Deno.args[0];
const exp = Deno.args[1];
const key = Deno.args[2];

if (!src) {
console.error("%cnot set source text!%c", "color:red;", "");
Deno.exit();
}
if (!exp) {
console.error("%cnot set export target text!%c", "color:red;", "");
Deno.exit();
}

if (!key) {
console.error("%cnot set key text!%c", "color:red;", "");
Deno.exit();
}

let inputFileA = null;

try {
inputFileA = Deno.readFileSync(`${src}.a`);
} catch (e) {
console.error(e);
console.error("%cfile is not exist!%c", "color:red;", "");
Deno.exit();
}

let inputFileB = null;

try {
inputFileB = Deno.readFileSync(`${src}.b`);
} catch (e) {
console.error(e);
console.error("%cfile is not exist!%c", "color:red;", "");
Deno.exit();
}

const encryptedTextArrA = new TextDecoder().decode(inputFileA).split("");
const encryptedTextArrB = new TextDecoder().decode(inputFileB).split("");

const srcTextArr: string[] = [];
const length = encryptedTextArrA.length + encryptedTextArrB.length;

for (let i = 0; i < length; i++) {
if (i % 2 == 0) {
srcTextArr.push(encryptedTextArrA[i / 2]);
} else {
srcTextArr.push(encryptedTextArrB[Math.abs(Math.ceil(i / 2 - 1))]);
}
}

const srcText = srcTextArr.join("");

const decryptedText = crypto.default.AES.decrypt(srcText, key).toString(
crypto.default.enc.Utf8
);

Deno.writeFileSync(`${exp}`, decode(decryptedText));

実行してみます。

1
2
3
4
5
6
$ deno run --allow-write --allow-read --allow-env encrypt_to_files.ts i.jpg password

$ ls i.jpg.*
i.jpg.en.a i.jpg.en.b

$ deno run --allow-write --allow-read --allow-env decrypt_from_files.ts i.jpg.en out.jpg password

2 つのファイルを両方入手しないと復元できなくなりました。
パスワードも含めると 3 者で持ち合うことができますね。


JavaScript でファイルの暗号化を試みました。
WebCrypt API でできることの注意事項についても。
最後はもうただの趣味ですが面白かったので良かったとしましょう。

ではでは。