api.go 19.4 KB
Newer Older
Sietse Ringers's avatar
Sietse Ringers committed
1
// Package servercore is the core of the IRMA server library, allowing IRMA verifiers, issuers
2
3
// or attribute-based signature applications to perform IRMA sessions with irmaclient instances
// (i.e. the IRMA app). It exposes a small interface to expose to other programming languages
Sietse Ringers's avatar
Sietse Ringers committed
4
// through cgo. It is used by the irmaserver package but otherwise not meant for use in Go.
5
package servercore
6
7
8

import (
	"encoding/json"
9
	"io/ioutil"
10
	"net/http"
11
	"path/filepath"
12
	"regexp"
13
	"strconv"
14
	"strings"
15
	"time"
16
17

	"github.com/go-errors/errors"
18
	"github.com/jasonlvhit/gocron"
19
20
	"github.com/privacybydesign/gabi"
	"github.com/privacybydesign/gabi/big"
21
	"github.com/privacybydesign/gabi/revocation"
22
	"github.com/privacybydesign/irmago"
23
	"github.com/privacybydesign/irmago/internal/fs"
Sietse Ringers's avatar
Sietse Ringers committed
24
	"github.com/privacybydesign/irmago/server"
25
	"github.com/sirupsen/logrus"
26
27
)

28
type Server struct {
29
30
31
32
	conf          *server.Configuration
	sessions      sessionStore
	scheduler     *gocron.Scheduler
	stopScheduler chan bool
33
34
35
36
37
38
39
}

func New(conf *server.Configuration) (*Server, error) {
	s := &Server{
		conf:      conf,
		scheduler: gocron.NewScheduler(),
		sessions: &memorySessionStore{
40
41
42
			requestor: make(map[string]*session),
			client:    make(map[string]*session),
			conf:      conf,
43
44
45
46
47
		},
	}
	s.scheduler.Every(10).Seconds().Do(func() {
		s.sessions.deleteExpired()
	})
48
	s.stopScheduler = s.scheduler.Start()
49
50
51

	return s, s.verifyConfiguration(s.conf)
}
52

53
func (s *Server) Stop() {
54
55
56
	if err := s.conf.IrmaConfiguration.Close(); err != nil {
		_ = server.LogWarning(err)
	}
57
58
59
60
	s.stopScheduler <- true
	s.sessions.stop()
}

61
func (s *Server) verifyIrmaConf(configuration *server.Configuration) error {
62
	if s.conf.IrmaConfiguration == nil {
63
64
65
66
67
		var (
			err    error
			exists bool
		)
		if s.conf.SchemesPath == "" {
68
			s.conf.SchemesPath = irma.DefaultSchemesPath() // Returns an existing path
69
70
71
72
73
		}
		if exists, err = fs.PathExists(s.conf.SchemesPath); err != nil {
			return server.LogError(err)
		}
		if !exists {
74
			return server.LogError(errors.Errorf("Nonexisting schemes_path provided: %s", s.conf.SchemesPath))
75
		}
76
		s.conf.Logger.WithField("schemes_path", s.conf.SchemesPath).Info("Determined schemes path")
77
78
		if s.conf.SchemesAssetsPath == "" {
			s.conf.IrmaConfiguration, err = irma.NewConfiguration(s.conf.SchemesPath)
79
		} else {
80
			s.conf.IrmaConfiguration, err = irma.NewConfigurationFromAssets(s.conf.SchemesPath, s.conf.SchemesAssetsPath)
81
		}
82
		if err != nil {
83
			return server.LogError(err)
84
		}
85
		if err = s.conf.IrmaConfiguration.ParseFolder(); err != nil {
86
			return server.LogError(err)
87
		}
88
89
90
91
		if err = fs.EnsureDirectoryExists(s.conf.RevocationPath); err != nil {
			return server.LogError(err)
		}
		s.conf.IrmaConfiguration.RevocationPath = s.conf.RevocationPath
92
93
	}

94
	if len(s.conf.IrmaConfiguration.SchemeManagers) == 0 {
95
96
97
		s.conf.Logger.Infof("No schemes found in %s, downloading default (irma-demo and pbdf)", s.conf.SchemesPath)
		if err := s.conf.IrmaConfiguration.DownloadDefaultSchemes(); err != nil {
			return server.LogError(err)
98
		}
99
	}
100

101
	if !s.conf.DisableSchemesUpdate {
102
103
104
		if s.conf.SchemesUpdateInterval == 0 {
			s.conf.SchemesUpdateInterval = 60
		}
105
		s.conf.IrmaConfiguration.AutoUpdateSchemes(uint(s.conf.SchemesUpdateInterval))
106
107
	} else {
		s.conf.SchemesUpdateInterval = 0
Sietse Ringers's avatar
Sietse Ringers committed
108
109
	}

110
111
112
113
	return nil
}

