スタディサプリ Product Team Blog

株式会社リクルートが開発するスタディサプリのプロダクトチームのブログです

k6+TypeScriptでRSA公開鍵を用いたチャレンジレスポンス認証を突破する

こんにちは。24 卒で入社し、7 月から『スタディサプリ for SCHOOL』 の開発に携わっている @nagasho1122 です。 本記事はスタディサプリProduct Team Advent Calendar 2024 21日目の記事になります。

本日は、負荷試験やパフォーマンステストに利用されるk6を用いて、各処理をTypeScriptで記述し、RSA公開鍵を用いたチャレンジレスポンス認証のAPIを呼び出す方法について紹介致します!

k6とは

k6.io

k6は、Grafana Labsがオープンソースとして提供するコードベースの負荷試験ツールです。 テストシナリオはJavaScriptで記述でき、提供されているメソッドはシンプルで直感的です。CLI上で簡単に実行できるだけでなく、プラグインや拡張機能を活用してカスタマイズすることも可能です。

k6の特徴

k6では、各Virtual User(VU)単位で分離された独自のJavaScript Runtimeが実行されます。 これは、Node.jsやブラウザで利用されているV8やJSCとは異なります。 これに伴い、Node.jsが提供するAPI依存のパッケージやwindowオブジェクトのようなブラウザ固有のAPIは動作しないという制約があり、k6に対応したライブラリを使用する必要があります。

TypeScriptを利用する理由

2024年6月にリリースされたk6のv0.52.0以降、TypeScriptが直接実行可能になりました。 APIのリクエストボディが複雑な場合、TypeScriptの型定義が非常に役立つため、私はTypeScriptの使用を選択しています。

k6をTypeScriptで実行する周りの話は「TypeScriptで 負荷テストを書こう 〜k6のシングルバイナリの秘密〜」が分かりやすいです。

やりたいこと

k6を利用して負荷テストを行う際、認証を含むAPIを呼び出すことがあります。 このとき、認証を突破する必要があり、特に認証により各ユーザーを識別するAPIの場合、VUごとに適切な認証処理が求められます。 今回のシナリオでは、VUごとにRSA公開鍵を用いたチャレンジレスポンス認証を突破する必要がありました。

k6での暗号操作

k6では、WebCrypto APIをk6上で実行できるように拡張した独自のWebCrypto APIが利用可能です。 このAPIを用いることで、k6上で暗号化・復号化、デジタル署名の生成・検証、鍵の生成・管理などの暗号操作を行うことができます。

ただし、本APIはまだ実験的なモジュールであり、WebCrypto APIに存在する一部のアルゴリズムや機能が不足している可能性があります。 特に、RSA方式の鍵を利用した処理は、2024年11月のk6のv0.55.0リリースで完全にサポートされました。 これにより、WebAuthnやPasskeyなどの認証フローでRSA方式の鍵を利用するニーズに応えることができ、これらの技術を用いたセキュアな認証プロセスをk6でテストできるようになりました。

k6での基本的な暗号操作手法は「k6での暗号操作」が分かりやすいです。

認証手順

キーペアを生成

公開鍵を用いたチャレンジレスポンス認証では、ユーザーのデバイス側でキーペアを生成する必要があるため、k6側でRSA方式の鍵ペアを生成します。 以下のgenerateKeyPairメソッドでは、crypto.subtle.generateKeyメソッドを使用して、鍵ペアを生成しています。

  • 第一引数には、生成する鍵の種類やハッシュ方式を指定します。
    • nameにはRSASSA-PKCS1-v1_5を指定し、RSA署名方式を選択します。
    • modulusLengthには2048ビットを設定しています。他にも1024ã‚„4096等が使用可能です。
    • publicExponentには、65537の24ビット表現を指定しています。
    • hashにはSHA-256を指定しています。
  • 第二引数は、生成した鍵をエクスポート可能かどうかを示すブール値です。trueを指定することで、後で鍵をエクスポートできるようにしています。
  • 第三引数には、生成した鍵がどのように使用されるかを示す文字列の配列を渡します。ここでは、署名を行うために["sign", "verify"]を指定しています。

各アルゴリズムと第三引数の組み合わせはドキュメントをご確認ください。

import { crypto, CryptoKeyPair } from "k6/experimental/webcrypto";

export async function generateKeyPair(): Promise<CryptoKeyPair> {
  const keyPair = await crypto.subtle.generateKey(
    {
      name: "RSASSA-PKCS1-v1_5",
      modulusLength: 2048,
      publicExponent: new Uint8Array([1, 0, 1]),
      hash: "SHA-256",
    },
    true,
    ["sign", "verify"],
  );

  return keyPair;
}

