Verstecken spielen (Linux-Magazin, Dezember 2022)

Ob auf Notizzetteln unterm Bildschirm oder in einer kommerziellen Applikation wie OnePass, irgendwo müssen sich User ihre Passwörter aufschreiben. Die in dieser Ausgabe vorgestellte Go-Applikation für's Terminal legt die sensiblen Daten verschlüsselt auf der Festplatte ab und zeigt nach Eingabe des Master-Passworts selektierte Einträge an. Die geheimen Daten hinterlassen außer im Speicher des Rechners keinerlei Spuren auf dem Rechner und verflüchtigen sich nach Abschluss des Programms automatisch.

Findige User könnten nun natürlich einfach alle Accountnamen und Passwörter in einer Textdatei ablegen und diese verschlüsseln, aber um neue Einträge hinzuzufügen, müsste die Datei entschlüsselt werden, und nach dem Editieren wieder verschlüsselt. Damit auf der Platte keine Klardaten verbleiben, müsste ein Schrubbbefehl anschließend die gelöschte Datei hinterher auch noch überschreiben. Außerdem kämen nach dem Entschlüsseln alle Passwörter zugleich hoch, prangten prominent auf dem Bildschirm, und ein vorbeieilender Kollege mit Adleraugen könnte vielleicht eines oder mehrere davon erhaschen.

Eine Reihe von Passwort-Apps verwalten Passwörter vorbildlich, aber wer vertraut schon wildfremden Firmen sensible Daten an und verlässt sich darauf, dass diese keine Fehler beim Verschlüsseln oder der Verwaltung machen? Außerdem schlagen Apps wie OnePass mit nicht unerhebliche monatliche Gebühren zu Buche, und ein Mann wie ich muss mit dem Kreuzer rechnen. Das in dieser Ausgabe vorgestellte Programm pv (Password-View) verwaltete eine verschlüsselte Kollektion von Passwörtern und zeigt jeweils einen ausgewählten Eintrag nach Eingabe des Master-Passwords in einer Terminal-UI an (Abbildung 1). Der User kann durch die Einträge scrollen, und sich den gewünschten heraussuchen, bevor dessen sensible Daten dann tatsächlich auf Knopfdruck erscheinen.

Abbildung 1: Der Passwort-Speicher "pv" in Aktion

Auf das Drücken der Enter-Taste hin lässt pv nämlich im ausgewählten Eintrag die Sternchen verschwinden und enthüllt die geheimen Account- und Passwortdaten. Fährt der User mit den Cursor-Tasten k und j (wie beim vi) in der Liste herauf oder herunter, maskiert pv den freigegebenen Eintrag wieder mit Sternchen. Auf Druck der Taste Q faltet das Programm die Terminal-UI zusammen und putzt das Terminalfenster blank. Keinerlei sensitive Daten verbleiben, und auch auf der Festplatte steht nur noch die verschlüsselte Passwortdatei.

Portabel

Als Go-Binary enthält das Programm bereits alles, was es zur Laufzeit auf ähnlichen Architekturen braucht, User müssen also lediglich das Binary und die verschlüsselte Passwortdatei auf Systeme kopieren, auf denen sie Zugriff auf die Passwörter wünschen. Mit der Option --add aufgerufen, fragt das Programm erst nach dem Master-Passwort, und gibt der User es korrekt ein, darf er auf den Prompt New Entry: eine neue Zeile an die verschlüsselte Datei anfügen:

    $ pv --add
    Password: ***
    New entry: gmail bodo@gmail.com hunter123

Dabei ist das erste Wort der zum Passwort zugehörige Service (im vorliegenden Fall "gmail"), dessen Name auch im maskierten Zustand einer Zeile auf der UI erscheint (wie in Zeile 1 in Abbildung 1). Er dient als Navigationshilfe, um den gesuchten Eintrag zu finden und anschließend auszuwählen und anzuzeigen. Der Rest der neu eingefügten Zeile beinhaltet den User-Account und das Passwort, das Format ist frei wählbar, wer will, kann auch nur Merkhilfen speichern und nicht die vollständigen Daten.