func (s *Server) verifyPrivateKeys(configuration *server.Configuration) error {
114
115
	if s.conf.IssuerPrivateKeys == nil {
		s.conf.IssuerPrivateKeys = make(map[irma.IssuerIdentifier]*gabi.PrivateKey)
116
	}
117
118
	if s.conf.IssuerPrivateKeysPath != "" {
		files, err := ioutil.ReadDir(s.conf.IssuerPrivateKeysPath)
119
		if err != nil {
120
			return server.LogError(err)
121
122
123
		}
		for _, file := range files {
			filename := file.Name()
124
125
			if filepath.Ext(filename) != ".xml" || filename[0] == '.' || strings.Count(filename, ".") != 2 {
				s.conf.Logger.WithField("file", filename).Infof("Skipping non-private key file encountered in private keys path")
126
127
				continue
			}
128
			issid := irma.NewIssuerIdentifier(strings.TrimSuffix(filename, filepath.Ext(filename))) // strip .xml
129
			if _, ok := s.conf.IrmaConfiguration.Issuers[issid]; !ok {
130
				return server.LogError(errors.Errorf("Private key %s belongs to an unknown issuer", filename))
131
			}
132
			sk, err := gabi.NewPrivateKeyFromFile(filepath.Join(s.conf.IssuerPrivateKeysPath, filename))
133
			if err != nil {
134
				return server.LogError(err)
135
			}
136
			s.conf.IssuerPrivateKeys[issid] = sk
137
138
		}
	}
139
140
	for issid, sk := range s.conf.IssuerPrivateKeys {
		pk, err := s.conf.IrmaConfiguration.PublicKey(issid, int(sk.Counter))
141
		if err != nil {
142
			return server.LogError(err)
143
144
		}
		if pk == nil {
145
			return server.LogError(errors.Errorf("Missing public key belonging to private key %s-%d", issid.String(), sk.Counter))
146
147
		}
		if new(big.Int).Mul(sk.P, sk.Q).Cmp(pk.N) != 0 {
148
			return server.LogError(errors.Errorf("Private key %s-%d does not belong to corresponding public key", issid.String(), sk.Counter))
149
150
		}
	}
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
	for issid := range s.conf.IrmaConfiguration.Issuers {
		sk, err := s.conf.PrivateKey(issid)
		if err != nil {
			return server.LogError(err)
		}
		if sk == nil || !sk.RevocationSupported() {
			continue
		}
		for credid, credtype := range s.conf.IrmaConfiguration.CredentialTypes {
			if credtype.IssuerIdentifier() != issid || !credtype.SupportsRevocation {
				continue
			}
			db, err := s.conf.IrmaConfiguration.RevocationDB(credid)
			if err != nil {
				return server.LogError(err)
			}
167
168
169
170
171
172
			if !db.Enabled() {
				s.conf.Logger.WithFields(logrus.Fields{"cred": credid}).Warn("revocation supported in scheme but not enabled")
			} else {
				if err = db.LoadCurrent(); err != nil {
					return server.LogError(err)
				}
173
				s.conf.RevocableCredentials[credid] = struct{}{}
174
175
176
			}
		}
	}
177

178
179
180
181
	return nil
}

func (s *Server) verifyURL(configuration *server.Configuration) error {
182
183
184
	if s.conf.URL != "" {
		if !strings.HasSuffix(s.conf.URL, "/") {
			s.conf.URL = s.conf.URL + "/"
185
		}
186
187
188
189
190
191
192
193
194
195
		if !strings.HasPrefix(s.conf.URL, "https://") {
			if !s.conf.Production || s.conf.DisableTLS {
				s.conf.DisableTLS = true
				s.conf.Logger.Warnf("TLS is not enabled on the url \"%s\" to which the IRMA app will connect. "+
					"Ensure that attributes are encrypted in transit by either enabling TLS or adding TLS in a reverse proxy.", s.conf.URL)
			} else {
				return server.LogError(errors.Errorf("Running without TLS in production mode is unsafe without a reverse proxy. " +
					"Either use a https:// URL or explicitly disable TLS."))
			}
		}
196
	} else {
197
		s.conf.Logger.Warn("No url parameter specified in configuration; unless an url is elsewhere prepended in the QR, the IRMA client will not be able to connect")
198
	}
199
200
	return nil
}
201