公開鍵をPEM形式に変換して送信

作成したキーペアの公開鍵は、crypto.subtle.exportKeyメソッドを使用してArrayBufferとしてエクスポートできます。 getPublicKeyPemメソッドにて、このエクスポートされた公開鍵をBase64エンコードし、PEM形式に変換してAPIのリクエストボディに格納します。

import encoding from "k6/encoding";
import { crypto, CryptoKey } from "k6/experimental/webcrypto";

export async function getPublicKeyPem(publicKey: any) {
  const publicKeyBuffer = await exportPublicKey(publicKey);
  const publicKeyBase64 = encoding.b64encode(new Uint8Array(publicKeyBuffer));
  return toPEM(publicKeyBase64, "PUBLIC KEY");
}

async function exportPublicKey(publicKey: CryptoKey): Promise<ArrayBuffer> {
  return await crypto.subtle.exportKey("spki", publicKey) as ArrayBuffer; // spkiを指定しているので、ArrayBufferが返る
}

function toPEM(base64: string, type: string): string {
  const pemHeader = `-----BEGIN ${type}-----\n`;
  const pemFooter = `-----END ${type}-----\n`;
  const pemBody = base64.match(/.{1,64}/g)?.join("\n") || "";
  return pemHeader + pemBody + "\n" + pemFooter;
}

チャレンジより署名を生成

PEM形式の公開鍵をAPIにPOSTすることでチャレンジと呼ばれる乱数が返ってきます。 認証するAPIの仕様上、チャレンジはUTF-8エンコーディングする必要があったため、encodeUTF8Characterメソッドにてエンコードしています。 k6側でcrypto.subtle.signメソッドにて秘密鍵を用いてチャレンジから署名を生成することができます。

  • 第一引数: 生成する鍵の種類やハッシュ方式を指定します。
    • nameにはRSASSA-PKCS1-v1_5を指定し、RSA署名方式を選択します。
    • modulusLengthには2048ビットを設定し、セキュリティの観点から適切な鍵サイズを確保します。
    • publicExponentには、65537の24ビット表現を指定します。
    • hashにはSHA-256を指定し、署名のハッシュアルゴリズムを設定します。
    • github上のサンプルコードに記載されているRSAのアルゴリズムは、2024å¹´12月現在のk6公式の型定義に対応していなかったため、型エラーを無視するために// @ts-ignoreを使用しています。
  • 第二引数: 生成した鍵をエクスポート可能かどうかを示すブール値です。trueを指定することで、後で鍵をエクスポートできるようになります。
  • 第三引数: 生成した鍵がどのように使用されるかを示す文字列の配列を渡します。ここでは、署名を行うために["sign", "verify"]を指定しています。

作成した署名はリクエストヘッダーに改行を含めることが出来ないため、 RFC4648 "Base 64 Encoding with URL and Filename Safe Alphabet" でエンコードしています。

import encoding from "k6/encoding";
import { crypto, CryptoKey } from "k6/experimental/webcrypto";

const SIGN_ALGORITHM = { name: "RSASSA-PKCS1-v1_5", hash: { name: "SHA-256" } };

async function getUrlSafeSignedText(
  privateKey: any,
  text: string,
): Promise<string> {
  const signature = await signText(privateKey, text);
  const signatureBase64 = encoding.b64encode(new Uint8Array(signature));
  return toUrlSafeBase64(signatureBase64);
}

async function signText(
  privateKey: CryptoKey,
  text: string,
): Promise<ArrayBuffer> {
  return await crypto.subtle.sign(
    // @ts-ignore
    SIGN_ALGORITHM,
    privateKey,
    stringToArrayBuffer(text),
  );
}

const stringToArrayBuffer = (str: string): Uint8Array => {
  const utf8 = [];
  for (let i = 0; i < str.length; i++) {
    utf8.push(...encodeUTF8Character(str.charCodeAt(i)));
  }
  return new Uint8Array(utf8);
};

function encodeUTF8Character(code: number): number[] {
  const utf8Char = [];
  if (code < 0x80) {
    utf8Char.push(code);
  } else if (code < 0x800) {
    utf8Char.push((code >> 6) | 0xc0, (code & 0x3f) | 0x80);
  } else if (code < 0x10000) {
    utf8Char.push(
      (code >> 12) | 0xe0,
      ((code >> 6) & 0x3f) | 0x80,
      (code & 0x3f) | 0x80,
    );
  } else if (code < 0x110000) {
    utf8Char.push(
      (code >> 18) | 0xf0,
      ((code >> 12) & 0x3f) | 0x80,
      ((code >> 6) & 0x3f) | 0x80,
      (code & 0x3f) | 0x80,
    );
  }
  return utf8Char;
}

