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
}