diff --git a/CHANGELOG.md b/CHANGELOG.md index 359e8a607..e303efb75 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,21 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [0.8.0] - 2021-03-17 +### Added +* Support for device pairing to prevent shoulder surfing (i.e. make it impossible for someone in close physical proximity to a user to scan the QR code that was meant for the user) + * Introduced new frontend endpoints to manage device pairing + * The API of the `irmaserver` package has two new functions `SetFrontendOptions` and `PairingCompleted` + * A new server status `"PAIRING"` is introduced +* A new function `SessionStatus` is available in the API of the `irmaserver` to get a channel with status updates of an IRMA session + +### Changes +* The `server.SessionPackage` struct now contains an extra field `FrontendAuth` +* The `irma.Qr` struct now contains an optional field `PairingRecommended` (named `pairingHint` when being marshalled to JSON) that is set to true when pairing is recommended for that session, as indication to the frontend +* The `StartSession` function from the API of the `irmaserver` package now returns three values: the session pointer (type *irma.QR), the requestor token (type irma.RequestorToken) and the frontend authorization token (type irma.FrontendAuthorization) +* The `token` parameter, as used by most functions in the API of the `irmaserver` package, now has the type `irma.RequestorToken` +* The `server.Status` type has been moved to `irma.ServerStatus`; the related constants are also moved, e.g. from `server.StatusInitialized` to `irma.ServerStatusInitialized` + ## [0.7.0] - 2021-03-17 ### Fixed * Bug causing scheme updating to fail if OS temp dir is on other file system than the schemes @@ -155,6 +170,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Combined issuance-disclosure requests with two schemes one of which has a keyshare server now work as expected - Various other bugfixes +[0.8.0]: https://github.com/privacybydesign/irmago/compare/v0.7.0...v0.8.0 [0.7.0]: https://github.com/privacybydesign/irmago/compare/v0.6.1...v0.7.0 [0.6.1]: https://github.com/privacybydesign/irmago/compare/v0.6.0...v0.6.1 [0.6.0]: https://github.com/privacybydesign/irmago/compare/v0.5.1...v0.6.0 diff --git a/go.mod b/go.mod index cdef2dc52..1c1bc988e 100644 --- a/go.mod +++ b/go.mod @@ -16,6 +16,7 @@ require ( github.com/go-sql-driver/mysql v1.5.0 // indirect github.com/hashicorp/go-multierror v1.0.0 github.com/hashicorp/go-retryablehttp v0.6.2 + github.com/inconshreveable/mousetrap v1.0.0 // indirect github.com/jasonlvhit/gocron v0.0.0-20180312192515-54194c9749d4 github.com/jinzhu/gorm v1.9.12 github.com/lib/pq v1.3.0 // indirect @@ -26,6 +27,7 @@ require ( github.com/onsi/ginkgo v1.12.0 // indirect github.com/onsi/gomega v1.9.0 // indirect github.com/privacybydesign/gabi v0.0.0-20210409092845-6113e0d3ec81 + github.com/pelletier/go-toml v1.2.0 // indirect github.com/sietseringers/cobra v1.0.1-0.20200909200314-c50c3838234b github.com/sietseringers/go-sse v0.0.0-20200801161811-e2cf2c63ca50 github.com/sietseringers/pflag v1.0.4-0.20200909193609-0cde7e893819 diff --git a/go.sum b/go.sum index 8b555d02c..38b760478 100644 --- a/go.sum +++ b/go.sum @@ -175,6 +175,7 @@ github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= github.com/lib/pq v1.1.1/go.mod h1:5WUZQaWbwv1U+lTReE5YruASi9Al49XbQIvNi/34Woo= github.com/lib/pq v1.3.0 h1:/qkRGz8zljWiDcFvgpwUpwIAPu3r07TDvs3Rws+o/pU= github.com/lib/pq v1.3.0/go.mod h1:5WUZQaWbwv1U+lTReE5YruASi9Al49XbQIvNi/34Woo= +github.com/magiconair/properties v1.8.0/go.mod h1:PppfXfuXeibc/6YijjN8zIbojt8czPbwD3XqdrwzmxQ= github.com/magiconair/properties v1.8.1 h1:ZC2Vc7/ZFkGmsVC9KvOjumD+G5lXy2RtTKyzRKO2BQ4= github.com/magiconair/properties v1.8.1/go.mod h1:PppfXfuXeibc/6YijjN8zIbojt8czPbwD3XqdrwzmxQ= github.com/mattn/go-colorable v0.0.9 h1:UVL0vNpWh04HeJXV0KLcaT7r06gOH2l4OW6ddYRUIY4= @@ -274,8 +275,13 @@ github.com/spf13/afero v1.2.0 h1:O9FblXGxoTc51M+cqr74Bm2Tmt4PvkA5iu/j8HrkNuY= github.com/spf13/afero v1.2.0/go.mod h1:9ZxEEn6pIJ8Rxe320qSDBk6AsU0r9pR7Q4OcevTdifk= github.com/spf13/cast v1.3.0 h1:oget//CVOEoFewqQxwr0Ej5yjygnqGkvggSE/gB35Q8= github.com/spf13/cast v1.3.0/go.mod h1:Qx5cxh0v+4UWYiBimWS+eyWzqEqokIECu5etghLkUJE= +github.com/spf13/cobra v1.0.0 h1:6m/oheQuQ13N9ks4hubMG6BnvwOeaJrqSPLahSnczz8= +github.com/spf13/cobra v1.0.0/go.mod h1:/6GTrnGXV9HjY+aR4k0oJ5tcvakLuG6EuKReYlHNrgE= github.com/spf13/jwalterweatherman v1.0.0 h1:XHEdyB+EcvlqZamSM4ZOMGlc93t6AcsBEu9Gc1vn7yk= github.com/spf13/jwalterweatherman v1.0.0/go.mod h1:cQK4TGJAtQXfYWX+Ddv3mKDzgVb68N+wFjFa4jdeBTo= +github.com/spf13/pflag v1.0.3 h1:zPAT6CGy6wXeQ7NtTnaTerfKOsV6V6F8agHXFiazDkg= +github.com/spf13/pflag v1.0.3/go.mod h1:DYY7MBk1bdzusC3SYhjObp+wFpr4gzcvqqNjLnInEg4= +github.com/spf13/viper v1.4.0/go.mod h1:PTJ7Z/lr49W6bUbkmS1V3by4uWynFiR9p7+dSq/yZzE= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= github.com/stretchr/objx v0.1.1/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs= @@ -292,6 +298,7 @@ github.com/timshannon/bolthold v0.0.0-20180829183128-83840edea944/go.mod h1:jUig github.com/timshannon/bolthold v0.0.0-20190812165541-a85bcc049a2e h1:FC5JjwU5y5ZBR/vH8LhmPman3k5dep45jRyCpR1VDVQ= github.com/timshannon/bolthold v0.0.0-20190812165541-a85bcc049a2e/go.mod h1:jUigdmrbdCxcIDEFrq82t4X9805XZfwFZoYUap0ET/U= github.com/tmc/grpc-websocket-proxy v0.0.0-20190109142713-0ad062ec5ee5/go.mod h1:ncp9v5uamzpCO7NfCPTXjqaC+bZgJeR0sMTm6dMHP7U= +github.com/ugorji/go v1.1.4/go.mod h1:uQMGLiO92mf5W77hV/PUCpI3pbzQx3CRekS0kk+RGrc= github.com/urfave/cli v1.20.0/go.mod h1:70zkFmudgCuE/ngEzBv17Jvp/497gISqfk5gWijbERA= github.com/x-cray/logrus-prefixed-formatter v0.5.2 h1:00txxvfBM9muc0jiLIEAkAcIMJzfthRT6usrui8uGmg= github.com/x-cray/logrus-prefixed-formatter v0.5.2/go.mod h1:2duySbKsL6M18s5GU7VPsoEPHyzalCE06qoARUCeBBE= @@ -299,6 +306,7 @@ github.com/x448/float16 v0.8.3/go.mod h1:14CWIYCyZA/cWjXOioeEpHeN/83MdbZDRQHoFcY github.com/x448/float16 v0.8.4 h1:qLwI1I70+NjRFUR3zs1JPUCgaCXSh3SW62uAKT1mSBM= github.com/x448/float16 v0.8.4/go.mod h1:14CWIYCyZA/cWjXOioeEpHeN/83MdbZDRQHoFcYsOfg= github.com/xiang90/probing v0.0.0-20190116061207-43a291ad63a2/go.mod h1:UETIi67q53MR2AWcXfiuqkDkRtnGDLqkBTpCHuJHxtU= +github.com/xordataexchange/crypt v0.0.3-0.20170626215501-b2862e3d0a77/go.mod h1:aYKd//L2LvnjZzWKhF00oedf4jCCReLcmhLdhm1A27Q= go.etcd.io/bbolt v1.3.0/go.mod h1:IbVyRI1SCnLcuJnV2u8VeU0CEYM7e686BmAb1XKL+uU= go.etcd.io/bbolt v1.3.2 h1:Z/90sZLPOeCy2PwprqkFa25PdkusRzaj9P8zm/KNyvk= go.etcd.io/bbolt v1.3.2/go.mod h1:IbVyRI1SCnLcuJnV2u8VeU0CEYM7e686BmAb1XKL+uU= @@ -350,6 +358,7 @@ golang.org/x/net v0.0.0-20190311183353-d8887717615a/go.mod h1:t9HGtf8HONx5eT2rtn golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= golang.org/x/net v0.0.0-20190501004415-9ce7a6920f09/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= golang.org/x/net v0.0.0-20190503192946-f4e77d36d62c/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= +golang.org/x/net v0.0.0-20190522155817-f3200d17e092/go.mod h1:HSz+uSET+XFnRR8LxR5pz3Of3rY3CfYBVs4xY44aLks= golang.org/x/net v0.0.0-20190603091049-60506f45cf65/go.mod h1:HSz+uSET+XFnRR8LxR5pz3Of3rY3CfYBVs4xY44aLks= golang.org/x/net v0.0.0-20190620200207-3b0461eec859 h1:R/3boaszxrf1GEUWTVDzSKVwLmSJpwZ1yqXm8j0v2QI= golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= @@ -425,6 +434,7 @@ google.golang.org/genproto v0.0.0-20190911173649-1774047e7e51/go.mod h1:IbNlFCBr google.golang.org/genproto v0.0.0-20191108220845-16a3f7862a1a/go.mod h1:n3cpQtvxv34hfy77yVDNjmbRyujviMdxYliBSkLhpCc= google.golang.org/grpc v1.19.0/go.mod h1:mqu4LbDTu4XGKhr4mRzUsmM4RtVoemTSY81AxZiDr8c= google.golang.org/grpc v1.20.1/go.mod h1:10oTOabMzJvdu6/UiuZezV6QK5dSlG84ov/aaiqXj38= +google.golang.org/grpc v1.21.0/go.mod h1:oYelfM1adQP15Ek0mdvEgi9Df8B9CZIaU1084ijfRaM= google.golang.org/grpc v1.21.1/go.mod h1:oYelfM1adQP15Ek0mdvEgi9Df8B9CZIaU1084ijfRaM= gopkg.in/alecthomas/kingpin.v2 v2.2.6/go.mod h1:FMv+mEhP44yOT+4EoQTLFTRgOQ1FBLkstjWtayDeSgw= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= @@ -440,6 +450,7 @@ gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7 h1:uRGJdciOHaEIrze2W8Q3AKkep gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7/go.mod h1:dt/ZhP58zS4L8KSrWDmTeBkI65Dw0HsyUHuEVlX15mw= gopkg.in/yaml.v2 v2.0.0-20170812160011-eb3733d160e7/go.mod h1:JAlM8MvJe8wmxCU4Bli9HhUf9+ttbYbLASfIpnQbh74= gopkg.in/yaml.v2 v2.2.1/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= +gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= gopkg.in/yaml.v2 v2.2.4 h1:/eiJrUcujPVeJ3xlSWaiNi3uSVmDGBK1pDHUHAnao1I= gopkg.in/yaml.v2 v2.2.4/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c h1:dUUwHk2QECo/6vqA44rthZ8ie2QXMNeKRTHCNY2nXvo= diff --git a/internal/common/common.go b/internal/common/common.go index c31a1fdf1..b1a707363 100644 --- a/internal/common/common.go +++ b/internal/common/common.go @@ -27,6 +27,8 @@ var ForceHTTPS = true const ( sessionChars = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789" sessionTokenLength = 20 + pairingCodeChars = "0123456789" + pairingCodeLength = 4 ) // AssertPathExists returns nil only if it has been successfully @@ -273,15 +275,23 @@ type SSECtx struct { } func NewSessionToken() string { - r := make([]byte, sessionTokenLength) + return newRandomString(sessionTokenLength, sessionChars) +} + +func NewPairingCode() string { + return newRandomString(pairingCodeLength, pairingCodeChars) +} + +func newRandomString(count int, characterSet string) string { + r := make([]byte, count) _, err := rand.Read(r) if err != nil { panic(err) } - b := make([]byte, sessionTokenLength) + b := make([]byte, count) for i := range b { - b[i] = sessionChars[r[i]%byte(len(sessionChars))] + b[i] = characterSet[r[i]%byte(len(characterSet))] } return string(b) } diff --git a/internal/sessiontest/handlers_test.go b/internal/sessiontest/handlers_test.go index afa305121..7acd3b7fa 100644 --- a/internal/sessiontest/handlers_test.go +++ b/internal/sessiontest/handlers_test.go @@ -82,6 +82,9 @@ type TestHandler struct { expectedServerName *irma.RequestorInfo wait time.Duration result string + pairingCodeChan chan string + dismisser irmaclient.SessionDismisser + frontendTransport *irma.HTTPTransport } func (th TestHandler) KeyshareEnrollmentIncomplete(manager irma.SchemeManagerIdentifier) { @@ -96,7 +99,7 @@ func (th TestHandler) KeyshareEnrollmentMissing(manager irma.SchemeManagerIdenti func (th TestHandler) KeyshareEnrollmentDeleted(manager irma.SchemeManagerIdentifier) { th.Failure(&irma.SessionError{Err: errors.Errorf("Keyshare enrollment deleted for %s", manager.String())}) } -func (th TestHandler) StatusUpdate(action irma.Action, status irma.Status) {} +func (th TestHandler) StatusUpdate(action irma.Action, status irma.ClientStatus) {} func (th *TestHandler) Success(result string) { th.result = result th.c <- nil @@ -150,6 +153,15 @@ func (th TestHandler) RequestSchemeManagerPermission(manager *irma.SchemeManager func (th TestHandler) RequestPin(remainingAttempts int, callback irmaclient.PinHandler) { callback(true, "12345") } +func (th TestHandler) PairingRequired(pairingCode string) { + // Send pairing code via channel to calling test. This is done such that + // calling tests can detect it when this handler is skipped unexpectedly. + if th.pairingCodeChan != nil { + th.pairingCodeChan <- pairingCode + return + } + th.Failure(&irma.SessionError{ErrorType: irma.ErrorType("Pairing required")}) +} type SessionResult struct { Err error @@ -197,7 +209,7 @@ type ManualTestHandler struct { action irma.Action } -func (th *ManualTestHandler) StatusUpdate(action irma.Action, status irma.Status) { +func (th *ManualTestHandler) StatusUpdate(action irma.Action, status irma.ClientStatus) { th.action = action } diff --git a/internal/sessiontest/legacy_test.go b/internal/sessiontest/legacy_test.go index 0eef65f39..2ca050422 100644 --- a/internal/sessiontest/legacy_test.go +++ b/internal/sessiontest/legacy_test.go @@ -34,3 +34,39 @@ func TestSessionUsingLegacyStorage(t *testing.T) { // Test whether credential is still there after the storage has been reloaded sessionHelper(t, getDisclosureRequest(idRoot), "verification", client) } + +func TestWithoutPairingSupport(t *testing.T) { + defaultMaxVersion := maxClientVersion + defer func() { + maxClientVersion = defaultMaxVersion + }() + maxClientVersion = &irma.ProtocolVersion{Major: 2, Minor: 7} + + t.Run("TestSigningSession", TestSigningSession) + t.Run("TestDisclosureSession", TestDisclosureSession) + t.Run("TestNoAttributeDisclosureSession", TestNoAttributeDisclosureSession) + t.Run("TestEmptyDisclosure", TestEmptyDisclosure) + t.Run("TestIssuanceSession", TestIssuanceSession) + t.Run("TestMultipleIssuanceSession", TestMultipleIssuanceSession) + t.Run("TestDefaultCredentialValidity", TestDefaultCredentialValidity) + t.Run("TestIssuanceDisclosureEmptyAttributes", TestIssuanceDisclosureEmptyAttributes) + t.Run("TestIssuanceOptionalZeroLengthAttributes", TestIssuanceOptionalZeroLengthAttributes) + t.Run("TestIssuanceOptionalSetAttributes", TestIssuanceOptionalSetAttributes) + t.Run("TestIssuanceSameAttributesNotSingleton", TestIssuanceSameAttributesNotSingleton) + t.Run("TestIssuancePairing", TestIssuancePairing) + t.Run("TestLargeAttribute", TestLargeAttribute) + t.Run("TestIssuanceSingletonCredential", TestIssuanceSingletonCredential) + t.Run("TestUnsatisfiableDisclosureSession", TestUnsatisfiableDisclosureSession) + t.Run("TestAttributeByteEncoding", TestAttributeByteEncoding) + t.Run("TestOutdatedClientIrmaConfiguration", TestOutdatedClientIrmaConfiguration) + t.Run("TestDisclosureNewAttributeUpdateSchemeManager", TestDisclosureNewAttributeUpdateSchemeManager) + t.Run("TestIssueNewAttributeUpdateSchemeManager", TestIssueNewAttributeUpdateSchemeManager) + t.Run("TestIssueOptionalAttributeUpdateSchemeManager", TestIssueOptionalAttributeUpdateSchemeManager) + t.Run("TestIssueNewCredTypeUpdateSchemeManager", TestIssueNewCredTypeUpdateSchemeManager) + t.Run("TestDisclosureNewCredTypeUpdateSchemeManager", TestDisclosureNewCredTypeUpdateSchemeManager) + t.Run("TestDisclosureNonexistingCredTypeUpdateSchemeManager", TestDisclosureNonexistingCredTypeUpdateSchemeManager) + t.Run("TestStaticQRSession", TestStaticQRSession) + t.Run("TestIssuedCredentialIsStored", TestIssuedCredentialIsStored) + t.Run("TestPOSTSizeLimit", TestPOSTSizeLimit) + t.Run("TestDisablePairing", TestDisablePairing) +} diff --git a/internal/sessiontest/main_test.go b/internal/sessiontest/main_test.go index d5227a3b2..ed69bc1d4 100644 --- a/internal/sessiontest/main_test.go +++ b/internal/sessiontest/main_test.go @@ -6,8 +6,10 @@ import ( "io/ioutil" "os" "path/filepath" + "reflect" "testing" "time" + "unsafe" jwt "github.com/dgrijalva/jwt-go" irma "github.com/privacybydesign/irmago" @@ -18,6 +20,9 @@ import ( "github.com/stretchr/testify/require" ) +// Defines the maximum protocol version of an irmaclient in tests +var maxClientVersion = &irma.ProtocolVersion{Major: 2, Minor: 8} + func TestMain(m *testing.M) { // Create HTTP server for scheme managers test.StartSchemeManagerHttpServer() @@ -44,6 +49,12 @@ func parseExistingStorage(t *testing.T, storage string) (*irmaclient.Client, *Te handler, ) require.NoError(t, err) + + // Set max version we want to test on + version := extractClientMaxVersion(client) + version.Major = maxClientVersion.Major + version.Minor = maxClientVersion.Minor + client.SetPreferences(irmaclient.Preferences{DeveloperMode: true}) return client, handler } @@ -120,7 +131,7 @@ func getMultipleIssuanceRequest() *irma.IssuanceRequest { var TestType = "irmaserver-jwt" -func startSession(t *testing.T, request irma.SessionRequest, sessiontype string) *server.SessionPackage { +func startSession(t *testing.T, request irma.SessionRequest, sessiontype string) (*server.SessionPackage, *irma.FrontendSessionRequest) { var ( sesPkg server.SessionPackage err error @@ -141,7 +152,7 @@ func startSession(t *testing.T, request irma.SessionRequest, sessiontype string) } require.NoError(t, err) - return &sesPkg + return &sesPkg, sesPkg.FrontendRequest } func getJwt(t *testing.T, request irma.SessionRequest, sessiontype string, alg jwt.SigningMethod) string { @@ -189,7 +200,14 @@ func getJwt(t *testing.T, request irma.SessionRequest, sessiontype string, alg j return j } -func sessionHelper(t *testing.T, request irma.SessionRequest, sessiontype string, client *irmaclient.Client) string { +func sessionHelperWithFrontendOptions( + t *testing.T, + request irma.SessionRequest, + sessiontype string, + client *irmaclient.Client, + frontendOptionsHandler func(handler *TestHandler), + pairingHandler func(handler *TestHandler), +) string { if client == nil { var handler *TestClientHandler client, handler = parseStorage(t) @@ -201,25 +219,70 @@ func sessionHelper(t *testing.T, request irma.SessionRequest, sessiontype string defer StopRequestorServer() } - sesPkg := startSession(t, request, sessiontype) + sesPkg, frontendRequest := startSession(t, request, sessiontype) c := make(chan *SessionResult) - h := &TestHandler{t: t, c: c, client: client, expectedServerName: expectedRequestorInfo(t, client.Configuration)} + h := &TestHandler{ + t: t, + c: c, + client: client, + expectedServerName: expectedRequestorInfo(t, client.Configuration), + } + + if frontendOptionsHandler != nil || pairingHandler != nil { + h.pairingCodeChan = make(chan string) + h.frontendTransport = irma.NewHTTPTransport(sesPkg.SessionPtr.URL, false) + h.frontendTransport.SetHeader(irma.AuthorizationHeader, string(frontendRequest.Authorization)) + } + if frontendOptionsHandler != nil { + frontendOptionsHandler(h) + } + qrjson, err := json.Marshal(sesPkg.SessionPtr) require.NoError(t, err) - client.NewSession(string(qrjson), h) + h.dismisser = client.NewSession(string(qrjson), h) + + if pairingHandler != nil { + pairingHandler(h) + } if result := <-c; result != nil { require.NoError(t, result.Err) } var resJwt string - err = irma.NewHTTPTransport("http://localhost:48682/session/"+sesPkg.Token, false).Get("result-jwt", &resJwt) + err = irma.NewHTTPTransport("http://localhost:48682/session/"+string(sesPkg.Token), false).Get("result-jwt", &resJwt) require.NoError(t, err) return resJwt } +func sessionHelper(t *testing.T, request irma.SessionRequest, sessiontype string, client *irmaclient.Client) string { + return sessionHelperWithFrontendOptions(t, request, sessiontype, client, nil, nil) +} + +func extractClientTransport(dismisser irmaclient.SessionDismisser) *irma.HTTPTransport { + return extractPrivateField(dismisser, "transport").(*irma.HTTPTransport) +} + +func extractClientMaxVersion(client *irmaclient.Client) *irma.ProtocolVersion { + return extractPrivateField(client, "maxVersion").(*irma.ProtocolVersion) +} + +func extractPrivateField(i interface{}, field string) interface{} { + rct := reflect.ValueOf(i).Elem().FieldByName(field) + return reflect.NewAt(rct.Type(), unsafe.Pointer(rct.UnsafeAddr())).Elem().Interface() +} + +func setPairingMethod(method irma.PairingMethod, handler *TestHandler) string { + optionsRequest := irma.NewFrontendOptionsRequest() + optionsRequest.PairingMethod = method + options := &irma.SessionOptions{} + err := handler.frontendTransport.Post("frontend/options", options, optionsRequest) + require.NoError(handler.t, err) + return options.PairingCode +} + func expectedRequestorInfo(t *testing.T, conf *irma.Configuration) *irma.RequestorInfo { if common.ForceHTTPS { return irma.NewRequestorInfo("localhost") diff --git a/internal/sessiontest/requestor_test.go b/internal/sessiontest/requestor_test.go index b0bf6ef68..4653ae781 100644 --- a/internal/sessiontest/requestor_test.go +++ b/internal/sessiontest/requestor_test.go @@ -1,10 +1,7 @@ package sessiontest import ( - "bytes" "encoding/json" - "io/ioutil" - "net/http" "reflect" "testing" "time" @@ -60,7 +57,7 @@ func requestorSessionHelper(t *testing.T, request interface{}, client *irmaclien clientChan := make(chan *SessionResult, 2) serverChan := make(chan *server.SessionResult) - qr, token, err := irmaServer.StartSession(request, func(result *server.SessionResult) { + qr, requestorToken, _, err := irmaServer.StartSession(request, func(result *server.SessionResult) { serverChan <- result }) require.NoError(t, err) @@ -68,13 +65,13 @@ func requestorSessionHelper(t *testing.T, request interface{}, client *irmaclien var h irmaclient.Handler requestor := expectedRequestorInfo(t, client.Configuration) if opts&sessionOptionUnsatisfiableRequest > 0 { - h = &UnsatisfiableTestHandler{TestHandler: TestHandler{t, clientChan, client, requestor, 0, ""}} + h = &UnsatisfiableTestHandler{TestHandler: TestHandler{t, clientChan, client, requestor, 0, "", nil, nil, nil}} } else { var wait time.Duration = 0 if opts&sessionOptionClientWait > 0 { wait = 2 * time.Second } - h = &TestHandler{t, clientChan, client, requestor, wait, ""} + h = &TestHandler{t, clientChan, client, requestor, wait, "", nil, nil, nil} } j, err := json.Marshal(qr) @@ -91,19 +88,12 @@ func requestorSessionHelper(t *testing.T, request interface{}, client *irmaclien } serverResult := <-serverChan - require.Equal(t, token, serverResult.Token) + require.Equal(t, requestorToken, serverResult.Token) if opts&sessionOptionRetryPost > 0 { - req, err := http.NewRequest(http.MethodPost, - qr.URL+"/proofs", - bytes.NewBuffer([]byte(h.(*TestHandler).result)), - ) - require.NoError(t, err) - req.Header.Add("Content-Type", "application/json") - res, err := new(http.Client).Do(req) - require.NoError(t, err) - require.True(t, res.StatusCode < 300) - _, err = ioutil.ReadAll(res.Body) + clientTransport := extractClientTransport(dismisser) + var result string + err := clientTransport.Post("proofs", &result, h.(*TestHandler).result) require.NoError(t, err) } @@ -114,7 +104,7 @@ func requestorSessionHelper(t *testing.T, request interface{}, client *irmaclien func TestRequestorInvalidRequest(t *testing.T) { StartIrmaServer(t, false, "") defer StopIrmaServer() - _, _, err := irmaServer.StartSession(irma.NewDisclosureRequest( + _, _, _, err := irmaServer.StartSession(irma.NewDisclosureRequest( irma.NewAttributeTypeIdentifier("irma-demo.RU.foo.bar"), irma.NewAttributeTypeIdentifier("irma-demo.baz.qux.abc"), ), nil) @@ -124,7 +114,7 @@ func TestRequestorInvalidRequest(t *testing.T) { func TestRequestorDoubleGET(t *testing.T) { StartIrmaServer(t, false, "") defer StopIrmaServer() - qr, _, err := irmaServer.StartSession(irma.NewDisclosureRequest( + qr, _, _, err := irmaServer.StartSession(irma.NewDisclosureRequest( irma.NewAttributeTypeIdentifier("irma-demo.RU.studentCard.studentID"), ), nil) require.NoError(t, err) @@ -226,7 +216,7 @@ func TestRequestorCombinedSessionMultipleAttributes(t *testing.T) { ] }`), &ir)) - require.Equal(t, server.StatusDone, requestorSessionHelper(t, &ir, nil).Status) + require.Equal(t, irma.ServerStatusDone, requestorSessionHelper(t, &ir, nil).Status) } func testRequestorIssuance(t *testing.T, keyshare bool, client *irmaclient.Client) { @@ -378,12 +368,12 @@ func TestClientDeveloperMode(t *testing.T) { // Try to start another session with our non-https server issuanceRequest = getNameIssuanceRequest() - qr, _, err := irmaServer.StartSession(issuanceRequest, nil) + qr, _, _, err := irmaServer.StartSession(issuanceRequest, nil) require.NoError(t, err) c := make(chan *SessionResult, 1) j, err := json.Marshal(qr) require.NoError(t, err) - client.NewSession(string(j), &TestHandler{t, c, client, nil, 0, ""}) + client.NewSession(string(j), &TestHandler{t, c, client, nil, 0, "", nil, nil, nil}) result := <-c // Check that it failed with an appropriate error message @@ -460,10 +450,10 @@ func TestIssueExpiredKey(t *testing.T) { expireKey(t, client.Configuration) result = requestorSessionHelper(t, getIssuanceRequest(true), client, sessionOptionReuseServer, sessionOptionIgnoreError) require.Nil(t, result.Err) - require.Equal(t, server.StatusCancelled, result.Status) + require.Equal(t, irma.ServerStatusCancelled, result.Status) // server aborts issuance sessions in case of expired public keys expireKey(t, irmaServerConfiguration.IrmaConfiguration) - _, _, err := irmaServer.StartSession(getIssuanceRequest(true), nil) + _, _, _, err := irmaServer.StartSession(getIssuanceRequest(true), nil) require.Error(t, err) } diff --git a/internal/sessiontest/revocation_test.go b/internal/sessiontest/revocation_test.go index b39ebf075..a552c6d02 100644 --- a/internal/sessiontest/revocation_test.go +++ b/internal/sessiontest/revocation_test.go @@ -236,7 +236,7 @@ func TestRevocationAll(t *testing.T) { stopRevocationServer() result := revocationSession(t, client, nil, sessionOptionIgnoreError) - require.Equal(t, server.StatusCancelled, result.Status) + require.Equal(t, irma.ServerStatusCancelled, result.Status) require.NotNil(t, result.Err) require.Equal(t, result.Err.ErrorName, string(server.ErrorRevocation.Type)) }) diff --git a/internal/sessiontest/session_test.go b/internal/sessiontest/session_test.go index d77aef937..e6beda8de 100644 --- a/internal/sessiontest/session_test.go +++ b/internal/sessiontest/session_test.go @@ -136,6 +136,47 @@ func TestIssuanceSameAttributesNotSingleton(t *testing.T) { require.Equal(t, prevLen+1, len(client.CredentialInfoList())) } +func TestIssuancePairing(t *testing.T) { + id := irma.NewAttributeTypeIdentifier("irma-demo.RU.studentCard.studentID") + request := getCombinedIssuanceRequest(id) + + var pairingCode string + frontendOptionsHandler := func(handler *TestHandler) { + pairingCode = setPairingMethod(irma.PairingMethodPin, handler) + } + pairingHandler := func(handler *TestHandler) { + // Below protocol version 2.8 pairing is not supported, so then the pairing stage is expected to be skipped. + if extractClientMaxVersion(handler.client).Below(2, 8) { + return + } + + require.Equal(t, pairingCode, <-handler.pairingCodeChan) + + // Check whether access to request endpoint is denied as long as pairing is not finished + clientTransport := extractClientTransport(handler.dismisser) + err := clientTransport.Get("request", struct{}{}) + require.Error(t, err) + sessionErr := err.(*irma.SessionError) + require.Equal(t, irma.ErrorApi, sessionErr.ErrorType) + require.Equal(t, server.ErrorPairingRequired.Status, sessionErr.RemoteError.Status) + require.Equal(t, string(server.ErrorPairingRequired.Type), sessionErr.RemoteError.ErrorName) + + // Check whether pairing cannot be disabled again after client is connected. + request := irma.NewFrontendOptionsRequest() + result := &irma.SessionOptions{} + err = handler.frontendTransport.Post("frontend/options", result, request) + require.Error(t, err) + sessionErr = err.(*irma.SessionError) + require.Equal(t, irma.ErrorApi, sessionErr.ErrorType) + require.Equal(t, server.ErrorUnexpectedRequest.Status, sessionErr.RemoteError.Status) + require.Equal(t, string(server.ErrorUnexpectedRequest.Type), sessionErr.RemoteError.ErrorName) + + err = handler.frontendTransport.Post("frontend/pairingcompleted", nil, nil) + require.NoError(t, err) + } + sessionHelperWithFrontendOptions(t, request, "issue", nil, frontendOptionsHandler, pairingHandler) +} + func TestLargeAttribute(t *testing.T) { client, handler := parseStorage(t) defer test.ClearTestStorage(t, handler.storage) @@ -366,7 +407,7 @@ func TestIssueOptionalAttributeUpdateSchemeManager(t *testing.T) { serverChan := make(chan *server.SessionResult) StartIrmaServer(t, false, "") // Run a server with old configuration (level is non-optional) - _, _, err := irmaServer.StartSession(issuanceRequest, func(result *server.SessionResult) { + _, _, _, err := irmaServer.StartSession(issuanceRequest, func(result *server.SessionResult) { serverChan <- result }) expectedError := &irma.RequiredAttributeMissingError{ @@ -389,7 +430,7 @@ func TestIssueOptionalAttributeUpdateSchemeManager(t *testing.T) { _, err = client.Configuration.Download(issuanceRequest) require.NoError(t, err) require.True(t, client.Configuration.CredentialTypes[credid].AttributeType(attrid).IsOptional()) - _, _, err = irmaServer.StartSession(issuanceRequest, func(result *server.SessionResult) { + _, _, _, err = irmaServer.StartSession(issuanceRequest, func(result *server.SessionResult) { serverChan <- result }) require.NoError(t, err) @@ -488,7 +529,7 @@ func TestStaticQRSession(t *testing.T) { c := make(chan *SessionResult) // Perform session - client.NewSession(string(bts), &TestHandler{t, c, client, requestor, 0, ""}) + client.NewSession(string(bts), &TestHandler{t, c, client, requestor, 0, "", nil, nil, nil}) if result := <-c; result != nil { require.NoError(t, result.Err) } @@ -538,7 +579,7 @@ func TestBlindIssuanceSession(t *testing.T) { StartIrmaServer(t, false, "") defer StopIrmaServer() - _, _, err := irmaServer.StartSession(request, nil) + _, _, _, err := irmaServer.StartSession(request, nil) require.EqualError(t, err, "Error type: randomblind\nDescription: randomblind attribute cannot be set in credential request\nStatus code: 0") // Make the request valid @@ -629,3 +670,14 @@ func TestChainedSessions(t *testing.T) { require.NoError(t, errors.New("newly issued credential not found in client")) } + +func TestDisablePairing(t *testing.T) { + id := irma.NewAttributeTypeIdentifier("irma-demo.RU.studentCard.studentID") + request := getCombinedIssuanceRequest(id) + + frontendOptionsHandler := func(handler *TestHandler) { + _ = setPairingMethod(irma.PairingMethodPin, handler) + _ = setPairingMethod(irma.PairingMethodNone, handler) + } + sessionHelperWithFrontendOptions(t, request, "issue", nil, frontendOptionsHandler, nil) +} diff --git a/irma/cmd/request.go b/irma/cmd/request.go index 422207ac4..83fb3996e 100644 --- a/irma/cmd/request.go +++ b/irma/cmd/request.go @@ -7,7 +7,6 @@ import ( "os" "strconv" "strings" - "time" "github.com/dgrijalva/jwt-go" "github.com/go-errors/errors" @@ -16,7 +15,6 @@ import ( "github.com/privacybydesign/irmago/internal/common" "github.com/privacybydesign/irmago/server" "github.com/sietseringers/cobra" - sseclient "github.com/sietseringers/go-sse" "github.com/sietseringers/pflag" ) @@ -112,52 +110,6 @@ func configureRequest(cmd *cobra.Command) (irma.RequestorRequest, *irma.Configur // Helper functions -func wait(initialStatus server.Status, transport *irma.HTTPTransport, statuschan chan server.Status) { - events := make(chan *sseclient.Event) - - go func() { - for { - if e := <-events; e != nil && e.Type != "open" { - status := server.Status(strings.Trim(string(e.Data), `"`)) - statuschan <- status - if status.Finished() { - return - } - } - } - }() - - if err := sseclient.Notify(nil, transport.Server+"statusevents", true, events); err != nil { - fmt.Println("SSE failed, fallback to polling", err) - close(events) - poll(initialStatus, transport, statuschan) - return - } -} - -// poll recursively polls the session status until a final status is received. -func poll(initialStatus server.Status, transport *irma.HTTPTransport, statuschan chan server.Status) { - // First we wait - <-time.NewTimer(pollInterval).C - - // Get session status - var s string - if err := transport.Get("status", &s); err != nil { - _ = server.LogFatal(err) - } - status := server.Status(strings.Trim(s, `"`)) - - // report if status changed - if status != initialStatus { - statuschan <- status - } - - if status.Finished() { - return - } - go poll(status, transport, statuschan) -} - func constructSessionRequest(cmd *cobra.Command, conf *irma.Configuration) (irma.RequestorRequest, error) { disclose, _ := cmd.Flags().GetStringArray("disclose") issue, _ := cmd.Flags().GetStringArray("issue") diff --git a/irma/cmd/session.go b/irma/cmd/session.go index 36457b17a..f1630a420 100644 --- a/irma/cmd/session.go +++ b/irma/cmd/session.go @@ -1,12 +1,13 @@ package cmd import ( + "bufio" "fmt" "net/http" + "os" "regexp" "strconv" "sync" - "time" "github.com/go-errors/errors" irma "github.com/privacybydesign/irmago" @@ -17,8 +18,6 @@ import ( prefixed "github.com/x-cray/logrus-prefixed-formatter" ) -const pollInterval = 1000 * time.Millisecond - var ( httpServer *http.Server irmaServer *irmaserver.Server @@ -63,6 +62,7 @@ irma session --server http://localhost:8088 --authmethod token --key mytoken --d url, _ := cmd.Flags().GetString("url") serverurl, _ := cmd.Flags().GetString("server") noqr, _ := cmd.Flags().GetBool("noqr") + pairing, _ := cmd.Flags().GetBool("pairing") if url != defaulturl && serverurl != "" { die("Failed to read configuration", errors.New("--url can't be combined with --server")) @@ -72,12 +72,12 @@ irma session --server http://localhost:8088 --authmethod token --key mytoken --d port, _ := flags.GetInt("port") privatekeysPath, _ := flags.GetString("privkeys") verbosity, _ := cmd.Flags().GetCount("verbose") - result, err = libraryRequest(request, irmaconfig, url, port, privatekeysPath, noqr, verbosity) + result, err = libraryRequest(request, irmaconfig, url, port, privatekeysPath, noqr, verbosity, pairing) } else { authmethod, _ := flags.GetString("authmethod") key, _ := flags.GetString("key") name, _ := flags.GetString("name") - result, err = serverRequest(request, serverurl, authmethod, key, name, noqr) + result, err = serverRequest(request, serverurl, authmethod, key, name, noqr, pairing) } if err != nil { die("Session failed", err) @@ -100,6 +100,7 @@ func libraryRequest( privatekeysPath string, noqr bool, verbosity int, + pairing bool, ) (*server.SessionResult, error) { if err := configureSessionServer(url, port, privatekeysPath, irmaconfig, verbosity); err != nil { return nil, err @@ -108,18 +109,43 @@ func libraryRequest( // Start the session resultchan := make(chan *server.SessionResult) - qr, _, err := irmaServer.StartSession(request, func(r *server.SessionResult) { + qr, requestorToken, _, err := irmaServer.StartSession(request, func(r *server.SessionResult) { resultchan <- r }) if err != nil { return nil, errors.WrapPrefix(err, "IRMA session failed", 0) } + // Enable pairing if necessary + var sessionOptions *irma.SessionOptions + if pairing { + optionsRequest := irma.NewFrontendOptionsRequest() + optionsRequest.PairingMethod = irma.PairingMethodPin + if sessionOptions, err = irmaServer.SetFrontendOptions(requestorToken, &optionsRequest); err != nil { + return nil, errors.WrapPrefix(err, "Failed to enable pairing", 0) + } + } + // Print QR code if err := printQr(qr, noqr); err != nil { return nil, errors.WrapPrefix(err, "Failed to print QR", 0) } + if pairing { + // Listen for session status + statuschan, err := irmaServer.SessionStatus(requestorToken) + if err != nil { + return nil, errors.WrapPrefix(err, "Failed to start listening for session statuses", 0) + } + + _, err = handlePairing(sessionOptions, statuschan, func() error { + return irmaServer.PairingCompleted(requestorToken) + }) + if err != nil { + return nil, errors.WrapPrefix(err, "Failed to handle pairing", 0) + } + } + // Wait for session to finish and then return session result return <-resultchan, nil } @@ -128,40 +154,77 @@ func serverRequest( request irma.RequestorRequest, serverurl, authmethod, key, name string, noqr bool, + pairing bool, ) (*server.SessionResult, error) { logger.Debug("Server URL: ", serverurl) // Start session at server - qr, transport, err := postRequest(serverurl, request, name, authmethod, key) + qr, frontendRequest, transport, err := postRequest(serverurl, request, name, authmethod, key) if err != nil { return nil, err } + // Enable pairing if necessary + var frontendTransport *irma.HTTPTransport + sessionOptions := &irma.SessionOptions{} + if pairing { + frontendTransport = irma.NewHTTPTransport(qr.URL, false) + frontendTransport.SetHeader(irma.AuthorizationHeader, string(frontendRequest.Authorization)) + optionsRequest := irma.NewFrontendOptionsRequest() + optionsRequest.PairingMethod = irma.PairingMethodPin + err = frontendTransport.Post("frontend/options", sessionOptions, optionsRequest) + if err != nil { + return nil, errors.WrapPrefix(err, "Failed to enable pairing", 0) + } + } + // Print session QR logger.Debug("QR: ", prettyprint(qr)) if err := printQr(qr, noqr); err != nil { return nil, errors.WrapPrefix(err, "Failed to print QR", 0) } - statuschan := make(chan server.Status) + statuschan := make(chan irma.ServerStatus) + errorchan := make(chan error) var wg sync.WaitGroup - go wait(server.StatusInitialized, transport, statuschan) + go irma.WaitStatus(transport, irma.ServerStatusInitialized, statuschan, errorchan) + go func() { + err := <-errorchan + if err != nil { + _ = server.LogFatal(err) + } + }() wg.Add(1) go func() { defer wg.Done() - // Wait until client connects - status := <-statuschan - if status != server.StatusConnected { - err = errors.Errorf("Unexpected status: %s", status) - return + var status irma.ServerStatus + if pairing { + status, err = handlePairing(sessionOptions, statuschan, func() error { + err = frontendTransport.Post("frontend/pairingcompleted", nil, nil) + if err != nil { + return errors.WrapPrefix(err, "Failed to complete pairing", 0) + } + return nil + }) + if err != nil { + err = errors.WrapPrefix(err, "Failed to handle pairing", 0) + return + } + } else { + // Wait until client connects if pairing is disabled + status := <-statuschan + if status != irma.ServerStatusConnected { + err = errors.Errorf("Unexpected status: %s", status) + return + } } // Wait until client finishes status = <-statuschan - if status != server.StatusCancelled && status != server.StatusDone { + if status != irma.ServerStatusCancelled && status != irma.ServerStatusDone { err = errors.Errorf("Unexpected status: %s", status) return } @@ -180,7 +243,8 @@ func serverRequest( return result, nil } -func postRequest(serverurl string, request irma.RequestorRequest, name, authmethod, key string) (*irma.Qr, *irma.HTTPTransport, error) { +func postRequest(serverurl string, request irma.RequestorRequest, name, authmethod, key string) ( + *irma.Qr, *irma.FrontendSessionRequest, *irma.HTTPTransport, error) { var ( err error pkg = &server.SessionPackage{} @@ -197,17 +261,63 @@ func postRequest(serverurl string, request irma.RequestorRequest, name, authmeth var jwtstr string jwtstr, err = signRequest(request, name, authmethod, key) if err != nil { - return nil, nil, err + return nil, nil, nil, err } logger.Debug("Session request JWT: ", jwtstr) err = transport.Post("session", pkg, jwtstr) default: - return nil, nil, errors.New("Invalid authentication method (must be none, token, hmac or rsa)") + return nil, nil, nil, errors.New("Invalid authentication method (must be none, token, hmac or rsa)") + } + + if err != nil { + return nil, nil, nil, err + } + + transport.Server += fmt.Sprintf("session/%s/", pkg.Token) + return pkg.SessionPtr, pkg.FrontendRequest, transport, err +} + +func handlePairing(options *irma.SessionOptions, statusChan chan irma.ServerStatus, completePairing func() error) ( + irma.ServerStatus, error) { + errorChan := make(chan error) + pairingStarted := false + for { + select { + case status := <-statusChan: + if status == irma.ServerStatusInitialized { + continue + } else if status == irma.ServerStatusPairing { + pairingStarted = true + go requestPairingPermission(options, completePairing, errorChan) + continue + } else if status == irma.ServerStatusConnected && !pairingStarted { + fmt.Println("Pairing is not supported by the connected device.") + } + return status, nil + case err := <-errorChan: + return "", err + } } +} - token := pkg.Token - transport.Server += fmt.Sprintf("session/%s/", token) - return pkg.SessionPtr, transport, err +func requestPairingPermission(options *irma.SessionOptions, completePairing func() error, errorChan chan error) { + if options.PairingMethod == irma.PairingMethodPin { + fmt.Println("\nPairing code:", options.PairingCode) + fmt.Println("Press Enter to confirm your device shows the same pairing code; otherwise press Ctrl-C.") + _, err := bufio.NewReader(os.Stdin).ReadString('\n') + if err != nil { + errorChan <- err + return + } + if err = completePairing(); err != nil { + errorChan <- err + return + } + fmt.Println("Pairing completed.") + errorChan <- nil + return + } + errorChan <- errors.Errorf("Pairing method %s is not supported", options.PairingMethod) } // Configuration functions @@ -260,6 +370,7 @@ func init() { flags.StringP("url", "u", defaulturl, "external URL to which IRMA app connects (when not using --server), \":port\" being replaced by --port value") flags.IntP("port", "p", 48680, "port to listen at (when not using --server)") flags.Bool("noqr", false, "Print JSON instead of draw QR") + flags.Bool("pairing", false, "Let IRMA app first pair, by entering the pairing code, before it can access the session") flags.StringP("request", "r", "", "JSON session request") flags.StringP("privkeys", "k", "", "path to private keys") flags.Bool("disable-schemes-update", false, "disable scheme updates") diff --git a/irmaclient/client.go b/irmaclient/client.go index ff7a898bd..b8c4df24e 100644 --- a/irmaclient/client.go +++ b/irmaclient/client.go @@ -56,6 +56,10 @@ type Client struct { // Legacy storage needed when client has not updated to the new storage yet fileStorage fileStorage + // Versions the client supports + minVersion *irma.ProtocolVersion + maxVersion *irma.ProtocolVersion + // Other state Preferences Preferences Configuration *irma.Configuration @@ -158,6 +162,8 @@ func New( attributes: make(map[irma.CredentialTypeIdentifier][]*irma.AttributeList), irmaConfigurationPath: irmaConfigurationPath, handler: handler, + minVersion: &irma.ProtocolVersion{Major: 2, Minor: supportedVersions[2][0]}, + maxVersion: &irma.ProtocolVersion{Major: 2, Minor: supportedVersions[2][len(supportedVersions[2])-1]}, } client.Configuration, err = irma.NewConfiguration( diff --git a/irmaclient/handlers.go b/irmaclient/handlers.go index 885d3c78e..d51efa6a8 100644 --- a/irmaclient/handlers.go +++ b/irmaclient/handlers.go @@ -53,7 +53,7 @@ func (h *keyshareEnrollmentHandler) fail(err error) { } // Not interested, ingore -func (h *keyshareEnrollmentHandler) StatusUpdate(action irma.Action, status irma.Status) {} +func (h *keyshareEnrollmentHandler) StatusUpdate(action irma.Action, status irma.ClientStatus) {} // The methods below should never be called, so we let each of them fail the session func (h *keyshareEnrollmentHandler) RequestVerificationPermission(request *irma.DisclosureRequest, satisfiable bool, candidates [][]DisclosureCandidates, ServerName *irma.RequestorInfo, callback PermissionHandler) { @@ -83,3 +83,6 @@ func (h *keyshareEnrollmentHandler) KeyshareEnrollmentMissing(manager irma.Schem func (h *keyshareEnrollmentHandler) ClientReturnURLSet(clientReturnURL string) { h.fail(errors.New("Keyshare enrollment session unexpectedly found an external return url")) } +func (h *keyshareEnrollmentHandler) PairingRequired(pairingCode string) { + h.fail(errors.New("Keyshare enrollment session failed: device pairing required")) +} diff --git a/irmaclient/irmaclient_test.go b/irmaclient/irmaclient_test.go index da806e994..742f889e1 100644 --- a/irmaclient/irmaclient_test.go +++ b/irmaclient/irmaclient_test.go @@ -124,7 +124,7 @@ func TestCandidates(t *testing.T) { // but we should also get the option to get another value request := irma.NewDisclosureRequest(attrtype) disjunction := request.Disclose[0] - request.ProtocolVersion = &irma.ProtocolVersion{Major: 2, Minor: 6} + request.ProtocolVersion = &irma.ProtocolVersion{Major: 2, Minor: 8} attrs, satisfiable, err := client.candidatesDisCon(request, disjunction) require.NoError(t, err) require.True(t, satisfiable) @@ -232,7 +232,7 @@ func TestCandidateConjunctionOrder(t *testing.T) { ) req := &irma.DisclosureRequest{ - BaseRequest: irma.BaseRequest{ProtocolVersion: maxVersion}, + BaseRequest: irma.BaseRequest{ProtocolVersion: client.maxVersion}, Disclose: cdc, } diff --git a/irmaclient/session.go b/irmaclient/session.go index 166ee5aee..ca2b60c47 100644 --- a/irmaclient/session.go +++ b/irmaclient/session.go @@ -29,8 +29,9 @@ type PinHandler func(proceed bool, pin string) // A Handler contains callbacks for communication to the user. type Handler interface { - StatusUpdate(action irma.Action, status irma.Status) + StatusUpdate(action irma.Action, status irma.ClientStatus) ClientReturnURLSet(clientReturnURL string) + PairingRequired(pairingCode string) Success(result string) Cancelled() Failure(err *irma.SessionError) @@ -111,10 +112,9 @@ var supportedVersions = map[int][]int{ 5, // introduces condiscon feature 6, // introduces nonrevocation proofs 7, // introduces chained sessions + 8, // introduces session binding }, } -var minVersion = &irma.ProtocolVersion{Major: 2, Minor: supportedVersions[2][0]} -var maxVersion = &irma.ProtocolVersion{Major: 2, Minor: supportedVersions[2][len(supportedVersions[2])-1]} // Session constructors @@ -165,13 +165,13 @@ func (client *Client) newManualSession(request irma.SessionRequest, handler Hand Action: action, Handler: handler, client: client, - Version: minVersion, + Version: client.minVersion, request: request, done: doneChannel, prepRevocation: make(chan error), } client.sessions.add(session) - session.Handler.StatusUpdate(session.Action, irma.StatusManualStarted) + session.Handler.StatusUpdate(session.Action, irma.ClientStatusManualStarted) session.processSessionInfo() return session @@ -212,8 +212,8 @@ func (client *Client) newQrSession(qr *irma.Qr, handler Handler) *session { } client.sessions.add(session) - session.Handler.StatusUpdate(session.Action, irma.StatusCommunicating) - min := minVersion + session.Handler.StatusUpdate(session.Action, irma.ClientStatusCommunicating) + min := client.minVersion // Check if the action is one of the supported types switch session.Action { @@ -232,7 +232,14 @@ func (client *Client) newQrSession(qr *irma.Qr, handler Handler) *session { } session.transport.SetHeader(irma.MinVersionHeader, min.String()) - session.transport.SetHeader(irma.MaxVersionHeader, maxVersion.String()) + session.transport.SetHeader(irma.MaxVersionHeader, client.maxVersion.String()) + + // From protocol version 2.8 also an authorization header must be included. + if client.maxVersion.Above(2, 7) { + clientAuth := common.NewSessionToken() + session.transport.SetHeader(irma.AuthorizationHeader, clientAuth) + } + if !strings.HasSuffix(session.ServerURL, "/") { session.ServerURL += "/" } @@ -244,21 +251,60 @@ func (client *Client) newQrSession(qr *irma.Qr, handler Handler) *session { // Core session methods // getSessionInfo retrieves the first message in the IRMA protocol (only in interactive sessions) +// If needed, it also handles pairing. func (session *session) getSessionInfo() { defer session.recoverFromPanic() - session.Handler.StatusUpdate(session.Action, irma.StatusCommunicating) + session.Handler.StatusUpdate(session.Action, irma.ClientStatusCommunicating) // Get the first IRMA protocol message and parse it - err := session.transport.Get("", session.request) + cr := &irma.ClientSessionRequest{ + Request: session.request, // As request is an interface, it needs to be initialized with a specific instance. + } + // UnmarshalJSON of ClientSessionRequest takes into account legacy protocols, so we do not have to check that here. + err := session.transport.Get("", cr) if err != nil { session.fail(err.(*irma.SessionError)) return } + // Check whether pairing is needed, and if so, wait for it to be completed. + if cr.Options.PairingMethod != irma.PairingMethodNone { + if err = session.handlePairing(cr.Options.PairingCode); err != nil { + session.fail(err.(*irma.SessionError)) + return + } + } + session.processSessionInfo() } +func (session *session) handlePairing(pairingCode string) error { + session.Handler.PairingRequired(pairingCode) + + statuschan := make(chan irma.ServerStatus) + errorchan := make(chan error) + + go irma.WaitStatusChanged(session.transport, irma.ServerStatusPairing, statuschan, errorchan) + select { + case status := <-statuschan: + if status == irma.ServerStatusConnected { + return session.transport.Get("request", session.request) + } else { + return &irma.SessionError{ErrorType: irma.ErrorPairingRejected} + } + case err := <-errorchan: + if serr, ok := err.(*irma.SessionError); ok { + return serr + } + return &irma.SessionError{ + ErrorType: irma.ErrorServerResponse, + Info: "Pairing aborted by server", + Err: err, + } + } +} + func requestorInfo(serverURL string, conf *irma.Configuration) *irma.RequestorInfo { if serverURL == "" { return nil @@ -384,7 +430,7 @@ func (session *session) requestPermission() { return } - session.Handler.StatusUpdate(session.Action, irma.StatusConnected) + session.Handler.StatusUpdate(session.Action, irma.ClientStatusConnected) // Ask for permission to execute the session switch session.Action { @@ -423,7 +469,7 @@ func (session *session) doSession(proceed bool, choice *irma.DisclosureChoice) { session.fail(&irma.SessionError{ErrorType: irma.ErrorRequiredAttributeMissing, Err: err}) return } - session.Handler.StatusUpdate(session.Action, irma.StatusCommunicating) + session.Handler.StatusUpdate(session.Action, irma.ClientStatusCommunicating) // wait for revocation preparation to finish err := <-session.prepRevocation @@ -770,11 +816,11 @@ func (session *session) KeyshareError(manager *irma.SchemeManagerIdentifier, err } func (session *session) KeysharePin() { - session.Handler.StatusUpdate(session.Action, irma.StatusConnected) + session.Handler.StatusUpdate(session.Action, irma.ClientStatusConnected) } func (session *session) KeysharePinOK() { - session.Handler.StatusUpdate(session.Action, irma.StatusCommunicating) + session.Handler.StatusUpdate(session.Action, irma.ClientStatusCommunicating) } func (s sessions) remove(token string) { diff --git a/messages.go b/messages.go index aab755cd4..21e1723b2 100644 --- a/messages.go +++ b/messages.go @@ -15,12 +15,16 @@ import ( "github.com/privacybydesign/gabi" ) -// Status encodes the status of an IRMA session (e.g., connected). -type Status string +// ClientStatus encodes the client status of an IRMA session (e.g., connected). +type ClientStatus string + +// ServerStatus encodes the server status of an IRMA session (e.g., CONNECTED). +type ServerStatus string const ( - MinVersionHeader = "X-IRMA-MinProtocolVersion" - MaxVersionHeader = "X-IRMA-MaxProtocolVersion" + MinVersionHeader = "X-IRMA-MinProtocolVersion" + MaxVersionHeader = "X-IRMA-MaxProtocolVersion" + AuthorizationHeader = "Authorization" ) // ProtocolVersion encodes the IRMA protocol version of an IRMA session. @@ -164,11 +168,29 @@ type Qr struct { Type Action `json:"irmaqr"` } -// Statuses +// Tokens to identify a session from the perspective of the different agents +type RequestorToken string +type ClientToken string + +// Authorization headers +type ClientAuthorization string +type FrontendAuthorization string + +// Client statuses +const ( + ClientStatusConnected = ClientStatus("connected") + ClientStatusCommunicating = ClientStatus("communicating") + ClientStatusManualStarted = ClientStatus("manualStarted") +) + +// Server statuses const ( - StatusConnected = Status("connected") - StatusCommunicating = Status("communicating") - StatusManualStarted = Status("manualStarted") + ServerStatusInitialized ServerStatus = "INITIALIZED" // The session has been started and is waiting for the client + ServerStatusPairing ServerStatus = "PAIRING" // The client is waiting for the frontend to give permission to connect + ServerStatusConnected ServerStatus = "CONNECTED" // The client has retrieved the session request, we wait for its response + ServerStatusCancelled ServerStatus = "CANCELLED" // The session is cancelled, possibly due to an error + ServerStatusDone ServerStatus = "DONE" // The session has completed successfully + ServerStatusTimeout ServerStatus = "TIMEOUT" // Session timed out ) // Actions @@ -197,6 +219,8 @@ const ( ErrorCrypto = ErrorType("crypto") // Error involving revocation or nonrevocation proofs ErrorRevocation = ErrorType("revocation") + // Our pairing attempt was rejected by the server + ErrorPairingRejected = ErrorType("pairingRejected") // Server rejected our response (second IRMA message) ErrorRejected = ErrorType("rejected") // (De)serializing of a message failed @@ -349,6 +373,10 @@ func (qr *Qr) Validate() (err error) { return nil } +func (status ServerStatus) Finished() bool { + return status == ServerStatusDone || status == ServerStatusCancelled || status == ServerStatusTimeout +} + type ServerSessionResponse struct { ProofStatus ProofStatus `json:"proofStatus"` IssueSignatures []*gabi.IssueSignatureMessage `json:"sigs,omitempty"` @@ -358,3 +386,8 @@ type ServerSessionResponse struct { ProtocolVersion *ProtocolVersion `json:"-"` SessionType Action `json:"-"` } + +type FrontendSessionStatus struct { + Status ServerStatus `json:"status"` + NextSession *Qr `json:"nextSession,omitempty"` +} diff --git a/requests.go b/requests.go index 942f9e61d..dec7f0bf4 100644 --- a/requests.go +++ b/requests.go @@ -5,6 +5,7 @@ import ( "encoding/xml" "fmt" "io/ioutil" + "reflect" "strconv" "time" @@ -18,11 +19,14 @@ import ( ) const ( - LDContextDisclosureRequest = "https://irma.app/ld/request/disclosure/v2" - LDContextSignatureRequest = "https://irma.app/ld/request/signature/v2" - LDContextIssuanceRequest = "https://irma.app/ld/request/issuance/v2" - LDContextRevocationRequest = "https://irma.app/ld/request/revocation/v1" - DefaultJwtValidity = 120 + LDContextDisclosureRequest = "https://irma.app/ld/request/disclosure/v2" + LDContextSignatureRequest = "https://irma.app/ld/request/signature/v2" + LDContextIssuanceRequest = "https://irma.app/ld/request/issuance/v2" + LDContextRevocationRequest = "https://irma.app/ld/request/revocation/v1" + LDContextFrontendOptionsRequest = "https://irma.app/ld/request/frontendoptions/v1" + LDContextClientSessionRequest = "https://irma.app/ld/request/client/v1" + LDContextSessionOptions = "https://irma.app/ld/options/v1" + DefaultJwtValidity = 120 ) // BaseRequest contains information used by all IRMA session types, such the context and nonce, @@ -210,6 +214,31 @@ type AttributeRequest struct { NotNull bool `json:"notNull,omitempty"` } +type PairingMethod string + +const ( + PairingMethodNone = "none" + PairingMethodPin = "pin" +) + +// An FrontendOptionsRequest asks for a options change of a particular session. +type FrontendOptionsRequest struct { + LDContext string `json:"@context,omitempty"` + PairingMethod PairingMethod `json:"pairingMethod"` +} + +// FrontendSessionRequest contains session parameters for the frontend. +type FrontendSessionRequest struct { + // Authorization token to access frontend endpoints. + Authorization FrontendAuthorization `json:"authorization"` + // PairingRecommended indictes to the frontend that pairing is recommended when starting the session. + PairingRecommended bool `json:"pairingHint,omitempty"` + // MinProtocolVersion that the server supports for the frontend protocol. + MinProtocolVersion *ProtocolVersion `json:"minProtocolVersion"` + // MaxProtocolVersion that the server supports for the frontend protocol. + MaxProtocolVersion *ProtocolVersion `json:"maxProtocolVersion"` +} + type RevocationRequest struct { LDContext string `json:"@context,omitempty"` CredentialType CredentialTypeIdentifier `json:"type"` @@ -224,6 +253,20 @@ type NonRevocationRequest struct { type NonRevocationParameters map[CredentialTypeIdentifier]*NonRevocationRequest +type SessionOptions struct { + LDContext string `json:"@context,omitempty"` + PairingMethod PairingMethod `json:"pairingMethod"` + PairingCode string `json:"pairingCode,omitempty"` +} + +// ClientSessionRequest contains all information irmaclient needs to know to initiate a session. +type ClientSessionRequest struct { + LDContext string `json:"@context,omitempty"` + ProtocolVersion *ProtocolVersion `json:"protocolVersion,omitempty"` + Options *SessionOptions `json:"options,omitempty"` + Request SessionRequest `json:"request,omitempty"` +} + func (choice *DisclosureChoice) Validate() error { if choice == nil { return nil @@ -1092,3 +1135,57 @@ func SignRequestorRequest(request RequestorRequest, alg jwt.SigningMethod, key i func NewAttributeRequest(attr string) AttributeRequest { return AttributeRequest{Type: NewAttributeTypeIdentifier(attr)} } + +// NewFrontendOptionsRequest returns a new options request initialized with default values for each option +func NewFrontendOptionsRequest() FrontendOptionsRequest { + return FrontendOptionsRequest{ + LDContext: LDContextFrontendOptionsRequest, + PairingMethod: PairingMethodNone, + } +} + +func (or *FrontendOptionsRequest) Validate() error { + if or.LDContext != LDContextFrontendOptionsRequest { + return errors.New("Not a frontend options request") + } + return nil +} + +func (cr *ClientSessionRequest) UnmarshalJSON(data []byte) error { + // Unmarshal in alias first to prevent infinite recursion + type alias ClientSessionRequest + err := json.Unmarshal(data, (*alias)(cr)) + if err != nil { + return err + } + if cr.LDContext == LDContextClientSessionRequest { + return nil + } + + // For legacy sessions initialize client request by hand using the fetched request + err = json.Unmarshal(data, cr.Request) + if err != nil { + return err + } + cr.LDContext = LDContextClientSessionRequest + cr.ProtocolVersion = cr.Request.Base().ProtocolVersion + cr.Options = &SessionOptions{ + LDContext: LDContextSessionOptions, + PairingMethod: PairingMethodNone, + } + return nil +} + +func (cr *ClientSessionRequest) Validate() error { + if cr.LDContext != LDContextClientSessionRequest { + return errors.New("Not a client request") + } + // The 'Request' field is not required. When this field is empty, we have to skip the validation. + // We cannot detect this easily, because in Go empty structs are automatically populated with + // default values. We can also not use a pointer reference because SessionRequest is an interface. + // Therefore we use reflection to check whether the struct that implements the interface is empty. + if !reflect.ValueOf(cr.Request).Elem().IsZero() { + return cr.Request.Validate() + } + return nil +} diff --git a/server/api.go b/server/api.go index b723e949e..6a0ae5293 100644 --- a/server/api.go +++ b/server/api.go @@ -26,21 +26,22 @@ import ( var Logger *logrus.Logger = logrus.StandardLogger() type SessionPackage struct { - SessionPtr *irma.Qr `json:"sessionPtr"` - Token string `json:"token"` + SessionPtr *irma.Qr `json:"sessionPtr"` + Token irma.RequestorToken `json:"token"` + FrontendRequest *irma.FrontendSessionRequest `json:"frontendRequest"` } // SessionResult contains session information such as the session status, type, possible errors, // and disclosed attributes or attribute-based signature if appropriate to the session type. type SessionResult struct { - Token string `json:"token"` - Status Status `json:"status"` - Type irma.Action `json:"type"'` + Token irma.RequestorToken `json:"token"` + Status irma.ServerStatus `json:"status"` + Type irma.Action `json:"type"` ProofStatus irma.ProofStatus `json:"proofStatus,omitempty"` Disclosed [][]*irma.DisclosedAttribute `json:"disclosed,omitempty"` Signature *irma.SignedMessage `json:"signature,omitempty"` Err *irma.RemoteError `json:"error,omitempty"` - NextSession string `json:"nextSession,omitempty"` + NextSession irma.RequestorToken `json:"nextSession,omitempty"` LegacySession bool `json:"-"` // true if request was started with legacy (i.e. pre-condiscon) session request } @@ -49,17 +50,14 @@ type SessionResult struct { // once an IRMA session has completed. type SessionHandler func(*SessionResult) -// Status is the status of an IRMA session. -type Status string - type LogOptions struct { Response, Headers, From, EncodeBinary bool } // Remove this when dropping support for legacy pre-condiscon session requests type LegacySessionResult struct { - Token string `json:"token"` - Status Status `json:"status"` + Token irma.RequestorToken `json:"token"` + Status irma.ServerStatus `json:"status"` Type irma.Action `json:"type"` ProofStatus irma.ProofStatus `json:"proofStatus,omitempty"` Disclosed []*irma.DisclosedAttribute `json:"disclosed,omitempty"` @@ -68,17 +66,10 @@ type LegacySessionResult struct { } const ( - StatusInitialized Status = "INITIALIZED" // The session has been started and is waiting for the client - StatusConnected Status = "CONNECTED" // The client has retrieved the session request, we wait for its response - StatusCancelled Status = "CANCELLED" // The session is cancelled, possibly due to an error - StatusDone Status = "DONE" // The session has completed successfully - StatusTimeout Status = "TIMEOUT" // Session timed out -) - -const ( - ComponentRevocation = "revocation" - ComponentSession = "session" - ComponentStatic = "static" + ComponentRevocation = "revocation" + ComponentSession = "session" + ComponentFrontendSession = "frontendsession" + ComponentStatic = "static" ) const ( @@ -96,10 +87,6 @@ func (r *SessionResult) Legacy() *LegacySessionResult { return &LegacySessionResult{r.Token, r.Status, r.Type, r.ProofStatus, disclosed, r.Signature, r.Err} } -func (status Status) Finished() bool { - return status == StatusDone || status == StatusCancelled || status == StatusTimeout -} - // RemoteError converts an error and an explaining message to an *irma.RemoteError. func RemoteError(err Error, message string) *irma.RemoteError { var stack string diff --git a/server/errors.go b/server/errors.go index f35b5a3d4..54caf2176 100644 --- a/server/errors.go +++ b/server/errors.go @@ -19,6 +19,8 @@ var ( ErrorAttributesWrong Error = Error{Type: "ATTRIBUTES_WRONG", Status: 400, Description: "Specified attribute(s) do not belong to this credential type or missing attributes"} ErrorCannotIssue Error = Error{Type: "CANNOT_ISSUE", Status: 500, Description: "Cannot issue this credential"} + ErrorIrmaUnauthorized Error = Error{Type: "UNAUTHORIZED", Status: 403, Description: "You are not authorized to access the session"} + ErrorPairingRequired Error = Error{Type: "PAIRING_REQUIRED", Status: 403, Description: "Pairing is required first"} ErrorIssuanceFailed Error = Error{Type: "ISSUANCE_FAILED", Status: 500, Description: "Failed to create credential(s)"} ErrorInvalidProofs Error = Error{Type: "INVALID_PROOFS", Status: 400, Description: "Invalid secret key commitments and/or disclosure proofs"} ErrorAttributesMissing Error = Error{Type: "ATTRIBUTES_MISSING", Status: 400, Description: "Not all requested-for attributes were present"} diff --git a/server/irmac/irmac.go b/server/irmac/irmac.go index 60980e73f..4ffb1cf42 100644 --- a/server/irmac/irmac.go +++ b/server/irmac/irmac.go @@ -12,6 +12,8 @@ import ( "net/http" "net/http/httptest" + irma "github.com/privacybydesign/irmago" + "github.com/privacybydesign/irmago/server" "github.com/privacybydesign/irmago/server/irmaserver" ) @@ -49,9 +51,10 @@ func Initialize(IrmaConfiguration *C.char) *C.char { func StartSession(requestString *C.char) (r *C.char) { // Create struct for return information result := struct { - IrmaQr string - Token string - Error string + IrmaQr string + RequestorToken string + FrontendRequest *irma.FrontendSessionRequest + Error string }{} defer func() { j, _ := json.Marshal(result) @@ -65,7 +68,7 @@ func StartSession(requestString *C.char) (r *C.char) { } // Run the actual core function - qr, token, err := s.StartSession(C.GoString(requestString), nil) + qr, requestorToken, frontendRequest, err := s.StartSession(C.GoString(requestString), nil) // And properly return the result if err != nil { @@ -79,7 +82,8 @@ func StartSession(requestString *C.char) (r *C.char) { } // return actual results result.IrmaQr = string(qrJson) - result.Token = token + result.RequestorToken = string(requestorToken) + result.FrontendRequest = frontendRequest return } @@ -91,7 +95,7 @@ func GetSessionResult(token *C.char) *C.char { } // Run the actual core function - result := s.GetSessionResult(C.GoString(token)) + result := s.GetSessionResult(irma.RequestorToken(C.GoString(token))) // And properly return results if result == nil { @@ -113,7 +117,7 @@ func GetRequest(token *C.char) *C.char { } // Run the core function - result := s.GetRequest(C.GoString(token)) + result := s.GetRequest(irma.RequestorToken(C.GoString(token))) // And properly return results if result == nil { @@ -135,7 +139,7 @@ func CancelSession(token *C.char) *C.char { } // Run the core function - err := s.CancelSession(C.GoString(token)) + err := s.CancelSession(irma.RequestorToken(C.GoString(token))) if err != nil { return C.CString(err.Error()) diff --git a/server/irmaserver/api.go b/server/irmaserver/api.go index 2ed2b004f..c87d4dcc2 100644 --- a/server/irmaserver/api.go +++ b/server/irmaserver/api.go @@ -23,7 +23,7 @@ type Server struct { sessions sessionStore scheduler *gocron.Scheduler stopScheduler chan bool - handlers map[string]server.SessionHandler + handlers map[irma.RequestorToken]server.SessionHandler serverSentEvents *sse.Server } @@ -50,11 +50,11 @@ func New(conf *server.Configuration) (*Server, error) { conf: conf, scheduler: gocron.NewScheduler(), sessions: &memorySessionStore{ - requestor: make(map[string]*session), - client: make(map[string]*session), + requestor: make(map[irma.RequestorToken]*session), + client: make(map[irma.ClientToken]*session), conf: conf, }, - handlers: make(map[string]server.SessionHandler), + handlers: make(map[irma.RequestorToken]server.SessionHandler), serverSentEvents: e, } @@ -109,16 +109,27 @@ func (s *Server) HandlerFunc() http.HandlerFunc { r.NotFound(errorWriter(notfound, server.WriteResponse)) r.MethodNotAllowed(errorWriter(notallowed, server.WriteResponse)) - r.Route("/session/{token}", func(r chi.Router) { + r.Route("/session/{clientToken}", func(r chi.Router) { r.Use(s.sessionMiddleware) r.Delete("/", s.handleSessionDelete) r.Get("/status", s.handleSessionStatus) r.Get("/statusevents", s.handleSessionStatusEvents) + r.Route("/frontend", func(r chi.Router) { + r.Use(s.frontendMiddleware) + r.Get("/status", s.handleFrontendStatus) + r.Get("/statusevents", s.handleFrontendStatusEvents) + r.Post("/options", s.handleFrontendOptionsPost) + r.Post("/pairingcompleted", s.handleFrontendPairingCompleted) + }) r.Group(func(r chi.Router) { r.Use(s.cacheMiddleware) r.Get("/", s.handleSessionGet) - r.Post("/commitments", s.handleSessionCommitments) - r.Post("/proofs", s.handleSessionProofs) + r.Group(func(r chi.Router) { + r.Use(s.pairingMiddleware) + r.Get("/request", s.handleSessionGetRequest) + r.Post("/commitments", s.handleSessionCommitments) + r.Post("/proofs", s.handleSessionProofs) + }) }) }) r.Post("/session/{name}", s.handleStaticMessage) @@ -149,24 +160,27 @@ func (s *Server) Stop() { } // StartSession starts an IRMA session, running the handler on completion, if specified. -// The session token (the second return parameter) can be used in GetSessionResult() -// and CancelSession(). +// The session requestorToken (the second return parameter) can be used in GetSessionResult() +// and CancelSession(). The session's frontendAuth (the third return parameter) is needed +// by frontend clients (i.e. browser libraries) to POST to the '/frontend' endpoints of the IRMA protocol. // The request parameter can be an irma.RequestorRequest, or an irma.SessionRequest, or a // ([]byte or string) JSON representation of one of those (for more details, see server.ParseSessionRequest().) -func StartSession(request interface{}, handler server.SessionHandler) (*irma.Qr, string, error) { +func StartSession(request interface{}, handler server.SessionHandler, +) (*irma.Qr, irma.RequestorToken, *irma.FrontendSessionRequest, error) { return s.StartSession(request, handler) } -func (s *Server) StartSession(req interface{}, handler server.SessionHandler) (*irma.Qr, string, error) { +func (s *Server) StartSession(req interface{}, handler server.SessionHandler, +) (*irma.Qr, irma.RequestorToken, *irma.FrontendSessionRequest, error) { rrequest, err := server.ParseSessionRequest(req) if err != nil { - return nil, "", err + return nil, "", nil, err } request := rrequest.SessionRequest() action := request.Action() if err := s.validateRequest(request); err != nil { - return nil, "", err + return nil, "", nil, err } if action == irma.ActionIssuing { // Include the AttributeTypeIdentifiers of random blind attributes to each CredentialRequest. @@ -177,66 +191,120 @@ func (s *Server) StartSession(req interface{}, handler server.SessionHandler) (* } if err := s.validateIssuanceRequest(request.(*irma.IssuanceRequest)); err != nil { - return nil, "", err + return nil, "", nil, err } } + pairingRecommended := false + if rrequest.Base().NextSession != nil && rrequest.Base().NextSession.URL != "" { + pairingRecommended = true + } else if action == irma.ActionDisclosing { + err := request.Disclosure().Disclose.Iterate(func(attr *irma.AttributeRequest) error { + if attr.Value != nil { + pairingRecommended = true + } + return nil + }) + if err != nil { + return nil, "", nil, err + } + } else { + // For issuing and signing actions, we always recommend pairing. + pairingRecommended = true + } + request.Base().DevelopmentMode = !s.conf.Production session := s.newSession(action, rrequest) - s.conf.Logger.WithFields(logrus.Fields{"action": action, "session": session.token}).Infof("Session started") + s.conf.Logger.WithFields(logrus.Fields{"action": action, "session": session.requestorToken}).Infof("Session started") if s.conf.Logger.IsLevelEnabled(logrus.DebugLevel) { - s.conf.Logger.WithFields(logrus.Fields{"session": session.token, "clienttoken": session.clientToken}).Info("Session request: ", server.ToJson(rrequest)) + s.conf.Logger.WithFields(logrus.Fields{"session": session.requestorToken, "clienttoken": session.clientToken}).Info("Session request: ", server.ToJson(rrequest)) } else { - s.conf.Logger.WithFields(logrus.Fields{"session": session.token}).Info("Session request (purged of attribute values): ", server.ToJson(purgeRequest(rrequest))) + s.conf.Logger.WithFields(logrus.Fields{"session": session.requestorToken}).Info("Session request (purged of attribute values): ", server.ToJson(purgeRequest(rrequest))) } if handler != nil { - s.handlers[session.token] = handler + s.handlers[session.requestorToken] = handler } return &irma.Qr{ - Type: action, - URL: s.conf.URL + "session/" + session.clientToken, - }, session.token, nil + Type: action, + URL: s.conf.URL + "session/" + string(session.clientToken), + }, + session.requestorToken, + &irma.FrontendSessionRequest{ + Authorization: session.frontendAuth, + PairingRecommended: pairingRecommended, + MinProtocolVersion: minFrontendProtocolVersion, + MaxProtocolVersion: maxFrontendProtocolVersion, + }, + nil } // GetSessionResult retrieves the result of the specified IRMA session. -func GetSessionResult(token string) *server.SessionResult { - return s.GetSessionResult(token) +func GetSessionResult(requestorToken irma.RequestorToken) *server.SessionResult { + return s.GetSessionResult(requestorToken) } -func (s *Server) GetSessionResult(token string) *server.SessionResult { - session := s.sessions.get(token) +func (s *Server) GetSessionResult(requestorToken irma.RequestorToken) *server.SessionResult { + session := s.sessions.get(requestorToken) if session == nil { - s.conf.Logger.Warn("Session result requested of unknown session ", token) + s.conf.Logger.Warn("Session result requested of unknown session ", requestorToken) return nil } return session.result } // GetRequest retrieves the request submitted by the requestor that started the specified IRMA session. -func GetRequest(token string) irma.RequestorRequest { +func GetRequest(token irma.RequestorToken) irma.RequestorRequest { return s.GetRequest(token) } -func (s *Server) GetRequest(token string) irma.RequestorRequest { - session := s.sessions.get(token) +func (s *Server) GetRequest(requestorToken irma.RequestorToken) irma.RequestorRequest { + session := s.sessions.get(requestorToken) if session == nil { - s.conf.Logger.Warn("Session request requested of unknown session ", token) + s.conf.Logger.Warn("Session request requested of unknown session ", requestorToken) return nil } return session.rrequest } // CancelSession cancels the specified IRMA session. -func CancelSession(token string) error { - return s.CancelSession(token) +func CancelSession(requestorToken irma.RequestorToken) error { + return s.CancelSession(requestorToken) } -func (s *Server) CancelSession(token string) error { - session := s.sessions.get(token) +func (s *Server) CancelSession(requestorToken irma.RequestorToken) error { + session := s.sessions.get(requestorToken) if session == nil { - return server.LogError(errors.Errorf("can't cancel unknown session %s", token)) + return server.LogError(errors.Errorf("can't cancel unknown session %s", requestorToken)) } session.handleDelete() return nil } +// Requests a change of the session frontend options at the server. +// Returns the updated session options struct. Frontend options can only be +// changed when the client is not connected yet. Otherwise an error is returned. +// Options that are not specified in the request, keep their old value. +func SetFrontendOptions(requestorToken irma.RequestorToken, request *irma.FrontendOptionsRequest) (*irma.SessionOptions, error) { + return s.SetFrontendOptions(requestorToken, request) +} +func (s *Server) SetFrontendOptions(requestorToken irma.RequestorToken, request *irma.FrontendOptionsRequest) (*irma.SessionOptions, error) { + session := s.sessions.get(requestorToken) + if session == nil { + return nil, server.LogError(errors.Errorf("can't set frontend options of unknown session %s", requestorToken)) + } + return session.updateFrontendOptions(request) +} + +// Complete pairing between the irma client and the frontend. Returns +// an error when no client is actually connected. +func PairingCompleted(requestorToken irma.RequestorToken) error { + return s.PairingCompleted(requestorToken) +} +func (s *Server) PairingCompleted(requestorToken irma.RequestorToken) error { + session := s.sessions.get(requestorToken) + if session == nil { + return server.LogError(errors.Errorf("can't complete pairing of unknown session %s", requestorToken)) + } + return session.pairingCompleted() +} + // Revoke revokes the earlier issued credential specified by key. (Can only be used if this server // is the revocation server for the specified credential type and if the corresponding // issuer private key is present in the server configuration.) @@ -265,9 +333,9 @@ func (s *Server) SubscribeServerSentEvents(w http.ResponseWriter, r *http.Reques var session *session if requestor { - session = s.sessions.get(token) + session = s.sessions.get(irma.RequestorToken(token)) } else { - session = s.sessions.clientGet(token) + session = s.sessions.clientGet(irma.ClientToken(token)) } if session == nil { return server.LogError(errors.Errorf("can't subscribe to server sent events of unknown session %s", token)) @@ -285,7 +353,25 @@ func (s *Server) SubscribeServerSentEvents(w http.ResponseWriter, r *http.Reques go func() { time.Sleep(200 * time.Millisecond) s.serverSentEvents.SendMessage("session/"+token, sse.NewMessage("", "", "open")) + s.serverSentEvents.SendMessage("frontendsession/"+token, sse.NewMessage("", "", "open")) }() s.serverSentEvents.ServeHTTP(w, r) return nil } + +// SessionStatus retrieves a channel on which the current session status of the specified +// IRMA session can be retrieved. +func SessionStatus(requestorToken irma.RequestorToken) (chan irma.ServerStatus, error) { + return s.SessionStatus(requestorToken) +} +func (s *Server) SessionStatus(requestorToken irma.RequestorToken) (chan irma.ServerStatus, error) { + session := s.sessions.get(requestorToken) + if session == nil { + return nil, server.LogError(errors.Errorf("can't get session status of unknown session %s", requestorToken)) + } + + statusChan := make(chan irma.ServerStatus, 4) + statusChan <- session.status + session.statusChannels = append(session.statusChannels, statusChan) + return statusChan, nil +} diff --git a/server/irmaserver/handle.go b/server/irmaserver/handle.go index 0b24977b8..b96c5b462 100644 --- a/server/irmaserver/handle.go +++ b/server/irmaserver/handle.go @@ -29,21 +29,34 @@ func (session *session) handleDelete() { } session.markAlive() - session.result = &server.SessionResult{Token: session.token, Status: server.StatusCancelled, Type: session.action} - session.setStatus(server.StatusCancelled) + session.result = &server.SessionResult{Token: session.requestorToken, Status: irma.ServerStatusCancelled, Type: session.action} + session.setStatus(irma.ServerStatusCancelled) } -func (session *session) handleGetRequest(min, max *irma.ProtocolVersion) (irma.SessionRequest, *irma.RemoteError) { - if session.status != server.StatusInitialized { +func (session *session) handleGetClientRequest(min, max *irma.ProtocolVersion, clientAuth irma.ClientAuthorization) ( + interface{}, *irma.RemoteError) { + + if session.status != irma.ServerStatusInitialized { return nil, server.RemoteError(server.ErrorUnexpectedRequest, "Session already started") } session.markAlive() - logger := session.conf.Logger.WithFields(logrus.Fields{"session": session.token}) + logger := session.conf.Logger.WithFields(logrus.Fields{"session": session.requestorToken}) + + var err error + if session.version, err = session.chooseProtocolVersion(min, max); err != nil { + return nil, session.fail(server.ErrorProtocolVersion, "") + } + + // Protocol versions below 2.8 don't include an authorization header. Therefore skip the authorization + // header presence check if a lower version is used. + if clientAuth == "" && session.version.Above(2, 7) { + return nil, session.fail(server.ErrorIrmaUnauthorized, "No authorization header provided") + } + session.clientAuth = clientAuth // we include the latest revocation updates for the client here, as opposed to when the session // was started, so that the client always gets the very latest revocation records - var err error if err = session.conf.IrmaConfiguration.Revocation.SetRevocationUpdates(session.request.Base()); err != nil { return nil, session.fail(server.ErrorRevocation, err.Error()) } @@ -56,13 +69,14 @@ func (session *session) handleGetRequest(min, max *irma.ProtocolVersion) (irma.S logger.Info("Using condiscon: backwards compatibility with legacy IRMA apps is disabled") } - if session.version, err = session.chooseProtocolVersion(min, max); err != nil { - return nil, session.fail(server.ErrorProtocolVersion, "") - } logger.WithFields(logrus.Fields{"version": session.version.String()}).Debugf("Protocol version negotiated") session.request.Base().ProtocolVersion = session.version - session.setStatus(server.StatusConnected) + if session.options.PairingMethod != irma.PairingMethodNone && session.version.Above(2, 7) { + session.setStatus(irma.ServerStatusPairing) + } else { + session.setStatus(irma.ServerStatusConnected) + } if session.version.Below(2, 5) { logger.Info("Returning legacy session format") @@ -70,30 +84,26 @@ func (session *session) handleGetRequest(min, max *irma.ProtocolVersion) (irma.S return legacy, nil } - // In case of issuance requests, strip revocation keys from []CredentialRequest - isreq, issuing := session.request.(*irma.IssuanceRequest) - if !issuing { - return session.request, nil + if session.version.Below(2, 8) { + // These versions do not support the ClientSessionRequest format, so send the SessionRequest. + request, err := session.getRequest() + if err != nil { + return nil, session.fail(server.ErrorRevocation, err.Error()) + } + return request, nil } - cpy, err := copyObject(isreq) + info, err := session.getClientRequest() if err != nil { return nil, session.fail(server.ErrorRevocation, err.Error()) } - for _, cred := range cpy.(*irma.IssuanceRequest).Credentials { - cred.RevocationSupported = cred.RevocationKey != "" - cred.RevocationKey = "" - } - return cpy.(*irma.IssuanceRequest), nil + return info, nil } -func (session *session) handleGetStatus() (server.Status, *irma.RemoteError) { +func (session *session) handleGetStatus() (irma.ServerStatus, *irma.RemoteError) { return session.status, nil } func (session *session) handlePostSignature(signature *irma.SignedMessage) (*irma.ServerSessionResponse, *irma.RemoteError) { - if session.status != server.StatusConnected { - return nil, server.RemoteError(server.ErrorUnexpectedRequest, "Session not yet started or already finished") - } session.markAlive() var err error @@ -106,7 +116,7 @@ func (session *session) handlePostSignature(signature *irma.SignedMessage) (*irm session.result.Disclosed, session.result.ProofStatus, err = signature.Verify(session.conf.IrmaConfiguration, request) if err == nil { - session.setStatus(server.StatusDone) + session.setStatus(irma.ServerStatusDone) } else { if err == irma.ErrMissingPublicKey { rerr = session.fail(server.ErrorUnknownPublicKey, err.Error()) @@ -122,9 +132,6 @@ func (session *session) handlePostSignature(signature *irma.SignedMessage) (*irm } func (session *session) handlePostDisclosure(disclosure *irma.Disclosure) (*irma.ServerSessionResponse, *irma.RemoteError) { - if session.status != server.StatusConnected { - return nil, server.RemoteError(server.ErrorUnexpectedRequest, "Session not yet started or already finished") - } session.markAlive() var err error @@ -136,7 +143,7 @@ func (session *session) handlePostDisclosure(disclosure *irma.Disclosure) (*irma session.result.Disclosed, session.result.ProofStatus, err = disclosure.Verify(session.conf.IrmaConfiguration, request) if err == nil { - session.setStatus(server.StatusDone) + session.setStatus(irma.ServerStatusDone) } else { if err == irma.ErrMissingPublicKey { rerr = session.fail(server.ErrorUnknownPublicKey, err.Error()) @@ -153,11 +160,7 @@ func (session *session) handlePostDisclosure(disclosure *irma.Disclosure) (*irma } func (session *session) handlePostCommitments(commitments *irma.IssueCommitmentMessage) (*irma.ServerSessionResponse, *irma.RemoteError) { - if session.status != server.StatusConnected { - return nil, server.RemoteError(server.ErrorUnexpectedRequest, "Session not yet started or already finished") - } session.markAlive() - request := session.request.(*irma.IssuanceRequest) discloseCount := len(commitments.Proofs) - len(request.Credentials) @@ -233,7 +236,7 @@ func (session *session) handlePostCommitments(commitments *irma.IssueCommitmentM sigs = append(sigs, sig) } - session.setStatus(server.StatusDone) + session.setStatus(irma.ServerStatusDone) return &irma.ServerSessionResponse{ SessionType: irma.ActionIssuing, ProtocolVersion: session.version, @@ -248,7 +251,7 @@ func (session *session) nextSession() (irma.RequestorRequest, irma.AttributeConD return nil, nil, nil } url := base.NextSession.URL - if session.result.Status != server.StatusDone || + if session.result.Status != irma.ServerStatusDone || session.result.ProofStatus != irma.ProofStatusValid || session.result.Err != nil { return nil, nil, errors.New("session in invalid state") @@ -306,16 +309,18 @@ func (s *Server) startNext(session *session, res *irma.ServerSessionResponse) er if next == nil { return nil } - qr, token, err := s.StartSession(next, nil) + qr, token, _, err := s.StartSession(next, nil) if err != nil { return err } session.result.NextSession = token + session.next = qr // All attributes that were disclosed in the previous session, as well as any attributes // from sessions before that, need to be disclosed in the new session as well newsession := s.sessions.get(token) newsession.implicitDisclosure = disclosed + newsession.frontendAuth = session.frontendAuth res.NextSession = qr return nil @@ -394,9 +399,9 @@ func (s *Server) handleSessionStatusEvents(w http.ResponseWriter, r *http.Reques session.Unlock() r = r.WithContext(context.WithValue(r.Context(), "sse", common.SSECtx{ Component: server.ComponentSession, - Arg: session.clientToken, + Arg: string(session.clientToken), })) - if err := s.SubscribeServerSentEvents(w, r, session.clientToken, false); err != nil { + if err := s.SubscribeServerSentEvents(w, r, string(session.clientToken), false); err != nil { server.WriteError(w, server.ErrorUnknown, err.Error()) return } @@ -418,17 +423,83 @@ func (s *Server) handleSessionGet(w http.ResponseWriter, r *http.Request) { return } session := r.Context().Value("session").(*session) - res, err := session.handleGetRequest(&min, &max) + clientAuth := irma.ClientAuthorization(r.Header.Get(irma.AuthorizationHeader)) + res, err := session.handleGetClientRequest(&min, &max, clientAuth) server.WriteResponse(w, res, err) } +func (s *Server) handleSessionGetRequest(w http.ResponseWriter, r *http.Request) { + session := r.Context().Value("session").(*session) + if session.version.Below(2, 8) { + server.WriteError(w, server.ErrorUnexpectedRequest, "Endpoint is not support in used protocol version") + return + } + var rerr *irma.RemoteError + request, err := session.getRequest() + if err != nil { + rerr = session.fail(server.ErrorRevocation, err.Error()) + } + server.WriteResponse(w, request, rerr) +} + +func (s *Server) handleFrontendStatus(w http.ResponseWriter, r *http.Request) { + session := r.Context().Value("session").(*session) + status := irma.FrontendSessionStatus{Status: session.status, NextSession: session.next} + server.WriteResponse(w, status, nil) +} + +func (s *Server) handleFrontendStatusEvents(w http.ResponseWriter, r *http.Request) { + session := r.Context().Value("session").(*session) + session.locked = false + session.Unlock() + r = r.WithContext(context.WithValue(r.Context(), "sse", common.SSECtx{ + Component: server.ComponentFrontendSession, + Arg: string(session.clientToken), + })) + if err := s.SubscribeServerSentEvents(w, r, string(session.clientToken), false); err != nil { + server.WriteError(w, server.ErrorUnknown, err.Error()) + return + } +} + +func (s *Server) handleFrontendOptionsPost(w http.ResponseWriter, r *http.Request) { + optionsRequest := &irma.FrontendOptionsRequest{} + bts, err := ioutil.ReadAll(r.Body) + if err != nil { + server.WriteError(w, server.ErrorMalformedInput, err.Error()) + return + } + err = irma.UnmarshalValidate(bts, optionsRequest) + if err != nil { + server.WriteError(w, server.ErrorMalformedInput, err.Error()) + return + } + + session := r.Context().Value("session").(*session) + res, err := session.updateFrontendOptions(optionsRequest) + if err != nil { + server.WriteError(w, server.ErrorUnexpectedRequest, err.Error()) + return + } + server.WriteResponse(w, res, nil) +} + +func (s *Server) handleFrontendPairingCompleted(w http.ResponseWriter, r *http.Request) { + session := r.Context().Value("session").(*session) + if err := session.pairingCompleted(); err != nil { + server.WriteError(w, server.ErrorUnexpectedRequest, err.Error()) + return + } + w.WriteHeader(http.StatusNoContent) +} + func (s *Server) handleStaticMessage(w http.ResponseWriter, r *http.Request) { rrequest := s.conf.StaticSessionRequests[chi.URLParam(r, "name")] if rrequest == nil { server.WriteResponse(w, nil, server.RemoteError(server.ErrorInvalidRequest, "unknown static session")) return } - qr, _, err := s.StartSession(rrequest, s.doResultCallback) + qr, _, _, err := s.StartSession(rrequest, s.doResultCallback) if err != nil { server.WriteResponse(w, nil, server.RemoteError(server.ErrorMalformedInput, err.Error())) return diff --git a/server/irmaserver/helpers.go b/server/irmaserver/helpers.go index a67b2d82f..8c0ddca30 100644 --- a/server/irmaserver/helpers.go +++ b/server/irmaserver/helpers.go @@ -31,11 +31,11 @@ import ( func (session *session) markAlive() { session.lastActive = time.Now() - session.conf.Logger.WithFields(logrus.Fields{"session": session.token}).Debugf("Session marked active, expiry delayed") + session.conf.Logger.WithFields(logrus.Fields{"session": session.requestorToken}).Debugf("Session marked active, expiry delayed") } -func (session *session) setStatus(status server.Status) { - session.conf.Logger.WithFields(logrus.Fields{"session": session.token, "prevStatus": session.prevStatus, "status": status}). +func (session *session) setStatus(status irma.ServerStatus) { + session.conf.Logger.WithFields(logrus.Fields{"session": session.requestorToken, "prevStatus": session.prevStatus, "status": status}). Info("Session status updated") session.status = status session.result.Status = status @@ -43,21 +43,61 @@ func (session *session) setStatus(status server.Status) { } func (session *session) onUpdate() { + // Send status update to all listener channels + for _, statusChan := range session.statusChannels { + statusChan <- session.status + if session.status.Finished() { + close(statusChan) + } + } + + frontendstatus, _ := json.Marshal(irma.FrontendSessionStatus{Status: session.status, NextSession: session.next}) + if session.sse == nil { return } - session.sse.SendMessage("session/"+session.clientToken, + session.sse.SendMessage("session/"+string(session.clientToken), sse.SimpleMessage(fmt.Sprintf(`"%s"`, session.status)), ) - session.sse.SendMessage("session/"+session.token, + session.sse.SendMessage("session/"+string(session.requestorToken), sse.SimpleMessage(fmt.Sprintf(`"%s"`, session.status)), ) + session.sse.SendMessage("frontendsession/"+string(session.clientToken), + sse.SimpleMessage(string(frontendstatus)), + ) +} + +// Checks whether requested options are valid in the current session context. +func (session *session) updateFrontendOptions(request *irma.FrontendOptionsRequest) (*irma.SessionOptions, error) { + if session.status != irma.ServerStatusInitialized { + return nil, errors.New("Frontend options can only be updated when session is in initialized state") + } + if request.PairingMethod == "" { + return &session.options, nil + } else if request.PairingMethod == irma.PairingMethodNone { + session.options.PairingCode = "" + } else if request.PairingMethod == irma.PairingMethodPin { + session.options.PairingCode = common.NewPairingCode() + } else { + return nil, errors.New("Pairing method unknown") + } + session.options.PairingMethod = request.PairingMethod + return &session.options, nil +} + +// Complete the pairing process of frontend and irma client +func (session *session) pairingCompleted() error { + if session.status == irma.ServerStatusPairing { + session.setStatus(irma.ServerStatusConnected) + return nil + } + return errors.New("Pairing was not enabled") } func (session *session) fail(err server.Error, message string) *irma.RemoteError { rerr := server.RemoteError(err, message) - session.setStatus(server.StatusCancelled) - session.result = &server.SessionResult{Err: rerr, Token: session.token, Status: server.StatusCancelled, Type: session.action} + session.setStatus(irma.ServerStatusCancelled) + session.result = &server.SessionResult{Err: rerr, Token: session.requestorToken, Status: irma.ServerStatusCancelled, Type: session.action} return rerr } @@ -92,11 +132,13 @@ const retryTimeLimit = 10 * time.Second // checkCache returns a previously cached response, for replaying against multiple requests from // irmago's retryablehttp client, if: -// - the same was POSTed as last time +// - the same body was POSTed to the same endpoint as last time +// - the body is not empty // - last time was not more than 10 seconds ago (retryablehttp client gives up before this) // - the session status is what it is expected to be when receiving the request for a second time. -func (session *session) checkCache(message []byte) (int, []byte) { - if len(session.responseCache.response) == 0 || +func (session *session) checkCache(endpoint string, message []byte) (int, []byte) { + if session.responseCache.endpoint != endpoint || + len(session.responseCache.response) == 0 || session.responseCache.sessionStatus != session.status || session.lastActive.Before(time.Now().Add(-retryTimeLimit)) || sha256.Sum256(session.responseCache.message) != sha256.Sum256(message) { @@ -267,6 +309,40 @@ func (session *session) getProofP(commitments *irma.IssueCommitmentMessage, sche return session.kssProofs[scheme], nil } +func (session *session) getClientRequest() (*irma.ClientSessionRequest, error) { + info := irma.ClientSessionRequest{ + LDContext: irma.LDContextClientSessionRequest, + ProtocolVersion: session.version, + Options: &session.options, + } + + if session.options.PairingMethod == irma.PairingMethodNone { + request, err := session.getRequest() + if err != nil { + return nil, err + } + info.Request = request + } + return &info, nil +} + +func (session *session) getRequest() (irma.SessionRequest, error) { + // In case of issuance requests, strip revocation keys from []CredentialRequest + isreq, issuing := session.request.(*irma.IssuanceRequest) + if !issuing { + return session.request, nil + } + cpy, err := copyObject(isreq) + if err != nil { + return nil, err + } + for _, cred := range cpy.(*irma.IssuanceRequest).Credentials { + cred.RevocationSupported = cred.RevocationKey != "" + cred.RevocationKey = "" + } + return cpy.(*irma.IssuanceRequest), nil +} + // Other func (s *Server) doResultCallback(result *server.SessionResult) { @@ -354,6 +430,8 @@ func eventServer(conf *server.Configuration) *sse.Server { switch ssectx.(common.SSECtx).Component { case server.ComponentSession: return "session/" + ssectx.(common.SSECtx).Arg + case server.ComponentFrontendSession: + return "frontendsession/" + ssectx.(common.SSECtx).Arg case server.ComponentRevocation: return "revocation/" + ssectx.(common.SSECtx).Arg default: @@ -375,6 +453,19 @@ func errorWriter(err *irma.RemoteError, writer func(w http.ResponseWriter, objec } } +func (s *Server) frontendMiddleware(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + session := r.Context().Value("session").(*session) + frontendAuth := irma.FrontendAuthorization(r.Header.Get(irma.AuthorizationHeader)) + + if frontendAuth != session.frontendAuth { + server.WriteError(w, server.ErrorIrmaUnauthorized, "") + return + } + next.ServeHTTP(w, r) + }) +} + func (s *Server) cacheMiddleware(next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { session := r.Context().Value("session").(*session) @@ -389,7 +480,7 @@ func (s *Server) cacheMiddleware(next http.Handler) http.Handler { r.Body = ioutil.NopCloser(bytes.NewBuffer(message)) // if a cache is set and applicable, return it - status, output := session.checkCache(message) + status, output := session.checkCache(r.URL.Path, message) if status > 0 && len(output) > 0 { w.WriteHeader(status) _, _ = w.Write(output) @@ -403,6 +494,7 @@ func (s *Server) cacheMiddleware(next http.Handler) http.Handler { next.ServeHTTP(ww, r) session.responseCache = responseCache{ + endpoint: r.URL.Path, message: message, response: buf.Bytes(), status: ww.Status(), @@ -413,7 +505,7 @@ func (s *Server) cacheMiddleware(next http.Handler) http.Handler { func (s *Server) sessionMiddleware(next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - token := chi.URLParam(r, "token") + token := irma.ClientToken(chi.URLParam(r, "clientToken")) session := s.sessions.clientGet(token) if session == nil { server.WriteError(w, server.ErrorSessionUnknown, "") @@ -434,7 +526,7 @@ func (s *Server) sessionMiddleware(next http.Handler) http.Handler { if session.status.Finished() { if handler := s.handlers[result.Token]; handler != nil { go handler(result) - delete(s.handlers, token) + delete(s.handlers, result.Token) } } } @@ -447,3 +539,28 @@ func (s *Server) sessionMiddleware(next http.Handler) http.Handler { next.ServeHTTP(w, r.WithContext(context.WithValue(ctx, "session", session))) }) } + +func (s *Server) pairingMiddleware(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + session := r.Context().Value("session").(*session) + + if session.status == irma.ServerStatusPairing { + server.WriteError(w, server.ErrorPairingRequired, "") + return + } + + // Endpoints behind the pairingMiddleware can only be accessed when the client is already connected + // and the request includes the right authorization header to prove we still talk to the same client as before. + if session.status != irma.ServerStatusConnected { + server.WriteError(w, server.ErrorUnexpectedRequest, "Session not yet started or already finished") + return + } + clientAuth := irma.ClientAuthorization(r.Header.Get(irma.AuthorizationHeader)) + if session.clientAuth != clientAuth { + server.WriteError(w, server.ErrorIrmaUnauthorized, "") + return + } + + next.ServeHTTP(w, r) + }) +} diff --git a/server/irmaserver/sessions.go b/server/irmaserver/sessions.go index 9c7138273..9b0986270 100644 --- a/server/irmaserver/sessions.go +++ b/server/irmaserver/sessions.go @@ -20,19 +20,24 @@ type session struct { locked bool action irma.Action - token string - clientToken string + requestorToken irma.RequestorToken + clientToken irma.ClientToken + frontendAuth irma.FrontendAuthorization version *irma.ProtocolVersion rrequest irma.RequestorRequest request irma.SessionRequest legacyCompatible bool // if the request is convertible to pre-condiscon format implicitDisclosure irma.AttributeConDisCon + next *irma.Qr - status server.Status - prevStatus server.Status - sse *sse.Server - responseCache responseCache + options irma.SessionOptions + status irma.ServerStatus + prevStatus irma.ServerStatus + sse *sse.Server + statusChannels []chan irma.ServerStatus + responseCache responseCache + clientAuth irma.ClientAuthorization lastActive time.Time result *server.SessionResult @@ -43,15 +48,16 @@ type session struct { } type responseCache struct { + endpoint string message []byte response []byte status int - sessionStatus server.Status + sessionStatus irma.ServerStatus } type sessionStore interface { - get(token string) *session - clientGet(token string) *session + get(token irma.RequestorToken) *session + clientGet(token irma.ClientToken) *session add(session *session) update(session *session) deleteExpired() @@ -62,8 +68,8 @@ type memorySessionStore struct { sync.RWMutex conf *server.Configuration - requestor map[string]*session - client map[string]*session + requestor map[irma.RequestorToken]*session + client map[irma.ClientToken]*session } const ( @@ -72,16 +78,19 @@ const ( var ( minProtocolVersion = irma.NewVersion(2, 4) - maxProtocolVersion = irma.NewVersion(2, 7) + maxProtocolVersion = irma.NewVersion(2, 8) + + minFrontendProtocolVersion = irma.NewVersion(1, 0) + maxFrontendProtocolVersion = irma.NewVersion(1, 1) ) -func (s *memorySessionStore) get(t string) *session { +func (s *memorySessionStore) get(t irma.RequestorToken) *session { s.RLock() defer s.RUnlock() return s.requestor[t] } -func (s *memorySessionStore) clientGet(t string) *session { +func (s *memorySessionStore) clientGet(t irma.ClientToken) *session { s.RLock() defer s.RUnlock() return s.client[t] @@ -90,7 +99,7 @@ func (s *memorySessionStore) clientGet(t string) *session { func (s *memorySessionStore) add(session *session) { s.Lock() defer s.Unlock() - s.requestor[session.token] = session + s.requestor[session.requestorToken] = session s.client[session.clientToken] = session } @@ -103,8 +112,9 @@ func (s *memorySessionStore) stop() { defer s.Unlock() for _, session := range s.requestor { if session.sse != nil { - session.sse.CloseChannel("session/" + session.token) - session.sse.CloseChannel("session/" + session.clientToken) + session.sse.CloseChannel("session/" + string(session.requestorToken)) + session.sse.CloseChannel("session/" + string(session.clientToken)) + session.sse.CloseChannel("frontendsession/" + string(session.clientToken)) } } } @@ -113,27 +123,27 @@ func (s *memorySessionStore) deleteExpired() { // First check which sessions have expired // We don't need a write lock for this yet, so postpone that for actual deleting s.RLock() - toCheck := make(map[string]*session, len(s.requestor)) + toCheck := make(map[irma.RequestorToken]*session, len(s.requestor)) for token, session := range s.requestor { toCheck[token] = session } s.RUnlock() - expired := make([]string, 0, len(toCheck)) + expired := make([]irma.RequestorToken, 0, len(toCheck)) for token, session := range toCheck { session.Lock() timeout := maxSessionLifetime - if session.status == server.StatusInitialized && session.rrequest.Base().ClientTimeout != 0 { + if session.status == irma.ServerStatusInitialized && session.rrequest.Base().ClientTimeout != 0 { timeout = time.Duration(session.rrequest.Base().ClientTimeout) * time.Second } if session.lastActive.Add(timeout).Before(time.Now()) { if !session.status.Finished() { - s.conf.Logger.WithFields(logrus.Fields{"session": session.token}).Infof("Session expired") + s.conf.Logger.WithFields(logrus.Fields{"session": session.requestorToken}).Infof("Session expired") session.markAlive() - session.setStatus(server.StatusTimeout) + session.setStatus(irma.ServerStatusTimeout) } else { - s.conf.Logger.WithFields(logrus.Fields{"session": session.token}).Infof("Deleting session") + s.conf.Logger.WithFields(logrus.Fields{"session": session.requestorToken}).Infof("Deleting session") expired = append(expired, token) } } @@ -145,8 +155,9 @@ func (s *memorySessionStore) deleteExpired() { for _, token := range expired { session := s.requestor[token] if session.sse != nil { - session.sse.CloseChannel("session/" + session.token) - session.sse.CloseChannel("session/" + session.clientToken) + session.sse.CloseChannel("session/" + string(session.requestorToken)) + session.sse.CloseChannel("session/" + string(session.clientToken)) + session.sse.CloseChannel("frontendsession/" + string(session.clientToken)) } delete(s.client, session.clientToken) delete(s.requestor, token) @@ -157,39 +168,45 @@ func (s *memorySessionStore) deleteExpired() { var one *big.Int = big.NewInt(1) func (s *Server) newSession(action irma.Action, request irma.RequestorRequest) *session { - token := common.NewSessionToken() - clientToken := common.NewSessionToken() + clientToken := irma.ClientToken(common.NewSessionToken()) + requestorToken := irma.RequestorToken(common.NewSessionToken()) + frontendAuth := irma.FrontendAuthorization(common.NewSessionToken()) base := request.SessionRequest().Base() if s.conf.AugmentClientReturnURL && base.AugmentReturnURL && base.ClientReturnURL != "" { if strings.Contains(base.ClientReturnURL, "?") { - base.ClientReturnURL += "&token=" + token + base.ClientReturnURL += "&token=" + string(requestorToken) } else { - base.ClientReturnURL += "?token=" + token + base.ClientReturnURL += "?token=" + string(requestorToken) } } ses := &session{ - action: action, - rrequest: request, - request: request.SessionRequest(), - lastActive: time.Now(), - token: token, - clientToken: clientToken, - status: server.StatusInitialized, - prevStatus: server.StatusInitialized, - conf: s.conf, - sessions: s.sessions, - sse: s.serverSentEvents, + action: action, + rrequest: request, + request: request.SessionRequest(), + options: irma.SessionOptions{ + LDContext: irma.LDContextSessionOptions, + PairingMethod: irma.PairingMethodNone, + }, + lastActive: time.Now(), + requestorToken: requestorToken, + clientToken: clientToken, + frontendAuth: frontendAuth, + status: irma.ServerStatusInitialized, + prevStatus: irma.ServerStatusInitialized, + conf: s.conf, + sessions: s.sessions, + sse: s.serverSentEvents, result: &server.SessionResult{ LegacySession: request.SessionRequest().Base().Legacy(), - Token: token, + Token: requestorToken, Type: action, - Status: server.StatusInitialized, + Status: irma.ServerStatusInitialized, }, } - s.conf.Logger.WithFields(logrus.Fields{"session": ses.token}).Debug("New session started") + s.conf.Logger.WithFields(logrus.Fields{"session": ses.requestorToken}).Debug("New session started") nonce, _ := gabi.GenerateNonce() base.Nonce = nonce base.Context = one diff --git a/server/requestorserver/server.go b/server/requestorserver/server.go index b6df38b23..8b1e385af 100644 --- a/server/requestorserver/server.go +++ b/server/requestorserver/server.go @@ -208,7 +208,7 @@ func (s *Server) Handler() http.Handler { // Server routes r.Route("/session", func(r chi.Router) { r.Post("/", s.handleCreateSession) - r.Route("/{token}", func(r chi.Router) { + r.Route("/{requestorToken}", func(r chi.Router) { r.Delete("/", s.handleDelete) r.Get("/status", s.handleStatus) r.Get("/statusevents", s.handleStatusEvents) @@ -308,7 +308,7 @@ func (s *Server) handleRevocation(w http.ResponseWriter, r *http.Request) { } func (s *Server) handleStatus(w http.ResponseWriter, r *http.Request) { - res := s.irmaserv.GetSessionResult(chi.URLParam(r, "token")) + res := s.irmaserv.GetSessionResult(irma.RequestorToken(chi.URLParam(r, "requestorToken"))) if res == nil { server.WriteError(w, server.ErrorSessionUnknown, "") return @@ -317,13 +317,13 @@ func (s *Server) handleStatus(w http.ResponseWriter, r *http.Request) { } func (s *Server) handleStatusEvents(w http.ResponseWriter, r *http.Request) { - token := chi.URLParam(r, "token") - s.conf.Logger.WithFields(logrus.Fields{"session": token}).Debug("new client subscribed to server sent events") + requestorToken := chi.URLParam(r, "requestorToken") + s.conf.Logger.WithFields(logrus.Fields{"session": requestorToken}).Debug("new client subscribed to server sent events") r = r.WithContext(context.WithValue(r.Context(), "sse", common.SSECtx{ Component: server.ComponentSession, - Arg: token, + Arg: requestorToken, })) - if err := s.irmaserv.SubscribeServerSentEvents(w, r, token, true); err != nil { + if err := s.irmaserv.SubscribeServerSentEvents(w, r, requestorToken, true); err != nil { server.WriteResponse(w, nil, &irma.RemoteError{ Status: server.ErrorUnsupported.Status, ErrorName: string(server.ErrorUnsupported.Type), @@ -333,14 +333,14 @@ func (s *Server) handleStatusEvents(w http.ResponseWriter, r *http.Request) { } func (s *Server) handleDelete(w http.ResponseWriter, r *http.Request) { - err := s.irmaserv.CancelSession(chi.URLParam(r, "token")) + err := s.irmaserv.CancelSession(irma.RequestorToken(chi.URLParam(r, "requestorToken"))) if err != nil { server.WriteError(w, server.ErrorSessionUnknown, "") } } func (s *Server) handleResult(w http.ResponseWriter, r *http.Request) { - res := s.irmaserv.GetSessionResult(chi.URLParam(r, "token")) + res := s.irmaserv.GetSessionResult(irma.RequestorToken(chi.URLParam(r, "requestorToken"))) if res == nil { server.WriteError(w, server.ErrorSessionUnknown, "") return @@ -359,8 +359,8 @@ func (s *Server) handleJwtResult(w http.ResponseWriter, r *http.Request) { return } - sessiontoken := chi.URLParam(r, "token") - res := s.irmaserv.GetSessionResult(sessiontoken) + requestorToken := irma.RequestorToken(chi.URLParam(r, "requestorToken")) + res := s.irmaserv.GetSessionResult(requestorToken) if res == nil { server.WriteError(w, server.ErrorSessionUnknown, "") return @@ -387,8 +387,8 @@ func (s *Server) handleJwtProofs(w http.ResponseWriter, r *http.Request) { return } - sessiontoken := chi.URLParam(r, "token") - res := s.irmaserv.GetSessionResult(sessiontoken) + requestorToken := irma.RequestorToken(chi.URLParam(r, "requestorToken")) + res := s.irmaserv.GetSessionResult(requestorToken) if res == nil { server.WriteError(w, server.ErrorSessionUnknown, "") return @@ -413,7 +413,7 @@ func (s *Server) handleJwtProofs(w http.ResponseWriter, r *http.Request) { claims["iss"] = s.conf.JwtIssuer } claims["status"] = res.ProofStatus - validity := s.irmaserv.GetRequest(sessiontoken).Base().ResultJwtValidity + validity := s.irmaserv.GetRequest(requestorToken).Base().ResultJwtValidity if validity != 0 { claims["exp"] = time.Now().Unix() + int64(validity) } @@ -518,15 +518,16 @@ func (s *Server) createSession(w http.ResponseWriter, requestor string, rrequest } // Everything is authenticated and parsed, we're good to go! - qr, token, err := s.irmaserv.StartSession(rrequest, s.doResultCallback) + qr, requestorToken, frontendRequest, err := s.irmaserv.StartSession(rrequest, s.doResultCallback) if err != nil { server.WriteError(w, server.ErrorInvalidRequest, err.Error()) return } server.WriteJson(w, server.SessionPackage{ - SessionPtr: qr, - Token: token, + SessionPtr: qr, + Token: requestorToken, + FrontendRequest: frontendRequest, }) } diff --git a/verify.go b/verify.go index dc34d3902..4329d7dad 100644 --- a/verify.go +++ b/verify.go @@ -394,7 +394,7 @@ func (d *Disclosure) Verify(configuration *Configuration, request *DisclosureReq // Verify the attribute-based signature, optionally against a corresponding signature request. If the request is present // (i.e. not nil), then the first attributes in the returned result match with the disjunction list in the request // (that is, the i'th attribute in the result should satisfy the i'th disjunction in the request). If the request is not -// fully satisfied in this fasion, the Status of the result is ProofStatusMissingAttributes. Any remaining attributes +// fully satisfied in this fashion, the Status of the result is ProofStatusMissingAttributes. Any remaining attributes // (i.e. not asked for by the request) are also included in the result, after the attributes that match disjunctions // in the request. // diff --git a/wait_status.go b/wait_status.go new file mode 100644 index 000000000..4b3d75d66 --- /dev/null +++ b/wait_status.go @@ -0,0 +1,102 @@ +package irma + +import ( + "context" + "strings" + "time" + + sseclient "github.com/sietseringers/go-sse" +) + +const pollInterval = 1000 * time.Millisecond + +func WaitStatus(transport *HTTPTransport, initialStatus ServerStatus, statuschan chan ServerStatus, errorchan chan error) { + if err := subscribeSSE(transport, statuschan, errorchan, false); err != nil { + go poll(transport, initialStatus, statuschan, errorchan) + } +} + +func WaitStatusChanged(transport *HTTPTransport, initialStatus ServerStatus, statuschan chan ServerStatus, errorchan chan error) { + if err := subscribeSSE(transport, statuschan, errorchan, true); err != nil { + go pollUntilChange(transport, initialStatus, statuschan, errorchan) + } +} + +// Start listening for server-sent events +func subscribeSSE(transport *HTTPTransport, statuschan chan ServerStatus, errorchan chan error, untilNextOnly bool) error { + ctx, cancel := context.WithCancel(context.Background()) + + events := make(chan *sseclient.Event) + cancelled := false + go func() { + for { + e := <-events + if e == nil || e.Type == "open" { + continue + } + status := ServerStatus(strings.Trim(string(e.Data), `"`)) + statuschan <- status + if untilNextOnly || status.Finished() { + errorchan <- nil + cancelled = true + cancel() + return + } + } + }() + + err := sseclient.Notify(ctx, transport.Server+"statusevents", true, events) + // When sse was cancelled, an error is expected to be returned. The channels are already closed then. + if cancelled { + return nil + } + close(events) + return err +} + +// poll recursively polls the session status until a final status is received. +func poll(transport *HTTPTransport, initialStatus ServerStatus, statuschan chan ServerStatus, errorchan chan error) { + status := initialStatus + statuschanPolling := make(chan ServerStatus) + errorchanPolling := make(chan error) + go pollUntilChange(transport, status, statuschanPolling, errorchanPolling) + for { + select { + case status = <-statuschanPolling: + statuschan <- status + if status.Finished() { + errorchan <- nil + return + } + break + case err := <-errorchanPolling: + if err != nil { + errorchan <- err + return + } + go pollUntilChange(transport, status, statuschanPolling, errorchanPolling) + } + } +} + +func pollUntilChange(transport *HTTPTransport, initialStatus ServerStatus, statuschan chan ServerStatus, errorchan chan error) { + // First we wait + <-time.NewTimer(pollInterval).C + + // Get session status + var s string + if err := transport.Get("status", &s); err != nil { + errorchan <- err + return + } + status := ServerStatus(strings.Trim(s, `"`)) + + // report if status changed + if status != initialStatus { + statuschan <- status + errorchan <- nil + return + } + + go pollUntilChange(transport, status, statuschan, errorchan) +}