Eine eigene Web-Anwendung zu schreiben, ist inzwischen nicht mehr schwer. Es gibt gute Fameworks wie Ruby on Rails, Django, Sinatra oder Gatsby, die das Programmieren erheblich vereinfachen. Wenn so eine Web-Anwendung dann abgesichert werden soll, wird meist eine Anmeldung mit Benutzername und Passwort eingebaut. Mit der neuen SSI Technologie kann man aber auch einen App-basierten Schlüssel für die Anmeldung nutzen. Das Scannen eines QR Codes und ein Klick zur Bestätigung genügen für die Anmeldung. Man braucht sich kein Passwort merken und als Admin keine Benutzer verwalten. Und eine zusätzliche TAN App wie den Google Authenticator kann man sich auch sparen, da SSI Wallets von Haus aus 2-Faktor-Authentifizierung bieten.

SSI steht für Self-Sovereign Identity und basiert auf einem neuen Webstandard für digitale Benutzerausweise. Das besondere daran ist, dass neben der Identität auch die dazugehörigen Daten verknüpft werden können. Also zB die Benutzerrolle, oder eine Adresse, oder ein Benutzerprofil, oder eine Anwendungs-Konfiguration, und vieles mehr. Für eine Anmeldung wird dann lediglich die Kennung geprüft. Die ist nämlich weltweit eindeutig und ist ohne den dazugehörigen geheimen Schlüssel nutzlos. Man kann einen Benutzer also eindeutig identifizieren und die mitgeschickten Daten dem Benutzer sicher zugeordnet werden. Das eröffnet unendlich viele Anwendungsfälle.

Eine Typescript Entwicklungsumgebung einrichten

Anhand einer einfachen Web-Anwendung auf dem Raspberry Pi auf Basis von Node.js soll gezeigt werden wie eine SSI-basierte Anmeldung funktioniert. Der Code der Anwendung liegt auf Github und kann mit dem folgendem Befehl heruntergeladen werden:

git clone git@github.com:rheikvaneyck/sideos-login.git

Für die Entwicklungsumgebung der Typescript Anwendung müssen die entsprechenden Tools installiert werden. Mit

sudo curl -o- https://raw.githubusercontent.com/nvm-sh/nvm/v0.39.3/install.sh | bash

wird der node Versions-Manager installiert. Anschließend kann mit

nvm install 16

die Node.js Version 16 installiert werden. Als Paket-Manager verwenden wir yarn, welcher mit

npm install —global yarn

installiert wird. Auf dem Raspberry Pi wird allerdings nicht der Link für cmdtest überschrieben, da es einen Konflikt mit einem anderen Programm yarn gibt. Die einfachste Lösung war ein

alias yarn=/home/pi/.nvm/versions/node/v16.19.0/bin/yarn  

in der ~/.bashrc Datei, um einen alias auf das richtige yarn script zu definieren. Jetzt noch die Redis Datenbank installieren und als Systemdienst starten:

sudo apt update && sudo apt install redis-server -y
sudo systemctl enable redis-server
sudo systemctl start redis-server

In Redis werden dann die Benutzer-abhängigen Session Daten gespeichert. Nun kann man in das Code Verzeichnis wechseln und die node Pakete installieren. Außerdem wird noch eine Datei für die Umgebungsvariablen erstellt:

cd sideos-login
yarn install
touch .env 

Die Konfiguration für den Server holt sich die Anwendung dann aus den Umgebungsvariablen. Die werden in der eben erstellten .env Datei gespeichert und sollten so aussehen:

ACCESS_TOKEN=<API token>
SSI_SERVER='https://juno.sideos.io'
LOGIN_TEMPLATE_ID=<template id>
CALLBACK_URL=<server url>
DID_ISSUER=<did>
SESSION_SECRET=<session secret>

Die Werte für die Variable ACCESS_TOKEN, SSI_SERVER, LOGIN_TEMPLATE_ID, und DID_ISSUER basieren auf den Informationen aus dem sideos Account, den wir gleich anlegen werden.

Die CALLBACK_URL Variable enthält die URL des Servers und wird nach dem Start des Servers angezeigt, zB http://pi3p:9000.

Die Variable SESSION_SECRET ist eine Zeichenkette mit dem geheimen Session Kennwort. Dafür kann man zB mit uuid eine zufällige Zeichenfolge generieren.

