RustではじめるOIDC

暗号の基礎

この章でわかること:

  • ハッシュ関数(SHA-256)の性質と、衝突・原像を探すのにかかる手間
  • MACとHMACが保証すること、比較を定数時間で行う理由(タイミング攻撃)
  • 共通鍵暗号と公開鍵暗号の違い、デジタル署名(ECDSA P-256、RSA、Ed25519)の仕組みと使い方
  • Base64urlとパディングの扱い、推測できない値を作るための乱数(CSPRNG)
  • パスワードハッシュとソルトの役割

暗号の部品が保証すること

ここまでの章で、パスワード、セッションID、認可コード、アクセストークンなど、 「持っていれば使える」値を扱ってきました。後の章では、これらの値を保護するために 暗号の部品を組み合わせます。ログインの結果を伝えるトークン(6章で扱うOpenID ConnectのIDトークン)は、 署名で改ざんを防ぎます(7章)。 PKCEはハッシュ関数で認可コードの横取りを防ぎます(9章)。 DPoPは秘密鍵の所持を証明します(16章)。

暗号の部品は、それぞれ保証する性質が異なります。部品を正しく選ぶには、 何を保証し、何を保証しないかを区別する必要があります。この章で使う性質は次の3つです。

  • 完全性(integrity): データが途中で変更されていないことです
  • 真正性(authenticity): データが、名乗っている送信者によって作られたことです
  • 機密性(confidentiality): データの内容が、許された者以外に読まれないことです
部品 完全性 真正性 機密性 鍵
ハッシュ関数 単独では不可 不可 不可 なし
MAC(HMAC) 可 可(鍵を共有する者の間) 不可 共通鍵
デジタル署名 可 可(誰でも検証できる) 不可 秘密鍵と公開鍵
暗号化 方式による 方式による 可 共通鍵または公開鍵
Base64/Base64url 不可 不可 不可 なし

この表の読み方は、章の各節で実験とともに説明します。特に、署名は機密性を 提供しないこと、Base64urlはどの性質も提供しないことは、後の章で繰り返し問題になります。

ハッシュ関数

ハッシュ関数(hash function)は、任意の長さの入力から、固定の長さの出力を計算する関数です。 暗号に使うハッシュ関数(暗号学的ハッシュ関数)は、出力から入力を推測できないなどの 性質を持つように設計されています。本書でハッシュ関数と書くときは、暗号学的ハッシュ関数を指します。 ハッシュ関数の出力をダイジェスト(digest)またはハッシュ値と呼びます。

本書で使うハッシュ関数はSHA-256です。SHA-256は、米国の標準技術研究所(NIST)が FIPS 180-4で定めるSHA-2系列のハッシュ関数の1つで、任意の長さの入力から 256ビット(32バイト)のダイジェストを計算します。PKCEのS256、JWSのHS256やES256、 JWKのThumbprintなど、OAuthとOpenID Connectのほとんどの箇所でSHA-256が使われています。

暗号学的ハッシュ関数に求められる性質は、主に次の3つです。

  • 原像困難性(preimage resistance): ダイジェストhが与えられたとき、 SHA-256(x) = hとなる入力xを見つけることが計算上困難である性質です。 xを原像(preimage)と呼びます
  • 第二原像困難性(second preimage resistance): 入力xが与えられたとき、 同じダイジェストになる別の入力x'を見つけることが計算上困難である性質です
  • 衝突困難性(collision resistance): 同じダイジェストになる2つの異なる入力の組を 見つけることが計算上困難である性質です。同じダイジェストになる異なる入力の組を 衝突(collision)と呼びます

「計算上困難」とは、現実的な計算資源と時間では見つけられない、という意味です。 出力が256ビットのハッシュ関数で原像を総当たりで探すと、おおよそ2の256乗回の計算が必要です。 衝突は、どの2つでもよいので、おおよそ2の128乗回で見つかります(誕生日のパラドックスと 呼ばれる確率の性質によるもので、出力のビット数の半分が目安になります)。 2の128乗回の計算は、現在の計算機をすべて使っても現実的な時間では終わりません。

ハッシュ関数は鍵を使わないので、誰でも同じ計算ができます。このため、ハッシュ値を データと一緒に送っても改ざんは防げません。攻撃者はデータを書き換えて、 ハッシュ値も計算し直せるからです。表で「単独では不可」としたのはこの意味です。 ハッシュ関数が完全性の確認に役立つのは、ハッシュ値を別の信頼できる経路で受け取った場合です。 PKCEは、認可リクエストでcode_verifierのハッシュ値を先に送っておき、 後でトークンリクエストで元の値を送るという形で、この性質を使います(9章)。

実験: SHA-256の性質を確かめる

SHA-256のテストベクタで実装を確認し、入力の小さな変化が出力に与える影響と、 衝突と原像を探す手間の違いを確かめます。256ビットのままでは探索が終わらないので、 出力を先頭の20ビットに切り詰めた関数で探します。

SHA-256のテストベクタ、出力の変化、衝突と原像の探索
use sha2::{Digest, Sha256};
use std::collections::HashMap;

fn hex(bytes: &[u8]) -> String {
    bytes.iter().map(|b| format!("{b:02x}")).collect()
}

fn sha256(data: &[u8]) -> [u8; 32] {
    Sha256::digest(data).into()
}

// ダイジェストの先頭 bits ビットだけを取り出す (短いハッシュ関数の代わり)
fn truncated(data: &[u8], bits: u32) -> u32 {
    let d = sha256(data);
    u32::from_be_bytes([d[0], d[1], d[2], d[3]]) >> (32 - bits)
}

