eleven / messenger Public

Clone
git clone https://amadan.net/eleven/messenger

Plain git — no account needed to clone.

Download

Download this file

1package main
2
3import (
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.
31var 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").
38func bakeHours(channel string) int { return int(bakeDelays[channel].Hours()) }
39
40// firstSeenPath persists the bake clock, next to the anti-replay stamp.
41func 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.
46const firstSeenKeep = 12
47
48// firstSeenState is the bake clock: for each channel, when this box first
49// observed each version at its head.
50type 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).
61func 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.
79func (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
111func (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).
124func (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.
138func 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