From 024e441f53cd6bf163806eb84f6ac1174b581444 Mon Sep 17 00:00:00 2001 From: Lorenzo Corallo Date: Wed, 21 Oct 2026 03:23:18 +0200 Subject: [PATCH 01/15] docs(signing): say that releases are not signed yet The README claimed macOS builds are notarized and Windows builds signed, while users still meet Gatekeeper and SmartScreen. It now says so and keeps the SignPath attribution for when signing is set up. --- README.md | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index dbe2276..eb9dadb 100644 --- a/README.md +++ b/README.md @@ -296,6 +296,8 @@ and DNF repositories and the AUR package: see [`docs/linux-packages.md`](docs/li ## Code signing -Windows: free code signing provided by [SignPath.io](https://about.signpath.io), certificate by -[SignPath Foundation](https://signpath.org). macOS: signed and notarized with PoliNetwork APS's Developer ID. See -[`docs/code-signing.md`](docs/code-signing.md) for the policy and setup. +Releases are not signed yet: macOS builds are signed ad hoc, so Gatekeeper asks each user to allow the app once, and +Windows builds are unsigned, so SmartScreen warns (see the maintainer TODO above). Once set up, Windows builds get free +code signing provided by [SignPath.io](https://about.signpath.io), certificate by +[SignPath Foundation](https://signpath.org), and macOS builds are signed and notarized with PoliNetwork APS's Developer +ID. See [`docs/code-signing.md`](docs/code-signing.md) for the policy and setup. From cb82392b87826537a9fb6d4612cc0d5c77a1a240 Mon Sep 17 00:00:00 2001 From: Lorenzo Corallo Date: Wed, 21 Oct 2026 03:23:40 +0200 Subject: [PATCH 02/15] fix(cie): stop crashing when no reader is connected On macOS, listing the readers polled an empty list, which panics in ebfe/scard: with the reader unplugged every status and signature got an empty reply, and `esign status` and `esign doctor` crashed. --- internal/token/cie/pcsc.go | 15 ++++++++++++ internal/token/cie/pcsc_test.go | 42 +++++++++++++++++++++++++++++++++ 2 files changed, 57 insertions(+) create mode 100644 internal/token/cie/pcsc_test.go diff --git a/internal/token/cie/pcsc.go b/internal/token/cie/pcsc.go index 292d20f..6477425 100644 --- a/internal/token/cie/pcsc.go +++ b/internal/token/cie/pcsc.go @@ -3,6 +3,7 @@ package cie import ( "errors" "fmt" + "time" "github.com/ebfe/scard" @@ -24,12 +25,22 @@ func establish() (*scard.Context, error) { return ctx, nil } +// pcscContext is the part of *scard.Context that lists the readers. +type pcscContext interface { + ListReaders() ([]string, error) + GetStatusChange([]scard.ReaderState, time.Duration) error +} + func (pcscConnector) Readers() ([]Reader, error) { ctx, err := establish() if err != nil { return nil, err } defer ctx.Release() + return readersOf(ctx) +} + +func readersOf(ctx pcscContext) ([]Reader, error) { names, err := ctx.ListReaders() if errors.Is(err, scard.ErrNoReadersAvailable) { return nil, nil @@ -37,6 +48,10 @@ func (pcscConnector) Readers() ([]Reader, error) { if err != nil { return nil, err } + // ebfe/scard's macOS GetStatusChange indexes the first state, and panics without one. + if len(names) == 0 { + return nil, nil + } states := make([]scard.ReaderState, len(names)) for i, n := range names { states[i] = scard.ReaderState{Reader: n, CurrentState: scard.StateUnaware} diff --git a/internal/token/cie/pcsc_test.go b/internal/token/cie/pcsc_test.go new file mode 100644 index 0000000..60ab725 --- /dev/null +++ b/internal/token/cie/pcsc_test.go @@ -0,0 +1,42 @@ +package cie + +import ( + "testing" + "time" + + "github.com/ebfe/scard" +) + +type fakeContext struct { + names []string + err error + polled bool + present string +} + +func (f *fakeContext) ListReaders() ([]string, error) { return f.names, f.err } + +func (f *fakeContext) GetStatusChange(states []scard.ReaderState, _ time.Duration) error { + f.polled = true + for i := range states { + if states[i].Reader == f.present { + states[i].EventState = scard.StatePresent + states[i].Atr = []byte{0x3B, 0x8F} + } + } + return nil +} + +// TestReadersOfNone: with no reader the states are never polled, which would panic in ebfe/scard on macOS. +func TestReadersOfNone(t *testing.T) { + for _, ctx := range []*fakeContext{{names: []string{}}, {err: scard.ErrNoReadersAvailable}} { + readers, err := readersOf(ctx) + if readers != nil || err != nil || ctx.polled { + t.Errorf("ListReaders %v, %v: got %v, %v, polled %v", ctx.names, ctx.err, readers, err, ctx.polled) + } + } + readers, err := readersOf(&fakeContext{names: []string{"A", "B"}, present: "B"}) + if err != nil || len(readers) != 2 || readers[0].ATR != nil || readers[1].ATR == nil { + t.Errorf("two readers: %+v, %v", readers, err) + } +} From 53e7a3eb825a54d80313355fe60955f523b6707e Mon Sep 17 00:00:00 2001 From: Lorenzo Corallo Date: Wed, 21 Oct 2026 03:27:09 +0200 Subject: [PATCH 03/15] fix(sign): say when the reader is missing or the agent stopped Without a reader, signing now fails with no_reader, so the window and the dashboard both say to connect it. A provider that panics is reported unavailable instead of dropping the answer, `status` and `doctor` go on to the next provider, and when the agent stops answering the window says so instead of "Failed to fetch". --- cmd/esign/doctor.go | 6 +- cmd/esign/main.go | 6 +- internal/server/remember_test.go | 2 +- internal/server/server.go | 42 ++++++++++--- internal/server/server_test.go | 95 ++++++++++++++++++++++++++++++ internal/server/web/assets/sign.js | 39 ++++++++---- internal/token/token.go | 13 ++++ internal/token/token_test.go | 15 +++++ 8 files changed, 196 insertions(+), 22 deletions(-) diff --git a/cmd/esign/doctor.go b/cmd/esign/doctor.go index 5b02d35..775f02c 100644 --- a/cmd/esign/doctor.go +++ b/cmd/esign/doctor.go @@ -13,6 +13,7 @@ import ( "github.com/PoliNetworkOrg/esign/internal/config" "github.com/PoliNetworkOrg/esign/internal/install" "github.com/PoliNetworkOrg/esign/internal/launch" + "github.com/PoliNetworkOrg/esign/internal/token" "github.com/PoliNetworkOrg/esign/internal/token/worker" ) @@ -85,12 +86,15 @@ func doctor(ctx context.Context, w io.Writer, cfg config.Config, configPath, dir } } sctx, cancel := context.WithTimeout(ctx, 30*time.Second) - st := s.Provider.Status(sctx) + st, err := token.SafeStatus(sctx, s.Provider) cancel() fmt.Fprintf(w, " available: %v\n", st.Available) if st.Detail != "" { fmt.Fprintf(w, " detail: %s\n", st.Detail) } + if err != nil { + fmt.Fprintf(w, " error: %v\n", err) + } if st.Available && len(st.Readers) == 0 { fmt.Fprintln(w, " readers: none") } diff --git a/cmd/esign/main.go b/cmd/esign/main.go index 822ff74..41fcdb2 100644 --- a/cmd/esign/main.go +++ b/cmd/esign/main.go @@ -23,6 +23,7 @@ import ( "github.com/PoliNetworkOrg/esign/internal/launch" "github.com/PoliNetworkOrg/esign/internal/macapp" "github.com/PoliNetworkOrg/esign/internal/server" + "github.com/PoliNetworkOrg/esign/internal/token" "github.com/PoliNetworkOrg/esign/internal/token/soft" ) @@ -272,8 +273,11 @@ func executable() string { func printStatus(ctx context.Context, specs []providerSpec) { for _, s := range specs { - st := s.Provider.Status(ctx) + st, err := token.SafeStatus(ctx, s.Provider) fmt.Printf("%s: available=%v %s\n", st.Provider, st.Available, st.Detail) + if err != nil { + fmt.Printf(" error: %v\n", err) + } for _, r := range st.Readers { fmt.Printf(" reader: %s\n", r) } diff --git a/internal/server/remember_test.go b/internal/server/remember_test.go index 9aa71b9..aac92f6 100644 --- a/internal/server/remember_test.go +++ b/internal/server/remember_test.go @@ -43,7 +43,7 @@ func (r *reader) swap(t *testing.T) { func (r *reader) Name() string { return "reader" } func (r *reader) Status(context.Context) token.Status { present := r.present.Load() - return token.Status{Provider: "reader", Available: true, CardPresent: &present} + return token.Status{Provider: "reader", Available: true, Readers: []string{"reader"}, CardPresent: &present} } func (r *reader) Open(ctx context.Context, pin string) (token.Session, error) { r.opened.Add(1) diff --git a/internal/server/server.go b/internal/server/server.go index ef28307..c142ff2 100644 --- a/internal/server/server.go +++ b/internal/server/server.go @@ -17,6 +17,7 @@ import ( "log/slog" "net" "net/http" + "runtime/debug" "strconv" "strings" "sync" @@ -209,6 +210,15 @@ func (s *Server) api(handler func(w http.ResponseWriter, r *http.Request)) http. writeError(w, http.StatusUnsupportedMediaType, "bad_request", "Send JSON") return } + defer func() { + if v := recover(); v != nil { + if v == http.ErrAbortHandler { + panic(v) + } + s.opts.Logger.Error("api panic", "path", r.URL.Path, "panic", v, "stack", string(debug.Stack())) + writeError(w, http.StatusInternalServerError, "internal", "PoliNetwork eSign hit an unexpected error. Try again.") + } + }() handler(w, r) } } @@ -224,10 +234,19 @@ type statusResponse struct { Remembered *rememberedInfo `json:"remembered,omitempty"` } +// providerStatus is p's status; a panic in it is logged and reported as unavailable. +func (s *Server) providerStatus(ctx context.Context, p token.Provider) token.Status { + st, err := token.SafeStatus(ctx, p) + if err != nil { + s.opts.Logger.Error("provider status", "provider", p.Name(), "error", err) + } + return st +} + func (s *Server) statuses(ctx context.Context) statusResponse { res := statusResponse{Agent: "polinetwork-esign", Version: s.opts.Version, OS: osName()} for _, p := range s.opts.Providers { - st := p.Status(ctx) + st := s.providerStatus(ctx, p) res.Providers = append(res.Providers, st) if st.Available && st.CardPresent != nil && *st.CardPresent { res.Ready = true @@ -378,11 +397,15 @@ var fallbackCodes = map[token.Code]bool{ } // open authenticates with the first provider that sees a card, falling back only when the provider says no PIN was -// sent: a PIN that reached a module or card may have used an attempt, and must not be tried again elsewhere. +// sent: a PIN that reached a module or card may have used an attempt, and must not be tried again elsewhere. When no +// provider can be tried, the error says what is missing: a reader, CIE software or the card. func (s *Server) open(ctx context.Context, pin string) (token.Session, token.Provider, error) { var firstErr error + readers, available := false, false for _, p := range s.opts.Providers { - st := p.Status(ctx) + st := s.providerStatus(ctx, p) + readers = readers || len(st.Readers) > 0 + available = available || st.Available if !st.Available || (st.CardPresent != nil && !*st.CardPresent) { continue } @@ -398,10 +421,15 @@ func (s *Server) open(ctx context.Context, pin string) (token.Session, token.Pro firstErr = err } } - if firstErr == nil { - firstErr = token.Errorf(token.CodeNoCard, "no provider sees a card") - } - return nil, nil, firstErr + switch { + case firstErr != nil: + return nil, nil, firstErr + case !readers: + return nil, nil, token.Errorf(token.CodeNoReader, "no provider sees a reader") + case !available: + return nil, nil, token.Errorf(token.CodeMiddlewareMissing, "no provider is available") + } + return nil, nil, token.Errorf(token.CodeNoCard, "no provider sees a card") } // signResult is what happened to one ticket: "completed", "failed" or "skipped" (not tried after a card error). diff --git a/internal/server/server_test.go b/internal/server/server_test.go index 7d52f56..4580034 100644 --- a/internal/server/server_test.go +++ b/internal/server/server_test.go @@ -556,3 +556,98 @@ func TestNoFallbackForUntrustedCard(t *testing.T) { t.Fatalf("error %v, next opened %d times", err, next.opened) } } + +// broken is a provider whose Status or Open panics. +type broken struct{ status, open bool } + +func (b *broken) Name() string { return "broken" } +func (b *broken) Status(context.Context) token.Status { + if b.status { + panic("status") + } + present := true + return token.Status{Provider: "broken", Available: true, CardPresent: &present} +} +func (b *broken) Open(context.Context, string) (token.Session, error) { + if b.open { + panic("open") + } + return nil, token.Errorf(token.CodeNoCard, "no card") +} + +// counted counts the sessions opened on a software card. +type counted struct { + *soft.Provider + opened int +} + +func (c *counted) Open(ctx context.Context, pin string) (token.Session, error) { + c.opened++ + return c.Provider.Open(ctx, pin) +} + +func softCard(t *testing.T) *counted { + t.Helper() + p, err := soft.New("12345678") + if err != nil { + t.Fatal(err) + } + return &counted{Provider: p} +} + +// TestStatusPanic: a provider whose status panics is reported unavailable, and the next one still signs. +func TestStatusPanic(t *testing.T) { + fb := &fakeBackend{} + card := softCard(t) + s := newServer(fb, &broken{status: true}, card) + rec := do(t, s, req{method: "GET", path: "/api/status"}) + var st statusResponse + _ = json.Unmarshal(rec.Body.Bytes(), &st) + if rec.Code != http.StatusOK || len(st.Providers) != 2 || st.Providers[0].Available || !st.Providers[1].Available || !st.Ready { + t.Fatalf("status: %d %s", rec.Code, rec.Body) + } + rec, res := signRequest(t, s, "12345678", ticket) + if got := statuses(res); rec.Code != http.StatusOK || !slices.Equal(got, []string{"completed"}) || card.opened != 1 { + t.Fatalf("sign: %d %s", rec.Code, rec.Body) + } +} + +// TestOpenPanic: a panic while opening the card is answered as an internal error, and the PIN goes nowhere else. +func TestOpenPanic(t *testing.T) { + card := softCard(t) + s := newServer(&fakeBackend{}, &broken{open: true}, card) + rec, _ := signRequest(t, s, "12345678", ticket) + if rec.Code != http.StatusInternalServerError || errorCode(rec) != "internal" || card.opened != 0 { + t.Fatalf("sign: %d %s, fallback opened %d times", rec.Code, rec.Body, card.opened) + } +} + +// fixed is a provider with a fixed status that has no card to open. +type fixed struct{ st token.Status } + +func (f *fixed) Name() string { return f.st.Provider } +func (f *fixed) Status(context.Context) token.Status { return f.st } +func (f *fixed) Open(context.Context, string) (token.Session, error) { + panic("a provider without a card was opened") +} + +// TestNothingToOpen: when no provider can be tried, the error names what is missing, and the job learns it. +func TestNothingToOpen(t *testing.T) { + absent := false + off := &fixed{token.Status{Provider: "off"}} + cases := []struct { + providers []token.Provider + code string + }{ + {[]token.Provider{off, off}, "no_reader"}, + {[]token.Provider{&fixed{token.Status{Provider: "off", Readers: []string{"ACS ACR1252"}}}}, "middleware_missing"}, + {[]token.Provider{off, &fixed{token.Status{Provider: "empty", Available: true, Readers: []string{"ACS ACR1252"}, CardPresent: &absent}}}, "no_card"}, + } + for _, c := range cases { + fb := &fakeBackend{} + rec, _ := signRequest(t, newServer(fb, c.providers...), "12345678", ticket) + if rec.Code != http.StatusServiceUnavailable || errorCode(rec) != c.code || !slices.Equal(fb.failed, []failure{{ticket, c.code}}) { + t.Errorf("want %s: %d %s, failed %v", c.code, rec.Code, rec.Body, fb.failed) + } + } +} diff --git a/internal/server/web/assets/sign.js b/internal/server/web/assets/sign.js index cf6f117..3be08db 100644 --- a/internal/server/web/assets/sign.js +++ b/internal/server/web/assets/sign.js @@ -19,6 +19,8 @@ if (!CSS.supports("-webkit-text-security", "disc")) $("pin").type = "password" const sections = ["loading", "fatal", "form", "working", "done"] let pollTimer = 0 +/** Status polls in a row that got no answer from the agent. */ +let unanswered = 0 let signing = false /** Every document of the link, in order: { ticket, job } or { ticket, error }. */ let docs = [] @@ -55,12 +57,19 @@ function notify(status, detail) { } async function api(path, body) { - const res = await fetch(path, { - method: body ? "POST" : "GET", - headers: { "X-PN-eSign": "1", ...(body ? { "Content-Type": "application/json" } : {}) }, - body: body ? JSON.stringify(body) : undefined, - cache: "no-store", - }) + let res + try { + res = await fetch(path, { + method: body ? "POST" : "GET", + headers: { "X-PN-eSign": "1", ...(body ? { "Content-Type": "application/json" } : {}) }, + body: body ? JSON.stringify(body) : undefined, + cache: "no-store", + }) + } catch { + throw Object.assign(new Error("PoliNetwork eSign stopped answering. Start it again, then press Sign."), { + code: "agent_unreachable", + }) + } const data = await res.json().catch(() => ({})) if (!res.ok) throw Object.assign(new Error(data.error?.message || res.statusText), data.error || {}) return data @@ -240,26 +249,32 @@ function stopPolling() { async function pollStatus() { if (signing) return + const card = $("card") let status try { status = await api("/api/status") - } catch { + } catch (error) { + if (error.code === "agent_unreachable" && ++unanswered >= 3) { + card.dataset.state = "waiting" + $("card-text").textContent = "PoliNetwork eSign stopped answering. Start it again." + } return } + unanswered = 0 if (status.busy) return setRemembered(status.remembered) - const card = $("card") const usable = status.providers.filter((p) => p.available) - const readers = [...new Set(usable.flatMap((p) => p.readers || []))] + // Readers of usable providers first: they are the ones named. + const readers = [...new Set([...usable, ...status.providers].flatMap((p) => p.readers || []))] if (status.ready) { card.dataset.state = "ready" $("card-text").textContent = readers.length ? `Card detected on ${readers[0]}` : "Card detected" - } else if (usable.length && readers.length) { + } else if (!readers.length) { card.dataset.state = "waiting" - $("card-text").textContent = `Place your CIE on the reader (${readers[0]})` + $("card-text").textContent = "Connect the NFC card reader" } else if (usable.length) { card.dataset.state = "waiting" - $("card-text").textContent = "Connect the NFC card reader" + $("card-text").textContent = `Place your CIE on the reader (${readers[0]})` } else { card.dataset.state = "waiting" $("card-text").textContent = "No CIE software found. See the setup guide in the dashboard." diff --git a/internal/token/token.go b/internal/token/token.go index b6b0d1b..d820192 100644 --- a/internal/token/token.go +++ b/internal/token/token.go @@ -8,6 +8,7 @@ import ( "crypto/x509" "errors" "fmt" + "runtime/debug" ) // Provider is one way to reach a signing key: a PKCS#11 module, the card over PC/SC, or a software key. @@ -21,6 +22,18 @@ type Provider interface { Open(ctx context.Context, pin string) (Session, error) } +// SafeStatus is p.Status, except that a panic in it gives an unavailable status and an error with the stack, so one +// broken provider does not take the others down. +func SafeStatus(ctx context.Context, p Provider) (st Status, err error) { + defer func() { + if v := recover(); v != nil { + st = Status{Provider: p.Name(), Detail: "internal error"} + err = fmt.Errorf("status panicked: %v\n%s", v, debug.Stack()) + } + }() + return p.Status(ctx), nil +} + // Session is an authenticated card session. type Session interface { // Certificate is the signer's certificate. diff --git a/internal/token/token_test.go b/internal/token/token_test.go index ea939c4..3d93875 100644 --- a/internal/token/token_test.go +++ b/internal/token/token_test.go @@ -1,7 +1,9 @@ package token import ( + "context" "errors" + "strings" "testing" ) @@ -22,3 +24,16 @@ func TestAfterPIN(t *testing.T) { t.Fatal("unmarked error reported as sent") } } + +type panicking struct{} + +func (panicking) Name() string { return "broken" } +func (panicking) Status(context.Context) Status { panic("index out of range") } +func (panicking) Open(context.Context, string) (Session, error) { return nil, nil } + +func TestSafeStatus(t *testing.T) { + st, err := SafeStatus(t.Context(), panicking{}) + if st.Available || st.Provider != "broken" || err == nil || !strings.Contains(err.Error(), "index out of range") { + t.Fatalf("got %+v, %v", st, err) + } +} From 9609eb1c53ba703a8a4dc9d14269352fc75ea8a6 Mon Sep 17 00:00:00 2001 From: Lorenzo Corallo Date: Wed, 21 Oct 2026 03:31:37 +0200 Subject: [PATCH 04/15] feat(sign): show whether this computer is ready to sign The agent's home page said "The agent is running" even without a reader or usable CIE software. It now checks the reader, the CIE and the signing software every 1.5 seconds while visible, says "Ready to sign" when all are fine, and shows the version. The built-in provider no longer counts as missing software when only the reader is missing; the signing page reads the status the same way. --- README.md | 6 ++- internal/server/web/assets/index.js | 67 ++++++++++++++++++++++++++++ internal/server/web/assets/sign.js | 22 +++------ internal/server/web/assets/status.js | 40 +++++++++++++++++ internal/server/web/assets/style.css | 22 +++++++++ internal/server/web/index.html | 22 ++++++++- internal/server/web/sign.html | 1 + internal/token/cie/cie.go | 9 ++-- internal/token/cie/cie_test.go | 13 +++++- 9 files changed, 175 insertions(+), 27 deletions(-) create mode 100644 internal/server/web/assets/index.js create mode 100644 internal/server/web/assets/status.js diff --git a/README.md b/README.md index eb9dadb..b36f76f 100644 --- a/README.md +++ b/README.md @@ -63,7 +63,8 @@ link handler, Debian package) and keeps its configuration and card pairings. **System Settings > Privacy & Security**, choose **Open Anyway** next to the message about PoliNetwork eSign, and confirm. 3. The app copies itself to `~/Applications`, starts at login (it appears in **System Settings > General > Login - Items**) and opens `http://127.0.0.1:47321/` in your browser: "The agent is running". + Items**) and opens `http://127.0.0.1:47321/` in your browser. The page shows whether the reader, the CIE and the + signing software are ready: with the CIE on the reader it says "Ready to sign". The official Software CIE module is built for Intel Macs only. On Apple Silicon the agent runs it in a helper process under Rosetta: if macOS offers to install Rosetta, accept (or run `softwareupdate --install-rosetta`). The first time you @@ -75,7 +76,8 @@ card pairing that Software CIE keeps in its own folder. 1. Download `PoliNetwork-eSign--windows-amd64.zip`, extract it and double-click `esign.exe`. 2. The program is not code-signed yet: if SmartScreen warns, choose **More info > Run anyway**. 3. It copies itself to `%LOCALAPPDATA%\PoliNetwork\eSign`, starts at login, appears in **Settings > Apps > - Installed apps**, and opens `http://127.0.0.1:47321/` in your browser. + Installed apps**, and opens `http://127.0.0.1:47321/` in your browser, which shows whether the reader, the CIE and + the signing software are ready. ### Linux diff --git a/internal/server/web/assets/index.js b/internal/server/web/assets/index.js new file mode 100644 index 0000000..e4d1c28 --- /dev/null +++ b/internal/server/web/assets/index.js @@ -0,0 +1,67 @@ +"use strict" + +// The home page shows whether this computer is ready to sign. It polls only while visible: a status call holds the +// card, and a signature arriving meanwhile is refused as busy. + +const $ = (id) => document.getElementById(id) +let timer = 0 +let inFlight = false + +/** Sets text only when it changes, so the live region announces changes and nothing else. */ +function setText(el, text) { + if (el.textContent !== text) el.textContent = text +} + +function setRow(id, row) { + const el = $(id) + el.hidden = !row + if (!row) return + el.dataset.state = row.ok ? "ready" : "waiting" + setText(el.querySelector(".status-text"), row.text) +} + +function setHeading(text, ready) { + setText($("heading"), text) + $("ready-icon").hidden = !ready +} + +function render(status) { + const { ready, reader, card, software } = describeStatus(status) + setRow("reader", reader) + setRow("card", card) + setRow("software", software) + setText($("software-detail"), software.detail || "") + $("software-detail").hidden = !software.detail + setHeading(ready ? "Ready to sign" : "PoliNetwork eSign is running", ready) + setText($("version"), `PoliNetwork eSign ${status.version}`) +} + +function renderStopped() { + setRow("reader", { ok: false, text: "Open it again." }) + setRow("card", undefined) + setRow("software", undefined) + $("software-detail").hidden = true + setHeading("PoliNetwork eSign stopped", false) +} + +async function poll() { + clearTimeout(timer) + inFlight = true + try { + const res = await fetch("/api/status", { headers: { "X-PN-eSign": "1" }, cache: "no-store" }) + const status = await res.json() + // While a signature runs the agent answers busy: the last state still holds. + if (res.ok && !status.busy) render(status) + } catch { + renderStopped() + } + inFlight = false + if (document.visibilityState === "visible") timer = setTimeout(poll, 1500) +} + +document.addEventListener("visibilitychange", () => { + clearTimeout(timer) + if (document.visibilityState === "visible" && !inFlight) poll() +}) + +if (document.visibilityState === "visible") poll() diff --git a/internal/server/web/assets/sign.js b/internal/server/web/assets/sign.js index 3be08db..326c804 100644 --- a/internal/server/web/assets/sign.js +++ b/internal/server/web/assets/sign.js @@ -263,22 +263,12 @@ async function pollStatus() { unanswered = 0 if (status.busy) return setRemembered(status.remembered) - const usable = status.providers.filter((p) => p.available) - // Readers of usable providers first: they are the ones named. - const readers = [...new Set([...usable, ...status.providers].flatMap((p) => p.readers || []))] - if (status.ready) { - card.dataset.state = "ready" - $("card-text").textContent = readers.length ? `Card detected on ${readers[0]}` : "Card detected" - } else if (!readers.length) { - card.dataset.state = "waiting" - $("card-text").textContent = "Connect the NFC card reader" - } else if (usable.length) { - card.dataset.state = "waiting" - $("card-text").textContent = `Place your CIE on the reader (${readers[0]})` - } else { - card.dataset.state = "waiting" - $("card-text").textContent = "No CIE software found. See the setup guide in the dashboard." - } + const { reader, software } = describeStatus(status) + card.dataset.state = status.ready ? "ready" : "waiting" + if (status.ready) $("card-text").textContent = reader?.name ? `Card detected on ${reader.name}` : "Card detected" + else if (!reader?.ok) $("card-text").textContent = "Connect the NFC card reader" + else if (software.ok) $("card-text").textContent = `Place your CIE on the reader (${reader.name})` + else $("card-text").textContent = "No CIE software found. See the setup guide in the dashboard." } function formError(message) { diff --git a/internal/server/web/assets/status.js b/internal/server/web/assets/status.js new file mode 100644 index 0000000..aec6dc9 --- /dev/null +++ b/internal/server/web/assets/status.js @@ -0,0 +1,40 @@ +"use strict" + +// What an /api/status answer means for the user, shared by the signing page and the home page so they never disagree. + +const SOFTWARE = { + "cie-mw": "Official CIE software", + opencie: "OpenCIE", + cie: "Built into PoliNetwork eSign", + soft: "Development key (signatures not valid)", +} + +/** + * Describes the reader, the card and the software that would sign, each { ok, text }. The reader also has its name + * ("" without one), and the software, when none is usable, every provider's detail for support. The development key + * needs no reader, so with it reader and card are left out; card is left out without a reader too. ready is true when + * everything described is ok. + */ +function describeStatus(status) { + const available = status.providers.filter((p) => p.available) + // The provider the agent opens first (Server.open): available, and not known to be without a card. + const signing = available.find((p) => p.cardPresent !== false) || available[0] + const software = signing + ? { ok: true, text: SOFTWARE[signing.provider] || signing.provider } + : { + ok: false, + text: "Signing software not available", + detail: status.providers.map((p) => `${p.provider}: ${p.detail || "not available"}`).join("\n"), + } + if (signing?.provider === "soft") return { ready: status.ready, software } + + // Readers of usable providers first: they are the ones named. + const [name = ""] = new Set([...available, ...status.providers].flatMap((p) => p.readers || [])) + const reader = name + ? { ok: true, text: `Card reader: ${name}`, name } + : { ok: false, text: "Connect the NFC card reader", name } + const card = name + ? { ok: status.ready, text: status.ready ? "CIE detected" : "Place your CIE on the reader" } + : undefined + return { ready: reader.ok && Boolean(card?.ok) && software.ok, reader, card, software } +} diff --git a/internal/server/web/assets/style.css b/internal/server/web/assets/style.css index 888a6cd..3bab7b6 100644 --- a/internal/server/web/assets/style.css +++ b/internal/server/web/assets/style.css @@ -218,6 +218,28 @@ h1 { background: currentColor; } +.status-list { + display: grid; + gap: 8px; + margin-bottom: 18px; +} + +.status-list .card-status { + margin: 0; +} + +.status-list .detail { + margin: 0; + padding: 0 12px; + white-space: pre-line; + overflow-wrap: anywhere; +} + +.version { + min-height: 1.5em; + margin: 0; +} + label { display: block; margin-bottom: 6px; diff --git a/internal/server/web/index.html b/internal/server/web/index.html index 150517f..3708c7f 100644 --- a/internal/server/web/index.html +++ b/internal/server/web/index.html @@ -6,6 +6,8 @@ PoliNetwork eSign + +
@@ -14,9 +16,25 @@
- -

The agent is running

+ +

PoliNetwork eSign is running

+
+
+ + Looking for the card reader… +
+ + + +

To sign a document, open it in the PoliNetwork dashboard and choose “Sign with CIE”.

+

diff --git a/internal/server/web/sign.html b/internal/server/web/sign.html index 56801c3..d9ee3c8 100644 --- a/internal/server/web/sign.html +++ b/internal/server/web/sign.html @@ -7,6 +7,7 @@ Sign with CIE · PoliNetwork + diff --git a/internal/token/cie/cie.go b/internal/token/cie/cie.go index ad23ab2..72fe039 100644 --- a/internal/token/cie/cie.go +++ b/internal/token/cie/cie.go @@ -109,13 +109,12 @@ func (p *Provider) Status(context.Context) token.Status { notCIE = append(notCIE, r.Name) } } - if len(readers) == 0 { - st.Detail = "no smart-card reader" - return st - } + // Without a reader the provider still works once one is connected: only a PC/SC failure makes it unavailable. st.Available = true st.CardPresent = &present - if !present && len(notCIE) > 0 { + if len(readers) == 0 { + st.Detail = "no smart-card reader" + } else if !present && len(notCIE) > 0 { st.Detail = "the card on " + strings.Join(notCIE, ", ") + " is not a CIE" } return st diff --git a/internal/token/cie/cie_test.go b/internal/token/cie/cie_test.go index f9718fd..3d74ea9 100644 --- a/internal/token/cie/cie_test.go +++ b/internal/token/cie/cie_test.go @@ -140,10 +140,19 @@ func digest() []byte { return d[:] } -func TestStatus(t *testing.T) { - if st := cie.NewWithConnector(&connector{}).Status(t.Context()); st.Available || st.Detail != "no smart-card reader" { +// TestStatusNoReader: without a reader the provider is available with no card, and signing asks for the reader. +func TestStatusNoReader(t *testing.T) { + p := cie.NewWithConnector(&connector{}) + st := p.Status(t.Context()) + if !st.Available || st.CardPresent == nil || *st.CardPresent || len(st.Readers) != 0 || st.Detail != "no smart-card reader" { t.Fatalf("no readers: %+v", st) } + if _, err := p.Open(t.Context(), "12345678"); token.CodeOf(err) != token.CodeNoReader { + t.Fatalf("open without a reader: %v", err) + } +} + +func TestStatus(t *testing.T) { empty := &connector{readers: []*reader{{name: "ACS ACR1252"}}} st := cie.NewWithConnector(empty).Status(t.Context()) if !st.Available || st.CardPresent == nil || *st.CardPresent || len(st.Readers) != 1 { From 05a0dc401ac6dea0dcc9c8a77026e8987407dd94 Mon Sep 17 00:00:00 2001 From: Lorenzo Corallo Date: Wed, 21 Oct 2026 03:34:25 +0200 Subject: [PATCH 05/15] fix(sign): end the window on errors that retrying cannot fix A blocked PIN, a card that is not set up or not genuine, missing CIE software or no certificate now show the reason and Close instead of an empty PIN form inviting another attempt. Setup problems link the dashboard's setup guide, and the blocked PIN says where the PUK is. Messages that already say what to do no longer get "start again from the dashboard" added. --- internal/server/server.go | 2 +- internal/server/web/assets/sign.js | 33 ++++++++++++++++++++++++---- internal/server/web/assets/style.css | 12 +++++++++- internal/server/web/sign.html | 5 ++++- 4 files changed, 45 insertions(+), 7 deletions(-) diff --git a/internal/server/server.go b/internal/server/server.go index c142ff2..7d46eb1 100644 --- a/internal/server/server.go +++ b/internal/server/server.go @@ -645,7 +645,7 @@ var messageOf = map[token.Code]string{ token.CodeNotFound: "No signing certificate found on the card.", token.CodePinIncorrect: "Wrong PIN.", token.CodePinInvalid: "Type the 8 digits of your CIE PIN.", - token.CodePinLocked: "Your PIN is blocked. Unblock it with the PUK in the CIE ID app.", + token.CodePinLocked: "Your CIE PIN is blocked. Unblock it with your PUK in the CieID app on your phone or in CIE ID on this computer, then sign again from the dashboard. The PUK’s first half is on the receipt from the Comune, the second half in the letter that came with the card.", token.CodeBusy: "Another signature is in progress.", token.CodeDevice: "The card could not complete the operation. Try again.", } diff --git a/internal/server/web/assets/sign.js b/internal/server/web/assets/sign.js index 326c804..d161e59 100644 --- a/internal/server/web/assets/sign.js +++ b/internal/server/web/assets/sign.js @@ -75,9 +75,17 @@ async function api(path, body) { return data } -function fatal(message) { +const START_AGAIN = "Close this window and start again from the dashboard." + +/** Ends the page on message, followed by hint ("" for none) and, with setup, a link to the dashboard's setup guide. */ +function fatal(message, { hint = START_AGAIN, setup = false } = {}) { stopPolling() $("fatal-message").textContent = message + $("fatal-hint").textContent = hint + $("fatal-hint").hidden = !hint + // origin was accepted by the agent before any error that asks for the setup guide. + if (setup) $("fatal-setup").href = new URL("/dashboard/esign/setup", origin).href + $("fatal-setup-row").hidden = !setup show("fatal") } @@ -87,12 +95,26 @@ const JOB_ENDED = new Set([ "job_expired", "job_done", "job_cancelled", + "job_failed", "origin_forbidden", "signer_mismatch", ]) +/** Card errors that signing again from this window cannot fix. */ +const TERMINAL = new Set(["pin_locked", "card_untrusted", "middleware_missing", "not_paired", "certificate_not_found"]) +/** Errors that need this computer set up: the page links the setup guide. */ +const SETUP = new Set(["middleware_missing", "not_paired", "card_untrusted"]) +/** Errors whose message already says what to do next. */ +const EXPLAINED = new Set(["job_done", "job_cancelled", "document_changed", "job_failed", "pin_locked"]) /** Card errors after which the same documents can be signed again. */ const RETRYABLE = new Set(["card_removed", "device_error", "no_card", "no_reader"]) +/** Ends the page on an error that leaves nothing to sign here. */ +function fatalError(error) { + const setup = SETUP.has(error?.code) + const hint = setup || EXPLAINED.has(error?.code) ? "" : START_AGAIN + fatal(error?.message || "The signature failed.", { hint, setup }) +} + const plural = (n, one, many) => `${n} ${n === 1 ? one : many}` const pageList = new Intl.ListFormat("en-GB", { type: "conjunction" }) @@ -170,7 +192,7 @@ async function load() { for (const doc of docs) if (!doc.job) outcome.set(doc.ticket, { status: "failed", error: doc.error }) const ready = docs.filter((d) => d.job) if (!ready.length) { - if (docs.length === 1) return fatal(docs[0].error.message) + if (docs.length === 1) return fatalError(docs[0].error) fillList($("fatal-docs"), docs, describeDoc) return fatal("None of these documents can be signed.") } @@ -296,7 +318,7 @@ function showResults() { if (!all) { // One document: a card error goes back to the PIN, anything else ends here, as before. if (again.length) return showForm(docs, [docs[0].ticket], result.error.message) - return fatal(result.error?.message || "The signature failed.") + return fatalError(result.error) } } @@ -344,7 +366,10 @@ $("form").addEventListener("submit", async (event) => { result = await api("/api/sign", body) } catch (error) { signing = false - if (JOB_ENDED.has(error.code)) return fatal(error.message) + if (JOB_ENDED.has(error.code) || TERMINAL.has(error.code)) { + notify("error", error.code) + return fatalError(error) + } // After a failure the agent no longer remembers the PIN, unless the card was busy and was never tried. if (error.code !== "busy") remembered = null show("form") diff --git a/internal/server/web/assets/style.css b/internal/server/web/assets/style.css index 3bab7b6..fc628df 100644 --- a/internal/server/web/assets/style.css +++ b/internal/server/web/assets/style.css @@ -269,7 +269,8 @@ input.masked { } input:focus-visible, -button:focus-visible { +button:focus-visible, +a:focus-visible { outline: 2px solid var(--focus); outline-offset: 2px; } @@ -323,6 +324,15 @@ button.link:hover { color: var(--accent-hover); } +a { + color: var(--accent); + font-weight: 600; +} + +a:hover { + color: var(--accent-hover); +} + .error { margin: 14px 0 0; padding: 10px 12px; diff --git a/internal/server/web/sign.html b/internal/server/web/sign.html index d9ee3c8..d97b601 100644 --- a/internal/server/web/sign.html +++ b/internal/server/web/sign.html @@ -25,7 +25,10 @@

