1
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

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

Last updated at Posted at 2024-08-31

間違ってる情報等あれば、コメントにお願いします。
この投稿は、zennに投稿したものと同じものです

ハイブリッド暗号化とは

AES暗号(共通鍵暗号方式)と RSA暗号(公開鍵暗号方式)の両方を組み合わせて使用する暗号化手法です。

AES暗号化(Advanced Encryption Standard)

同じ鍵(共通鍵)を使ってデータの暗号化と復号を行う。

  • メリット
    この方法は、暗号化と復号が高速であるため、大量のデータを効率的に処理するのに適している。

  • デメリット
    鍵の共有に関するセキュリティリスクが伴う。
    (鍵が漏洩すると、暗号化されたデータも容易に解読されてしまう。)

RSA暗号化(Rivest-Shamir-Adleman)

公開鍵暗号方式では、2つの鍵(公開鍵と秘密鍵)を使用する。
公開鍵は、公開可能で暗号化のみ可能。一方、秘密鍵はその所有者のみが保持し、暗号化・復号化の両方が可能。

  • メリット
    AES暗号と違い、秘密鍵無しでは解読できないと言われている (いつかは破られる)
    鍵の配送や管理の問題を解決できる
    (例としては、「ブラウザ側で公開鍵を使い暗号化」・「サーバー側で秘密鍵で復号化」という感じで、サーバー上でも暗号化されたままの状態を保つことができ、エンドツーエンドの暗号化を実現できる)

  • デメリット
    暗号化と復号の速度が比較的遅いため、データ量が多い場合には非効率

詳しくはわからないけど、データ量で 190B を超えるものを暗号化しようとすると「OperationError: The operation failed for an operation-specific reason」が発生するっぽい

  • アルファベットのみ: 190文字以下
  • 日本語(マルチバイト文字)のみ: 63文字以下

そして、この2種類の暗号化方式を使うことで、RSAの高い安全性とAESの高いパフォーマンスを組み合わせた効果的な暗号化を実現できる

これにより、「プライバシー強化」「SSL/TLSの脆弱性(POODLEHeartbleedなどの)対策」 を実現できる

わかりやすく言うなら、SSL/TLS層が破られた場合でも、送信データは別の暗号化レイヤーで保護されているため、データ漏洩のリスクを軽減できる

ハイブリッド暗号化を図にするなら、

ってことです。

導入

Next.jsで使うのが前提なので、そこら辺ご了承ください
(ファイルのパスなど修正すれば、Next.jsに限らず使えます)

Node.js v19以上のバージョンを前提としています。
※Web Crypto APIを使用しているため: 互換性表 | Web Crypto API - MDN

  • クライアントサイド関数
/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;
}

使い方

  • 「Server Actions」で使うことを前提としています
  • 前回のCloudflare Turnstileとコードと統合しようとすると、複雑化するので注意(Github Gistにでも、統合版を公開する予定)
  • ページ
/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)

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

1
1
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
1
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?