はじめに
こんにちは、梅雨です。
今回は、セキュアなプロトコルである SRP 認証 を行える npm パッケージを TypeScript を用いて実装していきたいと思います。
SRP とは?
SRPとは Secure Remote Password の略で、パスワードをサーバに送信することなく認証を行うことのできるプロトコルです。
このような仕組みは「ゼロ知識証明」と呼ばれており、万が一通信を盗聴されたり中間者攻撃が行われた場合でもパスワードが漏洩することがなく、セキュリティ的に安全であると言えます。
SRP についての詳しい内容は以下の記事が非常に詳しく解説してくれているため、詳細な仕組みが気になる方はぜひご覧ください。
実装を行う
それでは実際に実装を行なっていきましょう。
ディレクトリ構造
ディレクトリは以下のようなモノレポ構造になっています。クライアントは React、サーバは Expressで実装します。
srp-demo
├── client
├── packages
│ └── srp
└── server
クライアントの環境構築
以下を参考にしてください。
追加で、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
{
"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"
}
}
{
"compilerOptions": {
"target": "es2016",
"module": "Node16",
"esModuleInterop": true,
"forceConsistentCasingInFileNames": true,
"strict": true,
"skipLibCheck": true,
"outDir": "./dist",
"moduleResolution": "node16"
}
}
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
としてインポートできるようにモジュールのエクスポートを設定しています。
{
"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"
}
}
{
"compilerOptions": {
"target": "es2016",
"module": "commonjs",
"esModuleInterop": true,
"forceConsistentCasingInFileNames": true,
"strict": true,
"skipLibCheck": true,
"outDir": "./dist"
}
}
サインアップの実装
クライアント用サインアップ関数
SRP では JavaScript で扱える整数の範囲を超える数を扱うため、big-integer というライブラリを使用します。
$ npm i big-integer
固定値は以下のように定義します。
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 文字列からハッシュ値を計算するヘルパーを作成しましょう。
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()
関数は以下のようになります。
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 で扱うための設定も同時にしました。
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 をデータベースに保存します。
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 を使います。
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;
続いてログインページを作ります。この辺りは記事のメインの内容ではないので割愛します。
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;
以上でサインアップの実装が完了しました。
続き