mirror of
https://github.com/gesellix/Bose-SoundTouch.git
synced 2026-08-19 17:16:16 +00:00
Hardens TrustCACertFromBytes against the failure mode behind issue #262 (corrupted /etc/pki/tls/certs/ca-bundle.crt on a SoundTouch 20) and against silent transport-time corruption of our own writes. Three-part change. 1. Atomic write path. The previous flow piped bytes straight into the live bundle via `cat > <path>`; a dropped SSH session or partial write left the device with a half-written trust store and no way to roll back. The new path: - uploads to <bundlePath>.aftertouch.tmp (sibling on the same filesystem, same rw remount), - reads the tmp back over SSH, - validates the readback at the PEM-frame layer + the AfterTouch sentinel bracketing, - atomically `mv`s the tmp into place, - on any verification failure: `rm -f` the tmp; the live bundle is never touched, so there is no rollback semantics to reason about. The .original backup written on first install stays as defense-in-depth (manual recovery for corruption from outside this code path), but it is no longer the primary safety net. 2. New validators in pkg/service/setup/ca_validation.go. - validateCABundleBytes: BEGIN/END marker counts match, every decoded block is a CERTIFICATE with a non-empty body, decoded block count equals BEGIN-marker count (catches a block with unparseable base64 body), trailing non-PEM/non-comment content rejected. - validateAfterTouchLabelBracketing: CALabel appears exactly twice and brackets exactly one CERTIFICATE block. - stripAfterTouchEntries: collapses any number of stale AfterTouch entries from the existing bundle. Older releases reported to have appended without stripping, so long-lived devices can carry several copies; we strip them all and log the cleanup count rather than failing validation. Unpaired sentinels (truncated prior install) surface as a structured anomaly the caller logs and warns about. The validators stay at the PEM-frame layer on purpose — an earlier iteration called x509.ParseCertificate per block and rejected the real ST20 bundle on block 29 (Go 1.23+ disallows negative serial numbers, but Mozilla CCADB still ships ancient CA roots that have them). Shipping that version would have made every legitimate speaker install fail. The corruption mode #262 surfaces at the PEM-framing layer; x509-level checks aren't what we needed. 3. testdata/ca_bundle_st20_pristine.crt is the pristine /etc/pki/tls/certs/ca-bundle.crt captured off a real SoundTouch 20 (firmware 27.0.6.46330.5043500, snapshot 2022-08-04). Mozilla CCADB public dataset, 165 certs, ~251 KB. TestValidateRealSpeakerBundle locks in the cert count and asserts the strip pass is a no-op against a bundle that has never been touched by AfterTouch. Test infrastructure. mockSSH (both the setup-package and the handlers-package copies) now mirrors UploadContent into a private map so a subsequent `cat <path>` on the same path returns what was written there. Lets the tmp-readback step in TrustCACertFromBytes work against tests that only scripted the live-bundle path, without per-test wiring. Two new behavioural tests in setup_test.go: TestTrustCACert_StripsMultipleStaleEntriesSilently (pins the multi-entry cleanup contract) and TestTrustCACert_PostUploadVerificationFailureCleansUpTmp (pins the rollback-free recovery: live bundle untouched, tmp removed). Refs #262. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
211 lines
6.6 KiB
Go
211 lines
6.6 KiB
Go
package setup
|
|
|
|
import (
|
|
"bytes"
|
|
"encoding/pem"
|
|
"fmt"
|
|
"strings"
|
|
)
|
|
|
|
// validateCABundleBytes walks bundle as a sequence of PEM-encoded
|
|
// CERTIFICATE blocks and asserts the framing is structurally intact:
|
|
// every BEGIN marker has a matching END marker, every block decodes
|
|
// as a valid PEM block, and no stray non-PEM/non-comment content
|
|
// appears between blocks. We deliberately do NOT call
|
|
// x509.ParseCertificate on the block bytes — that would reject
|
|
// legitimate Mozilla CCADB entries (negative serial numbers, ancient
|
|
// certificates from the 2000s that fail strict RFC 5280 enforcement
|
|
// in Go 1.23+), and the failure mode this check exists to defend
|
|
// against (issue #262, a corrupted CA bundle on disk) shows up at
|
|
// the PEM-framing layer, not at the x509 layer.
|
|
//
|
|
// Returns the parsed block count on success.
|
|
func validateCABundleBytes(bundle []byte) (int, error) {
|
|
if len(bundle) == 0 {
|
|
return 0, fmt.Errorf("CA bundle is empty")
|
|
}
|
|
|
|
const (
|
|
beginMarker = "-----BEGIN CERTIFICATE-----"
|
|
endMarker = "-----END CERTIFICATE-----"
|
|
)
|
|
|
|
beginCount := bytes.Count(bundle, []byte(beginMarker))
|
|
|
|
endCount := bytes.Count(bundle, []byte(endMarker))
|
|
if beginCount != endCount {
|
|
return 0, fmt.Errorf("PEM framing mismatch: %d BEGIN markers, %d END markers", beginCount, endCount)
|
|
}
|
|
|
|
rest := bundle
|
|
|
|
count := 0
|
|
|
|
for {
|
|
var block *pem.Block
|
|
|
|
block, rest = pem.Decode(rest)
|
|
if block == nil {
|
|
break
|
|
}
|
|
|
|
count++
|
|
|
|
if block.Type != "CERTIFICATE" {
|
|
return count, fmt.Errorf("PEM block %d has type %q, want CERTIFICATE", count, block.Type)
|
|
}
|
|
|
|
if len(block.Bytes) == 0 {
|
|
return count, fmt.Errorf("PEM block %d has empty body", count)
|
|
}
|
|
}
|
|
|
|
if count == 0 {
|
|
return 0, fmt.Errorf("CA bundle contains no PEM CERTIFICATE blocks")
|
|
}
|
|
|
|
if count != beginCount {
|
|
return count, fmt.Errorf("decoded %d PEM blocks but found %d BEGIN markers (suggests a block has unparseable base64 body)", count, beginCount)
|
|
}
|
|
|
|
if trail := bytes.TrimSpace(rest); len(trail) > 0 {
|
|
// Tolerate anything that's just whitespace, comments, or our
|
|
// own sentinel lines — but reject stray non-PEM bytes that
|
|
// don't fall on a block boundary. Comment lines (starting
|
|
// with `#`) are allowed because CALabel is one.
|
|
for _, raw := range bytes.Split(trail, []byte("\n")) {
|
|
line := bytes.TrimSpace(raw)
|
|
if len(line) == 0 {
|
|
continue
|
|
}
|
|
|
|
if bytes.HasPrefix(line, []byte("#")) {
|
|
continue
|
|
}
|
|
|
|
return count, fmt.Errorf("trailing non-PEM content after block %d: %q", count, line)
|
|
}
|
|
}
|
|
|
|
return count, nil
|
|
}
|
|
|
|
// validateAfterTouchLabelBracketing asserts the AfterTouch CALabel
|
|
// sentinel appears exactly twice in bundle (open + close), and that
|
|
// exactly one CERTIFICATE block sits between the two occurrences.
|
|
// Used as a post-upload check to detect transport truncation that
|
|
// either drops the closing sentinel or drops the certificate body
|
|
// between them.
|
|
func validateAfterTouchLabelBracketing(bundle []byte) error {
|
|
count := strings.Count(string(bundle), CALabel)
|
|
if count != 2 {
|
|
return fmt.Errorf("AfterTouch CA label %q appears %d times, want exactly 2 (open + close)", CALabel, count)
|
|
}
|
|
|
|
parts := strings.SplitN(string(bundle), CALabel, 3)
|
|
if len(parts) != 3 {
|
|
// Shouldn't reach here given the count check above, but
|
|
// defend against malformed input that splits unexpectedly.
|
|
return fmt.Errorf("AfterTouch CA label %q does not bracket cleanly", CALabel)
|
|
}
|
|
|
|
bracketed := parts[1]
|
|
|
|
if strings.Count(bracketed, "-----BEGIN CERTIFICATE-----") != 1 {
|
|
return fmt.Errorf("expected exactly one BEGIN CERTIFICATE between AfterTouch CA labels, found %d",
|
|
strings.Count(bracketed, "-----BEGIN CERTIFICATE-----"))
|
|
}
|
|
|
|
if strings.Count(bracketed, "-----END CERTIFICATE-----") != 1 {
|
|
return fmt.Errorf("expected exactly one END CERTIFICATE between AfterTouch CA labels, found %d",
|
|
strings.Count(bracketed, "-----END CERTIFICATE-----"))
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
// stripAfterTouchEntriesResult is the structured outcome of
|
|
// stripAfterTouchEntries — non-fatal anomalies surface as fields so
|
|
// the caller can decide whether to log them or surface them in the
|
|
// migration UI.
|
|
type stripAfterTouchEntriesResult struct {
|
|
// CleanedBundle is the bundle content with every AfterTouch entry
|
|
// (each `# AfterTouch` sentinel pair and the cert lines between
|
|
// them) removed.
|
|
CleanedBundle string
|
|
|
|
// RemovedEntries counts the number of complete sentinel pairs
|
|
// stripped. >1 means an earlier release added our CA more than
|
|
// once and we just collapsed the duplicates; the caller should
|
|
// log this so the user knows their bundle was cleaned up.
|
|
RemovedEntries int
|
|
|
|
// UnpairedSentinel is true when the input had an odd number of
|
|
// AfterTouch sentinel lines — a sign of a previous truncated or
|
|
// botched install. The trailing "open" sentinel and anything that
|
|
// follows it (until EOF) gets dropped along with the orphaned
|
|
// half of a pair; that may silently drop legitimate non-AfterTouch
|
|
// content that happened to sit after the truncation point, which
|
|
// is why we surface this as a structured anomaly rather than
|
|
// just logging it.
|
|
UnpairedSentinel bool
|
|
}
|
|
|
|
// stripAfterTouchEntries removes every CALabel sentinel line from
|
|
// bundle and every line between paired sentinels (i.e. the
|
|
// previously-injected AfterTouch CA payload). It's the line-walking
|
|
// equivalent of "strip our own entry"; the caller appends a fresh
|
|
// entry afterward.
|
|
//
|
|
// The implementation tolerates the multi-entry case explicitly —
|
|
// older AfterTouch releases are reported to have appended the CA on
|
|
// every install without stripping the previous one, so the live
|
|
// bundle on long-lived devices may carry several copies. We strip
|
|
// them all and let the caller log the cleanup count.
|
|
func stripAfterTouchEntries(bundle string) stripAfterTouchEntriesResult {
|
|
lines := strings.Split(bundle, "\n")
|
|
|
|
var (
|
|
out []string
|
|
inOurCA bool
|
|
removedEntries int
|
|
unpairedTrailer bool
|
|
)
|
|
|
|
for _, line := range lines {
|
|
if strings.Contains(line, CALabel) {
|
|
if inOurCA {
|
|
// closing sentinel — one full entry consumed
|
|
removedEntries++
|
|
}
|
|
|
|
inOurCA = !inOurCA
|
|
|
|
continue
|
|
}
|
|
|
|
if !inOurCA {
|
|
out = append(out, line)
|
|
}
|
|
}
|
|
|
|
if inOurCA {
|
|
// Loop ended with an open bracket — trailing content was
|
|
// dropped along with the unpaired opening sentinel. The
|
|
// (truncated) entry doesn't count as "removed" because no
|
|
// closing sentinel ever marked it complete.
|
|
unpairedTrailer = true
|
|
}
|
|
|
|
cleaned := strings.Join(out, "\n")
|
|
if cleaned != "" && !strings.HasSuffix(cleaned, "\n") {
|
|
cleaned += "\n"
|
|
}
|
|
|
|
return stripAfterTouchEntriesResult{
|
|
CleanedBundle: cleaned,
|
|
RemovedEntries: removedEntries,
|
|
UnpairedSentinel: unpairedTrailer,
|
|
}
|
|
}
|