| 1 | package main |
| 2 | |
| 3 | import ( |
| 4 | "encoding/json" |
| 5 | "fmt" |
| 6 | "math/rand" |
| 7 | "os" |
| 8 | "path/filepath" |
| 9 | "time" |
| 10 | ) |
| 11 | |
| 12 | // The supply-chain bake window: beta and stable subscribers don't install a |
| 13 | // release the moment it appears — the box waits until the release has sat at |
| 14 | // the channel head for a while first, so a compromised release that gets |
| 15 | // caught and pulled back (docs/server-releases.md) never reaches them. The |
| 16 | // clock is THIS BOX's own observation ("when did I first see this version at |
| 17 | // this channel head"), never a manifest timestamp: an attacker holding the |
| 18 | // signing key controls every timestamp in the feed and would simply backdate |
| 19 | // one, but cannot backdate what the box already saw. |
| 20 | // |
| 21 | // edge has no window — it IS the operator's own deploy path (`publish && |
| 22 | // ship`). beta's 24h absorbs the caught-within-a-day mass of real |
| 23 | // supply-chain incidents; stable's 72h additionally spans a weekend, so a |
| 24 | // Friday-night malicious publish is seen by a human on Monday with margin to |
| 25 | // pull it back. |
| 26 | // |
| 27 | // There is deliberately NO publisher-side bypass ("critical fix, skip the |
| 28 | // hold") — that flag is the first thing an attacker sets. The bypass is |
| 29 | // local and explicit: `lchat update run --ignore-bake` or a plain |
| 30 | // `lchat deploy <host> --version <ver>`, both of which need root on the box. |
| 31 | var bakeDelays = map[string]time.Duration{ |
| 32 | "edge": 0, |
| 33 | "beta": 24 * time.Hour, |
| 34 | "stable": 72 * time.Hour, |
| 35 | } |
| 36 | |
| 37 | // bakeHours is the display form consoles show ("held up to N hours"). |
| 38 | func bakeHours(channel string) int { return int(bakeDelays[channel].Hours()) } |
| 39 | |
| 40 | // firstSeenPath persists the bake clock, next to the anti-replay stamp. |
| 41 | func firstSeenPath() string { return filepath.Join(optDir, "channel-first-seen.json") } |
| 42 | |
| 43 | // firstSeenKeep bounds each channel's history. Past heads are kept (not just |
| 44 | // the current one) so a pull-back to a previously-seen version is immediately |
| 45 | // eligible — evacuating a bad release must never itself sit in a bake window. |
| 46 | const firstSeenKeep = 12 |
| 47 | |
| 48 | // firstSeenState is the bake clock: for each channel, when this box first |
| 49 | // observed each version at its head. |
| 50 | type firstSeenState struct { |
| 51 | // Jitter desynchronises a fleet of boxes: each waits delay*(1+Jitter), so |
| 52 | // self-hosted boxes don't all install in the same hour and the earliest |
| 53 | // become canaries for the rest. Minted once per box, in [0, 0.25). |
| 54 | Jitter float64 `json:"jitter"` |
| 55 | Channels map[string]map[string]time.Time `json:"channels"` |
| 56 | } |
| 57 | |
| 58 | // loadFirstSeen reads the bake clock. A missing or corrupt file is an empty |
| 59 | // clock — every head then bakes from "now", which fails closed (a wiped clock |
| 60 | // delays updates; it never lets one through early). |
| 61 | func loadFirstSeen() *firstSeenState { |
| 62 | st := &firstSeenState{} |
| 63 | if raw, err := os.ReadFile(firstSeenPath()); err == nil { |
| 64 | _ = json.Unmarshal(raw, st) |
| 65 | } |
| 66 | if st.Channels == nil { |
| 67 | st.Channels = map[string]map[string]time.Time{} |
| 68 | } |
| 69 | if st.Jitter < 0 || st.Jitter >= 0.25 { |
| 70 | st.Jitter = 0 |
| 71 | } |
| 72 | return st |
| 73 | } |
| 74 | |
| 75 | // observe records every channel head the manifest shows that this box hasn't |
| 76 | // seen at that head before. Returns whether anything changed (the caller |
| 77 | // persists). Never called on a manifest that failed verification — the clock |
| 78 | // only ever ticks for signed feeds. |
| 79 | func (st *firstSeenState) observe(m *releaseManifest, now time.Time) bool { |
| 80 | changed := false |
| 81 | if st.Jitter == 0 && len(st.Channels) == 0 { |
| 82 | // A fresh box mints its jitter once, alongside its first observation. |
| 83 | st.Jitter = rand.Float64() * 0.25 |
| 84 | changed = true |
| 85 | } |
| 86 | for ch, e := range m.Channels { |
| 87 | seen := st.Channels[ch] |
| 88 | if seen == nil { |
| 89 | seen = map[string]time.Time{} |
| 90 | st.Channels[ch] = seen |
| 91 | } |
| 92 | if _, ok := seen[e.Version]; !ok { |
| 93 | seen[e.Version] = now.UTC().Truncate(time.Second) |
| 94 | changed = true |
| 95 | } |
| 96 | // Prune oldest entries beyond the cap — the just-recorded head is the |
| 97 | // newest, so it always survives. |
| 98 | for len(seen) > firstSeenKeep { |
| 99 | oldest, at := "", time.Time{} |
| 100 | for v, t := range seen { |
| 101 | if oldest == "" || t.Before(at) { |
| 102 | oldest, at = v, t |
| 103 | } |
| 104 | } |
| 105 | delete(seen, oldest) |
| 106 | } |
| 107 | } |
| 108 | return changed |
| 109 | } |
| 110 | |
| 111 | func (st *firstSeenState) save() error { |
| 112 | raw, err := json.MarshalIndent(st, "", " ") |
| 113 | if err != nil { |
| 114 | return err |
| 115 | } |
| 116 | return os.WriteFile(firstSeenPath(), append(raw, '\n'), 0o644) |
| 117 | } |
| 118 | |
| 119 | // bakeEligibleAt is the moment a version first seen at a channel head may be |
| 120 | // installed: firstSeen + delay*(1+jitter). ok=false when the version has no |
| 121 | // record at this channel's head — brand new, so not eligible (updatePass |
| 122 | // calls observe() first, which makes that case unreachable there; status |
| 123 | // reads the clock without ticking it and can legitimately see it). |
| 124 | func (st *firstSeenState) bakeEligibleAt(channel, version string) (time.Time, bool) { |
| 125 | seen, ok := st.Channels[channel][version] |
| 126 | if !ok { |
| 127 | return time.Time{}, false |
| 128 | } |
| 129 | d := bakeDelays[channel] |
| 130 | if d == 0 { |
| 131 | return seen, true |
| 132 | } |
| 133 | return seen.Add(time.Duration(float64(d) * (1 + st.Jitter))), true |
| 134 | } |
| 135 | |
| 136 | // bakeHold returns a non-empty human reason while a channel head must not yet |
| 137 | // be installed on this box, "" once it may be. |
| 138 | func bakeHold(st *firstSeenState, channel, version string, ignore bool, now time.Time) string { |
| 139 | if ignore { |
| 140 | return "" |
| 141 | } |
| 142 | at, ok := st.bakeEligibleAt(channel, version) |
| 143 | if !ok { |
| 144 | // Fail closed: no record means no clock has run, so nothing installs. |
| 145 | return "in its supply-chain bake window (clock starts this pass)" |
| 146 | } |
| 147 | if now.Before(at) { |
| 148 | return fmt.Sprintf("in its supply-chain bake window until %s", at.UTC().Format("2006-01-02 15:04 MST")) |
| 149 | } |
| 150 | return "" |
| 151 | } |
| 152 | |