From 067c39c22f6f840d7220833f152ed48eedf96ea1 Mon Sep 17 00:00:00 2001 From: Lorenzo Corallo Date: Sat, 17 Oct 2026 19:42:13 +0200 Subject: [PATCH] feat(sign): optionally remember the PIN for 30 minutes The PIN form offers to remember the PIN on this computer for 30 minutes. The agent keeps it in memory only and forgets it when the time runs out (not extended by use), when the CIE leaves the reader, when a different card is on the reader, on any error opening the card, when the user picks "Use a different PIN" or types the PIN without ticking the box. Only the browser that typed it can use it, through a random key in an HttpOnly, SameSite=Strict cookie; every signature still needs a click on Sign. The page and the dashboard never see the PIN. Co-Authored-By: Claude Opus 5.5 --- README.md | 14 ++ internal/server/remember.go | 195 +++++++++++++++++++ internal/server/remember_test.go | 280 +++++++++++++++++++++++++++ internal/server/server.go | 73 ++++++- internal/server/server_test.go | 4 + internal/server/web/assets/sign.js | 76 +++++++- internal/server/web/assets/style.css | 43 ++++ internal/server/web/sign.html | 53 +++-- 8 files changed, 700 insertions(+), 38 deletions(-) create mode 100644 internal/server/remember.go create mode 100644 internal/server/remember_test.go diff --git a/README.md b/README.md index 7abeb1a..dbe2276 100644 --- a/README.md +++ b/README.md @@ -33,6 +33,20 @@ finalized by the PoliNetwork backend (PAdES), the agent only signs a digest with The PIN reaches at most one of them: once a provider has sent it to the card, a failure is reported, never retried elsewhere. +### Remembering the PIN + +When you sign, you can tick **Remember the PIN on this computer for 30 minutes**. For the next 30 minutes, signing +needs only a click on **Sign**: you don't type the PIN again. The agent keeps the PIN in memory only, never on disk, +and forgets it: + +- 30 minutes after you typed it, however often it was used; +- as soon as the CIE leaves the reader, so the PIN is never sent to a card placed later; +- when the card on the reader is not the one the PIN was typed for, or the card reports any error; +- when you choose **Use a different PIN**, type the PIN without ticking the box, or the agent stops. + +Only the browser where you typed the PIN can use it, through a random key in a cookie that the page's scripts cannot +read. The dashboard never sees the PIN. + ## Install Download the file for your system from the [latest release](https://github.com/PoliNetworkOrg/esign/releases/latest). diff --git a/internal/server/remember.go b/internal/server/remember.go new file mode 100644 index 0000000..87b57d2 --- /dev/null +++ b/internal/server/remember.go @@ -0,0 +1,195 @@ +package server + +import ( + "bytes" + "context" + "crypto/rand" + "crypto/subtle" + "encoding/base64" + "net/http" + "time" + + "github.com/PoliNetworkOrg/esign/internal/token" +) + +// A PIN the user chose to remember stays in the agent's memory only, never on disk, and only while all of these hold: +// - less than Options.RememberFor has passed since it was typed (the time is not extended by use); +// - the card stays on the reader: the agent checks every Options.CardWatch and forgets the PIN when no provider +// sees a card, so it is never sent to a card placed later; +// - the card that accepts it is the one it was typed for (same certificate); +// - no PIN error happens with it. +// +// It is usable only by the browser it was typed in, which holds a random key in an HttpOnly, SameSite=Strict +// cookie, and only through the agent's own page: every signature still needs the user to press Sign. + +// DefaultRememberFor is how long a remembered PIN lasts unless Options.RememberFor says otherwise. +const DefaultRememberFor = 30 * time.Minute + +const ( + rememberCookie = "pn_esign_pin" + defaultCardWatch = 2 * time.Second +) + +// remembered is a PIN kept for a while. Its fields do not change after it is created, except pin, which is wiped +// when it is forgotten. +type remembered struct { + pin []byte + key string // the browser's cookie value + cert []byte // the certificate of the card that accepted the PIN + signer string + expires time.Time + done chan struct{} +} + +// rememberedInfo tells the page that a PIN is remembered, never the PIN. +type rememberedInfo struct { + Signer string `json:"signer"` + Expires time.Time `json:"expires"` +} + +// recall returns the PIN remembered for the browser making r, and a copy of it taken before the watcher can wipe +// it; nil when there is none. +func (s *Server) recall(r *http.Request) (*remembered, string) { + c, err := r.Cookie(rememberCookie) + if err != nil || c.Value == "" { + return nil, "" + } + s.memMu.Lock() + defer s.memMu.Unlock() + m := s.mem + if m == nil || subtle.ConstantTimeCompare([]byte(c.Value), []byte(m.key)) != 1 || !time.Now().Before(m.expires) { + return nil, "" + } + return m, string(m.pin) +} + +// rememberedFor describes the remembered PIN of r's browser for the page, nil when there is none. +func (s *Server) rememberedFor(r *http.Request) *rememberedInfo { + m, _ := s.recall(r) + if m == nil { + return nil + } + return &rememberedInfo{Signer: m.signer, Expires: m.expires} +} + +// remember keeps pin for the browser w answers, in place of any PIN remembered before, and starts watching the card. +func (s *Server) remember(w http.ResponseWriter, pin string, cert []byte, signer string) { + key := make([]byte, 32) + if _, err := rand.Read(key); err != nil { + return + } + m := &remembered{ + pin: []byte(pin), + key: base64.RawURLEncoding.EncodeToString(key), + cert: bytes.Clone(cert), + signer: signer, + expires: time.Now().Add(s.opts.RememberFor), + done: make(chan struct{}), + } + s.memMu.Lock() + old := s.mem + s.mem = m + s.memMu.Unlock() + if old != nil { + s.drop(old) + } + http.SetCookie(w, &http.Cookie{ + Name: rememberCookie, + Value: m.key, + Path: "/api/", + MaxAge: int(s.opts.RememberFor / time.Second), + HttpOnly: true, + SameSite: http.SameSiteStrictMode, + }) + s.opts.Logger.Info("PIN remembered", "signer", signer, "until", m.expires.Format(time.TimeOnly)) + go s.watch(m) +} + +// forget drops m if it is still the remembered PIN. +func (s *Server) forget(m *remembered, reason string) { + s.memMu.Lock() + current := s.mem == m + if current { + s.mem = nil + } + s.memMu.Unlock() + if current { + s.drop(m) + s.opts.Logger.Info("PIN forgotten", "reason", reason) + } +} + +// forgetAll drops any remembered PIN. +func (s *Server) forgetAll(reason string) { + s.memMu.Lock() + m := s.mem + s.memMu.Unlock() + if m != nil { + s.forget(m, reason) + } +} + +// drop wipes m's PIN and stops its watcher. Only the goroutine that took m out of s.mem calls it. +func (s *Server) drop(m *remembered) { + clear(m.pin) + close(m.done) +} + +func clearRememberCookie(w http.ResponseWriter) { + http.SetCookie(w, &http.Cookie{Name: rememberCookie, Path: "/api/", MaxAge: -1, HttpOnly: true, SameSite: http.SameSiteStrictMode}) +} + +// watch forgets m when it expires or when the card leaves the reader. +func (s *Server) watch(m *remembered) { + expiry := time.NewTimer(time.Until(m.expires)) + defer expiry.Stop() + tick := time.NewTicker(s.opts.CardWatch) + defer tick.Stop() + for { + select { + case <-m.done: + return + case <-expiry.C: + s.forget(m, "expired") + return + case <-tick.C: + // While a signature runs the card is in use, and the signature itself fails without it. + if !s.card.TryLock() { + continue + } + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + gone := cardGone(s.statuses(ctx).Providers) + cancel() + s.card.Unlock() + if gone { + s.forget(m, "card removed") + return + } + } + } +} + +// cardGone reports whether the providers agree that no card is on a reader: none sees one, and at least one can +// tell, or no provider is usable at all (the reader was unplugged). +func cardGone(statuses []token.Status) bool { + usable, absent := false, false + for _, st := range statuses { + if !st.Available { + continue + } + usable = true + if st.CardPresent != nil { + if *st.CardPresent { + return false + } + absent = true + } + } + return absent || !usable +} + +func (s *Server) forgetPIN(w http.ResponseWriter, r *http.Request) { + s.forgetAll("forgotten by the user") + clearRememberCookie(w) + writeJSON(w, http.StatusOK, map[string]string{"status": "forgotten"}) +} diff --git a/internal/server/remember_test.go b/internal/server/remember_test.go new file mode 100644 index 0000000..9aa71b9 --- /dev/null +++ b/internal/server/remember_test.go @@ -0,0 +1,280 @@ +package server + +import ( + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "slices" + "strings" + "sync/atomic" + "testing" + "time" + + "github.com/PoliNetworkOrg/esign/internal/token" + "github.com/PoliNetworkOrg/esign/internal/token/soft" +) + +// reader is a software card that can be taken off the reader or swapped for another. +type reader struct { + card atomic.Pointer[soft.Provider] + present atomic.Bool + opened atomic.Int32 +} + +func newReader(t *testing.T) *reader { + t.Helper() + r := &reader{} + r.swap(t) + r.present.Store(true) + return r +} + +// swap places another card, with the same PIN. +func (r *reader) swap(t *testing.T) { + t.Helper() + p, err := soft.New("12345678") + if err != nil { + t.Fatal(err) + } + r.card.Store(p) +} + +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} +} +func (r *reader) Open(ctx context.Context, pin string) (token.Session, error) { + r.opened.Add(1) + if !r.present.Load() { + return nil, token.Errorf(token.CodeNoCard, "no card") + } + return r.card.Load().Open(ctx, pin) +} + +func rememberServer(t *testing.T, rememberFor time.Duration) (*Server, *fakeBackend, *reader) { + t.Helper() + fb := &fakeBackend{} + r := newReader(t) + s := New(Options{ + Port: 47321, + AllowedOrigins: []string{dashboard}, + Providers: []token.Provider{r}, + Version: "test", + Backend: func(string) Backend { return fb }, + RememberFor: rememberFor, + CardWatch: 5 * time.Millisecond, + }) + return s, fb, r +} + +func signWith(t *testing.T, s *Server, body jobRequest, cookie *http.Cookie) *httptest.ResponseRecorder { + t.Helper() + body.Origin = dashboard + if body.Tickets == nil { + body.Tickets = []string{ticket} + } + return do(t, s, req{method: "POST", path: "/api/sign", origin: self, body: body, cookie: cookie}) +} + +func errorCode(rec *httptest.ResponseRecorder) string { + var res struct { + Error struct{ Code string } `json:"error"` + } + _ = json.Unmarshal(rec.Body.Bytes(), &res) + return res.Error.Code +} + +func rememberCookieOf(t *testing.T, rec *httptest.ResponseRecorder) *http.Cookie { + t.Helper() + for _, c := range rec.Result().Cookies() { + if c.Name == rememberCookie { + return c + } + } + return nil +} + +// rememberPIN signs once with the PIN, asking to remember it, and returns the browser's cookie. +func rememberPIN(t *testing.T, s *Server) *http.Cookie { + t.Helper() + rec := signWith(t, s, jobRequest{PIN: "12345678", Remember: true}, nil) + c := rememberCookieOf(t, rec) + if rec.Code != http.StatusOK || c == nil || c.Value == "" { + t.Fatalf("remember: %d %v %s", rec.Code, rec.Result().Cookies(), rec.Body) + } + if !c.HttpOnly || c.SameSite != http.SameSiteStrictMode || c.Path != "/api/" { + t.Errorf("cookie attributes: %+v", c) + } + return c +} + +func waitForgotten(t *testing.T, s *Server) { + t.Helper() + deadline := time.Now().Add(2 * time.Second) + for time.Now().Before(deadline) { + s.memMu.Lock() + m := s.mem + s.memMu.Unlock() + if m == nil { + return + } + time.Sleep(time.Millisecond) + } + t.Fatal("the PIN was not forgotten") +} + +func TestRememberPIN(t *testing.T) { + s, fb, _ := rememberServer(t, time.Minute) + if rec := signWith(t, s, jobRequest{}, nil); rec.Code != http.StatusUnauthorized || errorCode(rec) != codePINRequired { + t.Fatalf("no PIN, nothing remembered: %d %s", rec.Code, rec.Body) + } + cookie := rememberPIN(t, s) + + status := do(t, s, req{method: "GET", path: "/api/status", cookie: cookie}) + var st statusResponse + _ = json.Unmarshal(status.Body.Bytes(), &st) + if st.Remembered == nil || st.Remembered.Signer != "Prova Sviluppo" || time.Until(st.Remembered.Expires) < 50*time.Second { + t.Errorf("status: %s", status.Body) + } + if strings.Contains(status.Body.String(), "12345678") { + t.Error("status leaks the PIN") + } + + rec := signWith(t, s, jobRequest{}, cookie) + var res signResponse + _ = json.Unmarshal(rec.Body.Bytes(), &res) + if rec.Code != http.StatusOK || !slices.Equal(statuses(res), []string{"completed"}) || len(fb.signatures) != 2 { + t.Fatalf("sign with the remembered PIN: %d %s", rec.Code, rec.Body) + } + + // Another browser, or a forged key, gets nothing. + for _, c := range []*http.Cookie{nil, {Name: rememberCookie, Value: "forged"}} { + if rec := signWith(t, s, jobRequest{}, c); rec.Code != http.StatusUnauthorized || errorCode(rec) != codePINRequired { + t.Errorf("cookie %v: %d %s", c, rec.Code, rec.Body) + } + } + if st := do(t, s, req{method: "GET", path: "/api/status"}); strings.Contains(st.Body.String(), "remembered") { + t.Errorf("status without the cookie: %s", st.Body) + } + + // Typing the PIN without asking to remember it forgets the remembered one. + if rec := signWith(t, s, jobRequest{PIN: "12345678"}, cookie); rec.Code != http.StatusOK || rememberCookieOf(t, rec).MaxAge >= 0 { + t.Fatalf("typed PIN: %d %v", rec.Code, rec.Result().Cookies()) + } + if rec := signWith(t, s, jobRequest{}, cookie); rec.Code != http.StatusUnauthorized { + t.Errorf("remembered after typing it again: %d %s", rec.Code, rec.Body) + } +} + +func TestRememberedPINWiped(t *testing.T) { + s, _, _ := rememberServer(t, time.Minute) + cookie := rememberPIN(t, s) + m, _ := s.recall(httptest.NewRequest("GET", "/", nil)) + if m != nil { + t.Fatal("recalled without a cookie") + } + r := httptest.NewRequest("GET", "/", nil) + r.AddCookie(cookie) + m, pin := s.recall(r) + if m == nil || pin != "12345678" { + t.Fatal("not recalled") + } + rec := do(t, s, req{method: "POST", path: "/api/forget", origin: self, body: struct{}{}, cookie: cookie}) + if rec.Code != http.StatusOK || rememberCookieOf(t, rec).MaxAge >= 0 { + t.Fatalf("forget: %d %v", rec.Code, rec.Result().Cookies()) + } + if string(m.pin) != "\x00\x00\x00\x00\x00\x00\x00\x00" { + t.Errorf("PIN not wiped: %q", m.pin) + } + if rec := signWith(t, s, jobRequest{}, cookie); rec.Code != http.StatusUnauthorized { + t.Errorf("signed after forgetting: %d %s", rec.Code, rec.Body) + } +} + +func TestRememberedPINExpires(t *testing.T) { + s, _, r := rememberServer(t, 30*time.Millisecond) + cookie := rememberPIN(t, s) + waitForgotten(t, s) + opened := r.opened.Load() + if rec := signWith(t, s, jobRequest{}, cookie); rec.Code != http.StatusUnauthorized || errorCode(rec) != codePINRequired { + t.Errorf("expired: %d %s", rec.Code, rec.Body) + } + if r.opened.Load() != opened { + t.Error("the card was opened after the PIN expired") + } +} + +// TestRememberedPINCardRemoved: taking the card off the reader forgets the PIN, so a card placed later never gets it. +func TestRememberedPINCardRemoved(t *testing.T) { + s, fb, r := rememberServer(t, time.Minute) + cookie := rememberPIN(t, s) + r.present.Store(false) + waitForgotten(t, s) + r.swap(t) + r.present.Store(true) + opened := r.opened.Load() + if rec := signWith(t, s, jobRequest{}, cookie); rec.Code != http.StatusUnauthorized || errorCode(rec) != codePINRequired { + t.Errorf("after removal: %d %s", rec.Code, rec.Body) + } + if r.opened.Load() != opened || len(fb.signatures) != 1 { + t.Error("the new card received the PIN") + } +} + +// TestRememberedPINOtherCard: a card swapped faster than the watcher notices is not signed with, even if it accepts +// the PIN. +func TestRememberedPINOtherCard(t *testing.T) { + s, fb, r := rememberServer(t, time.Minute) + cookie := rememberPIN(t, s) + r.swap(t) + rec := signWith(t, s, jobRequest{}, cookie) + if rec.Code != http.StatusUnauthorized || errorCode(rec) != codePINRequired || len(fb.signatures) != 1 { + t.Fatalf("other card: %d %s, %d signatures", rec.Code, rec.Body, len(fb.signatures)) + } + waitForgotten(t, s) +} + +// TestRememberedPINError: any failure to open the card with the remembered PIN forgets it. +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 + r.present.Store(false) + s.card.Unlock() + rec := signWith(t, s, jobRequest{}, cookie) + if rec.Code != http.StatusServiceUnavailable || errorCode(rec) != "no_card" { + t.Fatalf("no card: %d %s", rec.Code, rec.Body) + } + waitForgotten(t, s) +} + +func TestRememberDisabled(t *testing.T) { + s, _, _ := rememberServer(t, -1) + rec := signWith(t, s, jobRequest{PIN: "12345678", Remember: true}, nil) + if c := rememberCookieOf(t, rec); rec.Code != http.StatusOK || c != nil && c.MaxAge >= 0 { + t.Errorf("remembered while disabled: %d %v", rec.Code, rec.Result().Cookies()) + } +} + +func TestCardGone(t *testing.T) { + yes, no := true, false + cases := []struct { + statuses []token.Status + gone bool + }{ + {nil, true}, + {[]token.Status{{Available: false}}, true}, + {[]token.Status{{Available: true, CardPresent: &no}}, true}, + {[]token.Status{{Available: true, CardPresent: &yes}}, false}, + {[]token.Status{{Available: true}}, false}, + {[]token.Status{{Available: true}, {Available: true, CardPresent: &no}}, true}, + {[]token.Status{{Available: true, CardPresent: &no}, {Available: true, CardPresent: &yes}}, false}, + } + for i, c := range cases { + if got := cardGone(c.statuses); got != c.gone { + t.Errorf("case %d: cardGone = %v", i, got) + } + } +} diff --git a/internal/server/server.go b/internal/server/server.go index 9379bad..ef28307 100644 --- a/internal/server/server.go +++ b/internal/server/server.go @@ -5,6 +5,7 @@ package server import ( + "bytes" "context" "crypto" "crypto/rsa" @@ -46,6 +47,11 @@ type Options struct { Logger *slog.Logger // Backend builds the client for a dashboard origin; backend.New by default (tests replace it). Backend func(origin string) Backend + // RememberFor is how long a PIN lasts when the user asks to remember it: DefaultRememberFor when zero, never + // when negative. + RememberFor time.Duration + // CardWatch is how often the card is checked while a PIN is remembered; 2 seconds when zero. + CardWatch time.Duration } // Backend is the part of the dashboard's API the agent uses. @@ -62,6 +68,10 @@ type Server struct { // card serializes signatures: one card, one operation at a time. card sync.Mutex static http.Handler + + // memMu guards mem, the PIN the user chose to remember (see remember.go). Lock it after card, never before. + memMu sync.Mutex + mem *remembered } // New returns a server. @@ -73,6 +83,12 @@ func New(opts Options) *Server { version := opts.Version opts.Backend = func(origin string) Backend { return backend.New(origin, version) } } + if opts.RememberFor == 0 { + opts.RememberFor = DefaultRememberFor + } + if opts.CardWatch <= 0 { + opts.CardWatch = defaultCardWatch + } sub, _ := fs.Sub(webFS, "web") return &Server{opts: opts, static: http.FileServerFS(sub)} } @@ -87,6 +103,7 @@ func (s *Server) Handler() http.Handler { mux.HandleFunc("POST /api/job", s.api(s.job)) mux.HandleFunc("POST /api/sign", s.api(s.sign)) mux.HandleFunc("POST /api/cancel", s.api(s.cancel)) + mux.HandleFunc("POST /api/forget", s.api(s.forgetPIN)) return s.guard(mux) } @@ -203,6 +220,8 @@ type statusResponse struct { Providers []token.Status `json:"providers"` // Ready is true when some provider sees a card. Ready bool `json:"ready"` + // Remembered is set while this browser has a remembered PIN. + Remembered *rememberedInfo `json:"remembered,omitempty"` } func (s *Server) statuses(ctx context.Context) statusResponse { @@ -226,7 +245,9 @@ func (s *Server) status(w http.ResponseWriter, r *http.Request) { defer s.card.Unlock() ctx, cancel := context.WithTimeout(r.Context(), 10*time.Second) defer cancel() - writeJSON(w, http.StatusOK, s.statuses(ctx)) + res := s.statuses(ctx) + res.Remembered = s.rememberedFor(r) + writeJSON(w, http.StatusOK, res) } // MaxTickets is how many documents the page can sign with one PIN. @@ -235,7 +256,10 @@ const MaxTickets = 10 type jobRequest struct { Tickets []string `json:"tickets"` Origin string `json:"origin"` - PIN string `json:"pin,omitempty"` + // PIN is empty to sign with the PIN this browser remembered. + PIN string `json:"pin,omitempty"` + // Remember asks to remember PIN for Options.RememberFor once the card accepts it. + Remember bool `json:"remember,omitempty"` } // apiError is the error of one ticket in a response. @@ -317,7 +341,11 @@ func (s *Server) job(w http.ResponseWriter, r *http.Request) { } ctx, cancel := context.WithTimeout(r.Context(), 20*time.Second) defer cancel() - writeJSON(w, http.StatusOK, map[string]any{"jobs": fetchJobs(ctx, be, req.Tickets)}) + res := map[string]any{"jobs": fetchJobs(ctx, be, req.Tickets), "remembered": s.rememberedFor(r)} + if s.opts.RememberFor > 0 { + res["rememberMinutes"] = int(s.opts.RememberFor / time.Minute) + } + writeJSON(w, http.StatusOK, res) } func (s *Server) cancel(w http.ResponseWriter, r *http.Request) { @@ -389,14 +417,21 @@ func signTimeout(signatures int) time.Duration { return min(time.Minute+time.Duration(signatures)*30*time.Second, 15*time.Minute) } -// sign opens one card session with the PIN and signs every job in order. A backend error ends only its job; a card -// error ends the session, and the jobs after it are skipped. +// sign opens one card session with the PIN, typed or remembered, and signs every job in order. A backend error ends +// only its job; a card error ends the session, and the jobs after it are skipped. func (s *Server) sign(w http.ResponseWriter, r *http.Request) { req, be, ok := s.decode(w, r) if !ok { return } - if !token.ValidPIN(req.PIN, 8) { + var mem *remembered + if req.PIN == "" { + 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 } @@ -431,8 +466,14 @@ func (s *Server) sign(w http.ResponseWriter, r *http.Request) { ctx, cancel := context.WithTimeout(r.Context(), signTimeout(signatures)) defer cancel() session, provider, err := s.open(ctx, req.PIN) + pin := req.PIN req.PIN = "" if err != nil { + if mem != nil { + // Whatever went wrong, the card it was typed for may not be there: ask for the PIN again. + s.forget(mem, "card error: "+string(token.CodeOf(err))) + clearRememberCookie(w) + } code := token.CodeOf(err) for _, i := range todo { s.fail(ctx, be, jobs[i].Ticket, string(code), messageOf[code]) @@ -441,11 +482,26 @@ func (s *Server) sign(w http.ResponseWriter, r *http.Request) { return } defer session.Close() + info := certinfo.Parse(session.Certificate()) + switch { + case mem != nil && !bytes.Equal(session.Certificate().Raw, mem.cert): + // Another card, which happens to have the same PIN: sign only after the user typed it for this card. + s.forget(mem, "another card") + clearRememberCookie(w) + writeError(w, http.StatusUnauthorized, codePINRequired, "A different CIE is on the reader. Type its PIN.") + return + case mem == nil && req.Remember && s.opts.RememberFor > 0: + s.remember(w, pin, session.Certificate().Raw, info.DisplayName) + case mem == nil: + // Typing the PIN without asking to remember it replaces a PIN remembered before. + s.forgetAll("PIN typed again") + clearRememberCookie(w) + } + pin = "" var chain [][]byte for _, c := range session.Chain() { chain = append(chain, c.Raw) } - info := certinfo.Parse(session.Certificate()) // stop is the card error that ended the session: the remaining jobs get it too. var stop *apiError @@ -474,6 +530,9 @@ func (s *Server) sign(w http.ResponseWriter, r *http.Request) { writeJSON(w, http.StatusOK, map[string]any{"results": results, "signer": info}) } +// codePINRequired answers a signature without a PIN when none is remembered, or no longer usable. +const codePINRequired = "pin_required" + // cardError is a failure of the card session, which is not used again after it. type cardError struct{ error } diff --git a/internal/server/server_test.go b/internal/server/server_test.go index 0c5eca9..7d52f56 100644 --- a/internal/server/server_test.go +++ b/internal/server/server_test.go @@ -143,6 +143,7 @@ type req struct { method, path, host, origin, site string body any noHeader bool + cookie *http.Cookie } func do(t *testing.T, s *Server, r req) *httptest.ResponseRecorder { @@ -168,6 +169,9 @@ func do(t *testing.T, s *Server, r req) *httptest.ResponseRecorder { if r.body != nil { h.Header.Set("Content-Type", "application/json") } + if r.cookie != nil { + h.AddCookie(r.cookie) + } rec := httptest.NewRecorder() s.Handler().ServeHTTP(rec, h) return rec diff --git a/internal/server/web/assets/sign.js b/internal/server/web/assets/sign.js index d40e75d..cf6f117 100644 --- a/internal/server/web/assets/sign.js +++ b/internal/server/web/assets/sign.js @@ -27,6 +27,10 @@ let pending = [] /** What happened to each ticket: { status, error }. */ const outcome = new Map() let signer = "" +/** The PIN this browser asked the agent to remember, { signer, expires }, or null. The page never sees the PIN. */ +let remembered = null +/** How long the agent remembers a PIN on request, 0 when it does not offer to. */ +let rememberMinutes = 0 function readTickets() { try { @@ -150,7 +154,7 @@ async function load() { if (tickets.length > MAX_TICKETS) return fatal(`Sign at most ${MAX_TICKETS} documents at a time.`) if (tickets.length > 1) $("loading-text").textContent = "Loading the documents…" try { - ;({ jobs: docs } = await api("/api/job", { tickets, origin })) + ;({ jobs: docs, remembered, rememberMinutes = 0 } = await api("/api/job", { tickets, origin })) } catch (error) { return fatal(error.message || "Cannot load the document.") } @@ -187,12 +191,43 @@ function showForm(listed, toSign, message = "") { } $("submit").textContent = n === 1 ? "Sign" : `Sign ${n} documents` $("pin").value = "" + $("remember").checked = false formError(message) show("form") - $("pin").focus() + showPIN() startPolling() } +const timeFormat = new Intl.DateTimeFormat(undefined, { hour: "2-digit", minute: "2-digit" }) + +/** Shows the PIN field, or that the agent remembers the PIN, and moves the focus to what comes next. */ +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 = + `Your PIN is remembered${remembered.signer ? ` for ${remembered.signer}` : ""} until ${until}, while the CIE stays on the reader.` + $("submit").focus() + } else { + $("remember-row").hidden = !rememberMinutes + $("remember-text").textContent = `Remember the PIN on this computer for ${rememberMinutes} minutes` + $("pin").focus() + } +} + +/** Updates what the agent remembers from a status, and asks for the PIN when the agent forgot it meanwhile. */ +function setRemembered(next) { + const was = Boolean(remembered) + remembered = next || null + if (was === Boolean(remembered) || $("form").hidden) return + showPIN() + if (was) formError("The remembered PIN was forgotten: the CIE left the reader or the time ran out.") +} + function startPolling() { stopPolling() pollStatus() @@ -212,6 +247,7 @@ async function pollStatus() { return } 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 || []))] @@ -280,11 +316,17 @@ function showResults() { $("form").addEventListener("submit", async (event) => { event.preventDefault() - const pin = $("pin").value.trim() - if (!/^\d{8}$/.test(pin)) { - formError("Type the 8 digits of your CIE PIN.") - $("pin").focus() - return + // Without a PIN the agent uses the one it remembers for this browser. + const body = { tickets: pending, origin } + if (!remembered) { + const pin = $("pin").value.trim() + if (!/^\d{8}$/.test(pin)) { + formError("Type the 8 digits of your CIE PIN.") + $("pin").focus() + return + } + body.pin = pin + body.remember = $("remember").checked } // Cleared before the form goes away, so a browser watching the submission has nothing to offer to save. $("pin").value = "" @@ -294,11 +336,14 @@ $("form").addEventListener("submit", async (event) => { show("working") let result try { - result = await api("/api/sign", { tickets: pending, origin, pin }) + result = await api("/api/sign", body) } catch (error) { signing = false if (JOB_ENDED.has(error.code)) return fatal(error.message) + // 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") + showPIN() $("pin").value = "" let message = error.message || "The signature failed." if (error.code === "pin_incorrect") { @@ -308,8 +353,8 @@ $("form").addEventListener("submit", async (event) => { : "Wrong PIN. After 3 wrong attempts the PIN is blocked and needs the PUK." } formError(message) - $("pin").focus() - notify("error", error.code) + // Asking for the PIN is not a failure of the signature. + if (error.code !== "pin_required") notify("error", error.code) return } signing = false @@ -322,6 +367,17 @@ $("pin").addEventListener("input", () => { $("pin").value = $("pin").value.replace(/\D/g, "").slice(0, 8) }) +$("forget").addEventListener("click", async () => { + try { + await api("/api/forget", {}) + } catch { + // The page asks for the PIN anyway, and typing it replaces the remembered one. + } + remembered = null + formError("") + showPIN() +}) + $("cancel").addEventListener("click", async () => { try { await api("/api/cancel", { tickets: pending, origin }) diff --git a/internal/server/web/assets/style.css b/internal/server/web/assets/style.css index c2d1199..888a6cd 100644 --- a/internal/server/web/assets/style.css +++ b/internal/server/web/assets/style.css @@ -258,6 +258,49 @@ button:focus-visible { font-size: 13px; } +.check-row { + display: flex; + align-items: center; + gap: 8px; + margin: 14px 0 0; + font-weight: 400; + cursor: pointer; +} + +.check-row input { + flex: none; + width: 18px; + height: 18px; + margin: 0; + accent-color: var(--accent); + cursor: pointer; +} + +.remembered { + padding: 10px 12px; + border: 1px solid var(--line); + border-radius: 8px; + font-size: 14px; +} + +.remembered p { + margin: 0 0 4px; +} + +button.link { + height: auto; + padding: 0; + background: none; + color: var(--accent); + font-weight: 600; + text-decoration: underline; +} + +button.link:hover { + background: none; + 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 0cf2d52..56801c3 100644 --- a/internal/server/web/sign.html +++ b/internal/server/web/sign.html @@ -43,27 +43,38 @@

Looking for the card reader… - - -

The 8 digits of your CIE PIN. It stays on this computer.

+ + +
+ + +

The 8 digits of your CIE PIN. It stays on this computer.

+ +