【TypeScript】SRP 認証を行える npm パッケージを実装する（後編）

# はじめに

こんにちは、梅雨です。

この記事は以下の記事の続きとなります。

https://qiita.com/tsuyuni/items/3babaed95dcf3435097e

前半ではサインアップの実装まで行いました。今回は実際にログインして認証を行えるようにするところまで実装してしていきたいと思います。

# 実装を行う

まずは、ログイン機能を実装していきます。

## ログインの実装

### クライアント用ログイン関数（１）

クライアントの `signup()` 関数では、2段階に分けてサーバにリクエストを送信します。

1段階目では鍵のペアを作成し、ユーザ名と公開鍵をサーバに送信します。

```math
A=g^a\bmod N
```

```ts:packages/srp/src/client/signin.ts
import BigInteger from "big-integer";
import { g, N } from "../utils/constants";

type SignInInput = {
  userName: string;
  password: string;
};

export const signIn = async (
  url: string,
  { userName, password }: SignInInput
) => {
  // ランダムな a の値を生成
  const array = new Uint8Array(128);
  window.crypto.getRandomValues(array);
  const randomHex = Array.from(array)
    .map((byte) => byte.toString(16).padStart(2, "0"))
    .join("");
  const aValue = BigInteger(randomHex, 16);

  // A = g^a mod N
  const largeAValue = g.modPow(aValue, N);

  // userName, A をサーバに送信
  const res = await fetch(url, {
    method: "POST",
    headers: {
      "Content-Type": "application/json",
    },
    body: JSON.stringify({
      type: "USER_SRP_AUTH",
      authParameters: {
        USERNAME: userName,
        SRP_A: largeAValue.toString(16),
      },
    }),
  });
};
```

### サーバ用ログイン関数（１）

サーバ側も同様に鍵のペアを作成します。ここで、サーバ側はブラウザ環境ではなく Node.js であるためランダム時の生成には `crypto` を使用します。`crypto` は Node.js の標準モジュールであるためインストールは不要ですが、Node.js の型定義はインストールしておく必要があります。

```bash
$ npm i -D @types/node
```

```ts:packages/srp/src/client/signin.ts
import BigInteger from "big-integer";
import { g, N } from "../utils/constants";
import { getRandomValues } from "crypto";

type SignInInput = { type: "USER_SRP_AUTH" };

export function signIn({ type }: { type: "USER_SRP_AUTH" }): {
  bValue: string;
  SRP_B: string;
};
export function signIn({ type }: SignInInput) {
  switch (type) {
    case "USER_SRP_AUTH":
      // ランダムな b の値を生成
      const array = new Uint8Array(128);
      getRandomValues(array);
      const randomHex = Array.from(array)
        .map((byte) => byte.toString(16).padStart(2, "0"))
        .join("");
      const bValue = BigInteger(randomHex, 16);

      // B = g^b mod N
      const largeBValue = g.modPow(bValue, N);

      return {
        bValue: bValue.toString(16),
        SRP_B: largeBValue.toString(16),
      };
    default:
      return;
  }
}
```

### バックエンド API のエンドポイント実装（１）

エンドポイントでは、リクエストボディに指定されたユーザ名を使ってデータベースからソルトを取得します。そして、`SALT`、`SRP_B`、`USERNAME` を返却します。

```ts:server/src/index.ts
app.post("/signin", (req, res) => {
  const { type } = req.body;

  switch (type) {
    case "USER_SRP_AUTH":
      const { authParameters } = req.body;
      const { USERNAME, SRP_A } = authParameters;

      db.get<{ id: number; name: string; salt: string; verifier: string }>(
        "SELECT * FROM users WHERE name = ?",
        USERNAME,
        (err, { name, salt }) => {
          const { SRP_B } = signIn({ type: "USER_SRP_AUTH" });
          res.send({
            SALT: salt,
            SRP_B,
            USERNAME: name,
          });
        }
      );
  }
});
```

