Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
26 changes: 26 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,32 @@
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 — 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
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 the CSC flow that reads its card the same way (above).

## v0.1.2

### Changed — a token is minted only from a register answer about the person who signed in
Expand Down
10 changes: 7 additions & 3 deletions Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
8 changes: 4 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 |
| `eparakstsMobile` | `mobileid` \| `smart_id` \| `cloud` | `high` | `eparakstsMobile`, `eparakstsMobileEseal`, `csc` | 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.

Expand Down
23 changes: 15 additions & 8 deletions identity/identity.go
Original file line number Diff line number Diff line change
Expand Up @@ -302,14 +302,21 @@ 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 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
Expand All @@ -318,11 +325,11 @@ 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, FlowCSC}
return []string{FlowEParakstsMobile, FlowEParakstsMobileEseal}
default:
return nil
}
Expand Down
32 changes: 25 additions & 7 deletions identity/identity_test.go
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
package identity

import (
"slices"
"testing"

"github.com/go-quicktest/qt"
Expand Down Expand Up @@ -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{
Expand All @@ -99,33 +100,50 @@ 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
// 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",
AMR: []string{"urn:eparaksts:tws:policies:authentication:adaptive:methods:mobile-eid"},
})

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) {
Expand Down
Loading