Skip to content

Commit 7782a2a

Browse files
authored
feat(pairing): allow key uploading (#1874)
Allow using Astarte as proxy to upload keys in OpenBao. Endpoint: POST /v1/:realm_name/fdo/owner_key Payload params: action="upload" / key_name / key_data Controller tests added Signed-off-by: frossq <francesco.asquini@secomind.com>
1 parent 958aa2b commit 7782a2a

File tree

7 files changed

+211
-12
lines changed

7 files changed

+211
-12
lines changed

apps/astarte_pairing/lib/astarte_pairing_web/controllers/fallback_controller.ex

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -78,6 +78,22 @@ defmodule Astarte.PairingWeb.FallbackController do
7878
|> render(:invalid_auth_path)
7979
end
8080

81+
# The format of the uploaded (FDO) key is not the expected one
82+
def call(conn, {:error, :unprocessable_key}) do
83+
conn
84+
|> put_status(:unprocessable_entity)
85+
|> put_view(Astarte.PairingWeb.ErrorView)
86+
|> render(:unprocessable_key)
87+
end
88+
89+
# A key with the same name has already been imported in OpenBao
90+
def call(conn, {:error, :key_already_imported}) do
91+
conn
92+
|> put_status(:conflict)
93+
|> put_view(Astarte.PairingWeb.ErrorView)
94+
|> render(:key_already_imported)
95+
end
96+
8197
# This is called when no JWT token is present
8298
def auth_error(conn, {:unauthenticated, :unauthenticated}, _opts) do
8399
conn

apps/astarte_pairing/lib/astarte_pairing_web/controllers/owner_key_controller.ex

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -40,9 +40,10 @@ defmodule Astarte.PairingWeb.OwnerKeyController do
4040

4141
with {:ok, create_or_upload_changeset} <-
4242
Ecto.Changeset.apply_action(create_or_upload_changeset, :insert),
43-
{:ok, public_key} <-
43+
{:ok, resp} <-
4444
OwnerKeyInitialization.create_or_upload(create_or_upload_changeset, realm_name) do
45-
send_resp(conn, 200, public_key)
45+
# the successful resp will be a public key (when creating) or an empty string (when uploading)
46+
send_resp(conn, 200, resp)
4647
end
4748
end
4849

apps/astarte_pairing/lib/astarte_pairing_web/views/error_view.ex

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -71,6 +71,22 @@ defmodule Astarte.PairingWeb.ErrorView do
7171
}
7272
end
7373

74+
def render("unprocessable_key.json", _assigns) do
75+
%{
76+
errors: %{
77+
detail: "The provided key format is invalid or cannot be processed."
78+
}
79+
}
80+
end
81+
82+
def render("key_already_imported.json", _assigns) do
83+
%{
84+
errors: %{
85+
detail: "A key with this name has already been imported"
86+
}
87+
}
88+
end
89+
7490
# In case no render clause matches or no
7591
# template is found, let's render it as 500
7692
def template_not_found(_template, assigns) do

apps/astarte_pairing/test/astarte_pairing_web/controllers/owner_key_controller_test.exs

Lines changed: 126 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -26,16 +26,17 @@ defmodule Astarte.PairingWeb.Controllers.OwnerKeyControllerTest do
2626
alias Astarte.Secrets.Key
2727
alias Astarte.Secrets.OwnerKeyInitialization
2828
alias Astarte.Secrets.OwnerKeyInitializationOptions
29+
alias COSE.Keys
2930

3031
setup :verify_on_exit!
3132

32-
describe "/fdo/owner_key" do
33+
describe "POST /fdo/owner_key" do
3334
setup :owner_key_setup
3435

3536
test "rejects the request if no correct key action is specified", context do
3637
%{
3738
auth_conn: conn,
38-
create_path: path,
39+
owner_key_path: path,
3940
create_key_payload: payload
4041
} = context
4142

