ECDSA の計算をライブラリなしで実装する

はじめに

前回の記事では、Web Crypto API の crypto.subtle を使って ECDSA の署名と検証を体験した。API を呼ぶだけで署名・検証ができたが、内部で何を計算しているかはブラックボックスのままだった。

この記事では、暗号ライブラリを一切使わず、JavaScript の BigInt だけで ECDSA の署名・検証を実装する。全ステップの中間値を出力し、何が起きているかを確認する。

なお、この記事のコードと解説は AI(Claude)との対話を通じて作成した。小さな曲線を使って全値を 2 桁に収めるアイデアや、中間値を表示して計算過程を追えるようにする構成も、AI との議論の中で生まれたものである。


方針: 小さな曲線で試す

本物の ECDSA(P-256)では 256bit の巨大な数を扱うが、アルゴリズムは曲線のサイズに依存しない。ここでは y² = x³ + x + 4 (mod 97) という小さな曲線を使い、すべての値が 2 桁の整数に収まるようにした。

この曲線では有限体の素数 p = 97 と群の位数 n = 89 が異なる。本物の P-256 でも p ≠ n であり、署名式の mod n と点の座標演算の mod p は別の値になる。

const p = 97n;  // 曲線の法(有限体の素数)
const a = 1n;   // 曲線パラメータ a
const b = 4n;   // 曲線パラメータ b
// 曲線: y² = x³ + ax + b (mod p)
// 群の位数 n = 89(p とは異なる)

ECDSA の 3 層構造

ECDSA の署名・検証は、以下の 3 層で構成されている。

内容実装する関数
1. モジュラー演算mod 演算、モジュラー逆元mod, modInverse
2. 楕円曲線の点の演算点の加算、スカラー倍算pointAdd, scalarMul
3. ECDSA プロトコル鍵生成、署名、検証上記の組み合わせ

これらを下から順に実装していく。


第 1 層: モジュラー演算

ECDSA の全計算は「mod n の世界」で行われる。必要な演算は 2 つ: mod 演算と、モジュラー逆元である。

mod 関数

JavaScript の % は剰余演算子であり、負の数に対して負の結果を返す(例: -1n % 3n-1n)。((a % m) + m) % m とすることで、結果を常に [0, m) の範囲に収める。

const mod = (a, m) => ((a % m) + m) % m;

モジュラー逆元

モジュラー逆元 k⁻¹ は「k⁻¹ × k ≡ 1 (mod m) を満たす整数」で、通常の割り算の代わりに使う。拡張ユークリッド互除法で求められる。

const modInverse = (a, m) => {
  a = mod(a, m);
  let [old_r, r] = [a, m];
  let [old_s, s] = [1n, 0n];
  while (r !== 0n) {
    const q = old_r / r;
    [old_r, r] = [r, old_r - q * r];
    [old_s, s] = [s, old_s - q * s];
  }
  if (old_r !== 1n) throw new Error(`No inverse: gcd(${a}, ${m}) = ${old_r}`);
  return mod(old_s, m);
};

例えば modInverse(42n, 89n)53n を返す。42 × 53 = 2226 = 89 × 25 + 1 なので、確かに 42 × 53 mod 89 = 1 である。


第 2 層: 楕円曲線上の点の演算

楕円曲線上の「点の加算」は通常の足し算とは異なる幾何学的な操作である。無限遠点(単位元)の扱いも必要になる。

const INFINITY = { x: null, y: null };
const isInfinity = (P) => P.x === null && P.y === null;

点の加算 P + Q

2 点を通る直線が曲線と交わる第 3 の点を x 軸で反転する。P = Q の場合は接線を使う(点の 2 倍)。

