eleven / ocho Public

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

Plain git — no account needed to clone.

Download

Download this file

1<!-- πŸ€– Public security ledger (the third status page, beside fleet + books).
2 Fully self-contained: inline CSS/JS, no external requests except
3 ./security.json (relative, so it works at any origin). The JSON is
4 COMMITTED data written at build time by deploy/security-scan.sh; the box
5 publisher only re-uploads it.
6
7 ============== THE DISCLOSURE LINE β€” NON-NEGOTIABLE ======================
8 This page states security POSTURE, never a live exploit map:
9 - Dependency findings publish as a STATUS + COUNT only ("clean" or
10 "N advisories under remediation") β€” never the CVE/GHSA/OSV ids,
11 affected packages, or code locations of anything UNPATCHED. That
12 would be a responsible-disclosure violation and an attacker roadmap.
13 Once patched it's history and may be named elsewhere.
14 - No secret inventory, no unpatched-hole specifics, no internal
15 component versions beyond the build sha the release feed already
16 publishes.
17 - Everything else on the page is an architectural fact (true by
18 design), a hygiene status, or an honestly-named architectural edge β€”
19 the deliberate, disclosed tradeoffs.
20 - No scan published β†’ the page says "scan pending", never a fake
21 "clean" (degrade, never lie).
22 ========================================================================== -->
23<!doctype html>
24<html lang="en">
25<head>
26<meta charset="utf-8" />
27<meta name="viewport" content="width=device-width, initial-scale=1" />
28<meta name="color-scheme" content="light dark" />
29<title>Eleven β€” security</title>
30<link rel="icon" type="image/png" href="./icon-192.png" />
31<link rel="stylesheet" href="./status.css" />
32<style>
33 /* Security-only: tighter tiles + status tones on the values, the fact
34 checklists, and the scan-pending notice. Status colors always ride with a
35 glyph + word β€” never color alone. Everything shared lives in status.css. */
36 .tiles { margin-bottom: 12px; }
37 .tile .value { font-size: 26px; }
38 .tile .value.ok { color: var(--good); }
39 .tile .value.warn { color: var(--warn); }
40 .tile .value.mono { font-family: ui-monospace, SFMono-Regular, Menlo, monospace; font-size: 22px; }
41
42 /* Fact checklists: the glyph reinforces, the words carry the meaning (never
43 color, or glyph, alone). Edges use the brand violet marker β€” disclosed
44 tradeoffs, not statuses. */
45 .facts { list-style: none; padding: 0; background: var(--surface); border: 1px solid var(--border); border-radius: 12px; }
46 .facts li { display: grid; grid-template-columns: 1.4em 1fr; gap: 8px; padding: 11px 16px; }
47 .facts li + li { border-top: 1px solid var(--grid); }
48 .facts .mark { color: var(--good); font-weight: 700; }
49 .facts.edges .mark { color: var(--series); }
50 .facts b { font-weight: 650; }
51
52 #pending { display: none; background: var(--surface); border: 1px solid var(--border); border-radius: 12px; padding: 14px 16px; margin-bottom: 12px; }
53 #pending.show { display: block; }
54</style>
55</head>
56<body>
57<div class="wrap">
58 <header data-title="Security"></header>
59 <nav class="status-nav" aria-label="Status"></nav>
60 <script src="./status-nav.js"></script>
61 <p class="lede">Eleven’s security posture, published as it stands. This is
62 what is true by design and how the hygiene looks today β€” never a map of open
63 problems: if a scan finds something, this page shows a <em>count</em> until
64 it’s fixed (responsible disclosure). Found a vulnerability? Tell us
65 privately: <a href="mailto:team@elevenmessenger.com">team@elevenmessenger.com</a>.</p>
66 <p class="asof">Dependency scan as of <time id="asof" datetime="">β€”</time><span id="staleness"></span></p>
67
68 <section aria-labelledby="h-commitment">
69 <h2 id="h-commitment">The commitment</h2>
70 <p class="note explain"><strong>If our servers are stolen, the thief gets
71 nothing they can read.</strong> These are the load-bearing rules every
72 change is reviewed against β€” architecture, not policy:</p>
73 <ul class="facts">
74 <li><span class="mark" aria-hidden="true">βœ“</span><span>Our servers hold
75 <b>sealed boxes and the plumbing to deliver them</b> β€” never a key that
76 opens anything. Messages, names, and files are locked on your device
77 before they leave it.</span></li>
78 <li><span class="mark" aria-hidden="true">βœ“</span><span><b>Keys live only
79 on your devices.</b> Every conversation is sealed with its own key,
80 handed only to its members; the server stores wrapped copies it cannot
81 open.</span></li>
82 <li><span class="mark" aria-hidden="true">βœ“</span><span><b>Every space is
83 an island</b> β€” its own database, its own keys, no shared pool of user
84 data, no cross-user state anywhere, and no protocol tying spaces
85 together.</span></li>
86 <li><span class="mark" aria-hidden="true">βœ“</span><span><b>Invite links
87 keep their secret in the URL fragment</b> β€” the part a browser never
88 sends to any server.</span></li>
89 <li><span class="mark" aria-hidden="true">βœ“</span><span><b>Credentials
90 are never keys.</b> Admin tokens manage the roster; push credentials
91 authorize delivery of sealed bytes. Neither opens a single
92 message.</span></li>
93 <li><span class="mark" aria-hidden="true">βœ“</span><span><b>Bot code never
94 runs inside the server.</b> Bots live on machines their operator
95 controls, as ordinary members β€” compromising our server yields no bot
96 keys.</span></li>
97 <li><span class="mark" aria-hidden="true">βœ“</span><span><b>No personal
98 data in our stores</b> β€” no emails, no per-user analytics. Customer
99 identity for paid spaces lives with the payment processor; we hold an
100 opaque pointer and an entitlement flag.</span></li>
101 </ul>
102 </section>
103
104 <section aria-labelledby="h-hygiene">
105 <h2 id="h-hygiene">Dependency hygiene</h2>
106 <p class="note explain"><strong>Counted, not named:</strong> the product
107 that ships is a single Go binary (the web client is plain JavaScript it
108 serves β€” zero runtime npm packages; npm is test tooling only). Each release
109 build is scanned with govulncheck against the exact binary the fleet runs.
110 A finding publishes here as a count until it’s patched β€” naming an
111 unpatched hole would hand attackers a map.</p>
112 <p id="pending" role="status"><strong>Dependency scan pending.</strong> No
113 scan has been published for the current build (or the feed didn’t load).
114 Shown honestly rather than assumed clean β€” the architecture and hardening
115 facts on this page hold regardless.</p>
116 <div class="tiles">
117 <div class="tile">
118 <div class="label">Known vulnerabilities</div>
119 <div class="value" id="t-dep-status">β€”</div>
120 <div class="sub" id="t-dep-sub"></div>
121 </div>
122 <div class="tile">
123 <div class="label">Go modules in the build</div>
124 <div class="value" id="t-modules">β€”</div>
125 <div class="sub">direct + indirect, a count not an inventory</div>
126 </div>
127 <div class="tile">
128 <div class="label">Scanned build</div>
129 <div class="value mono" id="t-sha">β€”</div>
130 <div class="sub">the same build sha the release feed publishes</div>
131 </div>
132 </div>
133 </section>
134
135 <section aria-labelledby="h-hardening">
136 <h2 id="h-hardening">Hardening</h2>
137 <p class="note explain"><strong>The posture, not the address:</strong> how
138 the serving machines are run. Facts only β€” nothing here locates a
139 box.</p>
140 <ul class="facts">
141 <li><span class="mark" aria-hidden="true">βœ“</span><span><b>Firewall is
142 default-deny.</b> Inbound is web traffic and administration (SSH)
143 only; everything else is refused.</span></li>
144 <li><span class="mark" aria-hidden="true">βœ“</span><span><b>SSH is
145 key-only.</b> Password login is disabled outright.</span></li>
146 <li><span class="mark" aria-hidden="true">βœ“</span><span><b>OS security
147 updates apply automatically</b> (unattended upgrades).</span></li>
148 <li><span class="mark" aria-hidden="true">βœ“</span><span><b>Services run
149 as an unprivileged user in a systemd sandbox</b> β€” read-only system,
150 no privilege escalation, memory-capped.</span></li>
151 <li><span class="mark" aria-hidden="true">βœ“</span><span><b>TLS
152 everywhere.</b> Every space is served over HTTPS with automatically
153 issued and renewed certificates.</span></li>
154 <li><span class="mark" aria-hidden="true">βœ“</span><span><b>Member content
155 at rest is ciphertext.</b> A stolen disk β€” or a stolen backup β€” is a
156 pile of locked boxes.</span></li>
157 </ul>
158 </section>
159
160 <section aria-labelledby="h-edges">
161 <h2 id="h-edges">Known edges, on purpose</h2>
162 <p class="note explain"><strong>What we don’t protect, and why:</strong>
163 the deliberate tradeoffs, named plainly. These are disclosed design
164 choices, not bugs.</p>
165 <ul class="facts edges">
166 <li><span class="mark" aria-hidden="true">β—†</span><span><b>Link
167 previews:</b> when someone posts a link, the server fetches that URL
168 to build the preview card β€” so it briefly sees URLs (never message
169 text). The price of previews; gateable behind a setting later.</span></li>
170 <li><span class="mark" aria-hidden="true">β—†</span><span><b>GIF
171 search:</b> your browser queries Giphy directly, so Giphy sees your
172 search terms and network address; our server sees nothing. The chosen
173 GIF is sealed to the conversation like any attachment.</span></li>
174 <li><span class="mark" aria-hidden="true">β—†</span><span><b>Branding is
175 plaintext:</b> a space’s name, optional photo, and description are
176 admin-set and must render before a joiner holds any key β€” so they are
177 deliberately not encrypted. Everything members write still is.</span></li>
178 <li><span class="mark" aria-hidden="true">β—†</span><span><b>Bots are
179 members:</b> a bot you add runs on a machine its operator controls,
180 and that machine can read exactly the conversations the bot is in β€”
181 never anything else. Scoped, disclosed, and removable like any
182 member.</span></li>
183 <li><span class="mark" aria-hidden="true">β—†</span><span><b>A
184 content-blind store can’t scan content:</b> because we can’t read
185 anything, no server-side abuse filter is possible β€” a store we can’t
186 open is also a dead drop we can’t inspect. Abuse handling is
187 structural instead: invite-gated rosters, admin removal, and members’
188 own judgement. We accept that trade with open eyes.</span></li>
189 </ul>
190 </section>
191
192 <footer>
193 <p>The commitment, hardening, and known-edges sections are attested
194 content: they live in the product’s repository and change only by reviewed
195 commit. The scan block is generated at build time against the exact binary
196 the fleet runs; unpatched findings publish as a count only, and full
197 details are held until fixed. Report vulnerabilities privately to
198 <a href="mailto:team@elevenmessenger.com">team@elevenmessenger.com</a>.</p>
199 </footer>
200</div>
201
202<script>
203// contract: the fields this page reads from ./security.json β€” kept in sync
204// with deploy/security-scan.sh and checked by test/status-contract.test.mjs.
205const FIELDS = [
206 "generated_at", "scanned_sha", "go_modules",
207 "dependencies.status", "dependencies.advisories_under_remediation",
208];
209
210// The status vocabulary β€” the ONLY values the feed may hold. Word + glyph
211// carry the state; color is supplementary (never color alone). Anything
212// outside the vocabulary renders as pending rather than guessing.
213const DEP_STATES = {
214 "clean": { text: "βœ“ Clean", tone: "ok", sub: "0 known vulnerabilities in the shipped binary" },
215 "attention": { text: "β–² Attention", tone: "warn", sub: "" }, // sub filled with the count below
216 "pending": { text: "β€”", tone: "", sub: "scan pending" },
217};
218
219const $ = (id) => document.getElementById(id);
220let rendered = false;
221
222// All rendering is textContent / createElement β€” never innerHTML of fetched
223// data, even though the feed is our own.
224function render(d) {
225 rendered = true;
226 $("pending").classList.remove("show");
227
228 const t = new Date(d.generated_at);
229 $("asof").dateTime = d.generated_at;
230 $("asof").textContent = t.toLocaleString(undefined, { dateStyle: "medium", timeStyle: "short" });
231 // Honesty check: the scan rides releases, not a 5-minute timer β€” flag a
232 // scan older than 45 days (several missed release cycles).
233 const staleEl = $("staleness");
234 staleEl.textContent = "";
235 staleEl.className = "";
236 if (Date.now() - t.getTime() > 45 * 24 * 60 * 60 * 1000) {
237 staleEl.className = "stale";
238 staleEl.textContent = " β€” this scan looks stale";
239 }
240
241 const dep = d.dependencies || {};
242 const n = dep.advisories_under_remediation;
243 const state = DEP_STATES[dep.status] || DEP_STATES.pending;
244 const el = $("t-dep-status");
245 el.className = "value" + (state.tone ? " " + state.tone : "");
246 el.textContent = state.text;
247 // Count only, by design β€” never ids, packages, or locations (see the
248 // disclosure comment at the top of this file).
249 $("t-dep-sub").textContent = dep.status === "attention" && Number.isInteger(n)
250 ? n + " advisor" + (n === 1 ? "y" : "ies") + " under remediation β€” count only until patched"
251 : state.sub;
252
253 $("t-modules").textContent = Number.isInteger(d.go_modules) ? String(d.go_modules) : "β€”";
254 $("t-sha").textContent = typeof d.scanned_sha === "string" ? d.scanned_sha : "β€”";
255}
256
257function renderPending() {
258 // No scan visible: say so plainly β€” never a fake "clean" (degrade, never
259 // lie). A previously-rendered scan stays on screen instead.
260 if (rendered) return;
261 $("pending").classList.add("show");
262 $("asof").textContent = "β€” no scan published";
263 $("t-dep-status").className = "value";
264 $("t-dep-status").textContent = "β€”";
265 $("t-dep-sub").textContent = "scan pending";
266}
267
268async function refresh() {
269 try {
270 const res = await fetch("./security.json", { cache: "no-cache" });
271 if (!res.ok) throw new Error("HTTP " + res.status);
272 render(await res.json());
273 } catch (e) {
274 renderPending();
275 }
276}
277refresh();
278setInterval(refresh, 60 * 1000);
279void FIELDS;
280</script>
281</body>
282</html>
283