package main import ( "encoding/json" "fmt" "math/rand" "os" "path/filepath" "time" ) // The supply-chain bake window: beta and stable subscribers don't install a // release the moment it appears — the box waits until the release has sat at // the channel head for a while first, so a compromised release that gets // caught and pulled back (docs/server-releases.md) never reaches them. The // clock is THIS BOX's own observation ("when did I first see this version at // this channel head"), never a manifest timestamp: an attacker holding the // signing key controls every timestamp in the feed and would simply backdate // one, but cannot backdate what the box already saw. // // edge has no window — it IS the operator's own deploy path (`publish && // ship`). beta's 24h absorbs the caught-within-a-day mass of real // supply-chain incidents; stable's 72h additionally spans a weekend, so a // Friday-night malicious publish is seen by a human on Monday with margin to // pull it back. // // There is deliberately NO publisher-side bypass ("critical fix, skip the // hold") — that flag is the first thing an attacker sets. The bypass is // local and explicit: `lchat update run --ignore-bake` or a plain // `lchat deploy --version `, both of which need root on the box. var bakeDelays = map[string]time.Duration{ "edge": 0, "beta": 24 * time.Hour, "stable": 72 * time.Hour, } // bakeHours is the display form consoles show ("held up to N hours"). func bakeHours(channel string) int { return int(bakeDelays[channel].Hours()) } // firstSeenPath persists the bake clock, next to the anti-replay stamp. func firstSeenPath() string { return filepath.Join(optDir, "channel-first-seen.json") } // firstSeenKeep bounds each channel's history. Past heads are kept (not just // the current one) so a pull-back to a previously-seen version is immediately // eligible — evacuating a bad release must never itself sit in a bake window. const firstSeenKeep = 12 // firstSeenState is the bake clock: for each channel, when this box first // observed each version at its head. type firstSeenState struct { // Jitter desynchronises a fleet of boxes: each waits delay*(1+Jitter), so // self-hosted boxes don't all install in the same hour and the earliest // become canaries for the rest. Minted once per box, in [0, 0.25). Jitter float64 `json:"jitter"` Channels map[string]map[string]time.Time `json:"channels"` } // loadFirstSeen reads the bake clock. A missing or corrupt file is an empty // clock — every head then bakes from "now", which fails closed (a wiped clock // delays updates; it never lets one through early). func loadFirstSeen() *firstSeenState { st := &firstSeenState{} if raw, err := os.ReadFile(firstSeenPath()); err == nil { _ = json.Unmarshal(raw, st) } if st.Channels == nil { st.Channels = map[string]map[string]time.Time{} } if st.Jitter < 0 || st.Jitter >= 0.25 { st.Jitter = 0 } return st } // observe records every channel head the manifest shows that this box hasn't // seen at that head before. Returns whether anything changed (the caller // persists). Never called on a manifest that failed verification — the clock // only ever ticks for signed feeds. func (st *firstSeenState) observe(m *releaseManifest, now time.Time) bool { changed := false if st.Jitter == 0 && len(st.Channels) == 0 { // A fresh box mints its jitter once, alongside its first observation. st.Jitter = rand.Float64() * 0.25 changed = true } for ch, e := range m.Channels { seen := st.Channels[ch] if seen == nil { seen = map[string]time.Time{} st.Channels[ch] = seen } if _, ok := seen[e.Version]; !ok { seen[e.Version] = now.UTC().Truncate(time.Second) changed = true } // Prune oldest entries beyond the cap — the just-recorded head is the // newest, so it always survives. for len(seen) > firstSeenKeep { oldest, at := "", time.Time{} for v, t := range seen { if oldest == "" || t.Before(at) { oldest, at = v, t } } delete(seen, oldest) } } return changed } func (st *firstSeenState) save() error { raw, err := json.MarshalIndent(st, "", " ") if err != nil { return err } return os.WriteFile(firstSeenPath(), append(raw, '\n'), 0o644) } // bakeEligibleAt is the moment a version first seen at a channel head may be // installed: firstSeen + delay*(1+jitter). ok=false when the version has no // record at this channel's head — brand new, so not eligible (updatePass // calls observe() first, which makes that case unreachable there; status // reads the clock without ticking it and can legitimately see it). func (st *firstSeenState) bakeEligibleAt(channel, version string) (time.Time, bool) { seen, ok := st.Channels[channel][version] if !ok { return time.Time{}, false } d := bakeDelays[channel] if d == 0 { return seen, true } return seen.Add(time.Duration(float64(d) * (1 + st.Jitter))), true } // bakeHold returns a non-empty human reason while a channel head must not yet // be installed on this box, "" once it may be. func bakeHold(st *firstSeenState, channel, version string, ignore bool, now time.Time) string { if ignore { return "" } at, ok := st.bakeEligibleAt(channel, version) if !ok { // Fail closed: no record means no clock has run, so nothing installs. return "in its supply-chain bake window (clock starts this pass)" } if now.Before(at) { return fmt.Sprintf("in its supply-chain bake window until %s", at.UTC().Format("2006-01-02 15:04 MST")) } return "" }