ここで、`SRP_B` は公開鍵ですが、`SRP_B` とペアである秘密鍵（`bValue`）をサーバ側で保存する必要があります。

今回は `express-session` を用いて秘密鍵を保存しようと思います。

credential をクライアントから受け取る必要があるため、CORS の設定で `credentials: true` とすることを忘れないようにしましょう。

```bash
$ npm i express-session
$ npm i -D @types/express-session
```

```diff_typescript
import express from "express";
import cors from "cors";
import sqlite from "sqlite3";
import { signIn } from "srp/server";
+ import session from "express-session";

const app = express();

app.use(
  cors({
    origin: "http://localhost:3000",
+     credentials: true,
  })
);
app.use(express.json());
+ app.use(
+   session({
+     secret: "SECRET_KEY",
+     cookie: {
+       maxAge: 60000,
+     },
+   })
+ );
+
+ declare module "express-session" {
+   interface SessionData {
+     bValue: string;
+   }
+ }

// 略

app.post("/signin", (req, res) => {
  const { type } = req.body;

  switch (type) {
    case "USER_SRP_AUTH":
      const { authParameters } = req.body;
      const { USERNAME, SRP_A } = authParameters;

      db.get<{ id: number; name: string; salt: string; verifier: string }>(
        "SELECT * FROM users WHERE name = ?",
        USERNAME,
        (err, { name, salt }) => {
          const { SRP_B, bValue } = signIn({ type: "USER_SRP_AUTH" });
+           req.session.bValue = bValue;
+           req.session.save();
          res.send({
            SALT: salt,
            SRP_B,
            USERNAME: name,
          });
        }
      );
  }
});
```

続いて、バックエンドでは `SRP_A` と `SRP_B` から共有鍵 `u` を計算する必要があります。この共有鍵 `u` もセッションに保存しておきましょう。

```ts:packages/srp/src/server/signin.ts
import BigInteger from "big-integer";
import { g, N } from "../utils/constants";
import { getRandomValues } from "crypto";
import hexHash from "../utils/hexHash";

type SignInInput = { type: "USER_SRP_AUTH"; SRP_A?: string } & {
  type: "USER_SRP_AUTH";
  SRP_A: string;
};

export async function signIn({
  type,
  SRP_A,
}: {
  type: "USER_SRP_AUTH";
  SRP_A: string;
}): Promise<{
  bValue: string;
  SRP_B: string;
  uValue: string;
}>;
export async function signIn({ type, SRP_A }: SignInInput) {
  switch (type) {
    case "USER_SRP_AUTH":
      const array = new Uint8Array(128);
      getRandomValues(array);
      const randomHex = Array.from(array)
        .map((byte) => byte.toString(16).padStart(2, "0"))
        .join("");
      const bValue = BigInteger(randomHex, 16);

      const largeBValue = g.modPow(bValue, N);

      // SRP_A と SRP_B の値から共有鍵 u を計算
      const uValue = await hexHash(SRP_A + largeBValue.toString(16));

      return {
        bValue: bValue.toString(16),
        SRP_B: largeBValue.toString(16),
        uValue,
      };
    default:
      return;
  }
}
```

```diff_typescript:server/src/index.ts
declare module "express-session" {
  interface SessionData {
    bValue: string;
+     uValue: string;
  }
}

// 略

app.post("/signin", (req, res) => {
  const { type } = req.body;

  switch (type) {
    case "USER_SRP_AUTH":
      const { authParameters } = req.body;
      const { USERNAME, SRP_A } = authParameters;

      db.get<{ id: number; name: string; salt: string; verifier: string }>(
        "SELECT * FROM users WHERE name = ?",
        USERNAME,
-         (err, { name, salt }) => {
-           const { SRP_B, bValue, uValue } = signIn({ type: "USER_SRP_AUTH" });
+         async (err, { name, salt }) => {
+           const { SRP_B, bValue, uValue } = await signIn({
+             type: "USER_SRP_AUTH",
+             SRP_A,
+           });
          req.session.bValue = bValue;
+           req.session.uValue = uValue;
          req.session.save();
          res.send({
            SALT: salt,
            SRP_B,
            USERNAME: name,
          });
        }
      );
  }
});
```

