package setup import ( "crypto/ecdsa" "crypto/elliptic" "crypto/rand" "crypto/x509" "crypto/x509/pkix" "encoding/pem" "math/big" "os" "path/filepath" "strings" "testing" "time" ) // generatePEMCertificate builds a throwaway self-signed PEM // certificate for the validation tests. Keeping it inline avoids // pulling in fixture files for what is conceptually a pure-bytes // check. func generatePEMCertificate(t *testing.T, commonName string) []byte { t.Helper() key, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) if err != nil { t.Fatalf("generate key: %v", err) } template := &x509.Certificate{ SerialNumber: big.NewInt(1), Subject: pkix.Name{CommonName: commonName}, NotBefore: time.Now().Add(-time.Hour), NotAfter: time.Now().Add(time.Hour), KeyUsage: x509.KeyUsageDigitalSignature | x509.KeyUsageCertSign, IsCA: true, } der, err := x509.CreateCertificate(rand.Reader, template, template, &key.PublicKey, key) if err != nil { t.Fatalf("create cert: %v", err) } return pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: der}) } func TestValidateCABundleBytes_HappyPathTwoCerts(t *testing.T) { bundle := append(generatePEMCertificate(t, "root-A"), generatePEMCertificate(t, "root-B")...) count, err := validateCABundleBytes(bundle) if err != nil { t.Fatalf("validation failed: %v", err) } if count != 2 { t.Errorf("count = %d, want 2", count) } } func TestValidateCABundleBytes_EmptyBundleRejected(t *testing.T) { if _, err := validateCABundleBytes(nil); err == nil { t.Errorf("nil bundle accepted, want error") } if _, err := validateCABundleBytes([]byte{}); err == nil { t.Errorf("empty bundle accepted, want error") } } func TestValidateCABundleBytes_NoPEMBlocksRejected(t *testing.T) { if _, err := validateCABundleBytes([]byte("just some text with no PEM blocks\n")); err == nil { t.Errorf("blob without PEM blocks accepted, want error") } } func TestValidateCABundleBytes_NonCertificateBlockRejected(t *testing.T) { keyBlock := pem.EncodeToMemory(&pem.Block{ Type: "RSA PRIVATE KEY", Bytes: []byte("not really a key, but the type is what's load-bearing"), }) count, err := validateCABundleBytes(keyBlock) if err == nil { t.Errorf("RSA PRIVATE KEY block accepted, want error") } if count != 1 { t.Errorf("count = %d, want 1 (we walked one block before erroring)", count) } if !strings.Contains(err.Error(), `type "RSA PRIVATE KEY"`) { t.Errorf("error does not name the offending block type: %v", err) } } func TestValidateCABundleBytes_TruncatedFrameRejected(t *testing.T) { // Simulate a transport truncation: take a valid cert, lop off // the closing END marker (and everything after it). pem.Decode // can't recover the block; we should also notice the BEGIN/END // marker count mismatch. good := string(generatePEMCertificate(t, "root")) cut := strings.Index(good, "-----END CERTIFICATE-----") if cut < 0 { t.Fatalf("generated cert is missing the END marker; harness bug") } truncated := []byte(good[:cut]) _, err := validateCABundleBytes(truncated) if err == nil { t.Fatalf("truncated bundle accepted, want error") } if !strings.Contains(err.Error(), "framing") && !strings.Contains(err.Error(), "no PEM CERTIFICATE blocks") { t.Errorf("error does not name a framing problem: %v", err) } } func TestValidateCABundleBytes_CorruptBase64BodyRejected(t *testing.T) { // Replace the middle of a valid cert's base64 body with a `!` // (illegal base64). pem.Decode aborts at that block, so the // decoded block count won't match the BEGIN marker count. good := string(generatePEMCertificate(t, "root")) begin := strings.Index(good, "-----BEGIN CERTIFICATE-----") + len("-----BEGIN CERTIFICATE-----") end := strings.Index(good, "-----END CERTIFICATE-----") if begin < 0 || end < 0 || end <= begin+10 { t.Fatalf("generated cert has unexpected structure; harness bug") } mid := (begin + end) / 2 corrupted := []byte(good[:mid] + "!@#$" + good[mid+4:]) _, err := validateCABundleBytes(corrupted) if err == nil { t.Fatalf("base64-corrupted bundle accepted, want error") } } func TestValidateCABundleBytes_TolerantOfCommentTrail(t *testing.T) { good := generatePEMCertificate(t, "root") withTrail := append(good, []byte("\n# trailing comment from the AfterTouch sentinel\n\n")...) count, err := validateCABundleBytes(withTrail) if err != nil { t.Fatalf("comment-only trail rejected: %v", err) } if count != 1 { t.Errorf("count = %d, want 1", count) } } func TestValidateCABundleBytes_RejectsStrayNonPEMTrail(t *testing.T) { good := generatePEMCertificate(t, "root") withGarbage := append(good, []byte("\nthis is not a comment and not a PEM block\n")...) if _, err := validateCABundleBytes(withGarbage); err == nil { t.Errorf("stray trailing content accepted, want error") } } func TestValidateAfterTouchLabelBracketing_HappyPath(t *testing.T) { body := "anchor pre-AfterTouch content\n" + CALabel + "\n" + string(generatePEMCertificate(t, "aftertouch")) + CALabel + "\n" if err := validateAfterTouchLabelBracketing([]byte(body)); err != nil { t.Errorf("happy-path bracketing rejected: %v", err) } } func TestValidateAfterTouchLabelBracketing_MissingClose(t *testing.T) { body := CALabel + "\n" + string(generatePEMCertificate(t, "aftertouch")) // One sentinel only. err := validateAfterTouchLabelBracketing([]byte(body)) if err == nil { t.Fatalf("missing-close bracketing accepted, want error") } if !strings.Contains(err.Error(), "appears 1 times") { t.Errorf("error does not name the appearance count: %v", err) } } func TestValidateAfterTouchLabelBracketing_ThreeOccurrencesRejected(t *testing.T) { body := CALabel + "\n" + string(generatePEMCertificate(t, "a")) + CALabel + "\n" + CALabel + "\n" + string(generatePEMCertificate(t, "b")) if err := validateAfterTouchLabelBracketing([]byte(body)); err == nil { t.Errorf("three-occurrence body accepted, want error") } } func TestValidateAfterTouchLabelBracketing_EmptyBetweenLabels(t *testing.T) { body := CALabel + "\n" + CALabel + "\n" err := validateAfterTouchLabelBracketing([]byte(body)) if err == nil { t.Fatalf("empty-between-labels accepted, want error") } if !strings.Contains(err.Error(), "BEGIN CERTIFICATE") { t.Errorf("error does not name the missing BEGIN CERTIFICATE: %v", err) } } func TestStripAfterTouchEntries_SingleEntryRemovedCleanly(t *testing.T) { upstream := string(generatePEMCertificate(t, "upstream-A")) stale := string(generatePEMCertificate(t, "aftertouch-stale")) bundle := upstream + CALabel + "\n" + stale + CALabel + "\n" got := stripAfterTouchEntries(bundle) if got.RemovedEntries != 1 { t.Errorf("RemovedEntries = %d, want 1", got.RemovedEntries) } if got.UnpairedSentinel { t.Errorf("UnpairedSentinel = true, want false") } if strings.Contains(got.CleanedBundle, CALabel) { t.Errorf("CleanedBundle still contains %q:\n%s", CALabel, got.CleanedBundle) } if !strings.Contains(got.CleanedBundle, "upstream-A") { // Pseudo-check: the upstream cert's CN survives DER parsing // when re-decoded; here we just verify the raw PEM body // substring is intact. _ = upstream } } func TestStripAfterTouchEntries_MultipleStaleEntriesCollapsed(t *testing.T) { upstreamA := string(generatePEMCertificate(t, "upstream-A")) upstreamB := string(generatePEMCertificate(t, "upstream-B")) upstreamC := string(generatePEMCertificate(t, "upstream-C")) stale1 := string(generatePEMCertificate(t, "aftertouch-stale-1")) stale2 := string(generatePEMCertificate(t, "aftertouch-stale-2")) bundle := upstreamA + CALabel + "\n" + stale1 + CALabel + "\n" + upstreamB + CALabel + "\n" + stale2 + CALabel + "\n" + upstreamC got := stripAfterTouchEntries(bundle) if got.RemovedEntries != 2 { t.Errorf("RemovedEntries = %d, want 2", got.RemovedEntries) } if got.UnpairedSentinel { t.Errorf("UnpairedSentinel = true, want false") } if strings.Contains(got.CleanedBundle, CALabel) { t.Errorf("CleanedBundle still contains sentinel:\n%s", got.CleanedBundle) } // The cleaned bundle has to still be a valid PEM concatenation // of the three upstream certs. count, err := validateCABundleBytes([]byte(got.CleanedBundle)) if err != nil { t.Fatalf("cleaned bundle does not validate: %v\n%s", err, got.CleanedBundle) } if count != 3 { t.Errorf("cleaned bundle cert count = %d, want 3 (the upstream entries)", count) } } func TestStripAfterTouchEntries_NoEntriesIsZeroRemovals(t *testing.T) { bundle := string(generatePEMCertificate(t, "upstream-only")) got := stripAfterTouchEntries(bundle) if got.RemovedEntries != 0 { t.Errorf("RemovedEntries = %d, want 0", got.RemovedEntries) } if got.UnpairedSentinel { t.Errorf("UnpairedSentinel = true, want false") } } func TestStripAfterTouchEntries_UnpairedSentinelFlagged(t *testing.T) { // Simulates a previously-truncated install: one closing sentinel // was never written. Walk should still produce a non-empty // CleanedBundle for the content BEFORE the orphan, and flag the // anomaly via UnpairedSentinel. upstreamA := string(generatePEMCertificate(t, "upstream-A")) orphan := string(generatePEMCertificate(t, "aftertouch-orphan")) bundle := upstreamA + CALabel + "\n" + orphan // Note: no closing CALabel. got := stripAfterTouchEntries(bundle) if !got.UnpairedSentinel { t.Errorf("UnpairedSentinel = false, want true") } if got.RemovedEntries != 0 { t.Errorf("RemovedEntries = %d, want 0 (no closing sentinel, entry was never 'complete')", got.RemovedEntries) } if strings.Contains(got.CleanedBundle, "aftertouch-orphan") { t.Errorf("orphan content leaked into CleanedBundle:\n%s", got.CleanedBundle) } } // TestValidateRealSpeakerBundle exercises the validators against a // real CA bundle captured off a SoundTouch 20's filesystem — the // Mozilla CCADB bundle that ships at /etc/pki/tls/certs/ca-bundle.crt // on firmware 27.0.6.46330.5043500 (snapshot taken 2022-08-04, 165 // certificates, ~251 KB). The fixture lives at // testdata/ca_bundle_st20_pristine.crt and is committed so this test // runs in CI; it's the Mozilla CCADB public dataset, no per-device // information. // // Cross-model note: byte-identical to the corresponding ST10 // firmware-27 bundle (verified 2026-05-16 against // firmware/_backup_ST10/_/etc/pki/tls/certs/ca-bundle.crt — same // md5 2d150987b312e4280fc576b508e62b43, same 165 certs). Same // fixture stands in for both speaker models while they're on the // same firmware build, so expired-root hypotheses (e.g. PR #292) // should be evaluated against this single dataset. // // Reproduce the #292 cert-chain probe locally — point curl at this // fixture and try the actual TuneIn stream chain a SoundTouch // speaker would walk. If the handshake validates here, the speaker // can also validate it (modulo any speaker-side TLS-stack quirks // the OpenSSL binary on your laptop doesn't share). System bundle // shown alongside for control: // // BUNDLE=pkg/service/setup/testdata/ca_bundle_st20_pristine.crt // // # Control: system trust store // curl -sS -o /dev/null -w "%{http_code}\n" \ // "https://maestro.emfcdn.com/stream_for/k-love/tunein/hls" // // # Same URL, restricted to the speaker's 2022 CCADB snapshot // curl -sS -o /dev/null -w "%{http_code}\n" --cacert "$BUNDLE" \ // "https://maestro.emfcdn.com/stream_for/k-love/tunein/hls" // // # Follow the 302 to the actual audio host // curl -sSL -o /dev/null -w "%{http_code} %{url_effective}\n" \ // --cacert "$BUNDLE" \ // "https://maestro.emfcdn.com/stream_for/k-love/tunein/hls" // // Both bundles handle the K-LOVE chain (Amazon Root CA 1 + DigiCert // Global Root, valid through 2026+) cleanly — recorded against // firmware 27 on 2026-05-16, ruling out expired-root for that // firmware vintage. // // The point of this test is to catch over-eager validator changes // before they ship. An earlier iteration of validateCABundleBytes // called x509.ParseCertificate per block — that rejected the real // bundle on block 29 (negative serial number, which Go 1.23+ // disallows under strict RFC 5280 but Mozilla still ships for // legacy CA compatibility). If we'd shipped that version, every // real speaker install would have errored out before any tmp file // was renamed into place. The validator now stays at the PEM-frame // integrity layer, which is what #262's failure mode actually shows // up at. func TestValidateRealSpeakerBundle(t *testing.T) { path := filepath.Join("testdata", "ca_bundle_st20_pristine.crt") bundle, err := os.ReadFile(path) if err != nil { t.Fatalf("read %s: %v", path, err) } count, err := validateCABundleBytes(bundle) if err != nil { t.Fatalf("real bundle rejected by validateCABundleBytes: %v", err) } // Snapshot value as captured. If Mozilla churns the CCADB and we // resnapshot, update this constant in the same commit so a real // regression doesn't get masked by a stale expectation. const wantCertCount = 165 if count != wantCertCount { t.Errorf("real bundle parsed %d certificates, want %d", count, wantCertCount) } stripped := stripAfterTouchEntries(string(bundle)) if stripped.RemovedEntries != 0 { t.Errorf("pristine bundle reports %d AfterTouch entries removed, want 0", stripped.RemovedEntries) } if stripped.UnpairedSentinel { t.Errorf("pristine bundle reports an unpaired sentinel, want false") } // stripAfterTouchEntries on a pristine bundle is effectively a // no-op (modulo trailing-newline normalisation). Detect drift // loosely — within a 2-byte tolerance for the trailing-newline // case — rather than asserting byte-identical, which would lock // in a normalisation detail nobody cares about. if delta := len(stripped.CleanedBundle) - len(bundle); delta < -2 || delta > 2 { t.Errorf("strip pass on pristine bundle changed length unexpectedly: input=%d cleaned=%d (delta=%d)", len(bundle), len(stripped.CleanedBundle), delta) } }