はじめに
Go言語でPKI入門(勝手に復刻版)
のACME編です。
ACMEについて
Automatic Certificate Management Environment (ACME)は、
サーバー証明書の管理を自動化するための仕組みです。
Let’s Encryptによる証明書発行の自動化のために使われています。
に詳しい説明があります。
RFC8555
ACMEのプロトコル仕様は、RFC8555で定義されています。
まじめに全部読んで理解したほうがよいと思いますが、ざっくり要点だけ読んでまとめた図は
のようになります。
stepcaのソースコード分析
インターネット環境でサーバー証明書の発行を自動化したければ、Let’s Encryptを使えばよいのですが、
プラベートな環境でも証明書の発行を自動化したいことがあると思います。その場合に使えるのが、stepca
です。使い方を
の記事に書きました。
このサーバーはGo言語で開発されていて、GitHUBで公開されています。
ACMEのソースコードは、パッケージとして他のプロジェクトから使えるような状態ではありません。
そこで、まじめにソースコードを読んで必要部分を整理しました。
のような処理です。
Go言語でACMEサーバーを実装
ここまでの情報を元に、Go言語でACMEサーバーを実装してみました。
そのポイントを説明します。
ACMEサーバー用証明書
ACMEサーバーはHTTPSで通信する必要があります。そのためサーバー証明書と秘密鍵が必要です。
以前の記事の応用ですが、サーバー証明書は、
func CreateAcmeServerCertificate() {
key, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
if err != nil {
panic(err)
}
publicKey := &key.PublicKey
ca, err := x509.ParseCertificate(rootCACertificate)
if err != nil {
panic(err)
}
certTpl := &x509.Certificate{
SerialNumber: serial,
Subject: pkix.Name{
CommonName: "acme.example.org",
OrganizationalUnit: []string{"Example Org Unit"},
Organization: []string{"Example Org"},
Country: []string{"JP"},
},
NotBefore: time.Now().UTC(),
NotAfter: time.Now().AddDate(1, 0, 0).UTC(),
KeyUsage: x509.KeyUsageDigitalSignature,
ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth},
DNSNames: []string{"acme.example.com", "localhost"},
EmailAddresses: []string{"test@examle.com"},
IPAddresses: []net.IP{net.ParseIP("127.0.0.1")},
IsCA: false,
BasicConstraintsValid: true,
CRLDistributionPoints: []string{"http://127.0.0.1:5585/crl"},
OCSPServer: []string{"http://127.0.0.1:5585/ocsp"},
}
cert, err := x509.CreateCertificate(rand.Reader, certTpl, ca, publicKey, rootCAPrivateKey)
if err != nil {
panic(err)
}
OutPem(cert, "./acme.crt", "CERTIFICATE")
if b, err := x509.MarshalECPrivateKey(key); err == nil {
OutPem(b, "./acme.key", "EC PRIVATE KEY")
}
serial.Add(serial, big.NewInt(1))
}
acme.crtに証明書、acme.keyにサーバー証明書を作成します。
HTTPSサーバー
ACMEサーバーはHTTPSで動作します。Go言語標準のHTTPパッケージでも作れますが、
より簡単に作るためのEcho
を使います。
サーバーの設定は、
var baseURL = "https://127.0.0.1:9001"
func ACMEServer() {
e := echo.New()
e.Use(middleware.RequestLoggerWithConfig(middleware.RequestLoggerConfig{
LogStatus: true,
LogURI: true,
LogMethod: true,
LogContentLength: true,
LogValuesFunc: func(c echo.Context, v middleware.RequestLoggerValues) error {
fmt.Printf("%s %v %v %s\n", v.Method, v.URI, v.Status, v.ContentLength)
return nil
},
}))
// ここでeにACMEの処置を追加する
if err := e.StartTLS(":9001", "acme.crt", "acme.key"); err != http.ErrServerClosed {
log.Fatal(err)
}
}
のようにします。先ほど作った証明書と秘密鍵を使ってHTTPSで起動しています。
eにACMEのリクエストの処理を追加すれば、ACMEサーバーになります。
/directory
APIのURLを取得するたための最初のエントリーです。
var baseURL = "https://127.0.0.1:9001"
e.GET("/directory", func(c echo.Context) error {
d := Directory{
NewNonce: baseURL + "/new-nonce",
NewAccount: baseURL + "/new-account",
NewOrder: baseURL + "/new-order",
RevokeCert: baseURL + "/revoke-cert",
KeyChange: baseURL + "/key-change",
}
return c.JSON(http.StatusOK, d)
})
Echoを使っているので簡単にJSONの応答を返せます。
/nonce
noceの生成は、HEADとGETで処理をします。
HEADの場合とGETの場合で応答コードが違います。
e.HEAD("/new-nonce", func(c echo.Context) error {
addHeader(c)
return c.NoContent(http.StatusOK)
})
e.GET("/new-nonce", func(c echo.Context) error {
addHeader(c)
return c.NoContent(http.StatusNoContent)
})
共通の処理
共通のヘッダを返す処理とnoceを作る処理は
func addHeader(c echo.Context) {
c.Response().Header().Add("Replay-Nonce", CreateNonce())
c.Response().Header().Add("Link", baseURL+"/directory/index")
c.Response().Header().Add(echo.HeaderCacheControl, "no-store")
}
func CreateNonce() string {
b := make([]byte, 32)
if _, err := rand.Read(b); err != nil {
return "bad nonce"
}
var s string
for _, v := range b {
s += string(v%byte(94) + 33)
}
return base64.RawURLEncoding.EncodeToString([]byte(s))
}
/new-account
アカウントの作成です。
e.POST("/new-account", func(c echo.Context) error {
addHeader(c)
jws, err := getJWS(c)
if err != nil {
log.Printf("new-account err=%v", err)
return c.JSON(http.StatusBadRequest, err)
}
jwk := extractJWK(jws)
if jwk == nil {
log.Println("new-account jwk not found")
return c.JSON(http.StatusBadRequest, err)
}
payload, err := jws.Verify(jwk)
if err != nil {
log.Printf("new-account err=%v", err)
return c.JSON(http.StatusBadRequest, err)
}
var nar NewAccountRequest
if err := json.Unmarshal(payload, &nar); err != nil {
log.Printf("new-account err=%v", err)
return c.JSON(http.StatusBadRequest, err)
}
// narのチェック、
acc := &Account{
ID: jwk.KeyID,
Key: jwk,
Status: "valid",
Contact: nar.Contact,
OrdersURL: baseURL + "/account/yErbu3KD7bEdxbXsBlaJ82l5VRdtos5W/orders",
}
accMap[acc.ID] = acc
c.Response().Header().Add("Location", baseURL+"/account/"+acc.ID)
return c.JSON(http.StatusCreated, acc)
})
/new-order
証明書の発行を受け付けます。
オーダーを受付をチェックしてOKなら登録します。
e.POST("/new-order", func(c echo.Context) error {
addHeader(c)
jws, err := getJWS(c)
if err != nil {
log.Printf("new-order err=%v", err)
return c.JSON(http.StatusBadRequest, err)
}
acc, err := lookupJWKAndAccount(jws)
if err != nil {
log.Printf("new-order err=%v", err)
return c.JSON(http.StatusBadRequest, err)
}
payload, err := jws.Verify(acc.Key)
if err != nil {
log.Printf("new-order err=%v", err)
return c.JSON(http.StatusBadRequest, err)
}
var nor NewOrderRequest
if err := json.Unmarshal(payload, &nor); err != nil {
log.Printf("new-order err=%v", err)
return c.JSON(http.StatusBadRequest, err)
}
now := time.Now().UTC().Truncate(time.Second)
// norのチェック、
o := &Order{
ID: CreateNonce(),
AccountID: acc.ID,
Status: "pending",
Identifiers: nor.Identifiers,
ExpiresAt: now.Add(time.Hour * 24),
AuthorizationIDs: make([]string, len(nor.Identifiers)),
AuthorizationURLs: make([]string, len(nor.Identifiers)),
NotBefore: nor.NotBefore,
NotAfter: nor.NotAfter,
}
for i, identifier := range o.Identifiers {
azID := CreateNonce()
// identifierのタイプ(ホスト名、IPなど)から
// CAが発行できる証明書であることをチェックし
// チャレンジのタイプを設定する
chTypes := []string{}
switch identifier.Type {
case "ip":
chTypes = append(chTypes, "http-01")
chTypes = append(chTypes, "tls-alpn-01")
case "dns":
chTypes = append(chTypes, "dns-01")
if strings.HasPrefix(identifier.Value, "*") {
chTypes = append(chTypes, "http-01")
chTypes = append(chTypes, "tls-alpn-01")
}
case "permanent-identifier":
// TPM,Apple,YubiKeyなどのデバイス
chTypes = append(chTypes, "device-attest-01")
case "wireapp-user":
// OpenID Connect
case "wireapp-device":
// OpenID Connect
}
az := &Authorization{
ID: azID,
AccountID: acc.ID,
Identifier: identifier,
ExpiresAt: o.ExpiresAt,
Status: "pending",
Challenges: []*Challenge{},
Token: CreateNonce(),
}
for _, chType := range chTypes {
chID := CreateNonce()
az.Challenges = append(az.Challenges,
&Challenge{
ID: chID,
AccountID: acc.ID,
AuthorizationID: azID,
Type: chType,
Value: identifier.Value,
Token: az.Token,
Status: "pending",
URL: baseURL + "/challenge/" + azID + "/" + chID,
})
}
o.AuthorizationIDs[i] = az.ID
o.AuthorizationURLs[i] = baseURL + "/authz/" + azID
authzMap[az.ID] = az
}
if o.NotBefore.IsZero() {
o.NotBefore = now
}
if o.NotAfter.IsZero() {
o.NotAfter = o.NotBefore.Add(time.Hour * 24)
}
if nor.NotBefore.IsZero() {
o.NotBefore = o.NotBefore.Add(-time.Hour * 24)
}
o.FinalizeURL = baseURL + "/order/" + o.ID + "/finalize"
if o.CertificateID != "" {
o.CertificateURL = baseURL + "/certificate" + o.CertificateID
}
orderMap[o.ID] = o
c.Response().Header().Add("Location", baseURL+"/order/"+o.ID)
return c.JSON(http.StatusCreated, o)
})
/authz/:id
Authorizationをチェックします。
証明書の審査状況のチェックです。
e.POST("/authz/:id", func(c echo.Context) error {
addHeader(c)
jws, err := getJWS(c)
if err != nil {
log.Printf("authz err=%v", err)
return c.JSON(http.StatusBadRequest, err)
}
id := c.Param("id")
acc, err := lookupJWKAndAccount(jws)
if err != nil {
log.Printf("authz err=%v", err)
return c.JSON(http.StatusBadRequest, err)
}
// Check ID
az, ok := authzMap[id]
if !ok {
log.Printf("authz not found id=%s", id)
return c.JSON(http.StatusBadRequest, err)
}
if az.AccountID != acc.ID {
log.Printf("authz not owner %s!=%s", az.AccountID, acc.ID)
return c.JSON(http.StatusBadRequest, fmt.Errorf("account id %s!=%s", az.AccountID, acc.ID))
}
c.Response().Header().Add("Location", baseURL+"/authz/"+az.ID)
return c.JSON(http.StatusOK, az)
})
/challenge/:authzID/:chID
証明書発行の審査を要求します。
e.POST("/challenge/:authzID/:chID", func(c echo.Context) error {
addHeader(c)
jws, err := getJWS(c)
if err != nil {
log.Printf("challenge err=%v", err)
return c.JSON(http.StatusBadRequest, err)
}
authzID := c.Param("authzID")
chID := c.Param("chID")
acc, err := lookupJWKAndAccount(jws)
if err != nil {
log.Printf("challenge err=%v", err)
return c.JSON(http.StatusBadRequest, err)
}
// Check ID
az, ok := authzMap[authzID]
if !ok {
log.Printf("challenge not found id=%s", authzID)
return c.JSON(http.StatusBadRequest, fmt.Errorf("id not found %s", authzID))
}
if az.AccountID != acc.ID {
log.Printf("challenge acc id mismatch az=%s acc=%s", az.AccountID, acc.ID)
return c.JSON(http.StatusBadRequest, fmt.Errorf("acount id mismatch %s!=%s", az.AccountID, acc.ID))
}
// Challengeを探す
var ch *Challenge
for _, c := range az.Challenges {
if c.ID == chID {
ch = c
}
}
if ch == nil {
return c.JSON(http.StatusBadRequest, fmt.Errorf("challenge not found id=%s", chID))
}
switch ch.Type {
case "http-01":
// ここでHTTPのチェックをしてOKならStatusをvalidにすればよい
if err := http01Validate(acc, ch); err != nil {
log.Printf("challenge http01Validate err=%v", err)
return c.JSON(http.StatusBadRequest, err)
}
case "tls-alpn-01":
if err := tlsalpn01Validate(acc, ch); err != nil {
return c.JSON(http.StatusBadRequest, err)
}
case "dns-01":
if err := dns01Validate(acc, ch); err != nil {
return c.JSON(http.StatusBadRequest, err)
}
case "device-attest-01":
{
payload, err := jws.Verify(acc.Key)
if err != nil {
log.Printf("challenge err=%v", err)
return c.JSON(http.StatusBadRequest, err)
}
deviceAttest01Validate(acc, az, ch, payload)
}
default:
return c.JSON(http.StatusBadRequest, fmt.Errorf("challenge not supported type=%s", ch.Type))
}
ch.Status = "valid"
c.Response().Header().Add("Location", baseURL+"/challenge/"+az.ID+"/"+chID)
c.Response().Header().Add("Link", fmt.Sprintf("<%s/authz/%s>;rel=up", baseURL, az.ID))
return c.JSON(http.StatusOK, ch)
})
ここで、チャレンジの方式がいろいろでてきます。
サーバーの証明証ではDNSのTXTレコードを書き換える方法、HTTPの応答を書き換える方法、TLSの応答を書き換える方法などがあります。
デバイスの証明書のためには、TPMやAppleハードを使ったのデバイス用チャレンジもあります。
HTTPのチャレンジのみ後で紹介します。
/order/:id/finalize
チャレンジが成功した後、証明書の発行を行います。
そのリクエストの処理は
e.POST("/order/:id/finalize", func(c echo.Context) error {
addHeader(c)
jws, err := getJWS(c)
if err != nil {
log.Printf("finalize err=%v", err)
return c.JSON(http.StatusBadRequest, err)
}
id := c.Param("id")
acc, err := lookupJWKAndAccount(jws)
if err != nil {
log.Printf("finalize err=%v", err)
return c.JSON(http.StatusBadRequest, err)
}
// Check ID
o, ok := orderMap[id]
if !ok {
log.Printf("finalize not found id=%s", id)
return c.JSON(http.StatusBadRequest, fmt.Errorf("order not found id=%s", id))
}
if o.AccountID != acc.ID {
log.Printf("finalize acc id mismatch o=%s acc=%s", o.AccountID, acc.ID)
return c.JSON(http.StatusBadRequest, fmt.Errorf("account id missmatch %s!=%s", o.AccountID, acc.ID))
}
payload, err := jws.Verify(acc.Key)
if err != nil {
log.Printf("finalize err=%v", err)
return c.JSON(http.StatusBadRequest, err)
}
var fr FinalizeRequest
if err := json.Unmarshal(payload, &fr); err != nil {
log.Printf("finalize err=%v", err)
return c.JSON(http.StatusBadRequest, err)
}
csrBytes, err := base64.RawURLEncoding.DecodeString(strings.TrimRight(fr.CSR, "="))
if err != nil {
log.Printf("finalize err=%v", err)
return c.JSON(http.StatusBadRequest, err)
}
fr.csr, err = x509.ParseCertificateRequest(csrBytes)
if err != nil {
log.Printf("finalize err=%v", err)
return c.JSON(http.StatusBadRequest, err)
}
if err := fr.csr.CheckSignature(); err != nil {
log.Printf("finalize err=%v", err)
return c.JSON(http.StatusBadRequest, err)
}
cert, err := CreateCertificateFromCSRSub(csrBytes)
certID := serial.String()
serial.Add(serial, big.NewInt(1))
if err != nil {
log.Printf("finalize err=%v", err)
return c.JSON(http.StatusBadRequest, err)
}
pemCert := string(pem.EncodeToMemory(
&pem.Block{
Type: "CERTIFICATE",
Bytes: cert,
},
)) + "\n" +
string(pem.EncodeToMemory(
&pem.Block{
Type: "CERTIFICATE",
Bytes: rootCACertificate,
},
))
certMap[certID] = &Certificate{
ID: certID,
OrderID: o.ID,
AccountID: o.AccountID,
Certificate: []byte(pemCert),
}
o.Status = "valid"
o.CertificateID = certID
o.CertificateURL = baseURL + "/certificate/" + o.CertificateID
c.Response().Header().Add("Location", baseURL+"/order/"+id)
return c.JSON(http.StatusOK, o)
})
送信されたCSRから証明書を発行すればよいです。証明書の発行は、CA側の処理です。
/certificate/:id
発行した証明書をダウンロードするための処理です。
e.POST("/certificate/:id", func(c echo.Context) error {
addHeader(c)
jws, err := getJWS(c)
if err != nil {
log.Printf("certificate err=%v", err)
return c.JSON(http.StatusBadRequest, err)
}
id := c.Param("id")
acc, err := lookupJWKAndAccount(jws)
if err != nil {
log.Printf("err=%v", err)
return c.JSON(http.StatusBadRequest, err)
}
// Check ID
cert, ok := certMap[id]
if !ok {
log.Printf("certificate not found id=%s", id)
return c.JSON(http.StatusBadRequest, fmt.Errorf("cert not fond id=%s", id))
}
if cert.AccountID != acc.ID {
log.Printf("certificate acc id mismatch o=%s acc=%s", cert.AccountID, acc.ID)
return c.JSON(http.StatusBadRequest, err)
}
return c.Blob(http.StatusOK, "application/pem-certificate-chain", cert.Certificate)
})
/revoke-cert
証明書を失効します。
登録した人の失効リクエストか検証しています。
e.POST("/revoke-cert", func(c echo.Context) error {
addHeader(c)
jws, err := getJWS(c)
if err != nil {
log.Printf("revoke-cert err=%v", err)
return c.JSON(http.StatusBadRequest, err)
}
var jwk *jose.JSONWebKey
var acc *Account
if canExtractJWKFrom(jws) {
jwk = extractJWK(jws)
if jwk == nil {
log.Println("revoke-cert no jwk")
return c.JSON(http.StatusBadRequest, fmt.Errorf("missing jwk"))
}
} else {
acc, err = lookupJWKAndAccount(jws)
if err != nil {
log.Printf("revoke-cert err=%v", err)
return c.JSON(http.StatusBadRequest, err)
}
jwk = acc.Key
}
payload, err := jws.Verify(jwk)
if err != nil {
log.Printf("revoke-cert err=%v", err)
return c.JSON(http.StatusBadRequest, err)
}
var rvr RevokeReqest
if err := json.Unmarshal(payload, &rvr); err != nil {
log.Printf("revoke-cert err=%v", err)
return c.JSON(http.StatusBadRequest, err)
}
// nvrのチェック、
certBytes, err := base64.RawURLEncoding.DecodeString(rvr.Certificate)
if err != nil {
log.Printf("revoke-cert err=%v", err)
return c.JSON(http.StatusBadRequest, err)
}
certToBeRevoked, err := x509.ParseCertificate(certBytes)
if err != nil {
log.Printf("revoke-cert err=%v", err)
return c.JSON(http.StatusBadRequest, err)
}
serial := certToBeRevoked.SerialNumber.String()
cert, ok := certMap[serial]
if !ok {
log.Printf("revoke-cert not found serial=%s", serial)
return c.JSON(http.StatusBadRequest, fmt.Errorf("cert not found serial=%s", serial))
}
// ここで失効できるかチェックする
// jwkが送信された場合とアカウントから取得した場合でチェック方法が変わる
if acc != nil {
// アカウントの場合は、登録しているアカウントが証明書の所有者
if acc.ID != cert.AccountID {
log.Printf("revoke-cert not owner serial=%s", serial)
return c.JSON(http.StatusBadRequest, fmt.Errorf("you do not owwn this cert serial=%s", serial))
}
} else {
// 失効する証明書の秘密鍵で検証する
if _, err := jws.Verify(certToBeRevoked.PublicKey); err != nil {
log.Printf("revoke-cert err=%v", err)
return c.JSON(http.StatusBadRequest, err)
}
}
revokedMap[serial] = cert
delete(certMap, serial)
return c.NoContent(http.StatusOK)
})
HTTPのチャンレンジ
証明書を要求しているサーバーの80番ポートのHTTPサーバーに
GETリクエストを送信してチャレンジで指定した値がかえることで検証します。
とてもシンプルです。
func http01Validate(acc *Account, ch *Challenge) error {
u := &url.URL{Scheme: "http", Host: ch.Value, Path: fmt.Sprintf("/.well-known/acme-challenge/%s", ch.Token)}
client := &http.Client{
Timeout: 30 * time.Second,
Transport: &http.Transport{
Proxy: http.ProxyFromEnvironment,
TLSClientConfig: &tls.Config{
InsecureSkipVerify: true,
},
},
}
r, err := client.Get(u.String())
if err != nil {
return err
}
defer r.Body.Close()
body, err := io.ReadAll(r.Body)
if err != nil {
return err
}
keyAuth := strings.TrimSpace(string(body))
expected, err := KeyAuthorization(ch.Token, acc.Key)
if err != nil {
return err
}
if keyAuth != expected {
return fmt.Errorf("http get %s != %s", keyAuth, expected)
}
return nil
}
余談
ここで紹介した内容は、TWSNMPシリーズにACMEサーバーを組み込むために調べたことです。
組み込んだ結果
に反映されています。
詳しいソースコードを知りたい方は、
か
の
pki/acme.go
を参照してください。