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.
This commit is contained in:
Daniel Redetzke
2026-10-03 23:10:28 +03:00
parent 55aaaa3a78
commit ef1988e4d0
17 changed files with 885 additions and 119 deletions
+117
View File
@@ -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)
}