Who picks the tool
-->
Direkt zum Hauptbereich
At Fiatbitcoin you can log in with a Lightning wallet. No password, no email, no OAuth redirect to a third party. You scan a QR code, confirm in the wallet, and you are logged in. Behind it sits LNURL-auth, specified in LUD-04.
The scheme itself is surprisingly compact. The interesting question was never "how do I verify a signature", but "how do I get the result of a stateless HTTP callback back into exactly the browser tab that is showing the QR code". In a LiveView world that is not a given.
LNURL-auth is a challenge-response scheme built on secp256k1, the same curve Bitcoin uses. The flow per LUD-04:
k1 (32 bytes) and packs it together with tag=login into a URL. The URL is bech32-encoded and rendered as a QR code.linkingKey from its master key and the server's domain. One key per domain, deterministic, with nothing stored on the wallet side.k1 with the private part of the linkingKey and calls the server endpoint with key (the public key) and sig (the signature).key and k1. If it checks out, the key is the user's permanent identity.The crucial point: the public key is the user ID. No password hash, no shared secret. The user proves possession of a private key without ever revealing it. And because the linkingKey is derived per domain, users cannot be tracked across services.
In Elixir, verification is a one-liner via :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
One detail that is easy to miss: k1 is 32 bytes, exactly the size of a SHA-256 digest. So the challenge is not hashed again, it is signed and verified directly as a digest. Hence {:digest, decode(k1)} rather than the raw message.
This is where it gets interesting. During login there are two completely independent connections in play:
The two know nothing about each other. When the wallet delivers the signature, it lands in the controller. But the browser waiting to be logged in hangs off an entirely different process. How do you tell the right LiveView process that its user just authenticated?
The obvious solution would be Phoenix.PubSub with one topic per session. We chose a more direct route: we write the LiveView process's PID straight into the LNURL.
When rendering the QR code, the LiveView appends its own PID and a random number to the URL:
# in LnurlLive
qrcode: LnurlAuth.login("#{host}/users/auth/log_in", self(), random)
LnurlAuth.login/3 builds the full challenge URL from it:
def generate_encoded_lnurl(host, k1, action, pid, random) do
"#{host}?tag=login&k1=#{k1}&action=#{action}&r=#{random}&p=#{pid}"
|> encode_bech32()
end
So alongside the tag and k1 mandated by LUD-04, two extra parameters travel with the URL: p (the encoded PID) and r (a nonce). The wallet does not care about them, it only signs k1 and passes all parameters through to the callback unchanged. That is exactly what we exploit.
The callback arrives at the controller, the signature is checked, and then the PID is recovered from the parameter and messaged directly:
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 checks with Process.alive?/1 whether the LiveView process is still alive, then sends it a message:
send(pid, %{pid: pid, key: key, random: random})
On the other side the LiveView waits in handle_info/2, with two guards for protection:
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
The pattern match pid === self() makes sure a process only reacts to a message addressed to itself. The nonce random additionally binds the callback to exactly the session that produced the QR code. Only when both match is the user (identified by the linkingKey) created or loaded, a session token generated, and the browser redirected to the login route.
The nice part: the wallet's HTTP callback and the browser's WebSocket session are reunited without a central registry or PubSub topic. The LNURL carries its own return address.
LUD-04 requires the server to accept only k1 values it issued itself, and to remove them after use. That happens in two places.
When the QR code is rendered, the fresh k1 is stored as pending in the k1_cache table:
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
On the callback the k1 is consumed atomically. Signature check first, consume second, so a failed attempt does not burn a pending k1:
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
delete_all returns the number of rows deleted, which makes consume/1 race-safe: if two callbacks for the same k1 arrive at once, exactly one wins (count == 1). It also blocks replay, since a consumed k1 is gone and a second callback finds nothing.
The tempting shortcut is to only blacklist already-used k1 and accept everything else. It is tempting and wrong: an attacker can then sign an arbitrary, self-chosen k1 with their own key and pass the callback, and the server-issued-challenge property is lost. Only what the server issued may get through.
Consumed k1 disappear right away. Abandoned logins (QR rendered, never completed) leave their k1 behind. A periodic Oban job sweeps everything older than an hour, so the table stays small.
A small trap: LNURLs are bech32-encoded, the same format as Bitcoin addresses. The obvious library, bip0173, however enforces the 90-character length limit mandated by BIP-0173. For native SegWit addresses that is correct. An LNURL with host, k1, PID and nonce blows past that limit easily.
So we pack the 8-bit bytes into 5-bit groups by hand and call the list variant of the encoder, which does no length check:
def encode_bech32(lnurl) do
Bech32.encode(@lnurl, to_5bit_groups(lnurl))
end
For the tests there is a matching decode_bech32/1 that runs the same 5-bit conversion in reverse and strips the 6-character checksum. That lets a test verify that the encoded LNURL contains the expected parameters without relying on the length-limited library function.
As a touch for the eye, we embed a Bitcoin logo in the center of the QR SVG before it goes to the LiveView as a Base64 data URI. QR codes with high error correction (:high) tolerate a small overlay in the middle without trouble.
Three things I would pass on, and one I would sharpen up.
1. The LNURL can carry its own return address.
Writing the PID into the URL sounds like a hack at first, but it is a clean way to couple a stateless HTTP callback and a long-lived LiveView session without extra infrastructure. No PubSub topic, no registry, no ETS state. The pid === self() match and the nonce keep it safe.
2. k1 is already a digest.
Treating the 32 challenge bytes directly as a SHA-256 digest instead of hashing them again is the spot where most first implementations fail. :crypto.verify with {:digest, k1} is the correct form.
3. Watch out for library defaults.
The 90-character limit in bip0173 is spec-compliant for SegWit but wrong for LNURL. Silent assumptions like that in dependencies otherwise cost an hour of debugging.
4. What I would sharpen up: the PID decoded from a parameter.
decode_pid/1 reconstructs a PID via :erlang.list_to_pid/1 from a value that comes out of the URL, and therefore out of an externally influenced parameter. It is guarded by Process.alive?/1, the pid === self() match and the nonce, so an attacker cannot take over a foreign session or send messages to arbitrary processes that pass that match. Cleaner still would be to resolve the browser session through a token in a short-lived registry, rather than shipping a PID via the wallet.
If a detail interests you (the 5-bit conversion, the PID routing, the k1 lifecycle), drop it in the comments. I am happy to go deeper.
Kommentare