diff --git a/README.md b/README.md index dbe2276..ad2fc18 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 @@ -138,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 @@ -296,6 +298,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. 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.go b/internal/server/remember.go index 87b57d2..3894d6a 100644 --- a/internal/server/remember.go +++ b/internal/server/remember.go @@ -160,9 +160,12 @@ func (s *Server) watch(m *remembered) { ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) gone := cardGone(s.statuses(ctx).Providers) cancel() - s.card.Unlock() if gone { + // Before the card is free: a signature waiting for it then finds no PIN to send to a card placed later. s.forget(m, "card removed") + } + s.card.Unlock() + if gone { return } } diff --git a/internal/server/remember_test.go b/internal/server/remember_test.go index 9aa71b9..2f5aa96 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) @@ -240,7 +240,10 @@ func TestRememberedPINOtherCard(t *testing.T) { func TestRememberedPINError(t *testing.T) { s, _, r := rememberServer(t, time.Minute) cookie := rememberPIN(t, s) - s.card.Lock() // keeps the watcher from noticing the removal first + // Holding the card keeps the watcher from noticing the removal first. + if !s.card.LockWithin(context.Background(), time.Second) { + t.Fatal("the card stayed busy") + } r.present.Store(false) s.card.Unlock() rec := signWith(t, s, jobRequest{}, cookie) diff --git a/internal/server/server.go b/internal/server/server.go index ef28307..a233c84 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" @@ -52,6 +53,9 @@ type Options struct { RememberFor time.Duration // CardWatch is how often the card is checked while a PIN is remembered; 2 seconds when zero. CardWatch time.Duration + // CardWait is how long a signature waits for the card while a status check or the card watcher holds it; + // 3 seconds when zero. + CardWait time.Duration } // Backend is the part of the dashboard's API the agent uses. @@ -66,7 +70,7 @@ type Backend interface { type Server struct { opts Options // card serializes signatures: one card, one operation at a time. - card sync.Mutex + card cardLock static http.Handler // memMu guards mem, the PIN the user chose to remember (see remember.go). Lock it after card, never before. @@ -89,8 +93,42 @@ func New(opts Options) *Server { if opts.CardWatch <= 0 { opts.CardWatch = defaultCardWatch } + if opts.CardWait <= 0 { + opts.CardWait = defaultCardWait + } sub, _ := fs.Sub(webFS, "web") - return &Server{opts: opts, static: http.FileServerFS(sub)} + return &Server{opts: opts, card: make(cardLock, 1), static: http.FileServerFS(sub)} +} + +// defaultCardWait outlasts a status check, which the status pages make every 1.5 seconds. +const defaultCardWait = 3 * time.Second + +// cardLock is a mutex that a signature can wait for with a deadline. +type cardLock chan struct{} + +func (l cardLock) Unlock() { <-l } + +func (l cardLock) TryLock() bool { + select { + case l <- struct{}{}: + return true + default: + return false + } +} + +// LockWithin waits for the lock until d passes or ctx ends, and reports whether it got it. +func (l cardLock) LockWithin(ctx context.Context, d time.Duration) bool { + timer := time.NewTimer(d) + defer timer.Stop() + select { + case l <- struct{}{}: + return true + case <-timer.C: + return false + case <-ctx.Done(): + return false + } } // Handler returns the HTTP handler. @@ -170,7 +208,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) @@ -209,6 +247,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 +271,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 @@ -237,7 +293,8 @@ func (s *Server) statuses(ctx context.Context) statusResponse { } func (s *Server) status(w http.ResponseWriter, r *http.Request) { - // While a signature runs the card is busy: report it rather than queueing behind it. + // While a signature runs the card is busy: report it rather than queueing behind it. A signature arriving + // meanwhile waits for this check (Options.CardWait). if !s.card.TryLock() { writeJSON(w, http.StatusOK, map[string]any{"busy": true}) return @@ -281,16 +338,16 @@ 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) { 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 @@ -358,7 +415,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) } }) @@ -378,11 +435,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 +459,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") + 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, firstErr + 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). @@ -424,22 +490,25 @@ func (s *Server) sign(w http.ResponseWriter, r *http.Request) { if !ok { return } + if req.PIN != "" && !token.ValidPIN(req.PIN, 8) { + writeTokenError(w, token.Errorf(token.CodePinInvalid, "malformed PIN")) + return + } + // A status check or the card watcher holds the card only briefly: wait for it rather than refusing the PIN. + if !s.card.LockWithin(r.Context(), s.opts.CardWait) { + writeError(w, http.StatusConflict, string(token.CodeBusy), "Another signature is in progress.") + return + } + defer s.card.Unlock() var mem *remembered if req.PIN == "" { + // Recalled with the card held, after any check of the watcher that found the card gone. if mem, req.PIN = s.recall(r); mem == nil { clearRememberCookie(w) writeError(w, http.StatusUnauthorized, codePINRequired, "Type your CIE PIN.") return } - } else if !token.ValidPIN(req.PIN, 8) { - writeTokenError(w, token.Errorf(token.CodePinInvalid, "malformed PIN")) - return } - if !s.card.TryLock() { - writeError(w, http.StatusConflict, string(token.CodeBusy), "Another signature is in progress.") - return - } - defer s.card.Unlock() // The jobs say how many signatures to expect. One that cannot be fetched (it ended meanwhile) is not signed. fetchCtx, cancelFetch := context.WithTimeout(r.Context(), 20*time.Second) @@ -617,7 +686,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/server_test.go b/internal/server/server_test.go index 7d52f56..0d030a7 100644 --- a/internal/server/server_test.go +++ b/internal/server/server_test.go @@ -556,3 +556,171 @@ 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) + } +} + +// heldStatus is a software card whose first status check holds the card until released, as a slow reader's can. +type heldStatus struct { + *counted + once sync.Once + holding chan struct{} + release chan struct{} +} + +func (h *heldStatus) Status(ctx context.Context) token.Status { + h.once.Do(func() { + close(h.holding) + <-h.release + }) + return h.counted.Status(ctx) +} + +// TestSignWaitsForStatus: a signature arriving while a status check (the status pages poll) holds the card waits for +// it instead of answering busy, which would make the user type the PIN again. +func TestSignWaitsForStatus(t *testing.T) { + card := &heldStatus{counted: softCard(t), holding: make(chan struct{}), release: make(chan struct{})} + s := newServer(&fakeBackend{}, card) + status := make(chan *httptest.ResponseRecorder, 1) + go func() { status <- do(t, s, req{method: "GET", path: "/api/status"}) }() + <-card.holding + signed := make(chan *httptest.ResponseRecorder, 1) + go func() { + rec, _ := signRequest(t, s, "12345678", ticket) + signed <- rec + }() + select { + case rec := <-signed: + t.Fatalf("sign answered while the status check held the card: %d %s", rec.Code, rec.Body) + case <-time.After(100 * time.Millisecond): + } + close(card.release) + + if rec := <-status; rec.Code != http.StatusOK || strings.Contains(rec.Body.String(), `"busy"`) { + t.Errorf("status: %d %s", rec.Code, rec.Body) + } + rec := <-signed + var res signResponse + _ = json.Unmarshal(rec.Body.Bytes(), &res) + if rec.Code != http.StatusOK || !slices.Equal(statuses(res), []string{"completed"}) || card.opened != 1 { + t.Fatalf("sign: %d %s, opened %d times", rec.Code, rec.Body, card.opened) + } +} + +// TestBusyCard: while a signature holds the card, a status check answers busy at once, and another signature after +// Options.CardWait without trying the card. +func TestBusyCard(t *testing.T) { + fb := &fakeBackend{} + card := softCard(t) + s := New(Options{ + Port: 47321, + AllowedOrigins: []string{dashboard}, + Providers: []token.Provider{card}, + Version: "test", + Backend: func(string) Backend { return fb }, + CardWait: 20 * time.Millisecond, + }) + if !s.card.TryLock() { + t.Fatal("the card is busy") + } + defer s.card.Unlock() + if rec := do(t, s, req{method: "GET", path: "/api/status"}); rec.Code != http.StatusOK || strings.TrimSpace(rec.Body.String()) != `{"busy":true}` { + t.Errorf("status: %d %s", rec.Code, rec.Body) + } + rec, _ := signRequest(t, s, "12345678", ticket) + if rec.Code != http.StatusConflict || errorCode(rec) != "busy" || card.opened != 0 || len(fb.failed) != 0 { + t.Fatalf("sign: %d %s, opened %d times, failed %v", rec.Code, rec.Body, card.opened, fb.failed) + } +} + +// 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/index.js b/internal/server/web/assets/index.js new file mode 100644 index 0000000..7965a47 --- /dev/null +++ b/internal/server/web/assets/index.js @@ -0,0 +1,69 @@ +"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 waits for it. + +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}`) +} + +/** Shows heading and text in place of the status rows. */ +function renderProblem(heading, text) { + setRow("reader", { ok: false, text }) + setRow("card", undefined) + setRow("software", undefined) + $("software-detail").hidden = true + setHeading(heading, 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().catch(() => ({})) + // While a signature runs the agent answers busy: the last state still holds. A failed check holds nothing. + if (!res.ok) renderProblem("PoliNetwork eSign is running", status.error?.message || "Cannot check the card reader.") + else if (!status.busy) render(status) + } catch { + renderProblem("PoliNetwork eSign stopped", "Open it again.") + } + 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 cf6f117..d480a8d 100644 --- a/internal/server/web/assets/sign.js +++ b/internal/server/web/assets/sign.js @@ -19,11 +19,19 @@ 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 = [] /** Tickets the PIN form signs. */ let pending = [] +/** Documents the PIN form lists. */ +let listed = [] +/** When the first of the pending jobs expires by this computer's clock (ms since the epoch), Infinity when unknown. */ +let deadline = Infinity +/** Whether the agent is asking the backend if the pending jobs expired. */ +let checkingExpiry = false /** What happened to each ticket: { status, error }. */ const outcome = new Map() let signer = "" @@ -55,21 +63,38 @@ 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 } -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") + // The control that had the focus is gone: move it to the heading, as the done screen does, so it is announced. + $("fatal-title").focus() } const JOB_ENDED = new Set([ @@ -78,11 +103,28 @@ const JOB_ENDED = new Set([ "job_expired", "job_done", "job_cancelled", + "job_failed", "origin_forbidden", "signer_mismatch", ]) -/** Card errors after which the same documents can be signed again. */ -const RETRYABLE = new Set(["card_removed", "device_error", "no_card", "no_reader"]) +/** 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"]) +/** Errors after which the same documents can be signed again: card errors, and a backend that did not answer. */ +const RETRYABLE = new Set(["card_removed", "device_error", "no_card", "no_reader", "backend_unreachable"]) + +/** 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(errorText(error) || "The signature failed.", { hint, setup }) +} const plural = (n, one, many) => `${n} ${n === 1 ? one : many}` const pageList = new Intl.ListFormat("en-GB", { type: "conjunction" }) @@ -122,9 +164,11 @@ function describeDoc(li, doc) { const title = document.createElement("strong") title.textContent = titleOf(doc) li.append(title) - if (!doc.job) { + // A document that could not be loaded, or whose link expired while the form was open. + const error = outcome.get(doc.ticket)?.error + if (!doc.job || error?.code === "job_expired") { li.classList.add("failed") - line(li, doc.error.message, "bad") + line(li, errorText(error), "bad") return } const by = [doc.job.requestedBy && `Requested by ${doc.job.requestedBy}`, doc.job.signer.role].filter(Boolean) @@ -144,7 +188,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) } @@ -161,7 +205,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.") } @@ -169,8 +213,23 @@ async function load() { } /** Shows the PIN form for the tickets to sign, listing the given documents. */ -function showForm(listed, toSign, message = "") { +function showForm(list, toSign, message = "") { + setPending(list, toSign) + $("pin").value = "" + $("remember").checked = false + formError(message) + show("form") + showPIN() + startPolling() +} + +/** Sets the tickets that the PIN form signs and the documents it lists, and describes them; the PIN stays. */ +function setPending(list, toSign) { pending = toSign + listed = list + // 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 @@ -190,12 +249,6 @@ function showForm(listed, toSign, message = "") { document.title = `Sign “${job.document.title}” · PoliNetwork` } $("submit").textContent = n === 1 ? "Sign" : `Sign ${n} documents` - $("pin").value = "" - $("remember").checked = false - formError(message) - show("form") - showPIN() - startPolling() } const timeFormat = new Intl.DateTimeFormat(undefined, { hour: "2-digit", minute: "2-digit" }) @@ -205,8 +258,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 = @@ -238,32 +289,67 @@ function stopPolling() { clearInterval(pollTimer) } +/** How long the PIN form stays before asking the backend again whether the pending jobs expired. */ +const EXPIRY_RECHECK = 30_000 + +/** + * Once this computer's clock says a pending job expired, asks the backend, which decides: a clock running fast must + * not end the window early. A job the backend confirms expired leaves the form, which keeps the others, and shows as + * failed; the expiry screen comes only when none is left. The jobs are not cancelled (the dashboard then says they + * expired). A signature already running is left to finish. + */ +async function checkExpiry() { + if (signing || checkingExpiry || $("form").hidden || Date.now() < deadline) return + checkingExpiry = true + let jobs = [] + try { + ;({ jobs = [] } = await api("/api/job", { tickets: pending, origin })) + } catch { + // Neither the agent nor the backend says the jobs expired: ask again later. + } + checkingExpiry = false + if (signing || $("form").hidden) return + const expired = jobs.filter((d) => pending.includes(d.ticket) && d.error?.code === "job_expired").map((d) => d.ticket) + const left = pending.filter((t) => !expired.includes(t)) + if (!left.length) { + notify("error", "job_expired") + fatalError({ code: "job_expired" }) + return + } + if (expired.length) { + for (const ticket of expired) outcome.set(ticket, { status: "failed", error: { code: "job_expired" } }) + setPending(listed, left) + const what = expired.length === 1 ? "One signing link" : `${expired.length} signing links` + formError(`${what} expired. You can still sign the rest.`) + } + // The backend says the jobs left are valid, whatever this computer's clock says: ask again later. + if (!(deadline > Date.now())) deadline = Date.now() + EXPIRY_RECHECK +} + async function pollStatus() { if (signing) return + checkExpiry() + 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 || []))] - 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) { - card.dataset.state = "waiting" - $("card-text").textContent = `Place your CIE on the reader (${readers[0]})` - } else if (usable.length) { - card.dataset.state = "waiting" - $("card-text").textContent = "Connect the NFC card reader" - } 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" + // Unavailable software lists no readers, so it comes first. + else if (!software.ok) $("card-text").textContent = "No CIE software found. See the setup guide in the dashboard." + else if (!reader?.ok) $("card-text").textContent = "Connect the NFC card reader" + else $("card-text").textContent = `Place your CIE on the reader (${reader.name})` } function formError(message) { @@ -289,9 +375,9 @@ function showResults() { if (docs.length === 1) { const result = outcome.get(docs[0].ticket) if (!all) { - // One document: a card error goes back to the PIN, anything else ends here, as before. + // One document: an error that retrying can fix 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) } } @@ -305,7 +391,9 @@ function showResults() { let message = signer ? `Signed as ${signer}.` : "" if (all) message += " You can close this window." - else if (again.length) message += " Keep the CIE still on the reader and try again." + else if (again.some((d) => outcome.get(d.ticket).error.code !== "backend_unreachable")) + message += " Keep the CIE still on the reader and try again." + else if (again.length) message += " Try again in a moment." else message += " Start again from the dashboard for the others." $("done-message").textContent = message.trim() $("retry").hidden = again.length === 0 @@ -321,7 +409,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 } @@ -339,7 +431,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") @@ -395,4 +490,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() 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..fc628df 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; @@ -247,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; } @@ -301,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/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..57edf59 100644 --- a/internal/server/web/sign.html +++ b/internal/server/web/sign.html @@ -7,6 +7,7 @@ Sign with CIE · PoliNetwork + @@ -20,15 +21,18 @@

Loading the document…

-