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

# はじめに

こんにちは、梅雨です。

今回は、セキュアなプロトコルである **SRP 認証** を行える npm パッケージを TypeScript を用いて実装していきたいと思います。

# SRP とは？

SRPとは **Secure Remote Password** の略で、パスワードをサーバに送信することなく認証を行うことのできるプロトコルです。

このような仕組みは「ゼロ知識証明」と呼ばれており、万が一通信を盗聴されたり中間者攻撃が行われた場合でもパスワードが漏洩することがなく、セキュリティ的に安全であると言えます。

SRP についての詳しい内容は以下の記事が非常に詳しく解説してくれているため、詳細な仕組みが気になる方はぜひご覧ください。

https://zenn.dev/caru/articles/ec27e2d1700270

# 実装を行う

それでは実際に実装を行なっていきましょう。

## ディレクトリ構造

ディレクトリは以下のようなモノレポ構造になっています。クライアントは React、サーバは Expressで実装します。

```bash
srp-demo
├── client
├── packages
│   └── srp
└── server
```

## クライアントの環境構築

以下を参考にしてください。

https://qiita.com/tsuyuni/items/d2edef23c169bd79de93

追加で、`tsconfig.json` を以下のように変更しました。


```json:tsconfig.json
{
  "compilerOptions": {
    "target": "es2016",
    "jsx": "react-jsx",
    "module": "ES2022",
    "esModuleInterop": true,
    "forceConsistentCasingInFileNames": true,
    "strict": true,
    "skipLibCheck": true,
    "moduleResolution": "bundler"
  }
}

```

## サーバの環境構築

`/server` 内部の構造は以下のようにしました。データベースには簡単のために **SQLite** を使用しています。

```bash
server
├── package-lock.json
├── package.json
├── src
│   └── index.ts
└── tsconfig.json
```

```json:package.json
{
  "name": "srp-demo",
  "version": "1.0.0",
  "scripts": {
    "build": "tsc",
    "dev": "ts-node src/index.ts",
    "start": "node dist/index.js"
  },
  "dependencies": {
    "express": "^4.21.2",
    "sqlite3": "^5.1.7"
  },
  "devDependencies": {
    "@types/express": "^5.0.0",
    "ts-node": "^10.9.2"
  }
}
```

```json:tsconfig.json
{
  "compilerOptions": {
    "target": "es2016",
    "module": "Node16",
    "esModuleInterop": true,
    "forceConsistentCasingInFileNames": true,
    "strict": true,
    "skipLibCheck": true,
    "outDir": "./dist",
    "moduleResolution": "node16"
  }
}
```

```ts:src/index.ts
import express from "express";

const app = express();

app.get("/", (req, res) => {
  res.send("Hello World!");
});

app.listen(8000, () => {
  console.log("Server is running on port http://localhost:8000");
});
```

## データベースの作成

次に、データベースを作成します。

テーブル構造は以下のようにします。

- id: ユーザID
- name: 名前
- salt: ランダムに生成されるソルト
- verifier: ソルトとパスワードから生成されるハッシュ値

```bash
$ cd server
$ sqlite3
sqlite> .open database.db
sqlite> CREATE TABLE users (id INTEGER PRIMARY KEY, name TEXT, salt TEXT, verifier TEXT);
sqlite> .exit
```

これでデータベースおよび `users` テーブルの作成ができました。

## SRP パッケージの作成

`/packages/srp` 内部の構造は以下のようにしました。

```bash
srp
├── package-lock.json
├── package.json
├── src
│   ├── client
│   │   └── index.ts
│   └── server
│       └── index.ts
└── tsconfig.json
```

パッケージを使用する際に、`srp/client` または `srp/server` としてインポートできるようにモジュールのエクスポートを設定しています。

```json:package.json
{
  "name": "srp",
  "version": "1.0.0",
  "main": "dist/index.js",
  "scripts": {
    "build": "tsc"
  },
  "exports": {
    "./server": "./dist/server/index.js",
    "./client": "./dist/client/index.js"
  },
  "dependencies": {
    "node": "^23.4.0"
  },
  "devDependencies": {
    "@types/node": "^22.10.2"
  }
}
```