Nach Eingabe der neuen Daten (und auch sofort falls pv ohne Optionen aufgerufen wurde) erscheint die Terminal-UI, mit der scrollbaren Listbox, die auf Wunsch ausgewählte Einträge enthüllt.

Crypto-Tausendsassa

Zur Implementierung: Der Passwort-Safe nutzt eine symmetrische Verschlüsselung, also das gleiche Passwort, um die Datei mit den geheimen Passwörtern sowohl zu ver- als auch zu entschlüsseln. Das Projekt "Age" auf Github ([2]) bietet eine von einem Google-Ingenieur geschriebene fertige Go-Library zum Ver- und Entschlüsseln von Daten, hauptsächlich nach Public-Key-Verfahren, aber auch symmetrische Verschlüsselung steht auf dem Programm. Laut Projektseite wird "age" wie das italienische Wort "aghe" (Nadeln) ausgesprochen, also etwa wie das bayrische "Ah, geh!".

Abbildung 2: Das Verschlüsselungsprojekt "Age"

Symmetrisch verschlüsselt

Für die Passworteinträge wählt das Go-Programm eine symmetrische Verschlüsselung, denn dasselbe Master-Passwort soll die Daten sowohl verschlüsseln als auch wieder entschlüsseln. Das ist für eine Datei, auf die immer nur ein User zugreift, die praktischste Lösung. Falls mehrere User sich den Zugriff untereinander teilen, lässt sich mit Public/Private-Schlüsselpaaren, ebenfalls mit Methoden aus der Age-Library, eine Lösung implementieren, die unterschiedlichen Usern den Zugriff auf eine geteilte Datei mit dem jeweils eigenen Passwort erlaubt.

Listing 1 zeigt die später vom Hauptprogramm genutzten Funktionen writeEnc() und readEnc() zum Ver- und Entschlüsseln der Klartextdaten. Zeile 11 definiert mit test.age den Namen der verschlüsselten Passwortdatei auf der Festplatte.

Die Age-Library nutzt zum Schreiben (also zum Verschlüsseln) ein Objekt vom Typ Recipient, also ein Empfänger, der verschlüsselte Daten zugeschickt bekommt. Der Aufruf der Funktion NewScryptRecipient() in Zeile 14 nimmt als einzigen Parameter das Passwort entgegen und Scrypt deutet auf eine symmetrische Crypt-Funktion hin, die Age implementiert. Zeile 19 öffnet die Passwortdatei zum Schreiben, und legt sie wegen O_CREATE neu an, falls sie noch nicht existiert.

Derselben Option fehlt in der Programmiersprache C auf Unix übrigens das letzte "E", dort heißt sie "O_CREAT". Ken Thompson, einer der Unix-Gründerväter, wurde einmal gefragt, was er denn besser machen würde, falls er Unix noch einmal zu entwerfen hätte, und er sagte prompt: "I'd spell creat with an 'e'." ([3]). Go hat ihm offensichtlich diesen Wunsch erfüllt.

Listing 1: crypto.go

    01 package main
    02 
    03 import (
    04   "bytes"
    05   "filippo.io/age"
    06   "filippo.io/age/armor"
    07   "io"
    08   "os"
    09 )
    10 
    11 const secFile string = "test.age"
    12 
    13 func writeEnc(txt string, pass string) error {
    14   recipient, err := age.NewScryptRecipient(pass)
    15   if err != nil {
    16     return err
    17   }
    18 
    19   out, err := os.OpenFile(secFile, os.O_RDWR|os.O_CREATE|os.O_TRUNC, 0600)
    20   if err != nil {
    21     return err
    22   }
    23   defer out.Close()
    24 
    25   armorWriter := armor.NewWriter(out)
    26   defer armorWriter.Close()
    27 
    28   w, err := age.Encrypt(armorWriter, recipient)
    29   if err != nil {
    30     return err
    31   }
    32   defer w.Close()
    33 
    34   if _, err := io.WriteString(w, txt); err != nil {
    35     return err
    36   }
    37 
    38   return nil
    39 }
    40 
    41 func readEnc(pass string) (string, error) {
    42   identity, err := age.NewScryptIdentity(pass)
    43   if err != nil {
    44     return "", err
    45   }
    46 
    47   out := &bytes.Buffer{}
    48 
    49   in, err := os.Open(secFile)
    50   if err != nil {
    51     return "", err
    52   }
    53   defer in.Close()
    54 
    55   armorReader := armor.NewReader(in)
    56 
    57   r, err := age.Decrypt(armorReader, identity)
    58   if err != nil {
    59     return "", err
    60   }
    61   if _, err := io.Copy(out, r); err != nil {
    62     return "", err
    63   }
    64 
    65   return out.String(), nil
    66 }

