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.
+ +