Scert/backend/internal/certificate/lego_protocol_fixture_test.go

376 lines
12 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

package certificate
import (
"context"
"crypto"
"crypto/ecdsa"
"crypto/elliptic"
"crypto/rand"
"crypto/x509"
"crypto/x509/pkix"
"encoding/base64"
"encoding/json"
"encoding/pem"
"fmt"
"io"
"math/big"
"net/http"
"net/http/httptest"
"net/url"
"slices"
"strconv"
"strings"
"sync"
"testing"
"time"
"git.pcux.cn/xuans/Scert/backend/internal/httpchallenge"
"github.com/go-jose/go-jose/v4"
)
type protocolTransport struct {
base http.RoundTripper
target *url.URL
}
func (p protocolTransport) RoundTrip(r *http.Request) (*http.Response, error) {
if r.URL.Scheme == "https" && r.URL.Path == "/directory" &&
(r.URL.Host == "acme-v02.api.letsencrypt.org" || r.URL.Host == "acme-staging-v02.api.letsencrypt.org") {
r = r.Clone(r.Context())
target := *p.target
target.Path = "/directory"
r.URL = &target
r.Host = target.Host
}
if r.URL.Scheme != p.target.Scheme || r.URL.Host != p.target.Host {
return nil, fmt.Errorf("隔离协议测试禁止访问外部地址")
}
return p.base.RoundTrip(r)
}
type protocolCA struct {
mu sync.Mutex
server *httptest.Server
challenge *httptest.Server
client *http.Client
user *AccountUser
root *x509.Certificate
key *ecdsa.PrivateKey
names []string
statuses []string
nonces map[string]bool
sequence int
registered bool
finalized bool
downloads int
proofs int
csrNames []string
chain []byte
reject bool
cancel context.CancelFunc
failures []string
}
func newProtocolCA(t *testing.T, store *httpchallenge.Store, names []string) *protocolCA {
t.Helper()
key, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
if err != nil {
t.Fatal(err)
}
accountKey, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
if err != nil {
t.Fatal(err)
}
template := &x509.Certificate{
SerialNumber: big.NewInt(1), Subject: pkix.Name{CommonName: "Scert protocol test CA"},
NotBefore: time.Now().Add(-time.Hour), NotAfter: time.Now().Add(time.Hour),
IsCA: true, BasicConstraintsValid: true, KeyUsage: x509.KeyUsageCertSign,
}
der, err := x509.CreateCertificate(rand.Reader, template, template, &key.PublicKey, key)
if err != nil {
t.Fatal(err)
}
root, err := x509.ParseCertificate(der)
if err != nil {
t.Fatal(err)
}
c := &protocolCA{
key: key, root: root, names: slices.Clone(names), nonces: make(map[string]bool),
user: &AccountUser{Email: "acme@example.com", PrivateKey: accountKey},
statuses: make([]string, len(names)),
}
for i := range c.statuses {
c.statuses[i] = "pending"
}
c.challenge = httptest.NewServer(store)
t.Cleanup(c.challenge.Close)
c.server = httptest.NewTLSServer(http.HandlerFunc(c.serveHTTP))
t.Cleanup(c.server.Close)
target, err := url.Parse(c.server.URL)
if err != nil {
t.Fatal(err)
}
// 仅注入测试 CA 信任,不跳过 TLS;真实签发器的生产/测试目录选择不变。
c.client = c.server.Client()
c.client.Timeout = 5 * time.Second
c.client.Transport = protocolTransport{base: c.client.Transport, target: target}
return c
}
func protocolToken(index int) string {
return base64.RawURLEncoding.EncodeToString([]byte(fmt.Sprintf("scert-isolated-acme-token-%d", index)))
}
func (c *protocolCA) fail(w http.ResponseWriter, err error) {
c.failures = append(c.failures, err.Error())
http.Error(w, "隔离 CA 协议断言失败", http.StatusBadRequest)
}
func (c *protocolCA) signedPayload(r *http.Request) ([]byte, error) {
if r.Method != http.MethodPost || r.Header.Get("Content-Type") != "application/jose+json" {
return nil, fmt.Errorf("请求方法或签名媒体类型不正确")
}
data, err := io.ReadAll(io.LimitReader(r.Body, 64*1024))
if err != nil {
return nil, err
}
jws, err := jose.ParseSigned(string(data), []jose.SignatureAlgorithm{jose.ES256})
if err != nil || len(jws.Signatures) != 1 {
return nil, fmt.Errorf("请求缺少唯一有效签名")
}
header := jws.Signatures[0].Protected
if !c.nonces[header.Nonce] || header.ExtraHeaders[jose.HeaderKey("url")] != c.server.URL+r.URL.Path {
return nil, fmt.Errorf("签名地址或一次性随机数无效")
}
delete(c.nonces, header.Nonce)
if r.URL.Path == "/account" {
if header.JSONWebKey == nil || header.KeyID != "" {
return nil, fmt.Errorf("注册请求缺少账户公钥")
}
} else if !c.registered || header.KeyID != c.server.URL+"/account/1" || header.JSONWebKey != nil {
return nil, fmt.Errorf("请求未引用已注册账户")
}
return jws.Verify(c.user.PrivateKey.Public())
}
func (c *protocolCA) order() map[string]any {
status := "ready"
ids := make([]map[string]string, len(c.names))
auths := make([]string, len(c.names))
for i, name := range c.names {
ids[i] = map[string]string{"type": "dns", "value": name}
auths[i] = fmt.Sprintf("%s/auth/%d", c.server.URL, i)
if c.statuses[i] != "valid" {
status = "pending"
}
}
if c.finalized {
status = "valid"
}
return map[string]any{"status": status, "identifiers": ids, "authorizations": auths,
"finalize": c.server.URL + "/finalize", "certificate": c.server.URL + "/certificate"}
}
func (c *protocolCA) authorization(index int) map[string]any {
challenge := map[string]any{
"type": "http-01", "url": fmt.Sprintf("%s/challenge/%d", c.server.URL, index),
"status": c.statuses[index], "token": protocolToken(index),
}
if c.statuses[index] == "invalid" {
challenge["error"] = map[string]any{"type": "urn:ietf:params:acme:error:unauthorized", "detail": "隔离 CA 拒绝授权", "status": 403}
}
return map[string]any{
"status": c.statuses[index], "identifier": map[string]string{"type": "dns", "value": c.names[index]},
"challenges": []any{map[string]string{"type": "dns-01", "url": c.server.URL + "/forbidden-dns", "status": "pending", "token": protocolToken(index)}, challenge},
}
}
func (c *protocolCA) readProof(ctx context.Context, name, token string) (int, string, error) {
ctx, cancel := context.WithTimeout(ctx, 3*time.Second)
defer cancel()
req, err := http.NewRequestWithContext(ctx, http.MethodGet, c.challenge.URL+httpchallenge.PathPrefix+token, nil)
if err != nil {
return 0, "", err
}
req.Host = name
resp, err := c.challenge.Client().Do(req)
if err != nil {
return 0, "", err
}
defer resp.Body.Close()
data, err := io.ReadAll(io.LimitReader(resp.Body, 4096))
return resp.StatusCode, string(data), err
}
func (c *protocolCA) serveHTTP(w http.ResponseWriter, r *http.Request) {
c.mu.Lock()
defer c.mu.Unlock()
c.sequence++
nonce := base64.RawURLEncoding.EncodeToString([]byte(fmt.Sprintf("scert-isolated-nonce-%d", c.sequence)))
c.nonces[nonce] = true
w.Header().Set("Replay-Nonce", nonce)
w.Header().Set("Content-Type", "application/json")
write := func(value any) { _ = json.NewEncoder(w).Encode(value) }
if r.URL.Path == "/directory" && r.Method == http.MethodGet {
write(map[string]any{"newNonce": c.server.URL + "/nonce", "newAccount": c.server.URL + "/account", "newOrder": c.server.URL + "/order", "revokeCert": c.server.URL + "/revoke", "keyChange": c.server.URL + "/key-change"})
return
}
if r.URL.Path == "/nonce" && r.Method == http.MethodHead {
w.WriteHeader(http.StatusNoContent)
return
}
payload, err := c.signedPayload(r)
if err != nil {
c.fail(w, err)
return
}
switch r.URL.Path {
case "/account":
var account struct {
Terms bool `json:"termsOfServiceAgreed"`
Contact []string `json:"contact"`
}
if json.Unmarshal(payload, &account) != nil || !account.Terms || !slices.Equal(account.Contact, []string{"mailto:" + c.user.Email}) {
c.fail(w, fmt.Errorf("注册未确认条款或账户邮箱不符"))
return
}
c.registered = true
w.Header().Set("Location", c.server.URL+"/account/1")
w.WriteHeader(http.StatusCreated)
write(map[string]any{"status": "valid", "contact": account.Contact, "orders": c.server.URL + "/orders"})
case "/order":
var order struct {
Identifiers []struct {
Type string `json:"type"`
Value string `json:"value"`
} `json:"identifiers"`
}
if json.Unmarshal(payload, &order) != nil || len(order.Identifiers) != len(c.names) {
c.fail(w, fmt.Errorf("订单域名数量不符"))
return
}
for i, id := range order.Identifiers {
if id.Type != "dns" || id.Value != c.names[i] {
c.fail(w, fmt.Errorf("订单域名被修改"))
return
}
}
w.Header().Set("Location", c.server.URL+"/order/1")
w.WriteHeader(http.StatusCreated)
write(c.order())
case "/order/1":
if len(payload) != 0 {
c.fail(w, fmt.Errorf("订单查询不是签名空载荷"))
return
}
write(c.order())
case "/finalize":
if err := c.finalize(payload); err != nil {
c.fail(w, err)
return
}
write(c.order())
case "/certificate":
if !c.finalized || len(payload) != 0 {
c.fail(w, fmt.Errorf("证书下载早于订单完成或载荷非空"))
return
}
c.downloads++
w.Header().Set("Content-Type", "application/pem-certificate-chain")
_, _ = w.Write(c.chain)
default:
parts := strings.Split(strings.TrimPrefix(r.URL.Path, "/"), "/")
if len(parts) != 2 || (parts[0] != "auth" && parts[0] != "challenge") {
c.fail(w, fmt.Errorf("客户端访问了未授权协议路径:%s", r.URL.Path))
return
}
index, err := strconv.Atoi(parts[1])
if err != nil || index < 0 || index >= len(c.names) {
c.fail(w, fmt.Errorf("授权索引无效"))
return
}
if parts[0] == "auth" {
if len(payload) != 0 {
var state struct {
Status string `json:"status"`
}
if json.Unmarshal(payload, &state) != nil || state.Status != "deactivated" {
c.fail(w, fmt.Errorf("授权更新不是合法停用"))
return
}
c.statuses[index] = "deactivated"
}
write(c.authorization(index))
return
}
if err := c.verifyChallenge(r.Context(), index); err != nil {
c.fail(w, err)
return
}
write(c.authorization(index)["challenges"].([]any)[1])
}
}
func (c *protocolCA) verifyChallenge(ctx context.Context, index int) error {
key := jose.JSONWebKey{Key: c.user.PrivateKey.Public()}
thumbprint, err := key.Thumbprint(crypto.SHA256)
if err != nil {
return err
}
code, value, err := c.readProof(ctx, c.names[index], protocolToken(index))
if err != nil || code != http.StatusOK || value != protocolToken(index)+"."+base64.RawURLEncoding.EncodeToString(thumbprint) {
return fmt.Errorf("真实 HTTP 挑战未返回账户公钥绑定证明:状态=%d", code)
}
c.proofs++
c.statuses[index] = "valid"
if c.reject {
c.statuses[index] = "invalid"
}
if c.cancel != nil {
c.cancel()
}
return nil
}
func (c *protocolCA) finalize(payload []byte) error {
for _, status := range c.statuses {
if status != "valid" {
return fmt.Errorf("未完成全部授权就提交 CSR")
}
}
var request struct {
CSR string `json:"csr"`
}
if err := json.Unmarshal(payload, &request); err != nil {
return err
}
der, err := base64.RawURLEncoding.DecodeString(request.CSR)
if err != nil {
return err
}
csr, err := x509.ParseCertificateRequest(der)
if err != nil {
return err
}
if err := csr.CheckSignature(); err != nil {
return err
}
if !slices.Equal(csr.DNSNames, c.names) {
return fmt.Errorf("CSR 的 SAN 与原订单不符")
}
c.csrNames = slices.Clone(csr.DNSNames)
leaf := &x509.Certificate{
SerialNumber: big.NewInt(2), Subject: csr.Subject, DNSNames: csr.DNSNames,
NotBefore: time.Now().Add(-time.Minute), NotAfter: time.Now().Add(30 * time.Minute),
KeyUsage: x509.KeyUsageDigitalSignature, ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth},
}
der, err = x509.CreateCertificate(rand.Reader, leaf, c.root, csr.PublicKey, c.key)
if err != nil {
return err
}
c.chain = pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: der})
c.chain = append(c.chain, pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: c.root.Raw})...)
c.finalized = true
return nil
}