fn main() {
    println!("[1] SHA-256 のテストベクタ (FIPS 180 の例)");
    let d = sha256(b"abc");
    println!("  SHA-256(\"abc\") = {}", hex(&d));
    let expected = "ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad";
    println!("  期待値と一致: {}", hex(&d) == expected);
    println!("  入力の長さに関係なく 32 バイト: {} バイト", sha256(&[0u8; 100_000]).len());

    println!("\n[2] 入力を1文字変えると、出力のビットの約半分が変わる");
    let a = sha256(b"amount=100");
    let b = sha256(b"amount=900");
    let diff: u32 = a.iter().zip(b.iter()).map(|(x, y)| (x ^ y).count_ones()).sum();
    println!("  SHA-256(\"amount=100\") = {}", hex(&a));
    println!("  SHA-256(\"amount=900\") = {}", hex(&b));
    println!("  256 ビット中 {diff} ビットが異なる");

    let bits = 20;
    println!("\n[3] 出力を {bits} ビットに切り詰めたハッシュで、衝突と原像を探す");

    // 衝突: 同じ出力になる2つの異なる入力を、どれでもよいから見つける
    let mut seen: HashMap<u32, u64> = HashMap::new();
    let mut tries = 0u64;
    let (m1, m2) = loop {
        tries += 1;
        let msg = format!("message-{tries}");
        let h = truncated(msg.as_bytes(), bits);
        if let Some(prev) = seen.insert(h, tries) {
            break (format!("message-{prev}"), msg);
        }
    };
    println!("  衝突: {m1:?} と {m2:?} が同じ値 (試行 {tries} 回)");

    // 原像: 決められた出力になる入力を見つける
    let target = truncated(b"target", bits);
    let mut tries = 0u64;
    let found = loop {
        tries += 1;
        let msg = format!("guess-{tries}");
        if truncated(msg.as_bytes(), bits) == target {
            break msg;
        }
    };
    println!("  原像: {found:?} が目標値 {target:#07x} になる (試行 {tries} 回)");
    println!("  目安: 衝突は 2^{} = {} 回程度、原像は 2^{bits} = {} 回程度", bits / 2, 1u64 << (bits / 2), 1u64 << bits);
}

[1]は、"abc"のダイジェストがFIPS 180の例と一致することを示しています。 暗号の実装を使うときは、このように公開されたテストベクタで結果を確かめます。 100000バイトの入力でも出力は32バイトです。

[2]は、amount=100とamount=900のように1文字だけ違う入力のダイジェストです。 256ビットのうち約半分(この実行では138ビット)が異なり、2つのダイジェストの間に 目で見てわかる関係はありません。入力が似ていても出力から入力の関係を推測できない、 という性質を確かめています。

[3]は、20ビットに切り詰めたハッシュでの探索です。衝突は957回の試行で見つかり、 原像は約207万回の試行で見つかりました。目安はそれぞれ2の10乗(1024)回と2の20乗(約105万)回で、 実測はこの程度の範囲に入っています(原像の探索は確率的なので、目安の2倍程度かかることもあります)。 出力のビット数が同じでも、衝突は原像よりはるかに少ない手間で見つかります。 256ビットに戻すと、衝突は約2の128乗回、原像は約2の256乗回になり、どちらも探索できません。

MACとHMAC

ハッシュ関数だけでは改ざんを防げない問題は、鍵を使うことで解決できます。 MAC(message authentication code、メッセージ認証コード)は、メッセージと秘密の鍵から 短い値(タグ)を計算する仕組みです。同じ鍵を持つ者だけが、正しいタグを計算できます。 送信者はメッセージとタグを送り、受信者は同じ鍵でタグを計算し直して、受け取ったタグと比べます。 一致すれば、メッセージは鍵を持つ者が作ったもので、途中で変更されていないことがわかります。

HMAC(hash-based MAC)は、ハッシュ関数を使ってMACを構成する方式で、RFC 2104が定めています。 SHA-256を使うものをHMAC-SHA256と呼びます。JWSのHS256はHMAC-SHA256による タグを署名の代わりに使う方式です(7章)。

MACで使う鍵のように、送信者と受信者が同じ鍵を共有する方式を共通鍵暗号 (symmetric-key cryptography)と呼びます。共通鍵暗号には、鍵を共有するすべての者が タグを作れるという性質があります。受信者もタグを作れるので、受信者は 「このメッセージは送信者が作った」と第三者に証明することはできません。 また、検証する者が増えるたびに、同じ秘密の鍵を配る相手が増えます。

タイミング攻撃と定数時間比較

MACの検証では、計算したタグと受け取ったタグを比べます。この比較の方法に注意が必要です。

一般的な比較の処理は、先頭から1バイトずつ比べ、異なるバイトが見つかった時点でfalseを返します。 この方法では、先頭の何バイトが一致していたかによって比較にかかる時間が変わります。 攻撃者がタグを1バイトずつ変えながら何度も検証を依頼し、応答時間を測れば、 どのバイトまで正しいかを知ることができます。このように、処理時間の違いから 秘密の情報を推測する攻撃をタイミング攻撃(timing attack)と呼びます。

対策は、入力の内容に関係なく同じ時間で終わる比較を使うことです。これを定数時間比較 (constant-time comparison)と呼びます。定数時間比較は、異なるバイトを見つけても途中で打ち切らず、 すべてのバイトを比べてから結果を返します。RustではsubtleクレートのConstantTimeEqが この比較を提供します。hmacクレートのverify_sliceも、内部で定数時間比較を使います。 コンパイラの最適化によって定数時間の性質が失われないよう、これらのクレートは 最適化を抑制する工夫をしています。比較の関数を自作するより、これらを使うほうが安全です。

ネットワーク越しの時間の測定には揺らぎがありますが、測定を多数回繰り返して 平均を取ることで、小さな差でも検出できる場合があります。秘密の値(タグ、トークン、 パスワードの照合値、PKCEの値など)の比較には、常に定数時間比較を使ってください。

実験: HMACと定数時間比較

RFC 4231のテストベクタでHMAC-SHA256を確認し、改ざんの検出と、比較方法による タイミング攻撃の成否を確かめます。実際の時間の測定は環境の揺らぎが大きいので、 この実験では「比較したバイト数」を攻撃者が観測できる値として扱います。 これは比較にかかる時間に比例する量です。

HMAC-SHA256と、比較方法によるタイミング攻撃の成否
use hmac::{Hmac, KeyInit, Mac};
use sha2::Sha256;
use subtle::ConstantTimeEq;

type HmacSha256 = Hmac<Sha256>;

fn hex(bytes: &[u8]) -> String {
    bytes.iter().map(|b| format!("{b:02x}")).collect()
}

fn hmac_sha256(key: &[u8], msg: &[u8]) -> [u8; 32] {
    let mut mac = HmacSha256::new_from_slice(key).expect("HMAC は任意長の鍵を受け付ける");
    mac.update(msg);
    mac.finalize().into_bytes().into()
}

// 先頭から比べ、最初に異なるバイトで打ち切る比較。比べたバイト数も返す
fn naive_eq(a: &[u8], b: &[u8]) -> (bool, usize) {
    for i in 0..a.len() {
        if a[i] != b[i] {
            return (false, i + 1);
        }
    }
    (true, a.len())
}

// 定数時間比較。常に全バイトを比べる
fn constant_time_eq(a: &[u8], b: &[u8]) -> (bool, usize) {
    (a.ct_eq(b).into(), a.len())
}