function toUrlSafeBase64(base64: string): string {
  return base64.replace(/\+/g, "-").replace(/\//g, "_");
}

まとめ

最終的に以下のようなコードになりました!

import encoding from "k6/encoding";
import { crypto, CryptoKey, CryptoKeyPair } from "k6/experimental/webcrypto";

const SIGN_ALGORITHM = { name: "RSASSA-PKCS1-v1_5", hash: { name: "SHA-256" } };

export async function generateKeyPair(): Promise<CryptoKeyPair> {
  const keyPair = await crypto.subtle.generateKey(
    {
      name: "RSASSA-PKCS1-v1_5",
      modulusLength: 2024,
      publicExponent: new Uint8Array([1, 0, 1]),
      hash: "SHA-256",
    },
    true,
    ["sign", "verify"],
  );

  return keyPair;
}

export async function getUrlSafeSignedText(
  privateKey: any,
  text: string,
): Promise<string> {
  const signature = await signText(privateKey, text);
  const signatureBase64 = encoding.b64encode(new Uint8Array(signature));
  return toUrlSafeBase64(signatureBase64);
}

async function signText(
  privateKey: CryptoKey,
  text: string,
): Promise<ArrayBuffer> {
  return await crypto.subtle.sign(
    // @ts-ignore
    SIGN_ALGORITHM,
    privateKey,
    stringToArrayBuffer(text),
  );
}

export async function getPublicKeyPem(publicKey: any) {
  const publicKeyBuffer = await exportPublicKey(publicKey);
  const publicKeyBase64 = encoding.b64encode(new Uint8Array(publicKeyBuffer));
  return toPEM(publicKeyBase64, "PUBLIC KEY");
}

async function exportPublicKey(publicKey: CryptoKey): Promise<ArrayBuffer> {
  return await crypto.subtle.exportKey("spki", publicKey) as ArrayBuffer; // spkiを指定しているので、ArrayBufferが返る
}

function toPEM(base64: string, type: string): string {
  const pemHeader = `-----BEGIN ${type}-----\n`;
  const pemFooter = `-----END ${type}-----\n`;
  const pemBody = base64.match(/.{1,64}/g)?.join("\n") || "";
  return pemHeader + pemBody + "\n" + pemFooter;
}

const stringToArrayBuffer = (str: string): Uint8Array => {
  const utf8 = [];
  for (let i = 0; i < str.length; i++) {
    utf8.push(...encodeUTF8Character(str.charCodeAt(i)));
  }
  return new Uint8Array(utf8);
};

function encodeUTF8Character(code: number): number[] {
  const utf8Char = [];
  if (code < 0x80) {
    utf8Char.push(code);
  } else if (code < 0x800) {
    utf8Char.push((code >> 6) | 0xc0, (code & 0x3f) | 0x80);
  } else if (code < 0x10000) {
    utf8Char.push(
      (code >> 12) | 0xe0,
      ((code >> 6) & 0x3f) | 0x80,
      (code & 0x3f) | 0x80,
    );
  } else if (code < 0x110000) {
    utf8Char.push(
      (code >> 18) | 0xf0,
      ((code >> 12) & 0x3f) | 0x80,
      ((code >> 6) & 0x3f) | 0x80,
      (code & 0x3f) | 0x80,
    );
  }
  return utf8Char;
}

function toUrlSafeBase64(base64: string): string {
  return base64.replace(/\+/g, "-").replace(/\//g, "_");
}

各VUごとに、以下の工程を踏むことで、VUごとに異なるユーザーとして負荷試験を行うことができます。

  1. generateKeyPairで鍵を生成
  2. getPublicKeyPemでPEM形式の鍵を取得
  3. チャレンジ・レスポンス認証のAPIにPEM形式の鍵をPOST
  4. APIよりチャレンジが返ってくる
  5. getUrlSafeSignedTextにて、チャレンジを秘密鍵を用いて署名し、URL safeな形式にエンコード
  6. 署名をHeaderに格納してAPIを叩くことで認証突破

最後に

以上、k6とTypeScriptを用いたRSA公開鍵を用いたチャレンジレスポンス認証の実装について紹介しました。 私が本実装をしようとした前日にk6 v0.55.0がリリースされ、RSAへの完全対応がなされたため、非常にタイミングが良かったと感じています!

明日のブログもお楽しみに!