From 51f3fd5003bf37e0622401bd91d9de3c95124ee2 Mon Sep 17 00:00:00 2001 From: GatisB Date: Mon, 28 Sep 2026 20:47:56 +0300 Subject: [PATCH 1/3] Split the CSC signing flow in two, by how the eID card is read Signed-off-by: GatisB --- CHANGELOG.md | 15 +++++++++++++++ README.md | 2 +- identity/identity.go | 14 +++++++++----- identity/identity_test.go | 10 +++++++--- 4 files changed, 32 insertions(+), 9 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 4e4140a..e386f6b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,21 @@ Notable changes to this service, newest first, per release. This file is written for whoever runs the service or integrates against it. +## v0.2.0 + +### Changed — an eParaksts Mobile login no longer permits the CSC signing flow + +The CSC remote-signing flow authenticates with the eID card only (read by a phone, or in a card reader), so +it is no longer among the flows an eParaksts Mobile login may drive. The single `csc` flow name is also +retired in favour of two, `cscEidScan` and `cscEidPlugin`, named for how the card is read. The session's +`permitted_flows` for an eParaksts Mobile login: + +```json +{ "login_method": "eparakstsMobile", "permitted_flows": ["eparakstsMobile", "eparakstsMobileEseal"] } +``` + +A Web eID or eID Scan login permits what it did before. + ## v0.1.2 ### Changed — a token is minted only from a register answer about the person who signed in diff --git a/README.md b/README.md index f0d4e84..a0022f1 100644 --- a/README.md +++ b/README.md @@ -270,7 +270,7 @@ The `identity` package is the anti-corruption layer between the identity provide |---|---|---|---|---| | `webEid` | (validated by the web-eid engine) | `high` | `webEid` | permitted (card login) | | `eidScan` | `mobile-eid` | `high` | `eidScan` | permitted | -| `eparakstsMobile` | `mobileid` \| `smart_id` \| `cloud` | `high` | `eparakstsMobile`, `eparakstsMobileEseal`, `csc` | permitted | +| `eparakstsMobile` | `mobileid` \| `smart_id` \| `cloud` | `high` | `eparakstsMobile`, `eparakstsMobileEseal` | permitted | | `eid` | `sc_plugin` \| `smartcard` | — | none | **rejected** — eID card must use Web eID | The `login_method` value is one camelCase literal shared by name with the signing service, so a login and the signature it authorises correlate on a single token. The binding **fails closed**: an unknown or empty method — and the plugin `eid` path — permits nothing. Two independent guards enforce the "eID card is Web eID only" rule: the callback rejects a login that resolves to `eid` with 403 even if the identity provider's page offered it, and the built-in login-method policy never maps a bare `eid`. Assurance-level and method vocabularies can be overridden per environment (`LOA_POLICY`) once production's exact `acr` values are confirmed. diff --git a/identity/identity.go b/identity/identity.go index bc9f9cf..e269462 100644 --- a/identity/identity.go +++ b/identity/identity.go @@ -302,14 +302,18 @@ const ( FlowEIDScan = "eidScan" FlowEParakstsMobile = "eparakstsMobile" FlowEParakstsMobileEseal = "eparakstsMobileEseal" - FlowCSC = "csc" + // The CSC remote-signing flows, named for how the eID card is read: by a phone + // (eID Scan) or in a card reader through the provider's browser extension. + FlowCSCEidScan = "cscEidScan" + FlowCSCEidPlugin = "cscEidPlugin" ) // BindingResolver implements the login-method ↔ signing-flow binding: each login // method permits a specific set of signing flows. eParaksts Mobile is the only -// method that authorizes more than one (its personal cloud signature, the -// mobile-bound organisation eSeal, and the CSC flow); a Web eID login and an eID -// Scan login each bind to their own single flow and do not cross over. +// method that authorizes more than one (its personal cloud signature and the +// mobile-bound organisation eSeal); a Web eID login and an eID Scan login each +// bind to their own single flow and do not cross over. The CSC flows authenticate +// with the eID card only, so no eParaksts Mobile login reaches them. type BindingResolver struct{} // PermittedFlows returns the signing flows a login method may drive. An unknown @@ -322,7 +326,7 @@ func (BindingResolver) PermittedFlows(loginMethod string) []string { case LoginEIDScan: return []string{FlowEIDScan} case LoginEParakstsMobile: - return []string{FlowEParakstsMobile, FlowEParakstsMobileEseal, FlowCSC} + return []string{FlowEParakstsMobile, FlowEParakstsMobileEseal} default: return nil } diff --git a/identity/identity_test.go b/identity/identity_test.go index 6d5f4da..796bbd1 100644 --- a/identity/identity_test.go +++ b/identity/identity_test.go @@ -1,6 +1,7 @@ package identity import ( + "slices" "testing" "github.com/go-quicktest/qt" @@ -88,8 +89,8 @@ func TestInterpretMethod(t *testing.T) { } // TestResolveBindsPermittedFlows proves the end-to-end binding: a mobileid AMR -// resolves to eParaksts Mobile, which permits the cloud/eSeal/csc flows (and not -// the eID or Web eID flows). +// resolves to eParaksts Mobile, which permits the cloud and eSeal flows (and not +// the eID, Web eID or CSC flows — CSC authenticates with the eID card only). func TestResolveBindsPermittedFlows(t *testing.T) { r := NewResolver(nil) id := r.Resolve(UserInfo{ @@ -99,7 +100,10 @@ func TestResolveBindsPermittedFlows(t *testing.T) { qt.Check(t, qt.Equals(id.LoginMethod, LoginEParakstsMobile)) qt.Check(t, qt.DeepEquals(BindingResolver{}.PermittedFlows(id.LoginMethod), - []string{FlowEParakstsMobile, FlowEParakstsMobileEseal, FlowCSC})) + []string{FlowEParakstsMobile, FlowEParakstsMobileEseal})) + for _, csc := range []string{FlowCSCEidScan, FlowCSCEidPlugin} { + qt.Check(t, qt.IsFalse(slices.Contains(BindingResolver{}.PermittedFlows(LoginEParakstsMobile), csc))) + } } // TestEIDScanBinding proves eID Scan resolves to its own login method and binds From 2696fe8550fb68f1f9e8687c077d5b5da0249855 Mon Sep 17 00:00:00 2001 From: GatisB Date: Mon, 28 Sep 2026 23:14:18 +0300 Subject: [PATCH 2/3] Cache Go modules and builds in the image build Signed-off-by: GatisB --- Dockerfile | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/Dockerfile b/Dockerfile index 1c47851..13d5e54 100644 --- a/Dockerfile +++ b/Dockerfile @@ -3,14 +3,18 @@ ARG GO_VERSION=1.27.0 FROM golang:${GO_VERSION} AS build WORKDIR /src -COPY . . +# The module list alone, so an edit to the source reuses the downloaded modules. +COPY go.mod go.sum ./ +RUN --mount=type=cache,target=/go/pkg/mod go mod download -RUN go mod download +COPY . . # VERSION is supplied by ci.yml (build-args) and reaches the binary through -X. # Without both halves the pipeline computes a version that is thrown away and # every log line reports the dev default instead of the build that is running. ARG VERSION=dev -RUN CGO_ENABLED=0 GOOS=linux go build -trimpath -ldflags="-s -w -X main.Version=${VERSION}" -o /out/server ./cmd/server +RUN --mount=type=cache,target=/go/pkg/mod \ + --mount=type=cache,target=/root/.cache/go-build \ + CGO_ENABLED=0 GOOS=linux go build -trimpath -ldflags="-s -w -X main.Version=${VERSION}" -o /out/server ./cmd/server FROM ghcr.io/wntrtech/scratch:v1.0.0-3 COPY --from=build /out/server /server From 19828fbed4409e8cb4663003fa7f52a38217c4f2 Mon Sep 17 00:00:00 2001 From: GatisB Date: Tue, 29 Sep 2026 09:15:04 +0300 Subject: [PATCH 3/3] Let each card login sign through its CSC flow Signed-off-by: GatisB --- CHANGELOG.md | 13 ++++++++++++- README.md | 6 +++--- identity/identity.go | 17 ++++++++++------- identity/identity_test.go | 22 ++++++++++++++++++---- 4 files changed, 43 insertions(+), 15 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index e386f6b..881677f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,17 @@ runs the service or integrates against it. ## v0.2.0 +### Changed — each card login also permits the CSC flow that reads the card its way + +A Web eID login (the card in a reader) now permits `cscEidPlugin` besides `webEid`; an eID Scan login (the card +read by a phone) permits `cscEidScan` besides `eidScan`. Neither login reaches the other card route. The +session's `permitted_flows`: + +```json +{ "login_method": "webEid", "permitted_flows": ["webEid", "cscEidPlugin"] } +{ "login_method": "eidScan", "permitted_flows": ["eidScan", "cscEidScan"] } +``` + ### Changed — an eParaksts Mobile login no longer permits the CSC signing flow The CSC remote-signing flow authenticates with the eID card only (read by a phone, or in a card reader), so @@ -16,7 +27,7 @@ retired in favour of two, `cscEidScan` and `cscEidPlugin`, named for how the car { "login_method": "eparakstsMobile", "permitted_flows": ["eparakstsMobile", "eparakstsMobileEseal"] } ``` -A Web eID or eID Scan login permits what it did before. +A Web eID or eID Scan login permits the CSC flow that reads its card the same way (above). ## v0.1.2 diff --git a/README.md b/README.md index a0022f1..010c88d 100644 --- a/README.md +++ b/README.md @@ -268,12 +268,12 @@ The `identity` package is the anti-corruption layer between the identity provide | Login method (`login_method`) | Recognised token | Assurance | Permitted signing flows | Status | |---|---|---|---|---| -| `webEid` | (validated by the web-eid engine) | `high` | `webEid` | permitted (card login) | -| `eidScan` | `mobile-eid` | `high` | `eidScan` | permitted | +| `webEid` | (validated by the web-eid engine) | `high` | `webEid`, `cscEidPlugin` | permitted (card login) | +| `eidScan` | `mobile-eid` | `high` | `eidScan`, `cscEidScan` | permitted | | `eparakstsMobile` | `mobileid` \| `smart_id` \| `cloud` | `high` | `eparakstsMobile`, `eparakstsMobileEseal` | permitted | | `eid` | `sc_plugin` \| `smartcard` | — | none | **rejected** — eID card must use Web eID | -The `login_method` value is one camelCase literal shared by name with the signing service, so a login and the signature it authorises correlate on a single token. The binding **fails closed**: an unknown or empty method — and the plugin `eid` path — permits nothing. Two independent guards enforce the "eID card is Web eID only" rule: the callback rejects a login that resolves to `eid` with 403 even if the identity provider's page offered it, and the built-in login-method policy never maps a bare `eid`. Assurance-level and method vocabularies can be overridden per environment (`LOA_POLICY`) once production's exact `acr` values are confirmed. +A card login permits the CSC signing flow that reads the card the same way the login did — the card in a reader for Web eID, read by a phone for eID Scan — and never the other card route. The `login_method` value is one camelCase literal shared by name with the signing service, so a login and the signature it authorises correlate on a single token. The binding **fails closed**: an unknown or empty method — and the plugin `eid` path — permits nothing. Two independent guards enforce the "eID card is Web eID only" rule: the callback rejects a login that resolves to `eid` with 403 even if the identity provider's page offered it, and the built-in login-method policy never maps a bare `eid`. Assurance-level and method vocabularies can be overridden per environment (`LOA_POLICY`) once production's exact `acr` values are confirmed. **Step-up** re-authenticates in place: it elevates the *existing* session rather than creating a new one, and enforces that the method actually achieved matches the one requested — so a user cannot "step up" to a stronger method yet authenticate with the old one and keep the binding unchanged. diff --git a/identity/identity.go b/identity/identity.go index e269462..16887e5 100644 --- a/identity/identity.go +++ b/identity/identity.go @@ -309,11 +309,14 @@ const ( ) // BindingResolver implements the login-method ↔ signing-flow binding: each login -// method permits a specific set of signing flows. eParaksts Mobile is the only -// method that authorizes more than one (its personal cloud signature and the -// mobile-bound organisation eSeal); a Web eID login and an eID Scan login each -// bind to their own single flow and do not cross over. The CSC flows authenticate -// with the eID card only, so no eParaksts Mobile login reaches them. +// method permits a specific set of signing flows. A card login permits two ways to +// sign with the card, both reading it the way the login did: a Web eID login (the +// card in a reader) permits Web eID and the CSC flow through the provider's browser +// extension; an eID Scan login (the card read by a phone) permits eID Scan and the +// CSC flow read by eID Scan. The two card logins do not cross over. eParaksts Mobile +// permits its personal cloud signature and the mobile-bound organisation eSeal; the +// CSC flows authenticate with the eID card only, so no eParaksts Mobile login +// reaches them. type BindingResolver struct{} // PermittedFlows returns the signing flows a login method may drive. An unknown @@ -322,9 +325,9 @@ type BindingResolver struct{} func (BindingResolver) PermittedFlows(loginMethod string) []string { switch loginMethod { case LoginWebEID: - return []string{FlowWebEID} + return []string{FlowWebEID, FlowCSCEidPlugin} case LoginEIDScan: - return []string{FlowEIDScan} + return []string{FlowEIDScan, FlowCSCEidScan} case LoginEParakstsMobile: return []string{FlowEParakstsMobile, FlowEParakstsMobileEseal} default: diff --git a/identity/identity_test.go b/identity/identity_test.go index 796bbd1..0cab84a 100644 --- a/identity/identity_test.go +++ b/identity/identity_test.go @@ -107,7 +107,8 @@ func TestResolveBindsPermittedFlows(t *testing.T) { } // TestEIDScanBinding proves eID Scan resolves to its own login method and binds -// to its own single signing flow (it no longer shares one with Web eID). +// to its own signing flows: eID Scan, and the CSC flow that reads the card the same +// way (it shares neither with Web eID). func TestEIDScanBinding(t *testing.T) { id := NewResolver(nil).Resolve(UserInfo{ ACR: "urn:eparaksts:authentication:flow:mobile-eid", @@ -115,21 +116,34 @@ func TestEIDScanBinding(t *testing.T) { }) qt.Check(t, qt.Equals(id.LoginMethod, LoginEIDScan)) - qt.Check(t, qt.DeepEquals(BindingResolver{}.PermittedFlows(LoginEIDScan), []string{FlowEIDScan})) + qt.Check(t, qt.DeepEquals(BindingResolver{}.PermittedFlows(LoginEIDScan), []string{FlowEIDScan, FlowCSCEidScan})) qt.Check(t, qt.Equals(id.LoA, LoAHigh)) // mobile-eid is a QSCD method } // TestWebEIDBinding proves the Web eID card login (set directly by the Web eID -// adapter, not via the AMR resolver) binds to its own Web eID signing flow, and +// adapter, not via the AMR resolver) binds to its own Web eID signing flow and the +// CSC flow that reads the card in a reader too, and // that a login method that permits nothing (legacy plugin eID / unknown / empty) // fails closed. func TestWebEIDBinding(t *testing.T) { - qt.Check(t, qt.DeepEquals(BindingResolver{}.PermittedFlows(LoginWebEID), []string{FlowWebEID})) + qt.Check(t, qt.DeepEquals(BindingResolver{}.PermittedFlows(LoginWebEID), []string{FlowWebEID, FlowCSCEidPlugin})) qt.Check(t, qt.IsNil(BindingResolver{}.PermittedFlows(LoginEID))) qt.Check(t, qt.IsNil(BindingResolver{}.PermittedFlows(""))) qt.Check(t, qt.IsNil(BindingResolver{}.PermittedFlows("unknown"))) } +// TestCardLoginsDoNotCrossOver proves each card login reaches only the CSC flow that +// reads the card the way the login did: never the other card route, and never the +// other card login's own flow. +func TestCardLoginsDoNotCrossOver(t *testing.T) { + web := BindingResolver{}.PermittedFlows(LoginWebEID) + scan := BindingResolver{}.PermittedFlows(LoginEIDScan) + qt.Check(t, qt.IsFalse(slices.Contains(web, FlowCSCEidScan))) + qt.Check(t, qt.IsFalse(slices.Contains(web, FlowEIDScan))) + qt.Check(t, qt.IsFalse(slices.Contains(scan, FlowCSCEidPlugin))) + qt.Check(t, qt.IsFalse(slices.Contains(scan, FlowWebEID))) +} + // TestResolveLoA covers both acr shapes: the production Safelayer level URN and // the demo flow URN (which is why a real mobile login previously showed "low"). func TestResolveLoA(t *testing.T) {