// 攻撃者は「比較にかかった手間」(ここではバイト数) だけを観測できるとする。
// 1バイトずつ候補を試し、手間が増えた候補をそのバイトの正解とみなす
fn timing_attack(compare: fn(&[u8], &[u8]) -> (bool, usize), secret_tag: &[u8; 32]) -> (Vec<u8>, u32) {
    let mut guess = [0u8; 32];
    let mut queries = 0;
    for pos in 0..32 {
        let mut best = (0u8, 0usize);
        for candidate in 0..=255u8 {
            guess[pos] = candidate;
            queries += 1;
            let (ok, work) = compare(&guess, secret_tag);
            if ok {
                return (guess.to_vec(), queries);
            }
            if work > best.1 {
                best = (candidate, work);
            }
        }
        guess[pos] = best.0;
    }
    (guess.to_vec(), queries)
}

fn main() {
    println!("[1] RFC 4231 4.3節 (Test Case 2)");
    let tag = hmac_sha256(b"Jefe", b"what do ya want for nothing?");
    let expected = "5bdcc146bf60754e6a042426089575c75a003f089d2739839dec58b964ec3843";
    println!("  HMAC-SHA256 = {}", hex(&tag));
    println!("  期待値と一致: {}", hex(&tag) == expected);

    println!("\n[2] 鍵を知らない者はタグを作れない");
    let key = b"dummy-shared-key-for-book-only!!";
    let msg = b"user=alice&role=member";
    let tag = hmac_sha256(key, msg);
    let tampered = b"user=alice&role=admin";
    println!("  元のメッセージのタグ    : {}", hex(&tag));
    println!("  改ざん後の正しいタグ    : {}", hex(&hmac_sha256(key, tampered)));
    let mut verifier = HmacSha256::new_from_slice(key).unwrap();
    verifier.update(tampered);
    println!("  改ざん後に元のタグで検証: {:?}", verifier.verify_slice(&tag).map_err(|_| "不一致"));

    println!("\n[3] 比較方法とタイミング攻撃 (比較したバイト数を観測できる場合)");
    let (found, queries) = timing_attack(naive_eq, &tag);
    println!("  打ち切り比較  : 復元 {} / 問い合わせ {queries} 回",
        if found == tag { "成功" } else { "失敗" });
    let (found, queries) = timing_attack(constant_time_eq, &tag);
    println!("  定数時間比較  : 復元 {} / 問い合わせ {queries} 回",
        if found == tag { "成功" } else { "失敗" });
    println!("  総当たりに必要な回数: 2^256 回");
}

[1]は、RFC 4231の4.3節(Test Case 2)で、鍵Jefeとメッセージ what do ya want for nothing?のHMAC-SHA256が、RFCの値と一致することを示しています。

[2]は、メッセージのrole=memberをrole=adminに書き換えた場合です。書き換え後のメッセージの 正しいタグは元のタグとまったく異なり、元のタグで検証すると失敗します。攻撃者は鍵を知らないので、 書き換え後のメッセージの正しいタグを計算できません。

[3]は、32バイトのタグを1バイトずつ推測するタイミング攻撃です。打ち切りのある比較では、 各位置で256通りの候補を試し、比較したバイト数が1つ増えた候補をその位置の正解とみなすことで、 8136回の問い合わせでタグ全体を復元しています(最大で32×256=8192回です)。 定数時間比較では、どの候補でも比較したバイト数が32で変わらないので、正解の候補を区別できず、 復元に失敗しています。総当たりなら2の256乗回が必要なところを、打ち切りのある比較では 1万回以下で復元できます。

公開鍵暗号とデジタル署名

共通鍵暗号の制約(検証する者もタグを作れる、鍵を配る相手が増える)を解決するのが 公開鍵暗号(public-key cryptography)です。公開鍵暗号では、数学的に対応する2つの鍵を 組にして使います。この組を鍵ペア(key pair)と呼びます。

  • 秘密鍵(private key): 持ち主だけが保持し、外部に出さない鍵です
  • 公開鍵(public key): 誰に知られてもよい鍵で、秘密鍵から計算されます。 公開鍵から秘密鍵を計算することは計算上困難です

デジタル署名(digital signature)は、公開鍵暗号を使った真正性と完全性の保証です。 署名者は秘密鍵でメッセージの署名を計算し、検証者は公開鍵で署名を検証します。 公開鍵では署名を作れないので、検証者がいくら増えても、署名を作れるのは秘密鍵の持ち主だけです。 OpenID Connectでは、ログインを処理するサーバが、ログインの結果を伝えるトークンに秘密鍵で署名し、 公開鍵を決まったURLで公開します(10章)。トークンを受け取る アプリケーションは誰でもその公開鍵で署名を検証できますが、トークンを偽造することはできません。

本書で扱う署名方式は3つです。

RSAは、大きな数の素因数分解が困難であることに基づく公開鍵暗号の方式で、 1977年に発表されました。鍵の長さは2048ビット以上が使われ、署名の長さは鍵の長さと同じ (2048ビットの鍵なら256バイト)になります。JWSのRS256(RSASSA-PKCS1-v1_5とSHA-256)と PS256(RSASSA-PSSとSHA-256)がRSAを使います。RSASSA-PSSは、乱数を使うパディング方式を用いるRSA署名です。多くの認可サーバが既定の署名方式として使っています。

ECDSA(Elliptic Curve Digital Signature Algorithm)は、楕円曲線(elliptic curve)の上の 演算を使う署名方式です。楕円曲線は、ここではy² = x³ + ax + bの形の式を満たす点の集合と、 その点の間の加算の規則を指し、暗号では有限体の上で定義したものを使います。 同じ安全性の水準をRSAより短い鍵で実現できます。曲線にはいくつかの種類があり、本書で使うのは P-256です。P-256はNISTが定めた256ビットの曲線で、secp256r1とも呼ばれます。 ECDSAとP-256とSHA-256の組み合わせが、JWSのES256です。

Ed25519は、Curve25519という楕円曲線から作られた別の署名方式(EdDSA)の1つで、RFC 8032が定めています。 同じメッセージには常に同じ署名を生成する設計で、署名ごとの乱数に起因する誤りが起きません。 JOSEでは長くEdDSAという名前で使われてきましたが、RFC 9864(2025年10月)が 曲線を名前に含むEd25519という値を定め、EdDSAは非推奨(Deprecated)になりました (23章)。

方式 JWSのalg 公開鍵の長さ 署名の長さ
RSA(2048ビット) RS256、PS256 約256バイト 256バイト
ECDSA P-256 ES256 65バイト(非圧縮形式) 64バイト
Ed25519 Ed25519(旧EdDSA) 32バイト 64バイト