202
func (s *Server) verifyEmail(configuration *server.Configuration) error {
Sietse Ringers's avatar
Sietse Ringers committed
203
204
205
206
207
	if s.conf.Email != "" {
		// Very basic sanity checks
		if !strings.Contains(s.conf.Email, "@") || strings.Contains(s.conf.Email, "\n") {
			return server.LogError(errors.New("Invalid email address specified"))
		}
Sietse Ringers's avatar
Sietse Ringers committed
208
		t := irma.NewHTTPTransport("https://metrics.privacybydesign.foundation/history")
Sietse Ringers's avatar
Sietse Ringers committed
209
210
		t.SetHeader("User-Agent", "irmaserver")
		var x string
Sietse Ringers's avatar
Sietse Ringers committed
211
		_ = t.Post("email", &x, s.conf.Email)
Sietse Ringers's avatar
Sietse Ringers committed
212
	}
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
	return nil
}

func (s *Server) verifyConfiguration(configuration *server.Configuration) error {
	if s.conf.Logger == nil {
		s.conf.Logger = server.NewLogger(s.conf.Verbose, s.conf.Quiet, s.conf.LogJSON)
	}
	server.Logger = s.conf.Logger
	irma.Logger = s.conf.Logger

	// loop to avoid repetetive err != nil line triplets
	for _, f := range []func(*server.Configuration) error{s.verifyIrmaConf, s.verifyPrivateKeys, s.verifyURL, s.verifyEmail} {
		if err := f(configuration); err != nil {
			return err
		}
	}
Sietse Ringers's avatar
Sietse Ringers committed
229

230
231
232
	return nil
}

233
func (s *Server) validateRequest(request irma.SessionRequest) error {
234
235
236
237
	if _, err := s.conf.IrmaConfiguration.Download(request); err != nil {
		return err
	}
	return request.Disclosure().Disclose.Validate(s.conf.IrmaConfiguration)
238
239
}

240
func (s *Server) StartSession(req interface{}) (*irma.Qr, string, error) {
241
242
	rrequest, err := server.ParseSessionRequest(req)
	if err != nil {
243
		return nil, "", err
244
	}
245
246
247

	request := rrequest.SessionRequest()
	action := request.Action()
248

Leon's avatar
Leon committed
249
250
251
252
	if err := s.validateRequest(request); err != nil {
		return nil, "", err
	}

253
	if action == irma.ActionIssuing {
254
		if err := s.validateIssuanceRequest(request.(*irma.IssuanceRequest)); err != nil {
255
			return nil, "", err
256
257
258
		}
	}

259
260
261
262
	session := s.newSession(action, rrequest)
	s.conf.Logger.WithFields(logrus.Fields{"action": action, "session": session.token}).Infof("Session started")
	if s.conf.Logger.IsLevelEnabled(logrus.DebugLevel) {
		s.conf.Logger.WithFields(logrus.Fields{"session": session.token}).Info("Session request: ", server.ToJson(rrequest))
263
	} else {
264
		s.conf.Logger.WithFields(logrus.Fields{"session": session.token}).Info("Session request (purged of attribute values): ", server.ToJson(purgeRequest(rrequest)))
265
	}
266
267
	return &irma.Qr{
		Type: action,
268
		URL:  s.conf.URL + "session/" + session.clientToken,
269
270
271
	}, session.token, nil
}

272
273
func (s *Server) GetSessionResult(token string) *server.SessionResult {
	session := s.sessions.get(token)
274
	if session == nil {
275
		s.conf.Logger.Warn("Session result requested of unknown session ", token)
Sietse Ringers's avatar
Sietse Ringers committed
276
277
278
279
280
		return nil
	}
	return session.result
}

281
282
func (s *Server) GetRequest(token string) irma.RequestorRequest {
	session := s.sessions.get(token)
283
	if session == nil {
284
		s.conf.Logger.Warn("Session request requested of unknown session ", token)
285
286
287
288
289
		return nil
	}
	return session.rrequest
}