const pointAdd = (P, Q, a, p) => {
  if (isInfinity(P)) return Q;
  if (isInfinity(Q)) return P;
  if (P.x === Q.x && mod(P.y + Q.y, p) === 0n) return INFINITY;

  let lam;
  if (P.x === Q.x && P.y === Q.y) {
    // 点の2倍: λ = (3x² + a) / (2y)
    lam = mod((3n * P.x * P.x + a) * modInverse(2n * P.y, p), p);
  } else {
    // 異なる2点の加算: λ = (y₂ - y₁) / (x₂ - x₁)
    lam = mod((Q.y - P.y) * modInverse(Q.x - P.x, p), p);
  }
  const xr = mod(lam * lam - P.x - Q.x, p);
  const yr = mod(lam * (P.x - xr) - P.y, p);
  return { x: xr, y: yr };
};

スカラー倍算 k · P

G を k 回足し合わせる操作。愚直に k 回ループすると遅いので、ダブル&アド法を使う。k を 2 進数で見て、各ビットに対応する 2^i · G を足し合わせる。

const scalarMul = (k, P, a, p, n) => {
  let result = INFINITY;
  let addend = { ...P };
  k = mod(k, n);
  while (k > 0n) {
    if (k & 1n) result = pointAdd(result, addend, a, p);
    addend = pointAdd(addend, addend, a, p);
    k >>= 1n;
  }
  return result;
};

補助関数

ベースポイント G の位数(n · G = ∞ となる最小の n)を求める関数と、SHA-256 の代わりに使う簡易ハッシュ関数:

const findOrder = (G, a, p) => {
  let P = { ...G };
  for (let i = 2n; i < p * 4n; i++) {
    P = pointAdd(P, G, a, p);
    if (isInfinity(P)) return i;
  }
  return null;
};

const toyHash = (msg, n) => {
  let h = 0n;
  for (let i = 0; i < msg.length; i++) {
    h = mod(h * 31n + BigInt(msg.charCodeAt(i)), n);
  }
  return h === 0n ? 1n : h;
};

曲線パラメータとベースポイント G

曲線上の点を見つける

x に 0, 1, 2, ... を順に代入して x³ + ax + b mod p を計算し、その値が になる y が存在するか調べる。

x = 0:  x³+x+4 mod 97 =  4  →  y² ≡  4 (mod 97)  →  y = 2, 95   →  (0,2), (0,95)
x = 1:  x³+x+4 mod 97 =  6  →  y² ≡  6 (mod 97)  →  y = 43, 54  →  (1,43), (1,54)
x = 2:  x³+x+4 mod 97 = 14  →  y² ≡ 14 (mod 97)  →  平方根なし(点が存在しない)
x = 3:  x³+x+4 mod 97 = 34  →  y² ≡ 34 (mod 97)  →  平方根なし(点が存在しない)
x = 4:  x³+x+4 mod 97 = 72  →  y² ≡ 72 (mod 97)  →  y = 13, 84  →  (4,13), (4,84)

見つかるときは常に y と p−y のペア(2 + 95 = 97 = p)で、x 軸に対して対称な 2 点になる。この曲線には全部で 88 個の点がある(+ 無限遠点 ∞)。

位数が素数の点を選ぶ

ECDSA では G の位数(n · G = ∞ となる最小の n)が素数である必要がある。

const isPrime = (n) => {
  if (n < 2n) return false;
  for (let d = 2n; d * d <= n; d++) {
    if (n % d === 0n) return false;
  }
  return true;
};

// 曲線上の点を探索し、位数が素数の点を見つける
for (let x = 0n; x < p; x++) {
  const rhs = mod(x * x * x + a * x + b, p);
  for (let y = 0n; y < p; y++) {
    if (mod(y * y, p) !== rhs) continue;
    const candidate = { x, y };
    const order = findOrder(candidate, a, p);
    if (isPrime(order)) {
      console.log(`G = (${x}, ${y}), 位数 n = ${order}`);
      break;
    }
  }
}

群の位数が 89(素数)なので、全 88 点の位数がすべて 89 であり、どの点を G に選んでもよい。今回は G = (0, 2) を採用した。

