Skip to content

Commit bb7a12b

Browse files
authored
Merge pull request #241 from borag/secret-safe
Harden secret handling in connection settings
2 parents c249f3d + 28ea027 commit bb7a12b

6 files changed

Lines changed: 210 additions & 7 deletions

File tree

‎lib/gnat.ex‎

Lines changed: 28 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -51,6 +51,20 @@ defmodule Gnat do
5151
* `inbox_prefix` - Prefix to use for the message inbox of this connection
5252
* `no_responders` - Enable the no responders behavior (see `Gnat.request/4`)
5353
* `name` - The client name reported by NATS monitoring endpoints
54+
* `nkey_seed` - The nkey seed used to sign the server's nonce: a zero-arity function that
55+
returns the nkey seed as a binary. A binary is also accepted for backward compatibility, but
56+
is not recommended, because the seed can then appear in supervisor crash reports.
57+
* `username` - The user name for user/password authentication
58+
* `password` - The password for user/password authentication: a zero-arity function that
59+
returns the password as a binary. A binary is also accepted for backward compatibility, but
60+
is not recommended, because the password can then appear in supervisor crash reports.
61+
* `token` - The token for token authentication: a zero-arity function that returns the token
62+
as a binary. A binary is also accepted for backward compatibility, but is not recommended,
63+
because the token can then appear in supervisor crash reports.
64+
65+
A function is called while the CONNECT message is built, during each connection handshake.
66+
The `nkey_seed`, `password` and `token` settings are redacted from the connection's process
67+
status, its crash reports and the connection supervisor's log.
5468
"""
5569
@type connection_settings :: %{
5670
optional(:connection_timeout) => non_neg_integer(),
@@ -62,7 +76,11 @@ defmodule Gnat do
6276
optional(:tcp_opts) => list(),
6377
optional(:tls) => boolean(),
6478
optional(:no_responders) => boolean(),
65-
optional(:name) => binary()
79+
optional(:name) => binary(),
80+
optional(:nkey_seed) => binary() | (-> binary()),
81+
optional(:username) => binary(),
82+
optional(:password) => binary() | (-> binary()),
83+
optional(:token) => binary() | (-> binary())
6684
}
6785

6886
@typedoc """
@@ -468,6 +486,15 @@ defmodule Gnat do
468486
GenServer.call(name, :server_info)
469487
end
470488

489+
# GenServer declares format_status/1 from Elixir 1.17; OTP calls it on every supported version.
490+
if Version.match?(System.version(), ">= 1.17.0"), do: @impl(GenServer)
491+
492+
def format_status(%{state: %{connection_settings: settings} = state} = status) do
493+
%{status | state: %{state | connection_settings: Gnat.Handshake.redact_settings(settings)}}
494+
end
495+
496+
def format_status(status), do: status
497+
471498
@impl GenServer
472499
def init(connection_settings) do
473500
connection_settings = Map.merge(@default_connection_settings, connection_settings)

‎lib/gnat/connection_supervisor.ex‎