Einen sideos Account einrichten

Für die SSI-basierte Anmeldung brauchen wir ein SSI Wallet und das entsprechende Verifiable Credential auf dem Smartphone. Wir nutzen hier sideos für einen schnellen Einstieg in das Thema SSI. sideos ist ein Cloud Service für SSI as a Service und kann über eine REST API angesprochen werden. Auf der Registrierungsseite von sideos kann kostenlos ein Account eingerichtet werden. Die SSI Wallet gibt es im Google Play Store oder Apple Store. Die nächsten Schritte sind also:

  1. download der sideos Transponder App aus dem Google Play oder Apple Store
  2. einen Account bei sideos einrichten indem man den Anweisungen auf der sideos Registrierungsseite folgt

Verifiable Credentials erstellen

Für die Registrierung bei sideos wurde bereits das erste Verifiable Credential erstellt, denn auch bei der Konsole meldet man sich nicht mehr mit einem Passwort an. Nach dem Einloggen landet man in der Admin Konsole und kann dort eigene Vorlagen für Verifiable Credentials erstellen. Diese Vorlagen definieren den Datensatz, der als Verifiable Credential erstellt oder später überprüft werden soll. Für das Login soll ein Verifiable Credential zum Beispiel eine Email enthalten. Die Datenfelder im Verifiable Credential nennt man Proof.

Für ein eigenes Credential definieren wir ein Proof „Email“ und erstellen dann ein Template für ein Verifiable Credential:

  1. In Proofs, füge einen solchen hinzu: add a new proof. Gib dem Proof einen Namen, erstelle einen neuen Typ, zB „email“, und einen Kontext. Der Kontext ist in unserem Beispiel einer einfachen Zeichenfolge ein „DaaFeedItem“.
  2. In Credentials, erstelle eine neue Vorlage mit new template. Erstelle einen neuen Typ mit Credential type, zB „User“ und gib ihm einen Namen. Wähle nun den Proof, den wir eben erstellt haben.
  3. Notiere die ID der Vorlage, die wir eben erstellt haben. Diese ID wird als LOGIN_TEMPLATE_ID in die Datei .env hinzugefügt.
  4. In Settings gehe zu Company Settings und erstelle einen Token der in der Datei .env als ACCESS_TOKEN Variable hinzugefügt wird. Der Wert der Variable DID_ISSUER wird von Company DID übernommen.

Nun müssten alle Variablen in der .env Datei vollständig sein.

Das Verifiable Credential in die SSI Wallet aufnehmen

Normalerweise werden Verifiable Credentials mit der sideos API angefragt und per Email, Web Service oder Push Notification an das Benutzer-Wallet geschickt. Die Daten werden dafür an die API geschickt und als Antwort erhält man ein Credential Offer auf Basis der gewählten Vorlage. Das Credential Offer wird dann mit der Wallet eingelesen und als Verifiable Credential gespeichert. Man kann aber auch Verifiable Credentials direkt in der Konsole erstellen. Dafür öffnet man die Detailansicht für eine Vorlage und klickt auf den Button Test credentials. Das so erstellte Credential kann sofort in die SSI Wallet geladen werden.

Den Test-Service starten

Wenn der sideos Account eingerichtet ist, die Vorlage erstellt und das Verifiable Credential in der SSI Wallet geladen wurde, kann der Test Server mit yarn start:dev gestartet werden. Mit dem Browser öffnen wir dann die Server URL, in unserem Fall http://pi3p:9000/, und probieren das Login aus,

pi@pi3p:~/sideos-login $ yarn start:dev
yarn run v1.22.19
$ nodemon src/index.ts
[nodemon] 2.0.20
[nodemon] to restart at any time, enter `rs`
[nodemon] watching path(s): src/**/*
[nodemon] watching extensions: ts,json
[nodemon] starting `ts-node ./src/index.ts src/index.ts`
Server started at http://pi3p:9000
Redis Client connected...
Redis Client ready

Wie funktionierts?

Die Anwendung startet einen web server mit Hilfe des express package (Zeile 15-19) und verbindet sich mit Redis (Zeile 21-28). Redis wird dann als Session Speicher genutzt (Zeile 30-36), und hält während des Logins die SSI Token für die jeweilige Session.