```json:tsconfig.json
{
  "compilerOptions": {
    "target": "es2016",
    "module": "commonjs",
    "esModuleInterop": true,
    "forceConsistentCasingInFileNames": true,
    "strict": true,
    "skipLibCheck": true,
    "outDir": "./dist"
  }
}
```

## サインアップの実装

### クライアント用サインアップ関数

SRP では JavaScript で扱える整数の範囲を超える数を扱うため、big-integer というライブラリを使用します。

```bash
$ npm i big-integer
```

固定値は以下のように定義します。

```ts:packages/srp/src/utils/constants.ts
import BigInteger from "big-integer";

export const N = BigInteger(
  "FFFFFFFF" + "FFFFFFFF" + "C90FDAA2" + "2168C234" + "C4C6628B" + "80DC1CD1" +
  "29024E08" + "8A67CC74" + "020BBEA6" + "3B139B22" + "514A0879" + "8E3404DD" +
  "EF9519B3" + "CD3A431B" + "302B0A6D" + "F25F1437" + "4FE1356D" + "6D51C245" +
  "E485B576" + "625E7EC6" + "F44C42E9" + "A637ED6B" + "0BFF5CB6" + "F406B7ED" +
  "EE386BFB" + "5A899FA5" + "AE9F2411" + "7C4B1FE6" + "49286651" + "ECE45B3D" +
  "C2007CB8" + "A163BF05" + "98DA4836" + "1C55D39A" + "69163FA8" + "FD24CF5F" +
  "83655D23" + "DCA3AD96" + "1C62F356" + "208552BB" + "9ED52907" + "7096966D" +
  "670C354E" + "4ABC9804" + "F1746C08" + "CA18217C" + "32905E46" + "2E36CE3B" +
  "E39E772C" + "180E8603" + "9B2783A2" + "EC07A28F" + "B5C55DF0" + "6F4C52C9" +
  "DE2BCBF6" + "95581718" + "3995497C" + "EA956AE5" + "15D22618" + "98FA0510" + 
  "15728E5A" + "8AAAC42D" + "AD33170D" + "04507A33" + "A85521AB" + "DF1CBA64" +
  "ECFB8504" + "58DBEF0A" + "8AEA7157" + "5D060C7D" + "B3970F85" + "A6E1E4C7" +
  "ABF5AE8C" + "DB0933D7" + "1E8C94E0" + "4A25619D" + "CEE3D226" + "1AD2EE6B" +
  "F12FFA06" + "D98A0864" + "D8760273" + "3EC86A64" + "521F2B18" + "177B200C" +
  "BBE11757" + "7A615D6C" + "770988C0" + "BAD946E2" + "08E24FA0" + "74E5AB31" +
  "43DB5BFC" + "E0FD108E" + "4B82D120" + "A93AD2CA" + "FFFFFFFF" + "FFFFFFFF",
  16
);

export const g = BigInteger("2", 16);
```

この N の値は RFC 3526 で定められている値となります。

https://datatracker.ietf.org/doc/html/rfc3526#section-4

また、Hex 文字列からハッシュ値を計算するヘルパーを作成しましょう。

```ts:packages/srp/src/utils/hexHash.ts
const hexHash = async (hexStr: string) => {
  const buffer = new ArrayBuffer(Math.ceil(hexStr.length / 2));
  const view = new DataView(buffer);
  for (let i = 0; i < hexStr.length; i += 2) {
    const byte = parseInt(hexStr.substring(i, i + 3), 16);
    view.setUint8(i / 2, byte);
  }

  const hashBuffer = await crypto.subtle.digest("SHA-256", buffer);

  const hashArray = Array.from(new Uint8Array(hashBuffer));
  const hashHex = hashArray
    .map((byte) => byte.toString(16).padStart(2, "0"))
    .join("");

  return hashHex;
};

export default hexHash;
```

`signup()` 関数は以下のようになります。

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

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

