adminui: show the enrolment link as a QR code
Enrolling a device that cannot reach the admin UI meant transcribing a 200-character link with a base64 pin in it — the step the link format exists to avoid, and the one where a pin wrong by one character fails later as an inscrutable TLS error. Rendered as inline SVG rather than a PNG data: URI, because the page's CSP is default-src 'none' and means it: a data: image would need img-src opened, markup needs nothing. One path rather than a rect per module, since a link this long encodes to about 60x60 and two thousand elements is a lot of DOM for a picture of a square. It is generated from the same validated value as the href, so a rejected link produces neither. This relaxes the stdlib-only rule, deliberately and recorded in CLAUDE.md. The rule bought one self-contained binary with no supply chain to audit, which one small pure-Go package barely dents; F-Droid never applied to the server, only the app ships there. A correct QR encoder is ~500 lines of Reed-Solomon that nobody should be hand-writing. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5
parent
5b02d40802
commit
40e76c52ca
@@ -1,3 +1,5 @@
|
|||||||
module echo-lot.app/server
|
module echo-lot.app/server
|
||||||
|
|
||||||
go 1.24
|
go 1.24
|
||||||
|
|
||||||
|
require github.com/skip2/go-qrcode v0.0.0-20200617195104-da1b6568686e // indirect
|
||||||
|
|||||||
@@ -0,0 +1,2 @@
|
|||||||
|
github.com/skip2/go-qrcode v0.0.0-20200617195104-da1b6568686e h1:MRM5ITcdelLK2j1vwZ3Je0FKVCfqOLp5zO6trqMLYs0=
|
||||||
|
github.com/skip2/go-qrcode v0.0.0-20200617195104-da1b6568686e/go.mod h1:XV66xRDqSt+GTGFMVlhk3ULuV0y9ZmzeVGR4mloJI3M=
|
||||||
@@ -138,7 +138,8 @@ func (s *Server) devices(w http.ResponseWriter, r *http.Request, sess *adminauth
|
|||||||
}
|
}
|
||||||
s.render(w, r, "devices", map[string]any{
|
s.render(w, r, "devices", map[string]any{
|
||||||
"Session": sess, "CSRF": s.csrfToken(sess), "Rows": rows,
|
"Session": sess, "CSRF": s.csrfToken(sess), "Rows": rows,
|
||||||
"Link": link, "LinkHref": href, "Admin": sess.Admin,
|
// Rendered from the same validated value as the href, so a rejected link produces neither.
|
||||||
|
"Link": link, "LinkHref": href, "LinkQR": qrSVG(string(href)), "Admin": sess.Admin,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,58 @@
|
|||||||
|
// SPDX-FileCopyrightText: 2026 Echolot contributors
|
||||||
|
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||||
|
|
||||||
|
package adminui
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"html/template"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
qrcode "github.com/skip2/go-qrcode"
|
||||||
|
)
|
||||||
|
|
||||||
|
// qrSVG renders text as an inline SVG QR code, or empty if it will not encode.
|
||||||
|
//
|
||||||
|
// Inline SVG rather than a PNG data: URI because the page's CSP is `default-src 'none'` and means
|
||||||
|
// it. A data: image would need img-src opened up; markup needs nothing, and the QR is generated
|
||||||
|
// here from a boolean matrix, so nothing a user supplied reaches the output.
|
||||||
|
//
|
||||||
|
// Drawn as one path rather than a rect per module: a link of this length encodes to roughly 60x60
|
||||||
|
// modules, and two thousand elements is a lot of DOM for a picture of a square.
|
||||||
|
func qrSVG(text string) template.HTML {
|
||||||
|
if text == "" {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
// Medium recovery: a phone camera reading a screen has no dirt or creases to survive, and
|
||||||
|
// lower recovery keeps the module count down, which keeps it scannable on a small display.
|
||||||
|
q, err := qrcode.New(text, qrcode.Medium)
|
||||||
|
if err != nil {
|
||||||
|
return "" // too long to encode; the link text below it still works
|
||||||
|
}
|
||||||
|
bitmap := q.Bitmap()
|
||||||
|
n := len(bitmap)
|
||||||
|
if n == 0 {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
var path strings.Builder
|
||||||
|
for y, row := range bitmap {
|
||||||
|
for x, dark := range row {
|
||||||
|
if dark {
|
||||||
|
fmt.Fprintf(&path, "M%d %dh1v1h-1z", x, y)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// A quiet zone is part of the spec, not decoration: without it a scanner cannot find the
|
||||||
|
// symbol's edges against whatever is next to it on the page.
|
||||||
|
var out strings.Builder
|
||||||
|
fmt.Fprintf(&out,
|
||||||
|
`<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 %d %d" `+
|
||||||
|
`width="240" height="240" shape-rendering="crispEdges" role="img" `+
|
||||||
|
`aria-label="Enrolment link as a QR code">`+
|
||||||
|
`<rect width="%d" height="%d" fill="#fff"/>`+
|
||||||
|
`<path d="%s" fill="#000"/></svg>`,
|
||||||
|
n, n, n, n, path.String())
|
||||||
|
return template.HTML(out.String())
|
||||||
|
}
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
package adminui
|
||||||
|
|
||||||
|
import (
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestQrSVGEncodesAnEnrolmentLink(t *testing.T) {
|
||||||
|
link := "echolot://enroll?v=1&u=https%3A%2F%2Ffmr.echo-lot.app&p=pin-sha256%3AzRV9qkiLnRexAeh4RrSfJzbPWO%2BU%2F2Oj2%2FNVM%2FKfXlg%3D&t=20e6ccaa2a028dc0aab16442c258d1b8eadb5794682905fe"
|
||||||
|
out := string(qrSVG(link))
|
||||||
|
if !strings.HasPrefix(out, "<svg") || !strings.Contains(out, "<path d=\"M") {
|
||||||
|
t.Fatalf("expected an svg with a path, got %.80q", out)
|
||||||
|
}
|
||||||
|
// A quiet zone is part of the symbol; without it scanners cannot find its edges.
|
||||||
|
if !strings.Contains(out, `fill="#fff"`) {
|
||||||
|
t.Error("no light background rendered")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestQrSVGEmptyForNoLink(t *testing.T) {
|
||||||
|
if qrSVG("") != "" {
|
||||||
|
t.Error("no link should render no code")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -167,6 +167,10 @@ const baseHTML = `<!doctype html>
|
|||||||
.narrow{max-width:27rem}
|
.narrow{max-width:27rem}
|
||||||
.panel{background:var(--hull);border:1px solid var(--rule);border-radius:3px;
|
.panel{background:var(--hull);border:1px solid var(--rule);border-radius:3px;
|
||||||
padding:.95rem 1rem;margin:.9rem 0;min-width:0}
|
padding:.95rem 1rem;margin:.9rem 0;min-width:0}
|
||||||
|
/* White plate behind the code: a QR needs the light modules to actually be light, and this
|
||||||
|
page is dark. */
|
||||||
|
.qr{display:inline-block;background:#fff;padding:8px;border-radius:4px;margin:.2rem 0;line-height:0}
|
||||||
|
.qr svg{display:block;width:min(240px,60vw);height:auto}
|
||||||
.empty{border:1px dashed var(--rule);border-radius:3px;padding:1.4rem 1rem;
|
.empty{border:1px dashed var(--rule);border-radius:3px;padding:1.4rem 1rem;
|
||||||
color:var(--dim);font-size:.9rem}
|
color:var(--dim);font-size:.9rem}
|
||||||
code,pre,.mono{font-family:var(--mono);font-size:.82rem}
|
code,pre,.mono{font-family:var(--mono);font-size:.82rem}
|
||||||
@@ -328,8 +332,9 @@ const baseHTML = `<!doctype html>
|
|||||||
app, so following the link hands it the token directly. Copying a 200-character string
|
app, so following the link hands it the token directly. Copying a 200-character string
|
||||||
between two devices is the step that goes wrong, and it does not have to happen at all. -->
|
between two devices is the step that goes wrong, and it does not have to happen at all. -->
|
||||||
{{with $.LinkHref}}<p><a class="btn" href="{{.}}">Open in the Echolot app</a></p>{{end}}
|
{{with $.LinkHref}}<p><a class="btn" href="{{.}}">Open in the Echolot app</a></p>{{end}}
|
||||||
<p class="muted">Only works on the phone you are enrolling. From anywhere else, copy the link
|
<p class="muted">Works on the phone you are enrolling. From another device, scan this:</p>
|
||||||
into the app's enrolment field, or deliver it over adb.</p>
|
{{with $.LinkQR}}<div class="qr">{{.}}</div>{{end}}
|
||||||
|
<p class="muted">Or copy the link into the app's enrolment field, or deliver it over adb.</p>
|
||||||
<p><code>{{.}}</code></p>
|
<p><code>{{.}}</code></p>
|
||||||
<p class="muted mono">adb shell am start -a android.intent.action.VIEW -d "{{.}}"</p>
|
<p class="muted mono">adb shell am start -a android.intent.action.VIEW -d "{{.}}"</p>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
Reference in New Issue
Block a user