Panzern zum Transfer

Die Option O_TRUNC staucht wiederum eine bereits existierende Passwortdatei auf Null zusammen, sodass nachfolgende Print-Befehle sie einfach überschreiben. Damit der Datensalat in der verschlüsselten Datei keinen Editor verwirrt und kein Transferprogramm bei der Übertragung des Inhalts übers Netz versucht, binäre Sequenzen umzustrukturieren, setzt Zeile 25 einen Writer vom Typ armor auf, der quasi eine "Panzerung" (engl. "armor") um die Binärdaten legt, sodass sie zwar immer noch verschlüsselt aber schön als gleichlange Zeilen ohne Escape-Sequenzen im Editor erscheinen (Abbildung 3).

Abbildung 3: Die verschlüsselte Passwortdatei auf der Festplatte

An den verwendeten Funktionen lässt sich schön der in Go oft verwendete Writer-Mechanismus illustrieren. Ein Writer nimmt immer Daten entgegen, und schreibt sie irgendwo hin. So öffnet zum Beispiel OpenFile() in Zeile 19 eine Datei und gibt einen Writer namens out zurück. Der Panzerungs-Mechanismus aus dem armor-Paket nimmt das Writer-Objekt entgegen und gibt ein eigenes Writer-Objekt armorWriter zurück. Dieses wiederum nimmt die Funktion Encrypt() in Zeile 28 entgegen und gibt einen weiteren Writer w zurück, in den dann Zeile 34 mit io.WriteString() hineinzuschreiben beginnt. Der Code implementiert also eine Unix-Pipe-artige Verknüpfung von geschachtelten Funktionen. Am Anfang schreibt wer hinein, und am Ende purzeln die mehrfach bearbeiteten Ergebnisdaten heraus. Dank des von Go unterstützten Writer-Interfaces müssen sich die Funktionen in der Kette auch keine Gedanken über den Typus der Daten machen, die sie da transportieren, solange jedes Glied in der Kette das Writer-Interface unterstützt, läuft alles wie am Schnürchen.

Im vorliegenden Fall nutzen die Age-Funktionen sogar das WriteCloser-Interface, das sowohl Write()- als auch Close()-Aufrufe kennt. Letztere sind bei gepufferten Ausgaben enorm wichtig, unterbleibt der Close()-Aufruf, wird der Cache am Ende unter Umständen nicht geleert, und es stellt sich ruckzuck abgehackter und damit unlesbarer Datensalat am Ziel ein.

Verschlüsseltes lesen

Umgekehrt liest readEnc() ab Zeile 41 Daten aus der verschlüsselten Passwortdatei aus und gibt sie als Stringtext zurück. Dazu nimmt sie das eingegebene Master-Passwort für die symmetrische Verschlüsselung als String entgegen, und pimpt es zu einem Identity-Objekt auf, für den späteren Aufruf der Funktion Decrypt() in Zeile 57.

Doch auch hier muss der Reader erst die Panzerung der verschlüsselten Daten durchbrechen, das erledigt der Reader vom Typ armor in Zeile 55 mit einem weiteren Reader auf die geöffnete Passwortdatei als Parameter. Um die Daten aus dem Reader des Panzerbrechers auszulesen und in einem String zur Rückgabe abzulegen, saugt Zeile 61 mit io.Copy() alle Reader-Daten ab und legt sie in den bereitgestellten bytes-Puffer out. Dessen Methode String() macht aus dem Datenarray einen String und Zeile 65 gibt die somit vorliegen Klardaten an den Aufrufer zurück.