290
291
func (s *Server) CancelSession(token string) error {
	session := s.sessions.get(token)
292
	if session == nil {
293
		return server.LogError(errors.Errorf("can't cancel unknown session %s", token))
294
295
296
297
298
	}
	session.handleDelete()
	return nil
}

299
300
301
302
303
func ParsePath(path string) (token, noun string, arg []string, err error) {
	client := regexp.MustCompile("session/(\\w+)/?(|commitments|proofs|status|statusevents)$")
	matches := client.FindStringSubmatch(path)
	if len(matches) == 3 {
		return matches[1], matches[2], nil, nil
304
	}
305
306
307
308
309
310
311
312
313

	rev := regexp.MustCompile("-/revocation/(records)/?(.*)$")
	matches = rev.FindStringSubmatch(path)
	if len(matches) == 3 {
		args := strings.Split(matches[2], "/")
		return "", matches[1], args, nil
	}

	return "", "", nil, server.LogWarning(errors.Errorf("Invalid URL: %s", path))
314
315
}

316
func (s *Server) SubscribeServerSentEvents(w http.ResponseWriter, r *http.Request, token string, requestor bool) error {
317
318
319
320
	if !s.conf.EnableSSE {
		return errors.New("Server sent events disabled")
	}

321
322
323
324
325
326
	var session *session
	if requestor {
		session = s.sessions.get(token)
	} else {
		session = s.sessions.clientGet(token)
	}
327
328
329
330
331
332
333
334
335
	if session == nil {
		return server.LogError(errors.Errorf("can't subscribe to server sent events of unknown session %s", token))
	}
	if session.status.Finished() {
		return server.LogError(errors.Errorf("can't subscribe to server sent events of finished session %s", token))
	}

	session.Lock()
	defer session.Unlock()
336
337
338
339
340
341
342
343
344
345
346
347
348

	// The EventSource.onopen Javascript callback is not consistently called across browsers (Chrome yes, Firefox+Safari no).
	// However, when the SSE connection has been opened the webclient needs some signal so that it can early detect SSE failures.
	// So we manually send an "open" event. Unfortunately:
	// - we need to give the webclient that connected just now some time, otherwise it will miss the "open" event
	// - the "open" event also goes to all other webclients currently listening, as we have no way to send this
	//   event to just the webclient currently listening. (Thus the handler of this "open" event must be idempotent.)
	evtSource := session.eventSource()
	go func() {
		time.Sleep(200 * time.Millisecond)
		evtSource.SendEventMessage("", "open", "")
	}()
	evtSource.ServeHTTP(w, r)
349
350
351
	return nil
}

352
func (s *Server) HandleProtocolMessage(
353
354
355
356
	path string,
	method string,
	headers map[string][]string,
	message []byte,
357
358
359
360
) (int, []byte, *server.SessionResult) {
	var start time.Time
	if s.conf.Verbose >= 2 {
		start = time.Now()
361
		server.LogRequest("client", method, path, "", http.Header(headers), message)
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
	}

	status, output, result := s.handleProtocolMessage(path, method, headers, message)

	if s.conf.Verbose >= 2 {
		server.LogResponse(status, time.Now().Sub(start), output)
	}

	return status, output, result
}

func (s *Server) handleProtocolMessage(
	path string,
	method string,
	headers map[string][]string,
	message []byte,
Sietse Ringers's avatar
Sietse Ringers committed
378
) (status int, output []byte, result *server.SessionResult) {
379
380
381
382
383
384
385
386
387
	// Parse path into session and action
	if len(path) > 0 { // Remove any starting and trailing slash
		if path[0] == '/' {
			path = path[1:]
		}
		if path[len(path)-1] == '/' {
			path = path[:len(path)-1]
		}
	}
388

389
	token, noun, args, err := ParsePath(path)
390
391
	if err != nil {
		status, output = server.JsonResponse(nil, server.RemoteError(server.ErrorUnsupported, ""))
392
393
	}

394
395
396
397
398
399
400
401
402
403
404
	if token != "" {
		status, output, result = s.handleClientMessage(token, noun, method, headers, message)
	} else {
		status, output = s.handleRevocationMessage(noun, method, args, headers, message)
	}
	return
}

