2
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

認証認可Advent Calendar 2024

Day 9

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

Last updated at Posted at 2024-12-13

はじめに

こんにちは、梅雨です。

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

SRP とは?

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

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

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

実装を行う

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

ディレクトリ構造

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

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

クライアントの環境構築

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

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

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

サーバの環境構築

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

server
├── package-lock.json
├── package.json
├── src
│   └── index.ts
└── tsconfig.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"
  }
}
tsconfig.json
{
  "compilerOptions": {
    "target": "es2016",
    "module": "Node16",
    "esModuleInterop": true,
    "forceConsistentCasingInFileNames": true,
    "strict": true,
    "skipLibCheck": true,
    "outDir": "./dist",
    "moduleResolution": "node16"
  }
}
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: ソルトとパスワードから生成されるハッシュ値
$ 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 内部の構造は以下のようにしました。

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

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

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"
  }
}
tsconfig.json
{
  "compilerOptions": {
    "target": "es2016",
    "module": "commonjs",
    "esModuleInterop": true,
    "forceConsistentCasingInFileNames": true,
    "strict": true,
    "skipLibCheck": true,
    "outDir": "./dist"
  }
}

サインアップの実装

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

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

$ npm i big-integer

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

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 で定められている値となります。

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

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() 関数は以下のようになります。

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 パッケージを使用します。

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

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

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 をデータベースに保存します。

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 を使います。

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;

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

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;

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

続き

2
0
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
2
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?