Listing 2: util.go

    01 package main
    02 
    03 func mask(s string) string {
    04   masked := []byte(s)
    05 
    06   tomask := false
    07 
    08   for i := 0; i < len(s); i++ {
    09     if tomask {
    10       masked[i] = '*'
    11     } else {
    12       masked[i] = s[i]
    13     }
    14     if s[i] == ' ' {
    15       tomask = true
    16     }
    17   }
    18   return string(masked)
    19 }
    20 

Damit Einträge in der Listbox des Passwort-Viewers später nicht alle sofort erscheinen, wenn die UI hochkommt, sondern nur das erste Wort jeder Zeile lesbar ist, während den Rest Sternchen zieren, nimmt die Utility-Funktion mask() in Listing 2 einen String entgegen, iteriert über dessen Zeichen, und ersetzt sie durch ein "*" falls das Flag tomask gesetzt ist. Anfangs ist das nicht der Fall, bis Zeile 14 ein Leerzeichen im String erkennt und der Algorithmus sich daraufhin an der Stelle nach dem ersten Wort wähnt. Dort setzt er tomask auf true, und der Rest des Strings wird unter Sternen begraben.

Listing 3: pv.go

    01 package main
    02 
    03 import (
    04   "bufio"
    05   "errors"
    06   "flag"
    07   "fmt"
    08   "golang.org/x/crypto/ssh/terminal"
    09   "os"
    10   "strings"
    11 )
    12 
    13 func main() {
    14   add := flag.Bool("add", false, "Add new password entry")
    15   flag.Parse()
    16 
    17   fmt.Printf("Password: ")
    18   password, err := terminal.ReadPassword(int(os.Stdin.Fd()))
    19   if err != nil {
    20     panic(err)
    21   }
    22 
    23   txt, err := readEnc(string(password))
    24   if err != nil {
    25     if !errors.Is(err, os.ErrNotExist) {
    26       panic(err)
    27     }
    28   }
    29 
    30   if *add {
    31     fmt.Printf("\rNew entry: ")
    32     reader := bufio.NewReader(os.Stdin)
    33     entry, _ := reader.ReadString('\n')
    34     txt = txt + entry
    35     writeEnc(txt, string(password))
    36     return
    37   }
    38 
    39   lines := strings.Split(strings.TrimSuffix(txt, "\n"), "\n")
    40   runUI(lines)
    41 }

Die Hauptfunktion main() in Listing 3 frägt mit dem Paket flag das optionale Kommandozeilen-Flag --add ab und falls der User es gesetzt hat, springt der if-Block in Zeile 30 in den Code ab Zeile 31, der vom User einen neuen Passworteintrag aus der Standardeingabe entgegennimmt und an den Text der vorher entschlüsselte Passwortdatei anhängt.

Keine Datei, kein Problem

Hierzu hat vorher Zeile 17 mit dem "Password:"-Prompt in Zeile 17 zur Eingabe des Master-Passworts aufgefordert, und Zeile 18 hat es mit Hilfe des Standardpakets terminal und dessen ReadPassword()-Funktion eingelesen. Letztere stellt die Standardausgabe auf stumm, also kann der User das Passwort wie gewohnt blind eintippen. Stimmt das Passwort nicht mit dem ursprünglich für die Passwortdatei gesetzten überein, schlägt readEnc() in Zeile 23 fehl und panic() in Zeile 26 bricht das Programm ab. Schlägt readEnc() allerdings fehl, weil die Passwortdatei noch nicht existiert, bekommt dies Zeile 25 mit und lässt das Programm weiterlaufen, bis weiter unten entweder ein neuer Eintrag angehängt oder die leere Datei in der UI angezeigt wird.