Cannot sign

-

Close this window and start again from the dashboard.

+

+ From 1248b93d0c0ee54849876162f3528a3a1616b956 Mon Sep 17 00:00:00 2001 From: Lorenzo Corallo Date: Wed, 21 Oct 2026 03:35:48 +0200 Subject: [PATCH 06/15] fix(sign): show an expired link before the PIN is typed The PIN form now switches to "This signing link expired." by itself when the first job expires, also when the window comes back to the front after its timers slowed down. The expiry reads the same however it is noticed, and messages from the agent no longer repeat "start again from the dashboard". --- internal/server/server.go | 4 ++-- internal/server/web/assets/sign.js | 32 ++++++++++++++++++++++++++---- 2 files changed, 30 insertions(+), 6 deletions(-) diff --git a/internal/server/server.go b/internal/server/server.go index 7d46eb1..e6f3236 100644 --- a/internal/server/server.go +++ b/internal/server/server.go @@ -300,11 +300,11 @@ func (s *Server) decode(w http.ResponseWriter, r *http.Request) (*jobRequest, Ba valid = valid && backend.TicketPattern.MatchString(t) } if !valid { - writeError(w, http.StatusBadRequest, "ticket_invalid", "This signing link is not valid. Start again from the dashboard.") + writeError(w, http.StatusBadRequest, "ticket_invalid", "This signing link is not valid.") return nil, nil, false } if len(req.Tickets) > MaxTickets { - writeError(w, http.StatusBadRequest, "too_many_tickets", "Sign at most "+strconv.Itoa(MaxTickets)+" documents at a time. Start again from the dashboard.") + writeError(w, http.StatusBadRequest, "too_many_tickets", "Sign at most "+strconv.Itoa(MaxTickets)+" documents at a time.") return nil, nil, false } if !OriginAllowed(s.opts.AllowedOrigins, req.Origin) { diff --git a/internal/server/web/assets/sign.js b/internal/server/web/assets/sign.js index d161e59..6d16e97 100644 --- a/internal/server/web/assets/sign.js +++ b/internal/server/web/assets/sign.js @@ -26,6 +26,8 @@ let signing = false let docs = [] /** Tickets the PIN form signs. */ let pending = [] +/** When the first of the pending jobs expires (ms since the epoch), Infinity when unknown. */ +let deadline = Infinity /** What happened to each ticket: { status, error }. */ const outcome = new Map() let signer = "" @@ -108,11 +110,14 @@ const EXPLAINED = new Set(["job_done", "job_cancelled", "document_changed", "job /** Card errors after which the same documents can be signed again. */ const RETRYABLE = new Set(["card_removed", "device_error", "no_card", "no_reader"]) +/** The page's own words for an expired link, the same however the expiry was noticed. */ +const errorText = (error) => (error?.code === "job_expired" ? "This signing link expired." : error?.message) + /** Ends the page on an error that leaves nothing to sign here. */ function fatalError(error) { const setup = SETUP.has(error?.code) const hint = setup || EXPLAINED.has(error?.code) ? "" : START_AGAIN - fatal(error?.message || "The signature failed.", { hint, setup }) + fatal(errorText(error) || "The signature failed.", { hint, setup }) } const plural = (n, one, many) => `${n} ${n === 1 ? one : many}` @@ -155,7 +160,7 @@ function describeDoc(li, doc) { li.append(title) if (!doc.job) { li.classList.add("failed") - line(li, doc.error.message, "bad") + line(li, errorText(doc.error), "bad") return } const by = [doc.job.requestedBy && `Requested by ${doc.job.requestedBy}`, doc.job.signer.role].filter(Boolean) @@ -175,7 +180,7 @@ function describeResult(li, doc) { text.append(title) if (result.status === "completed") line(text, "Signed", "ok") else if (result.status === "skipped") line(text, "Not signed", "bad") - else line(text, result.error?.message || "Not signed", "bad") + else line(text, errorText(result.error) || "Not signed", "bad") li.classList.add("result") li.append(mark, text) } @@ -202,6 +207,9 @@ async function load() { /** Shows the PIN form for the tickets to sign, listing the given documents. */ function showForm(listed, toSign, message = "") { pending = toSign + // Go sends a missing time as year 1. + const expiries = docs.filter((d) => pending.includes(d.ticket)).map((d) => Date.parse(d.job.expiresAt)) + deadline = Math.min(...expiries.filter((t) => t > 0)) const several = docs.length > 1 const n = pending.length $("facts").hidden = several @@ -269,8 +277,19 @@ function stopPolling() { clearInterval(pollTimer) } +/** + * Shows the expiry screen once the PIN form outlives a pending job, without cancelling the jobs: the dashboard then + * says they expired. A signature already running is left to finish, and the backend decides. + */ +function checkExpiry() { + if (signing || $("form").hidden || Date.now() < deadline) return false + notify("error", "job_expired") + fatalError({ code: "job_expired" }) + return true +} + async function pollStatus() { - if (signing) return + if (signing || checkExpiry()) return const card = $("card") let status try { @@ -425,4 +444,9 @@ $("retry").addEventListener("click", () => { for (const button of document.querySelectorAll("[data-close]")) button.addEventListener("click", () => window.close()) +// Timers slow down in a covered window or a sleeping laptop: check again as soon as the window shows. +document.addEventListener("visibilitychange", () => { + if (document.visibilityState === "visible") checkExpiry() +}) + load() From 23d3909d8029957ede9eebd176ca57c2b67beec8 Mon Sep 17 00:00:00 2001 From: Lorenzo Corallo Date: Wed, 21 Oct 2026 03:37:13 +0200 Subject: [PATCH 07/15] fix(sign): say where the 8 PIN digits come from Typing only the last 4 digits, as other CIE apps ask, now explains that all 8 are needed instead of a browser bubble that blocked the page's own messages. The hint says the halves are on the Comune's receipt and in the card's letter, and user-facing messages say PoliNetwork eSign instead of "the agent". --- README.md | 4 ++-- internal/server/server.go | 6 +++--- internal/server/web/assets/sign.js | 8 +++++--- internal/server/web/sign.html | 7 +++++-- 4 files changed, 15 insertions(+), 10 deletions(-) diff --git a/README.md b/README.md index b36f76f..ad2fc18 100644 --- a/README.md +++ b/README.md @@ -140,8 +140,8 @@ The official CIE ID middleware for Linux is published for x86_64 only (`.deb`, ` ## Using it In the dashboard, open a document and choose **Sign with CIE**. A small window from the agent asks for your PIN; keep -the card on the reader until it says the document is signed. If the dashboard says the agent is not running, use its -**Start the agent** link (or open the app again). +the card on the reader until it says the document is signed. If the dashboard says PoliNetwork eSign is not running, +use its **Start PoliNetwork eSign** button (or open the app again). You type the PIN once even when there is more to sign: a document where you sign in several places (each place is a separate signature), or up to 10 documents that the dashboard sends together. The window lists them, signs them one diff --git a/internal/server/server.go b/internal/server/server.go index e6f3236..452cd25 100644 --- a/internal/server/server.go +++ b/internal/server/server.go @@ -171,7 +171,7 @@ func (s *Server) guard(next http.Handler) http.Handler { h.Set("Content-Security-Policy", "default-src 'none'; script-src 'self'; style-src 'self'; img-src 'self' data:; connect-src 'self'; form-action 'none'; frame-ancestors 'none'; base-uri 'none'") // Blocks DNS rebinding: a foreign host name resolving to 127.0.0.1 is refused. if !s.hostAllowed(r.Host) { - http.Error(w, "Address the agent as 127.0.0.1 or localhost", http.StatusMisdirectedRequest) + http.Error(w, "Address PoliNetwork eSign as 127.0.0.1 or localhost", http.StatusMisdirectedRequest) return } next.ServeHTTP(w, r) @@ -309,7 +309,7 @@ func (s *Server) decode(w http.ResponseWriter, r *http.Request) (*jobRequest, Ba } if !OriginAllowed(s.opts.AllowedOrigins, req.Origin) { s.opts.Logger.Warn("refused dashboard origin", "origin", req.Origin) - writeError(w, http.StatusForbidden, "origin_forbidden", "This agent does not sign for "+req.Origin+".") + writeError(w, http.StatusForbidden, "origin_forbidden", "PoliNetwork eSign does not sign for "+req.Origin+".") return nil, nil, false } return &req, s.opts.Backend(req.Origin), true @@ -377,7 +377,7 @@ func (s *Server) cancel(w http.ResponseWriter, r *http.Request) { var wg sync.WaitGroup for _, t := range req.Tickets { wg.Go(func() { - if err := be.Fail(ctx, t, "cancelled", "Cancelled in the agent"); err != nil { + if err := be.Fail(ctx, t, "cancelled", "Cancelled in PoliNetwork eSign"); err != nil { s.opts.Logger.Warn("cancel job", "error", err) } }) diff --git a/internal/server/web/assets/sign.js b/internal/server/web/assets/sign.js index 6d16e97..aa6bdc3 100644 --- a/internal/server/web/assets/sign.js +++ b/internal/server/web/assets/sign.js @@ -244,8 +244,6 @@ function showPIN() { const known = Boolean(remembered) $("remembered").hidden = !known $("pin-fields").hidden = known - // A disabled field is left out of the form's validation. - $("pin").disabled = known if (known) { const until = timeFormat.format(new Date(remembered.expires)) $("remembered-text").textContent = @@ -367,7 +365,11 @@ $("form").addEventListener("submit", async (event) => { if (!remembered) { const pin = $("pin").value.trim() if (!/^\d{8}$/.test(pin)) { - formError("Type the 8 digits of your CIE PIN.") + formError( + /^\d{4}$/.test(pin) + ? "Type all 8 digits of your CIE PIN. Other CIE apps ask only for the last 4." + : "Type the 8 digits of your CIE PIN.", + ) $("pin").focus() return } diff --git a/internal/server/web/sign.html b/internal/server/web/sign.html index d97b601..3097e05 100644 --- a/internal/server/web/sign.html +++ b/internal/server/web/sign.html @@ -32,7 +32,7 @@

Cannot sign

-