export const signUp = async (
  url: string,
  { userName, password }: SignUpInput
) => {
  // ランダムなソルトを生成
  const array = new Uint8Array(16);
  window.crypto.getRandomValues(array);
  const randomHex = Array.from(array)
    .map((byte) => byte.toString(16).padStart(2, "0"))
    .join("");
  const salt = BigInteger(randomHex, 16);

  // ユーザ名とパスワードからハッシュ値を計算
  const userNamePassword = `${userName}:${password}`;
  const usernamePasswordHash = await hexHash(userNamePassword);

  // ソルトと組み合わせて x の値を計算
  const xValue = BigInteger(await hexHash(salt + usernamePasswordHash), 16);

  // v = g^x mod N
  const verifier = g.modPow(xValue, N);

  // API のエンドポイントに userName, salt, verifier を送信
  await fetch(url, {
    method: "POST",
    headers: {
      "Content-Type": "application/json",
    },
    body: JSON.stringify({
      userName,
      salt: salt.toString(16),
      verifier: verifier.toString(16),
    }),
  });
};
```

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

エンドポイントの実装はシンプルで、受け取った

- `userName`
- `salt`
- `verifier`

をデータベースに保存します。

ここではクライアントからのリクエストを受け付けるために、`cors` パッケージを使用します。

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

以下のようにしてアプリケーションでCORS対応をしましょう。またリクエストボディを json で扱うための設定も同時にしました。

```diff_typescript:server/src/index.ts
import express from "express";
import cors from "cors";

const app = express();

+ app.use(
+   cors({
+     origin: "http://localhost:3000",
+   })
+ );
+ app.use(express.json());

app.get("/", (req, res) => {
  res.send("Hello World!");
});

app.listen(8000, () => {
  console.log("Server is running on port http://localhost:8000");
});
```

サインアップ用のエンドポイントである `/signup` では、受け取った userName, salt, verifier をデータベースに保存します。

```diff_typescript:server/src/index.ts
import express from "express";
import cors from "cors";
+ import sqlite from "sqlite3";

const app = express();

app.use(
  cors({
    origin: "http://localhost:3000",
  })
);
app.use(express.json());

+ const db = new sqlite.Database("database.db");
+
+ app.post("/signup", (req, res) => {
+   const { userName, salt, verifier } = req.body;
+ 
+   db.run("INSERT INTO users (name, salt, verifier) VALUES (?, ?, ?)", [
+     userName,
+     salt,
+     verifier,
+   ]);
+ 
+   res.sendStatus(200);
+ });
+
app.listen(8000, () => {
  console.log("Server is running on port http://localhost:8000");
});

```

### フロントエンド UI の実装

サインアップページを作成します。ルーティングには React Router を使います。

```tsx:client/src/App.tsx
import "./App.css";
import { BrowserRouter, Route, Routes } from "react-router-dom";
import SignUp from "./signup";

const App = () => {
  return (
    <BrowserRouter>
      <Routes>
        <Route path="/signup" element={<SignUp />} />
      </Routes>
    </BrowserRouter>
  );
};

export default App;
```

続いてログインページを作ります。この辺りは記事のメインの内容ではないので割愛します。

```tsx:client/src/signup/index.tsx
import { useState } from "react";
import { useNavigate } from "react-router-dom";
import { signUp } from "srp/client";

const SignUp = () => {
  const navigate = useNavigate();
  const [userName, setUserName] = useState("");
  const [password, setPassword] = useState("");

  return (
    <form
      style={{
        display: "flex",
        flexDirection: "column",
        alignItems: "center",
      }}
      onSubmit={(e) => {
        e.preventDefault();
        signUp("http://localhost:8000/signup", {
          userName,
          password,
        }).then(() => {
          navigate("/");
        });
      }}
    >
      <input
        type="text"
        placeholder="username"
        value={userName}
        onChange={(e) => {
          setUserName(e.target.value);
        }}
      />
      <input
        type="password"
        placeholder="password"
        value={password}
        onChange={(e) => {
          setPassword(e.target.value);
        }}
      />
      <button type="submit">Sign Up</button>
    </form>
  );
};

export default SignUp;
```

以上でサインアップの実装が完了しました。

# 続き

https://qiita.com/tsuyuni/items/9ebf3adaaa05cc6a5910