Von den Zeilen der entschlüsselten Datei entfernt Zeile 39 dann mit TrimSuffix() das letzte Newline-Zeichen und spaltet die Zeilen mit Split(), beide aus dem Standard-Paket strings, in einen Array von Strings auf, die es in Zeile 40 an die Funktion runUI() übergibt, damit diese die UI startet, die läuft, bis der User sie abbricht und damit das Hauptprogramm endet.

Listing 4: ui.go

    01 package main
    02 
    03 import (
    04   "fmt"
    05   ui "github.com/gizak/termui/v3"
    06   "github.com/gizak/termui/v3/widgets"
    07 )
    08 
    09 func runUI(lines []string) {
    10   rows := []string{}
    11   for _, line := range lines {
    12     rows = append(rows, mask(line))
    13   }
    14 
    15   if err := ui.Init(); err != nil {
    16     panic(err)
    17   }
    18   defer ui.Close()
    19 
    20   lb := widgets.NewList()
    21   lb.Rows = rows
    22   lb.SelectedRow = 0
    23   lb.SelectedRowStyle = ui.NewStyle(ui.ColorBlack)
    24   lb.TextStyle.Fg = ui.ColorGreen
    25   lb.Title = fmt.Sprintf("passview 1.0")
    26 
    27   pa := widgets.NewParagraph()
    28   pa.Text = "[Q]uit [Enter]reveal"
    29   pa.TextStyle.Fg = ui.ColorBlack
    30 
    31   w, h := ui.TerminalDimensions()
    32   lb.SetRect(0, 0, w, h-3)
    33   pa.SetRect(0, h-3, w, h)
    34   ui.Render(lb, pa)
    35 
    36   uiEvents := ui.PollEvents()
    37 
    38   for {
    39     select {
    40     case e := <-uiEvents:
    41       switch e.ID {
    42       case "k":
    43         hideCur(lb)
    44         lb.ScrollUp()
    45         ui.Render(lb)
    46       case "j":
    47         hideCur(lb)
    48         lb.ScrollDown()
    49         ui.Render(lb)
    50       case "q", "<C-c>":
    51         return
    52       case "<Enter>":
    53         showCur(lb, lines)
    54         ui.Render(lb)
    55 
    56       }
    57     }
    58   }
    59 }
    60 
    61 func hideCur(lb *widgets.List) {
    62   idx := lb.SelectedRow
    63   lb.Rows[idx] = mask(lb.Rows[idx])
    64 }
    65 
    66 func showCur(lb *widgets.List, lines []string) {
    67   idx := lb.SelectedRow
    68   lb.Rows[idx] = lines[idx]
    69 }

Die Terminal-UI nutzt, wie schon in vorherigen Snapshot-Kolumnen, das Paket termui von Github. Listing 4 initialisiert dessen Funktionen in Zeile 16 mit ui.Init(), und jedwegen Abbruch seitens des Users quittiert ui.Close() in der defer-Anweisung in Zeile 18. Dies faltet die UI sauber zusammen, damit ein brauchbares Terminal für die Shell zurückbleibt.

Zwei Widgets

Die UI in Abbildung 1 besteht aus zwei übereinanderliegenden Widgets: Oben liegt eine Listbox mit den Passwort-Einträgen, durch die der User scrollen kann, und die auch Paging über mehrere Seiten beherrscht, falls die Anzahl der Einträge über eine Seite hinauswächst. Am unteren Rand des Terminal-Fensters klebt ein Paragraph-Widget, das angibt, welche Tasten der User als Nächstes drücken kann: Die Enter-Taste enthüllt das ausgewählte Passwort, die Taste Q beendet das Programm.

Damit die UI die gesamte Geometrie des Terminalfensters, dessen Größe anfangs unbekannt ist, ausnutzen kann, frägt Zeile 31 dessen Dimensionen mit der Hilfsfunktion TerminalsDimensions() des Termui-Pakets ab. Aus der Breite und Höhe des Fensters bestimmen die Zeile 32-33 dann die Lage und Dimensionen der beiden übereinanderliegenden Widgets, wobei das untere Paragraph-Widget die untersten drei Zeilen erhält und die obenliegende Listbox den Rest. Horizontal breiten sich beide Widgets jeweils bis an die Ränder des Terminal-Fensters aus.

