From 5dfc710ae9a69843424c1884de00c348bd280545 Mon Sep 17 00:00:00 2001 From: Dani Date: Fri, 22 Aug 2025 18:54:10 -0400 Subject: [PATCH] Added panic mode protections to make the server more secure --- client/app.js | 402 ++++++++++-------- client/index.html | 33 +- client/styles.css | 33 +- cmd/shard/main.go | 36 +- internal/api/http.go | 94 +++- internal/api/ratelimit.go | 78 ++++ ...03f1a3b891fc06b0189a0c5d5916435d9e5d74e963 | 1 - 7 files changed, 430 insertions(+), 247 deletions(-) create mode 100644 internal/api/ratelimit.go delete mode 100644 testdata/objects/5dcfd2e73a6c3a17b0efb103f1a3b891fc06b0189a0c5d5916435d9e5d74e963 diff --git a/client/app.js b/client/app.js index d65039f..ce79a91 100644 --- a/client/app.js +++ b/client/app.js @@ -1,10 +1,13 @@ import { encryptString, decryptToString, toBlob } from "./crypto.js"; +// ---------- DOM ---------- const els = { shardUrl: document.getElementById("shardUrl"), bearer: document.getElementById("bearer"), passphrase: document.getElementById("passphrase"), saveConn: document.getElementById("saveConn"), + keySignIn: document.getElementById("keySignIn"), + panicWipe: document.getElementById("panicWipe"), health: document.getElementById("health"), visibility: document.getElementById("visibility"), title: document.getElementById("title"), @@ -13,170 +16,258 @@ const els = { publishStatus: document.getElementById("publishStatus"), posts: document.getElementById("posts"), discordStart: document.getElementById("discordStart"), - signinDevice: document.getElementById("signinDevice"), }; +// ---------- Config (no bearer in localStorage) ---------- const LS_KEY = "gc_client_config_v1"; const POSTS_KEY = "gc_posts_index_v1"; -const DEVKEY_KEY = "gc_device_key_v1"; // pkcs8/spki (p256) base64url - -function defaultApiBase() { - try { const qs = new URLSearchParams(window.location.search); const qApi = qs.get("api"); if (qApi) return qApi.replace(/\/+$/,""); } catch {} - const m = document.querySelector('meta[name="gc-api-base"]'); if (m && m.content) return m.content.replace(/\/+$/,""); - try { - const u = new URL(window.location.href); - const proto = u.protocol; const host = u.hostname; const portStr = u.port; - const bracketHost = host.includes(":") ? `[${host}]` : host; - const port = portStr ? parseInt(portStr,10) : null; - let apiPort = port; - if (port === 8082) apiPort = 8080; else if (port === 9082) apiPort = 9080; else if (port) apiPort = Math.max(1, port - 2); - return apiPort ? `${proto}//${bracketHost}:${apiPort}` : `${proto}//${bracketHost}`; - } catch { return window.location.origin.replace(/\/+$/,""); } -} - function loadConfig(){ try { return JSON.parse(localStorage.getItem(LS_KEY)) ?? {}; } catch { return {}; } } -function saveConfig(c){ localStorage.setItem(LS_KEY, JSON.stringify(c)); Object.assign(cfg, c); } +function saveConfig(c){ localStorage.setItem(LS_KEY, JSON.stringify({ url: c.url, passphrase: c.passphrase })); Object.assign(cfg, c); } function getPosts(){ try { return JSON.parse(localStorage.getItem(POSTS_KEY)) ?? []; } catch { return []; } } function setPosts(v){ localStorage.setItem(POSTS_KEY, JSON.stringify(v)); renderPosts(); } function norm(u){ return (u||"").replace(/\/+$/,""); } -function applyConfig(){ els.shardUrl.value = cfg.url ?? defaultApiBase(); els.bearer.value = cfg.bearer ?? ""; els.passphrase.value = cfg.passphrase ?? ""; } -function msg(t, err=false){ els.publishStatus.textContent=t; els.publishStatus.style.color = err ? "#ff6b6b" : "#8b949e"; } -function getBearer(){ return sessionStorage.getItem("gc_bearer") || cfg.bearer || ""; } +function getBearer(){ return sessionStorage.getItem("gc_bearer") || ""; } +function setBearer(tok){ if (!tok) sessionStorage.removeItem("gc_bearer"); else sessionStorage.setItem("gc_bearer", tok); els.bearer.value = tok ? "••• (session)" : ""; } +const cfg = loadConfig(); -const cfg = loadConfig(); applyConfig(); +// ---------- Security helpers ---------- +const enc = new TextEncoder(); +const dec = new TextDecoder(); +const b64 = (u) => { let s=""; u=new Uint8Array(u); for (let i=0;i { s=s.replace(/-/g,"+").replace(/_/g,"/"); while(s.length%4) s+="="; const bin=atob(s); const b=new Uint8Array(bin.length); for(let i=0;ix.toString(16).padStart(2,"0")).join(""); } -// ---- Device key management (P-256) ---- -async function ensureDeviceKey() { - const stored = JSON.parse(localStorage.getItem(DEVKEY_KEY) || "null"); - if (stored && stored.priv && stored.pub) return; - const kp = await crypto.subtle.generateKey({ name:"ECDSA", namedCurve:"P-256" }, true, ["sign","verify"]); - const pkcs8 = await crypto.subtle.exportKey("pkcs8", kp.privateKey); - const rawPub = await crypto.subtle.exportKey("raw", kp.publicKey); // 65B uncompressed - localStorage.setItem(DEVKEY_KEY, JSON.stringify({ alg:"p256", priv: b64(rawPub ? pkcs8 : pkcs8), pub: b64(rawPub) })); -} -async function getDevicePriv(){ - const s = JSON.parse(localStorage.getItem(DEVKEY_KEY) || "{}"); - if (s.alg !== "p256") throw new Error("unsupported alg"); - return crypto.subtle.importKey("pkcs8", ub64(s.priv), { name:"ECDSA", namedCurve:"P-256" }, false, ["sign"]); -} -function getDevicePubHdr(){ - const s = JSON.parse(localStorage.getItem(DEVKEY_KEY) || "{}"); - return s && s.pub ? "p256:" + s.pub : ""; +// Device key (P-256), stored locally (not a bearer) +async function getDevice() { + let dev = JSON.parse(localStorage.getItem('gc_device_key_v1')||'null'); + if (!dev) { + const kp = await crypto.subtle.generateKey({name:"ECDSA", namedCurve:"P-256"}, true, ["sign","verify"]); + const pkcs8 = await crypto.subtle.exportKey("pkcs8", kp.privateKey); + const rawPub = await crypto.subtle.exportKey("raw", kp.publicKey); // 65B 0x04||X||Y + dev = { alg:"p256", priv: b64(pkcs8), pub: b64(rawPub) }; + localStorage.setItem('gc_device_key_v1', JSON.stringify(dev)); + } + return dev; } -// ---- DPoP-style proof headers (sign path, not absolute URL) ---- -async function popHeaders(method, pathOnly, bodyBytes){ +// Proof-of-Possession headers for this request +async function popHeaders(method, pathOnly, bodyBuf){ + const dev = await getDevice(); const ts = Math.floor(Date.now()/1000).toString(); - const pub = getDevicePubHdr(); - const digest = await sha256Hex(bodyBytes || new Uint8Array()); - const msg = (method.toUpperCase()+"\n"+pathOnly+"\n"+ts+"\n"+digest); - const priv = await getDevicePriv(); - const sig = await crypto.subtle.sign({ name:"ECDSA", hash:"SHA-256" }, priv, new TextEncoder().encode(msg)); - return { "X-GC-Key": pub, "X-GC-TS": ts, "X-GC-Proof": b64(new Uint8Array(sig)) }; + const hashHex = await sha256Hex(bodyBuf || new Uint8Array()); + const msg = enc.encode(method.toUpperCase()+"\n"+pathOnly+"\n"+ts+"\n"+hashHex); + const priv = await crypto.subtle.importKey("pkcs8", ub64(dev.priv), { name:"ECDSA", namedCurve:"P-256" }, false, ["sign"]); + const sig = await crypto.subtle.sign({ name:"ECDSA", hash:"SHA-256" }, priv, msg); + return { + "X-GC-Key": "p256:"+dev.pub, + "X-GC-TS": ts, + "X-GC-Proof": b64(sig), + }; } -async function fetchAPI(path, opts = {}, bodyBytes){ - if (!cfg.url) throw new Error("Set shard URL first."); - const method = (opts.method || "GET").toUpperCase(); - const headers = Object.assign({}, opts.headers || {}); - const bearer = getBearer(); if (bearer) headers["Authorization"] = "Bearer " + bearer; - const pop = await popHeaders(method, path, bodyBytes); - Object.assign(headers, pop); - const r = await fetch(cfg.url + path, Object.assign({}, opts, { method, headers })); - return r; +// Idle timeout → clear bearer +(function idleGuard(){ + let idle; + const bump=()=>{ clearTimeout(idle); idle=setTimeout(()=>setBearer(""), 30*60*1000); }; // 30 min + ["click","keydown","mousemove","touchstart","focus","visibilitychange"].forEach(ev=>addEventListener(ev,bump,{passive:true})); + bump(); +})(); + +// ---------- API base detection ---------- +function defaultApiBase() { + try { + const qs = new URLSearchParams(window.location.search); + const qApi = qs.get("api"); if (qApi) return qApi.replace(/\/+$/, ""); + } catch {} + const m = document.querySelector('meta[name="gc-api-base"]'); + if (m && m.content) return m.content.replace(/\/+$/, ""); + try { + const u = new URL(window.location.href); + const proto = u.protocol, host = u.hostname, portStr = u.port; + const bracketHost = host.includes(":") ? `[${host}]` : host; + const port = portStr ? parseInt(portStr, 10) : null; + let apiPort = port; + if (port === 8082) apiPort = 8080; + else if (port === 9082) apiPort = 9080; + else if (port) apiPort = Math.max(1, port - 2); + return apiPort ? `${proto}//${bracketHost}:${apiPort}` : `${proto}//${bracketHost}`; + } catch { + return window.location.origin.replace(/\/+$/, ""); + } } -// ---- Health / Index / SSE ---- +// ---------- App init ---------- +function applyConfig(){ + els.shardUrl.value = cfg.url ?? defaultApiBase(); + els.passphrase.value = cfg.passphrase ?? ""; + els.bearer.value = getBearer() ? "••• (session)" : ""; +} +applyConfig(); checkHealth(); syncIndex(); sse(); + +// ---------- UI wiring ---------- +els.saveConn.onclick = async () => { + const c = { url: norm(els.shardUrl.value), passphrase: els.passphrase.value }; + saveConfig(c); await checkHealth(); await syncIndex(); sse(true); +}; +els.publish.onclick = publish; +els.discordStart.onclick = discordStart; +els.keySignIn.onclick = keySignIn; +els.panicWipe.onclick = panicWipe; + +// Panic wipe hotkey (double-tap ESC) +let escT=0; +addEventListener("keydown", (e) => { + if (e.key === "Escape") { + const now = Date.now(); + if (now - escT < 600) panicWipe(); + escT = now; + } +}); + +// ---------- Health / Index / SSE ---------- async function checkHealth() { if (!cfg.url) return; els.health.textContent = "Checking…"; - try { - const r = await fetch(cfg.url + "/healthz"); - els.health.textContent = r.ok ? "Connected ✔" : `Error: ${r.status}`; - } catch { els.health.textContent = "Not reachable"; } + try { const r = await fetch(cfg.url + "/healthz"); els.health.textContent = r.ok ? "Connected ✔" : `Error: ${r.status}`; } + catch { els.health.textContent = "Not reachable"; } } async function syncIndex() { if (!cfg.url) return; try { - const r = await fetchAPI("/v1/index"); + const hdrs = {}; + const b = getBearer(); + if (b) Object.assign(hdrs, await popHeaders("GET", "/v1/index", new Uint8Array())); + const r = await fetch(cfg.url + "/v1/index", { headers: Object.assign(hdrs, b?{Authorization:"Bearer "+b}:{}) }); if (!r.ok) throw new Error("index fetch failed"); const entries = await r.json(); - setPosts(entries.map(e => ({ hash:e.hash, title:"(title unknown — fetch)", bytes:e.bytes, ts:e.stored_at, enc:e.private, tz:e.creator_tz }))); + setPosts(entries.map(e => ({ hash:e.hash, title:"(title unknown — fetch)", bytes:e.bytes, ts:e.stored_at, enc:e.private, tz:e.creator_tz||"" }))); } catch(e){ console.warn("index sync failed", e); } } let sseCtrl; -async function sse(){ +function sse(reset){ if (!cfg.url) return; if (sseCtrl) { sseCtrl.abort(); sseCtrl = undefined; } sseCtrl = new AbortController(); - const path = "/v1/index/stream"; - const headers = {}; - const b = getBearer(); if (b) headers["Authorization"] = "Bearer " + b; - Object.assign(headers, await popHeaders("GET", path, new Uint8Array())); - fetch(cfg.url + path, { headers, signal: sseCtrl.signal }).then(async resp => { - if (!resp.ok) return; - const reader = resp.body.getReader(); const decoder = new TextDecoder(); - let buf = ""; - while (true) { - const { value, done } = await reader.read(); if (done) break; - buf += decoder.decode(value, { stream:true }); - let idx; - while ((idx = buf.indexOf("\n\n")) >= 0) { - const chunk = buf.slice(0, idx); buf = buf.slice(idx+2); - if (chunk.startsWith("data: ")) { - try { - const ev = JSON.parse(chunk.slice(6)); - if (ev.event === "put") { - const e = ev.data; - const posts = getPosts(); - if (!posts.find(p => p.hash === e.hash)) { - posts.unshift({ hash:e.hash, title:"(title unknown — fetch)", bytes:e.bytes, ts:e.stored_at, enc:e.private, tz:e.creator_tz }); - setPosts(posts); + const url = cfg.url + "/v1/index/stream"; + const b = getBearer(); + const start = async () => { + const hdrs = {}; + if (b) Object.assign(hdrs, await popHeaders("GET", "/v1/index/stream", new Uint8Array()), { Authorization: "Bearer "+b }); + fetch(url, { headers: hdrs, signal: sseCtrl.signal }).then(async resp => { + if (!resp.ok) return; + const reader = resp.body.getReader(); const decoder = new TextDecoder(); + let buf = ""; + while (true) { + const { value, done } = await reader.read(); if (done) break; + buf += decoder.decode(value, { stream:true }); + let idx; while ((idx = buf.indexOf("\n\n")) >= 0) { + const chunk = buf.slice(0, idx); buf = buf.slice(idx+2); + if (chunk.startsWith("data: ")) { + try { + const ev = JSON.parse(chunk.slice(6)); + if (ev.event === "put") { + const e = ev.data; + const posts = getPosts(); + if (!posts.find(p => p.hash === e.hash)) { + posts.unshift({ hash:e.hash, title:"(title unknown — fetch)", bytes:e.bytes, ts:e.stored_at, enc:e.private, tz:e.creator_tz||"" }); + setPosts(posts); + } + } else if (ev.event === "delete") { + const h = ev.data.hash; setPosts(getPosts().filter(p => p.hash !== h)); } - } else if (ev.event === "delete") { - const h = ev.data.hash; setPosts(getPosts().filter(p => p.hash !== h)); - } - } catch {} + } catch {} + } } } - } - }).catch(()=>{}); + }).catch(()=>{}); + }; + start(); } -// ---- Actions ---- +// ---------- Auth ---------- +async function keySignIn(){ + try { + if (!cfg.url) { alert("Set shard URL first."); return; } + // 1) challenge + const cResp = await fetch(cfg.url + "/v1/auth/key/challenge", { method:"POST" }); + const cTxt = await cResp.text(); + if (!cResp.ok) { alert("Challenge failed: " + cTxt); return; } + const c = JSON.parse(cTxt); + // 2) sign and verify + const dev = await getDevice(); + const priv = await crypto.subtle.importKey("pkcs8", ub64(dev.priv), { name:"ECDSA", namedCurve:"P-256" }, false, ["sign"]); + const msg = enc.encode("key-verify\n" + c.nonce); + const sig = await crypto.subtle.sign({ name:"ECDSA", hash:"SHA-256" }, priv, msg); + const vResp = await fetch(cfg.url + "/v1/auth/key/verify", { + method:"POST", + headers: { "Content-Type":"application/json" }, + body: JSON.stringify({ nonce:c.nonce, alg:"p256", pub: dev.pub, sig: b64(sig) }) + }); + const vTxt = await vResp.text(); + if (!vResp.ok) { alert("Verify failed: " + vTxt); return; } + const j = JSON.parse(vTxt); + setBearer(j.bearer); + alert("Signed in ✔ (session)"); + await syncIndex(); + } catch (e) { + alert("Key sign-in exception: " + (e?.message || e)); + } +} + +async function panicWipe(){ + try { + if (cfg.url) await fetch(cfg.url + "/v1/session/clear", { method:"POST" }); + } catch {} + sessionStorage.clear(); + localStorage.clear(); + caches && caches.keys().then(keys => keys.forEach(k => caches.delete(k))); + location.replace("about:blank"); +} + +// ---------- Publishing / Viewing ---------- +function msg(t, err=false){ els.publishStatus.textContent=t; els.publishStatus.style.color = err ? "#ff6b6b" : "inherit"; } + async function publish() { if (!cfg.url) return msg("Set shard URL first.", true); - const title = els.title.value.trim(); const body = els.body.value; const vis = els.visibility.value; + const b = getBearer(); if (!b) return msg("Sign in first (device key).", true); + + const title = els.title.value.trim(); + const body = els.body.value; + const vis = els.visibility.value; try { - let blob, enc=false; + let blob, encp=false; if (vis === "private") { - if (!cfg.passphrase) return msg("Set a passphrase (community key) for encrypted posts.", true); + if (!cfg.passphrase) return msg("Set a passphrase for private posts.", true); const payload = await encryptString(JSON.stringify({ title, body }), cfg.passphrase); - blob = toBlob(payload); enc=true; + blob = toBlob(payload); encp=true; } else { blob = toBlob(JSON.stringify({ title, body })); } - const tz = Intl.DateTimeFormat().resolvedOptions().timeZone || ""; - const headers = { "Content-Type":"application/octet-stream", "X-GC-TZ": tz }; - const bearer = getBearer(); if (bearer) headers["Authorization"] = "Bearer " + bearer; - if (enc) headers["X-GC-Private"] = "1"; - const bodyBytes = new Uint8Array(await blob.arrayBuffer()); - Object.assign(headers, await popHeaders("PUT", "/v1/object", bodyBytes)); - const r = await fetch(cfg.url + "/v1/object", { method:"PUT", headers, body: blob }); + const buf = new Uint8Array(await blob.arrayBuffer()); + const path = "/v1/object"; + const headers = { "Content-Type":"application/octet-stream", Authorization: "Bearer "+b }; + if (encp) headers["X-GC-Private"] = "1"; + const pop = await popHeaders("PUT", path, buf); + Object.assign(headers, pop); + const r = await fetch(cfg.url + path, { method:"PUT", headers, body: buf }); if (!r.ok) throw new Error(await r.text()); const j = await r.json(); const posts = getPosts(); - posts.unshift({ hash:j.hash, title: title || "(untitled)", bytes:j.bytes, ts:j.stored_at, enc:j.private, tz:j.creator_tz }); + posts.unshift({ hash:j.hash, title: title || "(untitled)", bytes:j.bytes, ts:j.stored_at, enc:j.private, tz:j.creator_tz||"" }); setPosts(posts); - els.body.value = ""; msg(`Published ${enc?"encrypted":"plaintext"} post. Hash: ${j.hash}`); + els.body.value = ""; msg(`Published ${encp?"private":"public"} post. Hash: ${j.hash}`); } catch(e){ msg("Publish failed: " + (e?.message||e), true); } } async function viewPost(p, pre) { pre.textContent = "Loading…"; try { - const r = await fetchAPI("/v1/object/" + p.hash); + const path = "/v1/object/" + p.hash; + const headers = {}; + const b = getBearer(); + if (b) Object.assign(headers, await popHeaders("GET", path, new Uint8Array()), { Authorization: "Bearer "+b }); + const r = await fetch(cfg.url + path, { headers }); if (!r.ok) throw new Error("fetch failed " + r.status); const buf = new Uint8Array(await r.arrayBuffer()); let text; @@ -192,91 +283,52 @@ async function viewPost(p, pre) { } async function saveBlob(p) { - const r = await fetchAPI("/v1/object/" + p.hash); + const path = "/v1/object/" + p.hash; + const headers = {}; + const b = getBearer(); + if (b) Object.assign(headers, await popHeaders("GET", path, new Uint8Array()), { Authorization: "Bearer "+b }); + const r = await fetch(cfg.url + path, { headers }); if (!r.ok) return alert("download failed " + r.status); - const b = await r.blob(); - const a = document.createElement("a"); a.href = URL.createObjectURL(b); + const bl = await r.blob(); + const a = document.createElement("a"); a.href = URL.createObjectURL(bl); a.download = p.hash + (p.enc ? ".gcenc" : ".json"); a.click(); URL.revokeObjectURL(a.href); } async function delServer(p) { + const path = "/v1/object/" + p.hash; + const b = getBearer(); if (!b) return alert("Sign in first."); + const headers = { Authorization: "Bearer "+b }; + Object.assign(headers, await popHeaders("DELETE", path, new Uint8Array())); if (!confirm("Delete blob from server by hash?")) return; - const r = await fetchAPI("/v1/object/" + p.hash, { method:"DELETE" }); + const r = await fetch(cfg.url + path, { method:"DELETE", headers }); if (!r.ok) return alert("delete failed " + r.status); setPosts(getPosts().filter(x=>x.hash!==p.hash)); } +// ---------- Discord SSO ---------- async function discordStart() { if (!cfg.url) { alert("Set shard URL first."); return; } - const headers = { "X-GC-3P-Assent":"1", "X-GC-Key": getDevicePubHdr() }; - const r = await fetch(cfg.url + "/v1/auth/discord/start", { headers }); + const r = await fetch(cfg.url + "/v1/auth/discord/start", { headers: { "X-GC-3P-Assent":"1" }}); if (!r.ok) { alert("Discord SSO not available"); return; } const j = await r.json(); location.href = j.url; } -async function signInWithDeviceKey(){ - try { - if (!cfg.url) { alert("Set shard URL first."); return; } - - // 1) challenge - const cResp = await fetch(cfg.url + "/v1/auth/key/challenge", { method:"POST" }); - const cTxt = await cResp.text(); - if (!cResp.ok) { alert("Challenge failed: " + cTxt); return; } - const c = JSON.parse(cTxt); - if (!c.nonce) { alert("Challenge bad JSON: " + cTxt); return; } - - // 2) sign "key-verify\n" - const msg = "key-verify\n" + c.nonce; - const priv = await getDevicePriv(); - const sig = await crypto.subtle.sign({ name:"ECDSA", hash:"SHA-256" }, priv, new TextEncoder().encode(msg)); - - // 3) send verify - const body = JSON.stringify({ - nonce: c.nonce, - alg: "p256", - pub: (getDevicePubHdr()||"").slice("p256:".length), - sig: b64(new Uint8Array(sig)) - }); - - const vResp = await fetch(cfg.url + "/v1/auth/key/verify", { - method:"POST", - headers:{ "Content-Type":"application/json" }, - body - }); - const vTxt = await vResp.text(); - if (!vResp.ok) { alert("Verify failed: " + vTxt); return; } - - const j = JSON.parse(vTxt); - if (!j.bearer) { alert("Verify returned no bearer: " + vTxt); return; } - - sessionStorage.setItem("gc_bearer", j.bearer); - const k = "gc_client_config_v1"; const cfg0 = JSON.parse(localStorage.getItem(k) || "{}"); cfg0.bearer = j.bearer; localStorage.setItem(k, JSON.stringify(cfg0)); - els.bearer.value = j.bearer; - alert("Signed in ✔"); - } catch (e) { - alert("Key sign-in exception: " + (e?.message || e)); - } -} - - -// ---- Render ---- +// ---------- Render ---------- function renderPosts() { const posts = getPosts(); els.posts.innerHTML = ""; for (const p of posts) { const div = document.createElement("div"); div.className = "post"; - const badge = p.enc ? `encrypted` : `plaintext`; - const tsLocal = new Date(p.ts).toLocaleString(); - const tz = p.tz ? ` · author TZ: ${p.tz}` : ""; + const badge = p.enc ? `private` : `public`; div.innerHTML = ` -
${p.hash.slice(0,10)}… · ${p.bytes} bytes · ${tsLocal}${tz} ${badge}
+
${p.hash.slice(0,10)}… · ${p.bytes} bytes · ${p.ts} ${badge}
-
`;
+      
`;
     const pre = div.querySelector(".content");
     div.querySelector('[data-act="view"]').onclick = () => viewPost(p, pre);
     div.querySelector('[data-act="save"]').onclick = () => saveBlob(p);
