NIST が定める「デジタル署名標準」(DSS, Digital Signature Standard)のうち、2024/08/13 に FIPS 204 で制定された「Module-Lattice-Based Digital Signature Standard」(格子ベースデジタル署名標準)のベースとなった "Dilithium" を Go 言語で使いたい。
CRISTALS Dilithium の本家の実装は C 言語ですが、本記事では Go 言語(以下 golang)の実装に Cloudflare 社の Go モジュール "CIRCL" に含まれる Sign
パッケージを使い、具体的な Dilithium 及び ML-DSA の署名の仕方を説明します。
CRISTALS-Dilithium とは
CRISTALS Dilithium は、NIST 主催の PQC のコンペティションで「DSA 部門」(デジタル署名アルゴリズム部門)で優勝した暗号アルゴリズムです。
PQC(Post-Quantum Cryptography)とは 2017 年から 6 年近くかけて開催された「量子コンピューター演算による解読にも耐性のある暗号アルゴリズム」のコンペです。詳しくは別記事の TS;DR をご覧ください。
なお、「Dilithium
」をベースに量子耐性のあるデジタル署名の標準(DSS, Digital Signature Standard)として NIST が制定したものを「ML-DSA
」と呼びます。Dilithium
と ML-DSA
は基本的に同じものなのですが、内部パラメーター値が異なるため互換はありません。
TL; DR (何はともあれソースコード)
Dilithium
には、Dilithium2
Dilithium3
Dilithium5
の三段階の強度があります。このサンプルでは、Dilithium5
を使って、Alice のメッセージと署名を Bob が検証します。
より堅牢な仕様を含む、NIST 制定版 Dilithium である ML-DSA(いわゆるひとつの FIPS 204)は TS; DR を参照ください。
package main
import (
"crypto/sha3"
"encoding/hex"
"fmt"
"log"
"github.com/cloudflare/circl/sign"
"github.com/cloudflare/circl/sign/schemes"
)
func main() {
const (
// Agreement on the mode to use between Alice and Bob.
// Mode choices are:
// * "Ed25519"
// * "Ed448"
// * "Ed25519-Dilithium2"
// * "Ed448-Dilithium3"
// * "Dilithium2"
// * "Dilithium3"
// * "Dilithium5"
// * "ML-DSA-44" (Dilithium2 base, NIST Security Level 2)
// * "ML-DSA-65" (Dilithium3 base, NIST Security Level 3)
// * "ML-DSA-87" (Dilithium5 base, NIST Security Level 5)
modeName = "Dilithium5"
)
// ------------------------------------------------------------------------
// Alice Key Generation and Signing
// ------------------------------------------------------------------------
scheme := schemes.ByName(modeName)
if scheme == nil {
log.Fatal("Unknown mode:", modeName)
}
alicePk, aliceSk, err := scheme.GenerateKey()
fatalOnError(err)
// Alternatively you can generate the keys from a seed.
// var seed [32]byte
// pk, sk := scheme.DeriveKey(seed[:])
// Public key of Alice to save
ppk, err := alicePk.MarshalBinary()
fatalOnError(err)
fmt.Printf("Public Key (%s): %x...%x\n", modeName, ppk[:16], ppk[len(ppk)-16:])
// Secret key of Alice to save
psk, err := aliceSk.MarshalBinary()
fatalOnError(err)
fmt.Printf("Secret Key (%s): %x...%x\n", modeName, psk[:16], psk[len(psk)-16:])
// Fingerprints of the keys
pkh := hexHash(ppk)
skh := hexHash(psk)
fmt.Println("Public Key Hash:", pkh)
fmt.Println("Secret Key Hash:", skh)
// Alice's message to sign
msg := []byte("Hello, world!")
// Alice signs the message with her secret key
sig := scheme.Sign(aliceSk, msg, nil)
fmt.Printf("Signature (%s) : %x...%x\n", modeName, sig[:16], sig[len(sig)-16:])
// ------------------------------------------------------------------------
// Bob Verifies Alice's Signature
// ------------------------------------------------------------------------
// `ppk` is the public key of Alice, which Bob has received.
pk2, err := scheme.UnmarshalBinaryPublicKey(ppk)
fatalOnError(err)
// Bob verifies the signature using Alice's public key
if !scheme.Verify(pk2, msg, sig, nil) {
log.Fatal("Signature verification: failed")
}
fmt.Println("Signature verification: succeeded")
}
// fatalOnError logs the error and exits the program if the error is not nil.
func fatalOnError(err error) {
if err != nil {
log.Fatal(err)
}
}
// hexHash returns a 16-byte hex-encoded SHAKE-256 hash of the input.
func hexHash(in []byte) string {
var ret [16]byte
h := sha3.NewSHAKE256()
_, _ = h.Write(in[:])
_, _ = h.Read(ret[:])
return hex.EncodeToString(ret[:])
}
- オンラインで動作を見る @ GoPlayground
TS; DR (NIST 制定版も試す)
Dilithium と ML-DSA
PQC のデジタル署名アルゴリズム部門で CRISTALS-Dilithium が優勝した後、NIST の FIPS204 で正式に制定されました。
FIPS204(ML-DSA)は、NIST が 2024 年 8 月 13 日に正式発表した、世界初の量子耐性デジタル署名標準で、CRYSTALS-Dilithium をベースに標準化した連邦標準です。
オリジナルの Dilithium
に、若干のパラメーター調整と後述するコンテキスト機能を加えた ML-DSA(Module-Lattice-Based Digital Signature Algorithm)として制定されています。
ML
(Module-Lattice-Based
)とは、格子ベース暗号という Dilithium が採用している「格子問題」を用いた暗号アルゴリズムです。
格子問題とは、「たくさん並んだ点(格子点)の中で、A 点から B 点まで移動したい」ときに、「限られた移動手段だけで、特定の条件を満たす点を探す」問題です。
ここでの「移動手段」とは、「右に n 進む」「上に m 進む」といったカードの組み合わせ的なことで、数学的に言えば「いくつかのベクトルの整数倍の組み合わせ」のことです。
つまり「点から点への移動」を示すベクトルを、複数組み合わせて B 点に限りなく近い点を見つける(辿り着く)ことができるか、というパズル的な問題です。
地面(2 次元)での移動なら可能なルート(組み合わせ)は限られますが、ジャングルジムのような多次元の格子構造では、移動の組み合わせが爆発的に増えます。
つまり、巡回セールスマン問題と同様に組み合わせ爆発による難しさを利用しています。
「巡回セールスマン問題」の場合は「最短のルートを通って全地点を回る」ことを目的とするのに対し、格子問題では「ある条件を満たす点(もしくは近い点)を見つける」ことが目的です。
しかも、高次元(多次元)における格子問題は、既知のアルゴリズムでは指数時間的に難しい(実用的に解けない)とされ、現実的な時間内で精度の高い解を出せるアルゴリズムが見つかっていません。
そして、問題を出す側は組み合わせをランダムに決めればよく、それが正しい答えとなるため、確認も容易です。
このように、「簡単には解けないけれど、答えを知っていればすぐ確認できる」という性質(一方向性)は、暗号技術、特に量子時代にも対応できる安全な暗号に活かされています。
ML-DSA(コンテキスト付き署名)
さて、Dilithium
のオリジナル・バージョンと NIST で制定されたバージョンには、若干の違いがあります。
研究向けに設定していた内部パラメーターが、実用向けのパラメーターに変わったことと、署名と検証にコンテキストをオプションで追加できるようになったことです。
- "Appendix D — Differences from the CRYSTALS-DILITHIUM Submission" | FIPS 204 Module-Lattice-Based Digital Signature Standard @ nist.gov
ここで言うコンテキストとは、署名者と検証者が事前に決めておいた「共通の文字列」のことです。
ハッシュ関数でいう salt
値のような役割をするもので、それを加えないと出力が同じにならないものです。つまり、同じコンテキストを所有する(知る)間でのみ署名を有効にすることができます。
以下は、ML-DSA のサンプルです。上記の Dilithium
のサンプルと、ほとんど同じですが以下の 2 点に注目ください。特に同じコンテキストを使った場合のみ "Signature verification: succeeded"
となります。
-
mode
(使用アルゴリズム)がML-DSA-87
に変わっている - 署名や検証前に
sign.SignatureOpts
オプションでコンテキストを設定している
package main
import (
"crypto/sha3"
"encoding/hex"
"fmt"
"log"
"github.com/cloudflare/circl/sign"
"github.com/cloudflare/circl/sign/schemes"
)
func main() {
const (
// Agreement on the mode to use between Alice and Bob.
// Mode choices are:
// * "Ed25519"
// * "Ed448"
// * "Ed25519-Dilithium2"
// * "Ed448-Dilithium3"
// * "Dilithium2"
// * "Dilithium3"
// * "Dilithium5"
// * "ML-DSA-44" (Dilithium2 base, NIST Security Level 2)
// * "ML-DSA-65" (Dilithium3 base, NIST Security Level 3)
// * "ML-DSA-87" (Dilithium5 base, NIST Security Level 5)
modeName = "ML-DSA-87"
// Common context between Alice and Bob.
ctx = "Any context agreed upon by both Alice and Bob"
)
// ------------------------------------------------------------------------
// Alice Key Generation and Signing
// ------------------------------------------------------------------------
scheme := schemes.ByName(modeName)
if scheme == nil {
log.Fatal("Unknown mode:", modeName)
}
alicePk, aliceSk, err := scheme.GenerateKey()
fatalOnError(err)
// Alternatively you can generate the keys from a seed.
// var seed [32]byte
// pk, sk := scheme.DeriveKey(seed[:])
// Public key of Alice
ppk, err := alicePk.MarshalBinary()
fatalOnError(err)
fmt.Printf("Public Key (%s): %x...%x\n", modeName, ppk[:16], ppk[len(ppk)-16:])
// Secret key of Alice
psk, err := aliceSk.MarshalBinary()
fatalOnError(err)
fmt.Printf("Secret Key (%s): %x...%x\n", modeName, psk[:16], psk[len(psk)-16:])
// Fingerprints of the keys
pkh := hexHash(ppk)
skh := hexHash(psk)
fmt.Println("Public Key Hash:", pkh)
fmt.Println("Secret Key Hash:", skh)
// Alice's message to sign
msg := []byte("Hello, world!")
// Set shared context between Alice and Bob
aliceOpts := &sign.SignatureOpts{}
if scheme.SupportsContext() {
aliceOpts.Context = ctx
}
// Alice signs the message with her secret key including a context
sig := scheme.Sign(aliceSk, msg, aliceOpts)
fmt.Printf("Signature (%s) : %x...%x\n", modeName, sig[:16], sig[len(sig)-16:])
// ------------------------------------------------------------------------
// Bob Verifies Alice's Signature
// ------------------------------------------------------------------------
// `ppk` is the public key of Alice, which Bob has received.
pk2, err := scheme.UnmarshalBinaryPublicKey(ppk)
fatalOnError(err)
// Set shared context between Alice and Bob
bobOpts := &sign.SignatureOpts{}
if pk2.Scheme().SupportsContext() {
bobOpts.Context = ctx
}
// Bob verifies the signature using Alice's public key with shared common
// context
if !scheme.Verify(pk2, msg, sig, bobOpts) {
log.Fatal("Signature verification: failed")
}
fmt.Println("Signature verification: succeeded")
}
// fatalOnError logs the error and exits the program if the error is not nil.
func fatalOnError(err error) {
if err != nil {
log.Fatal(err)
}
}
// hexHash returns a 16-byte hex-encoded SHAKE-256 hash of the input.
func hexHash(in []byte) string {
var ret [16]byte
h := sha3.NewSHAKE256()
_, _ = h.Write(in[:])
_, _ = h.Read(ret[:])
return hex.EncodeToString(ret[:])
}
- オンラインで動作を見る @ GoPlayground
コンテキストの役割
コンテキストは、いわば共通のパスワードのような役割をするわけですが、これには主に 2 つの攻撃を防ぐ目的があります。
- 「ドメイン分離」 (Domain Separation)
- 発行した署名ファイルが別のアプリやサービスに使われることを防ぐ
- 「リプレイ攻撃防止」 (Replay Attack Prevention)
- 古い署名ファイルが後で再利用されることを防ぐ
具体例で見てみましょう。
まずは「ドメイン分離」でヤバチンなパターンです。コンテキストがあると、特定の署名が特定の目的でしか使えないことがわかります。
// 悪い例(コンテキストなし)
msg := []byte("承認")
// アプリA: ログイン承認
sigA := scheme.Sign(sk, msg, nil)
// 攻撃者がsigAを盗んで...
// アプリB: 決済承認で悪用!
valid := scheme.Verify(pk, msg, sigA, nil) // ← 成功してしまう!😱
// 良い例(コンテキストあり)
msg := []byte("承認")
// アプリA: ログイン用
optsA := &sign.SignatureOpts{Context: "login-app"}
sigA := scheme.Sign(sk, msg, optsA)
// アプリB: 決済用
optsB := &sign.SignatureOpts{Context: "payment-app"}
valid := scheme.Verify(pk, msg, sigA, optsB) // ← 失敗する!✅
次に、「リプレイ攻撃防止」の例です。署名が再利用されるヤバチンなパターンです。コンテキストにタイムスタンプを活用することで、こちらも特定の署名が特定の目的でしか使えないことがわかります。
// 悪い例(コンテキストなし)
msg := []byte("送金: 1000円")
// 1月: Aliceが署名
sig := scheme.Sign(sk, msg, nil)
// 6月: 攻撃者が古い署名を再利用
valid := scheme.Verify(pk, msg, sig, nil) // ← まだ有効!😱
// 良い例(タイムスタンプ付きコンテキスト)
msg := []byte("送金: 1000円")
// 1月
opts1 := &sign.SignatureOpts{Context: "payment-2025-01-15"}
sig1 := scheme.Sign(sk, msg, opts1)
// 6月: 攻撃者が再利用を試みる
opts6 := &sign.SignatureOpts{Context: "payment-2025-06-15"}
valid := scheme.Verify(pk, msg, sig1, opts6) // ← 失敗する!✅
なお、どのアルゴリズム(スキーム)が「コンテキスト」オプションをサポートしているかは、以下のコードで確認できます。
package main
import (
"fmt"
"github.com/cloudflare/circl/sign/schemes"
)
func main() {
// Get all available schemes
allSchemes := schemes.All()
fmt.Println("Available signature schemesthat support context:")
for _, scheme := range allSchemes {
if scheme.SupportsContext() {
fmt.Printf("✅ %s\n", scheme.Name())
} else {
fmt.Printf("❌ %s\n", scheme.Name())
}
}
}
$ go run main.go
Available signature schemesthat support context:
❌ Ed25519
✅ Ed448
❌ Ed25519-Dilithium2
❌ Ed448-Dilithium3
❌ Dilithium2
❌ Dilithium3
❌ Dilithium5
✅ ML-DSA-44
✅ ML-DSA-65
✅ ML-DSA-87