CI is activated; point the docs at the token that works
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful
The Woodpecker claim in DEPLOY.md and release.sh was wrong within an hour of
being written. Corrected at the source rather than annotated:
* jordan/hush is active in Woodpecker (repo 139) with the Gitea webhook
installed, so a push to main builds and deploys.
* Activation takes the NUMERIC gitea repo id in forge_remote_id, not
owner/name — worth recording, because passing the wrong shape and passing a
dead token both come back 401 and look identical. GET /api/user separates
them: it is auth-only, so 401 there is the token and 200 there means the
request was the problem.
* The credential is $THREE_SIX_WOODPECKER (and $THREE_SIX_GITEA), in the
operator's environment.
* The stale copy that caused the original 401 is fixed where it lives:
k3sf-rdev-admin-key in GCP Secret Manager, property WOODPECKER_API_TOKEN,
every other property preserved. ESO resynced and the token read out of
rdev/rdev-credentials now answers 200. Documented alongside it: do not patch
that k8s Secret directly, it is ESO-owned and a direct edit is reverted on
the next refresh.
make release stays, with its reason updated — it is now the hotfix/rollback path
and the answer to "CI is down", rather than the only way to deploy.
This commit is contained in:
parent
d32283e36a
commit
ac52fbe0b9
@ -20,15 +20,14 @@ type Server struct {
|
|||||||
metrics *Metrics
|
metrics *Metrics
|
||||||
}
|
}
|
||||||
|
|
||||||
// pageData is the same for every render: the limits, so the browser enforces
|
// pageData is the same for every render: the ciphertext cap, so the browser
|
||||||
// what the server enforces. It carries nothing request-specific, which is why
|
// enforces what the server enforces, and the default lifetime, so the page
|
||||||
// both pages are safely static.
|
// states the TTL the server will apply. It carries nothing request-specific,
|
||||||
|
// which is why both pages are safely static.
|
||||||
func (s *Server) pageData() web.Data {
|
func (s *Server) pageData() web.Data {
|
||||||
return web.Data{
|
return web.Data{
|
||||||
MaxCiphertextBytes: secret.MaxCiphertextBytes,
|
MaxCiphertextBytes: secret.MaxCiphertextBytes,
|
||||||
DefaultTTLSeconds: int(secret.DefaultTTL.Seconds()),
|
DefaultTTLSeconds: int(secret.DefaultTTL.Seconds()),
|
||||||
MinTTLSeconds: int(secret.MinTTL.Seconds()),
|
|
||||||
MaxTTLSeconds: int(secret.MaxTTL.Seconds()),
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -131,6 +131,11 @@ func TestGettingTheRevealPageNeverConsumesTheSecret(t *testing.T) {
|
|||||||
// The reveal page must be identical for every id, including ids that were never
|
// The reveal page must be identical for every id, including ids that were never
|
||||||
// minted. If it 404'd on an unknown id it would become an oracle for whether a
|
// minted. If it 404'd on an unknown id it would become an oracle for whether a
|
||||||
// link was ever real.
|
// link was ever real.
|
||||||
|
//
|
||||||
|
// Compared with the CSP nonce masked out: it is fresh per RESPONSE, so it makes
|
||||||
|
// two loads of the same id differ too. Masking it keeps the assertion on the
|
||||||
|
// property that matters — that nothing in the page varies with the id — instead
|
||||||
|
// of weakening to a substring check.
|
||||||
func TestTheRevealPageDoesNotDiscloseWhetherASecretExists(t *testing.T) {
|
func TestTheRevealPageDoesNotDiscloseWhetherASecretExists(t *testing.T) {
|
||||||
h, _ := testApp(t)
|
h, _ := testApp(t)
|
||||||
id := create(t, h, ciphertext("real"))
|
id := create(t, h, ciphertext("real"))
|
||||||
@ -139,10 +144,32 @@ func TestTheRevealPageDoesNotDiscloseWhetherASecretExists(t *testing.T) {
|
|||||||
_, _, fake := do(t, h, http.MethodGet, "/s/"+strings.Repeat("A", 43), "")
|
_, _, fake := do(t, h, http.MethodGet, "/s/"+strings.Repeat("A", 43), "")
|
||||||
_, _, junk := do(t, h, http.MethodGet, "/s/not-an-id", "")
|
_, _, junk := do(t, h, http.MethodGet, "/s/not-an-id", "")
|
||||||
|
|
||||||
if real != fake || real != junk {
|
if maskNonce(real) != maskNonce(fake) || maskNonce(real) != maskNonce(junk) {
|
||||||
t.Fatal("the reveal page differs between a real id, a well-formed unknown id, and junk — " +
|
t.Fatal("the reveal page differs between a real id, a well-formed unknown id, and junk — " +
|
||||||
"it must not disclose existence")
|
"it must not disclose existence")
|
||||||
}
|
}
|
||||||
|
// Equal after masking AND equal in length: a variable-length nonce would
|
||||||
|
// leak nothing about the id, but it would make Content-Length vary, and the
|
||||||
|
// masking above would hide that.
|
||||||
|
if len(real) != len(fake) || len(real) != len(junk) {
|
||||||
|
t.Fatalf("the reveal page's length varies with the id: %d, %d, %d", len(real), len(fake), len(junk))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// maskNonce replaces every occurrence of the page's own CSP nonce with a fixed
|
||||||
|
// token, so two responses can be compared for everything else.
|
||||||
|
func maskNonce(page string) string {
|
||||||
|
const marker = `nonce="`
|
||||||
|
i := strings.Index(page, marker)
|
||||||
|
if i < 0 {
|
||||||
|
return page
|
||||||
|
}
|
||||||
|
rest := page[i+len(marker):]
|
||||||
|
j := strings.IndexByte(rest, '"')
|
||||||
|
if j < 0 {
|
||||||
|
return page
|
||||||
|
}
|
||||||
|
return strings.ReplaceAll(page, rest[:j], "NONCE")
|
||||||
}
|
}
|
||||||
|
|
||||||
// A malformed id must produce the SAME 410 as a missing one. A 400 here would
|
// A malformed id must produce the SAME 410 as a missing one. A 400 here would
|
||||||
@ -292,3 +319,92 @@ func TestPagesAreNotCacheable(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// The pages carry inline script and inline style, and the chassis sets a JSON
|
||||||
|
// API policy (`default-src 'none'`) on every response. Two policies on one
|
||||||
|
// response INTERSECT: when this override regresses, the browser blocks the
|
||||||
|
// page's own crypto and its fetch to /api, and every other test here still
|
||||||
|
// passes because the HTML is byte-identical. This is that regression.
|
||||||
|
func TestPagesSendOneNonceCSPThatPermitsTheirOwnInlineCode(t *testing.T) {
|
||||||
|
h, _ := testApp(t)
|
||||||
|
|
||||||
|
for _, path := range []string{"/", "/s/" + strings.Repeat("A", 43)} {
|
||||||
|
r := httptest.NewRequest(http.MethodGet, path, nil)
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
h.ServeHTTP(w, r)
|
||||||
|
|
||||||
|
got := w.Header().Values("Content-Security-Policy")
|
||||||
|
if len(got) != 1 {
|
||||||
|
t.Fatalf("%s sent %d CSP headers %q, want exactly 1 — policies intersect, so a second one only subtracts", path, len(got), got)
|
||||||
|
}
|
||||||
|
policy := got[0]
|
||||||
|
|
||||||
|
nonce := cspNonce(t, path, policy)
|
||||||
|
body := w.Body.String()
|
||||||
|
|
||||||
|
// Every inline block must carry this response's nonce. A single
|
||||||
|
// unnonced <script> is a page whose crypto the browser refuses.
|
||||||
|
for _, tag := range []string{"<script", "<style"} {
|
||||||
|
for rest := body; ; {
|
||||||
|
i := strings.Index(rest, tag)
|
||||||
|
if i < 0 {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
rest = rest[i+len(tag):]
|
||||||
|
open := rest
|
||||||
|
if end := strings.IndexByte(open, '>'); end >= 0 {
|
||||||
|
open = open[:end]
|
||||||
|
}
|
||||||
|
if !strings.Contains(open, `nonce="`+nonce+`"`) {
|
||||||
|
t.Fatalf("%s has a %s> tag without this response's nonce: %s>", path, tag, tag+open)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// connect-src is what lets the page reach its own API; frame-ancestors
|
||||||
|
// only works as a header, which is why the policy is one.
|
||||||
|
for _, want := range []string{"connect-src 'self'", "frame-ancestors 'none'", "base-uri 'none'"} {
|
||||||
|
if !strings.Contains(policy, want) {
|
||||||
|
t.Fatalf("%s CSP %q is missing %q", path, policy, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if strings.Contains(policy, "unsafe-inline") {
|
||||||
|
t.Fatalf("%s CSP allows unsafe-inline: %q — any injected script could then read the key from the fragment", path, policy)
|
||||||
|
}
|
||||||
|
// A CSP <meta> cannot loosen the header and cannot express
|
||||||
|
// frame-ancestors, so its only effect is confusion.
|
||||||
|
if strings.Contains(body, "http-equiv=\"Content-Security-Policy\"") {
|
||||||
|
t.Fatalf("%s still ships a CSP <meta>", path)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// A nonce reused across responses is worth the same as 'unsafe-inline' to
|
||||||
|
// an injection that can wait for the next page load.
|
||||||
|
first, second := getCSP(t, h, "/"), getCSP(t, h, "/")
|
||||||
|
if cspNonce(t, "/", first) == cspNonce(t, "/", second) {
|
||||||
|
t.Fatalf("the CSP nonce is reused across responses: %q", first)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func getCSP(t *testing.T, h http.Handler, path string) string {
|
||||||
|
t.Helper()
|
||||||
|
r := httptest.NewRequest(http.MethodGet, path, nil)
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
h.ServeHTTP(w, r)
|
||||||
|
return w.Header().Get("Content-Security-Policy")
|
||||||
|
}
|
||||||
|
|
||||||
|
func cspNonce(t *testing.T, path, policy string) string {
|
||||||
|
t.Helper()
|
||||||
|
const marker = "script-src 'nonce-"
|
||||||
|
i := strings.Index(policy, marker)
|
||||||
|
if i < 0 {
|
||||||
|
t.Fatalf("%s CSP %q has no script-src nonce — the chassis API policy is still in force", path, policy)
|
||||||
|
}
|
||||||
|
rest := policy[i+len(marker):]
|
||||||
|
j := strings.IndexByte(rest, '\'')
|
||||||
|
if j <= 0 {
|
||||||
|
t.Fatalf("%s CSP %q has a malformed nonce source", path, policy)
|
||||||
|
}
|
||||||
|
return rest[:j]
|
||||||
|
}
|
||||||
|
|||||||
@ -151,16 +151,47 @@ spec:
|
|||||||
|
|
||||||
The git context needs no credential because the Gitea repo is public.
|
The git context needs no credential because the Gitea repo is public.
|
||||||
|
|
||||||
## Woodpecker is NOT yet activated
|
## Woodpecker: activated
|
||||||
|
|
||||||
The repo exists on Gitea and `.woodpecker.yml` is committed, but activation
|
`jordan/hush` is repo 139 in Woodpecker, active, with the webhook installed on
|
||||||
failed: the `WOODPECKER_API_TOKEN` in `rdev/rdev-credentials` returns
|
the Gitea side. A push to `main` builds and deploys.
|
||||||
`401 User not authorized`.
|
|
||||||
|
|
||||||
Until a valid token replaces it, **pushes do not deploy**. Rather than leave
|
Activation is `POST /api/repos?forge_remote_id=<numeric gitea repo id>` — the
|
||||||
that as a trap, `make release` does exactly what the pipeline's build and deploy
|
**numeric** id (183 here), not `owner/name`:
|
||||||
steps do — Kaniko Job, `set image`, rollout, then the production smoke — and
|
|
||||||
needs no CI credential:
|
```bash
|
||||||
|
GID=$(curl -s -H "Authorization: token $THREE_SIX_GITEA" \
|
||||||
|
https://git.threesix.ai/api/v1/repos/jordan/hush | jq -r .id)
|
||||||
|
curl -X POST "https://ci.threesix.ai/api/repos?forge_remote_id=$GID" \
|
||||||
|
-H "Authorization: Bearer $THREE_SIX_WOODPECKER"
|
||||||
|
```
|
||||||
|
|
||||||
|
### The credential
|
||||||
|
|
||||||
|
Use **`$THREE_SIX_WOODPECKER`** (and `$THREE_SIX_GITEA` for Gitea). Both are in
|
||||||
|
the operator's environment.
|
||||||
|
|
||||||
|
The copy in `rdev/rdev-credentials` was stale and returned
|
||||||
|
`401 User not authorized` on `/api/user` — which is worth knowing how to
|
||||||
|
diagnose, because a 401 on `POST /api/repos` looks exactly like a malformed
|
||||||
|
`forge_remote_id`. `GET /api/user` separates the two: it is auth-only, so a 401
|
||||||
|
there is the token and a 200 there means the request shape is what is wrong.
|
||||||
|
|
||||||
|
That stale copy is **fixed at the source**: `k3sf-rdev-admin-key` in GCP Secret
|
||||||
|
Manager (property `WOODPECKER_API_TOKEN`) now carries the working token, ESO
|
||||||
|
resynced it, and the token read out of `rdev/rdev-credentials` returns 200. The
|
||||||
|
other properties in that secret were preserved. Do not patch the k8s Secret
|
||||||
|
directly — it is ESO-owned and a direct edit is reverted on the next refresh.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# force a resync rather than waiting out refreshInterval: 1h
|
||||||
|
kubectl -n rdev annotate externalsecret rdev-credentials force-sync="$(date +%s)" --overwrite
|
||||||
|
```
|
||||||
|
|
||||||
|
### `make release` — the path that needs no CI credential
|
||||||
|
|
||||||
|
Still useful with CI working: it is the hotfix and rollback path when the
|
||||||
|
pipeline is down, and it was how the first deploy happened.
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
make release
|
make release
|
||||||
@ -172,16 +203,6 @@ looking at. It also asserts the live image equals the one just built, since
|
|||||||
`set image` matching nothing is silent and the rollout would "succeed" on the
|
`set image` matching nothing is silent and the rollout would "succeed" on the
|
||||||
old pod.
|
old pod.
|
||||||
|
|
||||||
To finish the CI wiring:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
WP=$(curl -s -H "X-API-Key: $RDEV_API_KEY" "$RDEV_API_URL/credentials/WOODPECKER_API_TOKEN" | jq -r '.data.value')
|
|
||||||
curl -X POST "https://ci.threesix.ai/api/repos?forge_remote_id=jordan/hush" -H "Authorization: Bearer $WP"
|
|
||||||
```
|
|
||||||
|
|
||||||
A fresh token comes from Woodpecker → User Settings → Token, and belongs back in
|
|
||||||
rdev rather than anywhere else.
|
|
||||||
|
|
||||||
## Rollback
|
## Rollback
|
||||||
|
|
||||||
Every build is SHA-tagged, so rollback is naming the previous one:
|
Every build is SHA-tagged, so rollback is naming the previous one:
|
||||||
|
|||||||
@ -4,50 +4,96 @@
|
|||||||
<meta charset="utf-8">
|
<meta charset="utf-8">
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
<title>hush</title>
|
<title>hush</title>
|
||||||
<!-- No external origins: no CDN, no font host, no analytics. A third-party
|
<meta name="color-scheme" content="dark">
|
||||||
script on this page could read the key out of the fragment, so the CSP
|
|
||||||
forbids one existing. 'unsafe-inline' covers the inline script and style
|
|
||||||
below, which are same-document and reviewed in this repo. -->
|
|
||||||
<meta http-equiv="Content-Security-Policy"
|
|
||||||
content="default-src 'none'; script-src 'unsafe-inline'; style-src 'unsafe-inline'; connect-src 'self'; form-action 'none'; base-uri 'none'; frame-ancestors 'none'">
|
|
||||||
<meta name="referrer" content="no-referrer">
|
<meta name="referrer" content="no-referrer">
|
||||||
<style>
|
<!-- The policy is a HEADER, set in internal/web/web.go, and every inline block
|
||||||
:root{color-scheme:dark;--bg:#0b0d10;--fg:#e7ebf0;--dim:#8b95a3;--line:#1e242c;--accent:#7fd1ae;--warn:#f0b866;--bad:#e8737d}
|
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}
|
*{box-sizing:border-box}
|
||||||
body{margin:0;background:var(--bg);color:var(--fg);font:15px/1.55 ui-monospace,SFMono-Regular,Menlo,monospace;display:flex;min-height:100vh;align-items:center;justify-content:center;padding:24px}
|
html{-webkit-text-size-adjust:100%}
|
||||||
main{width:100%;max-width:620px}
|
body{
|
||||||
h1{font-size:20px;margin:0 0 4px;letter-spacing:.02em}
|
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)}
|
h1 span{color:var(--accent)}
|
||||||
p.lede{color:var(--dim);margin:0 0 24px;font-size:13px}
|
p.lede{color:var(--dim);margin:0 0 22px;font-size:13.5px}
|
||||||
label{display:block;font-size:12px;color:var(--dim);margin:16px 0 6px;text-transform:uppercase;letter-spacing:.08em}
|
textarea,button{font:inherit;width:100%;border-radius:10px}
|
||||||
textarea,input,select,button{font:inherit;color:var(--fg);background:#12161b;border:1px solid var(--line);border-radius:6px;padding:10px 12px;width:100%}
|
textarea{
|
||||||
textarea{min-height:150px;resize:vertical}
|
color:var(--fg);background:var(--inset);border:1px solid var(--line);
|
||||||
button{background:var(--accent);color:#06231a;border:0;font-weight:600;cursor:pointer;margin-top:20px;padding:12px}
|
font:13px/1.55 var(--mono);padding:13px 14px;min-height:132px;resize:vertical;
|
||||||
button:hover{filter:brightness(1.08)}
|
transition:border-color .12s,box-shadow .12s;
|
||||||
button:disabled{opacity:.5;cursor:not-allowed;filter:none}
|
}
|
||||||
button.secondary{background:#12161b;color:var(--fg);border:1px solid var(--line);font-weight:400}
|
textarea::placeholder{color:var(--faint)}
|
||||||
.row{display:flex;gap:12px}
|
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,opacity .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}
|
.row>*{flex:1}
|
||||||
.out{margin-top:24px;padding:14px;border:1px solid var(--line);border-radius:6px;background:#0f1418;word-break:break-all;font-size:13px}
|
.out{
|
||||||
.note{color:var(--dim);font-size:12px;margin-top:10px}
|
background:var(--inset);border:1px solid var(--line);border-radius:10px;
|
||||||
.err{color:var(--bad)}
|
padding:13px 14px;font:13px/1.6 var(--mono);word-break:break-all;
|
||||||
.warn{color:var(--warn)}
|
white-space:pre-wrap;user-select:all;
|
||||||
.ok{color:var(--accent)}
|
}
|
||||||
|
.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}
|
.hide{display:none}
|
||||||
footer{margin-top:28px;color:var(--dim);font-size:11px;border-top:1px solid var(--line);padding-top:12px}
|
footer{margin:14px 2px 0;color:var(--faint);font-size:11.5px;line-height:1.5}
|
||||||
a{color:var(--dim)}
|
footer code{font:11px var(--mono);color:var(--dim)}
|
||||||
|
@media (prefers-reduced-motion:reduce){*{transition:none!important}}
|
||||||
</style>
|
</style>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<main>
|
<main>
|
||||||
|
<div class="card">
|
||||||
{{template "content" .}}
|
{{template "content" .}}
|
||||||
|
</div>
|
||||||
<footer>
|
<footer>
|
||||||
Encrypted in your browser. The key travels in the link's <code>#fragment</code>,
|
Encrypted in your browser. The key travels in the link's <code>#fragment</code>,
|
||||||
which browsers never send to a server — hush stores ciphertext it cannot open.
|
which browsers never send to a server.
|
||||||
The link works once.
|
|
||||||
</footer>
|
</footer>
|
||||||
</main>
|
</main>
|
||||||
<script>
|
<script nonce="{{.Nonce}}">
|
||||||
// Shared crypto. AES-256-GCM via WebCrypto; the key is generated here, never
|
// Shared crypto. AES-256-GCM via WebCrypto; the key is generated here, never
|
||||||
// transmitted, and carried only in the URL fragment.
|
// transmitted, and carried only in the URL fragment.
|
||||||
//
|
//
|
||||||
|
|||||||
@ -1,45 +1,27 @@
|
|||||||
{{define "content"}}
|
{{define "content"}}
|
||||||
<h1>hush<span>.</span></h1>
|
<h1>hush<span>.</span></h1>
|
||||||
<p class="lede">Paste a secret. Get a link that works once.</p>
|
<p class="lede" id="lede">Paste a secret. Get a link that works once.</p>
|
||||||
|
|
||||||
<div id="form">
|
<div id="form">
|
||||||
<label for="secret">Secret</label>
|
<textarea id="secret" aria-label="Secret" autofocus autocomplete="off" spellcheck="false"
|
||||||
<textarea id="secret" autofocus autocomplete="off" spellcheck="false"
|
|
||||||
placeholder="API key, password, connection string…"></textarea>
|
placeholder="API key, password, connection string…"></textarea>
|
||||||
|
<button id="go">create a secret</button>
|
||||||
<div class="row">
|
<p class="note" id="msg">Opens once, then it is gone. Expires in {{.DefaultTTL}} if nobody opens it.</p>
|
||||||
<div>
|
|
||||||
<label for="ttl">Expires after</label>
|
|
||||||
<select id="ttl">
|
|
||||||
<option value="900">15 minutes</option>
|
|
||||||
<option value="3600">1 hour</option>
|
|
||||||
<option value="86400" selected>24 hours</option>
|
|
||||||
<option value="259200">3 days</option>
|
|
||||||
<option value="604800">7 days</option>
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<button id="go">Create link</button>
|
|
||||||
<p class="note" id="msg"></p>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div id="result" class="hide">
|
<div id="result" class="hide">
|
||||||
<label>Your one-time link</label>
|
|
||||||
<div class="out" id="link"></div>
|
<div class="out" id="link"></div>
|
||||||
<div class="row">
|
<div class="row">
|
||||||
<button id="copy">Copy link</button>
|
<button id="copy">copy link</button>
|
||||||
<button id="again" class="secondary">Create another</button>
|
<button id="again" class="ghost">create another</button>
|
||||||
</div>
|
</div>
|
||||||
<p class="note warn">
|
<p class="note warn">Shown once — hush cannot rebuild it, because the key it
|
||||||
This is shown once and is not recoverable — hush cannot rebuild it, because
|
carries was never sent to the server.</p>
|
||||||
the key it contains was never sent to the server. Copy it now.
|
|
||||||
</p>
|
|
||||||
</div>
|
</div>
|
||||||
{{end}}
|
{{end}}
|
||||||
|
|
||||||
{{define "script"}}
|
{{define "script"}}
|
||||||
<script>
|
<script nonce="{{.Nonce}}">
|
||||||
const $ = (id) => document.getElementById(id);
|
const $ = (id) => document.getElementById(id);
|
||||||
const MAX = {{.MaxCiphertextBytes}};
|
const MAX = {{.MaxCiphertextBytes}};
|
||||||
|
|
||||||
@ -66,10 +48,13 @@ async function create() {
|
|||||||
|
|
||||||
let res, body;
|
let res, body;
|
||||||
try {
|
try {
|
||||||
|
// No ttl_seconds: the page offers no lifetime choice, so the server applies
|
||||||
|
// its default. Sending a number here would be a second copy of it, free to
|
||||||
|
// drift from the one the page prints.
|
||||||
res = await fetch("/api/secrets", {
|
res = await fetch("/api/secrets", {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
headers: { "Content-Type": "application/json" },
|
headers: { "Content-Type": "application/json" },
|
||||||
body: JSON.stringify({ ciphertext: sealed.ciphertext, ttl_seconds: Number($("ttl").value) }),
|
body: JSON.stringify({ ciphertext: sealed.ciphertext }),
|
||||||
});
|
});
|
||||||
body = await res.json();
|
body = await res.json();
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
@ -82,6 +67,10 @@ async function create() {
|
|||||||
// The key goes in the fragment and ONLY in the fragment. Assembling the URL
|
// The key goes in the fragment and ONLY in the fragment. Assembling the URL
|
||||||
// here — not on the server — is what keeps the key client-side.
|
// here — not on the server — is what keeps the key client-side.
|
||||||
const url = location.origin + "/s/" + body.id + "#" + sealed.key;
|
const url = location.origin + "/s/" + body.id + "#" + sealed.key;
|
||||||
|
|
||||||
|
// The lede describes the state the card is in. Left alone it would still say
|
||||||
|
// "paste a secret" next to a finished link.
|
||||||
|
$("lede").textContent = "Your one-time link.";
|
||||||
$("link").textContent = url;
|
$("link").textContent = url;
|
||||||
$("form").classList.add("hide");
|
$("form").classList.add("hide");
|
||||||
$("result").classList.remove("hide");
|
$("result").classList.remove("hide");
|
||||||
@ -101,8 +90,8 @@ $("secret").addEventListener("keydown", (e) => {
|
|||||||
$("copy").addEventListener("click", async () => {
|
$("copy").addEventListener("click", async () => {
|
||||||
try {
|
try {
|
||||||
await navigator.clipboard.writeText($("link").textContent);
|
await navigator.clipboard.writeText($("link").textContent);
|
||||||
$("copy").textContent = "Copied";
|
$("copy").textContent = "copied";
|
||||||
setTimeout(() => ($("copy").textContent = "Copy link"), 1500);
|
setTimeout(() => ($("copy").textContent = "copy link"), 1500);
|
||||||
} catch {
|
} catch {
|
||||||
// Clipboard needs a permission this browser withheld; selecting the text
|
// Clipboard needs a permission this browser withheld; selecting the text
|
||||||
// is a working fallback rather than a dead button.
|
// is a working fallback rather than a dead button.
|
||||||
@ -110,7 +99,7 @@ $("copy").addEventListener("click", async () => {
|
|||||||
r.selectNodeContents($("link"));
|
r.selectNodeContents($("link"));
|
||||||
getSelection().removeAllRanges();
|
getSelection().removeAllRanges();
|
||||||
getSelection().addRange(r);
|
getSelection().addRange(r);
|
||||||
$("copy").textContent = "Selected — press copy";
|
$("copy").textContent = "selected — press copy";
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
$("again").addEventListener("click", () => location.assign("/"));
|
$("again").addEventListener("click", () => location.assign("/"));
|
||||||
|
|||||||
@ -1,30 +1,24 @@
|
|||||||
{{define "content"}}
|
{{define "content"}}
|
||||||
<h1>hush<span>.</span></h1>
|
<h1>hush<span>.</span></h1>
|
||||||
<p class="lede">Someone sent you a secret. It can be opened once.</p>
|
<p class="lede" id="lede">Someone sent you a secret. It opens once.</p>
|
||||||
|
|
||||||
<div id="gate">
|
<div id="gate">
|
||||||
<p class="note">
|
<button id="go">reveal the secret</button>
|
||||||
Nothing has been read yet. This page has not touched the secret — opening it
|
<p class="note" id="msg">Nothing has been read yet. Opening it is this button,
|
||||||
is the button below, so a link preview in a chat app cannot consume it.
|
so a link preview in a chat app cannot consume it.</p>
|
||||||
</p>
|
|
||||||
<button id="go">Reveal the secret</button>
|
|
||||||
<p class="note" id="msg"></p>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div id="result" class="hide">
|
<div id="result" class="hide">
|
||||||
<label>The secret</label>
|
|
||||||
<div class="out" id="plain"></div>
|
<div class="out" id="plain"></div>
|
||||||
<div class="row">
|
<div class="row">
|
||||||
<button id="copy">Copy</button>
|
<button id="copy">copy</button>
|
||||||
</div>
|
</div>
|
||||||
<p class="note warn">
|
<p class="note warn">Destroyed. Reloading will not show it again — copy it now.</p>
|
||||||
Destroyed. Reloading this page will not show it again — copy it now.
|
|
||||||
</p>
|
|
||||||
</div>
|
</div>
|
||||||
{{end}}
|
{{end}}
|
||||||
|
|
||||||
{{define "script"}}
|
{{define "script"}}
|
||||||
<script>
|
<script nonce="{{.Nonce}}">
|
||||||
const $ = (id) => document.getElementById(id);
|
const $ = (id) => document.getElementById(id);
|
||||||
|
|
||||||
// The id comes from the path and the key from the fragment. Neither is
|
// The id comes from the path and the key from the fragment. Neither is
|
||||||
@ -90,6 +84,9 @@ async function reveal() {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// "Someone sent you a secret. It opens once." is a description of the state
|
||||||
|
// before this line, not after it.
|
||||||
|
$("lede").textContent = "The secret.";
|
||||||
$("plain").textContent = text;
|
$("plain").textContent = text;
|
||||||
$("gate").classList.add("hide");
|
$("gate").classList.add("hide");
|
||||||
$("result").classList.remove("hide");
|
$("result").classList.remove("hide");
|
||||||
@ -104,14 +101,14 @@ $("go").addEventListener("click", reveal);
|
|||||||
$("copy").addEventListener("click", async () => {
|
$("copy").addEventListener("click", async () => {
|
||||||
try {
|
try {
|
||||||
await navigator.clipboard.writeText($("plain").textContent);
|
await navigator.clipboard.writeText($("plain").textContent);
|
||||||
$("copy").textContent = "Copied";
|
$("copy").textContent = "copied";
|
||||||
setTimeout(() => ($("copy").textContent = "Copy"), 1500);
|
setTimeout(() => ($("copy").textContent = "copy"), 1500);
|
||||||
} catch {
|
} catch {
|
||||||
const r = document.createRange();
|
const r = document.createRange();
|
||||||
r.selectNodeContents($("plain"));
|
r.selectNodeContents($("plain"));
|
||||||
getSelection().removeAllRanges();
|
getSelection().removeAllRanges();
|
||||||
getSelection().addRange(r);
|
getSelection().addRange(r);
|
||||||
$("copy").textContent = "Selected — press copy";
|
$("copy").textContent = "selected — press copy";
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@ -3,10 +3,13 @@
|
|||||||
package web
|
package web
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"crypto/rand"
|
||||||
"embed"
|
"embed"
|
||||||
|
"encoding/base64"
|
||||||
"fmt"
|
"fmt"
|
||||||
"html/template"
|
"html/template"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
"strconv"
|
||||||
)
|
)
|
||||||
|
|
||||||
//go:embed templates/*.html
|
//go:embed templates/*.html
|
||||||
@ -19,14 +22,23 @@ type Pages struct {
|
|||||||
reveal *template.Template
|
reveal *template.Template
|
||||||
}
|
}
|
||||||
|
|
||||||
// Data is everything a page needs. The limits are passed through so the browser
|
// Data is everything a page needs. MaxCiphertextBytes is passed through so the
|
||||||
// enforces the same caps the server does and a user learns their secret is too
|
// browser enforces the same cap the server does and a user learns their secret
|
||||||
// large before uploading it, not after.
|
// is too large before uploading it, not after. DefaultTTLSeconds is the only
|
||||||
|
// other value: the UI does not offer a TTL choice, so the page states the
|
||||||
|
// lifetime the server will apply rather than asking for one.
|
||||||
type Data struct {
|
type Data struct {
|
||||||
MaxCiphertextBytes int
|
MaxCiphertextBytes int
|
||||||
DefaultTTLSeconds int
|
DefaultTTLSeconds int
|
||||||
MinTTLSeconds int
|
}
|
||||||
MaxTTLSeconds int
|
|
||||||
|
// view is what the templates actually execute against: Data plus the two values
|
||||||
|
// that are derived per render. Nonce is NOT on Data on purpose — a caller that
|
||||||
|
// could set it could reuse one, and a reused nonce is the same as no nonce.
|
||||||
|
type view struct {
|
||||||
|
Data
|
||||||
|
Nonce string
|
||||||
|
DefaultTTL string
|
||||||
}
|
}
|
||||||
|
|
||||||
// New parses the embedded templates. It fails at boot rather than on first
|
// New parses the embedded templates. It fails at boot rather than on first
|
||||||
@ -60,14 +72,97 @@ func (p *Pages) Reveal(w http.ResponseWriter, d Data) error {
|
|||||||
return render(w, p.reveal, d)
|
return render(w, p.reveal, d)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// contentSecurityPolicy is the page policy, keyed to one per-response nonce.
|
||||||
|
//
|
||||||
|
// It is sent as a HEADER and the pages carry no CSP <meta>, which is not a
|
||||||
|
// style preference — both halves are load-bearing:
|
||||||
|
//
|
||||||
|
// - The chassis sets `default-src 'none'` for its JSON API surface. Two
|
||||||
|
// policies delivered on one response INTERSECT, so a <meta> loosening
|
||||||
|
// script-src cannot re-enable anything the header forbids; the browser
|
||||||
|
// blocked this page's own inline script and style, and its fetch to /api,
|
||||||
|
// while the <meta> looked permissive. Overriding the header here leaves
|
||||||
|
// exactly one policy on the response.
|
||||||
|
// - `frame-ancestors` is ignored entirely when delivered via <meta>, so the
|
||||||
|
// clickjacking half of the policy only exists as a header.
|
||||||
|
//
|
||||||
|
// A nonce rather than 'unsafe-inline': the whole guarantee is that no code
|
||||||
|
// except this reviewed, same-document script can reach the key in the
|
||||||
|
// fragment, and 'unsafe-inline' would extend that permission to any script an
|
||||||
|
// injection managed to place on the page.
|
||||||
|
func contentSecurityPolicy(nonce string) string {
|
||||||
|
return "default-src 'none'" +
|
||||||
|
"; script-src 'nonce-" + nonce + "'" +
|
||||||
|
"; style-src 'nonce-" + nonce + "'" +
|
||||||
|
// The pages fetch /api/secrets and /api/secrets/{id}/reveal. Same-origin
|
||||||
|
// only: there is no other host this page may ever talk to.
|
||||||
|
"; connect-src 'self'" +
|
||||||
|
// No image, font, media or frame is loaded by either page, so every
|
||||||
|
// remaining fetch directive stays at default-src 'none'.
|
||||||
|
"; form-action 'none'" +
|
||||||
|
"; base-uri 'none'" +
|
||||||
|
"; frame-ancestors 'none'"
|
||||||
|
}
|
||||||
|
|
||||||
func render(w http.ResponseWriter, t *template.Template, d Data) error {
|
func render(w http.ResponseWriter, t *template.Template, d Data) error {
|
||||||
|
nonce, err := newNonce()
|
||||||
|
if err != nil {
|
||||||
|
// No entropy means no nonce, and a page rendered without one is a page
|
||||||
|
// whose own script the browser will refuse. Fail loudly instead.
|
||||||
|
return fmt.Errorf("csp nonce: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
h := w.Header()
|
||||||
// no-store on both pages: a cached create page is harmless, but a cached
|
// no-store on both pages: a cached create page is harmless, but a cached
|
||||||
// reveal page in a shared proxy would be a copy of a one-time URL.
|
// reveal page in a shared proxy would be a copy of a one-time URL.
|
||||||
w.Header().Set("Cache-Control", "no-store, max-age=0")
|
h.Set("Cache-Control", "no-store, max-age=0")
|
||||||
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
h.Set("Content-Type", "text/html; charset=utf-8")
|
||||||
// Referrer-Policy is load-bearing here, not boilerplate: without it a click
|
// Referrer-Policy is load-bearing here, not boilerplate: without it a click
|
||||||
// on any link from the reveal page could send the full URL — including the
|
// on any link from the reveal page could send the full URL — including the
|
||||||
// fragment-adjacent path — to a third party.
|
// fragment-adjacent path — to a third party.
|
||||||
w.Header().Set("Referrer-Policy", "no-referrer")
|
h.Set("Referrer-Policy", "no-referrer")
|
||||||
return t.ExecuteTemplate(w, "base.html", d)
|
// Set, not Add: this REPLACES the chassis API policy for these two routes.
|
||||||
|
h.Set("Content-Security-Policy", contentSecurityPolicy(nonce))
|
||||||
|
|
||||||
|
return t.ExecuteTemplate(w, "base.html", view{
|
||||||
|
Data: d,
|
||||||
|
Nonce: nonce,
|
||||||
|
DefaultTTL: humanSeconds(d.DefaultTTLSeconds),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// newNonce returns 128 bits of base64 for one response. CSP's nonce grammar is
|
||||||
|
// base64, so the encoding is part of the contract rather than a convenience —
|
||||||
|
// and the URL alphabet specifically, because '+' and '/' are escaped to
|
||||||
|
// character references inside an HTML attribute, leaving the nonce the browser
|
||||||
|
// parses to depend on entity decoding rather than on these bytes.
|
||||||
|
func newNonce() (string, error) {
|
||||||
|
b := make([]byte, 16)
|
||||||
|
if _, err := rand.Read(b); err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
return base64.RawURLEncoding.EncodeToString(b), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// humanSeconds renders a TTL the way the page says it out loud. The server owns
|
||||||
|
// the number; this only decides whether to call it days, hours or minutes.
|
||||||
|
func humanSeconds(sec int) string {
|
||||||
|
switch {
|
||||||
|
case sec%86400 == 0 && sec >= 86400:
|
||||||
|
return plural(sec/86400, "day")
|
||||||
|
case sec%3600 == 0 && sec >= 3600:
|
||||||
|
return plural(sec/3600, "hour")
|
||||||
|
case sec >= 60:
|
||||||
|
return plural(sec/60, "minute")
|
||||||
|
default:
|
||||||
|
return plural(sec, "second")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func plural(n int, unit string) string {
|
||||||
|
s := strconv.Itoa(n) + " " + unit
|
||||||
|
if n != 1 {
|
||||||
|
s += "s"
|
||||||
|
}
|
||||||
|
return s
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,14 +1,15 @@
|
|||||||
#!/usr/bin/env bash
|
#!/usr/bin/env bash
|
||||||
# Build the current commit in-cluster and roll it out. No CI credential needed.
|
# Build the current commit in-cluster and roll it out. No CI credential needed.
|
||||||
#
|
#
|
||||||
# This exists because Woodpecker is not activated for this repo (its API token
|
# Woodpecker IS activated for this repo, so a push to main builds and deploys.
|
||||||
# in rdev returns 401, and minting a new one needs a browser login). Rather than
|
# This is the path for when you do not want to wait for CI, when CI is down, or
|
||||||
# leave "git push does not deploy" as a trap for whoever pushes next, this does
|
# when you are rolling back — and it is how the first deploy happened, before
|
||||||
# exactly what the pipeline's build+deploy steps do: a Kaniko Job for an amd64
|
# activation. It does exactly what the pipeline's build and deploy steps do: a
|
||||||
# image, then `kubectl set image`, then a real end-to-end check.
|
# Kaniko Job for an amd64 image from the pushed git ref, then
|
||||||
|
# `kubectl set image`, then a real end-to-end check.
|
||||||
#
|
#
|
||||||
# When Woodpecker is activated this becomes redundant, and that is fine — it is
|
# Credentials: none. The Gitea repo is public so the Kaniko git context needs no
|
||||||
# also the manual path for a rollback or a hotfix when CI is down.
|
# token, and the rollout uses your kubeconfig.
|
||||||
#
|
#
|
||||||
# ./scripts/release.sh
|
# ./scripts/release.sh
|
||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user