Die Webseiten werden aus dem Verzeichnis „views“ geladen und mit der ejs engine gerendert. Auf diese Weise können Variablen in die Webseiten eingebaut werden, die vor der Darstellung ausgewertet werden (Zeile 38-42). So wird der QR Code aus der URL erstellt und in der Webseite als SVG eingebettet.

Ohne gültigen Session Token wird der Benutzer immer wieder auf die Login Seite geleitet (Zeile 72-77).

Der Login-Vorgang erfolgt in 6 Schritten:

  1. Die Login-Seite holt sich mit der Funktion getLoginCreateReqest() ein Credential Request von der sideos API, der als QR Code in der Seite dargestellt wird. Da ein vollständiger SSI Request viele Daten enthält würde der QR Code sehr viele Pixel benötigen. Daher bauen wir einen Zwischenschritt ein und speichern den eigentlichen SSI Request in die Session Daten und stellen nur den Link zum SSI Request im QR Code dar. Der Credential Request wird auf Basis einer Credential Vorlage erstellt (hier ist die Template ID wichtig) und bedeutet in unserem Fall: „Hey, gib mir ein Verifiable Credential mit einer Email als Proof“ (Zeile 44-58).
  2. Gleichzeitig öffnet die Login-Seite einen Websocket zum Webserver. Auf diese Weise kann der Server der Login-Seite mitteilen, dass das Login erfolgreich war und die Login-Seite kann automatisch zur Homepage weiterleiten ohne das der Benutzer einen Button klicken muss (Zeile 61-67).
  3. Der Benutzer scannt den QR Code mit der SSI Wallet. Die Wallet lädt daraufhin den Request von http://pi3p:9000/gettoken/ und erkennt am Request, dass ein Verifiable Credential mit einem Email Proof angefordert wird. Ist ein solches Credenital in der Wallet vorhanden, wird der Benutzer gefragt, ob er es mit dem Web Service teilen möchte und schickt es bei Zustimmung an die Callback URL http://pi3p:9000/login/, wie im Request angegeben.
  4. Der Login-Endpunkt des Servers verarbeitet die Antwort mit der Funktion postLoginConsumeReqest(). Diese schickt die Antwort an die sideos API zur Überprüfung und falls alle Daten korrekt sind, wird die Session mit dem DID des Benutzers in Redis gespeichert. So wird die Session autorisiert, denn später wird bei Zugriff auf einer Webseite geschaut, ob für die jeweilige Session eine DID hinterlegt wurde. Ist die DID also gespeichert, wird nach dem Websocket der Session in Redis gesucht, um der Login-Seite hocherfreut den Inhalt des Credentials mitzuteilen (Zeile 58-59).
  5. Die Login-Seite empfängt die Neuigkeiten auf dem Websocket und leitet automatisch an Homepage weiter.
  6. Der Browser übermittelt beim Zugriff auf die Homepage die Session ID an den Server, der schaut, ob die Session ID tatsächlich autorisiert wurde und gibt den Inhalt der Homepage frei.

Es gibt noch einen Logout-Endpunkt, der die Session löscht und damit wieder auf die Login-Seite umleitet (Zeile 89-98).

require('dotenv').config()
import os from 'os';
import express, { Express, Request, Response, NextFunction } from 'express';
import expressWs from 'express-ws';
import WebSocket from 'ws';
import http from 'http';
import path from 'path';
import { createClient } from 'redis';
import connectRedis from 'connect-redis';
import session from 'express-session';
import qr from 'qrcode';
import { getLoginCreateReqest, getLoginVcToken, postLoginConsumeReqest } from './ssi_auth'
import { setSocket } from './sockets'

const app = express()
const server = http.createServer(app)
expressWs(app, server)
const router = express.Router()
const port = process.env.PORT || 9000;

const redisClient = createClient({ legacyMode: true })
const RedisStore = connectRedis(session)

redisClient.connect().catch(console.error)

redisClient.on('error', (err) => console.log('Redis Client Error', err));
redisClient.on('connect', (err) => console.log('Redis Client connected...'));
redisClient.on('ready', (err) => console.log('Redis Client ready'));

app.use(session({
  store: new RedisStore({ client: redisClient }),
  secret: process.env.SESSION_SECRET as string,
  resave: false,
  saveUninitialized: true,
  cookie: { secure: false }
}));

app.use(express.urlencoded({extended: true}));
app.use(express.json());
app.use(express.static(path.join(__dirname, '../public')));
app.set('views', path.join(__dirname, '../views'));
app.set('view engine', 'ejs');

