Pinned Post

OpenSpec UI: ein Live-Dashboard für deine Specs

Bild
OpenSpec UI: ein Live-Dashboard für deine Specs Wer mit einem KI-Agenten und OpenSpec arbeitet, kennt das Bild. Im Projekt wächst ein Verzeichnis openspec/ heran: Changes mit proposal.md , design.md , tasks.md und ihren Delta-Specs, daneben die kanonischen Specs der einzelnen Capabilities. Die Absicht hinter dem Code steht damit endlich geschrieben, statt sich in ihm zu verstecken. Nur verteilt sie sich über Dutzende Markdown-Dateien, und der Editor zeigt eben Dateien. Was er nicht zeigt, ist der Zustand. Woran arbeitet der Agent gerade? Welcher Task ist der nächste offene? Und was hat sich verändert, während ich zehn Minuten woanders hingeschaut habe? Genau diese Lücke füllt OpenSpec UI , ein kleines Phoenix-LiveView-Dashboard, das lokal neben deinem Projekt läuft und den OpenSpec-Workspace im Browser zeigt — live, während gearbeitet wird. Der Code liegt offen auf GitLab: https://gitlab.com/public_elixir/openspec_ui Ein Blick statt Datei-Hopping Du startest den Server, gibst d...

Login ohne Passwort: LNURL-auth in einer Phoenix-LiveView

Bei Fiatbitcoin kann man sich mit einer Lightning-Wallet einloggen. Kein Passwort, keine E-Mail, kein OAuth-Redirect zu einem Drittanbieter. Man scannt einen QR-Code, bestätigt in der Wallet, und ist eingeloggt. Dahinter steht LNURL-auth, spezifiziert in LUD-04.

Das Verfahren selbst ist erstaunlich kompakt. Die spannende Frage war nicht "wie verifiziere ich eine Signatur", sondern "wie bringe ich das Ergebnis eines stateless HTTP-Callbacks zurück in genau den Browser-Tab, der gerade den QR-Code anzeigt". In einer LiveView-Welt ist das nicht selbstverständlich.

Wie LNURL-auth funktioniert

LNURL-auth ist ein Challenge-Response-Verfahren auf Basis von secp256k1, derselben Kurve, die auch Bitcoin nutzt. Der Ablauf laut LUD-04:

  1. Der Server erzeugt eine zufällige Challenge k1 (32 Bytes) und packt sie zusammen mit tag=login in eine URL. Die URL wird bech32-kodiert und als QR-Code dargestellt.
  2. Die Wallet leitet aus ihrem Master-Key und der Domain des Servers einen domänenspezifischen linkingKey ab. Pro Domain ein eigener Schlüssel, deterministisch, ohne Speicherung auf Wallet-Seite.
  3. Die Wallet signiert k1 mit dem privaten Teil des linkingKey und ruft den Server-Endpunkt mit key (Public Key) und sig (Signatur) auf.
  4. Der Server verifiziert die Signatur gegen key und k1. Stimmt sie, ist der key die dauerhafte Identität des Users.

Der entscheidende Punkt: Der Public Key ist die User-ID. Kein Passwort-Hash, kein geteiltes Geheimnis. Der User beweist Besitz eines privaten Schlüssels, ohne ihn jemals preiszugeben. Und weil der linkingKey pro Domain abgeleitet wird, kann man Nutzer nicht über Services hinweg tracken.

Die Verifikation ist in Elixir ein Einzeiler über :crypto:

defp verify_auth?(k1, key, sig) when is_binary(k1) and is_binary(key) and is_binary(sig) do
  :crypto.verify(:ecdsa, :sha256, {:digest, decode(k1)}, decode(sig), [decode(key), :secp256k1])
end

Ein Detail, das leicht zu übersehen ist: k1 sind 32 Bytes, also exakt die Größe eines SHA-256-Digests. Deshalb wird die Challenge nicht noch einmal gehasht, sondern direkt als Digest signiert und verifiziert. Daher {:digest, decode(k1)} statt der Roh-Nachricht.

Das eigentliche Problem: zwei getrennte Verbindungen

Hier wird es interessant. Beim Login sind zwei völlig unabhängige Verbindungen im Spiel:

  • Der Browser des Users hält eine LiveView-Session offen und zeigt den QR-Code an. Das ist ein langlebiger Elixir-Prozess.
  • Die Wallet des Users (auf dem Handy, im selben oder einem anderen Netz) macht einen einmaligen HTTP-GET gegen einen stateless Controller-Endpunkt.