### クライアント用ログイン関数（２）

クライアントも同様にしてサーバから受け取った`SRP_B`、`SALT`、`USERNAME` を用いて共有鍵 `u` を生成します。

また、ユーザー名、パスワード、共有鍵（`u`）、ソルトからセッションキー（`K`）を生成します。

```math
S = (B - kg^x)^{a + ux}\mod n
```

```math
K = H(S)
```

サーバにはユーザ名とセッションキーを送信しましょう。

```diff_typescript
import BigInteger from "big-integer";
import { g, N } from "../utils/constants";
import hexHash from "../utils/hexHash";

type SignInInput = {
  userName: string;
  password: string;
};

export const signIn = async (
  url: string,
  { userName, password }: SignInInput
) => {
  const array = new Uint8Array(128);
  window.crypto.getRandomValues(array);
  const randomHex = Array.from(array)
    .map((byte) => byte.toString(16).padStart(2, "0"))
    .join("");
  const aValue = BigInteger(randomHex, 16);

  const largeAValue = g.modPow(aValue, N);

  const res = await fetch(url, {
    method: "POST",
    credentials: "include",
    headers: {
      "Content-Type": "application/json",
    },
    body: JSON.stringify({
      type: "USER_SRP_AUTH",
      authParameters: {
        USERNAME: userName,
        SRP_A: largeAValue.toString(16),
      },
    }),
  });

+   const { SALT, SRP_B, USERNAME } = await res.json();
+ 
+   const uValue = BigInteger(
+     await hexHash(largeAValue.toString(16) + SRP_B),
+     16
+   );
+ 
+   const usernamePassword = `${USERNAME}:${password}`;
+   const usernamePasswordHash = await hexHash(usernamePassword);
+   const xValue = BigInteger(await hexHash(SALT + usernamePasswordHash), 16);
+ 
+   const largeBValue = BigInteger(SRP_B, 16);
+   const kValue = BigInteger(await hexHash(N.toString(16) + g.toString(16)), 16);
+   const S = largeBValue
+     .subtract(kValue.multiply(g.modPow(xValue, N)))
+     .add(N)
+     .modPow(aValue.add(uValue.multiply(xValue)), N);
+   const K = BigInteger(await hexHash(S.toString(16)));
+
+   await fetch(url, {
+     method: "POST",
+     credentials: "include",
+     headers: {
+       "Content-Type": "application/json",
+     },
+     body: JSON.stringify({
+       type: "PASSWORD_VERIFIER",
+       authParameters: {
+         USERNAME,
+         SESSION_KEY: K.toString(16),
+       },
+     }),
+   });
};
```

### サーバ用ログイン関数（２）

サーバは、検証子（`v`）、ソルト、共有鍵（`u`）、秘密鍵（`b`）からセッションキーを生成します。

また、型解析のために一部の実装を変更しました。