本物の P-256 では?

NIST が曲線パラメータ (p, a, b) と G の座標をセットで仕様書に規定している。探索は曲線の設計時に一度だけ行えばよく、各実装は仕様に従って G を決め打ちで使う。

const p = 97n;
const a = 1n;
const b = 4n;
const G = { x: 0n, y: 2n }; // 仕様として固定

G が曲線上にあることの検算: 2² mod 97 = 4、0³ + 1·0 + 4 mod 97 = 4。一致する。


第 3 層: ECDSA プロトコル

鍵生成

秘密鍵 d を選び、公開鍵 Q = d · G を計算する。d は 1 から n-1 の範囲(この曲線では 1〜88)なら何でもよい。

const n = findOrder(G, a, p); // n = 89
const d = 23n;                // 秘密鍵 (1 ≤ d ≤ n-1)
const Q = scalarMul(d, G, a, p, n); // 公開鍵 Q = d · G
console.log(`秘密鍵 d = ${d}`);
console.log(`公開鍵 Q = (${Q.x}, ${Q.y})`);
秘密鍵 d = 23
公開鍵 Q = (63, 57)

d の値を変えると Q も変わる:

d = 22  →  Q = (96, 83)
d = 23  →  Q = (63, 57)   ← 今回のデモ
d = 24  →  Q = (56, 3)
d = 25  →  Q = (38, 77)

公開鍵 Q = (63, 57) から秘密鍵 d = 23 を逆算するのは、楕円曲線離散対数問題(ECDLP)であり、曲線が大きくなると計算量的に困難になる。

署名

秘密鍵 d でメッセージに署名して (r, s) のペアを生成する。

const message = "Hello, ECDSA!";
const h = toyHash(message, n);   // h = 85
const k = 42n;                   // 一時鍵(本番では署名ごとに暗号論的乱数を生成)

const R = scalarMul(k, G, a, p, n); // R = (87, 62)
const r = mod(R.x, n);           // r = 87
const kInv = modInverse(k, n);   // k⁻¹ = 53
const s = mod(kInv * (h + r * d), n); // s = 20

console.log(`h = ${h}`);
console.log(`R = (${R.x}, ${R.y})`);
console.log(`k⁻¹ = ${kInv}  (検算: ${k} × ${kInv} mod ${n} = ${mod(k * kInv, n)})`);
console.log(`s = ${kInv} × (${h} + ${r} × ${d}) mod ${n} = ${s}`);
console.log(`署名 (r, s) = (${r}, ${s})`);
h = 85
R = (87, 62)
k⁻¹ = 53  (検算: 42 × 53 mod 89 = 1)
s = 53 × (85 + 87 × 23) mod 89 = 20
署名 (r, s) = (87, 20)

注意: 一時鍵 k は署名のたびに新しい乱数を生成しなければならない。同じ k を異なる署名で使い回すと、2 つの署名から連立方程式を解いて秘密鍵 d を算出できてしまう。

検証

公開鍵 Q と署名 (r, s) だけで、メッセージの正当性を検証する。秘密鍵 d は不要。

const sInv = modInverse(s, n);          // s⁻¹ = 49
const u1 = mod(h * sInv, n);           // u1 = 71
const u2 = mod(r * sInv, n);           // u2 = 80

const P1 = scalarMul(u1, G, a, p, n);     // P1 = (45, 24)
const P2 = scalarMul(u2, Q, a, p, n);     // P2 = (31, 85)
const Rp = pointAdd(P1, P2, a, p);     // R' = (87, 62)

const valid = !isInfinity(Rp) && mod(Rp.x, n) === r;
console.log(`R' = (${Rp.x}, ${Rp.y})`);
console.log(`R'.x mod n = ${mod(Rp.x, n)},  r = ${r}`);
console.log(`検証結果: ${valid}`);
R' = (87, 62)
R'.x mod n = 87,  r = 87
検証結果: true

