Aus Linux-Magazin 12/2022

Passwortmanager in Go

© Andriy Popov / 123RF.com

Eine Go-Applikation fürs Terminal hilft Mike Schilli, sich seine Passwörter zu merken.

Ob auf Notizzetteln unter dem Bildschirm oder in einer kommerziellen Applikation wie OnePass, irgendwo müssen sich Benutzer ihre Passwörter aufschreiben. Die in dieser Ausgabe vorgestellte Go-Applikation für das Terminal legt die sensiblen Daten verschlüsselt auf der Festplatte ab und zeigt nach Eingabe des Master-Passworts ausgewählte Einträge an. Die geheimen Daten hinterlassen nur im Speicher des Rechners Spuren, die sich jedoch nach dem Schließen des Programms automatisch verflüchtigen.

Findige User könnten nun einfach alle Account-Namen und Passwörter in einer Textdatei ablegen und diese verschlüsseln. Um aber neue Einträge hinzuzufügen, müsste die Datei entschlüsselt und nach dem Editieren wieder verschlüsselt werden. Damit auf der Platte keine Klardaten verbleiben, müsste ein Schrubbbefehl hinterher die gelöschte Datei 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 verwaltet Passwörter vorbildlich, aber wer vertraut schon vertrauliche Daten wildfremden Firmen 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 unerheblichen monatlichen Gebühren zu Buche, und ein Mann wie ich muss mit dem Kreuzer rechnen. Das im Folgenden vorgestellte Programm Password View (»pv«) verwaltet eine verschlüsselte Kollektion von Passwörtern und zeigt nach Eingabe des Master-Passwords jeweils einen ausgewählten Eintrag in einer Terminal-UI an (Abbildung 1). Sie können 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.

Abbildung 1: Der Passwort-Speicher Pv in Aktion.

Auf das Drücken der Eingabetaste hin lässt Pv für den ausgewählten Eintrag die Sternchen verschwinden und enthüllt die geheimen Account- und Passwortdaten. Fahren Sie mit den Cursor-Tasten [K]+ und [J]+ (wie bei Vi(m)) in der Liste herauf oder herunter, maskiert Pv den freigegebenen Eintrag wieder mit Sternchen. Durch einen Druck auf [Q] falten Sie das Programm zusammen, das dabei das Terminalfenster blankputzt. 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. Sie 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 Pv erst nach dem Master-Passwort. Geben Sie es korrekt ein, dürfen Sie am Prompt »New Entry:« eine neue Zeile an die verschlüsselte Datei anfügen (Listing 1).

Listing 1

Neues Passwort

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

Dabei ist das erste Wort der zum Passwort gehörige Dienst (im Beispiel »gmail«), dessen Name auch im maskierten Zustand einer Zeile auf der UI erscheint (wie in der ersten Zeile von Abbildung 1). Der Name dient als Navigationshilfe, um den gesuchten Eintrag zu finden, auszuwählen und anzuzeigen. Der Rest der neu eingefügten Zeile beinhaltet den Benutzernamen und das Passwort. Deren Format können Sie frei wählen und zum Beispiel statt der vollständigen Daten nur Merkhilfen speichern.

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

Crypto-Tausendsassa

Der Passwort-Safe nutzt eine symmetrische Verschlüsselung. Er verwendet also dasselbe Passwort, um die Datei mit den geheimen Passwörtern sowohl zu ver- als auch zu entschlüsseln. Das Projekt Age [1] auf Github bietet eine von einem Google-Ingenieur geschriebene fertige Go-Library zum Ver- und Entschlüsseln von Daten. Sie arbeitet hauptsächlich nach Public-Key-Verfahren, aber auch symmetrische Verschlüsselung steht auf dem Programm. Laut Projektseite spricht man Age übrigens wie das italienische Wort “aghe” (Nadeln) aus.

Abbildung 2: Das Verschlüsselungsprojekt Age.

Abbildung 2: Das Verschlüsselungsprojekt Age.

Symmetrisch verschlüsselt

