All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful
docs/ARCHITECTURE.md records why internal/web.render replaces the chassis JSON-API policy with a per-response nonce policy, and the failure each of the three decisions prevents: a header-only policy because two policies on one response intersect, a nonce instead of 'unsafe-inline' because the guarantee is that only the reviewed same-document script reaches the fragment key, and a fresh url-alphabet value because a reused nonce is worth 'unsafe-inline' to anyone who waits for the next load and + or / would make enforcement depend on entity decoding. README.md now names the buttons the page actually renders and says outright that there is no lifetime picker — the server's 24h default applies and ttl_seconds is where a caller chooses. base.html drops the opacity transition; nothing animates opacity. .dockerignore is an allowlist, because the build stage COPYs only go.mod, go.sum, vendor/, cmd/ and internal/. A blocklist forgets the file nobody predicted, and for this service that file is a secret. .gitignore grows the same protection for the working tree.
141 lines
5.9 KiB
HTML
141 lines
5.9 KiB
HTML
{{define "base.html"}}<!doctype html>
|
|
<html lang="en">
|
|
<head>
|
|
<meta charset="utf-8">
|
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
<title>hush</title>
|
|
<meta name="color-scheme" content="dark">
|
|
<meta name="referrer" content="no-referrer">
|
|
<!-- The policy is a HEADER, set in internal/web/web.go, and every inline block
|
|
below carries that response's nonce. There is deliberately no CSP <meta>
|
|
here: two policies on one response intersect, so a <meta> cannot loosen the
|
|
header, and it silently made this page's own script look permitted while
|
|
the browser blocked it. No external origins either — no CDN, no font host,
|
|
no analytics: a third-party script on this page could read the key out of
|
|
the fragment. -->
|
|
<style nonce="{{.Nonce}}">
|
|
:root{
|
|
color-scheme:dark;
|
|
--bg:#08090a; /* page */
|
|
--panel:#0e1012; /* the one card */
|
|
--inset:#0a0b0c; /* fields and output, recessed from the card */
|
|
--line:#1c1e22; /* hairline */
|
|
--line-lit:#2b2f35; /* hairline, hovered or focused */
|
|
--fg:#f1f2f4;
|
|
--dim:#8d939d; /* supporting copy */
|
|
--faint:#5d626b; /* placeholder, footer */
|
|
--accent:#8ce0b8; /* used sparingly: the mark, focus, success */
|
|
--warn:#f2c078;
|
|
--bad:#ff8189;
|
|
--r:12px;
|
|
--mono:ui-monospace,SFMono-Regular,Menlo,monospace;
|
|
}
|
|
*{box-sizing:border-box}
|
|
html{-webkit-text-size-adjust:100%}
|
|
body{
|
|
margin:0;background:var(--bg);color:var(--fg);
|
|
font:15px/1.5 -apple-system,BlinkMacSystemFont,"Segoe UI",Inter,system-ui,sans-serif;
|
|
font-feature-settings:"kern" 1;-webkit-font-smoothing:antialiased;
|
|
display:flex;min-height:100vh;align-items:center;justify-content:center;padding:24px;
|
|
}
|
|
main{width:100%;max-width:440px}
|
|
.card{
|
|
background:var(--panel);border:1px solid var(--line);border-radius:var(--r);
|
|
padding:28px 26px;box-shadow:0 30px 60px -40px #000,0 1px 0 #ffffff05 inset;
|
|
}
|
|
h1{font-size:21px;font-weight:600;letter-spacing:-.02em;margin:0 0 6px}
|
|
h1 span{color:var(--accent)}
|
|
p.lede{color:var(--dim);margin:0 0 22px;font-size:13.5px}
|
|
textarea,button{font:inherit;width:100%;border-radius:10px}
|
|
textarea{
|
|
color:var(--fg);background:var(--inset);border:1px solid var(--line);
|
|
font:13px/1.55 var(--mono);padding:13px 14px;min-height:132px;resize:vertical;
|
|
transition:border-color .12s,box-shadow .12s;
|
|
}
|
|
textarea::placeholder{color:var(--faint)}
|
|
textarea:hover{border-color:var(--line-lit)}
|
|
textarea:focus,button:focus-visible{outline:0;border-color:var(--line-lit);box-shadow:0 0 0 3px #8ce0b81f}
|
|
button{
|
|
margin-top:14px;padding:11px 16px;border:1px solid transparent;
|
|
background:var(--fg);color:#08090a;font-weight:600;font-size:14px;
|
|
cursor:pointer;transition:background .12s,border-color .12s,color .12s;
|
|
}
|
|
button:hover:not(:disabled){background:#fff}
|
|
button:active:not(:disabled){transform:translateY(.5px)}
|
|
/* A disabled solid button is a grey slab that still dominates the card and
|
|
reads as "loading" rather than "not now". It recedes instead. */
|
|
button:disabled{background:none;color:var(--faint);border-color:var(--line);cursor:not-allowed}
|
|
button.ghost{background:none;color:var(--dim);border-color:var(--line);font-weight:500}
|
|
button.ghost:hover{background:none;color:var(--fg);border-color:var(--line-lit)}
|
|
.row{display:flex;gap:10px}
|
|
.row>*{flex:1}
|
|
.out{
|
|
background:var(--inset);border:1px solid var(--line);border-radius:10px;
|
|
padding:13px 14px;font:13px/1.6 var(--mono);word-break:break-all;
|
|
white-space:pre-wrap;user-select:all;
|
|
}
|
|
.note{color:var(--dim);font-size:12.5px;line-height:1.5;margin:12px 0 0}
|
|
.note.err{color:var(--bad)}
|
|
.note.warn{color:var(--warn)}
|
|
.hide{display:none}
|
|
footer{margin:14px 2px 0;color:var(--faint);font-size:11.5px;line-height:1.5}
|
|
footer code{font:11px var(--mono);color:var(--dim)}
|
|
@media (prefers-reduced-motion:reduce){*{transition:none!important}}
|
|
</style>
|
|
</head>
|
|
<body>
|
|
<main>
|
|
<div class="card">
|
|
{{template "content" .}}
|
|
</div>
|
|
<footer>
|
|
Encrypted in your browser. The key travels in the link's <code>#fragment</code>,
|
|
which browsers never send to a server.
|
|
</footer>
|
|
</main>
|
|
<script nonce="{{.Nonce}}">
|
|
// Shared crypto. AES-256-GCM via WebCrypto; the key is generated here, never
|
|
// transmitted, and carried only in the URL fragment.
|
|
//
|
|
// b64u: base64url without padding, matching Go's base64.RawURLEncoding on the
|
|
// server so the wire format has exactly one spelling.
|
|
const b64u = {
|
|
enc(bytes) {
|
|
let s = ""; for (const b of bytes) s += String.fromCharCode(b);
|
|
return btoa(s).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
|
|
},
|
|
dec(str) {
|
|
const p = str.replace(/-/g, "+").replace(/_/g, "/");
|
|
const raw = atob(p + "=".repeat((4 - (p.length % 4)) % 4));
|
|
const out = new Uint8Array(raw.length);
|
|
for (let i = 0; i < raw.length; i++) out[i] = raw.charCodeAt(i);
|
|
return out;
|
|
},
|
|
};
|
|
|
|
async function seal(plaintext) {
|
|
const key = await crypto.subtle.generateKey({ name: "AES-GCM", length: 256 }, true, ["encrypt", "decrypt"]);
|
|
// 96-bit nonce is the AES-GCM standard size and is generated per secret. It
|
|
// is prepended to the ciphertext rather than sent separately so a stored blob
|
|
// is self-contained.
|
|
const nonce = crypto.getRandomValues(new Uint8Array(12));
|
|
const body = new Uint8Array(await crypto.subtle.encrypt(
|
|
{ name: "AES-GCM", iv: nonce }, key, new TextEncoder().encode(plaintext)));
|
|
const blob = new Uint8Array(nonce.length + body.length);
|
|
blob.set(nonce, 0); blob.set(body, nonce.length);
|
|
const rawKey = new Uint8Array(await crypto.subtle.exportKey("raw", key));
|
|
return { ciphertext: b64u.enc(blob), key: b64u.enc(rawKey) };
|
|
}
|
|
|
|
async function open(ciphertext, keyStr) {
|
|
const blob = b64u.dec(ciphertext);
|
|
const key = await crypto.subtle.importKey("raw", b64u.dec(keyStr), "AES-GCM", false, ["decrypt"]);
|
|
const plain = await crypto.subtle.decrypt(
|
|
{ name: "AES-GCM", iv: blob.slice(0, 12) }, key, blob.slice(12));
|
|
return new TextDecoder().decode(plain);
|
|
}
|
|
</script>
|
|
{{template "script" .}}
|
|
</body>
|
|
</html>{{end}}
|