func (s *Server) handleClientMessage(
	token, noun, method string, headers map[string][]string, message []byte,
) (status int, output []byte, result *server.SessionResult) {
Sietse Ringers's avatar
Sietse Ringers committed
405
	// Fetch the session
406
	session := s.sessions.clientGet(token)
407
	if session == nil {
408
		s.conf.Logger.WithField("clientToken", token).Warn("Session not found")
Sietse Ringers's avatar
Sietse Ringers committed
409
		status, output = server.JsonResponse(nil, server.RemoteError(server.ErrorSessionUnknown, ""))
410
		return
411
	}
412
413
	session.Lock()
	defer session.Unlock()
414

415
416
	// However we return, if the session status has been updated
	// then we should inform the user by returning a SessionResult
417
	defer func() {
418
419
		if session.status != session.prevStatus {
			session.prevStatus = session.status
420
421
422
423
			result = session.result
		}
	}()

424
	// Route to handler
425
	switch len(noun) {
426
	case 0:
427
		if method == http.MethodDelete {
428
429
430
			session.handleDelete()
			status = http.StatusOK
			return
431
		}
432
		if method == http.MethodGet {
433
434
435
436
			status, output = session.checkCache(message, server.StatusConnected)
			if len(output) != 0 {
				return
			}
437
438
439
440
			h := http.Header(headers)
			min := &irma.ProtocolVersion{}
			max := &irma.ProtocolVersion{}
			if err := json.Unmarshal([]byte(h.Get(irma.MinVersionHeader)), min); err != nil {
Sietse Ringers's avatar
Sietse Ringers committed
441
				status, output = server.JsonResponse(nil, session.fail(server.ErrorMalformedInput, err.Error()))
442
				return
443
444
			}
			if err := json.Unmarshal([]byte(h.Get(irma.MaxVersionHeader)), max); err != nil {
Sietse Ringers's avatar
Sietse Ringers committed
445
				status, output = server.JsonResponse(nil, session.fail(server.ErrorMalformedInput, err.Error()))
446
				return
447
			}
Sietse Ringers's avatar
Sietse Ringers committed
448
			status, output = server.JsonResponse(session.handleGetRequest(min, max))
449
			session.responseCache = responseCache{message: message, response: output, status: status, sessionStatus: server.StatusConnected}
450
			return
451
		}
Sietse Ringers's avatar
Sietse Ringers committed
452
		status, output = server.JsonResponse(nil, session.fail(server.ErrorInvalidRequest, ""))
453
		return
454

455
	default:
456
457
458
459
460
461
		if noun == "statusevents" {
			err := server.RemoteError(server.ErrorInvalidRequest, "server sent events not supported by this server")
			status, output = server.JsonResponse(nil, err)
			return
		}

462
463
		if method == http.MethodGet && noun == "status" {
			status, output = server.JsonResponse(session.handleGetStatus())
Sietse Ringers's avatar
Sietse Ringers committed
464
			return
465
466
467
		}

		// Below are only POST enpoints
468
		if method != http.MethodPost {
Sietse Ringers's avatar
Sietse Ringers committed
469
			status, output = server.JsonResponse(nil, session.fail(server.ErrorInvalidRequest, ""))
Sietse Ringers's avatar
Sietse Ringers committed
470
471
472
			return
		}

473
		if noun == "commitments" && session.action == irma.ActionIssuing {
474
475
476
477
			status, output = session.checkCache(message, server.StatusDone)
			if len(output) != 0 {
				return
			}
Sietse Ringers's avatar
Sietse Ringers committed
478
			commitments := &irma.IssueCommitmentMessage{}
479
480
			if err = irma.UnmarshalValidate(message, commitments); err != nil {
				status, output = server.JsonResponse(nil, session.fail(server.ErrorMalformedInput, err.Error()))
481
				return
482
			}
Sietse Ringers's avatar
Sietse Ringers committed
483
			status, output = server.JsonResponse(session.handlePostCommitments(commitments))
484
			session.responseCache = responseCache{message: message, response: output, status: status, sessionStatus: server.StatusDone}
Sietse Ringers's avatar
Sietse Ringers committed
485
486
			return
		}
487

488
		if noun == "proofs" && session.action == irma.ActionDisclosing {
489
490
491
492
493
494
495
			status, output = session.checkCache(message, server.StatusDone)
			if len(output) != 0 {
				return
			}
			disclosure := &irma.Disclosure{}
			if err = irma.UnmarshalValidate(message, disclosure); err != nil {
				status, output = server.JsonResponse(nil, session.fail(server.ErrorMalformedInput, err.Error()))
496
				return
497
			}
Sietse Ringers's avatar
Sietse Ringers committed
498
			status, output = server.JsonResponse(session.handlePostDisclosure(disclosure))
499
			session.responseCache = responseCache{message: message, response: output, status: status, sessionStatus: server.StatusDone}
Sietse Ringers's avatar
Sietse Ringers committed
500
501
			return
		}
502

503
		if noun == "proofs" && session.action == irma.ActionSigning {
504
505
506
507
			status, output = session.checkCache(message, server.StatusDone)
			if len(output) != 0 {
				return
			}
Sietse Ringers's avatar
Sietse Ringers committed
508
			signature := &irma.SignedMessage{}
509
510
			if err = irma.UnmarshalValidate(message, signature); err != nil {
				status, output = server.JsonResponse(nil, session.fail(server.ErrorMalformedInput, err.Error()))
511
				return
512
			}
Sietse Ringers's avatar
Sietse Ringers committed
513
			status, output = server.JsonResponse(session.handlePostSignature(signature))
514
			session.responseCache = responseCache{message: message, response: output, status: status, sessionStatus: server.StatusDone}
515
			return
516
		}
Sietse Ringers's avatar
Sietse Ringers committed
517

Sietse Ringers's avatar
Sietse Ringers committed
518
		status, output = server.JsonResponse(nil, session.fail(server.ErrorInvalidRequest, ""))
519
		return
520
521
	}
}
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585