Für eine Datei, auf die immer nur ein User zugreift, ist eine symmetrische Verschlüsselung die praktischste Lösung. Falls sich mehrere Personen den Zugriff teilen, lässt sich mit Public-Private-Schlüssel-Paaren (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 2 zeigt die später vom Hauptprogramm genutzten Funktionen »writeEnc()« und »readEnc()« zum Ver- und Entschlüsseln der Klartextdaten. Zeile 9 definiert mit »test.age« den Namen der verschlüsselten Passwortdatei auf der Festplatte.

Die Age-Library verwendet zum Schreiben (also zum Verschlüsseln) ein Objekt vom Typ »Recipient«, also einen Empfänger, der verschlüsselte Daten zugeschickt bekommt. Der Aufruf der Funktion »NewScryptRecipient()« in Zeile 11 nimmt als einzigen Parameter das Passwort entgegen, und »Scrypt« deutet auf die symmetrische Crypt-Funktion hin, die Age implementiert. Zeile 15 öffnet die Passwortdatei zum Schreiben und legt sie per »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 sagte prompt: “I’d spell ‘creat’ with an ‘e’.” [2]. Go hat ihm offensichtlich diesen Wunsch erfüllt.

Listing 2

crypto.go

package main
import (
  "bytes"
  "filippo.io/age"
  "filippo.io/age/armor"
  "io"
  "os"
)
const secFile string = "test.age"
func writeEnc(txt string, pass string) error {
  recipient, err := age.NewScryptRecipient(pass)
  if err != nil {
    return err
  }
  out, err := os.OpenFile(secFile, os.O_RDWR|os.O_CREATE|os.O_TRUNC, 0600)
  if err != nil {
    return err
  }
  defer out.Close()
  armorWriter := armor.NewWriter(out)
  defer armorWriter.Close()
  w, err := age.Encrypt(armorWriter, recipient)
  if err != nil {
    return err
  }
  defer w.Close()
  if _, err := io.WriteString(w, txt); err != nil {
    return err
  }
  return nil
}
func readEnc(pass string) (string, error) {
  identity, err := age.NewScryptIdentity(pass)
  if err != nil {
    return "", err
  }
  out := &bytes.Buffer{}
  in, err := os.Open(secFile)
  if err != nil {
    return "", err
  }
  defer in.Close()
  armorReader := armor.NewReader(in)
  r, err := age.Decrypt(armorReader, identity)
  if err != nil {
    return "", err
  }
  if _, err := io.Copy(out, r); err != nil {
    return "", err
  }
  return out.String(), nil
}

Panzern zum Transfer

Die Option »O_TRUNC« staucht eine bereits existierende Passwortdatei auf null zusammen, sodass nachfolgende Print-Befehle sie einfach überschreiben. Nun könnte der Datensalat in der verschlüsselten Datei einen Editor verwirren, und möglicherweise wäre ein Transferprogramm bei der Übertragung des Inhalts übers Netz versucht, binäre Sequenzen umzustrukturieren. Deshalb setzt Zeile 20 einen Writer vom Typ »armor« auf, der quasi eine Panzerung (engl. “armor”) um die Binärdaten legt. Sie erscheinen dann zwar immer noch verschlüsselt im Editor, aber wenigstens als gleich lange Zeilen ohne Escape-Sequenzen (Abbildung 3).

Abbildung 3: Die verschlüsselte Passwortdatei auf der Festplatte.

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 15 eine Datei und gibt einen Writer namens »out« zurück. Der Panzerungsmechanismus aus dem Paket armor nimmt das Writer-Objekt entgegen und liefert ein eigenes Writer-Objekt »armorWriter« zurück. Das wiederum nimmt die Funktion »Encrypt()« in Zeile 22 entgegen und gibt einen weiteren Writer »w« zurück, in den dann Zeile 27 mit »io.WriteString()« hineinzuschreiben beginnt.

Der Code implementiert also eine Verknüpfung von geschachtelten Funktionen, die an eine Unix-Pipe erinnert. Am Anfang schreibt man hinein, 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 Typ 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 am Ziel ruckzuck abgehackter und damit unlesbarer Datensalat ein.

Verschlüsselt lesen

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

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

Die Einträge in der Listbox des Passwort-Viewers sollen später nicht alle sofort bei Erscheinen der UI hochkommen. Vielmehr soll man nur das erste Wort jeder Zeile lesen können, während den Rest Sternchen zieren. Dazu nimmt die Utility-Funktion »mask()« in Listing 3 einen String entgegen, iteriert über dessen Zeichen und ersetzt sie durch einen Asterisk, sofern das Flag »tomask« gesetzt ist. Anfangs ist das nicht der Fall, bis Zeile 11 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 begräbt den Rest der Zeichenkette unter Sternen.

Die Hauptfunktion »main()« in Listing 4 fragt mit dem Paket flag das optionale Kommandozeilenargument »–add« ab. Falls es gesetzt ist, 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.

Listing 3

util.go

package main
func mask(s string) string {
  masked := []byte(s)
  tomask := false
  for i := 0; i < len(s); i++ {
    if tomask {
      masked[i] = '*'
    } else {
      masked[i] = s[i]
    }
    if s[i] == ' ' {
      tomask = true
    }
  }
  return string(masked)
}

Listing 4

pv.go

package main
import (
  "bufio"
  "errors"
  "flag"
  "fmt"
  "golang.org/x/crypto/ssh/terminal"
  "os"
  "strings"
)
func main() {
  add := flag.Bool("add", false, "Add new password entry")
  flag.Parse()
  fmt.Printf("Password: ")
  password, err := terminal.ReadPassword(int(os.Stdin.Fd()))
  if err != nil {
    panic(err)
  }
  txt, err := readEnc(string(password))
  if err != nil {
    if !errors.Is(err, os.ErrNotExist) {
      panic(err)
    }
  }
  if *add {
    fmt.Printf("\rNew entry: ")
    reader := bufio.NewReader(os.Stdin)
    entry, _ := reader.ReadString('\n')
    txt = txt + entry
    writeEnc(txt, string(password))
    return
  }
  lines := strings.Split(strings.TrimSuffix(txt, "\n"), "\n")
  runUI(lines)
}

Keine Datei, kein Problem

Hierzu hat vorher Zeile 14 mit dem Prompt »Password:« zur Eingabe des Master-Passworts aufgefordert, das Zeile 15 mithilfe des Standardpakets terminal und dessen Funktion »ReadPassword()« einliest. Letztere stellt die Standardausgabe auf stumm, also kann der Benutzer 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 19 fehl und »panic()« in Zeile 22 bricht das Programm ab. Schlägt »readEnc()« allerdings fehl, weil die Passwortdatei noch nicht existiert, bekommt Zeile 21 das 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 33 dann mit »TrimSuffix()« das letzte Newline-Zeichen und spaltet die Zeilen mit »Split()« (beide aus dem Standard-Paket strings) in ein Array von Strings auf. Das übergibt es in Zeile 34 an die Funktion »runUI()«, damit diese die Benutzerschnittstelle startet. Sie läuft, bis der Anwender sie abbricht und damit das Hauptprogramm endet.

Zwei Widgets

Die Terminal-UI nutzt, wie schon in vorherigen Snapshot-Kolumnen, das Paket termui von Github. Listing 5 initialisiert dessen Funktionen in Zeile 12 mit »ui.Init()« und quittiert in der »defer«-Anweisung in Zeile 15 mittels »ui.Close()« einen Abbruch seitens des Users. Das faltet die UI sauber zusammen, damit ein brauchbares Terminal für die Shell zurückbleibt.

Listing 5

ui.go

package main
import (
  "fmt"
  ui "github.com/gizak/termui/v3"
  "github.com/gizak/termui/v3/widgets"
)
func runUI(lines []string) {
  rows := []string{}
  for _, line := range lines {
    rows = append(rows, mask(line))
  }
  if err := ui.Init(); err != nil {
    panic(err)
  }
  defer ui.Close()
  lb := widgets.NewList()
  lb.Rows = rows
  lb.SelectedRow = 0
  lb.SelectedRowStyle = ui.NewStyle(ui.ColorBlack)
  lb.TextStyle.Fg = ui.ColorGreen
  lb.Title = fmt.Sprintf("passview 1.0")
  pa := widgets.NewParagraph()
  pa.Text = "[Q]uit [Enter]reveal"
  pa.TextStyle.Fg = ui.ColorBlack
  w, h := ui.TerminalDimensions()
  lb.SetRect(0, 0, w, h-3)
  pa.SetRect(0, h-3, w, h)
  ui.Render(lb, pa)
  uiEvents := ui.PollEvents()
  for {
    select {
    case e := <-uiEvents:
      switch e.ID {
        case "k":
          hideCur(lb)
          lb.ScrollUp()
          ui.Render(lb)
        case "j":
          hideCur(lb)
          lb.ScrollDown()
          ui.Render(lb)
        case "q", "<C-c>":
          return
        case "<Enter>":
          showCur(lb, lines)
          ui.Render(lb)
      }
    }
  }
}
func hideCur(lb *widgets.List) {
  idx := lb.SelectedRow
  lb.Rows[idx] = mask(lb.Rows[idx])
}
func showCur(lb *widgets.List, lines []string) {
  idx := lb.SelectedRow
  lb.Rows[idx] = lines[idx]
}

Die Benutzerschnittstelle aus Abbildung 1 besteht aus zwei gestapelten Widgets: Oben liegt eine Listbox mit den Passwort-Einträgen, durch die der Benutzer scrollen kann. Sie beherrscht auch ein Paging über mehrere Seiten, falls die Anzahl der Einträge über eine Seite hinauswächst. Am unteren Rand des Terminalfensters klebt ein Paragraph-Widget, das angibt, welche Tasten der User als Nächstes drücken kann: [Eingabe]+ enthüllt das ausgewählte Passwort, [Q] beendet das Programm.

Damit die UI die gesamte Geometrie des Terminalfensters ausnutzen kann, fragt Zeile 25 dessen Dimensionen mit der Hilfsfunktion »TerminalsDimensions()« des termui-Pakets ab. Aus der Breite und Höhe des Fensters bestimmen die Zeilen 26 und 27 dann die Lage und Dimensionen der beiden übereinander liegenden Widgets. Dabei erhält das Paragraph-Widget die untersten drei Zeilen, die oben liegende Listbox den Rest. Horizontal breiten sich beide Widgets jeweils bis an die Ränder des Terminalfensters aus.

Die Einträge der Listbox sitzen als Array-Slice von Strings im Attribut »Rows« der Listbox. Zeile 17 besetzt es mit dem Array-Slice »rows«. Darin hat vorher die For-Schleife ab Zeile 9 die unmaskierten Originaleinträge aus »lines« (der Inhalt der Passwortdatei im Klartext) abgelegt, 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: Der Code muss nur auf derselben Indexnummer in den Original-Slice schauen, um die Sternchen wieder zu entfernen.

Nachdem Zeile 28 die Widgets auf den Schirm gebracht hat, feuert Zeile 29 mit »PollEvents()« eine Goroutine ab, die künftig nebenläufig alle Tastendrücke des Users abfängt und in den Channel »uiEvents« schickt. Von dort holt sie die »select«-Anweisung in der endlosen For-Schleife ab Zeile 30 ab und reagiert jeweils sofort auf alle ankommenden Ereignisse. Drückt der Benutzer [K]+, um nach oben zu scrollen, verhüllt Zeile 35 mit »hideCur()« (ab Zeile 51) und der Funktion »mask()« ein vorher im aktuellen Listbox-Eintrag eventuell schon enthülltes Passwort. Anschließend erteilt »ScrollUp()« der Listbox den Befehl, einen Eintrag nach oben zu scrollen, und das anschließende »Render«-Kommando zeigt die Veränderung flüssig in der UI an. Analoges gilt für einen Druck auf [J], mit dem der User in der Liste der Einträge nach unten scrollt.

Einen Druck auf die Eingabetaste fängt Zeile 44 ab und ruft die ab Zeile 66 definierte Funktion »showCur()« auf. Sie holt den ursprünglichen, unmaskierten Passworteintrag aus der Liste »lines« und ersetzt die aktuelle Zeile der Listbox damit. Schwuppdiwupp, schon steht das Passwort enthüllt da. »hideCur()« ab Zeile 61 erledigt das Umgekehrte und maskiert den aktuellen Eintrag mithilfe der Funktion »mask()«.

Installation

Wie immer lässt sich das Binary mit dem typischen Dreisatz (Listing 6) aus dem Go-Code erzeugen. Er holt alle abhängigen Libraries von Github, übersetzt sie und bindet alles zusammen, sodass das fertige Binary »pv« entsteht. Das können Sie anschließend auf jeden Zielrechner mit ähnlicher Architektur kopieren. Es läuft dort klaglos und zaubert die UI praktischerweise auch auf Remote-Maschinen ins Terminal. Die Passwortdatei »test.age« sollten Sie für den Produktionsbetrieb noch auf eine Datei in Ihrem Home-Verzeichnis kopieren, dann ist der Passwort-Merker betriebsbereit. (uba/jlu)

Listing 6

Programm kompilieren

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

Der Autor

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 mailto:mschilli@perlmeister.com beantwortet er gern Ihre Fragen.

Infos

  1. Age: https://github.com/FiloSottile/age
  2. “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
DIESEN ARTIKEL ALS PDF KAUFEN
EXPRESS-KAUF ALS PDFUmfang: 6 HeftseitenPreis €0,99
(inkl. 19% MwSt.)
LINUX-MAGAZIN KAUFEN
EINZELNE AUSGABE Print-Ausgaben Digitale Ausgaben
ABONNEMENTS Print-Abos Digitales Abo
TABLET & SMARTPHONE APPS Readly Logo
E-Mail Benachrichtigung
Benachrichtige mich zu:
0 Kommentare
Älteste
Neuste Beste Bewertung
Inline Feedbacks
Alle Kommentare anzeigen
Nach oben