Skip to content

Commit 77b3ac9

Browse files
committed
Extract secret from otpauth uri
1 parent 9a47f0e commit 77b3ac9

2 files changed

Lines changed: 136 additions & 1 deletion

File tree

lib/nimble_totp.ex

Lines changed: 53 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,7 @@ defmodule NimbleTOTP do
3030
3131
* Generate secrets composed of random bytes.
3232
* Generate URIs to be encoded in a QR Code.
33+
* Decode otpauth://totp/ URIs and extract the secret, label and issuer.
3334
* Generate Time-Based One-Time Passwords (TOTPs) based on a secret.
3435
3536
### Generating the secret
@@ -188,6 +189,52 @@ defmodule NimbleTOTP do
188189
otpauth_uri(issuer, account, secret, [])
189190
end
190191

192+
@doc """
193+
Extract the secret and label from an otpauth URI, as well as the extra properties.
194+
195+
The issuer will be extracted either from a label prefix or from an extra URI parameter.
196+
If both are present, they have to be equal or an error will be returned.
197+
198+
## Examples
199+
200+
iex> NimbleTOTP.decompose_otpauth_uri("otpauth://totp/Acme:alice?secret=MFRGGZA&issuer=Acme")
201+
{:ok, "abcd", "alice", %{"issuer" => "Acme"}}
202+
203+
iex> NimbleTOTP.decompose_otpauth_uri("otpauth://totp/Acme:alice?secret=INVALID!&issuer=Acme")
204+
:error
205+
206+
"""
207+
@spec decompose_otpauth_uri(String.t()) ::
208+
{:ok, <<>>, String.t(), map()} | {:error, :invalid_uri}
209+
def decompose_otpauth_uri(uri) when is_binary(uri) do
210+
with true <- uri =~ ~r"^otpauth://totp/",
211+
{:ok, uri} <- URI.new(uri),
212+
["", label] <- String.split(uri.path, "/"),
213+
# Reject empty prefix issuer or empty labels (with or without prefix issuer)
214+
false <- label =~ ~r/^(:|$|.*:$)/,
215+
query = URI.decode_query(uri.query, %{}, :rfc3986),
216+
{secret, query} <- Map.pop(query, "secret"),
217+
{:ok, secret} <- Base.decode32(secret, padding: false),
218+
param_issuer <- Map.get(query, "issuer"),
219+
false <- (param_issuer || "") =~ ":" do
220+
case String.split(label, ":") do
221+
[label] when param_issuer != "" ->
222+
{:ok, secret, label, query}
223+
224+
[^param_issuer, label] ->
225+
{:ok, secret, label, query}
226+
227+
[issuer, label] when is_nil(param_issuer) ->
228+
{:ok, secret, label, Map.put(query, "issuer", issuer)}
229+
230+
_ ->
231+
:error
232+
end
233+
else
234+
_ -> :error
235+
end
236+
end
237+
191238
@doc """
192239
Generate a binary composed of random bytes.
193240
@@ -217,7 +264,12 @@ defmodule NimbleTOTP do
217264
218265
## Examples
219266
220-
secret = Base.decode32!("PTEPUGZ7DUWTBGMW4WLKB6U63MGKKMCA")
267+
secret = Base.decode32!("PTEPUGZ7DUWTBGMW4WLKB6U63MGKKMCA", padding: false)
268+
NimbleTOTP.verification_code(secret)
269+
#=> "569777"
270+
271+
uri = "otpauth://Acme:alice?secret=PTEPUGZ7DUWTBGMW4WLKB6U63MGKKMCA"
272+
{:ok, secret, _label, _uri_params} = decompose_otpauth_uri(uri)
221273
NimbleTOTP.verification_code(secret)
222274
#=> "569777"
223275

test/nimble_totp_test.exs

Lines changed: 83 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -171,6 +171,89 @@ defmodule NimbleTOTPTest do
171171
end
172172
end
173173

174+
describe "decompose_otpauth_uri" do
175+
test "supports the absence of issuer" do
176+
uri = "otpauth://totp/alice?secret=MFRGGZA"
177+
assert {:ok, "abcd", "alice", %{}} == NimbleTOTP.decompose_otpauth_uri(uri)
178+
end
179+
180+
test "extracts the issuer from the prefix" do
181+
uri = "otpauth://totp/Acme:alice?secret=MFRGGZA"
182+
183+
assert {:ok, "abcd", "alice", %{"issuer" => "Acme"}} ==
184+
NimbleTOTP.decompose_otpauth_uri(uri)
185+
end
186+
187+
test "extracts the issuer from the URI params" do
188+
uri = "otpauth://totp/alice?secret=MFRGGZA&issuer=Acme"
189+
190+
assert {:ok, "abcd", "alice", %{"issuer" => "Acme"}} ==
191+
NimbleTOTP.decompose_otpauth_uri(uri)
192+
end
193+
194+
test "accepts identical issuers in prefix and URI params" do
195+
uri = "otpauth://totp/Acme:alice?secret=MFRGGZA&issuer=Acme"
196+
197+
assert {:ok, "abcd", "alice", %{"issuer" => "Acme"}} ==
198+
NimbleTOTP.decompose_otpauth_uri(uri)
199+
end
200+
201+
test "preserves extra URI params" do
202+
uri = "otpauth://totp/Acme:alice?secret=MFRGGZA&issuer=Acme&extra=1"
203+
204+
{:ok, "abcd", "alice", params} = NimbleTOTP.decompose_otpauth_uri(uri)
205+
assert [{"extra", "1"}, {"issuer", "Acme"}] == Enum.sort(params)
206+
end
207+
208+
test "rejects an empty label with no prefix issuer" do
209+
uri = "otpauth://totp/?secret=MFRGGZA"
210+
211+
assert :error == NimbleTOTP.decompose_otpauth_uri(uri)
212+
end
213+
214+
test "rejects an empty label with a prefix issuer" do
215+
uri = "otpauth://totp/Acme:?secret=MFRGGZA"
216+
217+
assert :error == NimbleTOTP.decompose_otpauth_uri(uri)
218+
end
219+
220+
test "rejects an empty prefix issuer" do
221+
uri = "otpauth://totp/:alice?secret=MFRGGZA"
222+
223+
assert :error == NimbleTOTP.decompose_otpauth_uri(uri)
224+
end
225+
226+
test "rejects an empty issuer from URI params" do
227+
uri = "otpauth://totp/alice?secret=MFRGGZA&issuer="
228+
229+
assert :error == NimbleTOTP.decompose_otpauth_uri(uri)
230+
end
231+
232+
test "rejects different issuers in prefix and URI params" do
233+
uri = "otpauth://totp/Acme:alice?secret=MFRGGZA&issuer=Corp"
234+
235+
assert :error == NimbleTOTP.decompose_otpauth_uri(uri)
236+
end
237+
238+
test "rejects URI with wrong scheme or host" do
239+
uri = "otpauth://hotp/Acme:alice?secret=MFRGGZA&issuer=Corp"
240+
241+
assert :error == NimbleTOTP.decompose_otpauth_uri(uri)
242+
end
243+
244+
test "rejects URI if issuer contains ':'" do
245+
uri = "otpauth://hotp/alice?secret=MFRGGZA&issuer=Acme:Corp"
246+
247+
assert :error == NimbleTOTP.decompose_otpauth_uri(uri)
248+
end
249+
250+
test "rejects URI if label contains ':'" do
251+
uri = "otpauth://hotp/Acme:Corp:alice?secret=MFRGGZA"
252+
253+
assert :error == NimbleTOTP.decompose_otpauth_uri(uri)
254+
end
255+
end
256+
174257
defp to_unix(naive_datetime),
175258
do: naive_datetime |> DateTime.from_naive!("Etc/UTC") |> DateTime.to_unix()
176259
end

0 commit comments

Comments
 (0)