@@ -50,7 +51,7 @@ defmodule Astarte.PairingWeb.Controllers.OwnerKeyControllerTest do
5051
test "rejects the request if no algorithm is specified while creating key", context do
5152
%{
5253
auth_conn: conn,
53-
create_path: path,
54+
owner_key_path: path,
5455
create_key_payload: payload
5556
} = context
5657

@@ -65,7 +66,7 @@ defmodule Astarte.PairingWeb.Controllers.OwnerKeyControllerTest do
6566
context do
6667
%{
6768
auth_conn: conn,
68-
create_path: path,
69+
owner_key_path: path,
6970
create_key_payload: payload
7071
} = context
7172

@@ -80,11 +81,13 @@ defmodule Astarte.PairingWeb.Controllers.OwnerKeyControllerTest do
8081
test "creates in OpenBao a key of the chosen type (EC256)", context do
8182
%{
8283
auth_conn: conn,
83-
create_path: path,
84+
owner_key_path: path,
8485
create_key_payload: payload,
8586
openbao_namespace: namespace
8687
} = context
8788

89+
on_exit(fn -> cleanup_keys(namespace) end)
90+
8891
public_key_created =
8992
conn
9093
|> post(path, payload)
@@ -96,8 +99,85 @@ defmodule Astarte.PairingWeb.Controllers.OwnerKeyControllerTest do
9699
assert public_key_created == public_key_retrieved
97100
end
98101

102+
test "rejects the request if no key data is specified while uploading key", context do
103+
%{
104+
auth_conn: conn,
105+
owner_key_path: path,
106+
upload_key_payload: payload
107+
} = context
108+
109+
{_, payload_no_key_data} = pop_in(payload, [:data, :key_data])
110+
111+
conn
112+
|> post(path, payload_no_key_data)
113+
|> response(422)
114+
end
115+
116+
test "rejects the request if an invalid key body is passed while uploading key",
117+
context do
118+
%{
119+
auth_conn: conn,
120+
owner_key_path: path,
121+
upload_key_payload: payload
122+
} = context
123+
124+
payload_wrong_key_data =
125+
update_in(payload, [:data, :key_data], fn _ -> "invalid_key_body" end)
126+
127+
conn
128+
|> post(path, payload_wrong_key_data)
129+
|> response(422)
130+
end
131+
132+
test "uploads in OpenBao a key of the chosen type (EC256)", context do
133+
%{
134+
auth_conn: conn,
135+
owner_key_path: path,
136+
upload_key_payload: payload,
137+
openbao_namespace: namespace
138+
} = context
139+
140+
on_exit(fn -> cleanup_keys(namespace) end)
141+
142+
conn
143+
|> post(path, payload)
144+
|> response(200)
145+
146+
assert {:ok, %Key{public_pem: public_key_retrieved}} =
147+
Secrets.get_key(payload[:data][:key_name], namespace: namespace)
148+
149+
# we load a private key PEM, OpenBao returns a public key PEM. Need to compare them
150+
assert private_pem_public_pem_match?(payload[:data][:key_data], public_key_retrieved)
151+
end
152+
153+
test "returns a warning if a key with the same name has been already imported in OpenBao",
154+
context do
155+
%{
156+
auth_conn: conn,
157+
owner_key_path: path,
158+
upload_key_payload: payload,
159+
openbao_namespace: namespace
160+
} = context
161+
162+
on_exit(fn -> cleanup_keys(namespace) end)
163+
164+
payload_duplicated_key =
165+
update_in(payload, [:data, :key_name], fn _ -> "duplicated_key" end)
166+
167+
conn
168+
|> post(path, payload_duplicated_key)
169+
|> response(200)
170+
171+
resp_message =
172+
conn
173+
|> post(path, payload_duplicated_key)
174+
|> response(409)
175+
176+
assert resp_message =~ "has already been imported"
177+
end
178+
99179
test "returns a 404 error if FDO feature is disabled", context do
100-
%{auth_conn: conn, create_path: path, create_key_payload: payload} = context
180+
%{auth_conn: conn, owner_key_path: path, create_key_payload: payload} = context
101181