ECDSAには注意点が1つあります。ECDSAは署名のたびに秘密の値(ナンス)を1つ使い、 同じナンスを異なるメッセージの署名に2回使うと、2つの署名から秘密鍵を計算できます。 過去に、この誤りによって秘密鍵が漏洩した製品があります。ナンスを署名ごとに乱数から作る実装と、 RFC 6979の方法で秘密鍵とメッセージから決定的に作る実装があります。この章の実験で使うringの ECDSA実装は、署名ごとに乱数を使います。ナンスの扱いはringのような検証済みのライブラリの実装を使い、 署名の処理を自作しないでください。

RFCを読む: RFC 7518 3.4節

JWSでES256の署名をどのバイト列として表すかは、RFC 7518(JWA、2015年5月)の3.4節が定めています。

  1. Generate a digital signature of the JWS Signing Input using ECDSA P-256 SHA-256 with the desired private key. The output will be the pair (R, S), where R and S are 256-bit unsigned integers.

  2. Turn R and S into octet sequences in big-endian order, with each array being be 32 octets long. The octet sequence representations MUST NOT be shortened to omit any leading zero octets contained in the values.

  3. Concatenate the two octet sequences in the order R and then S. (Note that many ECDSA implementations will directly produce this concatenation as their output.)

  4. The resulting 64-octet sequence is the JWS Signature value.

(RFC 7518 3.4節)

この節は、ECDSA P-256 SHA-256の署名を64バイトのバイト列として表す手順を定めています。 ECDSAの署名は、RとSという2つの256ビットの整数の組です。それぞれをビッグエンディアンで 32バイトのバイト列にし、先頭が0のバイトがあっても省略してはなりません(MUST NOT)。 Rの32バイトの後にSの32バイトを連結した64バイトが、JWSの署名の値になります。

この規定が必要なのは、ECDSAの署名の表現に別の形式があるためです。TLSの証明書などでは、 RとSをASN.1のDERという形式で符号化します。DER形式では長さが可変(おおよそ70〜72バイト)で、 整数の先頭の0の扱いも異なります。JWSでDER形式の署名を送ると、検証は失敗します。 ringでは、64バイトの連結形式をECDSA_P256_SHA256_FIXED_SIGNING、DER形式を ECDSA_P256_SHA256_ASN1_SIGNINGという別の定数で選びます。JWSでは前者を使います。 2つの形式の取り違えが脆弱性につながった例は23章で扱います。

検証側の規定も同じ節にあり、JWSの署名の値は64バイトでなければならず(MUST)、 64バイトでなければ検証は失敗とします。

実験: ringでES256の署名と検証を行う

ringは、暗号の基本的な部品(ハッシュ関数、HMAC、署名、鍵合意、乱数など)を 提供するRustのクレートです。誤用しにくいAPIを設計方針にしていて、例えば読み込んだ鍵ペア (EcdsaKeyPair)から秘密鍵のバイト列を取り出すAPIはありません。鍵の生成時に返る PKCS#8のバイト列には秘密鍵が含まれるので、その保管は利用者の責任です。本書では署名と乱数にring 0.17を使います。

この実験では、P-256の鍵ペアを生成し、メッセージに署名して検証します。続けて、 メッセージの改ざん、署名の1ビットの変更、別の鍵での検証、攻撃者の鍵での署名を試し、 どれも検出されることを確かめます。

ES256の鍵生成、署名、検証と改ざんの検出
use base64::Engine;
use base64::engine::general_purpose::URL_SAFE_NO_PAD;
use ring::rand::SystemRandom;
use ring::signature::{
    ECDSA_P256_SHA256_FIXED, ECDSA_P256_SHA256_FIXED_SIGNING, EcdsaKeyPair, KeyPair,
    UnparsedPublicKey,
};

fn b64(bytes: &[u8]) -> String {
    URL_SAFE_NO_PAD.encode(bytes)
}

fn verify(public_key: &[u8], msg: &[u8], sig: &[u8]) -> &'static str {
    let key = UnparsedPublicKey::new(&ECDSA_P256_SHA256_FIXED, public_key);
    match key.verify(msg, sig) {
        Ok(()) => "成功",
        Err(_) => "失敗",
    }
}

fn main() {
    let rng = SystemRandom::new();

    // 鍵ペアの生成。秘密鍵は PKCS#8 形式のバイト列として得られる
    let pkcs8 = EcdsaKeyPair::generate_pkcs8(&ECDSA_P256_SHA256_FIXED_SIGNING, &rng).unwrap();
    let key_pair =
        EcdsaKeyPair::from_pkcs8(&ECDSA_P256_SHA256_FIXED_SIGNING, pkcs8.as_ref(), &rng).unwrap();
    let public_key = key_pair.public_key().as_ref().to_vec();

    println!("[1] 鍵ペア (出力は実行ごとに変わる)");
    println!("  秘密鍵 (PKCS#8): {} バイト。外部に出さない", pkcs8.as_ref().len());
    println!("  公開鍵: {} バイト (先頭 0x{:02x} + x 32 バイト + y 32 バイト)",
        public_key.len(), public_key[0]);
    println!("    x = {}", b64(&public_key[1..33]));
    println!("    y = {}", b64(&public_key[33..65]));

    let msg = br#"{"sub":"alice","scope":"photos:read"}"#;
    let sig = key_pair.sign(&rng, msg).unwrap();
    let sig = sig.as_ref().to_vec();
    println!("\n[2] 署名");
    println!("  メッセージ: {}", String::from_utf8_lossy(msg));
    println!("  署名: {} バイト (r 32 バイト + s 32 バイト)", sig.len());
    println!("    {}", b64(&sig));

    let sig2 = key_pair.sign(&rng, msg).unwrap();
    println!("  同じメッセージをもう一度署名すると同じ値か: {}", sig2.as_ref() == sig.as_slice());

    println!("\n[3] 公開鍵による検証");
    println!("  元のメッセージと署名        : {}", verify(&public_key, msg, &sig));
    println!("  2回目の署名                 : {}", verify(&public_key, msg, sig2.as_ref()));

    let tampered = br#"{"sub":"alice","scope":"photos:delete"}"#;
    println!("  メッセージを改ざん          : {}", verify(&public_key, tampered, &sig));

    let mut bad_sig = sig.clone();
    bad_sig[10] ^= 0x01;
    println!("  署名の1ビットを反転         : {}", verify(&public_key, msg, &bad_sig));

    let other_pkcs8 = EcdsaKeyPair::generate_pkcs8(&ECDSA_P256_SHA256_FIXED_SIGNING, &rng).unwrap();
    let other = EcdsaKeyPair::from_pkcs8(&ECDSA_P256_SHA256_FIXED_SIGNING, other_pkcs8.as_ref(), &rng).unwrap();
    println!("  別の鍵ペアの公開鍵で検証    : {}", verify(other.public_key().as_ref(), msg, &sig));
    let forged = other.sign(&rng, tampered).unwrap();
    println!("  攻撃者の鍵で改ざん文に署名  : {}", verify(&public_key, tampered, forged.as_ref()));
}

