gates / gates (push) Successful in 34s
Names and end dates only, read from Traefik's store; no key material leaves the box. Claude-Session: https://claude.ai/code/session_0159rPz1ZhFKsS53msqPYxtS
120 lines
5.4 KiB
Go
120 lines
5.4 KiB
Go
package infra
|
|
|
|
import (
|
|
"crypto/ecdsa"
|
|
"crypto/elliptic"
|
|
"crypto/rand"
|
|
"crypto/x509"
|
|
"crypto/x509/pkix"
|
|
"encoding/base64"
|
|
"encoding/json"
|
|
"encoding/pem"
|
|
"math/big"
|
|
"os"
|
|
"path/filepath"
|
|
"strings"
|
|
"testing"
|
|
"time"
|
|
)
|
|
|
|
// acmeEntry makes one store entry the way Traefik writes it: the certificate (leaf + a second block, as a chain) and
|
|
// its PRIVATE KEY, both base64 of PEM.
|
|
func acmeEntry(t *testing.T, main string, notAfter time.Time) map[string]any {
|
|
t.Helper()
|
|
key, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
tmpl := &x509.Certificate{SerialNumber: big.NewInt(time.Now().UnixNano()), Subject: pkix.Name{CommonName: main},
|
|
Issuer: pkix.Name{CommonName: "Test CA"}, DNSNames: []string{main}, NotBefore: notAfter.Add(-90 * 24 * time.Hour), NotAfter: notAfter}
|
|
der, err := x509.CreateCertificate(rand.Reader, tmpl, tmpl, &key.PublicKey, key)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
leaf := pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: der})
|
|
chain := append(append([]byte{}, leaf...), leaf...) // a second block stands in for the issuer's certificate
|
|
kb, _ := x509.MarshalECPrivateKey(key)
|
|
keyPEM := pem.EncodeToMemory(&pem.Block{Type: "EC PRIVATE KEY", Bytes: kb})
|
|
return map[string]any{"domain": map[string]any{"main": main, "sans": []string{main}},
|
|
"certificate": base64.StdEncoding.EncodeToString(chain), "key": base64.StdEncoding.EncodeToString(keyPEM), "Store": "default"}
|
|
}
|
|
|
|
func writeStore(t *testing.T, entries ...map[string]any) string {
|
|
t.Helper()
|
|
p := filepath.Join(t.TempDir(), ACMEStoreFile)
|
|
b, _ := json.Marshal(map[string]any{"letsencrypt": map[string]any{
|
|
"Account": map[string]any{"Email": "household@example.hu", "PrivateKey": "QUNDT1VOVC1LRVktTVVTVC1OT1QtTEVBSw=="},
|
|
"Certificates": entries}})
|
|
if err := os.WriteFile(p, b, 0o600); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
return p
|
|
}
|
|
|
|
// felhom.eu R-1379 (D77): the box reads its web certificate's END DATE from Traefik's store so the hub can warn 14
|
|
// days before — and reads NOTHING else from a file that also holds the private keys. The consequence asserted: the
|
|
// right date per name, soonest first; and no byte of a key, the account key or the e-mail in what comes back.
|
|
// COMPANION RED-PROOFS (observed, felhom.eu audits/night-build-2026-11-14/B/redproof-controller-webcert.txt).
|
|
func TestReadWebCerts_EndDatesAndNoKeyMaterial(t *testing.T) {
|
|
soon := time.Date(2026, 11, 20, 1, 46, 0, 0, time.UTC)
|
|
later := time.Date(2027, 1, 19, 1, 46, 0, 0, time.UTC)
|
|
e1, e2 := acmeEntry(t, "*.example.hu", later), acmeEntry(t, "old.example.hu", soon)
|
|
got, err := ReadWebCerts(writeStore(t, e1, e2))
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if len(got) != 2 || got[0].Domain != "old.example.hu" || got[0].NotAfter != "2026-11-20T01:46:00Z" ||
|
|
got[1].Domain != "*.example.hu" || got[1].NotAfter != "2027-01-19T01:46:00Z" {
|
|
t.Fatalf("certificates = %+v, want old.example.hu 2026-11-20 first, then *.example.hu 2027-01-19", got)
|
|
}
|
|
wire, _ := json.Marshal(got)
|
|
for _, secret := range []string{e1["key"].(string), e2["key"].(string), "QUNDT1VOVC1LRVktTVVTVC1OT1QtTEVBSw==", "household@example.hu", "PRIVATE KEY"} {
|
|
if strings.Contains(string(wire), secret) || strings.Contains(string(wire), secret[:min(20, len(secret))]) {
|
|
t.Fatalf("what the box reports carries key material or the e-mail: %s", wire)
|
|
}
|
|
}
|
|
for _, c := range got {
|
|
if len(c.Domain)+len(c.NotAfter)+len(c.Issuer) > 200 {
|
|
t.Errorf("a reported certificate is %d bytes — more than a name, a date and an issuer: %+v", len(c.Domain)+len(c.NotAfter)+len(c.Issuer), c)
|
|
}
|
|
}
|
|
}
|
|
|
|
// No store, an empty store (the file the controller creates before Traefik's first run), and a store with no
|
|
// certificate yet: nothing to report and no error. A store that is not JSON: an error that does not quote the file.
|
|
func TestReadWebCerts_EmptyMissingAndBroken(t *testing.T) {
|
|
dir := t.TempDir()
|
|
if got, err := ReadWebCerts(filepath.Join(dir, "none.json")); got != nil || err != nil {
|
|
t.Errorf("a missing store: %v, %v", got, err)
|
|
}
|
|
empty := filepath.Join(dir, "empty.json")
|
|
_ = os.WriteFile(empty, nil, 0o600)
|
|
if got, err := ReadWebCerts(empty); got != nil || err != nil {
|
|
t.Errorf("an empty store: %v, %v", got, err)
|
|
}
|
|
if got, err := ReadWebCerts(writeStore(t)); len(got) != 0 || err != nil {
|
|
t.Errorf("a store with no certificate: %v, %v", got, err)
|
|
}
|
|
broken := filepath.Join(dir, "broken.json")
|
|
_ = os.WriteFile(broken, []byte(`{"letsencrypt": SECRET-LOOKING-BYTES`), 0o600)
|
|
_, err := ReadWebCerts(broken)
|
|
if err == nil || strings.Contains(err.Error(), "SECRET") {
|
|
t.Errorf("a broken store: err = %v, want an error that does not quote the file", err)
|
|
}
|
|
// an entry whose certificate is not a certificate is skipped, the good one stays
|
|
bad := map[string]any{"domain": map[string]any{"main": "bad.example.hu"}, "certificate": "bm90IGEgY2VydA=="}
|
|
got, err := ReadWebCerts(writeStore(t, bad, acmeEntry(t, "ok.example.hu", time.Date(2027, 2, 1, 0, 0, 0, 0, time.UTC))))
|
|
if err != nil || len(got) != 1 || got[0].Domain != "ok.example.hu" {
|
|
t.Errorf("a store with one unreadable entry: %+v, %v", got, err)
|
|
}
|
|
// more than the cap: the soonest ends stay
|
|
var many []map[string]any
|
|
for i := 0; i < 12; i++ {
|
|
many = append(many, acmeEntry(t, "n"+string(rune('a'+i))+".example.hu", time.Date(2027, 1, 1+i, 0, 0, 0, 0, time.UTC)))
|
|
}
|
|
got, _ = ReadWebCerts(writeStore(t, many...))
|
|
if len(got) != webCertMax || got[0].NotAfter != "2027-01-01T00:00:00Z" {
|
|
t.Errorf("cap: %d entries, first %+v", len(got), got[0])
|
|
}
|
|
}
|