Diese beiden wissen nichts voneinander. Wenn die Wallet die Signatur abliefert, landet sie im Controller. Aber der Browser, der auf die Anmeldung wartet, hängt an einem ganz anderen Prozess. Wie sagt man dem richtigen LiveView-Prozess Bescheid, dass sein User sich gerade authentifiziert hat?

Die naheliegende Lösung wäre Phoenix.PubSub mit einem Topic pro Session. Wir haben einen direkteren Weg gewählt: Wir schreiben die PID des LiveView-Prozesses direkt in die LNURL.

PID und Nonce in der LNURL

Beim Rendern des QR-Codes hängt die LiveView ihre eigene PID und eine Zufallszahl an die URL an:

# in LnurlLive
qrcode: LnurlAuth.login("#{host}/users/auth/log_in", self(), random)

LnurlAuth.login/3 baut daraus die vollständige Challenge-URL:

def generate_encoded_lnurl(host, k1, action, pid, random) do
  "#{host}?tag=login&k1=#{k1}&action=#{action}&r=#{random}&p=#{pid}"
  |> encode_bech32()
end

Neben den von LUD-04 vorgeschriebenen tag und k1 reisen also zwei zusätzliche Parameter mit: p (die kodierte PID) und r (eine Nonce). Die Wallet interessiert sich nicht dafür, sie signiert nur k1 und reicht alle Parameter unverändert an den Callback durch. Genau das nutzen wir aus.

Im Controller kommt der Callback an, die Signatur wird geprüft, und dann wird die PID aus dem Parameter zurückgewonnen und direkt angeschrieben:

case LnurlAuth.verify?(k1, key, sig) do
  true ->
    delegate_to_process(conn, key, LnurlAuth.decode_pid(pid), LnurlAuth.decode_random(random))
  false ->
    conn |> put_status(:bad_request) |> json(%{status: "Verification failed! Please, try again."})
end

delegate_to_process/4 prüft mit Process.alive?/1, ob der LiveView-Prozess noch lebt, und schickt ihm dann eine Nachricht:

send(pid, %{pid: pid, key: key, random: random})

Auf der anderen Seite wartet die LiveView in handle_info/2 darauf, mit zwei Guards als Schutz:

def handle_info(%{pid: pid, key: key, random: random}, socket)
    when is_pid(pid) and pid === self() do
  case socket.assigns.random == random do
    true ->
      token = get_or_create_user(key) |> Accounts.generate_user_session_token()
      {:noreply, redirect(socket, to: ~p"/users/log_in/#{token}")}
    false ->
      {:noreply, redirect(socket, to: ~p"/users/log_in/")}
  end
end

Der Pattern-Match pid === self() stellt sicher, dass ein Prozess nur auf eine an ihn selbst gerichtete Nachricht reagiert. Die Nonce random bindet den Callback zusätzlich an genau die Session, die den QR-Code erzeugt hat. Erst wenn beides passt, wird der User (über seinen linkingKey als Identität) angelegt oder geladen, ein Session-Token erzeugt und der Browser auf die Login-Route umgeleitet.

Das Schöne daran: Der HTTP-Callback der Wallet und die WebSocket-Session des Browsers werden ohne zentrale Registry oder PubSub-Topic wieder zusammengeführt. Die LNURL trägt die Rücksprungadresse selbst.

Der k1-Cache: server-ausgestellte Challenge erzwingen

LUD-04 verlangt, dass der Server nur k1-Werte akzeptiert, die er selbst ausgegeben hat, und sie nach Gebrauch wieder entfernt. Das passiert an zwei Stellen.

Beim Rendern des QR-Codes wird das frische k1 als ausstehend in der k1_cache-Tabelle gespeichert:

defp get_qr_code(host, action, pid, random) do
  k1 = generate_k1()

  K1CacheRepo.create_k1_cache_changeset(%{k1: k1})
  |> K1CacheRepo.save()

  generate_encoded_lnurl(host, k1, action, encode_pid(pid), encode_random(random))
  |> create_qrcode()
end

Beim Callback wird das k1 atomar konsumiert. Erst die Signaturprüfung, dann der Verbrauch, damit ein fehlerhafter Versuch kein ausstehendes k1 verbrennt:

def verify?(k1, key, sig) when is_binary(k1) and is_binary(key) and is_binary(sig) do
  verify_auth?(k1, key, sig) and K1CacheRepo.consume(k1)
end

def consume(k1) do
  {count, _} = from(k in K1Cache, where: k.k1 == ^k1) |> Repo.delete_all()
  count == 1
end