app.route('/login')
  .get(async (req: Request,res: Response) => {
    const request = await getLoginCreateReqest()
    if(request.data.challenge && request.data.jwt) {
      await redisClient.set('chjwt:'+request.data.challenge, request.data.jwt)
      await redisClient.set('chid:'+request.data.challenge, req.session.id)
      const url =  process.env.CALLBACK_URL + "/gettoken/" + request.data.challenge
      qr.toDataURL(url, (err, qrcode) => {
        if(err) res.send("Error occured")
        res.render("login",{qrcode: qrcode, ws_url: process.env.CALLBACK_URL?.replace(/http/, 'ws')+'/login'});
      })
    }
  });

app.route('/login/:token?')
  .post(postLoginConsumeReqest);

(app as unknown as expressWs.Application).ws('/login', (ws: WebSocket, req: Request) => {
    ws.on('message', (msg: string) => {
      console.log(msg);
    });
    console.log('socket', req.session.id);
    setSocket(req.session.id, ws)
})

app.route('/gettoken/:token?')
  .get(getLoginVcToken);

app.use(async function(req: Request,res: Response, next: NextFunction) {
  const user_id = await redisClient.v4.get('vc:'+req.session.id, (err: any, data: any) => { console.log('found:',data) })
  console.log('returned:', user_id)
  if(user_id) { return next(); } 
  else { res.redirect('/login') }
});

router.get('/', async function(reg: Request, res: Response, next: NextFunction) {
  res.redirect('/home');
})

router.get('/home', async function(req: Request, res: Response, next: NextFunction) {
  const session_id = req.session.id
  const vc = await redisClient.v4.get('vc:'+session_id)
  res.render("home", {vc: JSON.parse(vc)});
})

router.get('/logout', async (req: Request, res: Response, next: NextFunction) => {
  const nr = await redisClient.v4.del('vc:'+req.session.id, (err: Error, n: number) => {
    if(err) { console.log('Trouble deleting vc from redis') }
  })
  await req.session.destroy( (error: Error) => {
    if (error) { console.log('Error destroying the session', error); } 
    else { console.log('session destroyed') }
  })
  res.redirect('/login')
})

app.use('/', router)
server.listen(port, () => {
  console.log('Server started at http://' + os.hostname + ':' + port);
});

Wenn alles passt und das Credential vom vertrauenswürdigen Herausgeber stammt, wird die Homepage geladen.

Beim Login wird kein Passwort geprüft. Es gibt nicht einmal eine Benutzerverwaltung im Webserver. Hier werden nur Session Daten verwendet, denn die Prüfung ob der Benutzer autorisiert ist, auf die Webseite zuzugreifen, basiert auf der DID des Herausgebers. Das Credential wurde vom Herausgeber (dem sideos Account Inhaber) digital signiert. Wenn also ein solches Credential beim Einloggen vorgezeigt wird, gibt der Webserver dem Benutzer den Zugang frei.

if(vc.issuer.id === process.env.DID_ISSUER && vc.credentialSubject.id) {
      const did = vcs.credentialSubject.id;
      const session_id = await redisClient.v4.get('chid:'+authToken)
      await redisClient.v4.del('chid:'+authToken)
      await redisClient.set('vc:'+session_id, JSON.stringify({did: did, name: vc.credentialSubject.name, issuer: vc.issuer.name}))

      const ws = getSocket(session_id);   
      if(ws) {
        ws.send(JSON.stringify({
          error: 0,
          authToken: authToken,
          did: did,
          email: vc.credentialSubject.Email,
          name: vc.credentialSubject.name,
        })) 
      }        
    }

Der Code ist ziemlich übersichtlich und einfacher kann man ein Passwortloses Login kaum bauen. Das coole an der SSI Variante ist, dass es nicht nur beim Login bleiben muss, sondern man alle möglichen Daten in ein Credential packen kann. Zum Beispiel kann man komplette Anmeldedaten, Versanddaten, Konfigurationen und vieles mehr über SSI automatisch an einen Service schicken und sichergehen, dass die Daten nicht manipuliert wurden. SSI enthält ja kryptografische Funktionen für den Schutz der Daten, so dass bei Manipulation der Credentials ein Fehler bei der Prüfung von der sideos API zurückkommt.