102182
stub(Config, :enable_fdo!, fn -> false end)
103183

@@ -117,6 +197,8 @@ defmodule Astarte.PairingWeb.Controllers.OwnerKeyControllerTest do
117197
list_path: path
118198
} = context
119199

200+
on_exit(fn -> cleanup_keys(namespace) end)
201+
120202
keys =
121203
conn
122204
|> get(path)
@@ -142,7 +224,7 @@ defmodule Astarte.PairingWeb.Controllers.OwnerKeyControllerTest do
142224

143225
defp owner_key_setup(context) do
144226
%{auth_conn: conn, realm_name: realm_name} = context
145-
create_path = owner_key_path(conn, :create_or_upload_key, realm_name)
227+
owner_key_path = owner_key_path(conn, :create_or_upload_key, realm_name)
146228

147229
create_key_payload = %{
148230
data: %{
@@ -152,11 +234,20 @@ defmodule Astarte.PairingWeb.Controllers.OwnerKeyControllerTest do
152234
}
153235
}
154236

237+
upload_key_payload = %{
238+
data: %{
239+
action: "upload",
240+
key_name: "key_to_upload",
241+
key_data: Keys.ECC.generate(:es256) |> Keys.to_pem()
242+
}
243+
}
244+
155245
{:ok, namespace_es256} = Secrets.create_namespace(realm_name, :es256)
156246

157247
%{
158-
create_path: create_path,
248+
owner_key_path: owner_key_path,
159249
create_key_payload: create_key_payload,
250+
upload_key_payload: upload_key_payload,
160251
openbao_namespace: namespace_es256
161252
}
162253
end
@@ -202,4 +293,31 @@ defmodule Astarte.PairingWeb.Controllers.OwnerKeyControllerTest do
202293

203294
%{list_path: list_path, openbao_namespace: namespace_es256}
204295
end
296+
297+
# simple version, works only for EC keys
298+
# TODO implement using COSE functions
299+
defp private_pem_public_pem_match?(private_pem, public_pem) do
300+
private_pem_decoded = decode_pem(private_pem) |> extract_public()
301+
public_pem_decoded = decode_pem(public_pem)
302+
303+
private_pem_decoded == public_pem_decoded
304+
end
305+
306+
defp decode_pem(pem_string) do
307+
[entry] = :public_key.pem_decode(pem_string)
308+
:public_key.pem_entry_decode(entry)
309+
end
310+
311+
defp extract_public({:ECPrivateKey, _version, _priv, params, pub_bytes, _attributes}) do
312+
{{:ECPoint, pub_bytes}, params}
313+
end
314+
315+
defp cleanup_keys(namespace) do
316+
{:ok, keys_to_delete} = Secrets.list_keys_names(namespace: namespace)
317+
318+
Enum.each(keys_to_delete, fn key ->
319+
Secrets.enable_key_deletion(key, namespace: namespace)
320+
Secrets.delete_key(key, namespace: namespace)
321+
end)
322+
end
205323
end

libs/astarte_secrets/lib/astarte_secrets/core.ex

Lines changed: 16 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -181,6 +181,14 @@ defmodule Astarte.Secrets.Core do
181181
{:ok, %HTTPoison.Response{status_code: 204}} ->
182182
:ok
183183

