🔑
Next.js - SSL/TLSの限界を補うハイブリッド暗号化
ハイブリッド暗号化とは
AES暗号(共通鍵暗号方式)と RSA暗号(公開鍵暗号方式)の両方を組み合わせて使用する暗号化手法で、「プライバシー強化」 や 「SSL/TLSの脆弱性(POODLEやHeartbleedなどの)対策」 を実現できる。
わかりやすく言うなら、SSL/TLS層が破られた場合でも、送信データは別の暗号化レイヤーで保護されているため、データ漏洩のリスクを軽減できる
AES暗号化(Advanced Encryption Standard)
同じ鍵(共通鍵)を使ってデータの暗号化と復号を行う。
-
メリット
この方法は、暗号化と復号が高速であるため、大量のデータを効率的に処理するのに適している。 -
デメリット
鍵の共有に関するセキュリティリスクが伴う。
(鍵が漏洩すると、暗号化されたデータも容易に解読されてしまう。)
RSA暗号化(Rivest-Shamir-Adleman)
公開鍵暗号方式では、2つの鍵(公開鍵と秘密鍵)を使用する。
公開鍵は、公開可能で暗号化のみ可能。一方、秘密鍵はその所有者のみが保持し、暗号化・復号化の両方が可能。
-
メリット
AES暗号と違い、秘密鍵無しでは解読できないと言われている (いつかは破られる)
鍵の配送や管理の問題を解決できる
(例としては、「ブラウザ側で公開鍵を使い暗号化」・「サーバー側で秘密鍵で復号化」という感じで、サーバー上でも暗号化されたままの状態を保つことができ、エンドツーエンドの暗号化を実現できる) -
デメリット
暗号化と復号の速度が比較的遅いため、データ量が多い場合には非効率
そして、この2種類の暗号化方式を使うことで、RSAの高い安全性とAESの高いパフォーマンスを組み合わせた効果的な暗号化を実現できる
ハイブリッド暗号化を図にするなら、
ってことです。
導入
- クライアントサイド関数
/src/lib/crypto/hybrid-client.ts
/**
* Hybird Crypto: Client Side Functions
* - RSA暗号とAES暗号のハイブリッド暗号化
* - クライアントサイド関数
*
* @author nobodylocaler
*/
import { arrayBufferToBase64, base64ToArrayBuffer } from "../transform";
/**
* AES共通鍵
* @returns 共通鍵
*/
const generateSymmetricKey = () => crypto.subtle.generateKey(
{
name: "AES-CBC",
length: 256,
},
true,
["encrypt", "decrypt"],
);
/**
* 公開鍵インポート関数
* @param base64PublicKey 公開鍵(base64)
* @returns 公開鍵(CryptoKey)
*/
const importBase64PublicKey: (base64PublicKey: string) => Promise<CryptoKey> = async (base64PublicKey) => {
return crypto.subtle.importKey(
"spki",
base64ToArrayBuffer(base64PublicKey),
{
name: "RSA-OAEP",
hash: "SHA-512",
},
false,
["encrypt"],
);
};
/**
* 暗号化
* @param data 送信するデータ
* @returns 暗号化したデータと暗号化した共通鍵と初期ベクトル
*/
export const encrypt = async (data: string) => {
const publicKey = await importBase64PublicKey(process.env.NEXT_PUBLIC_PACKET_PUBLICKEY);
const symmetricKey = await generateSymmetricKey();
// 初期化ベクトル (IV) の生成
const iv = crypto.getRandomValues(new Uint8Array(16));
// AESを使ってデータを暗号化
const encodedData = new TextEncoder().encode(data);
const encryptedDataBuffer = await crypto.subtle.encrypt(
{
name: "AES-CBC",
iv: iv,
},
symmetricKey,
encodedData,
);
// 公開鍵を使って共通鍵を暗号化
const symmetricKeyBuffer = await crypto.subtle.exportKey("raw", symmetricKey);
const encryptedSymmetricKeyBuffer = await crypto.subtle.encrypt(
{
name: "RSA-OAEP",
},
publicKey,
symmetricKeyBuffer,
);
return {
encryptedData: arrayBufferToBase64(encryptedDataBuffer),
encryptedSymmetricKey: arrayBufferToBase64(encryptedSymmetricKeyBuffer),
iv: arrayBufferToBase64(iv.buffer),
};
};
- サーバーサイド関数
/src/lib/crypto/hybrid.ts
/**
* Hybird Crypto: Server Side Functions
* - RSA暗号とAES暗号のハイブリッド暗号化
* - サーバーサイド関数
*
* @author nobodylocaler
*/
import { base64ToArrayBuffer } from "../transform";
const importBase64PrivateKey: (base64PrivateKey: string) => Promise<CryptoKey> = async (base64PrivateKey) => {
return crypto.subtle.importKey(
"pkcs8",
base64ToArrayBuffer(base64PrivateKey),
{
name: "RSA-OAEP",
hash: "SHA-512",
},
false,
["decrypt"],
);
};
/**
* 秘密鍵: RSA/SHA-512
*/
const privateKey = await importBase64PrivateKey(process.env.PACKET_PRIVATE_KEY);
/**
* 復号化
* 1. RSA暗号化した共有鍵(AES暗号)を復号化
* 2. 複合した共有鍵(AES暗号)で、復号化
*
* @param encryptedData_ 暗号化されたデータ
* @param encryptedSymmetricKey_ 暗号化された秘密鍵(AES暗号)
* @param iv_ 初期ベクトル
*/
export const decrypt = async (encryptedData_: string, encryptedSymmetricKey_: string, iv_: string) => {
const encryptedData = base64ToArrayBuffer(encryptedData_);
const encryptedSymmetricKey = base64ToArrayBuffer(encryptedSymmetricKey_);
const iv = new Uint8Array(base64ToArrayBuffer(iv_));
const symmetricKeyBuffer = await crypto.subtle.decrypt(
{
name: "RSA-OAEP",
},
privateKey,
encryptedSymmetricKey,
);
/**
* 復号化された秘密鍵
*/
const symmetricKey = await crypto.subtle.importKey("raw", symmetricKeyBuffer, "AES-CBC", true, [
"encrypt",
"decrypt",
]);
// AESを使ってデータを復号化
const decryptedDataBuffer = await crypto.subtle.decrypt(
{
name: "AES-CBC",
iv: iv,
},
symmetricKey,
encryptedData,
);
return new TextDecoder().decode(decryptedDataBuffer);
};
- 文字列とArrayBufferの変換関数
/src/lib/transform.ts
export const base64ToArrayBuffer = (base64: string) => {
const str = atob(base64);
const buf = new ArrayBuffer(str.length);
const bufView = new Uint8Array(buf);
for (let i = 0, strLen = str.length; i < strLen; i++) {
bufView[i] = str.charCodeAt(i);
}
return buf;
};
export function arrayBufferToBase64(buffer: ArrayBufferLike) {
// Uint8Arrayに変換
const uint8Array = new Uint8Array(buffer);
// 各バイトを文字に変換して結合
let binaryString = "";
for (let i = 0; i < uint8Array.length; i++) {
binaryString += String.fromCharCode(uint8Array[i]);
}
// バイナリ文字列をBase64にエンコード
const base64String = btoa(binaryString);
return base64String;
}
使い方
- ページ
/app/login/page.tsx
import { encrypt } from "@/lib/crypto/hybrid-client";
import { useTransition } from "react";
import { Login } from "./actions";
import { toast } from "sonner"; // これについては、https://sonner.emilkowal.ski/ を参照
export default function LoginForm() {
const [isPending, startTransition] = useTransition();
const action = async (formData: FormData) => {
if (isPending) return;
startTransition(async () => {
try {
const input = Object.fromEntries(formData.entries());
const encrypted = await encrypt(JSON.stringify(input));
const result = await Login(encrypted);
if (result?.error) throw new Error(result.error);
} catch(e: unknown) {
if (e instanceof Error) {
toast.error(e.message);
console.error(e);
} else {
toast.error("異常なエラーを検知しました");
}
}
})
}
return (
<form action={Login}>
<input
type="email"
name="email"
autoComplete="email"
/>
<input
type="password"
name="password"
autoComplete="current-password"
/>
<button disabled={isPending} type="submit">
{isPending ? "処理中..." : "ログイン"}
</button>
</form>
)
}
- Server Actions
/app/login/actions.ts
"use server";
import { decrypt } from "@/lib/crypto/hybrid";
interface CryptoDataProp {
encryptedData: string;
encryptedSymmetricKey: string;
iv: string;
}
export const login = async (cryptoDataProp: CryptoDataProp) => {
try {
const { encryptedData, encryptedSymmetricKey, iv } = cryptoDataProp;
const data = JSON.parse(await decrypt(encryptedData, encryptedSymmetricKey, iv));
const { email, password } = data;
// ...ログイン処理...
} catch (e: unknown) {
// ログインできなかった際のエラー
if (e instanceof Error) {
console.error(e);
return {
error: e.message
}
}
}
};
最後に.env(環境変数)をセットする
- 以下のコードを、実行してコンソールにRSA暗号鍵(公開・秘密鍵)両方出るので、コピーしてください
// 非対称鍵ペアの生成
async function generateKeyPair() {
return crypto.subtle.generateKey(
{
name: "RSA-OAEP",
modulusLength: 4096,
publicExponent: new Uint8Array([0x01, 0x00, 0x01]),
hash: "SHA-512"
},
true,
["encrypt", "decrypt"]
);
}
const arrayBufferToBase64 = (buffer) => {
const str = String.fromCharCode.apply(
null,
new Uint8Array(buffer),
)
return btoa(str);
}
const publicKeyToBase64 = async (publicKey) => {
const key = await crypto.subtle.exportKey('spki', publicKey)
return arrayBufferToBase64(key)
}
const privateKeyToBase64 = async (privateKey) => {
const key = await crypto.subtle.exportKey('pkcs8', privateKey);
return arrayBufferToBase64(key);
}
const { privateKey, publicKey } = await generateKeyPair();
console.log("public key: \n", await publicKeyToBase64(publicKey));
console.log("private key: \n", await privateKeyToBase64(privateKey));
-
.env
に以下の通りに書いてください
.env
NEXT_PUBLIC_PACKET_PUBLICKEY=ここに公開鍵(public key)
PACKET_PRIVATE_KEY=ここに秘密鍵(private key)
以上。
コードだらけになってしまった...
Discussion
HTTPS通信をNext.js上で再実装したという理解で良いのでしょうか?
「HTTPS通信をNext.js上で再実装した」というよりは、「HTTPS通信の上に追加の暗号化層を実装した」ですかね
例えば、POODLEのようなSSL/TLSプロトコル自体の脆弱性が悪用されてSSL/TLS層が破られても、ハイブリッド暗号化があることで、送信される具体的なデータ(例えば個人情報や機密データ)はAESやRSAによってさらに暗号化されているので、攻撃者による解読が非常に困難になるって感じです