func (s *Server) handleRevocationMessage(
	noun, method string, args []string, headers map[string][]string, message []byte,
) (int, []byte) {
	if noun == "records" && method == http.MethodGet {
		if len(args) != 2 {
			return server.JsonResponse(nil, server.RemoteError(server.ErrorInvalidRequest, "GET records expects 2 url arguments"))
		}
		index, err := strconv.Atoi(args[1])
		if err != nil {
			return server.JsonResponse(nil, server.RemoteError(server.ErrorMalformedInput, err.Error()))
		}
		cred := irma.NewCredentialTypeIdentifier(args[0])
		return server.JsonResponse(s.handleGetRevocationRecords(cred, index))
	}
	if noun == "records" && method == http.MethodPost {
		if len(args) != 1 {
			return server.JsonResponse(nil, server.RemoteError(server.ErrorInvalidRequest, "POST records expects 1 url arguments"))
		}
		cred := irma.NewCredentialTypeIdentifier(args[0])
		var records []*revocation.Record
		if err := json.Unmarshal(message, &records); err != nil {
			return server.JsonResponse(nil, server.RemoteError(server.ErrorMalformedInput, err.Error()))
		}
		return server.JsonResponse(s.handlePostRevocationRecords(cred, records))
	}

	return server.JsonResponse(nil, server.RemoteError(server.ErrorInvalidRequest, ""))
}

func (s *Server) handlePostRevocationRecords(
	cred irma.CredentialTypeIdentifier, records []*revocation.Record,
) (interface{}, *irma.RemoteError) {
	if _, ok := s.conf.RevocableCredentials[cred]; !ok {
		return nil, server.RemoteError(server.ErrorInvalidRequest, "not supported by this server")
	}
	db, err := s.conf.IrmaConfiguration.RevocationDB(cred)
	if err != nil {
		return nil, server.RemoteError(server.ErrorUnknown, err.Error()) // TODO error type
	}
	for _, r := range records {
		if err = db.Add(r.Message, r.PublicKeyIndex); err != nil {
			return nil, server.RemoteError(server.ErrorUnknown, err.Error()) // TODO error type
		}
	}
	return nil, nil
}

func (s *Server) handleGetRevocationRecords(
	cred irma.CredentialTypeIdentifier, index int,
) ([]revocation.Record, *irma.RemoteError) {
	if _, ok := s.conf.RevocableCredentials[cred]; !ok {
		return nil, server.RemoteError(server.ErrorInvalidRequest, "not supported by this server")
	}
	db, err := s.conf.IrmaConfiguration.RevocationDB(cred)
	if err != nil {
		return nil, server.RemoteError(server.ErrorUnknown, err.Error()) // TODO error type
	}
	records, err := db.RevocationRecords(index)
	if err != nil {
		return nil, server.RemoteError(server.ErrorUnknown, err.Error()) // TODO error type
	}
	return records, nil
}