TLSハンドシェイク
実装:
crypto/tls// 実行:go test ./crypto/tls/
安全な通信路には3つが要る。盗聴されない秘匿、相手が本物だと分かる認証、書き換えられない完全性。どれも単体では足りない。TLSはこれらを1本の流れに束ねる。証明書で相手を認証し、Diffie–Hellmanで鍵を作り、その鍵で本文をAEADで守る。これまでの部品を組み合わせてハンドシェイクを組み、正規の接続が成立し、中間者は署名を偽造できず弾かれることを確かめる。
この章で作るもの
ここまでで暗号の部品が揃った。ハッシュとHMACで完全性、対称暗号で秘匿と AEAD、鍵交換で共有鍵の作り方、RSAで署名。だがどれも単体では安全な通信路にならない。鍵交換は共有鍵を作れるが、相手が本物かを確かめないので中間者に破られる。暗号化は読めなくするが、相手が誰かは保証しない。安全な通信路には、秘匿・認証・完全性の 3 つが同時に要る。
TLS は、これらを 1 本のハンドシェイクに束ねる。まず証明書で相手を認証する。証明書は「この主体名(example.com)は、この公開鍵の持ち主だ」という認証局(CA)の署名付きの保証だ。相手が証明書に対応する秘密鍵を持っていることを署名で確かめれば、なりすましを防げる。認証できたら Diffie–Hellman で共有鍵を作り、その鍵で本文を AEAD で暗号化+認証する。この章は、これまで作った部品をそのまま組み合わせて、この流れを実装する。
Client Server
│ ClientHello(DH公開鍵, 乱数) ─────────────▶ │
│ │ 一時DH鍵を生成
│ │ トランスクリプトに署名
│ ◀───── ServerHello(DH公開鍵, 証明書, 署名) │
│ ① 証明書は信頼するCAの署名か │
│ ② 主体名は接続先と一致するか │
│ ③ 署名は証明書の公開鍵で検証できるか │
│ 3つ通れば → 共有鍵を導出 → 以後AEADで通信 │順に見ていく。
- 証明書で相手を認証: CA の署名付き証明書で「公開鍵の持ち主」を保証。なりすましを防ぐ
- 署名付き鍵交換: サーバは DH 公開鍵にも署名する。中間者が鍵をすり替えても署名が合わず弾かれる
- 導出鍵で AEAD: 認証済みの鍵交換で共有鍵を作り、本文は暗号化+認証。3 つの性質が揃う
① 証明書: 公開鍵の持ち主を保証する
まず証明書と認証局を作る。証明書は、主体名とサーバの公開鍵を束ね、それに CA が署名したものだ。クライアントは CA の公開鍵を信頼していて、その署名を確かめることで「この公開鍵は確かに example.com のものだ」と判断する:
// pubBytes は RSA 公開鍵を署名対象のバイト列にする。
func pubBytes(p rsa.PublicKey) []byte {
b := make([]byte, 16)
binary.LittleEndian.PutUint64(b[0:], uint64(p.E))
binary.LittleEndian.PutUint64(b[8:], uint64(p.N))
return b
}
// digestInt はデータをハッシュし、玩具 RSA が扱える [0, n) の整数に落とす。
func digestInt(n int64, parts ...[]byte) int64 {
var buf []byte
for _, p := range parts {
buf = append(buf, p...)
}
d := hash.Sum(buf)
v := int64(binary.LittleEndian.Uint32(d[:4]))
v %= n
if v < 0 {
v += n
}
return v
}
// Certificate は「この主体名は、この公開鍵の持ち主だ」という CA の保証。
type Certificate struct {
Subject string // 例 "example.com"
ServerPub rsa.PublicKey // サーバの署名用公開鍵
Sig int64 // CA が (Subject, ServerPub) に付けた署名
}
// CA は認証局。自分の秘密鍵で証明書に署名する。
type CA struct {
Pub rsa.PublicKey
priv rsa.PrivateKey
}
// NewCA は認証局を作る。
func NewCA(p, q int64) *CA {
pub, priv := rsa.GenKeyPair(p, q)
return &CA{Pub: pub, priv: priv}
}
// Issue は subject と公開鍵を束ねて証明書に署名する。
func (ca *CA) Issue(subject string, serverPub rsa.PublicKey) Certificate {
m := digestInt(ca.Pub.N, []byte(subject), pubBytes(serverPub))
return Certificate{Subject: subject, ServerPub: serverPub, Sig: rsa.Sign(ca.priv, m)}
}
// VerifyCert は証明書が caPub の CA によって署名されたものかを確かめる。
func VerifyCert(caPub rsa.PublicKey, cert Certificate) bool {
m := digestInt(caPub.N, []byte(cert.Subject), pubBytes(cert.ServerPub))
return rsa.Verify(caPub, m, cert.Sig)
}なぜ CA が要るのか。クライアントは初めて会うサーバの公開鍵が本物か知らない。攻撃者が「これが example.com の公開鍵です」と偽物を差し出すかもしれない。そこで信頼の起点を CA に置く。クライアントは CA の公開鍵だけをあらかじめ信頼しておく(ブラウザには CA 証明書が組み込まれている)。CA が署名した証明書だけを信じれば、公開鍵の出所を一つずつ確かめずに済む。テストで、正規の証明書がその CA で検証でき、別の CA では検証が通らないことを固定した。
② ハンドシェイク: 認証付きで鍵を交換する
本体のハンドシェイクだ。クライアントは一時 DH 公開鍵を送る。サーバは自分の一時 DH 公開鍵・証明書・そして「この DH 公開鍵は確かに自分が出した」という署名を返す。クライアントは 3 つを検証してから鍵を導く:
// ClientHello はクライアントが最初に送るもの。一時 DH 公開鍵と乱数。
type ClientHello struct {
DHPub *big.Int
Random []byte
}
// ServerHello はサーバの応答。一時 DH 公開鍵・証明書・ハンドシェイク署名。
// 署名は「この DH 公開鍵は確かに証明書の持ち主が出した」ことを証す。
type ServerHello struct {
DHPub *big.Int
Cert Certificate
Sig int64
}
// Session は確立後の共有鍵。以後の本文はこの鍵で守る。
type Session struct{ key []byte }
// transcript はハンドシェイクの記録(署名と鍵導出の入力)。
func transcript(clientPub, serverPub *big.Int, random []byte) []byte {
var b []byte
b = append(b, clientPub.Bytes()...)
b = append(b, random...)
b = append(b, serverPub.Bytes()...)
return b
}
func deriveKey(shared *big.Int, ts []byte) []byte {
k := hash.HMAC(shared.Bytes(), ts)
return k[:]
}
// Client はハンドシェイクを始める側の状態。
type Client struct {
subject string
caPub rsa.PublicKey
dhPriv *big.Int
hello ClientHello
}
// NewClient は subject へ接続するクライアントを作り、ClientHello を用意する。
func NewClient(subject string, caPub rsa.PublicKey, r *dh.Rand) *Client {
priv, pub := group.Generate(r)
random := []byte("client-random-01")
return &Client{
subject: subject,
caPub: caPub,
dhPriv: priv,
hello: ClientHello{DHPub: pub, Random: random},
}
}
// Hello はサーバへ送る ClientHello を返す。
func (c *Client) Hello() ClientHello { return c.hello }
// Server はハンドシェイクに応じる側。証明書と、それに対応する RSA 秘密鍵を持つ。
type Server struct {
cert Certificate
rsaPriv rsa.PrivateKey
}
// NewServer はサーバを作る(証明書と署名用秘密鍵は対応している必要がある)。
func NewServer(cert Certificate, rsaPriv rsa.PrivateKey) *Server {
return &Server{cert: cert, rsaPriv: rsaPriv}
}
// Respond は ClientHello を受け、ServerHello と自分側の Session を返す。
// 一時 DH 鍵を作り、トランスクリプトに自分の RSA 秘密鍵で署名する。
func (s *Server) Respond(ch ClientHello, r *dh.Rand) (ServerHello, Session) {
priv, pub := group.Generate(r)
ts := transcript(ch.DHPub, pub, ch.Random)
sig := rsa.Sign(s.rsaPriv, digestInt(s.cert.ServerPub.N, ts))
shared := group.Shared(priv, ch.DHPub)
return ServerHello{DHPub: pub, Cert: s.cert, Sig: sig}, Session{key: deriveKey(shared, ts)}
}
var (
// ErrBadCert は証明書が信頼する CA の署名を持たないとき。
ErrBadCert = errors.New("tls: certificate not signed by trusted CA")
// ErrBadName は証明書の主体名が接続先と一致しないとき。
ErrBadName = errors.New("tls: certificate subject mismatch")
// ErrBadSig はハンドシェイク署名が検証できないとき(中間者の疑い)。
ErrBadSig = errors.New("tls: handshake signature invalid")
)
// Finish はクライアントが ServerHello を検証し、Session を確立する。
// 証明書の署名・主体名・ハンドシェイク署名の 3 つを確かめてから鍵を導く。
// どれか 1 つでも欠ければ接続を拒否する(ここで中間者をはじく)。
func (c *Client) Finish(sh ServerHello) (Session, error) {
if !VerifyCert(c.caPub, sh.Cert) {
return Session{}, ErrBadCert
}
if sh.Cert.Subject != c.subject {
return Session{}, ErrBadName
}
ts := transcript(c.hello.DHPub, sh.DHPub, c.hello.Random)
if !rsa.Verify(sh.Cert.ServerPub, digestInt(sh.Cert.ServerPub.N, ts), sh.Sig) {
return Session{}, ErrBadSig // DH 公開鍵が本人のものと証明できない
}
shared := group.Shared(c.dhPriv, sh.DHPub)
return Session{key: deriveKey(shared, ts)}, nil
}Finish の 3 つの検証がこの章の心臓だ。証明書が信頼する CA の署名を持つか(出所)、主体名が接続先と一致するか(なりすまし防止)、そしてハンドシェイク署名が証明書の公開鍵で検証できるか。3 つ目が 鍵交換の章で残した宿題への答えになる。素の DH は中間者に弱かった。相手の DH 公開鍵が本物か確かめられなかったからだ。ここではサーバが自分の DH 公開鍵に署名し、クライアントは証明書の公開鍵でその署名を検証する。中間者が DH 公開鍵をすり替えても、サーバの秘密鍵を持たないので署名を偽造できない。テストで、中間者が DH 公開鍵をすり替えると ErrBadSig で弾かれること、偽の CA が発行した証明書は ErrBadCert で弾かれることを固定した。認証を足すことで、鍵交換が中間者に耐えるようになる。
③ レコード: 導出鍵で本文を守る
ハンドシェイクが済めば、両者は同じ共有秘密を持つ。これをそのまま鍵に使うのでなく、トランスクリプト(ここまでのやり取りの記録)と混ぜて鍵を導出する(HMAC ベース)。以後の本文は、この鍵で AEAD 暗号化する:
// nonce は本文保護に使う 8 バイト(教科書用の固定値。実物はレコードごとに変える)。
var nonce = []byte("tls-non1")
// Seal は確立した鍵で本文を暗号化+認証する(AEAD)。
func (se Session) Seal(plain []byte) []byte {
c := cipher.NewCipher(se.key)
return c.Seal(se.key, plain, nonce)
}
// Open は Seal の逆。改ざんされていれば false。
func (se Session) Open(sealed []byte) ([]byte, bool) {
c := cipher.NewCipher(se.key)
return c.Open(se.key, sealed, nonce)
}
// SameKey は 2 つの Session が同じ鍵を共有しているかを返す(検証用)。
func (se Session) SameKey(other Session) bool {
if len(se.key) != len(other.key) {
return false
}
var diff byte
for i := range se.key {
diff |= se.key[i] ^ other.key[i]
}
return diff == 0
}本文の保護は 対称暗号の章で作った Seal / Open をそのまま使う。暗号化してから認証タグを付けるので、盗聴も改ざんも防げる。テストで、正規の接続では両者が同じ鍵に到達して本文が往復すること、確立後に本文を改ざんすると AEAD が検知して弾くことを固定した。トランスクリプトを鍵導出に混ぜるのは、ハンドシェイク自体が改ざんされていないことを鍵に縛りつけるためだ。途中で 1 バイトでも書き換えられていれば、両者の導く鍵が食い違い、通信が成立しない。
動かす
下のデモはハンドシェイクを段階的に追う。正規の接続では 3 つの検証が通って鍵が確立し本文が流れる。中間者を割り込ませると、どの検証で弾かれるか(署名の偽造に失敗する様子)が見える。
DH 共有鍵とトランスクリプトのダイジェストは実際に計算している。TLS は証明書で相手を認証してから 鍵交換する。サーバは自分の DH 公開鍵に署名するので、中間者が公開鍵をすり替えても署名が合わず (③で食い違う)、秘密鍵を持たない Mallory は署名を作り直せない。だから接続は拒否され、鍵交換が 中間者に耐える。認証・鍵交換・本文の AEAD がそろって初めて安全な通信路になる。
設計の観点
- 信頼の起点(trust anchor): すべては「CA を信頼する」から始まる。CA の秘密鍵が漏れれば全体が崩れる。だから CA は厳重に守られ、証明書には失効の仕組み(CRL/OCSP)がある
- 前方秘匿性(forward secrecy): 鍵交換に使い捨ての一時鍵(ephemeral)を使う。後でサーバの長期鍵が漏れても、過去に録音された通信は復号できない。TLS 1.3 はこれを必須にした
- トランスクリプト署名: ハンドシェイク全体に署名・鍵を縛ることで、途中のメッセージ改ざん(ダウングレード攻撃など)を防ぐ。一部だけ署名すると隙ができる
- 認証と鍵交換の分離: DH が鍵を作り、署名が相手を認証する。この 2 つは別の役割で、両方揃って初めて安全。片方だけでは破れる
- 証明書チェーン: 実際はルート CA →中間 CA →サーバ証明書と連なる。信頼を段階的に委譲し、ルートの露出を減らす
対照と実例
| 部品 | 満たす性質 | 欠けると |
|---|---|---|
| DH 鍵交換 | 秘匿(共有鍵) | 単体では認証がなく中間者に破られる |
| 証明書 + 署名 | 認証(相手が本物) | なりすましを許す |
| AEAD | 秘匿 + 完全性(本文) | 改ざんを見逃す |
| トランスクリプト署名 | ハンドシェイクの完全性 | ダウングレード攻撃を許す |
裏どり:
- TLS 1.3 (RFC 8446): 現行の標準。RSA 鍵輸送を廃し、(EC)DHE による前方秘匿性を必須化。ハンドシェイクの往復も削減
- X.509 証明書 / CA/Browser Forum: 証明書の形式と、CA が守るべき基準。Web の信頼基盤
- Let's Encrypt / ACME: 証明書の自動発行。HTTPS を無料で普及させた
- 中間者攻撃の実例(Superfish 等): 端末に不正な CA を仕込むと TLS が無力化する。信頼の起点が崩れる怖さの実例
簡略化したこと
- 玩具の部品: RSA も DH も小さな数。安全性は保証しない(構造を見るもの)
- TLS 1.3 の一部のみ: 実物のメッセージ・拡張・0-RTT・証明書チェーンは省略
- 固定の乱数/nonce: 実運用は接続ごと・レコードごとに変える
- 鍵導出は簡略: 実物は HKDF で方向別に複数の鍵を導く
- 失効を扱わない: 証明書の失効(CRL/OCSP)は設計の観点で述べるに留めた
参考資料
- RFC 8446: TLS 1.3 — 現行 TLS の仕様
- The Illustrated TLS 1.3 Connection — ハンドシェイクを 1 バイトずつ図解
- Let's Encrypt: How It Works — 証明書発行の実際
- 実装: crypto/tls