鍵と署名は乱数から作るため、出力は実行ごとに変わります。

[1]は鍵ペアです。秘密鍵はPKCS#8という形式(秘密鍵を格納するための標準の形式で、 アルゴリズムの識別子と鍵の値をASN.1で符号化したもの)のバイト列として得られ、138バイトでした。 ringでは、鍵の生成(generate_pkcs8)と、PKCS#8からの読み込み(from_pkcs8)は別の関数で、 生成した鍵を保存して後で読み込む使い方を想定しています。公開鍵は65バイトで、 先頭の0x04は非圧縮形式を表し、その後に楕円曲線上の点のx座標とy座標が32バイトずつ続きます。 このxとyは、7章で扱うJWKのxとyにそのまま対応します。

[2]は署名です。長さは64バイトで、RFC 7518 3.4節のR(32バイト)とS(32バイト)の連結です。 同じメッセージをもう一度署名すると、異なる値になりました(false)。ringのECDSA実装が署名ごとに 異なる乱数を使うためで、同じメッセージの署名が毎回同じになるEd25519とはこの点が異なります。

[3]は検証の結果です。元のメッセージと署名は、1回目の署名でも2回目の署名でも検証に成功します。 署名の値が違っても、どちらも同じ秘密鍵で作った正しい署名だからです。 メッセージのphotos:readをphotos:deleteに書き換えると失敗し、署名の1ビットを変えても失敗します。 別の鍵ペアの公開鍵では検証できず、攻撃者が自分の秘密鍵で改ざん後のメッセージに署名しても、 正規の公開鍵での検証は失敗します。

この実験から、署名の検証で従うべき点が2つわかります。1つ目は、検証には 正しい公開鍵(署名者のものと確認された公開鍵)を使うことです。攻撃者の公開鍵で検証すれば、 攻撃者の署名は成功します。JWTの攻撃の多くは、検証者に攻撃者の鍵を使わせることを目的にしています (23章)。2つ目は、検証に失敗したメッセージの内容を一切使わないことです。

署名と暗号化の違い

暗号化(encryption)は、データを鍵がなければ読めない形に変換して、機密性を保証する処理です。 公開鍵暗号を使う暗号化では、公開鍵で暗号化し、秘密鍵で復号します。署名とは鍵の使い方が逆です。

署名は暗号化とは別の処理で、署名されたデータは誰でも読めます。署名が保証するのは 「誰が作ったか」と「変更されていないか」であり、「誰が読めるか」は保証しません。 実験の[2]で署名したメッセージ{"sub":"alice","scope":"photos:read"}は、署名の後も平文のままです。

この区別は、IDトークンやJWT形式のアクセストークンを扱うときに重要になります。 署名付きのJWT(JWS)の中身は、Base64urlを復号するだけで誰でも読めます。 JWTに秘密にすべき情報(パスワード、個人情報など)を入れてはなりません。 内容を秘密にする必要がある場合は、暗号化の仕様であるJWEを使います(7章)。

Base64urlとパディング

1章のBasic認証で、Base64を使いました。Base64の64種類の文字には +と/が含まれます。この2文字はURLの中で特別な意味を持ち、3章で見たように、 application/x-www-form-urlencodedでは+が空白として解釈されます。

Base64urlは、Base64の+を-に、/を_に置き換えた変種で、RFC 4648の5節が定めています。 置き換え後の文字はすべてURLの中でそのまま使えます。JOSE(JWS、JWT、JWKなど)とPKCEは、 バイト列を文字列にするときにBase64urlを使います。

Base64は入力の3バイトを4文字に変換します。入力の長さが3の倍数でない場合、 最後の4文字の組を埋めるために=を付けます。この=をパディング(padding)と呼びます。 1バイト余れば==、2バイト余れば=が付きます。JOSEは、パディングを付けない Base64urlを使います。=はURLのクエリで区切りの文字として使われるため、パディングを除いておくと、 値をURLに入れるときに符号化が不要になります。

RFCを読む: RFC 7515 2節と付録C

RFC 7515(JWS、2015年5月)の2節は、JOSEで使うBase64urlを次のように定義しています。

Base64 encoding using the URL- and filename-safe character set defined in Section 5 of RFC 4648 [RFC4648], with all trailing '=' characters omitted (as permitted by Section 3.2) and without the inclusion of any line breaks, whitespace, or other additional characters. Note that the base64url encoding of the empty octet sequence is the empty string. (See Appendix C for notes on implementing base64url encoding without padding.)

(RFC 7515 2節)

この定義は、JOSEのBase64urlを、RFC 4648の5節のURLで安全な文字の集合を使い、 末尾の=をすべて省き、改行や空白などの文字を含めないBase64、と定めています。 空のバイト列のBase64urlは空の文字列です。

付録Cは、パディングのない文字列を復号するときの規則を説明しています。

Specifically, if the length mod 4 is 0, no padding is added; if the length mod 4 is 2, two '=' padding characters are added; if the length mod 4 is 3, one '=' padding character is added; if the length mod 4 is 1, the input is malformed.

(RFC 7515 付録C)

この文は、パディングのない文字列の長さを4で割った余りから、省かれたパディングの数が決まることを 述べています。余りが0ならパディングなし、2なら==、3なら=を補います。さらに-を+に、 _を/に戻せば、標準のBase64として復号できます。 余りが1になることはなく、その場合は入力が不正です。付録Cには例として、 5バイトのバイト列3 236 255 224 193がA-z_4MEになることが示されています。

実験: Base64urlの符号化と復号

付録Cの例を再現し、入力の長さとパディングの関係、標準のBase64をURLに入れた場合の問題、 復号時の検査を確かめます。

Base64urlとパディング、復号の検査
use base64::Engine;
use base64::engine::general_purpose::{STANDARD, URL_SAFE, URL_SAFE_NO_PAD};

// RFC 7515 付録C の手順: 標準の Base64 から '=' を除き、'+' と '/' を置き換える
fn base64url_by_hand(bytes: &[u8]) -> String {
    STANDARD
        .encode(bytes)
        .trim_end_matches('=')
        .replace('+', "-")
        .replace('/', "_")
}