@@ -285,23 +337,3 @@ function renderPosts() {
     els.posts.appendChild(div);
   }
 }
-
-// ---- Boot ----
-(async () => {
-  await ensureDeviceKey();
-  await checkHealth(); await syncIndex(); await sse();
-})();
-
-els.saveConn.onclick = async () => {
-  const c = { url: norm(els.shardUrl.value), bearer: els.bearer.value.trim(), passphrase: els.passphrase.value };
-  saveConfig(c);
-  await checkHealth(); await syncIndex(); await sse();
-};
-els.publish.onclick = publish;
-els.discordStart.onclick = discordStart;
-els.signinDevice.onclick = signInWithDeviceKey;
-
-// ---- utils ----
-function b64(buf){ const b = buf instanceof Uint8Array ? buf : new Uint8Array(buf); let s=""; for (let i=0;ib.toString(16).padStart(2,"0")).join(""); }
diff --git a/client/index.html b/client/index.html
index 7f46295..27ece13 100644
--- a/client/index.html
+++ b/client/index.html
@@ -5,7 +5,7 @@
   GreenCoast — Client
   
   
-  
+  
   
 
 
@@ -16,29 +16,30 @@
       

Connect

- +
- - + +
- +
-
- +
- -
- Using third-party SSO is optional; we cannot vouch for their security. +
+ We use external providers only if you choose to. We cannot vouch for their security.
- - +
+ + + +
@@ -47,8 +48,8 @@
@@ -59,7 +60,9 @@
- +
+ +
diff --git a/client/styles.css b/client/styles.css index 7ed865f..c4a7bf9 100644 --- a/client/styles.css +++ b/client/styles.css @@ -1,18 +1,15 @@ -:root { --bg:#0b1117; --card:#0f1621; --fg:#e6edf3; --muted:#8b949e; --accent:#2ea043; } -* { box-sizing: border-box; } -body { margin:0; font-family: ui-sans-serif, system-ui, -apple-system, Segoe UI, Roboto, Arial; background:var(--bg); color:var(--fg); } -.container { max-width: 900px; margin: 2rem auto; padding: 0 1rem; } -h1 { font-size: 1.5rem; margin-bottom: 1rem; } -.card { background: var(--card); border-radius: 14px; padding: 1rem; margin-bottom: 1rem; box-shadow: 0 8px 24px rgba(0,0,0,.3); } -h2 { margin-top: 0; font-size: 1.1rem; } -.row { display: grid; grid-template-columns: 160px 1fr; gap: .75rem; align-items: center; margin: .5rem 0; } -label { color: var(--muted); } -input, select, textarea { width: 100%; padding: .6rem .7rem; border-radius: 10px; border: 1px solid #233; background: #0b1520; color: var(--fg); } -button { background: var(--accent); color: #08130b; border: none; padding: .6rem .9rem; border-radius: 10px; cursor: pointer; font-weight: 700; } -button:hover { filter: brightness(1.05); } -.muted { color: var(--muted); margin-top: .5rem; font-size: .9rem; } -.post { border: 1px solid #1d2734; border-radius: 12px; padding: .75rem; margin: .5rem 0; background: #0c1824; } -.post .meta { font-size: .85rem; color: var(--muted); margin-bottom: .4rem; } -.post .actions { margin-top: .5rem; display:flex; gap:.5rem; } -code { background:#0a1320; padding:.15rem .35rem; border-radius:6px; } -.badge { font-size:.75rem; padding:.1rem .4rem; border-radius: 999px; background:#132235; color:#9fb7d0; margin-left:.5rem; } +:root { color-scheme: light dark; } +body { font-family: system-ui, -apple-system, Segoe UI, Roboto, Ubuntu, Cantarell, "Noto Sans", sans-serif; margin: 0; padding: 2rem; } +.container { max-width: 860px; margin: 0 auto; } +h1 { margin: 0 0 1rem 0; } +.card { border: 1px solid #30363d; border-radius: 16px; padding: 1rem; margin: 1rem 0; box-shadow: 0 2px 6px rgba(0,0,0,.1); } +.row { display: grid; grid-template-columns: 160px 1fr; gap: .8rem; align-items: center; margin: .6rem 0; } +label { opacity: .8; } +input, textarea, select, button { font: inherit; padding: .6rem .7rem; border-radius: 10px; border: 1px solid #30363d; background: transparent; color: inherit; } +button { cursor: pointer; } +button.danger { border-color: #a4002a; color: #a4002a; } +.actions { display: flex; gap: .6rem; flex-wrap: wrap; margin-top: .4rem; } +.muted { opacity: .7; font-size: .9rem; } +.badge { display: inline-block; padding: .1rem .4rem; border-radius: 8px; border: 1px solid #30363d; font-size: .75rem; margin-left: .4rem; } +.post { border-top: 1px dashed #30363d; padding: .6rem 0; } +pre.content { white-space: pre-wrap; margin-top: .5rem; } diff --git a/cmd/shard/main.go b/cmd/shard/main.go index fc707ab..422c5cd 100644 --- a/cmd/shard/main.go +++ b/cmd/shard/main.go @@ -1,4 +1,3 @@ -// cmd/shard/main.go package main import ( @@ -26,8 +25,9 @@ func getenvBool(key string, def bool) bool { } func staticHeaders(next http.Handler) http.Handler { + onion := os.Getenv("GC_ONION_LOCATION") // optional: e.g., http://xxxxxxxx.onion/ return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - // Security headers + // Security headers + strict CSP (no inline) + COEP w.Header().Set("Referrer-Policy", "no-referrer") w.Header().Set("Cross-Origin-Opener-Policy", "same-origin") w.Header().Set("Cross-Origin-Resource-Policy", "same-site") @@ -35,6 +35,19 @@ func staticHeaders(next http.Handler) http.Handler { w.Header().Set("X-Frame-Options", "DENY") w.Header().Set("X-Content-Type-Options", "nosniff") w.Header().Set("Strict-Transport-Security", "max-age=15552000; includeSubDomains; preload") + w.Header().Set("Cross-Origin-Embedder-Policy", "require-corp") + // Allow only self + HTTPS for fetch/SSE; no inline styles/scripts + w.Header().Set("Content-Security-Policy", + "default-src 'self'; "+ + "script-src 'self'; "+ + "style-src 'self'; "+ + "img-src 'self' data:; "+ + "connect-src 'self' https:; "+ + "frame-ancestors 'none'; object-src 'none'; base-uri 'none'; form-action 'self'; "+ + "require-trusted-types-for 'script'") + if onion != "" { + w.Header().Set("Onion-Location", onion) + } // Basic CORS for static (GET only effectively) w.Header().Set("Access-Control-Allow-Origin", "*") @@ -72,12 +85,17 @@ func main() { dataDir = "/var/lib/greencoast" } - coarseTS := getenvBool("GC_COARSE_TS", false) + coarseTS := getenvBool("GC_COARSE_TS", true) // safer default (less precise metadata) zeroTrust := getenvBool("GC_ZERO_TRUST", true) encRequired := getenvBool("GC_ENCRYPTION_REQUIRED", true) // operator-blind by default - requirePOP := getenvBool("GC_REQUIRE_POP", true) // for logging only; API defaults to true internally + requirePOP := getenvBool("GC_REQUIRE_POP", true) // logged only here signingSecretHex := os.Getenv("GC_SIGNING_SECRET_HEX") + if len(signingSecretHex) < 64 { + log.Printf("WARN: GC_SIGNING_SECRET_HEX length=%d (need >=64 hex chars)", len(signingSecretHex)) + } else { + log.Printf("GC_SIGNING_SECRET_HEX OK (len=%d)", len(signingSecretHex)) + } discID := os.Getenv("GC_DISCORD_CLIENT_ID") discSecret := os.Getenv("GC_DISCORD_CLIENT_SECRET") @@ -90,13 +108,17 @@ func main() { } ix := index.New() - // Reindex on boot from whatever files exist on disk + // Reindex on boot from existing files (coarse time if enabled) if err := store.Walk(func(hash string, size int64, mod time.Time) error { + when := mod.UTC() + if coarseTS { + when = when.Truncate(time.Minute) + } return ix.Put(index.Entry{ Hash: hash, Bytes: size, - StoredAt: mod.UTC().Format(time.RFC3339Nano), - Private: false, // unknown here; safe default + StoredAt: when.Format(time.RFC3339Nano), + Private: false, // unknown here }) }); err != nil { log.Printf("reindex on boot: %v", err) diff --git a/internal/api/http.go b/internal/api/http.go index f7387f7..d1fc87c 100644 --- a/internal/api/http.go +++ b/internal/api/http.go @@ -15,6 +15,7 @@ import ( "log" "math/big" "net/http" + "os" "sort" "strconv" "strings" @@ -55,6 +56,9 @@ type Server struct { signingKey []byte discord DiscordProvider + // rate limiter + rl *rateLimiter + // device-key challenge cache chMu sync.Mutex chal map[string]time.Time // nonce -> expires @@ -70,7 +74,7 @@ type Server struct { func New(store BlobStore, idx *index.Index, coarseTS, zeroTrust bool, providers AuthProviders, encRequired bool) *Server { key, _ := hex.DecodeString(strings.TrimSpace(providers.SigningSecretHex)) - return &Server{ + s := &Server{ store: store, idx: idx, zeroTrust: zeroTrust, @@ -83,6 +87,9 @@ func New(store BlobStore, idx *index.Index, coarseTS, zeroTrust bool, providers replay: make(map[string]time.Time), sseSub: make(map[chan string]struct{}), } + // Default limiter: ~2 req/sec, burst 20, 10 min idle eviction + s.rl = newRateLimiter(2.0, 20, 10*time.Minute) + return s } func (s *Server) ListenHTTP(addr string) error { @@ -108,19 +115,22 @@ func (s *Server) routes() *http.ServeMux { mux.HandleFunc("/v1/auth/key/verify", s.handleAuthKeyVerify) // Objects - mux.HandleFunc("/v1/object", s.handlePutObject) // PUT - mux.HandleFunc("/v1/object/", s.handleObjectByHash) // GET/DELETE + mux.HandleFunc("/v1/object", s.handlePutObject) // PUT (requires bearer+PoP) + mux.HandleFunc("/v1/object/", s.handleObjectByHash) // GET/DELETE (DELETE requires bearer+PoP) // Index mux.HandleFunc("/v1/index", s.handleIndexList) mux.HandleFunc("/v1/index/stream", s.handleIndexStream) - // Admin + // Admin (requires admin device sub) mux.HandleFunc("/v1/admin/reindex", s.handleAdminReindex) // GDPR mux.HandleFunc("/v1/gdpr/policy", s.handleGDPRPolicy) + // Session panic wipe (no auth; clears browser caches) + mux.HandleFunc("/v1/session/clear", s.handleSessionClear) + return mux } @@ -138,14 +148,42 @@ func (s *Server) cors(next http.Handler) http.Handler { w.Header().Set("X-Frame-Options", "DENY") w.Header().Set("X-Content-Type-Options", "nosniff") w.Header().Set("Strict-Transport-Security", "max-age=15552000; includeSubDomains; preload") + + // Preflight is free (don’t rate-limit OPTIONS) if r.Method == http.MethodOptions { w.WriteHeader(http.StatusNoContent) return } + + // Rate limit (by device key CNF when available; else IP) + key := "" + if b := strings.TrimPrefix(r.Header.Get("Authorization"), "Bearer "); b != "" { + if ac, err := s.parseBearer(b); err == nil && ac.CNF != "" { + key = ac.CNF + } + } + if key == "" { + key = "ip:" + clientIP(r) + } + if !s.rl.allow(key) { + w.Header().Set("Retry-After", "5") + http.Error(w, "rate limited", http.StatusTooManyRequests) + return + } + next.ServeHTTP(w, r) }) } +func (s *Server) handleSessionClear(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + http.Error(w, "method not allowed", http.StatusMethodNotAllowed) + return + } + w.Header().Set("Clear-Site-Data", `"cache","storage"`) + w.WriteHeader(http.StatusNoContent) +} + // ---------- Helpers ---------- func b64u(b []byte) string { @@ -159,6 +197,21 @@ func b64ud(s string) ([]byte, error) { } func nowUTC() time.Time { return time.Now().UTC() } +// Admin allow-list: env GC_ADMIN_SUBS="sub1,sub2" +func (s *Server) isAdmin(ac authContext) bool { + raw := strings.TrimSpace(os.Getenv("GC_ADMIN_SUBS")) + if raw == "" || ac.CNF == "" { + return false + } + sub := thumbprintFromCNF(ac.CNF) + for _, x := range strings.Split(raw, ",") { + if sub == strings.TrimSpace(x) { + return true + } + } + return false +} + // ---------- Health ---------- func (s *Server) handleHealth(w http.ResponseWriter, r *http.Request) { @@ -202,7 +255,7 @@ type keyVerifyReq struct { Nonce string `json:"nonce"` Alg string `json:"alg"` // "p256" Pub string `json:"pub"` // base64url raw (uncompressed point, 65 bytes) - Sig string `json:"sig"` // base64url DER(ECDSA) + Sig string `json:"sig"` // base64url DER(ECDSA) or raw r||s (64B) } func (s *Server) handleAuthKeyVerify(w http.ResponseWriter, r *http.Request) { @@ -246,7 +299,7 @@ func (s *Server) handleAuthKeyVerify(w http.ResponseWriter, r *http.Request) { writeErr(http.StatusBadRequest, "bad pub") return } - sigDER, err := b64ud(req.Sig) + sig, err := b64ud(req.Sig) if err != nil { writeErr(http.StatusBadRequest, "bad sig") return @@ -256,8 +309,8 @@ func (s *Server) handleAuthKeyVerify(w http.ResponseWriter, r *http.Request) { x := new(big.Int).SetBytes(pubRaw[1:33]) y := new(big.Int).SetBytes(pubRaw[33:]) ek := &ecdsa.PublicKey{Curve: elliptic.P256(), X: x, Y: y} - if !ecdsaVerify(ek, msg, sigDER) { - http.Error(w, "verify failed", http.StatusUnauthorized) + if !ecdsaVerify(ek, msg, sig) { + writeErr(http.StatusUnauthorized, "verify failed") return } // mint token @@ -293,13 +346,13 @@ func ecdsaVerify(pub *ecdsa.PublicKey, msg []byte, sig []byte) bool { return false } -// Minimal DER parser that tolerates long-form lengths and leading 0x00 in INTEGERs. +// Minimal DER parser tolerant to long-form lengths and leading 0x00. func parseECDSADER(der []byte) (*big.Int, *big.Int, bool) { if len(der) < 8 || der[0] != 0x30 { return nil, nil, false } i := 1 - // Read SEQUENCE length (short or long form) + // sequence len if i >= len(der) { return nil, nil, false } @@ -318,12 +371,10 @@ func parseECDSADER(der []byte) (*big.Int, *big.Int, bool) { seqLen = int(der[i]) i++ } - if i >= len(der) { - return nil, nil, false - } + _ = seqLen // not strictly needed // INTEGER R - if der[i] != 0x02 { + if i >= len(der) || der[i] != 0x02 { return nil, nil, false } i++ @@ -337,6 +388,7 @@ func parseECDSADER(der []byte) (*big.Int, *big.Int, bool) { } rb := der[i : i+rLen] i += rLen + // INTEGER S if i >= len(der) || der[i] != 0x02 { return nil, nil, false @@ -418,7 +470,6 @@ func (s *Server) gc2Mint(cnf string, exp time.Time) (string, error) { } hdr := map[string]string{"alg": "HS256", "typ": "GC2"} iat := nowUTC().Unix() - // Fix: assign the sum before slicing to avoid "unaddressable value" sum := sha256.Sum256([]byte(fmt.Sprintf("%d", time.Now().UnixNano()))) jti := b64u(sum[:16]) @@ -480,7 +531,8 @@ func (s *Server) verifyPoP(w http.ResponseWriter, r *http.Request, ac authContex return false } now := nowUTC().Unix() - if ts < now-600 || ts > now+600 { + // Tight window: ±120s + if ts < now-120 || ts > now+120 { http.Error(w, "ts window", http.StatusUnauthorized) return false } @@ -496,7 +548,7 @@ func (s *Server) verifyPoP(w http.ResponseWriter, r *http.Request, ac authContex http.Error(w, "replay", http.StatusUnauthorized) return false } - s.replay[proof] = nowUTC().Add(10 * time.Minute) + s.replay[proof] = nowUTC().Add(2 * time.Minute) s.rpMu.Unlock() // Verify signature over METHOD \n PATH \n TS \n SHA256(bodyHex) @@ -513,7 +565,7 @@ func (s *Server) verifyPoP(w http.ResponseWriter, r *http.Request, ac authContex http.Error(w, "bad key", http.StatusUnauthorized) return false } - sigDER, err := b64ud(proof) + sig, err := b64ud(proof) if err != nil { http.Error(w, "bad proof", http.StatusUnauthorized) return false @@ -521,7 +573,7 @@ func (s *Server) verifyPoP(w http.ResponseWriter, r *http.Request, ac authContex x := new(big.Int).SetBytes(pubRaw[1:33]) y := new(big.Int).SetBytes(pubRaw[33:]) ek := &ecdsa.PublicKey{Curve: elliptic.P256(), X: x, Y: y} - if !ecdsaVerify(ek, msg, sigDER) { + if !ecdsaVerify(ek, msg, sig) { http.Error(w, "pop verify", http.StatusUnauthorized) return false } @@ -706,7 +758,6 @@ func (s *Server) sseBroadcastJSON(v any) { select { case ch <- string(b): default: - // drop if slow } } s.sseMu.Unlock() @@ -720,7 +771,8 @@ func (s *Server) handleAdminReindex(w http.ResponseWriter, r *http.Request) { return } ac, _ := s.parseBearer(strings.TrimPrefix(r.Header.Get("Authorization"), "Bearer ")) - if s.requirePOP && !s.verifyPoP(w, r, ac, nil) { + if !s.verifyPoP(w, r, ac, nil) || !s.isAdmin(ac) { + http.Error(w, "forbidden", http.StatusForbidden) return } var walked, indexed int64 diff --git a/internal/api/ratelimit.go b/internal/api/ratelimit.go new file mode 100644 index 0000000..3aec4ba --- /dev/null +++ b/internal/api/ratelimit.go @@ -0,0 +1,78 @@ +package api + +import ( + "net" + "net/http" + "sync" + "time" +) + +type rateLimiter struct { + mu sync.Mutex + bk map[string]*bucket + rate float64 // tokens per second + burst float64 + window time.Duration +} + +type bucket struct { + tokens float64 + last time.Time +} + +func newRateLimiter(rps float64, burst int, window time.Duration) *rateLimiter { + return &rateLimiter{ + bk: make(map[string]*bucket), + rate: rps, + burst: float64(burst), + window: window, + } +} + +func (rl *rateLimiter) allow(key string) bool { + now := time.Now() + rl.mu.Lock() + defer rl.mu.Unlock() + + b := rl.bk[key] + if b == nil { + b = &bucket{tokens: rl.burst, last: now} + rl.bk[key] = b + } + // refill + elapsed := now.Sub(b.last).Seconds() + b.tokens = min(rl.burst, b.tokens+elapsed*rl.rate) + b.last = now + + if b.tokens < 1.0 { + return false + } + b.tokens -= 1.0 + + // occasional cleanup + for k, v := range rl.bk { + if now.Sub(v.last) > rl.window { + delete(rl.bk, k) + } + } + return true +} + +func min(a, b float64) float64 { + if a < b { + return a + } + return b +} + +func clientIP(r *http.Request) string { + // Prefer Cloudflare’s header if present; fall back to RemoteAddr. + if ip := r.Header.Get("CF-Connecting-IP"); ip != "" { + return ip + } + host, _, err := net.SplitHostPort(r.RemoteAddr) + if err != nil { + return r.RemoteAddr + } + return host +} diff --git a/testdata/objects/5dcfd2e73a6c3a17b0efb103f1a3b891fc06b0189a0c5d5916435d9e5d74e963 b/testdata/objects/5dcfd2e73a6c3a17b0efb103f1a3b891fc06b0189a0c5d5916435d9e5d74e963 deleted file mode 100644 index 84a07ee..0000000 --- a/testdata/objects/5dcfd2e73a6c3a17b0efb103f1a3b891fc06b0189a0c5d5916435d9e5d74e963 +++ /dev/null @@ -1 +0,0 @@ -{"title":"Timezone Publish","body":"You can now include your timezone on all of your posts. This is completely optional but lets others see when you posted"} \ No newline at end of file