Die Einträge der Listbox sitzen als Array-Slice von Strings in im Attribut Rows der Listbox. Zeile 21 besetzt es mit dem Array-Slice rows, in dem vorher die For-Schleife ab Zeile 11 die unmaskierten Original-Einträge aus lines, der Inhalt der Passwortdatei im Klartext, abgelegt hat, und zwar mit Sternchen maskiert. Die zwei Array-Slices für maskierte und unmaskierte Einträge machen es später einfach, maskierte Einträge zu enthüllen, den der Code muss nur auf der gleichen Indexnummer in den Original-Slice schauen, um die Sternchen wieder wegzumachen.

Nachdem Zeile 34 die Widgets auf den Schirm gebracht hat, feuert Zeile 36 mit PollEvents() eine Goroutine ab, die zukünftig und nebenläufig alle Tastendrücke des Users abfangen und in den Channel uiEvents schicken wird. Von dort holt sie die select-Anweisung in der Endlos-For-Schleife ab Zeile 38 ab und reagiert jeweils sofort auf alle ankommenden Ereignisse. Tippt der User "k", will er nach oben scrollen, und Zeile 43 verhüllt mit hideCur() ab Zeile 61 und der Funktion mask() ein vorher im aktuellen Listboxeintrag eventuell schon enthülltes Passwort. Dann gibt ScrollUp() den Befehl an die Listbox, einen Eintrag nach oben zu scrollen, und der anschließende Render-Befehl zeigt die Veränderung flüssig in der UI an. Analoges gilt für die Taste "j", bei der der User in der Liste der Einträge nach unten scrollt.

Den Fall, dass der User Enter drückt, fängt Zeile 52 ab und ruft die ab Zeile 66 definierte Funktion showCur() auf, die den ursprünglichen (unmaskierten) Passwort-Eintrag aus der Liste lines holt und die aktuelle Zeile der Listbox damit ersetzt. Schwupp-die-Wupp, schon steht das Passwort enthüllt da. hideCur() ab Zeile 61 macht das Umgekehrte, und maskiert den aktuellen Eintrag mit Hilfe der Funktion mask().

Installation

Wie immer lässt sich das Binary aus dem Go-Code mit folgendem Dreisatz erzeugen:

    $ go mod init pv
    $ go mod tidy
    $ go build pv.go crypto.go util.go ui.go

Dies holt alle abhängigen Libraries von Github, übersetzt sie, und bindet alles zusammen, bis ein fertiges Binary pv entsteht. Dies kann anschließend auf Zielrechner mit ähnlicher Architektur kopiert werden. Es wird dort klaglos laufen und die UI praktischerweise auch auf Remote-Maschinen ins Terminal zaubern.

Die Passwortdatei test.age sollte für den Produktionsbetrieb noch auf eine Datei im Home-Verzeichnis des Users kopiert werden, dann ist der Passwort-Merker betriebsbereit.

Infos

[1]

Listings zu diesem Artikel: http://www.linux-magazin.de/static/listings/magazin/2022/12/snapshot/

[2]

"Age", Crypto-Bibliothek, https://github.com/FiloSottile/age

[3]

"What did Ken Thompson mean when he said, "I'd spell creat with an 'e'."?", https://unix.stackexchange.com/questions/10893/what-did-ken-thompson-mean-when-he-said-id-spell-creat-with-an-e

Michael Schilli

arbeitet als Software-Engineer in der San Francisco Bay Area in Kalifornien. In seiner seit 1997 laufenden Kolumne forscht er jeden Monat nach praktischen Anwendungen verschiedener Programmiersprachen. Unter mschilli@perlmeister.com beantwortet er gerne Ihre Fragen.

POD ERRORS

Hey! The above document had some coding errors, which are explained below:

Around line 5:

Unknown directive: =desc