fn main() {
    println!("[1] RFC 7515 付録C の例");
    let bytes = [3u8, 236, 255, 224, 193];
    println!("  入力: {bytes:?}");
    println!("  Base64            : {}", STANDARD.encode(bytes));
    println!("  Base64url (パディングあり): {}", URL_SAFE.encode(bytes));
    println!("  Base64url (パディングなし): {}", URL_SAFE_NO_PAD.encode(bytes));
    println!("  付録C の手順      : {}", base64url_by_hand(&bytes));
    println!("  RFC の値と一致    : {}", URL_SAFE_NO_PAD.encode(bytes) == "A-z_4ME");

    println!("\n[2] 入力の長さとパディング");
    for n in 1..=6 {
        let data = &b"abcdef"[..n];
        let padded = STANDARD.encode(data);
        let unpadded = URL_SAFE_NO_PAD.encode(data);
        println!("  {n} バイト: {padded:<8} → {unpadded:<8} (長さ mod 4 = {})", unpadded.len() % 4);
    }

    println!("\n[3] URL のクエリに入れたとき");
    let q = format!("token={}", STANDARD.encode(bytes));
    let parsed: Vec<(String, String)> = form_urlencoded::parse(q.as_bytes()).into_owned().collect();
    println!("  標準 Base64 をそのまま入れる: {q}");
    println!("  クエリとして解析した値      : {:?} ('+' が空白になる)", parsed[0].1);

    println!("\n[4] 復号の検査");
    let cases = ["A-z_4ME", "A-z_4ME=", "A+z/4ME", "A-z_4M", "A-z_4MEA", "A-z_4MEAB"];
    for s in cases {
        let r = URL_SAFE_NO_PAD.decode(s);
        let shown = match &r {
            Ok(v) => format!("{v:?}"),
            Err(e) => format!("エラー: {e}"),
        };
        println!("  {s:<10} (長さ mod 4 = {}) → {shown}", s.len() % 4);
    }
}

[1]は付録Cの例です。標準のBase64ではA+z/4ME=となり、+と/とパディングを含みます。 Base64urlではA-z_4ME=、パディングを除くとA-z_4MEで、RFCの値と一致します。 付録Cの手順(標準のBase64から=を除き、2文字を置き換える)でも同じ結果になります。

[2]は、1バイトから6バイトの入力の符号化です。パディングを除いた文字列の長さを4で割った余りは 2、3、0のいずれかで、1にはなりません。付録Cの規則のとおりです。

[3]は、標準のBase64の値をそのままクエリに入れた場合です。クエリとして解析すると、 +が空白になり、元の値と異なる文字列になります。Base64urlならこの問題は起きません。

[4]は、Base64url(パディングなし)として復号するときの検査です。正しいA-z_4MEは元の5バイトに戻ります。 パディング付きのA-z_4ME=はエラーになっています。JOSEの値にパディングは付かないので、 この実装はパディングを不正な入力として扱います。+と/を含む文字列も、Base64urlの文字ではないため エラーです。長さの余りが1のA-z_4MEABは、付録Cのとおり不正な長さとしてエラーになっています。

A-z_4Mのエラーは、長さとは別の検査によるものです。長さの余りは2なので4バイトに復号できる長さですが、 最後の文字Mには、復号に使われない下位のビットに0でない値が入っています。 base64クレートは、このような値を不正として扱います。下位のビットを無視して復号する実装では、 異なる文字列が同じバイト列に復号されます。署名やトークンの値を文字列のまま比較する処理があると、 この違いが検査の回避に使われる可能性があります。

乱数とエントロピー

セッションID、state、認可コード、アクセストークン、PKCEのcode_verifierは、どれも 「推測できないこと」が安全性の前提になっている値です。推測できない値を作るには、 適切な乱数が必要です。

乱数生成器(random number generator)は、予測できない数の列を出力する仕組みです。 プログラムの中で使う乱数生成器の多くは、初期値(シード)から決まった計算で数の列を生成する 擬似乱数生成器です。シミュレーションやゲームに使う擬似乱数生成器は、高速で統計的に偏りがないことを 目的に設計されていて、出力の一部から内部の状態を計算できるものがあります。 内部の状態がわかれば、以後の出力はすべて予測できます。

CSPRNG(cryptographically secure pseudo-random number generator、暗号論的擬似乱数生成器)は、 出力をいくら観測しても、次の出力や過去の出力を予測できないように設計された乱数生成器です。 シードには、OSが集めた予測できない情報(ハードウェアの雑音、割り込みのタイミングなど)を使います。

予測できなさの量を表す尺度がエントロピー(entropy)です。本書では、 値が取りうる場合の数を2の何乗かで表したビット数として扱います。128ビットのエントロピーを持つ値は、 2の128乗通りの中から等しい確率で選ばれた値で、1回の推測で当たる確率は2の128乗分の1です。 エントロピーは文字列の長さとは別のものです。0から9999の数をuser-1234のような 9文字の文字列にした場合、1万通りから一様に選んでもエントロピーは約13.3ビットしかありません。 連番で発行していれば、1つの値から次の値を予測できます。

RFCを読む: RFC 6749 10.10節

OAuth 2.0は、推測できない値のエントロピーについて具体的な下限を定めています。

The authorization server MUST prevent attackers from guessing access tokens, authorization codes, refresh tokens, resource owner passwords, and client credentials.

The probability of an attacker guessing generated tokens (and other credentials not intended for handling by end-users) MUST be less than or equal to 2^(-128) and SHOULD be less than or equal to 2^(-160).

(RFC 6749 10.10節)

この節は、認可サーバはアクセストークン、認可コード、リフレッシュトークン、パスワード、 クライアントの資格情報を攻撃者に推測させないようにしなければならない(MUST)と定め、 生成するトークン(利用者が手で扱わない資格情報)を推測で当てられる確率を、 2の-128乗以下にしなければならず(MUST)、2の-160乗以下にすべきである(SHOULD)、と定めています。 これは、トークンを生成する乱数のエントロピーが128ビット以上、望ましくは160ビット以上であることを意味します。 例えばCSPRNGから取り出した32バイト(256ビット)の乱数は、この条件を満たします。 RFC 6749の10.12節は、CSRF対策に使うstateの値にも、この10.10節の意味で推測できないことを求めています。

実験: 予測できる乱数と予測できない乱数

暗号用ではない乱数生成器でstateを作ると何が起きるかを確かめ、CSPRNGの使い方と、 エントロピーと文字列の長さの関係を確認します。