```diff_typescript:packages/srp/src/server/signin.ts
import BigInteger from "big-integer";
import { g, N } from "../utils/constants";
import { getRandomValues } from "crypto";
import hexHash from "../utils/hexHash";

type SignInInput = {
  type: "USER_SRP_AUTH" | "PASSWORD_VERIFIER";
  SRP_A?: string;
  SRP_B?: string;
  salt?: string;
  verifier: string;
  bValue?: string;
} & (
  | {
      type: "USER_SRP_AUTH";
      SRP_A?: undefined;
      SRP_B?: undefined;
      salt?: undefined;
      verifier: string;
      bValue?: undefined;
    }
  | {
      type: "PASSWORD_VERIFIER";
      SRP_A: string;
      SRP_B: string;
      salt: string;
      verifier: string;
      bValue: string;
    }
);

export async function signIn({
  type,
  verifier,
}: {
  type: "USER_SRP_AUTH";
  SRP_A?: undefined;
  salt?: undefined;
  verifier: string;
  bValue?: undefined;
  uValue?: undefined;
}): Promise<{
  bValue: string;
  SRP_B: string;
}>;
export async function signIn({
  type,
  SRP_A,
  SRP_B,
  salt,
  verifier,
  bValue,
}: {
  type: "PASSWORD_VERIFIER";
  SRP_A: string;
  SRP_B: string;
  salt: string;
  verifier: string;
  bValue: string;
}): Promise<{
  sessionKey: string;
}>;
export async function signIn({
  type,
  SRP_A,
  SRP_B,
  salt,
  verifier,
  bValue,
}: SignInInput) {
  switch (type) {
    case "USER_SRP_AUTH": {
      const array = new Uint8Array(128);
      getRandomValues(array);
      const randomHex = Array.from(array)
        .map((byte) => byte.toString(16).padStart(2, "0"))
        .join("");
      const bValue = BigInteger(randomHex, 16);

      const k = BigInteger(await hexHash(N.toString(16) + g.toString(16)), 16);

      const largeBValue = k
        .multiply(BigInteger(verifier, 16))
        .add(g.modPow(bValue, N))
        .mod(N);

      return {
        bValue: bValue.toString(16),
        SRP_B: largeBValue.toString(16),
      };
    }

    case "PASSWORD_VERIFIER": {
      const uValue = BigInteger(await hexHash(SRP_A + SRP_B), 16);

      const s = BigInteger(SRP_A, 16)
        .multiply(BigInteger(verifier, 16).modPow(uValue, N))
        .modPow(BigInteger(bValue, 16), N)
        .mod(N);

      const K = BigInteger(await hexHash(s.toString(16)), 16);

      return {
        sessionKey: K.toString(16),
      };
    }

    default:
      return;
  }
}
```

### バックエンド API のエンドポイント実装（２）

エンドポイントではクライアントから送信されたセッションキーを受け取り、サーバ内部で計算されたセッションキーとの一致を比較します。

```ts:server/src/index.ts
app.post("/signin", (req, res) => {
  const { type } = req.body;

  if (type === "USER_SRP_AUTH") {
    const { authParameters } = req.body;
    const { USERNAME, SRP_A } = authParameters;

    db.get<{ id: number; name: string; salt: string; verifier: string }>(
      "SELECT * FROM users WHERE name = ?",
      USERNAME,
      async (err, { name, salt, verifier }) => {
        const { SRP_B, bValue } = await signIn({
          type: "USER_SRP_AUTH",
          verifier,
        });

        req.session.bValue = bValue;
        req.session.SRP_A = SRP_A;
        req.session.SRP_B = SRP_B;
        req.session.save();

        res.send({
          SALT: salt,
          SRP_B,
          USERNAME: name,
        });
      }
    );
  } else if (type === "PASSWORD_VERIFIER") {
    const { authParameters } = req.body;
    const { USERNAME, SESSION_KEY } = authParameters;

    db.get<{ id: number; name: string; salt: string; verifier: string }>(
      "SELECT * FROM users WHERE name = ?",
      USERNAME,
      async (err, { salt, verifier }) => {
        const { bValue, SRP_A, SRP_B } = req.session;

        const { sessionKey } = await signIn({
          type: "PASSWORD_VERIFIER",
          SRP_A: SRP_A!,
          SRP_B: SRP_B!,
          salt,
          verifier,
          bValue: bValue!,
        });

        req.session.destroy((err) => {
          err && console.log(err);
        });

        sessionKey === SESSION_KEY ? res.sendStatus(200) : res.sendStatus(401);
      }
    );
  }
});
```

これで SRP を行うパッケージの実装ができました。

# おわりに

パッケージとしてまだまだ不十分なところが多いので、時間がある時に改善したいと思います。