Der delete_all liefert die Anzahl gelöschter Zeilen. Das macht consume/1 race-sicher: Treffen zwei Callbacks für dasselbe k1 gleichzeitig ein, gewinnt genau einer (count == 1). Und es blockt Replay, denn ein konsumiertes k1 ist weg, ein zweiter Callback findet nichts mehr.

Die naheliegende Abkürzung wäre, nur bereits benutzte k1 zu sperren und alles andere zu akzeptieren. Das ist verlockend und falsch: Dann kann ein Angreifer ein beliebiges, selbst gewähltes k1 mit eigenem Schlüssel signieren und den Callback bestehen. Der Charakter einer server-ausgestellten Challenge geht verloren. Nur was der Server ausgegeben hat, darf durchkommen.

Konsumierte k1 verschwinden sofort. Abgebrochene Logins (QR gerendert, nie abgeschlossen) lassen ihr k1 zurück. Ein periodischer Oban-Job räumt alles weg, was älter als eine Stunde ist, damit die Tabelle klein bleibt.

Bech32, aber ohne die 90-Zeichen-Grenze

Eine kleine Stolperfalle: LNURLs werden bech32-kodiert, demselben Format wie Bitcoin-Adressen. Die naheliegende Bibliothek bip0173 setzt aber die in BIP-0173 vorgeschriebene Längengrenze von 90 Zeichen durch. Für native SegWit-Adressen ist das korrekt. Eine LNURL mit Host, k1, PID und Nonce sprengt diese Grenze locker.

Deshalb packen wir die 8-Bit-Bytes von Hand in 5-Bit-Gruppen und rufen die Listen-Variante des Encoders auf, die keine Längenprüfung macht:

def encode_bech32(lnurl) do
  Bech32.encode(@lnurl, to_5bit_groups(lnurl))
end

Für die Tests gibt es einen passenden decode_bech32/1, der dieselbe 5-Bit-Umrechnung rückwärts macht und die 6-stellige Prüfsumme abschneidet. Damit lässt sich im Test verifizieren, dass die kodierte LNURL die erwarteten Parameter enthält, ohne sich auf die längenbeschränkte Bibliotheksfunktion zu verlassen.

Als Detail fürs Auge betten wir noch ein Bitcoin-Logo mittig in das QR-SVG ein, bevor es als Base64-Data-URI an die LiveView geht. QR-Codes mit hoher Fehlerkorrektur (:high) vertragen eine kleine Überdeckung in der Mitte problemlos.

Lessons Learned

Drei Dinge, die ich anderen mitgeben würde, und eines, bei dem ich nachschärfen würde.

1. Die LNURL kann ihre eigene Rücksprungadresse tragen.

Die PID in die URL zu schreiben, klingt erst nach einem Hack, ist aber ein sauberer Weg, einen stateless HTTP-Callback und eine langlebige LiveView-Session ohne zusätzliche Infrastruktur zu koppeln. Kein PubSub-Topic, keine Registry, kein State im ETS. Der Match pid === self() und die Nonce halten das Ganze sicher.

2. k1 ist schon ein Digest.

Die 32 Bytes der Challenge direkt als SHA-256-Digest zu behandeln statt sie erneut zu hashen, ist der Punkt, an dem die meisten ersten Implementierungen scheitern. :crypto.verify mit {:digest, k1} ist die korrekte Form.

3. Vorsicht mit den Bibliotheks-Defaults.

Die 90-Zeichen-Grenze von bip0173 ist spec-konform für SegWit, aber falsch für LNURL. Solche stillen Annahmen in Dependencies kosten sonst eine Stunde Debugging.

4. Was ich nachschärfen würde: die aus dem Parameter dekodierte PID.

decode_pid/1 rekonstruiert über :erlang.list_to_pid/1 eine PID aus einem Wert, der aus der URL und damit aus einem von außen beeinflussbaren Parameter stammt. Geschützt ist das durch Process.alive?/1, den pid === self()-Match und die Nonce, sodass ein Angreifer keine fremde Session übernehmen oder Nachrichten an beliebige Prozesse senden kann, die diesen Match bestehen. Sauberer wäre trotzdem, die Browser-Session über ein Token in einer kurzlebigen Registry aufzulösen, statt eine PID über die Wallet zu schicken.

Wenn euch ein Detail interessiert (die 5-Bit-Umrechnung, das PID-Routing, der k1-Lebenszyklus), schreibt es in die Kommentare. Ich gehe gerne tiefer rein.

Kommentare

Beliebte Posts aus diesem Blog

Splitting an ML model and a web app across two BEAM nodes — the technical blueprint

Jido in Practice: Agents in Elixir as Composable Actions

A Foundation Model in the BEAM: On-Chain Anomalies with Google TimesFM in Elixir