線形合同法の予測とCSPRNG
use base64::Engine;
use base64::engine::general_purpose::URL_SAFE_NO_PAD;
use ring::rand::{SecureRandom, SystemRandom};
use std::time::{SystemTime, UNIX_EPOCH};

// 線形合同法 (暗号用ではない乱数生成器)。内部状態は 64 ビットの整数1つ
fn lcg_next(state: u64) -> u64 {
    state
        .wrapping_mul(6364136223846793005)
        .wrapping_add(1442695040888963407)
}

fn main() {
    println!("[1] 暗号用ではない乱数生成器で「推測不能なはずの値」を作る");
    let seed = SystemTime::now().duration_since(UNIX_EPOCH).unwrap().as_nanos() as u64;
    let s1 = lcg_next(seed);
    let s2 = lcg_next(s1);
    let s3 = lcg_next(s2);
    println!("  サーバが発行した state (1件目): {s1:016x}");
    println!("  サーバが発行した state (2件目): {s2:016x}");
    println!("  サーバが発行した state (3件目): {s3:016x}");

    // 攻撃者は自分宛ての1件目を見ただけで、以後の値をすべて計算できる
    let predicted2 = lcg_next(s1);
    let predicted3 = lcg_next(predicted2);
    println!("  攻撃者が1件目から計算した2件目: {predicted2:016x} (一致: {})", predicted2 == s2);
    println!("  攻撃者が1件目から計算した3件目: {predicted3:016x} (一致: {})", predicted3 == s3);

    println!("\n[2] CSPRNG で同じことを試す (出力は実行ごとに変わる)");
    let mut a = [0u8; 32];
    let mut b = [0u8; 32];
    rand::fill(&mut a);
    rand::fill(&mut b);
    println!("  rand::fill (ThreadRng)  1件目: {}", URL_SAFE_NO_PAD.encode(a));
    println!("  rand::fill (ThreadRng)  2件目: {}", URL_SAFE_NO_PAD.encode(b));

    let sys = SystemRandom::new();
    let mut c = [0u8; 32];
    sys.fill(&mut c).unwrap();
    println!("  ring SystemRandom (OS)       : {}", URL_SAFE_NO_PAD.encode(c));

    println!("\n[3] エントロピーと文字列の長さ");
    for bytes in [16usize, 32] {
        let bits = bytes * 8;
        let encoded_len = URL_SAFE_NO_PAD.encode(vec![0u8; bytes]).len();
        println!("  乱数 {bytes} バイト = {bits} ビット → Base64url で {encoded_len} 文字。推測できる確率 2^-{bits}");
    }
    let weak = "user-1234";
    println!("  参考: {weak:?} のように 0〜9999 から一様に選んでも約 13.3 ビット。連番なら次の値を予測できる");
}

出力は実行ごとに変わります。

[1]は、線形合同法という単純な擬似乱数生成器でstateを3件作った場合です。この生成器は 内部の状態が64ビットの整数1つで、出力がそのまま次の状態になります。攻撃者は、 自分に発行された1件目を見るだけで、2件目と3件目を計算できています(true)。 2件目と3件目が他の利用者に発行されたものなら、攻撃者はそのstateを事前に知っていることになり、 stateによるCSRF対策は機能しません。シードに現在時刻を使っていますが、 時刻はおおよその範囲が推測できるので、シードを隠しても大きな違いはありません。

[2]は、CSPRNGから32バイトの乱数を取り出した例です。rand::fillはrandクレートの スレッドごとの生成器(ThreadRng)を使います。rand 0.10のThreadRngは、OSの乱数でシードした ChaChaという暗号用の生成器で、64KB分の出力ごとにOSの乱数で再シードします。 ringのSystemRandomは、呼び出すたびにOSの乱数をそのまま取り出します。 本書では、どちらも暗号用の乱数として扱います。

[3]は、エントロピーと文字列の長さの関係です。16バイト(128ビット)の乱数をBase64urlにすると22文字、 32バイト(256ビット)なら43文字になります。RFC 6749 10.10節の下限(128ビット)を満たすには 16バイト以上、推奨(160ビット)を満たすには20バイト以上が必要です。PKCEのcode_verifierは 32バイトの乱数をBase64urlにした43文字を使うことが推奨されています(9章)。

パスワードハッシュ

1章で、サーバはパスワードそのものではなく、パスワードから計算した値を 保存すると書きました。この計算にSHA-256のような高速なハッシュ関数をそのまま使うと、2つの問題があります。

1つ目は、同じパスワードが同じ値になることです。保存された値が漏洩したとき、 攻撃者はよく使われるパスワードのハッシュ値の表を事前に作っておき、照合するだけで 多くのパスワードを特定できます。同じ値を持つ利用者が同じパスワードを使っていることもわかります。

2つ目は、計算が速すぎることです。SHA-256は1回の計算が1マイクロ秒よりはるかに短く、 攻撃者はパスワードの候補を毎秒非常に多く試せます。

ソルト(salt)は1つ目の問題への対策です。ソルトは、利用者ごとに生成するランダムな値で、 パスワードと一緒にハッシュ関数に入力し、計算結果と一緒に保存します。ソルトが利用者ごとに異なるので、 同じパスワードでも保存される値が異なり、事前に作った表は使えません。ソルトは秘密の値ではありません。

パスワードハッシュ(password hashing)は2つ目の問題への対策で、パスワードの保存のために、 意図的に計算に時間とメモリがかかるように設計された関数です。正規のサーバはログインのたびに 1回計算するだけですが、攻撃者は候補ごとに計算する必要があるため、総当たりのコストが大きくなります。 代表的な関数は次のとおりです。

  • Argon2: 2015年のPassword Hashing Competitionで選ばれた関数で、RFC 9106が定めています。 計算時間とメモリ使用量を指定でき、メモリを多く使うため専用のハードウェアでの高速化が困難です。 変種のArgon2idが推奨されています
  • scrypt: メモリを多く使う設計の関数で、RFC 7914が定めています
  • bcrypt: 1999年から使われている関数です。入力が72バイトまでに制限されます
  • PBKDF2: HMACを指定した回数だけ繰り返す関数で、RFC 8018が定めています。 メモリ使用量が小さいため、並列計算用のハードウェア(GPUなど)による総当たりへの耐性を 反復回数以外の手段で高めにくい方式です

OWASP(Open Worldwide Application Security Project)は、Webアプリケーションのセキュリティに関する資料やチェックリストを公開している非営利の団体です。 OWASPのPassword Storage Cheat Sheet(2026年10月16日確認)は、Argon2idを第一に挙げ、 メモリ19MiB、反復2回、並列度1を最低限の設定として示しています。PBKDF2-HMAC-SHA256の場合は 60万回の反復を示しています。

