diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 02d090d..7dfb356 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -33,6 +33,13 @@ jobs: with: path: dist merge-multiple: true + - name: Check the downloads the dashboard links + # The dashboard downloads releases/latest/download/: without these files its buttons would answer 404. + # releases/latest is the newest release that is not a pre-release, so release candidates never reach it. + run: | + for name in PoliNetwork-eSign-macos.dmg PoliNetwork-eSign-windows-amd64.zip; do + if [ ! -f "dist/$name" ]; then echo "::error::dist/$name is missing"; exit 1; fi + done - name: Channel id: channel # v1.2.3-rc.1 and similar are pre-releases: GitHub only, never the package repositories or the AUR. diff --git a/README.md b/README.md index ad2fc18..3af228a 100644 --- a/README.md +++ b/README.md @@ -14,11 +14,12 @@ finalized by the PoliNetwork backend (PAdES), the agent only signs a digest with - A CIE (Carta d'Identità Elettronica 3.0) and its 8-digit PIN. - A contactless (NFC) smart-card reader, for example a bit4id miniLector AIR NFC. Most readers work without drivers on Windows and macOS; on Linux they need `pcscd` (see below). -- Optionally, CIE software for your system. The agent tries, in order: - - `cie-mw`, the official middleware: **Software CIE** (Mac App Store) on macOS, **CIE ID** on Windows and Linux, - from [cartaidentita.interno.gov.it](https://www.cartaidentita.interno.gov.it/pa-e-imprese/documentazione-middleware-cie/). - It works only with cards "paired" on this computer, a one-time step with the full PIN. If the card is not paired - yet, the agent pairs it through the middleware the first time you sign. +- **CIE ID**, the official CIE app, from + [cartaidentita.interno.gov.it](https://www.cartaidentita.interno.gov.it/pa-e-imprese/documentazione-middleware-cie/) + (on the Mac App Store it is listed as "Software CIE"). The agent tries, in order: + - `cie-mw`, the middleware that comes with CIE ID. It works only with cards "paired" on this computer, a one-time + step with the full PIN. If the card is not paired yet, the agent pairs it through the middleware the first time + you sign; on macOS, pair it in CIE ID when you install it (see below). - `opencie`, [OpenCIE](https://github.com/M0Rf30/opencie-pkcs11): put its PKCS#11 module (`libopencie-pkcs11.so`, `.dylib` or `.dll`) next to the agent or in the agent's configuration folder. It also keeps a pairing cache, which the agent creates the same way. @@ -51,14 +52,20 @@ read. The dashboard never sees the PIN. Download the file for your system from the [latest release](https://github.com/PoliNetworkOrg/esign/releases/latest). Installing needs no administrator rights: the agent is installed for your user only, starts when you log in, and lets -the dashboard start it with a `polinetwork-esign://open` link when it is not running. +the dashboard start it with a `polinetwork-esign://open` link when it is not running. To update on macOS or Windows, +download the latest version and open it the same way: it replaces the one installed. Until v0.1.x the agent was called PoliNetwork Sign Agent. Installing PoliNetwork eSign removes it (app, login item, link handler, Debian package) and keeps its configuration and card pairings. ### macOS (Apple Silicon and Intel) -1. Download `PoliNetwork-eSign--macos.dmg` (or the `.zip`) and open it. +First install **CIE ID** from the Mac App Store, where it is listed as "Software CIE", and open it: on an Apple Silicon +Mac, accept when macOS offers to install Rosetta. Pair your card in it with the 8-digit PIN. If it pairs there, signing +works with PoliNetwork eSign too; if it does not, ask whoever sent you the signing request. Then: + +1. Download [`PoliNetwork-eSign-macos.dmg`](https://github.com/PoliNetworkOrg/esign/releases/latest/download/PoliNetwork-eSign-macos.dmg) + (or the `.zip`) and open it. 2. Open **PoliNetwork eSign**. The app is not notarized yet, so macOS refuses the first time: open **System Settings > Privacy & Security**, choose **Open Anyway** next to the message about PoliNetwork eSign, and confirm. @@ -66,14 +73,16 @@ link handler, Debian package) and keeps its configuration and card pairings. 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 -sign, macOS asks whether **PoliNetwork eSign** may access data from other apps: allow it, so the agent can read the -card pairing that Software CIE keeps in its own folder. +CIE ID's middleware is built for Intel Macs only. On Apple Silicon the agent runs it in a helper process under Rosetta, +which opening CIE ID installs (or run `softwareupdate --install-rosetta`). The first time you sign, macOS asks whether +**PoliNetwork eSign** may access data from other apps: allow it, so the agent can read the card pairing that CIE ID +keeps in its own folder. ### Windows -1. Download `PoliNetwork-eSign--windows-amd64.zip`, extract it and double-click `esign.exe`. +1. Download + [`PoliNetwork-eSign-windows-amd64.zip`](https://github.com/PoliNetworkOrg/esign/releases/latest/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, which shows whether the reader, the CIE and @@ -139,8 +148,10 @@ 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 PoliNetwork eSign is not running, +In the dashboard, open a document and choose **Sign with CIE**. A small window from the agent opens: place the CIE on +the reader first, and the PIN field opens as soon as the agent sees the card. If you take the card away, the field +and **Sign** wait for it again, keeping what you typed. Keep the card on the reader until the window 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 @@ -148,8 +159,17 @@ separate signature), or up to 10 documents that the dashboard sends together. Th after the other, and shows which were signed. If the dashboard refuses one document (for example because it changed), the others are still signed; if the card stops answering, the rest are left unsigned and you can try again. +When the dashboard no longer accepts your version of PoliNetwork eSign, the window says so before it asks for the +PIN, with the download for your system: on macOS and Windows, open the downloaded file and it replaces the old version; +on Linux, update the package. + A wrong PIN uses one of the card's three attempts; the agent never retries a PIN by itself. After three wrong PINs the -card is blocked: unblock it with the PUK in the CIE software. +card is blocked: unblock it with the PUK in CIE ID. The agent counts the wrong PINs typed in a row for each signer, even +when the window is closed and opened again, and uses the attempts left that the card reports when it reports them (CIE +ID's middleware does not). Before the last attempt the window warns above the PIN field and does not offer to remember +the PIN. The count clears once a PIN opens a card that the dashboard accepts for the signer, even if the signature then +fails; it is kept in memory only, until the agent stops. The eye +at the end of the PIN field shows the digits, to check them before signing. ## Troubleshooting @@ -168,16 +188,20 @@ file as above is easier. Common reports: -- **module: not found** — the CIE software is not installed, or is installed somewhere unusual: set its path in the - configuration file (`pkcs11Module` or `openCieModule`). +- **module: not found** under `cie-mw (CIE ID)` — CIE ID is not installed, or is installed somewhere unusual: set its + path in the configuration file (`pkcs11Module`; `openCieModule` for OpenCIE). - **loading: impossible … x86_64 only** — an Apple Silicon Mac running a build without Intel code; install the release app, which contains both. - **readers: none** — the reader is not connected, or (Linux) `pcscd` is not running. - **card: false** — put the CIE flat on the reader and keep it still. -- "This CIE is not set up on this computer yet" when signing — the middleware does not export its pairing function: - pair the card in Software CIE / CIE ID. +- **paired cards: none** (macOS) — open CIE ID and pair the card with its 8-digit PIN. +- "This CIE is not paired with this computer yet" when signing — CIE ID's middleware does not export its pairing + function: pair the card in CIE ID. - **agent: NOT running; another program uses port 47321** — close that program, or change `port` in the configuration (the dashboard expects 47321). +- The dashboard says that PoliNetwork eSign isn't running while it runs, and the log has `refused dashboard origin` — + the agent does not sign for that dashboard (a staging copy, a new domain): add its origin to `allowedOrigins` in the + configuration (`doctor` lists the allowed ones). Logs: macOS `~/Library/Logs/PoliNetwork eSign.log`; Windows `%APPDATA%\PoliNetwork eSign\esign.log`; Linux `journalctl --user -u polinetwork-esign`. @@ -199,7 +223,7 @@ eSign` on macOS, `%APPDATA%\PoliNetwork eSign` on Windows, `~/.config/PoliNetwor ``` `worker` says when a PKCS#11 module runs in a separate helper process: `auto` when needed (another CPU architecture, -or Software CIE's environment), `always` (a crashing middleware cannot stop the agent), or `never`. +or CIE ID's environment on macOS), `always` (a crashing middleware cannot stop the agent), or `never`. ## Commands @@ -263,16 +287,39 @@ provider accepts it only when a test hands it that CSCA. The dashboard opens `/sign?origin=&ticket=&ticket=…`, one single-use ticket per document (1 to 10; repeats are dropped). The page talks only to the agent: -- `POST /api/job` `{tickets, origin}` returns `{jobs: [{ticket, job} | {ticket, error}]}`; +- `GET /api/status` describes the providers, readers and card. Its `card` is `present` when a provider sees the card, + `absent` when no provider could be tried, so a signature would fail before the PIN is sent (no reader, no card, no + CIE software), and `unknown` when a provider that cannot tell would be tried. The page takes the PIN only when it + is not `absent`, and waits for two answers in a row without the card before it holds the PIN back again; after one, + it asks again before it sends a PIN. Three failed answers in a row count as `unknown`; +- `POST /api/job` `{tickets, origin}` returns `{jobs: [{ticket, job} | {ticket, error}], attempts}`. `attempts` is + `{left, card}` when one of the jobs' signers typed a wrong PIN since their card last opened: the attempts left before + the card blocks the PIN, as the card reported them (`card: true`) or as the agent counted them, which misses wrong + PINs typed in other apps; `null` otherwise; - `POST /api/sign` `{tickets, origin, pin}` opens one card session and, for each job in order, repeats `prepare` (with `protocol: 2`), card signature and `complete` until the backend answers `completed`. It returns - `{results: [{ticket, status: completed | failed | skipped, error?}], signer}`. If the session does not open (wrong - PIN, no card) it returns that error and reports it on every job; + `{results: [{ticket, status: completed | failed | skipped, error?}], signer, attempts}`, with `attempts` as they are + after this PIN. If the session does not open (wrong PIN, no card) it returns that error and reports it on every job; + a wrong PIN's error has `attempts` too; - `POST /api/cancel` `{tickets, origin}` reports the jobs as cancelled. The agent calls the dashboard's `/api/esign/agent/jobs/{ticket}[/prepare|/complete|/fail]` with each ticket. A backend older than protocol 2 sends no steps, and its jobs are signed once. +The page tells the dashboard what happens with `postMessage({type: "pn-esign", status, detail, …})`, only to the +`origin` of its address and only when the agent signs for it: the agent checks the origin when it serves the page, so +the page knows where it may post before making any request. A dashboard that the agent does not sign for gets no +message at all, not even `ready`, so it takes the agent for one that is not running: other sites must not learn that +the agent runs on this computer. + +- `ready`, first thing when the page runs, with the agent's version in `detail` (`0.4.0`, or `dev`). A dashboard that + gets no `ready` can tell that the agent is not running. +- `error`, with the error code in `detail`. When the window cannot go on (the link is not valid, the jobs could not be + loaded or expired, the card cannot be used, …) the message also has `message`, the text the window shows, and + `fatal: true`. When the backend answers `agent_outdated`, on the job or on `prepare`, the window shows "Update + PoliNetwork eSign" with the download for its system instead of the PIN form. +- `completed` when every document was signed, `cancelled` when the signer cancelled. + Builds: ```sh @@ -284,17 +331,23 @@ Releases: push a `v*` tag; `.github/workflows/release.yml` builds everything, ad GitHub release (a pre-release for tags such as `v1.2.3-rc.1`). Releases that are not pre-releases also update the APT and DNF repositories and the AUR package: see [`docs/linux-packages.md`](docs/linux-packages.md). +The macOS and Windows files have the same names in every release (`PoliNetwork-eSign-macos.dmg`, +`PoliNetwork-eSign-macos.zip`, `PoliNetwork-eSign-windows-amd64.zip`), so +`https://github.com/PoliNetworkOrg/esign/releases/latest/download/` always downloads the newest release that is +not a pre-release. The dashboard and the agent's "Update PoliNetwork eSign" screen link the `.dmg` and the Windows +`.zip` that way, and the release fails if either is missing. + ### Maintainer TODO before a public release - **macOS notarization.** The app is only signed ad hoc, so Gatekeeper blocks it until the user allows it. Sign with a Developer ID Application certificate, with the hardened runtime and the `com.apple.security.cs.disable-library-validation` entitlement (the agent loads the CIE middleware, which another team signed), notarize with `xcrun notarytool submit --wait` and staple (`xcrun stapler staple`) both the app and the - `.dmg`. A stable signature also keeps macOS from asking again, after each update, for access to Software CIE's data. + `.dmg`. A stable signature also keeps macOS from asking again, after each update, for access to CIE ID's data. - **Windows code signing** (Authenticode) of `esign.exe`, to avoid SmartScreen warnings; an icon and version resource would help too. - **Test on real hardware**: Windows 10/11 x64 (and Windows on ARM, which runs the x64 build emulated), Intel and Apple - Silicon Macs with Software CIE, and the Linux distributions to support, each with a paired card. + Silicon Macs with CIE ID, and the Linux distributions to support, each with a paired card. ## Code signing diff --git a/cmd/esign/app_darwin.go b/cmd/esign/app_darwin.go index a67822e..ff2265e 100644 --- a/cmd/esign/app_darwin.go +++ b/cmd/esign/app_darwin.go @@ -42,15 +42,16 @@ func plistString(path, key string) string { return strings.TrimSpace(string(out)) } -// systemNotes reports the Software CIE app and its paired cards. +// systemNotes reports the CIE ID app and the cards paired in it. Pairing the card in CIE ID is the setup check on +// macOS: a card that pairs there signs here too. func systemNotes() []string { var notes []string app := "/Applications/CIE ID.app" if _, err := os.Stat(app); err == nil { info := filepath.Join(app, "Contents", "Info.plist") - notes = append(notes, fmt.Sprintf("Software CIE: %s %s (%s)", app, plistString(info, "CFBundleShortVersionString"), plistString(info, "CFBundleIdentifier"))) + notes = append(notes, fmt.Sprintf("CIE ID: %s %s (%s)", app, plistString(info, "CFBundleShortVersionString"), plistString(info, "CFBundleIdentifier"))) } else { - notes = append(notes, "Software CIE: not installed (Mac App Store, \"Software CIE\")") + notes = append(notes, "CIE ID: not installed (Mac App Store, where it is called \"Software CIE\")") } home, _ := os.UserHomeDir() cache := filepath.Join(home, "Library", "Group Containers", "group.it.ipzs.SoftwareCIE", "Library", "Caches", "CIEPKI") @@ -63,11 +64,15 @@ func systemNotes() []string { n++ } } - notes = append(notes, fmt.Sprintf("paired cards: %d in %s", n, cache)) + note := fmt.Sprintf("paired cards: %d in %s", n, cache) + if n == 0 { + note += "; pair the card in CIE ID" + } + notes = append(notes, note) case os.IsPermission(err): notes = append(notes, "paired cards: unknown, macOS did not let this program read "+cache) default: - notes = append(notes, "paired cards: none ("+cache+" is absent)") + notes = append(notes, "paired cards: none ("+cache+" is absent); pair the card in CIE ID") } return notes } diff --git a/cmd/esign/doctor.go b/cmd/esign/doctor.go index 775f02c..47d8774 100644 --- a/cmd/esign/doctor.go +++ b/cmd/esign/doctor.go @@ -17,6 +17,14 @@ import ( "github.com/PoliNetworkOrg/esign/internal/token/worker" ) +// providerNames name each provider as users know it, so the report names the app to install. +var providerNames = map[string]string{ + "cie-mw": "CIE ID", + "opencie": "OpenCIE", + "cie": "built in", + "soft": "development key", +} + // doctor prints what support needs to know: the build, installation, configuration, running agent, every // provider's module and architecture, and the readers and card each provider sees. It never asks for the PIN. func doctor(ctx context.Context, w io.Writer, cfg config.Config, configPath, dir string, specs []providerSpec) { @@ -63,7 +71,7 @@ func doctor(ctx context.Context, w io.Writer, cfg config.Config, configPath, dir fmt.Fprintln(w, "\nProviders") for _, s := range specs { - fmt.Fprintf(w, " %s\n", s.Name) + fmt.Fprintf(w, " %s (%s)\n", s.Name, providerNames[s.Name]) switch { case s.Module != "": marchs := strings.Join(s.Archs, "+") diff --git a/internal/server/attempts.go b/internal/server/attempts.go new file mode 100644 index 0000000..9884eba --- /dev/null +++ b/internal/server/attempts.go @@ -0,0 +1,93 @@ +package server + +import ( + "errors" + "sync" + + "github.com/PoliNetworkOrg/esign/internal/backend" + "github.com/PoliNetworkOrg/esign/internal/token" +) + +// pinTries is how many wrong PINs in a row block the PIN of a CIE. +const pinTries = 3 + +// attempts is what the page shows about the PIN attempts left before the card blocks the PIN. +type attempts struct { + // Left is the attempts left: as the card reported them after the last wrong PIN, or else pinTries minus the wrong + // PINs typed in a row on this computer, which misses those typed in other apps. + Left int `json:"left"` + // Card is set when Left is what the card reported. + Card bool `json:"card,omitempty"` +} + +// wrongPINs counts the wrong PINs typed in a row, so that the page can warn before the last attempt even after it was +// closed and opened again: the official middleware does not say how many attempts are left. +// +// The count belongs to the person the dashboard asks to sign, whose card the PIN was typed for: another person +// signing on this computer starts without a warning, while the same person with a new card is warned until a PIN +// opens a card that the backend accepts as theirs, which errs on the safe side. It is kept in memory only, until the +// agent stops. +type wrongPINs struct { + mu sync.Mutex + by map[string]attempts +} + +// signerKey identifies the person a job asks to sign. +func signerKey(origin string, job *backend.Job) string { + return origin + "\n" + job.Signer.Name +} + +// get returns the fewest attempts left among the signers, nil when none of them typed a wrong PIN. +func (w *wrongPINs) get(signers []string) *attempts { + w.mu.Lock() + defer w.mu.Unlock() + var least *attempts + for _, k := range signers { + if a, ok := w.by[k]; ok && (least == nil || a.Left < least.Left) { + least = &a + } + } + return least +} + +// wrong counts a wrong PIN for the signers, with the attempts the card reported (-1 when it did not), and returns +// what the page shows. A batch names its signer once per document, but the PIN was typed once: it counts once. +func (w *wrongPINs) wrong(signers []string, retriesLeft int) *attempts { + w.mu.Lock() + if w.by == nil { + w.by = map[string]attempts{} + } + for _, k := range unique(signers) { + a := attempts{Left: retriesLeft, Card: true} + if retriesLeft < 0 { + prev, ok := w.by[k] + if !ok { + prev.Left = pinTries + } + // The card refused the PIN without blocking it, so at least one attempt is left, whatever was missed. + a = attempts{Left: max(prev.Left-1, 1)} + } + w.by[k] = a + } + w.mu.Unlock() + return w.get(signers) +} + +// forget drops the count of the signers: a PIN worked on their card, or the card is blocked, and only the PUK, which +// restores every attempt, unblocks it. +func (w *wrongPINs) forget(signers []string) { + w.mu.Lock() + defer w.mu.Unlock() + for _, k := range signers { + delete(w.by, k) + } +} + +// retriesLeft is the attempts left that the card reported with a wrong PIN, -1 when it did not. +func retriesLeft(err error) int { + var te *token.Error + if errors.As(err, &te) && te.Code == token.CodePinIncorrect { + return te.RetriesLeft + } + return -1 +} diff --git a/internal/server/attempts_test.go b/internal/server/attempts_test.go new file mode 100644 index 0000000..264ea7c --- /dev/null +++ b/internal/server/attempts_test.go @@ -0,0 +1,183 @@ +package server + +import ( + "context" + "encoding/json" + "errors" + "net/http" + "net/http/httptest" + "slices" + "testing" + + "github.com/PoliNetworkOrg/esign/internal/backend" + "github.com/PoliNetworkOrg/esign/internal/token" + "github.com/PoliNetworkOrg/esign/internal/token/soft" +) + +// mute is a card whose middleware does not say how many PIN attempts are left, like the official one. +type mute struct{ *soft.Provider } + +func (m mute) Open(ctx context.Context, pin string) (token.Session, error) { + session, err := m.Provider.Open(ctx, pin) + var te *token.Error + if errors.As(err, &te) { + hidden := *te + hidden.RetriesLeft = -1 + return nil, &hidden + } + return session, err +} + +func muteCard(t *testing.T) mute { + t.Helper() + p, err := soft.New("12345678") + if err != nil { + t.Fatal(err) + } + return mute{p} +} + +// attemptsOf reads the attempts of a /api/job answer or of a /api/sign error. +func attemptsOf(t *testing.T, rec *httptest.ResponseRecorder) *attempts { + t.Helper() + var res struct { + Attempts *attempts `json:"attempts"` + Error struct { + Attempts *attempts `json:"attempts"` + } `json:"error"` + } + if err := json.Unmarshal(rec.Body.Bytes(), &res); err != nil { + t.Fatalf("%d %s", rec.Code, rec.Body) + } + if res.Attempts != nil { + return res.Attempts + } + return res.Error.Attempts +} + +// jobAttempts is what the page learns when it opens for tickets. +func jobAttempts(t *testing.T, s *Server, tickets ...string) *attempts { + t.Helper() + rec := do(t, s, req{method: "POST", path: "/api/job", origin: self, body: jobRequest{Tickets: tickets, Origin: dashboard}}) + if rec.Code != http.StatusOK { + t.Fatalf("job: %d %s", rec.Code, rec.Body) + } + return attemptsOf(t, rec) +} + +func wantAttempts(t *testing.T, what string, got *attempts, want *attempts) { + t.Helper() + if (got == nil) != (want == nil) || got != nil && *got != *want { + t.Errorf("%s: attempts %+v, want %+v", what, got, want) + } +} + +// TestWrongPINsCounted: with a middleware that does not report the attempts left, the agent counts the wrong PINs in +// a row; a page opened again still learns the count, and a PIN that works clears it. +func TestWrongPINsCounted(t *testing.T) { + s := newServer(&fakeBackend{}, muteCard(t)) + wantAttempts(t, "before any PIN", jobAttempts(t, s, ticket), nil) + + rec, _ := signRequest(t, s, "00000000", ticket) + if rec.Code != http.StatusUnauthorized || errorCode(rec) != "pin_incorrect" { + t.Fatalf("wrong PIN: %d %s", rec.Code, rec.Body) + } + wantAttempts(t, "first wrong PIN", attemptsOf(t, rec), &attempts{Left: 2}) + wantAttempts(t, "page opened again", jobAttempts(t, s, ticket), &attempts{Left: 2}) + + rec, _ = signRequest(t, s, "00000000", ticket) + wantAttempts(t, "second wrong PIN", attemptsOf(t, rec), &attempts{Left: 1}) + wantAttempts(t, "page opened again", jobAttempts(t, s, ticket), &attempts{Left: 1}) + + if rec, _ = signRequest(t, s, "12345678", ticket); rec.Code != http.StatusOK { + t.Fatalf("right PIN: %d %s", rec.Code, rec.Body) + } + wantAttempts(t, "after the right PIN", jobAttempts(t, s, ticket), nil) +} + +// TestWrongPINsFromCard: the attempts the card reports win, and a blocked card leaves nothing to warn about. +func TestWrongPINsFromCard(t *testing.T) { + s, _, _ := setup(t) + rec, _ := signRequest(t, s, "00000000", ticket) + wantAttempts(t, "first wrong PIN", attemptsOf(t, rec), &attempts{Left: 2, Card: true}) + rec, _ = signRequest(t, s, "00000000", ticket) + wantAttempts(t, "second wrong PIN", attemptsOf(t, rec), &attempts{Left: 1, Card: true}) + wantAttempts(t, "page opened again", jobAttempts(t, s, ticket), &attempts{Left: 1, Card: true}) + if rec, _ = signRequest(t, s, "00000000", ticket); errorCode(rec) != "pin_locked" { + t.Fatalf("third wrong PIN: %d %s", rec.Code, rec.Body) + } + wantAttempts(t, "blocked", jobAttempts(t, s, ticket), nil) +} + +// TestWrongPINsPerSigner: another person signing on the same computer is not warned about someone else's card, and +// a batch warns when any of its signers should be. +func TestWrongPINsPerSigner(t *testing.T) { + ts := tickets(3) + fb := &fakeBackend{signer: map[string]string{ts[0]: "Mario Rossi", ts[1]: "Anna Bianchi", ts[2]: "Mario Rossi"}} + s := newServer(fb, muteCard(t)) + signRequest(t, s, "00000000", ts[0]) + wantAttempts(t, "same signer, other job", jobAttempts(t, s, ts[2]), &attempts{Left: 2}) + wantAttempts(t, "other signer", jobAttempts(t, s, ts[1]), nil) + wantAttempts(t, "batch", jobAttempts(t, s, ts[1], ts[2]), &attempts{Left: 2}) +} + +// TestWrongPINsInABatch: a wrong PIN typed once for several documents of the same signer is one attempt. +func TestWrongPINsInABatch(t *testing.T) { + ts := tickets(3) + fb := &fakeBackend{signer: map[string]string{ts[0]: "Mario Rossi", ts[1]: "Mario Rossi", ts[2]: "Mario Rossi"}} + s := newServer(fb, muteCard(t)) + rec, _ := signRequest(t, s, "00000000", ts...) + wantAttempts(t, "first wrong PIN", attemptsOf(t, rec), &attempts{Left: 2}) + rec, _ = signRequest(t, s, "00000000", ts...) + wantAttempts(t, "second wrong PIN", attemptsOf(t, rec), &attempts{Left: 1}) +} + +// TestWrongPINsKeptForAnotherCard: a PIN that opens a card the backend refuses for the signer proves nothing about the +// signer's own card, so the count stays until a signature of theirs is accepted. +func TestWrongPINsKeptForAnotherCard(t *testing.T) { + fb := &fakeBackend{} + s := newServer(fb, muteCard(t)) + signRequest(t, s, "00000000", ticket) + fb.prepareErr = map[string]error{ticket: &backend.Error{Status: 403, Code: "signer_mismatch", Message: "This CIE belongs to someone else."}} + rec, res := signRequest(t, s, "12345678", ticket) + if rec.Code != http.StatusOK || statuses(res)[0] != "failed:signer_mismatch" { + t.Fatalf("other card: %d %s", rec.Code, rec.Body) + } + wantAttempts(t, "answer to another card", attemptsOf(t, rec), &attempts{Left: 2}) + wantAttempts(t, "after another card", jobAttempts(t, s, ticket), &attempts{Left: 2}) + fb.prepareErr = nil + signRequest(t, s, "12345678", ticket) + wantAttempts(t, "after the signer's card", jobAttempts(t, s, ticket), nil) +} + +// TestWrongPINsClearedByTheSignersCard: a PIN that opens a card the backend accepts for the signer clears the count +// even when the signature then fails, and the answer says so, for the form shown again. +func TestWrongPINsClearedByTheSignersCard(t *testing.T) { + p, err := soft.New("12345678") + if err != nil { + t.Fatal(err) + } + // The card moves away at the first signature. + s := newServer(&fakeBackend{}, &flaky{Provider: p}) + signRequest(t, s, "00000000", ticket) + rec, _ := signRequest(t, s, "00000000", ticket) + wantAttempts(t, "second wrong PIN", attemptsOf(t, rec), &attempts{Left: 1, Card: true}) + rec, res := signRequest(t, s, "12345678", ticket) + if rec.Code != http.StatusOK || !slices.Equal(statuses(res), []string{"failed:card_removed"}) { + t.Fatalf("right PIN: %d %s", rec.Code, rec.Body) + } + wantAttempts(t, "answer to the right PIN", attemptsOf(t, rec), nil) + wantAttempts(t, "page opened again", jobAttempts(t, s, ticket), nil) +} + +// TestWrongPINsNeverZero: a card that refuses a PIN without blocking it has an attempt left, whatever the count missed. +func TestWrongPINsNeverZero(t *testing.T) { + var w wrongPINs + k := []string{"signer"} + for range 4 { + w.wrong(k, -1) + } + wantAttempts(t, "after four wrong PINs", w.get(k), &attempts{Left: 1}) + wantAttempts(t, "card reports", w.wrong(k, 2), &attempts{Left: 2, Card: true}) + wantAttempts(t, "card stops reporting", w.wrong(k, -1), &attempts{Left: 1}) +} diff --git a/internal/server/os.go b/internal/server/os.go index dd1386f..7af75e7 100644 --- a/internal/server/os.go +++ b/internal/server/os.go @@ -3,3 +3,42 @@ package server import "runtime" func osName() string { return runtime.GOOS + "/" + runtime.GOARCH } + +// latestRelease is the release page of the newest version that is not a pre-release. +const latestRelease = "https://github.com/PoliNetworkOrg/esign/releases/latest" + +// downloadLink is how the signing page offers the newest version. +type downloadLink struct { + URL string + Label string + // Page is set when URL is the release page rather than the file that installs the agent. + Page bool + // Update says how the newest version replaces this one. + Update string +} + +// download is the newest version for goos. The macOS and Windows files have the same name in every release +// (scripts/build-macos.sh, scripts/build-windows.sh); Linux has a package per distribution, listed on the release page, +// which the package manager updates. +func download(goos string) downloadLink { + switch goos { + case "darwin": + return downloadLink{ + URL: latestRelease + "/download/PoliNetwork-eSign-macos.dmg", + Label: "Download for macOS", + Update: "Download the new one, open it and open PoliNetwork eSign inside: it replaces this one.", + } + case "windows": + return downloadLink{ + URL: latestRelease + "/download/PoliNetwork-eSign-windows-amd64.zip", + Label: "Download for Windows", + Update: "Download the new one, extract it and open esign.exe: it replaces this one.", + } + } + return downloadLink{ + URL: latestRelease, + Label: "Open the download page", + Page: true, + Update: "Update the polinetwork-esign package, or install the new one from the download page.", + } +} diff --git a/internal/server/server.go b/internal/server/server.go index a233c84..aa07c60 100644 --- a/internal/server/server.go +++ b/internal/server/server.go @@ -13,10 +13,12 @@ import ( "encoding/json" "errors" "fmt" + "html/template" "io/fs" "log/slog" "net" "net/http" + "runtime" "runtime/debug" "strconv" "strings" @@ -35,6 +37,27 @@ const APIHeader = "X-PN-eSign" //go:embed web var webFS embed.FS +// pages are the HTML pages, rendered with pageData; their assets are served as they are. +var pages = template.Must(template.ParseFS(webFS, "web/*.html")) + +// pageData is what the pages show before any script runs. +type pageData struct { + // Version is the agent's version as people read it: "0.4.0" for the release tagged v0.4.0, or "dev". + Version string + // Dashboard is the signing page's origin parameter when the agent signs for it: the page's messages go there only. + Dashboard string + // Download replaces this version when the dashboard no longer accepts it. + Download downloadLink +} + +// shownVersion drops the "v" of a release tag. +func shownVersion(version string) string { + if rest, ok := strings.CutPrefix(version, "v"); ok && rest != "" && rest[0] >= '0' && rest[0] <= '9' { + return rest + } + return version +} + // Options configures the server. type Options struct { // Port the agent listens on; Host headers must name it. @@ -76,6 +99,8 @@ type Server struct { // memMu guards mem, the PIN the user chose to remember (see remember.go). Lock it after card, never before. memMu sync.Mutex mem *remembered + + pins wrongPINs } // New returns a server. @@ -217,13 +242,18 @@ func (s *Server) guard(next http.Handler) http.Handler { func (s *Server) page(name string) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { - data, err := webFS.ReadFile("web/" + name) - if err != nil { - http.NotFound(w, r) + data := pageData{Version: shownVersion(s.opts.Version), Download: download(runtime.GOOS)} + if origin := r.URL.Query().Get("origin"); OriginAllowed(s.opts.AllowedOrigins, origin) { + data.Dashboard = origin + } + var page bytes.Buffer + if err := pages.ExecuteTemplate(&page, name, data); err != nil { + s.opts.Logger.Error("render page", "page", name, "error", err) + http.Error(w, "PoliNetwork eSign could not show this page.", http.StatusInternalServerError) return } w.Header().Set("Content-Type", "text/html; charset=utf-8") - _, _ = w.Write(data) + _, _ = w.Write(page.Bytes()) } } @@ -267,10 +297,43 @@ type statusResponse struct { Providers []token.Status `json:"providers"` // Ready is true when some provider sees a card. Ready bool `json:"ready"` + // Card says whether a PIN typed now could reach a card (see cardState). + Card string `json:"card"` // Remembered is set while this browser has a remembered PIN. Remembered *rememberedInfo `json:"remembered,omitempty"` } +// usable reports whether open would try the provider: it works and does not report that the card is missing. +func usable(st token.Status) bool { + return st.Available && (st.CardPresent == nil || *st.CardPresent) +} + +// Card states for the signing page, which takes the PIN only when it could reach a card. +const ( + // cardPresent: a usable provider sees a card. + cardPresent = "present" + // cardAbsent: no provider would be tried, so a signature would fail before the PIN is sent: no reader, no card, or + // no CIE software. + cardAbsent = "absent" + // cardUnknown: a usable provider cannot tell whether a card is there. The page must not hold the PIN back on it. + cardUnknown = "unknown" +) + +// cardState tells what a signature started now would find, as open sees it. +func cardState(statuses []token.Status) string { + state := cardAbsent + for _, st := range statuses { + switch { + case !usable(st): + case st.CardPresent != nil: + return cardPresent + default: + state = cardUnknown + } + } + return state +} + // 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) @@ -289,6 +352,7 @@ func (s *Server) statuses(ctx context.Context) statusResponse { res.Ready = true } } + res.Card = cardState(res.Providers) return res } @@ -332,7 +396,7 @@ func (s *Server) decode(w http.ResponseWriter, r *http.Request) (*jobRequest, Ba writeError(w, http.StatusBadRequest, "bad_request", "Malformed request") return nil, nil, false } - req.Tickets = uniqueTickets(req.Tickets) + req.Tickets = unique(req.Tickets) valid := len(req.Tickets) > 0 for _, t := range req.Tickets { valid = valid && backend.TicketPattern.MatchString(t) @@ -353,14 +417,14 @@ func (s *Server) decode(w http.ResponseWriter, r *http.Request) (*jobRequest, Ba return &req, s.opts.Backend(req.Origin), true } -// uniqueTickets returns tickets without repeats, in their order. -func uniqueTickets(tickets []string) []string { - seen := make(map[string]bool, len(tickets)) +// unique returns list without repeats, in its order. +func unique(list []string) []string { + seen := make(map[string]bool, len(list)) var out []string - for _, t := range tickets { - if !seen[t] { - seen[t] = true - out = append(out, t) + for _, s := range list { + if !seen[s] { + seen[s] = true + out = append(out, s) } } return out @@ -398,7 +462,14 @@ func (s *Server) job(w http.ResponseWriter, r *http.Request) { } ctx, cancel := context.WithTimeout(r.Context(), 20*time.Second) defer cancel() - res := map[string]any{"jobs": fetchJobs(ctx, be, req.Tickets), "remembered": s.rememberedFor(r)} + jobs := fetchJobs(ctx, be, req.Tickets) + var signers []string + for _, j := range jobs { + if j.Job != nil { + signers = append(signers, signerKey(req.Origin, j.Job)) + } + } + res := map[string]any{"jobs": jobs, "remembered": s.rememberedFor(r), "attempts": s.pins.get(signers)} if s.opts.RememberFor > 0 { res["rememberMinutes"] = int(s.opts.RememberFor / time.Minute) } @@ -444,7 +515,7 @@ func (s *Server) open(ctx context.Context, pin string) (token.Session, token.Pro st := s.providerStatus(ctx, p) readers = readers || len(st.Readers) > 0 available = available || st.Available - if !st.Available || (st.CardPresent != nil && !*st.CardPresent) { + if !usable(st) { continue } session, err := p.Open(ctx, pin) @@ -491,7 +562,7 @@ func (s *Server) sign(w http.ResponseWriter, r *http.Request) { return } if req.PIN != "" && !token.ValidPIN(req.PIN, 8) { - writeTokenError(w, token.Errorf(token.CodePinInvalid, "malformed PIN")) + writeTokenError(w, token.Errorf(token.CodePinInvalid, "malformed PIN"), nil) return } // A status check or the card watcher holds the card only briefly: wait for it rather than refusing the PIN. @@ -516,6 +587,7 @@ func (s *Server) sign(w http.ResponseWriter, r *http.Request) { cancelFetch() results := make([]signResult, len(jobs)) var todo []int + var signers []string signatures := 0 for i, j := range jobs { if j.Error != nil { @@ -524,6 +596,7 @@ func (s *Server) sign(w http.ResponseWriter, r *http.Request) { } results[i] = signResult{Ticket: j.Ticket, Status: "skipped"} todo = append(todo, i) + signers = append(signers, signerKey(req.Origin, j.Job)) signatures += max(j.Job.Signatures, 1) } if len(todo) == 0 { @@ -547,7 +620,14 @@ func (s *Server) sign(w http.ResponseWriter, r *http.Request) { for _, i := range todo { s.fail(ctx, be, jobs[i].Ticket, string(code), messageOf[code]) } - writeTokenError(w, err) + var left *attempts + switch code { + case token.CodePinIncorrect: + left = s.pins.wrong(signers, retriesLeft(err)) + case token.CodePinLocked: + s.pins.forget(signers) + } + writeTokenError(w, err, left) return } defer session.Close() @@ -582,7 +662,10 @@ func (s *Server) sign(w http.ResponseWriter, r *http.Request) { continue } started := time.Now() - n, err := signJob(ctx, be, session, chain, ticket) + n, err := signJob(ctx, be, session, chain, ticket, func() { + // The PIN opened a card that the backend accepts as this signer's: the card counts its attempts afresh. + s.pins.forget([]string{signerKey(req.Origin, job)}) + }) if err == nil { results[i].Status = "completed" s.opts.Logger.Info("signed", "provider", provider.Name(), "signer", info.DisplayName, "job", job.ID, "signatures", n, "duration", time.Since(started).Round(time.Millisecond)) @@ -596,7 +679,8 @@ func (s *Server) sign(w http.ResponseWriter, r *http.Request) { stop = e } } - writeJSON(w, http.StatusOK, map[string]any{"results": results, "signer": info}) + // The attempts left after this PIN, so that a form shown again does not warn about attempts the card restored. + writeJSON(w, http.StatusOK, map[string]any{"results": results, "signer": info, "attempts": s.pins.get(signers)}) } // codePINRequired answers a signature without a PIN when none is remembered, or no longer usable. @@ -608,8 +692,9 @@ type cardError struct{ error } func (e cardError) Unwrap() error { return e.error } // signJob adds the job's signatures one at a time (prepare, sign with the card, complete) until the backend says -// the job is complete, and returns how many it added. -func signJob(ctx context.Context, be Backend, session token.Session, chain [][]byte, ticket string) (int, error) { +// the job is complete, and returns how many it added. accepted runs once the backend accepts the card's certificate +// for the job's signer, at the first prepare. +func signJob(ctx context.Context, be Backend, session token.Session, chain [][]byte, ticket string, accepted func()) (int, error) { cert := session.Certificate() steps := 0 for n := 0; ; n++ { @@ -619,6 +704,7 @@ func signJob(ctx context.Context, be Backend, session token.Session, chain [][]b } if n == 0 { steps = p.Steps + accepted() } // The card signs only as many digests as the backend announced at the start. if n >= steps { @@ -679,28 +765,28 @@ var messageOf = map[token.Code]string{ token.CodeNoReader: "No smart-card reader found. Connect the NFC reader.", token.CodeNoCard: "No card on the reader. Place your CIE on the reader.", token.CodeCardRemoved: "The card moved away from the reader. Keep the CIE still on the reader and try again.", - token.CodeMiddlewareMissing: "No CIE software found on this computer.", - token.CodeNotPaired: "This CIE is not set up on this computer yet.", + token.CodeMiddlewareMissing: "CIE ID is not set up on this computer.", + token.CodeNotPaired: "This CIE is not paired with this computer yet. Pair it in CIE ID, then sign again.", token.CodeCardUnsupported: "This card is not a supported CIE.", token.CodeCardUntrusted: "This card could not be verified as a genuine CIE, so the PIN was not sent to it.", 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 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.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.", token.CodeBusy: "Another signature is in progress.", token.CodeDevice: "The card could not complete the operation. Try again.", } -func writeTokenError(w http.ResponseWriter, err error) { +// writeTokenError answers a card error, with the attempts left after a wrong PIN. +func writeTokenError(w http.ResponseWriter, err error, left *attempts) { code := token.CodeOf(err) status, ok := statusOf[code] if !ok { status = http.StatusBadGateway } body := map[string]any{"code": string(code), "message": messageOf[code]} - var te *token.Error - if errors.As(err, &te) && code == token.CodePinIncorrect && te.RetriesLeft >= 0 { - body["retriesLeft"] = te.RetriesLeft + if left != nil { + body["attempts"] = left } writeJSON(w, status, map[string]any{"error": body}) } diff --git a/internal/server/server_test.go b/internal/server/server_test.go index 0d030a7..aa42242 100644 --- a/internal/server/server_test.go +++ b/internal/server/server_test.go @@ -8,8 +8,13 @@ import ( "crypto/sha256" "encoding/json" "errors" + "html/template" "net/http" "net/http/httptest" + "net/url" + "os" + "path" + "runtime" "slices" "strconv" "strings" @@ -44,6 +49,7 @@ type failure struct{ ticket, code string } type fakeBackend struct { mu sync.Mutex steps map[string]int + signer map[string]string jobErr map[string]error prepareErr map[string]error cert []byte @@ -68,6 +74,7 @@ func (f *fakeBackend) Job(_ context.Context, t string) (*backend.Job, error) { } job := &backend.Job{ID: "job-" + t[:1], Status: "pending", Signatures: f.stepsOf(t)} job.Document.Title = "Verbale" + job.Signer.Name = f.signer[t] return job, nil } @@ -232,6 +239,125 @@ func TestGuards(t *testing.T) { } } +func TestPagesShowTheVersion(t *testing.T) { + for version, footer := range map[string]string{ + "v1.2.3": "PoliNetwork eSign 1.2.3", + "": "PoliNetwork eSign <b>", + } { + s := New(Options{Port: 47321, Version: version}) + for _, path := range []string{"/", "/sign"} { + rec := do(t, s, req{method: "GET", path: path}) + if rec.Code != http.StatusOK || !strings.Contains(rec.Body.String(), footer) { + t.Errorf("%s with version %q: %d, no %q in\n%s", path, version, rec.Code, footer, rec.Body) + } + } + } +} + +// TestSignPageDashboard: the signing page posts its messages, the first one before any request, only to a dashboard +// the agent signs for, which the agent names in the page. +func TestSignPageDashboard(t *testing.T) { + s, _, _ := setup(t) + cases := map[string]string{ + "/sign?origin=" + url.QueryEscape(dashboard) + "&ticket=" + ticket: dashboard, + "/sign?origin=" + url.QueryEscape(dashboard): dashboard, + "/sign?origin=" + url.QueryEscape("https://evil.example"): "", + "/sign?origin=" + url.QueryEscape(`">