From ef1988e4d00490d4c64267f2686616c5ebc0f390 Mon Sep 17 00:00:00 2001 From: Daniel Redetzke Date: Sat, 3 Oct 2026 23:10:28 +0300 Subject: [PATCH] Add one-time setup links as an alternative to the QR code A config can now be handed over as a one-time link, valid for 1 h, 24 h or 7 days and protected by a PIN by default. Keys are made only when the link is opened; the link works once and is revoked after 5 wrong PINs. Issuing a new config offers the same choice, and the current config keeps working until the link is used. Remove the option to paste a client's public key, in the web UI, the API and the iOS app. --- README.md | 11 ++ api.go | 127 ++++++++---- app.css | 35 ++++ app.js | 165 ++++++++++++---- config.go | 9 +- ios/GHOSTWIRE/Models.swift | 9 + ios/GHOSTWIRE/PeerDetailView.swift | 30 ++- ios/GHOSTWIRE/PeerForms.swift | 17 +- ios/GHOSTWIRE/Theme.swift | 10 +- kernel_linux.go | 2 +- kernel_other.go | 2 +- main_test.go | 117 +++++++++++ setup.html | 18 ++ setup.js | 132 +++++++++++++ setuplink.go | 299 +++++++++++++++++++++++++++++ stats.go | 4 +- web.go | 17 +- 17 files changed, 885 insertions(+), 119 deletions(-) create mode 100644 setup.html create mode 100644 setup.js create mode 100644 setuplink.go diff --git a/README.md b/README.md index 0ce453d..ea6f30c 100644 --- a/README.md +++ b/README.md @@ -38,6 +38,10 @@ dependencies on the server: the binary installs, updates and removes itself. - **Client private keys are never stored.** A config is shown once, as a download or QR code. "Issue new config" makes new keys. +- **Setup links:** instead of showing the QR code, you can send the device's + owner a one-time link, valid for 1 hour, 24 hours or 7 days and protected by + a 4-digit PIN by default. The keys are made only when the link is opened. + The link works once, and 5 wrong PINs revoke it. - **The service is not root.** It runs as user `ghostwire` with only `CAP_NET_ADMIN` and `CAP_NET_BIND_SERVICE`, and can write only to `/opt/ghostwire`. @@ -164,11 +168,18 @@ GET /peers POST /peers (returns the config and QR once) GET /peers/{id} PATCH /peers/{id} DELETE /peers/{id} POST /peers/{id}/enable | /disable | /issue-config GET /peers/{id}/stats?range=… GET /peers/{id}/sessions?limit=100 +GET /peers/{id}/setup (not read-only) DELETE /peers/{id}/setup GET /settings PATCH /settings POST /restart GET /logs?level=&limit=&audit=1 GET /logs/download admin: GET|POST /tokens · DELETE /tokens/{id} · GET /backup · POST /restore +public: GET /setup/{token} · POST /setup/{token} {"pin"} (what a setup link opens) ``` +`POST /peers` and `POST /peers/{id}/issue-config` take +`{"delivery": "link", "linkHours": 1|24|168, "linkPIN": true}` to answer with a +setup link (`setup.url`, `setup.pin`, `setup.qr`) instead of a config. With a +link, the peer's current keys keep working until the link is opened. + Traffic is reported from the peer's point of view: `down` is what the peer downloaded, `up` is what it uploaded. diff --git a/api.go b/api.go index e5a3fad..eac19b4 100644 --- a/api.go +++ b/api.go @@ -12,8 +12,6 @@ import ( "slices" "strings" "time" - - "golang.zx2c4.com/wireguard/wgctrl/wgtypes" ) // App wires the parts together and serves the HTTP API. @@ -126,6 +124,13 @@ func (a *App) routes() http.Handler { g("POST /api/v1/peers/{id}/issue-config", a.issueConfig) g("GET /api/v1/peers/{id}/stats", a.peerStats) g("GET /api/v1/peers/{id}/sessions", a.peerSessions) + g("GET /api/v1/peers/{id}/setup", a.getSetup) + g("DELETE /api/v1/peers/{id}/setup", a.revokeSetup) + + // Setup links work without signing in: the token in the link is the + // credential. + mux.HandleFunc("GET /api/v1/setup/{token}", a.setupInfo) + mux.HandleFunc("POST /api/v1/setup/{token}", a.setupRedeem) // Full-access tokens (the iOS app) may change app settings and read logs. // Password, tokens and backups stay with the admin account. @@ -143,6 +148,7 @@ func (a *App) routes() http.Handler { mux.HandleFunc("/api/", func(w http.ResponseWriter, r *http.Request) { writeJSON(w, http.StatusNotFound, map[string]string{"error": "no such endpoint"}) }) + mux.HandleFunc("GET /setup/{token}", setupPage) mux.Handle("/", webHandler()) csrf := http.NewCrossOriginProtection() @@ -505,6 +511,7 @@ type peerView struct { EffKeepalive int `json:"effectiveKeepalive"` Created time.Time `json:"created"` ConfigIssued *time.Time `json:"configIssued"` + Setup *setupView `json:"setup"` // null = no pending setup link Stats PeerSummary `json:"stats"` } @@ -513,7 +520,7 @@ func (a *App) peerView(c *Config, p *Peer) peerView { ID: p.ID, Name: p.Name, Note: p.Note, Enabled: p.Enabled, PublicKey: p.PublicKey, HasPSK: p.PresharedKey != "", IPv4: p.IPv4, DNS: p.DNS, AllowedIPs: p.AllowedIPs, Keepalive: p.Keepalive, EffDNS: peerDNS(c, p), EffAllowed: peerAllowedIPs(c, p), EffKeepalive: peerKeepalive(c, p), - Created: p.Created, ConfigIssued: p.ConfigIssued, Stats: a.stats.Summary(p.ID), + Created: p.Created, ConfigIssued: p.ConfigIssued, Setup: viewSetup(p.Setup), Stats: a.stats.Summary(p.ID), } if c.Server.IPv6Enabled { v.IPv6 = mapIPv6(netip.MustParsePrefix(c.Server.IPv6), netip.MustParseAddr(p.IPv4)).String() @@ -545,21 +552,19 @@ func (a *App) getPeer(w http.ResponseWriter, r *http.Request) { type issuedConfig struct { Peer peerView `json:"peer"` Config string `json:"config"` - QR string `json:"qr,omitempty"` - HasPrivKey bool `json:"includesPrivateKey"` + QR string `json:"qr"` + HasPrivKey bool `json:"includesPrivateKey"` // always true; kept for older clients ApplyError string `json:"applyError"` } -// newKeys returns a fresh key pair, or only the given public key when the -// client made its own keys. -func newKeys(clientPublic string) (priv, pub string, err error) { - if clientPublic != "" { - k, err := wgtypes.ParseKey(strings.TrimSpace(clientPublic)) - if err != nil { - return "", "", badRequest("public key is not a valid WireGuard key") - } - return "", k.String(), nil - } +// linkCreated answers a create or issue request that asked for a setup link. +type linkCreated struct { + Peer peerView `json:"peer"` + Setup setupSecret `json:"setup"` + ApplyError string `json:"applyError"` +} + +func newKeys() (priv, pub string, err error) { k, err := newPrivateKey() if err != nil { return "", "", err @@ -570,17 +575,24 @@ func newKeys(clientPublic string) (priv, pub string, err error) { func (a *App) issue(id, priv string) (issuedConfig, error) { cfg := a.store.Get() _, p := cfg.peerByID(id) - out := issuedConfig{Peer: a.peerView(cfg, p), Config: clientConfig(cfg, p, priv), HasPrivKey: priv != ""} - if priv != "" { - qr, err := qrDataURL(out.Config) - if err != nil { - return out, err - } - out.QR = qr + out := issuedConfig{Peer: a.peerView(cfg, p), Config: clientConfig(cfg, p, priv), HasPrivKey: true} + qr, err := qrDataURL(out.Config) + if err != nil { + return out, err } + out.QR = qr return out, nil } +func (a *App) linkCreated(r *http.Request, id string) (linkCreated, error) { + cfg := a.store.Get() + _, p := cfg.peerByID(id) + out := linkCreated{Peer: a.peerView(cfg, p)} + sec, err := secretFor(r, p.Setup) + out.Setup = sec + return out, err +} + func (a *App) createPeer(w http.ResponseWriter, r *http.Request) { var in struct { Name string `json:"name"` @@ -589,23 +601,36 @@ func (a *App) createPeer(w http.ResponseWriter, r *http.Request) { DNS []string `json:"dns"` AllowedIPs []string `json:"allowedIPs"` Keepalive *int `json:"keepalive"` - PublicKey string `json:"publicKey"` PresharedKey *bool `json:"presharedKey"` + setupRequest } if err := readJSON(r, &in); err != nil { writeErr(w, err) return } in.Name = strings.TrimSpace(in.Name) - priv, pub, err := newKeys(in.PublicKey) + link, err := in.newLink() if err != nil { writeErr(w, err) return } p := Peer{ - ID: newID(), Name: in.Name, Note: strings.TrimSpace(in.Note), Enabled: true, PublicKey: pub, + ID: newID(), Name: in.Name, Note: strings.TrimSpace(in.Note), Enabled: true, DNS: in.DNS, AllowedIPs: in.AllowedIPs, Keepalive: in.Keepalive, Created: time.Now().UTC(), } + // With a link, the keys are made when the link is opened. + var priv string + if in.wantsLink() { + p.Setup = link + } else { + var pub string + if priv, pub, err = newKeys(); err != nil { + writeErr(w, err) + return + } + now := time.Now().UTC() + p.PublicKey, p.ConfigIssued = pub, &now + } if in.PresharedKey == nil || *in.PresharedKey { psk, err := newPresharedKey() if err != nil { @@ -614,8 +639,6 @@ func (a *App) createPeer(w http.ResponseWriter, r *http.Request) { } p.PresharedKey = psk.String() } - now := time.Now().UTC() - p.ConfigIssued = &now err = a.store.Update(func(c *Config) error { if err := validatePeerName(p.Name); err != nil { return &userError{err.Error()} @@ -636,7 +659,16 @@ func (a *App) createPeer(w http.ResponseWriter, r *http.Request) { writeErr(w, err) return } - a.audit(r, "peer created", "peer", p.Name, "ip", p.IPv4) + a.audit(r, "peer created", "peer", p.Name, "ip", p.IPv4, "delivery", map[bool]string{true: "link", false: "show"}[in.wantsLink()]) + if in.wantsLink() { + out, err := a.linkCreated(r, p.ID) + if err != nil { + writeErr(w, err) + return + } + writeJSON(w, http.StatusCreated, out) + return + } out, err := a.issue(p.ID, priv) if err != nil { writeErr(w, err) @@ -751,19 +783,45 @@ func (a *App) deletePeer(w http.ResponseWriter, r *http.Request) { writeJSON(w, http.StatusOK, map[string]any{"ok": true, "applyError": a.apply()}) } -// issueConfig replaces the peer's keys. The old device stops working. +// issueConfig replaces the peer's keys. The old device stops working. With +// a setup link, the keys are replaced only when the link is opened. func (a *App) issueConfig(w http.ResponseWriter, r *http.Request) { id := r.PathValue("id") - var in struct { - PublicKey string `json:"publicKey"` - } + var in setupRequest if r.ContentLength > 0 { if err := readJSON(r, &in); err != nil { writeErr(w, err) return } } - priv, pub, err := newKeys(in.PublicKey) + link, err := in.newLink() + if err != nil { + writeErr(w, err) + return + } + if in.wantsLink() { + var name string + if err := a.store.Update(func(c *Config) error { + _, p := c.peerByID(id) + if p == nil { + return badRequest("no such peer") + } + p.Setup, name = link, p.Name + return nil + }); err != nil { + writeErr(w, err) + return + } + a.audit(r, "setup link created", "peer", name, "expires", link.Expires) + out, err := a.linkCreated(r, id) + if err != nil { + writeErr(w, err) + return + } + writeJSON(w, http.StatusOK, out) + return + } + priv, pub, err := newKeys() if err != nil { writeErr(w, err) return @@ -780,7 +838,8 @@ func (a *App) issueConfig(w http.ResponseWriter, r *http.Request) { return badRequest("no such peer") } now := time.Now().UTC() - p.PublicKey, p.ConfigIssued, name = pub, &now, p.Name + // A config issued here replaces any pending link. + p.PublicKey, p.ConfigIssued, p.Setup, name = pub, &now, nil, p.Name if p.PresharedKey != "" { p.PresharedKey = psk.String() } diff --git a/app.css b/app.css index 3df3a2b..83ae921 100644 --- a/app.css +++ b/app.css @@ -100,6 +100,7 @@ h1 { margin: 0; font-size: 26px; font-weight: 600; letter-spacing: -0.01em; over .dot.ok { background: var(--good); } .dot.off { background: transparent; border: 1.5px solid #9a9b97; } .dot.bad { background: var(--bad); } +.dot.warn { background: var(--up); } .dot.big { width: 10px; height: 10px; } .tag { display: inline-block; font-size: 11px; font-weight: 600; padding: 2px 8px; border-radius: 999px; background: var(--warn-bg); color: var(--warn-ink); margin-left: 6px; vertical-align: 1px; } .notice { display: flex; gap: 10px; align-items: flex-start; padding: 12px 14px; border-radius: 10px; background: var(--warn-bg); color: var(--warn-ink); font-size: 13px; } @@ -200,6 +201,11 @@ dialog::backdrop { background: rgba(22, 23, 26, .55); } .dlg .foot { display: flex; justify-content: flex-end; flex-wrap: wrap; gap: 8px; } .qrrow { display: flex; flex-wrap: wrap; gap: 16px; align-items: center; } .qrrow .col { display: flex; flex-direction: column; gap: 8px; } +.qr.small { width: 168px; height: 168px; } +.kv.grow { flex: 1 1 240px; margin: 0; } +.pinrow { display: flex; align-items: center; gap: 12px; } +.pinval { font-family: var(--mono); font-size: 22px; font-weight: 600; letter-spacing: 0.3em; } +.linkopts { display: grid; grid-template-columns: repeat(auto-fit, minmax(200px, 1fr)); gap: 12px 20px; align-items: end; padding: 4px 0 4px 40px; } /* toast */ .toasts { position: fixed; right: 16px; bottom: 16px; display: flex; flex-direction: column; gap: 8px; z-index: 50; max-width: calc(100vw - 32px); } @@ -222,4 +228,33 @@ dialog::backdrop { background: rgba(22, 23, 26, .55); } .loginform .err-text:empty { display: none; } .loginfoot { margin: 0; font-family: var(--mono); font-size: 11px; letter-spacing: 0.08em; text-transform: uppercase; color: #8d8e93; } .err-text { color: var(--bad-ink); font-size: 13px; margin: 0; } +/* setup link page (setup.html): same dark look as the login page */ +.setuppage { min-height: 100vh; display: flex; justify-content: center; padding: 48px 16px; background: var(--ink); color: #f4f4f1; font-size: 15px; } +.setuppage .loading-page { color: #8d8e93; } +.setupbox { width: 100%; max-width: 400px; display: flex; flex-direction: column; gap: 24px; margin: auto 0; } +.setupbox h1 { font-size: 22px; } +.setupbox p { margin: 0; color: #c9c9c3; } +.setupbox a { color: #9cc3f5; } +.setupbox a:hover { color: #fff; } +.setupbox .center, .setupbox.center { text-align: center; display: flex; flex-direction: column; align-items: center; gap: 8px; } +.setupbox.center { gap: 20px; } +.setupbox .ghost { opacity: .45; } +.setupbox p.note { text-align: center; font-size: 12px; color: #8d8e93; } +.setupbox .brand { padding: 0; } +.setupbox .brand.stack { flex-direction: column; gap: 14px; } +.setupbox .brand .wm { font-size: 16px; letter-spacing: 0.12em; } +.setupbox .brand.stack .wm { font-size: 18px; letter-spacing: 0.18em; margin-right: -0.18em; } +.setupbox strong.mono { color: #fff; font-weight: 500; } +.loginform input.pin { min-height: 56px; font-family: var(--mono); font-size: 24px; letter-spacing: 0.5em; text-align: center; } +.setupbox .notice { background: #3a2a14; color: #f6d3a6; } +.steps { margin: 0; padding: 0; list-style: none; display: flex; flex-direction: column; gap: 20px; } +.steps li { display: flex; gap: 14px; } +.steps li > div { flex: 1; min-width: 0; display: flex; flex-direction: column; gap: 10px; } +.steps .num { flex: none; width: 28px; height: 28px; border-radius: 50%; border: 1px solid #3a3b41; display: grid; place-items: center; font-family: var(--mono); font-size: 13px; } +.steps strong { font-weight: 500; } +.steps p { font-size: 13px; } +.steps .btn { min-height: 48px; width: 100%; } +.steps .btn:not(.primary) { background: #222328; border-color: #3a3b41; color: #fff; } +.steps .btn.primary { background: #f4f4f1; border-color: #f4f4f1; color: var(--ink); font-weight: 600; } +.steps .qr { width: 100%; height: auto; max-width: 280px; align-self: center; } .loading-page { padding: 40px; color: var(--ink-3); } diff --git a/app.js b/app.js index 045f078..8bd8df0 100644 --- a/app.js +++ b/app.js @@ -109,6 +109,10 @@ function peerState(p) { if (!p.enabled) return { key: 'disabled', label: 'Disabled', dot: 'dot bad' }; + if (!p.publicKey) { + if (p.setup && !p.setup.expired) return { key: 'setup', label: 'Waiting for setup', dot: 'dot warn' }; + return { key: 'nokey', label: p.setup ? 'Setup link expired' : 'No config yet', dot: 'dot off' }; + } if (p.stats.online) return { key: 'online', label: 'Online · ' + ago(p.stats.lastHandshake), dot: 'dot ok' }; if (p.stats.lastHandshake) return { key: 'offline', label: 'Offline · ' + ago(p.stats.lastHandshake), dot: 'dot' }; return { key: 'never', label: 'Never connected', dot: 'dot off' }; @@ -232,6 +236,52 @@ d.addEventListener('close', () => { applied(res); if (onClose) onClose(); }); } + // linkDialog shows a setup link with its PIN, to send to the device's owner. + function linkDialog(name, setup, onClose) { + const share = navigator.share + ? h('button', { type: 'button', class: 'btn', onClick: () => navigator.share({ title: 'VPN setup for ' + name, url: setup.url }).catch(() => {}) }, 'Share…') + : null; + const d = dialog((close) => h('div', { class: 'dlg' }, + h('h2', null, 'Setup link for ' + name), + h('div', { class: 'notice' }, setup.pin + ? 'Anyone with this link and the PIN can set up this peer once. Send the PIN separately, e.g. by phone or another messenger.' + : 'Anyone with this link can set up this peer once. Send it only to the device\'s owner.'), + h('div', { class: 'field' }, h('label', { htmlFor: 'sl' }, 'Link'), + h('div', { class: 'row' }, h('input', { id: 'sl', class: 'mono', value: setup.url, readOnly: true, onFocus: (e) => e.target.select() }), + h('button', { type: 'button', class: 'btn primary', onClick: () => copy(setup.url) }, 'Copy link'), share)), + h('div', { class: 'qrrow' }, + h('img', { class: 'qr small', src: setup.qr, alt: 'QR code of the setup link for ' + name }), + h('dl', { class: 'kv grow' }, + setup.pin ? [h('dt', null, 'PIN'), h('dd', { class: 'pinrow' }, h('span', { class: 'pinval' }, setup.pin), h('button', { type: 'button', class: 'btn small', onClick: () => copy(setup.pin) }, 'Copy'))] : null, + h('dt', null, 'Valid until'), h('dd', null, fmtStamp(setup.expires)), + h('dt', null, 'Uses'), h('dd', null, 'Once. Then the link stops working.'))), + h('p', { class: 'hint' }, 'The QR code holds only the link, not the config. Until the link is used, you can copy it again or revoke it on the peer\'s page.'), + h('div', { class: 'foot' }, h('button', { type: 'button', class: 'btn primary', onClick: close }, 'Done')))); + d.addEventListener('close', () => { if (onClose) onClose(); }); + } + + // handover is the "Show it here" / "Send a setup link" choice used when a + // config is created or issued again. + function handover({ showHint, linkHint, onChange }) { + let mode = 'show'; + const hours = h('select', { id: 'lh' }, [[1, '1 hour'], [24, '24 hours'], [168, '7 days']].map(([v, t]) => h('option', { value: String(v), selected: v === 24 }, t))); + const pin = h('input', { type: 'checkbox', checked: true }); + const more = h('div', { class: 'linkopts', hidden: true }, + h('div', { class: 'field' }, h('label', { htmlFor: 'lh' }, 'Link valid for'), hours), + h('label', { class: 'check' }, pin, h('span', null, 'Require a PIN', h('br'), h('span', { class: 'hint' }, 'Send it by another channel than the link')))); + const opt = (v, title, hint) => h('label', { class: 'opt' }, + h('input', { type: 'radio', name: 'handover', value: v, checked: v === mode, onChange: () => { mode = v; more.hidden = v !== 'link'; if (onChange) onChange(); } }), + h('span', null, h('strong', null, title), h('br'), h('span', { class: 'hint' }, hint))); + return { + el: h('fieldset', null, h('legend', { class: 'legend' }, 'Hand over the config'), + opt('show', 'Show it here', showHint), + opt('link', 'Send a setup link', linkHint), + more), + link: () => mode === 'link', + body: () => mode === 'link' ? { delivery: 'link', linkHours: Number(hours.value), linkPIN: pin.checked } : {}, + }; + } + // ---------- chart ---------- function niceTop(max) { @@ -512,7 +562,7 @@ const rows = data.peers.filter((p) => { const st = peerState(p).key; const hit = !q || (p.name + ' ' + p.ipv4 + ' ' + p.note).toLowerCase().includes(q); - const keep = filter === 'all' || filter === st || (filter === 'offline' && (st === 'offline' || st === 'never')); + const keep = filter === 'all' || filter === st || (filter === 'offline' && ['offline', 'never', 'setup', 'nokey'].includes(st)); return hit && keep; }); tbody.replaceChildren(...rows.map((p) => h('tr', null, @@ -597,7 +647,6 @@ const name = h('input', { id: 'n', autocomplete: 'off', required: true }); const note = h('input', { id: 'no' }); const ip = h('input', { id: 'ip', class: 'mono', placeholder: 'Next free address' }); - const pub = h('textarea', { id: 'pk', class: 'mono', rows: 2, placeholder: 'Base64 public key from the device', hidden: true, 'aria-label': 'Client public key' }); const psk = h('input', { type: 'checkbox', checked: true }); const preview = h('pre', { class: 'code' }); const ch = overrideChoices(srv, null); @@ -605,19 +654,30 @@ const dns = choice({ id: 'dns', label: 'DNS', ...ch.dns, placeholder: '9.9.9.9, 149.112.112.112', onChange: update }); const allowed = choice({ id: 'ai', label: 'Route through the VPN (AllowedIPs)', ...ch.allowed, placeholder: '10.0.0.0/24, 192.168.1.0/24', hint: 'Used in the client config', onChange: update }); const ka = choice({ id: 'ka', label: 'Persistent keepalive', ...ch.ka, placeholder: 'Seconds', hint: 'Keeps the tunnel open behind NAT', onChange: update }); - let keyMode = 'generate'; + const ho = handover({ + showHint: 'QR code and download right after you click Create. Best when the device is next to you.', + linkHint: 'A one-time link you send to the device\'s owner. Keys are made when the link is opened and never stored.', + onChange: update, + }); + const qrBox = h('div', { class: 'ph' }); function drawPreview() { + const link = ho.link(); + submit.textContent = link ? 'Create peer and link' : 'Create peer'; + qrBox.replaceChildren(link ? 'Setup link' : 'QR code', h('br'), 'after creation'); + aside.textContent = link + ? 'With a setup link, keys are created when the recipient opens it. Until then the peer is inactive.' + : 'Keys are created when you click Create peer. Then the config can be downloaded or scanned once.'; let o; try { o = overrides(dns, allowed, ka, srv); } catch { o = {}; } const d = srv.clientDefaults; const lines = ['[Interface]', - 'PrivateKey = ' + (keyMode === 'generate' ? '‹generated on create›' : '‹stays on the device›'), + 'PrivateKey = ' + (link ? '‹made when the link is opened›' : '‹generated on create›'), 'Address = ' + (ip.value || '‹next free›') + '/' + srv.ipv4.split('/')[1] + (srv.ipv6Enabled ? ',‹mapped IPv6›' : '')]; const dnsList = o.dns === undefined || o.dns === null ? d.dns : o.dns; if (dnsList.length) lines.push('DNS = ' + dnsList.join(', ')); lines.push('', '[Peer]', 'PublicKey = ' + srv.publicKey); - if (psk.checked) lines.push('PresharedKey = ‹generated on create›'); + if (psk.checked) lines.push('PresharedKey = ' + (link ? '‹made when the link is opened›' : '‹generated on create›')); lines.push('Endpoint = ' + (srv.endpoint || '‹set the endpoint in Server›') + ':' + (srv.endpointPort || srv.listenPort)); lines.push('AllowedIPs = ' + ((o.allowedIPs == null ? d.allowedIPs : o.allowedIPs).join(', '))); const k = o.keepalive == null ? d.keepalive : o.keepalive; @@ -625,23 +685,21 @@ preview.textContent = lines.join('\n'); } - const keyOpt = (v, title, hint) => h('label', { class: 'opt' }, - h('input', { type: 'radio', name: 'keys', value: v, checked: v === keyMode, onChange: () => { keyMode = v; pub.hidden = v !== 'paste'; drawPreview(); } }), - h('span', null, h('strong', null, title), h('br'), h('span', { class: 'hint' }, hint))); - + const aside = h('p', { class: 'lead' }); const submit = h('button', { type: 'submit', class: 'btn primary' }, 'Create peer'); const form = h('form', { class: 'card grow', onSubmit: async (e) => { e.preventDefault(); err.textContent = ''; let body; try { - body = { name: name.value.trim(), note: note.value.trim(), ipv4: ip.value.trim(), presharedKey: psk.checked, ...overrides(dns, allowed, ka, srv) }; + body = { name: name.value.trim(), note: note.value.trim(), ipv4: ip.value.trim(), presharedKey: psk.checked, ...overrides(dns, allowed, ka, srv), ...ho.body() }; } catch (x) { err.textContent = x.message; return; } - if (keyMode === 'paste') body.publicKey = pub.value.trim(); submit.disabled = true; try { const res = await api('POST', '/peers', body); - configDialog(res, () => { location.hash = '#/peers/' + res.peer.id; }); + const done = () => { location.hash = '#/peers/' + res.peer.id; }; + if (res.setup) linkDialog(res.peer.name, res.setup, done); + else configDialog(res, done); } catch (x) { err.textContent = x.message; submit.disabled = false; @@ -653,12 +711,8 @@ h('div', { class: 'field' }, h('label', { htmlFor: 'ip' }, 'IPv4 address'), ip, h('span', { class: 'hint' }, 'Leave empty for the next free address in ' + srv.ipv4)), srv.ipv6Enabled ? h('div', { class: 'field' }, h('label', null, 'IPv6 address'), h('input', { class: 'mono', readOnly: true, value: 'Derived from the IPv4 address' })) : null), h('div', { class: 'grid section' }, allowed.el, dns.el, ka.el), - h('fieldset', { class: 'section' }, - h('legend', { class: 'legend' }, 'Keys'), - keyOpt('generate', 'Generate here', 'The private key appears once in the config and QR code. It isn\'t stored.'), - keyOpt('paste', 'Paste the client\'s public key', 'For clients that make their own keys'), - pub, - h('label', { class: 'check' }, psk, 'Add a preshared key')), + h('div', { class: 'section' }, h('label', { class: 'check' }, psk, 'Add a preshared key')), + h('div', { class: 'section' }, ho.el), err, h('div', { class: 'formfoot' }, h('a', { class: 'btn', href: '#/peers' }, 'Cancel'), submit)); for (const el of [ip, psk]) el.addEventListener('input', drawPreview); @@ -667,13 +721,13 @@ fill(wrap, h('a', { class: 'back', href: '#/peers' }, '← Peers'), - h('div', null, h('h1', null, 'Add peer'), h('p', { class: 'sub' }, 'Creates a key pair, assigns the next free address and adds the peer to ' + srv.interface + ' without a restart.')), + h('div', null, h('h1', null, 'Add peer'), h('p', { class: 'sub' }, 'Assigns the next free address and adds the peer to ' + srv.interface + ' without a restart.')), h('div', { class: 'split' }, form, h('aside', { class: 'card aside', 'aria-labelledby': 'pv' }, h('h2', { id: 'pv' }, 'Client config preview'), - h('p', { class: 'lead' }, 'Keys are created when you click Create peer. Then the config can be downloaded or scanned once.'), + aside, preview, - h('div', { class: 'qrrow section' }, h('div', { class: 'ph' }, 'QR code', h('br'), 'after creation'))))); + h('div', { class: 'qrrow section' }, qrBox)))); name.focus(); } @@ -714,25 +768,53 @@ location.hash = '#/peers'; } catch (e) { toast(e.message, true); } }; - const reissue = async (publicKey) => { - if (!publicKey && !await confirmDialog({ title: 'Issue a new config?', text: 'New keys are created. The device that uses the current config stops working until it gets the new one.', ok: 'Issue new config' })) return; - try { - const res = await api('POST', '/peers/' + id + '/issue-config', publicKey ? { publicKey } : undefined); - configDialog(res, render); - } catch (e) { toast(e.message, true); } - }; - const pasteKey = () => { - const inp = h('textarea', { class: 'mono', rows: 2, 'aria-label': 'Public key' }); - const e = h('p', { class: 'err-text' }); + const reissue = () => { + const ho = handover({ + showHint: 'New keys now; QR code and download on this screen', + linkHint: p.publicKey ? 'The current config keeps working until the link is opened' : 'A one-time link you send to the device\'s owner', + }); + const e = h('p', { class: 'err-text', role: 'alert' }); dialog((close) => h('form', { class: 'dlg', onSubmit: async (ev) => { ev.preventDefault(); - close(); - await reissue(inp.value.trim()); + e.textContent = ''; + try { + const res = await api('POST', '/peers/' + id + '/issue-config', ho.body()); + close(); + if (res.setup) linkDialog(p.name, res.setup, render); + else configDialog(res, render); + } catch (x) { e.textContent = x.message; } } }, - h('h2', null, 'Use a key from the device'), - h('p', null, 'Paste the public key the device generated. The current config stops working.'), - inp, e, - h('div', { class: 'foot' }, h('button', { type: 'button', class: 'btn', onClick: close }, 'Cancel'), h('button', { type: 'submit', class: 'btn primary' }, 'Replace key')))); + h('h2', null, (p.publicKey ? 'Issue a new config for ' : 'Issue a config for ') + p.name + '?'), + p.publicKey ? h('p', null, 'New keys are created. The device that uses the current config stops working once it is replaced.') : null, + p.setup ? h('p', null, 'This replaces the current setup link.') : null, + ho.el, e, + h('div', { class: 'foot' }, h('button', { type: 'button', class: 'btn', onClick: close }, 'Cancel'), h('button', { type: 'submit', class: 'btn primary' }, 'Continue')))); + }; + const showLink = async () => { + try { linkDialog(p.name, await api('GET', '/peers/' + id + '/setup')); } catch (e) { toast(e.message, true); } + }; + const copyLink = async () => { + try { copy((await api('GET', '/peers/' + id + '/setup')).url); } catch (e) { toast(e.message, true); } + }; + const revoke = async () => { + if (!await confirmDialog({ title: 'Revoke the setup link?', text: 'The link stops working immediately.', ok: 'Revoke link', danger: true })) return; + try { await api('DELETE', '/peers/' + id + '/setup'); toast('Setup link revoked'); render(); } catch (e) { toast(e.message, true); } + }; + const setupCard = () => { + const su = p.setup; + if (!su) return null; + return h('section', { class: 'card', 'aria-labelledby': 'sl' }, + h('div', { class: 'cardhead' }, h('h2', { id: 'sl' }, 'Setup link'), h('span', { class: 'hint' }, su.expired ? 'Expired' : 'Not opened yet')), + h('dl', { class: 'kv' }, + h('dt', null, su.expired ? 'Expired' : 'Expires'), h('dd', null, fmtStamp(su.expires)), + h('dt', null, 'PIN'), h('dd', null, su.pinRequired ? 'Required · ' + su.pinFails + ' of 5 wrong tries' : 'Not required'), + p.publicKey ? [h('dt', null, 'Current config'), h('dd', null, 'Keeps working until the link is opened')] : null), + h('div', { class: 'actions section' }, su.expired + ? [h('button', { type: 'button', class: 'btn primary', onClick: reissue }, 'New link…'), + h('button', { type: 'button', class: 'btn', onClick: revoke }, 'Remove')] + : [h('button', { type: 'button', class: 'btn primary', onClick: copyLink }, 'Copy link'), + h('button', { type: 'button', class: 'btn', onClick: showLink }, su.pinRequired ? 'Show link & PIN' : 'Show link'), + h('button', { type: 'button', class: 'btn danger', onClick: revoke }, 'Revoke')])); }; // settings form @@ -765,6 +847,8 @@ h('button', { type: 'button', class: 'btn', onClick: toggle }, p.enabled ? 'Disable' : 'Enable'), h('button', { type: 'button', class: 'btn danger', onClick: del }, 'Delete'))), + setupCard(), + h('section', { class: 'card', 'aria-labelledby': 'traffic' }, h('div', { class: 'cardhead' }, h('div', null, h('h2', { id: 'traffic' }, 'Traffic'), totals), pills), traffic), @@ -777,16 +861,15 @@ h('dt', null, 'Endpoint'), h('dd', { class: 'mono' }, p.stats.endpoint || '–'), h('dt', null, 'Location'), h('dd', null, fmtLocation(p.stats.location) || '–'), h('dt', null, 'Latest handshake'), h('dd', null, ago(p.stats.lastHandshake)), - h('dt', null, 'Public key'), h('dd', { class: 'mono' }, p.publicKey), + h('dt', null, 'Public key'), h('dd', { class: 'mono' }, p.publicKey || '–'), h('dt', null, 'Preshared key'), h('dd', null, p.hasPresharedKey ? 'Set' : 'None'), h('dt', null, 'All-time traffic'), h('dd', null, 'Download ' + fmtBytes(p.stats.downTotal) + ' · Upload ' + fmtBytes(p.stats.upTotal)))), h('section', { class: 'card' }, h('h2', null, 'Client configuration'), h('p', { class: 'lead' }, 'This server doesn\'t keep the peer\'s private key. To set up a device again, issue a new config. The old one stops working.'), h('div', { class: 'actions' }, - h('button', { type: 'button', class: 'btn', onClick: () => reissue() }, 'Issue new config & QR'), - h('button', { type: 'button', class: 'btn', onClick: pasteKey }, 'Use a key from the device…')), - h('p', { class: 'hint', style: { margin: '12px 0 0' } }, p.configIssued ? 'Last issued ' + fmtDate(p.configIssued) + '.' : 'Created with a key from the device.'))), + h('button', { type: 'button', class: 'btn', onClick: reissue }, p.publicKey ? 'Issue new config…' : 'Issue config…')), + h('p', { class: 'hint', style: { margin: '12px 0 0' } }, p.configIssued ? 'Last issued ' + fmtDate(p.configIssued) + '.' : 'No config issued yet.'))), h('section', { class: 'card flush', 'aria-labelledby': 'hist' }, h('div', { class: 'cardhead' }, h('h2', { id: 'hist' }, 'Connection history'), diff --git a/config.go b/config.go index 1d8a44a..99ef24e 100644 --- a/config.go +++ b/config.go @@ -118,8 +118,15 @@ type Peer struct { Keepalive *int `json:"keepalive,omitempty"` // nil = server default Created time.Time `json:"created"` ConfigIssued *time.Time `json:"configIssued,omitempty"` + // Setup is a pending one-time setup link. A peer created with a link has + // no public key until the link is opened. + Setup *SetupLink `json:"setup,omitempty"` } +// hasKey reports whether the peer has a public key, i.e. it can be in the +// kernel. A peer waiting for its setup link has none yet. +func (p *Peer) hasKey() bool { return p.PublicKey != "" } + type LogConfig struct { Level string `json:"level"` // debug | info | warn | error MaxSizeMB int `json:"maxSizeMB"` @@ -346,7 +353,7 @@ func (c *Config) validate() error { return fmt.Errorf("address %s is used twice", ip) } ips[ip] = true - if keys[p.PublicKey] { + if p.hasKey() && keys[p.PublicKey] { return fmt.Errorf("peer %q: public key is used by another peer", p.Name) } keys[p.PublicKey] = true diff --git a/ios/GHOSTWIRE/Models.swift b/ios/GHOSTWIRE/Models.swift index ea00692..272cd8b 100644 --- a/ios/GHOSTWIRE/Models.swift +++ b/ios/GHOSTWIRE/Models.swift @@ -112,9 +112,18 @@ nonisolated struct Peer: Decodable, Identifiable, Hashable { let effectiveKeepalive: Int let created: Date let configIssued: Date? + let setup: SetupInfo? // pending setup link, nil if none let stats: PeerStats } +/// A pending setup link as peer lists show it. The link itself is not in it. +nonisolated struct SetupInfo: Decodable, Hashable { + let expires: Date + let expired: Bool + let pinRequired: Bool + let pinFails: Int +} + nonisolated struct PeerList: Decodable { let peers: [Peer] let capacity: Int diff --git a/ios/GHOSTWIRE/PeerDetailView.swift b/ios/GHOSTWIRE/PeerDetailView.swift index 0f527f2..01bc1b5 100644 --- a/ios/GHOSTWIRE/PeerDetailView.swift +++ b/ios/GHOSTWIRE/PeerDetailView.swift @@ -12,8 +12,6 @@ struct PeerDetailView: View { @State private var issued: IssuedConfig? @State private var confirmIssue = false @State private var confirmDelete = false - @State private var askKey = false - @State private var deviceKey = "" @State private var editing = false @State private var sessions: [ConnSession] = [] @State private var allSessions = false @@ -64,20 +62,10 @@ struct PeerDetailView: View { Text("The device loses access immediately. Its traffic history is deleted too. This cannot be undone.") } .confirmationDialog("Issue a new config?", isPresented: $confirmIssue, titleVisibility: .visible) { - Button("Issue new config") { Task { await issue(publicKey: nil) } } + Button("Issue new config") { Task { await issue() } } } message: { Text("New keys are created. The device that uses the current config stops working until it gets the new one.") } - .alert("Use a key from the device", isPresented: $askKey) { - TextField("Public key", text: $deviceKey) - .font(.mono(.footnote)) - .textInputAutocapitalization(.never) - .autocorrectionDisabled() - Button("Cancel", role: .cancel) {} - Button("Replace key") { Task { await issue(publicKey: deviceKey) } } - } message: { - Text("Paste the public key the device generated. The current config stops working.") - } .sheet(item: $issued, onDismiss: { Task { await load() } }) { IssuedConfigView(issued: $0) } .sheet(isPresented: $editing, onDismiss: { Task { await load() } }) { if let p = peer, let s = server { PeerEditView(peer: p, server: s) } @@ -120,7 +108,7 @@ struct PeerDetailView: View { KV(key: "Endpoint", value: p.stats.endpoint.isEmpty ? "–" : p.stats.endpoint, mono: true) KV(key: "Location", value: p.stats.location?.label.isEmpty == false ? p.stats.location!.label : "–") KV(key: "Latest handshake", value: ago(p.stats.lastHandshake)) - KV(key: "Public key", value: p.publicKey, mono: true) + KV(key: "Public key", value: p.publicKey.isEmpty ? "–" : p.publicKey, mono: true) KV(key: "Preshared key", value: p.hasPresharedKey ? "Set" : "None") KV(key: "All-time traffic", value: "Download \(fmtBytes(p.stats.downTotal)) · Upload \(fmtBytes(p.stats.upTotal))") } @@ -195,9 +183,13 @@ struct PeerDetailView: View { .foregroundStyle(Color.gwText2) Button { confirmIssue = true } label: { Label("Issue new config & QR", systemImage: "qrcode") } .buttonStyle(PrimaryButtonStyle()) - Button("Use a key from the device…") { deviceKey = ""; askKey = true } - .buttonStyle(SecondaryButtonStyle()) - Text(p.configIssued.map { "Last issued \(fmtDate($0))." } ?? "Created with a key from the device.") + if let s = p.setup { + Text(s.expired ? "The setup link expired \(fmtDate(s.expires)). Manage setup links in the web interface." + : "A setup link is waiting to be opened (until \(fmtDate(s.expires))). Issuing a config here replaces it.") + .font(.footnote) + .foregroundStyle(Color.gwWarnInk) + } + Text(p.configIssued.map { "Last issued \(fmtDate($0))." } ?? "No config issued yet.") .font(.caption) .foregroundStyle(Color.gwText2) } @@ -259,10 +251,10 @@ struct PeerDetailView: View { } } - private func issue(publicKey: String?) async { + private func issue() async { guard let api = session.api else { return } do { - let body: [String: Any?]? = publicKey.map { ["publicKey": $0.trimmingCharacters(in: .whitespacesAndNewlines)] } + let body: [String: Any?]? = nil issued = try await api.send("POST", "/peers/\(peerID)/issue-config", body) } catch { session.alert = session.message(for: error) diff --git a/ios/GHOSTWIRE/PeerForms.swift b/ios/GHOSTWIRE/PeerForms.swift index 1e88bf2..0f88860 100644 --- a/ios/GHOSTWIRE/PeerForms.swift +++ b/ios/GHOSTWIRE/PeerForms.swift @@ -109,8 +109,6 @@ struct AddPeerView: View { @State private var note = "" @State private var ipv4 = "" @State private var overrides = PeerOverrides() - @State private var pasteKey = false - @State private var publicKey = "" @State private var psk = true @State private var error: String? @State private var busy = false @@ -164,23 +162,11 @@ struct AddPeerView: View { OverrideSections(o: $overrides, server: server) Section { - Picker("Keys", selection: $pasteKey) { - Text("Generate here").tag(false) - Text("Paste the client's public key").tag(true) - } - .pickerStyle(.inline) - .labelsHidden() - if pasteKey { - TextField("Public key", text: $publicKey) - .font(.mono(.footnote)) - .textInputAutocapitalization(.never) - .autocorrectionDisabled() - } Toggle("Add a preshared key", isOn: $psk) } header: { Text("Keys") } footer: { - Text(pasteKey ? "For clients that make their own keys." : "The private key appears once in the config and QR code. It isn't stored.") + Text("The private key appears once in the config and QR code. It isn't stored.") } if let error { @@ -201,7 +187,6 @@ struct AddPeerView: View { body["note"] = note.trimmingCharacters(in: .whitespaces) body["ipv4"] = ipv4.trimmingCharacters(in: .whitespaces) body["presharedKey"] = psk - if pasteKey { body["publicKey"] = publicKey.trimmingCharacters(in: .whitespacesAndNewlines) } let r: IssuedConfig = try await api.send("POST", "/peers", body) session.reportApply(r.applyError) issued = r diff --git a/ios/GHOSTWIRE/Theme.swift b/ios/GHOSTWIRE/Theme.swift index d82f88c..18b03f7 100644 --- a/ios/GHOSTWIRE/Theme.swift +++ b/ios/GHOSTWIRE/Theme.swift @@ -104,10 +104,11 @@ struct Notice: View { } enum PeerState { - case online(Date), offline(Date), never, disabled + case online(Date), offline(Date), never, disabled, waiting, noConfig init(_ p: Peer) { if !p.enabled { self = .disabled } + else if p.publicKey.isEmpty { self = p.setup.map { !$0.expired } == true ? .waiting : .noConfig } else if let h = p.stats.lastHandshake { self = p.stats.online ? .online(h) : .offline(h) } else { self = .never } } @@ -118,13 +119,15 @@ enum PeerState { case .offline(let d): "Offline · " + ago(d) case .never: "Never connected" case .disabled: "Disabled" + case .waiting: "Waiting for setup" + case .noConfig: "No config yet" } } var key: String { switch self { case .online: "online" - case .offline, .never: "offline" + case .offline, .never, .waiting, .noConfig: "offline" case .disabled: "disabled" } } @@ -136,7 +139,8 @@ struct StatusDot: View { switch state { case .online: Circle().fill(Color.gwGood).frame(width: 8, height: 8) case .offline: Circle().fill(Color.gray).frame(width: 8, height: 8) - case .never: Circle().stroke(Color.gray, lineWidth: 1.5).frame(width: 8, height: 8) + case .never, .noConfig: Circle().stroke(Color.gray, lineWidth: 1.5).frame(width: 8, height: 8) + case .waiting: Circle().fill(Color.gwUp).frame(width: 8, height: 8) case .disabled: Circle().fill(Color.gwBad).frame(width: 8, height: 8) } } diff --git a/kernel_linux.go b/kernel_linux.go index c3f3acf..dfc669b 100644 --- a/kernel_linux.go +++ b/kernel_linux.go @@ -126,7 +126,7 @@ func (k *linuxKernel) syncDevice(c *Config) error { desired := map[wgtypes.Key]want{} for i := range c.Peers { p := &c.Peers[i] - if !p.Enabled { + if !p.Enabled || !p.hasKey() { continue } pub, err := wgtypes.ParseKey(p.PublicKey) diff --git a/kernel_other.go b/kernel_other.go index d57c86f..76b4c55 100644 --- a/kernel_other.go +++ b/kernel_other.go @@ -30,7 +30,7 @@ func (k *simKernel) Apply(c *Config) error { defer k.mu.Unlock() keep := map[string]bool{} for i, p := range c.Peers { - if !p.Enabled { + if !p.Enabled || !p.hasKey() { continue } keep[p.PublicKey] = true diff --git a/main_test.go b/main_test.go index 3f45d7d..b8cb9c2 100644 --- a/main_test.go +++ b/main_test.go @@ -314,6 +314,7 @@ func TestAPI(t *testing.T) { bearer("GET", "/peers", 200) bearer("DELETE", "/peers/"+id, 403) bearer("GET", "/tokens", 403) + bearer("GET", "/peers/"+id+"/setup", 403) // the link would set up a device call("DELETE", "/peers/"+id, nil, 200) if len(store.Get().Peers) != 0 { @@ -502,3 +503,119 @@ func TestGeoDatabase(t *testing.T) { t.Logf("%s → %+v", ep, info) } } + +// TestSetupLink creates a peer with a link, checks PIN handling and that the +// link works exactly once without storing the private key. +func TestSetupLink(t *testing.T) { + dir := t.TempDir() + store, err := openStore(filepath.Join(dir, "config.json")) + if err != nil { + t.Fatal(err) + } + hash, _ := hashPassword("a long test password") + _ = store.Update(func(c *Config) error { c.Admin.PasswordHash = hash; c.Server.Endpoint = "vpn.example.net"; return nil }) + k := &fakeKernel{} + st, _ := openStats(filepath.Join(dir, "stats.json"), store, k) + app := &App{store: store, kernel: k, recon: newReconciler(k, store), stats: st, auth: newAuth(store), + tls: &webTLS{}, logPath: filepath.Join(dir, "log.jsonl"), started: time.Now(), shutdown: func() {}} + srv := httptest.NewServer(app.routes()) + defer srv.Close() + jar, _ := cookiejar.New(nil) + admin := &http.Client{Jar: jar} + + call := func(cl *http.Client, method, path string, body any, want int) map[string]any { + t.Helper() + var rd io.Reader + if body != nil { + b, _ := json.Marshal(body) + rd = bytes.NewReader(b) + } + req, _ := http.NewRequest(method, srv.URL+path, rd) + req.Header.Set("Content-Type", "application/json") + resp, err := cl.Do(req) + if err != nil { + t.Fatal(err) + } + defer resp.Body.Close() + var out map[string]any + _ = json.NewDecoder(resp.Body).Decode(&out) + if resp.StatusCode != want { + t.Fatalf("%s %s: status %d, want %d: %v", method, path, resp.StatusCode, want, out) + } + return out + } + call(admin, "POST", "/api/v1/auth/login", map[string]string{"username": "admin", "password": "a long test password"}, 200) + + // Two peers waiting for setup have no key yet; that must not clash. + created := call(admin, "POST", "/api/v1/peers", map[string]any{"name": "phone-anna", "delivery": "link", "linkHours": 24}, 201) + call(admin, "POST", "/api/v1/peers", map[string]any{"name": "laptop-ben", "delivery": "link", "linkPIN": false}, 201) + call(admin, "POST", "/api/v1/peers", map[string]any{"name": "x", "delivery": "link", "linkHours": 5}, 400) + peer := created["peer"].(map[string]any) + id := peer["id"].(string) + if peer["publicKey"] != "" || created["config"] != nil { + t.Fatalf("link peer got keys or a config: %v", created) + } + setup := created["setup"].(map[string]any) + path, pin := setup["path"].(string), setup["pin"].(string) + if len(pin) != setupPINLen || !strings.HasPrefix(setup["url"].(string), srv.URL+"/setup/") || setup["qr"] == "" { + t.Fatalf("bad setup answer: %v", setup) + } + if s := call(admin, "GET", "/api/v1/peers/"+id+"/setup", nil, 200); s["pin"] != pin { + t.Fatal("admin cannot read the link again") + } + + public := &http.Client{} + api := strings.Replace(path, "/setup/", "/api/v1/setup/", 1) + if info := call(public, "GET", api, nil, 200); info["name"] != "phone-anna" || info["pinRequired"] != true { + t.Fatalf("setup info: %v", info) + } + call(public, "GET", "/api/v1/setup/nonsense", nil, 404) + call(public, "GET", path, nil, 200) // the page itself + + wrong := "0000" + if wrong == pin { + wrong = "1111" + } + if r := call(public, "POST", api, map[string]string{"pin": wrong}, 403); r["triesLeft"].(float64) != setupMaxFails-1 { + t.Fatalf("wrong PIN: %v", r) + } + got := call(public, "POST", api, map[string]string{"pin": pin}, 200) + conf := got["config"].(string) + if !strings.Contains(conf, "PrivateKey = ") || got["qr"] == "" { + t.Fatal("redeemed config lacks the private key or QR") + } + priv := strings.TrimSpace(strings.SplitN(strings.SplitN(conf, "PrivateKey = ", 2)[1], "\n", 2)[0]) + raw, _ := json.Marshal(store.Get()) + if bytes.Contains(raw, []byte(priv)) { + t.Fatal("client private key was stored") + } + call(public, "POST", api, map[string]string{"pin": pin}, 404) // works once + call(public, "GET", api, nil, 404) + p := call(admin, "GET", "/api/v1/peers/"+id, nil, 200) + if p["publicKey"] == "" || p["setup"] != nil || p["configIssued"] == nil { + t.Fatalf("peer not set up: %v", p) + } + + // Re-issue by link: the old key stays until the link is used. + oldKey := p["publicKey"] + re := call(admin, "POST", "/api/v1/peers/"+id+"/issue-config", map[string]any{"delivery": "link"}, 200) + if re["peer"].(map[string]any)["publicKey"] != oldKey { + t.Fatal("issuing a link replaced the key early") + } + api = strings.Replace(re["setup"].(map[string]any)["path"].(string), "/setup/", "/api/v1/setup/", 1) + for i := 0; i < setupMaxFails; i++ { + want := 403 + if i == setupMaxFails-1 { + want = 404 // revoked by the last wrong PIN + } + call(public, "POST", api, map[string]string{"pin": "x"}, want) + } + if p := call(admin, "GET", "/api/v1/peers/"+id, nil, 200); p["setup"] != nil || p["publicKey"] != oldKey { + t.Fatalf("link not revoked after wrong PINs: %v", p) + } + + // Revoking by hand. + call(admin, "POST", "/api/v1/peers/"+id+"/issue-config", map[string]any{"delivery": "link"}, 200) + call(admin, "DELETE", "/api/v1/peers/"+id+"/setup", nil, 200) + call(admin, "GET", "/api/v1/peers/"+id+"/setup", nil, 404) +} diff --git a/setup.html b/setup.html new file mode 100644 index 0000000..b0abb29 --- /dev/null +++ b/setup.html @@ -0,0 +1,18 @@ + + + + + + + +Set up your VPN · GHOSTWIRE + + + + + + +

Loading…

+ + + diff --git a/setup.js b/setup.js new file mode 100644 index 0000000..eaec456 --- /dev/null +++ b/setup.js @@ -0,0 +1,132 @@ +'use strict'; +// The page a setup link opens. It works without signing in: the token in +// the URL is the credential. The config exists only in this page's memory; +// leaving the page loses it, as the link works once. +(() => { + const app = document.getElementById('app'); + const token = location.pathname.split('/').pop(); + + function h(tag, props, ...kids) { + const el = document.createElement(tag); + for (const [k, v] of Object.entries(props || {})) { + if (v == null || v === false) continue; + if (k === 'class') el.className = v; + else if (k.startsWith('on')) el.addEventListener(k.slice(2).toLowerCase(), v); + else if (['value', 'hidden', 'htmlFor', 'disabled', 'src', 'alt', 'href', 'type', 'id', 'autocomplete', 'inputMode', 'maxLength', 'required'].includes(k)) el[k] = v; + else el.setAttribute(k, v === true ? '' : v); + } + for (const c of kids.flat(Infinity)) if (c != null && c !== false) el.append(c instanceof Node ? c : String(c)); + return el; + } + + // Same drawing as favicon.svg. + function logo(size, plain) { + const s = document.createElementNS('http://www.w3.org/2000/svg', 'svg'); + for (const [k, v] of Object.entries({ width: size, height: size, viewBox: '0 0 64 64', 'aria-hidden': 'true' })) s.setAttribute(k, v); + s.innerHTML = (plain ? '' : '') + + '' + + '' + + (plain ? '' : '') + ''; + return s; + } + + const fmtExpiry = (iso) => new Date(iso).toLocaleString(undefined, { weekday: 'short', day: 'numeric', month: 'short', hour: '2-digit', minute: '2-digit' }); + + async function call(method, body) { + const opt = { method, headers: {} }; + if (body) { + opt.headers['Content-Type'] = 'application/json'; + opt.body = JSON.stringify(body); + } + const r = await fetch('/api/v1/setup/' + encodeURIComponent(token), opt); + let data = {}; + try { data = await r.json(); } catch { /* empty */ } + return { status: r.status, data }; + } + + function show(...kids) { app.replaceChildren(h('div', { class: 'setupbox' }, kids)); } + + function invalid() { + app.replaceChildren(h('div', { class: 'setupbox center' }, + h('span', { class: 'ghost' }, logo(72, true)), + h('h1', null, 'This link isn\'t valid'), + h('p', null, 'It was already used, it expired, or it was revoked. Ask whoever sent it for a new one.'), + h('p', { class: 'loginfoot' }, 'GHOSTWIRE'))); + } + + function start(info) { + const err = h('p', { class: 'err-text', role: 'alert' }); + const pin = info.pinRequired + ? h('input', { id: 'pin', class: 'pin', inputMode: 'numeric', autocomplete: 'one-time-code', maxLength: 8, required: true }) + : null; + const btn = h('button', { type: 'submit', class: 'btn primary' }, info.pinRequired ? 'Continue' : 'Get my VPN profile'); + const form = h('form', { class: 'loginform', onSubmit: async (e) => { + e.preventDefault(); + err.textContent = ''; + btn.disabled = true; + try { + const { status, data } = await call('POST', { pin: pin ? pin.value.trim() : '' }); + if (status === 200) return ready(data); + if (status === 404) return invalid(); + err.textContent = data.error || 'Something went wrong. Try again.'; + if (pin) pin.select(); + } catch { + err.textContent = 'No connection to the server. Try again.'; + } + btn.disabled = false; + } }, + pin ? h('div', { class: 'field' }, h('label', { htmlFor: 'pin' }, 'PIN'), pin) : null, + err, btn); + app.replaceChildren(h('div', { class: 'setupbox' }, + h('div', { class: 'brand stack' }, logo(56), h('div', { class: 'wm' }, 'GHOSTWIRE')), + h('div', { class: 'center' }, + h('h1', null, 'Set up your VPN'), + h('p', null, 'This link adds the VPN profile ', h('strong', { class: 'mono' }, info.name), ' to your device.', + info.pinRequired ? ' Enter the PIN you were given.' : '')), + form, + h('p', { class: 'note' }, 'The link works once and expires ' + fmtExpiry(info.expires) + '.'))); + (pin || btn).focus(); + } + + function ready(res) { + const file = res.name + '.conf'; + const download = () => { + const url = URL.createObjectURL(new Blob([res.config], { type: 'application/octet-stream' })); + const a = h('a', { href: url, download: file }); + document.body.append(a); + a.click(); + a.remove(); + setTimeout(() => URL.revokeObjectURL(url), 1000); + }; + const qr = h('img', { class: 'qr', src: res.qr, alt: 'QR code of the VPN profile ' + res.name, hidden: true }); + const qrBtn = h('button', { type: 'button', class: 'btn', onClick: () => { qr.hidden = !qr.hidden; qrBtn.textContent = qr.hidden ? 'Show QR code' : 'Hide QR code'; } }, 'Show QR code'); + const step = (n, title, ...body) => h('li', null, h('span', { class: 'num' }, String(n)), h('div', null, h('strong', null, title), body)); + // Leaving the page loses the only copy of the private key. + window.addEventListener('beforeunload', (e) => e.preventDefault()); + show( + h('div', { class: 'brand' }, logo(32), h('div', { class: 'wm' }, 'GHOSTWIRE')), + h('h1', null, 'Your VPN profile is ready'), + h('div', { class: 'notice' }, 'Save it now. This page can\'t be opened again: the private key exists only here and isn\'t stored anywhere.'), + h('ol', { class: 'steps' }, + step(1, 'Install WireGuard', + h('p', null, h('a', { href: 'https://apps.apple.com/app/wireguard/id1441195209', rel: 'noopener' }, 'App Store'), ' · ', + h('a', { href: 'https://play.google.com/store/apps/details?id=com.wireguard.android', rel: 'noopener' }, 'Google Play'), ' · ', + h('a', { href: 'https://www.wireguard.com/install/', rel: 'noopener' }, 'Other systems'))), + step(2, 'Add the profile', + h('button', { type: 'button', class: 'btn primary', onClick: download }, 'Download ' + file), + h('p', null, 'Open the downloaded file with WireGuard, or in WireGuard tap + and choose “Create from file”.')), + step(3, 'Setting up another device?', + qrBtn, qr, + h('p', null, 'Opened this on a computer? Scan the QR code with WireGuard on your phone.')))); + } + + (async () => { + try { + const { status, data } = await call('GET'); + if (status === 200) start(data); + else invalid(); + } catch { + show(h('h1', null, 'No connection'), h('p', null, 'The server can\'t be reached. Reload the page to try again.')); + } + })(); +})(); diff --git a/setuplink.go b/setuplink.go new file mode 100644 index 0000000..ffe5a98 --- /dev/null +++ b/setuplink.go @@ -0,0 +1,299 @@ +package main + +import ( + "crypto/rand" + "crypto/subtle" + "fmt" + "log/slog" + "math/big" + "net" + "net/http" + "slices" + "time" +) + +// A setup link hands a client config to someone who is not next to the +// admin. The keys are made only when the link is opened, so the private key +// is never stored: the link works once, then it is deleted. +// +// The token and PIN are kept in config.json (0600) so the admin can copy the +// link again. Whoever can read that file already holds the server key. +type SetupLink struct { + Token string `json:"token"` + PIN string `json:"pin,omitempty"` + Created time.Time `json:"created"` + Expires time.Time `json:"expires"` + Fails int `json:"fails,omitempty"` // wrong PINs so far +} + +const ( + setupMaxFails = 5 // wrong PINs before the link is revoked + setupPINLen = 4 +) + +// setupHours are the lifetimes the UI offers. +var setupHours = []int{1, 24, 168} + +func (s *SetupLink) expired(now time.Time) bool { return !now.Before(s.Expires) } + +func randomPIN() string { + max := big.NewInt(1) + for range setupPINLen { + max.Mul(max, big.NewInt(10)) + } + n, err := rand.Int(rand.Reader, max) + if err != nil { + panic(err) + } + return fmt.Sprintf("%0*d", setupPINLen, n) +} + +// setupRequest is the part of a create or issue request that asks for a +// link instead of a config shown right away. +type setupRequest struct { + Delivery string `json:"delivery"` // "" or "show": config now; "link": setup link + LinkHours int `json:"linkHours"` // 1, 24 or 168; default 24 + LinkPIN *bool `json:"linkPIN"` // default true +} + +func (in setupRequest) wantsLink() bool { return in.Delivery == "link" } + +func (in setupRequest) newLink() (*SetupLink, error) { + switch in.Delivery { + case "", "show", "link": + default: + return nil, badRequest("delivery must be show or link") + } + hours := in.LinkHours + if hours == 0 { + hours = 24 + } + if !slices.Contains(setupHours, hours) { + return nil, badRequest("linkHours must be 1, 24 or 168") + } + now := time.Now().UTC() + l := &SetupLink{Token: randomString(24), Created: now, Expires: now.Add(time.Duration(hours) * time.Hour)} + if in.LinkPIN == nil || *in.LinkPIN { + l.PIN = randomPIN() + } + return l, nil +} + +// setupView is what peer lists show about a pending link: no secrets, so +// read-only tokens may see it. +type setupView struct { + Created time.Time `json:"created"` + Expires time.Time `json:"expires"` + Expired bool `json:"expired"` + PINRequired bool `json:"pinRequired"` + PINFails int `json:"pinFails"` +} + +func viewSetup(s *SetupLink) *setupView { + if s == nil { + return nil + } + return &setupView{Created: s.Created, Expires: s.Expires, Expired: s.expired(time.Now()), PINRequired: s.PIN != "", PINFails: s.Fails} +} + +// setupSecret is the link itself, for the admin who sends it. +type setupSecret struct { + URL string `json:"url"` + Path string `json:"path"` + PIN string `json:"pin,omitempty"` + Expires time.Time `json:"expires"` + QR string `json:"qr"` +} + +// setupBase is the scheme and host the admin reached this server with. +// Behind a local reverse proxy, X-Forwarded-Proto tells whether that was +// HTTPS. +func setupBase(r *http.Request) string { + scheme := "http" + if r.TLS != nil || (fromLoopback(r) && r.Header.Get("X-Forwarded-Proto") == "https") { + scheme = "https" + } + return scheme + "://" + r.Host +} + +func fromLoopback(r *http.Request) bool { + host, _, err := net.SplitHostPort(r.RemoteAddr) + if err != nil { + host = r.RemoteAddr + } + ip := net.ParseIP(host) + return ip != nil && ip.IsLoopback() +} + +func secretFor(r *http.Request, s *SetupLink) (setupSecret, error) { + path := "/setup/" + s.Token + out := setupSecret{URL: setupBase(r) + path, Path: path, PIN: s.PIN, Expires: s.Expires} + qr, err := qrDataURL(out.URL) + if err != nil { + return out, err + } + out.QR = qr + return out, nil +} + +// peerByToken finds the peer whose link matches token, in constant time per +// comparison. +func (c *Config) peerByToken(token string) *Peer { + if token == "" { + return nil + } + var found *Peer + for i := range c.Peers { + if s := c.Peers[i].Setup; s != nil && subtle.ConstantTimeCompare([]byte(s.Token), []byte(token)) == 1 { + found = &c.Peers[i] + } + } + return found +} + +// --- admin endpoints --- + +func (a *App) getSetup(w http.ResponseWriter, r *http.Request) { + // The link sets up a device, so read-only tokens must not see it. + if who(r).Scope == "ro" { + writeJSON(w, http.StatusForbidden, map[string]string{"error": "this token is read-only"}) + return + } + cfg := a.store.Get() + _, p := cfg.peerByID(r.PathValue("id")) + if p == nil || p.Setup == nil { + writeJSON(w, http.StatusNotFound, map[string]string{"error": "this peer has no setup link"}) + return + } + out, err := secretFor(r, p.Setup) + if err != nil { + writeErr(w, err) + return + } + writeJSON(w, http.StatusOK, out) +} + +func (a *App) revokeSetup(w http.ResponseWriter, r *http.Request) { + id := r.PathValue("id") + var name string + if err := a.store.Update(func(c *Config) error { + _, p := c.peerByID(id) + if p == nil { + return badRequest("no such peer") + } + p.Setup, name = nil, p.Name + return nil + }); err != nil { + writeErr(w, err) + return + } + a.audit(r, "setup link revoked", "peer", name) + cfg := a.store.Get() + _, p := cfg.peerByID(id) + writeJSON(w, http.StatusOK, map[string]any{"peer": a.peerView(cfg, p)}) +} + +// --- public endpoints, reached with the link alone --- + +// errSetupInvalid is the one answer for unknown, used, expired and revoked +// links, so a visitor cannot tell whether a link ever existed. +const errSetupInvalid = "this link isn't valid" + +func setupInvalid(w http.ResponseWriter) { + writeJSON(w, http.StatusNotFound, map[string]string{"error": errSetupInvalid}) +} + +func (a *App) setupInfo(w http.ResponseWriter, r *http.Request) { + cfg := a.store.Get() + p := cfg.peerByToken(r.PathValue("token")) + if p == nil || p.Setup.expired(time.Now()) { + setupInvalid(w) + return + } + writeJSON(w, http.StatusOK, map[string]any{"name": p.Name, "pinRequired": p.Setup.PIN != "", "expires": p.Setup.Expires}) +} + +// setupRedeem makes the keys, stores the public key and returns the config. +// The link is deleted in the same update, so it cannot be used twice. +func (a *App) setupRedeem(w http.ResponseWriter, r *http.Request) { + var in struct { + PIN string `json:"pin"` + } + if err := readJSON(r, &in); err != nil { + writeErr(w, err) + return + } + k, err := newPrivateKey() + if err != nil { + writeErr(w, err) + return + } + psk, err := newPresharedKey() + if err != nil { + writeErr(w, err) + return + } + ip := remoteIP(r) + var ( + id, name string + hadKey bool + invalid bool + wrongPIN bool + triesLeft int + revokedNow bool + ) + err = a.store.Update(func(c *Config) error { + p := c.peerByToken(r.PathValue("token")) + if p == nil || p.Setup.expired(time.Now()) { + invalid = true + return nil + } + name = p.Name + if p.Setup.PIN != "" && subtle.ConstantTimeCompare([]byte(p.Setup.PIN), []byte(in.PIN)) != 1 { + wrongPIN = true + p.Setup.Fails++ + triesLeft = setupMaxFails - p.Setup.Fails + if triesLeft <= 0 { + p.Setup, revokedNow = nil, true + } + return nil + } + now := time.Now().UTC() + id, hadKey = p.ID, p.hasKey() + p.PublicKey, p.ConfigIssued, p.Setup = k.PublicKey().String(), &now, nil + if p.PresharedKey != "" { + p.PresharedKey = psk.String() + } + return nil + }) + switch { + case err != nil: + writeErr(w, err) + return + case invalid: + slog.Warn("setup link not valid", "remote", ip) + setupInvalid(w) + return + case revokedNow: + slog.Warn("setup link revoked after wrong PINs", "audit", true, "actor", "setup link", "peer", name, "remote", ip) + setupInvalid(w) + return + case wrongPIN: + slog.Warn("setup link: wrong PIN", "peer", name, "remote", ip) + writeJSON(w, http.StatusForbidden, map[string]any{"error": fmt.Sprintf("Wrong PIN. %d tries left.", triesLeft), "triesLeft": triesLeft}) + return + } + if hadKey { + a.stats.Forget(id) + } + slog.Info("peer config issued", "audit", true, "actor", "setup link", "peer", name, "remote", ip) + out, err := a.issue(id, k.String()) + if err != nil { + writeErr(w, err) + return + } + if e := a.apply(); e != "" { + slog.Error("apply after setup link failed", "err", e) + } + writeJSON(w, http.StatusOK, map[string]any{"name": out.Peer.Name, "config": out.Config, "qr": out.QR}) +} diff --git a/stats.go b/stats.go index bde01d0..d8a2217 100644 --- a/stats.go +++ b/stats.go @@ -198,7 +198,9 @@ func (s *Stats) sample() { idByKey := map[string]string{} exists := map[string]bool{} for _, p := range cfg.Peers { - idByKey[p.PublicKey] = p.ID + if p.hasKey() { + idByKey[p.PublicKey] = p.ID + } exists[p.ID] = true } now := time.Now() diff --git a/web.go b/web.go index 67262a5..b7e01d7 100644 --- a/web.go +++ b/web.go @@ -8,14 +8,14 @@ import ( // The web UI and its icons are built into the binary. The UI talks only to // /api/v1, the same API the iOS app uses. // -//go:embed index.html app.js app.css favicon.svg apple-touch-icon.png +//go:embed index.html setup.html app.js setup.js app.css favicon.svg apple-touch-icon.png var webFiles embed.FS func webHandler() http.Handler { files := http.FileServerFS(webFiles) return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { switch r.URL.Path { - case "/", "/app.js", "/app.css", "/favicon.svg", "/apple-touch-icon.png": + case "/", "/app.js", "/setup.js", "/app.css", "/favicon.svg", "/apple-touch-icon.png": w.Header().Set("Cache-Control", "no-cache") files.ServeHTTP(w, r) case "/favicon.ico": @@ -26,3 +26,16 @@ func webHandler() http.Handler { } }) } + +// setupPage serves the page a setup link opens. The token stays in the URL; +// setup.js reads it from there and talks to /api/v1/setup. +func setupPage(w http.ResponseWriter, r *http.Request) { + b, err := webFiles.ReadFile("setup.html") + if err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + w.Header().Set("Content-Type", "text/html; charset=utf-8") + w.Header().Set("Cache-Control", "no-store") + _, _ = w.Write(b) +}