実験: ソルト付きのパスワードハッシュ

ringはArgon2を提供していないので、この実験ではringのPBKDF2(HMAC-SHA256)を使います。 ソルトの効果と、計算時間の違いを確かめます。実際のアプリケーションではArgon2idの実装 (argon2クレートなど)を使ってください。

ソルトなしのSHA-256とソルト付きPBKDF2
use base64::Engine;
use base64::engine::general_purpose::URL_SAFE_NO_PAD;
use ring::pbkdf2;
use ring::rand::{SecureRandom, SystemRandom};
use sha2::{Digest, Sha256};
use std::num::NonZeroU32;
use std::time::Instant;

// 保存するレコード: ソルトと反復回数と導出結果
struct Stored {
    salt: [u8; 16],
    iterations: NonZeroU32,
    hash: [u8; 32],
}

fn register(password: &str, iterations: u32, rng: &SystemRandom) -> Stored {
    let mut salt = [0u8; 16];
    rng.fill(&mut salt).unwrap();
    let iterations = NonZeroU32::new(iterations).unwrap();
    let mut hash = [0u8; 32];
    pbkdf2::derive(pbkdf2::PBKDF2_HMAC_SHA256, iterations, &salt, password.as_bytes(), &mut hash);
    Stored { salt, iterations, hash }
}

fn login(stored: &Stored, password: &str) -> bool {
    // verify は内部で定数時間比較を行う
    pbkdf2::verify(
        pbkdf2::PBKDF2_HMAC_SHA256,
        stored.iterations,
        &stored.salt,
        password.as_bytes(),
        &stored.hash,
    )
    .is_ok()
}

fn main() {
    let rng = SystemRandom::new();

    println!("[1] ソルトなしの SHA-256: 同じパスワードは同じ値になる");
    for user in ["alice", "bob"] {
        let h = Sha256::digest(b"dummy-password");
        println!("  {user}: {}", URL_SAFE_NO_PAD.encode(h));
    }

    println!("\n[2] ソルト付き PBKDF2: 同じパスワードでも値が異なる (出力は実行ごとに変わる)");
    let alice = register("dummy-password", 600_000, &rng);
    let bob = register("dummy-password", 600_000, &rng);
    for (user, s) in [("alice", &alice), ("bob", &bob)] {
        println!("  {user}: salt={} hash={}", URL_SAFE_NO_PAD.encode(s.salt), URL_SAFE_NO_PAD.encode(s.hash));
    }

    println!("\n[3] 照合");
    println!("  正しいパスワード  : {}", login(&alice, "dummy-password"));
    println!("  誤ったパスワード  : {}", login(&alice, "dummy-passwore"));

    println!("\n[4] 1回の計算にかかる時間 (環境によって変わる)");
    let start = Instant::now();
    for _ in 0..1000 {
        std::hint::black_box(Sha256::digest(b"dummy-password"));
    }
    let sha_us = start.elapsed().as_secs_f64() * 1e6 / 1000.0;
    let start = Instant::now();
    login(&alice, "dummy-password");
    let pbkdf2_ms = start.elapsed().as_secs_f64() * 1e3;
    println!("  SHA-256 1回           : 約 {sha_us:.2} マイクロ秒");
    println!("  PBKDF2 60万回反復 1回 : 約 {pbkdf2_ms:.0} ミリ秒");
    println!("  比: 約 {:.0} 倍", pbkdf2_ms * 1000.0 / sha_us);
}

ソルトは乱数から作り、計算時間は環境によって変わるため、出力は実行ごとに変わります。

[1]は、ソルトなしのSHA-256で、aliceとbobが同じパスワードを使った場合です。保存される値が同じになり、 保存値を見るだけで2人のパスワードが同じだとわかります。

[2]は、ソルト付きのPBKDF2(60万回の反復)です。同じパスワードでも、ソルトが異なるので 保存される値が異なります。

[3]は照合です。pbkdf2::verifyは、保存されたソルトと反復回数で計算し直して、 保存値と定数時間で比較します。最後の1文字だけが異なるパスワードも拒否されます。

[4]は、1回の計算にかかる時間です。手元のMac(Apple M5 Pro、releaseビルド)での筆者の実測では、 SHA-256の1回が約0.03〜0.04マイクロ秒、PBKDF2の60万回の反復が約56〜59ミリ秒でした (複数回の実行での範囲です)。ログインのたびに60ミリ秒程度かかることは利用者にとって問題になりませんが、攻撃者が 候補1つを試すコストは、SHA-256の100万倍以上になります。

後続の章との対応

この章の部品は、後の章で次のように使われます。

部品 使われる箇所 章
SHA-256 PKCEのS256、at_hash、JWK Thumbprint 7章、8章、9章
HMAC-SHA256 JWSのHS256、client_secret_jwt 7章、13章
ECDSA P-256 IDトークンのES256署名、DPoP証明、private_key_jwt 7章、13章、16章
定数時間比較 PKCEの検証、stateの照合、トークンの照合 9章
Base64url JWSの各部分、PKCEの値、JWKの鍵の値 7章、9章
CSPRNG state、nonce、code_verifier、認可コード、トークン 9章、12章

まとめ

  • ハッシュ関数(SHA-256)は任意の入力から32バイトのダイジェストを計算します。原像困難性と 衝突困難性を持ちますが、鍵がないので単独では改ざんを防げません
  • MAC(HMAC)は共通鍵でタグを計算し、完全性と真正性を保証します。タグの比較は 定数時間で行い、タイミング攻撃を防ぎます
  • デジタル署名は秘密鍵で署名し、公開鍵で検証します。ES256の署名はRとSを連結した64バイトで、 DER形式とは異なります。署名は機密性を提供しません
  • Base64urlはBase64の+と/を置き換え、JOSEではパディングを付けません。 長さを4で割った余りが1の文字列は不正です
  • 推測できない値はCSPRNGで作り、128ビット以上のエントロピーを持たせます
  • パスワードは、利用者ごとのソルトと、計算に時間とメモリがかかるパスワードハッシュ (Argon2idなど)で保存します

これでPart Iは終わりです。OAuth 2.0は認可の仕組みで、利用者が誰かをクライアントに伝える仕組みは 含んでいません。次章では、OAuth 2.0をログインに使うと何が起きるかを確かめ、 OpenID ConnectがIDトークンなどを追加して認証の仕組みを作っていることを説明します。