R' の x 座標が r と一致した。署名は正当である。

改ざん検知

メッセージを 1 文字変えて(!?)同じ署名で検証すると、失敗する。

const tampered = "Hello, ECDSA?";
const hBad = toyHash(tampered, n);        // h' = 26 (元の h = 85)

const u1b = mod(hBad * sInv, n);          // u1' = 28
const P1b = scalarMul(u1b, G, a, p, n);
const P2b = scalarMul(u2, Q, a, p, n);       // u2 は変わらず
const Rpb = pointAdd(P1b, P2b, a, p);     // R'' = (0, 95)

const validBad = !isInfinity(Rpb) && mod(Rpb.x, n) === r;
console.log(`R'' = (${Rpb.x}, ${Rpb.y})`);
console.log(`R''.x mod n = ${mod(Rpb.x, n)},  r = ${r}`);
console.log(`検証結果: ${validBad}`);
R'' = (0, 95)
R''.x mod n = 0,  r = 87
検証結果: false (改ざん検知!)

ハッシュ値が 85 → 26 に変わったことで、復元された点の x 座標が 0 になり、r = 87 と一致しなくなった。


まとめ

実装した関数は 4 つだけである: modInversepointAddscalarMultoyHash。これらの組み合わせで ECDSA の署名・検証が完結する。

ベースポイント G は曲線上の点を探索して位数が素数のものを選ぶことで決まるが、本番では仕様として固定されており、毎回探索するものではない。本物の P-256 との違いは曲線パラメータと数値のサイズ(256bit)だけで、アルゴリズムは全く同じである。

前回は crypto.subtle.sign() / crypto.subtle.verify() の 1 行で済ませていた処理の中身を、今回はすべて手で書いた。ブラックボックスの中で何が起きているかを確認できたと思う。


全体コード

以下のシェルコマンドを実行すると ecdsa-from-scratch.mjs として保存される。ブラウザの場合は cat 行と EOF 行を除いた JavaScript 部分を DevTools の Console に貼り付ける。

環境実行方法
ブラウザDevTools の Console に JavaScript 部分をコピペして実行
Bunbun ecdsa-from-scratch.mjs
Node.js (v19+)node ecdsa-from-scratch.mjs
cat << 'EOF' > ecdsa-from-scratch.mjs
// ============================================================
// ECDSA from Scratch — ライブラリなし・BigInt のみ
// 実行: node ecdsa-from-scratch.mjs / bun ecdsa-from-scratch.mjs
// ============================================================

// --- モジュラー演算 ---
const mod = (a, m) => ((a % m) + m) % m;

const modInverse = (a, m) => {
  a = mod(a, m);
  let [old_r, r] = [a, m];
  let [old_s, s] = [1n, 0n];
  while (r !== 0n) {
    const q = old_r / r;
    [old_r, r] = [r, old_r - q * r];
    [old_s, s] = [s, old_s - q * s];
  }
  if (old_r !== 1n) throw new Error(`No inverse: gcd(${a}, ${m}) = ${old_r}`);
  return mod(old_s, m);
};

// --- 楕円曲線上の点の演算 ---
const INFINITY = { x: null, y: null };
const isInfinity = (P) => P.x === null && P.y === null;

const pointAdd = (P, Q, a, p) => {
  if (isInfinity(P)) return Q;
  if (isInfinity(Q)) return P;
  if (P.x === Q.x && mod(P.y + Q.y, p) === 0n) return INFINITY;
  let lam;
  if (P.x === Q.x && P.y === Q.y) {
    lam = mod((3n * P.x * P.x + a) * modInverse(2n * P.y, p), p);
  } else {
    lam = mod((Q.y - P.y) * modInverse(Q.x - P.x, p), p);
  }
  const xr = mod(lam * lam - P.x - Q.x, p);
  const yr = mod(lam * (P.x - xr) - P.y, p);
  return { x: xr, y: yr };
};