Lines changed: 13 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,7 @@ defmodule Gnat.ConnectionSupervisor do
1919
}
2020
```
2121
22-
The connection settings can specify all of the same values that you pass to `Gnat.start_link/1`. Each time a connection is attempted we will use one of the provided connection settings to open the connection. This is a simplistic way of load balancing your connections across a cluster of nats nodes and allowing failover to other nodes in the cluster if one goes down.
22+
The connection settings can specify all of the same values that you pass to `Gnat.start_link/1`. Their secrets are redacted from this process's status and crash reports. Each time a connection is attempted we will use one of the provided connection settings to open the connection. This is a simplistic way of load balancing your connections across a cluster of nats nodes and allowing failover to other nodes in the cluster if one goes down.
2323
2424
To use this in your supervision tree add an entry like this:
2525
@@ -58,7 +58,7 @@ defmodule Gnat.ConnectionSupervisor do
5858
@impl GenServer
5959
def handle_info(:attempt_connection, state) do
6060
connection_config = random_connection_config(state)
61-
Logger.debug("connecting to #{inspect(connection_config)}")
61+
Logger.debug("connecting to #{inspect(Gnat.Handshake.redact_settings(connection_config))}")
6262

6363
case Gnat.start_link(connection_config, name: state.name) do
6464
{:ok, gnat} ->
@@ -89,6 +89,17 @@ defmodule Gnat.ConnectionSupervisor do
8989
{:noreply, state}
9090
end
9191

92+
# GenServer declares format_status/1 from Elixir 1.17; OTP calls it on every supported version.
93+
if Version.match?(System.version(), ">= 1.17.0"), do: @impl(GenServer)
94+
95+
def format_status(%{state: %{connection_settings: settings} = state} = status)
96+
when is_list(settings) do
97+
redacted = Enum.map(settings, &Gnat.Handshake.redact_settings/1)
98+
%{status | state: %{state | connection_settings: redacted}}
99+
end
100+
101+
def format_status(status), do: status
102+
92103
defp random_connection_config(%{connection_settings: connection_settings}) do
93104
connection_settings |> Enum.random()
94105
end

‎lib/gnat/handshake.ex‎

Lines changed: 21 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,20 @@ defmodule Gnat.Handshake do
1515
end
1616
end
1717

18+
@secret_settings [:nkey_seed, :password, :token]
19+
20+
@doc """
21+
Replaces the secrets in connection settings so they can be shown in process status and
22+
crash reports.
23+
"""
24+
def redact_settings(settings) when is_map(settings) do
25+
Enum.reduce(@secret_settings, settings, fn key, acc ->
26+
if Map.has_key?(acc, key), do: Map.put(acc, key, :redacted), else: acc
27+
end)
28+
end
29+
30+
def redact_settings(settings), do: settings
31+
1832
def negotiate_settings(server_settings, user_settings) do
1933
auth_required = server_settings[:auth_required] || user_settings[:auth_required] || false
2034

@@ -53,11 +67,11 @@ defmodule Gnat.Handshake do
5367
%{username: username, password: password} = _user,
5468
true = _auth_required
5569
) do
56-
Map.merge(settings, %{user: username, pass: password})
70+
Map.merge(settings, %{user: username, pass: resolve_secret(password)})
5771
end
5872

5973
defp negotiate_auth(settings, _server, %{token: token} = _user, true = _auth_required) do
60-
Map.merge(settings, %{auth_token: token})
74+
Map.merge(settings, %{auth_token: resolve_secret(token)})
6175
end
6276

6377
defp negotiate_auth(
@@ -66,7 +80,7 @@ defmodule Gnat.Handshake do
6680
%{nkey_seed: seed, jwt: jwt} = _user,
6781
true = _auth_required
6882
) do
69-
{:ok, nkey} = NKEYS.from_seed(seed)
83+
{:ok, nkey} = seed |> resolve_secret() |> NKEYS.from_seed()
7084
signature = NKEYS.sign(nkey, nonce) |> Base.url_encode64() |> String.replace("=", "")
7185

7286
Map.merge(settings, %{sig: signature, protocol: 1, jwt: jwt})
@@ -78,7 +92,7 @@ defmodule Gnat.Handshake do
7892
%{nkey_seed: seed} = _user,
7993
true = _auth_required
8094
) do
81-
{:ok, nkey} = NKEYS.from_seed(seed)
95+
{:ok, nkey} = seed |> resolve_secret() |> NKEYS.from_seed()
8296
signature = NKEYS.sign(nkey, nonce) |> Base.url_encode64() |> String.replace("=", "")
8397
public = NKEYS.public_nkey(nkey)
8498

@@ -89,6 +103,9 @@ defmodule Gnat.Handshake do
89103
settings
90104
end
91105

106+
defp resolve_secret(secret) when is_function(secret, 0), do: secret.()
107+
defp resolve_secret(secret), do: secret
108+
92109
defp negotiate_headers(settings, %{headers: true} = _server, user_settings) do
93110
if Map.get(user_settings, :headers, true) do
94111
Map.put(settings, :headers, true)
Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,52 @@
1+
defmodule Gnat.ConnectionSupervisorTest do
2+
use ExUnit.Case, async: true
3+
@moduletag :capture_log
4+
5+
@nkey_seed "test/fixtures/nkey_seed" |> File.read!() |> String.trim()
6+
7+
test "connection secrets are redacted from the process status" do
8+
secrets = %{password: "supervisor-password", token: "supervisor-token", nkey_seed: @nkey_seed}
9+
10+
settings = %{
11+
name: :connection_supervisor_redaction_test,
12+
backoff_period: 60_000,
13+
connection_settings: [Map.merge(%{host: "127.0.0.1", port: 1}, secrets)]
14+
}
15+
16+
{:ok, supervisor} = Gnat.ConnectionSupervisor.start_link(settings)
17+
status = inspect(:sys.get_status(supervisor), limit: :infinity, printable_limit: :infinity)
18+
19+
assert status =~ "connection_settings"
20+
Enum.each(Map.values(secrets), fn secret -> refute status =~ secret end)
21+
GenServer.stop(supervisor)
22+
end
23+
24+
test "connection settings are logged with their secrets redacted when connecting" do
25+
import ExUnit.CaptureLog
26+
secrets = %{password: "logged-password", token: "logged-token", nkey_seed: @nkey_seed}
27+
28+
settings = %{
29+
name: :connection_supervisor_logging_test,
30+
backoff_period: 60_000,
31+
connection_settings: [
32+
Map.merge(%{host: "127.0.0.1", port: 1, username: "logged-user"}, secrets)
33+
]
34+
}
35+
36+
log =
37+
capture_log([level: :debug], fn ->
38+
{:ok, supervisor} = Gnat.ConnectionSupervisor.start_link(settings)
39+
# the first connection attempt has been handled once the supervisor answers
40+
:sys.get_state(supervisor)
41+
GenServer.stop(supervisor)
42+
end)
43+
44+
assert log =~ "connecting to"
45+
assert log =~ ~s(username: "logged-user")
46+
assert log =~ ~s(host: "127.0.0.1")
47+
assert log =~ "password: :redacted"
48+
assert log =~ "token: :redacted"
49+
assert log =~ "nkey_seed: :redacted"
50+
Enum.each(Map.values(secrets), fn secret -> refute log =~ secret end)
51+
end
52+
end

‎test/gnat/handshake_test.exs‎

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -69,6 +69,31 @@ defmodule Gnat.HandshakeTest do
6969
assert result[:protocol] == 1
7070
end
7171

72+
test "accepts an nkey seed function" do
73+
seed = "SUAIBDPBAUTWCWBKIO6XHQNINK5FWJW4OHLXC3HQ2KFE4PEJUA44CNHTC4"
74+
server_settings = %{nonce: "test-nonce-value", auth_required: true}
75+
76+
from_binary = Handshake.negotiate_settings(server_settings, %{nkey_seed: seed})
77+
from_function = Handshake.negotiate_settings(server_settings, %{nkey_seed: fn -> seed end})
78+
79+
assert from_function == from_binary
80+
assert Map.has_key?(from_function, :sig)
81+
assert Map.has_key?(from_function, :nkey)
82+
end
83+
84+
test "accepts password and token functions" do
85+
server_settings = %{auth_required: true}
86+
87+
assert %{user: "test", pass: "secret"} =
88+
Handshake.negotiate_settings(server_settings, %{
89+
username: "test",
90+
password: fn -> "secret" end
91+
})
92+
93+
assert %{auth_token: "my-secret-token"} =
94+
Handshake.negotiate_settings(server_settings, %{token: fn -> "my-secret-token" end})
95+
end
96+
7297
test "works with JWT+nkey authentication when client forces auth" do
7398
nonce = "test-nonce-value"
7499
server_settings = %{nonce: nonce}

‎test/gnat_test.exs‎

Lines changed: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,36 @@ defmodule GnatTest do
3737
:ok = Gnat.stop(pid)
3838
end
3939

40+
@tag :multi_server
41+
test "connect to a server with user/pass authentication and a password function" do
42+
connection_settings = %{
43+
host: "localhost",
44+
port: 4223,
45+
tcp_opts: [:binary],
46+
username: "bob",
47+
password: fn -> "alice" end
48+
}
49+
50+
{:ok, pid} = Gnat.start_link(connection_settings)
51+
assert Process.alive?(pid)
52+
:ok = Gnat.stop(pid)
53+
end
54+
55+
@tag :multi_server
56+
test "connect to a server with token authentication and a token function" do
57+
connection_settings = %{
58+
host: "localhost",
59+
port: 4226,
60+
tcp_opts: [:binary],
61+
token: fn -> "SpecialToken" end,
62+
auth_required: true
63+
}
64+
65+
{:ok, pid} = Gnat.start_link(connection_settings)
66+
assert Process.alive?(pid)
67+
:ok = Gnat.stop(pid)
68+
end
69+
4070
@tag :multi_server
4171
test "connect to a server with token authentication" do
4272
connection_settings = %{
@@ -85,6 +115,17 @@ defmodule GnatTest do
85115
assert Gnat.stop(gnat) == :ok
86116
end
87117

118+
@tag :multi_server
119+
test "connect to a server which requires nkeys with a seed function" do
120+
connection_settings = %{
121+
port: 4227,
122+
nkey_seed: fn -> File.read!("test/fixtures/nkey_seed") end
123+
}
124+
125+
{:ok, gnat} = Gnat.start_link(connection_settings)
126+
assert Gnat.stop(gnat) == :ok
127+
end
128+
88129
test "subscribe to topic and receive a message" do
89130
{:ok, pid} = Gnat.start_link()
90131
{:ok, _ref} = Gnat.sub(pid, self(), "test")
@@ -362,6 +403,34 @@ defmodule GnatTest do
362403
:ok = Gnat.stop(gnat)
363404
end
364405

406+
test "connection secrets are redacted from the process status" do
407+
secrets = %{password: "status-password", token: "status-token", nkey_seed: nkey_seed()}
408+
{:ok, gnat} = Gnat.start_link(secrets)
409+
410+
status = inspect(:sys.get_status(gnat), limit: :infinity, printable_limit: :infinity)
411+
412+
assert status =~ "connection_settings"
413+
Enum.each(Map.values(secrets), fn secret -> refute status =~ secret end)
414+
assert Gnat.stop(gnat) == :ok
415+
end
416+
417+
test "connection secrets are redacted from crash reports" do
418+
import ExUnit.CaptureLog
419+
Process.flag(:trap_exit, true)
420+
secrets = %{password: "report-password", token: "report-token", nkey_seed: nkey_seed()}
421+
{:ok, gnat} = Gnat.start_link(secrets)
422+
423+
log =
424+
capture_log(fn ->
425+
GenServer.stop(gnat, :crash_report_test)
426+
assert_receive {:EXIT, ^gnat, :crash_report_test}
427+
end)
428+
429+
assert log =~ "crash_report_test"
430+
assert log =~ "connection_settings"
431+
Enum.each(Map.values(secrets), fn secret -> refute log =~ secret end)
432+
end
433+
365434
test "connection timeout" do
366435
start = System.monotonic_time(:millisecond)
367436
connection_settings = %{host: ~c"169.33.33.33", connection_timeout: 200}
@@ -386,4 +455,6 @@ defmodule GnatTest do
386455
assert Map.has_key?(info, :version)
387456
assert is_binary(info.version)
388457
end
458+
459+
defp nkey_seed, do: "test/fixtures/nkey_seed" |> File.read!() |> String.trim()
389460
end

0 commit comments

Comments
 (0)