🔑

Next.js - SSL/TLSの限界を補うハイブリッド暗号化

2024/09/01に公開
2

ハイブリッド暗号化とは

AES暗号(共通鍵暗号方式)と RSA暗号(公開鍵暗号方式)の両方を組み合わせて使用する暗号化手法で、「プライバシー強化」「SSL/TLSの脆弱性(POODLEHeartbleedなどの)対策」 を実現できる。

わかりやすく言うなら、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(環境変数)をセットする

  1. 以下のコードを、実行してコンソールに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));
  1. .env に以下の通りに書いてください
.env
NEXT_PUBLIC_PACKET_PUBLICKEY=ここに公開鍵(public key)
PACKET_PRIVATE_KEY=ここに秘密鍵(private key)

以上。
コードだらけになってしまった...

Discussion

tak458tak458

HTTPS通信をNext.js上で再実装したという理解で良いのでしょうか?

くもりにくもったクラウドサービスlocalerくもりにくもったクラウドサービスlocaler

「HTTPS通信をNext.js上で再実装した」というよりは、「HTTPS通信の上に追加の暗号化層を実装した」ですかね
例えば、POODLEのようなSSL/TLSプロトコル自体の脆弱性が悪用されてSSL/TLS層が破られても、ハイブリッド暗号化があることで、送信される具体的なデータ(例えば個人情報や機密データ)はAESやRSAによってさらに暗号化されているので、攻撃者による解読が非常に困難になるって感じです