const scalarMul = (k, P, a, p, n) => {
  let result = INFINITY;
  let addend = { ...P };
  k = mod(k, n);
  while (k > 0n) {
    if (k & 1n) result = pointAdd(result, addend, a, p);
    addend = pointAdd(addend, addend, a, p);
    k >>= 1n;
  }
  return result;
};

// --- 補助関数 ---
const findOrder = (G, a, p) => {
  let P = { ...G };
  for (let i = 2n; i < p * 4n; i++) {
    P = pointAdd(P, G, a, p);
    if (isInfinity(P)) return i;
  }
  return null;
};

const toyHash = (msg, n) => {
  let h = 0n;
  for (let i = 0; i < msg.length; i++) {
    h = mod(h * 31n + BigInt(msg.charCodeAt(i)), n);
  }
  return h === 0n ? 1n : h;
};

// --- 曲線パラメータ ---
const p = 97n;
const a = 1n;
const b = 4n;
const G = { x: 0n, y: 2n };
const fmt = (P) => isInfinity(P) ? "∞" : `(${P.x}, ${P.y})`;

// --- 鍵生成 ---
const n = findOrder(G, a, p);
const d = 23n;
const Q = scalarMul(d, G, a, p, n);
console.log(`【鍵生成】`);
console.log(`  秘密鍵 d = ${d}`);
console.log(`  公開鍵 Q = d · G = ${fmt(Q)}`);

// --- 署名 ---
const message = "Hello, ECDSA!";
const h = toyHash(message, n);
const k = 42n;
const R = scalarMul(k, G, a, p, n);
const r = mod(R.x, n);
const kInv = modInverse(k, n);
const s = mod(kInv * (h + r * d), n);
console.log(`\n【署名】`);
console.log(`  メッセージ: "${message}"`);
console.log(`  h = ${h}, k = ${k}`);
console.log(`  R = k · G = ${fmt(R)}`);
console.log(`  r = R.x mod n = ${r}`);
console.log(`  k⁻¹ = ${kInv}  (検算: ${k} × ${kInv} mod ${n} = ${mod(k * kInv, n)})`);
console.log(`  s = k⁻¹ × (h + r × d) mod n = ${s}`);
console.log(`  署名 (r, s) = (${r}, ${s})`);

// --- 検証 ---
const sInv = modInverse(s, n);
const u1 = mod(h * sInv, n);
const u2 = mod(r * sInv, n);
const P1 = scalarMul(u1, G, a, p, n);
const P2 = scalarMul(u2, Q, a, p, n);
const Rp = pointAdd(P1, P2, a, p);
const valid = !isInfinity(Rp) && mod(Rp.x, n) === r;
console.log(`\n【検証】`);
console.log(`  s⁻¹ = ${sInv}, u1 = ${u1}, u2 = ${u2}`);
console.log(`  R' = u1·G + u2·Q = ${fmt(Rp)}`);
console.log(`  R'.x mod n = ${mod(Rp.x, n)},  r = ${r}`);
console.log(`  検証結果: ${valid}`);

// --- 改ざん検知 ---
const tampered = "Hello, ECDSA?";
const hBad = toyHash(tampered, n);
const u1b = mod(hBad * sInv, n);
const P1b = scalarMul(u1b, G, a, p, n);
const P2b = scalarMul(u2, Q, a, p, n);
const Rpb = pointAdd(P1b, P2b, a, p);
const validBad = !isInfinity(Rpb) && mod(Rpb.x, n) === r;
console.log(`\n【改ざん検知】`);
console.log(`  改ざんメッセージ: "${tampered}"`);
console.log(`  h' = ${hBad} (元の h = ${h})`);
console.log(`  R'' = ${fmt(Rpb)}`);
console.log(`  R''.x mod n = ${mod(Rpb.x, n)},  r = ${r}`);
console.log(`  検証結果: ${validBad}`);
EOF