1
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

Go言語でPKI入門(勝手に復刻): ACME編

Posted at

はじめに

Go言語でPKI入門(勝手に復刻版)

のACME編です。

ACMEについて

Automatic Certificate Management Environment (ACME)は、
サーバー証明書の管理を自動化するための仕組みです。

Let’s Encryptによる証明書発行の自動化のために使われています。

に詳しい説明があります。

RFC8555

ACMEのプロトコル仕様は、RFC8555で定義されています。

まじめに全部読んで理解したほうがよいと思いますが、ざっくり要点だけ読んでまとめた図は

image.png

のようになります。

stepcaのソースコード分析

インターネット環境でサーバー証明書の発行を自動化したければ、Let’s Encryptを使えばよいのですが、
プラベートな環境でも証明書の発行を自動化したいことがあると思います。その場合に使えるのが、stepca

です。使い方を

の記事に書きました。

このサーバーはGo言語で開発されていて、GitHUBで公開されています。

ACMEのソースコードは、パッケージとして他のプロジェクトから使えるような状態ではありません。
そこで、まじめにソースコードを読んで必要部分を整理しました。

image.png

のような処理です。

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

を参照してください。

1
1
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
1
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?