184+
{:ok, %Response{status_code: 500, body: body}} = resp ->
185+
if body =~ "import path cannot be used with an existing key" do
186+
{:error, :key_already_imported}
187+
else
188+
Logger.error("Encountered HTTP error while importing key #{key_name}: #{inspect(resp)}")
189+
:error
190+
end
191+
184192
error_resp ->
185193
Logger.error(
186194
"Encountered HTTP error while importing key #{key_name}: #{inspect(error_resp)}"
@@ -275,7 +283,7 @@ defmodule Astarte.Secrets.Core do
275283
{:ok, %HTTPoison.Response{status_code: 200, body: resp_body}} ->
276284
case parse_data_key(resp_body, "keys") do
277285
{:ok, keys} ->
278-
{:ok, keys}
286+
filter_real_keys(keys)
279287

280288
{:error, reason} ->
281289
Logger.error("Encountered error while getting keys list: #{inspect(reason)}")
@@ -517,4 +525,11 @@ defmodule Astarte.Secrets.Core do
517525
%{key_algorithm => keys}
518526
end
519527
end
528+
529+
defp filter_real_keys(keys) do
530+
# drop the "import/" element that (if present) is listed as a key
531+
# but is actually an operational endpoint
532+
keys = Enum.reject(keys, fn key -> key == "import/" end)
533+
{:ok, keys}
534+
end
520535
end

libs/astarte_secrets/lib/astarte_secrets/owner_key_initialization.ex

Lines changed: 26 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@ defmodule Astarte.Secrets.OwnerKeyInitialization do
2525
alias Astarte.Secrets
2626
alias Astarte.Secrets.Core
2727
alias Astarte.Secrets.OwnerKeyInitializationOptions
28+
alias COSE.Keys
2829

2930
def create_or_upload(
3031
%OwnerKeyInitializationOptions{
@@ -36,10 +37,28 @@ defmodule Astarte.Secrets.OwnerKeyInitialization do
3637
) do
3738
{:ok, key_algorithm} = Core.string_to_key_type(key_algorithm)
3839
{:ok, namespace} = Secrets.create_namespace(realm_name, key_algorithm)
39-
4040
do_create_key(key_name, key_algorithm, namespace)
4141
end
4242

43+
def create_or_upload(
44+
%OwnerKeyInitializationOptions{
45+
action: "upload",
46+
key_name: key_name,
47+
key_data: key_data
48+
},
49+
realm_name
50+
) do
51+
case Keys.from_pem(key_data) do
52+
{:ok, decoded_key_data} ->
53+
key_algorithm = decoded_key_data.alg
54+
{:ok, namespace} = Secrets.create_namespace(realm_name, key_algorithm)
55+
do_upload_key(key_name, key_algorithm, decoded_key_data, namespace)
56+
57+
:error ->
58+
{:error, :unprocessable_key}
59+
end
60+
end
61+
4362
defp do_create_key(key_name, key_algorithm, namespace) do
4463
with {:ok, key_data} <-
4564
Secrets.create_keypair(key_name, key_algorithm, namespace: namespace) do
@@ -48,4 +67,10 @@ defmodule Astarte.Secrets.OwnerKeyInitialization do
4867
{:ok, public_key}
4968
end
5069
end
70+
71+
defp do_upload_key(key_name, key_algorithm, key_body, namespace) do
72+
with :ok <- Secrets.import_key(key_name, key_algorithm, key_body, namespace: namespace) do
73+
{:ok, ""}
74+
end
75+
end
5176
end

libs/astarte_secrets/test/astarte_secrets/core_test.exs

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -269,6 +269,14 @@ defmodule Astarte.Secrets.CoreTest do
269269
stored_pem = key_data.public_pem |> String.trim_trailing()
270270
assert expected_pub_pem == stored_pem
271271
end
272+
273+
test "returns error if the key has already been imported", %{unique_id: uid, opts: opts} do
274+
key_name = "imported_ec256_#{uid}"
275+
ec_key = ECC.generate(:es256)
276+
277+
assert :ok = Secrets.import_key(key_name, :es256, ec_key, opts)
278+
assert {:error, :key_already_imported} = Secrets.import_key(key_name, :es256, ec_key, opts)
279+
end
272280
end
273281

274282
defp http_stubs_setup(_context) do

0 commit comments

Comments
 (0)