diff --git a/.github/workflows/astarte-apps-build-workflow.yaml b/.github/workflows/astarte-apps-build-workflow.yaml index 7654fd546..ae957f7c7 100644 --- a/.github/workflows/astarte-apps-build-workflow.yaml +++ b/.github/workflows/astarte-apps-build-workflow.yaml @@ -117,6 +117,12 @@ jobs: --health-interval=2s --health-timeout=2s --health-retries=5 + rendezvous: + image: astarte/go-fdo-server:ade68cda47-20251128 + ports: + - 8041:8041 + restart: on-failure + command: '--log-level=debug rendezvous 0.0.0.0:8041 --db-type sqlite --db-dsn "file:/var/lib/fdo/rendezvous.db"' steps: - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 diff --git a/.github/workflows/astarte-end-to-end-test-workflow.yaml b/.github/workflows/astarte-end-to-end-test-workflow.yaml index 5be890ee2..d2fb26355 100644 --- a/.github/workflows/astarte-end-to-end-test-workflow.yaml +++ b/.github/workflows/astarte-end-to-end-test-workflow.yaml @@ -112,8 +112,8 @@ jobs: - name: Checkout fdo e2e repo uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 with: - repository: astarte-platform/astarte-device-fdo-rust - ref: dev + repository: noaccos/astarte-device-fdo-rust + ref: push-mzlxtwylktkr - uses: ./.github/actions/install-deps - name: Install astartectl run: | diff --git a/apps/astarte_housekeeping/lib/astarte_housekeeping/realms/queries.ex b/apps/astarte_housekeeping/lib/astarte_housekeeping/realms/queries.ex index 8c5a3e21b..4c4e3e339 100644 --- a/apps/astarte_housekeeping/lib/astarte_housekeeping/realms/queries.ex +++ b/apps/astarte_housekeeping/lib/astarte_housekeeping/realms/queries.ex @@ -625,9 +625,15 @@ defmodule Astarte.Housekeeping.Realms.Queries do defp create_ownership_vouchers_table(keyspace_name) do query = """ CREATE TABLE #{keyspace_name}.ownership_vouchers ( - private_key blob, - voucher_data blob, guid blob, + voucher_data blob, + output_voucher blob, + replacement_guid blob, + replacement_rendezvous_info blob, + replacement_public_key blob, + key_name varchar, + key_algorithm int, + user_id blob, PRIMARY KEY (guid) ); """ @@ -673,9 +679,6 @@ defmodule Astarte.Housekeeping.Realms.Queries do device_service_info map, blob>, owner_service_info list, last_chunk_sent int, - replacement_guid blob, - replacement_rv_info blob, - replacement_pub_key blob, replacement_hmac blob, PRIMARY KEY (guid) ) diff --git a/apps/astarte_housekeeping/priv/migrations/realm/0015_drop_replacement_data.sql b/apps/astarte_housekeeping/priv/migrations/realm/0015_drop_replacement_data.sql new file mode 100644 index 000000000..2ef82984e --- /dev/null +++ b/apps/astarte_housekeeping/priv/migrations/realm/0015_drop_replacement_data.sql @@ -0,0 +1,2 @@ +ALTER TABLE :keyspace.to2_sessions +DROP (replacement_guid, replacement_rv_info, replacement_pub_key) diff --git a/apps/astarte_housekeeping/priv/migrations/realm/0016_drop_private_key.sql b/apps/astarte_housekeeping/priv/migrations/realm/0016_drop_private_key.sql new file mode 100644 index 000000000..3e4e3e2e2 --- /dev/null +++ b/apps/astarte_housekeeping/priv/migrations/realm/0016_drop_private_key.sql @@ -0,0 +1,2 @@ +ALTER TABLE :keyspace.ownership_vouchers +DROP private_key diff --git a/apps/astarte_housekeeping/priv/migrations/realm/0017_add_replacement_data_and_remote_key.sql b/apps/astarte_housekeeping/priv/migrations/realm/0017_add_replacement_data_and_remote_key.sql new file mode 100644 index 000000000..7f401b18e --- /dev/null +++ b/apps/astarte_housekeeping/priv/migrations/realm/0017_add_replacement_data_and_remote_key.sql @@ -0,0 +1,10 @@ +ALTER TABLE :keyspace.ownership_vouchers +ADD ( + replacement_guid blob, + replacement_rendezvous_info blob, + replacement_public_key blob, + output_voucher blob, + key_name varchar, + key_algorithm int, + user_id blob +); diff --git a/apps/astarte_pairing/README.md b/apps/astarte_pairing/README.md index d0fa339d7..0868d1b90 100644 --- a/apps/astarte_pairing/README.md +++ b/apps/astarte_pairing/README.md @@ -34,6 +34,7 @@ docker run --rm -d -p 9042:9042 --name scylla scylladb/scylla docker run --rm -d -p 5672:5672 -p 15672:15672 --name rabbit rabbitmq:3.12.0-management docker run --rm -d --net=host -p 8080/tcp ispirata/docker-alpine-cfssl-autotest:astarte docker run --rm -d -p 8200:8200 --name openbao openbao/openbao:latest server -dev -dev-root-token-id=astarte_token +docker run -rm -d -p 8041:8041 --name rendezvous astarte/go-fdo-server:ade68cda47-20251128 --log-level=debug rendezvous 0.0.0.0:8041 --db-type sqlite --db-dsn "file:/var/lib/fdo/rendezvous.db" ``` by default `CASSANDRA_NODES` and `CFSSL_API_URL` environment variables map to localhost, so that diff --git a/apps/astarte_pairing/config/test.exs b/apps/astarte_pairing/config/test.exs index d45d25507..7377b13ba 100644 --- a/apps/astarte_pairing/config/test.exs +++ b/apps/astarte_pairing/config/test.exs @@ -108,7 +108,8 @@ config :astarte_pairing, :enable_credential_reuse, true config :astarte_secrets, bao_authentication_mechanism: :token config :astarte_secrets, bao_token: "astarte_token" -config :astarte_secrets, bao_url: "http://localhost:8200" + +config :astarte_fdo, fdo_rendezvous_url: "http://localhost:8041" config :bcrypt_elixir, log_rounds: 4 diff --git a/apps/astarte_pairing/lib/astarte_pairing/queries.ex b/apps/astarte_pairing/lib/astarte_pairing/queries.ex index cf1c9b61e..820a2eca0 100644 --- a/apps/astarte_pairing/lib/astarte_pairing/queries.ex +++ b/apps/astarte_pairing/lib/astarte_pairing/queries.ex @@ -207,7 +207,7 @@ defmodule Astarte.Pairing.Queries do query = from o in OwnershipVoucher, prefix: ^keyspace_name, - select: o.private_key + select: o.key_name consistency = Consistency.domain_model(:read) @@ -218,7 +218,7 @@ defmodule Astarte.Pairing.Queries do realm_name, guid, cbor_ownership_voucher, - owner_private_key, + key_name, ttl ) do keyspace_name = Realm.keyspace_name(realm_name) @@ -227,7 +227,7 @@ defmodule Astarte.Pairing.Queries do %OwnershipVoucher{ voucher_data: cbor_ownership_voucher, - private_key: owner_private_key, + key_name: key_name, guid: guid } |> Repo.insert(opts) @@ -245,13 +245,15 @@ defmodule Astarte.Pairing.Queries do def replace_ownership_voucher( realm_name, guid, - new_voucher, - owner_private_key, - ttl + new_voucher ) do - with {:ok, _} <- delete_ownership_voucher(realm_name, guid) do - create_ownership_voucher(realm_name, guid, new_voucher, owner_private_key, ttl) - end + keyspace = Realm.keyspace_name(realm_name) + consistency = Consistency.device_info(:write) + opts = [prefix: keyspace, consistency: consistency] + + %OwnershipVoucher{guid: guid} + |> Ecto.Changeset.change(output_voucher: new_voucher) + |> Repo.update(opts) end def store_session(realm_name, guid, session) do diff --git a/apps/astarte_pairing/lib/astarte_pairing_web/controllers/owner_key_controller.ex b/apps/astarte_pairing/lib/astarte_pairing_web/controllers/owner_key_controller.ex index 9799918bd..d316571a4 100644 --- a/apps/astarte_pairing/lib/astarte_pairing_web/controllers/owner_key_controller.ex +++ b/apps/astarte_pairing/lib/astarte_pairing_web/controllers/owner_key_controller.ex @@ -55,26 +55,37 @@ defmodule Astarte.PairingWeb.OwnerKeyController do } ) do with {:ok, keys} <- - Secrets.Core.get_keys_from_algorithm(realm_name, @supported_key_algorithms) do + Secrets.Core.get_keys(realm_name, @supported_key_algorithms) do send_resp(conn, 200, Jason.encode!(keys)) end end + def get_keys_for_algorithm(conn, %{ + "realm_name" => realm_name, + "key_algorithm" => key_algorithm + }) do + with {:ok, algorithm} <- Secrets.Core.string_to_key_type(key_algorithm), + {:ok, keys} <- Secrets.Core.get_keys(realm_name, [algorithm]) do + json(conn, %{data: keys}) + end + end + def get_key(conn, %{ "realm_name" => realm_name, "key_algorithm" => key_algorithm, "key_name" => key_name }) do - with {:ok, algorithm_atom} <- Secrets.Core.string_to_key_type(key_algorithm) do - case Secrets.Core.find_key(realm_name, algorithm_atom, key_name) do - {:ok, key} -> - json(conn, %{data: %{key_name: key.name, public_key: key.public_pem}}) + with {:ok, algorithm_atom} <- Secrets.Core.string_to_key_type(key_algorithm), + {:ok, key} <- Secrets.Core.find_key(realm_name, key_name, algorithm_atom) do + json(conn, %{data: %{key_name: key.name, public_key: key.public_pem}}) + else + :error -> + {:error, :unprocessable_key} - :not_found -> - conn - |> put_status(:not_found) - |> json(%{errors: %{detail: "Key not found"}}) - end + :not_found -> + conn + |> put_status(:not_found) + |> json(%{errors: %{detail: "Key not found"}}) end end end diff --git a/apps/astarte_pairing/lib/astarte_pairing_web/controllers/ownership_voucher_controller.ex b/apps/astarte_pairing/lib/astarte_pairing_web/controllers/ownership_voucher_controller.ex index 8f21c5e8b..fce870f58 100644 --- a/apps/astarte_pairing/lib/astarte_pairing_web/controllers/ownership_voucher_controller.ex +++ b/apps/astarte_pairing/lib/astarte_pairing_web/controllers/ownership_voucher_controller.ex @@ -19,7 +19,6 @@ defmodule Astarte.PairingWeb.OwnershipVoucherController do use Astarte.PairingWeb, :controller - alias Astarte.DataAccess.FDO.OwnershipVoucher.CreateRequest alias Astarte.FDO.OwnershipVoucher alias Astarte.FDO.OwnershipVoucher.LoadRequest alias Astarte.FDO.TO0 @@ -27,46 +26,31 @@ defmodule Astarte.PairingWeb.OwnershipVoucherController do action_fallback Astarte.PairingWeb.FallbackController - def create(conn, %{ - "data" => data, - "realm_name" => realm_name - }) do - create = CreateRequest.changeset(%CreateRequest{}, data) - - with {:ok, create} <- Ecto.Changeset.apply_action(create, :insert), - %CreateRequest{ - decoded_ownership_voucher: decoded_ownership_voucher, - cbor_ownership_voucher: cbor_ownership_voucher, - private_key: private_key, - extracted_private_key: extracted_private_key, - device_guid: device_guid - } = create, - :ok <- - OwnershipVoucher.save_voucher( - realm_name, - cbor_ownership_voucher, - device_guid, - private_key - ), - :ok <- - TO0.claim_ownership_voucher( - realm_name, - decoded_ownership_voucher, - extracted_private_key - ) do - send_resp(conn, 200, "") - end - end - @doc """ - Validates an FDO Ownership Voucher load request. + Validates an FDO Ownership Voucher load request and register the OV in the database. Returns `200 OK` with the owner public key PEM on success. """ def register(conn, %{"data" => data, "realm_name" => realm_name}) do with {:ok, req} <- LoadRequest.changeset(%LoadRequest{}, Map.put(data, "realm_name", realm_name)) - |> Ecto.Changeset.apply_action(:insert) do + |> Ecto.Changeset.apply_action(:insert), + :ok <- + OwnershipVoucher.save_voucher(realm_name, %{ + voucher_data: req.cbor_ownership_voucher, + guid: req.device_guid, + key_name: req.key_name, + key_algorithm: req.key_algorithm, + replacement_guid: req.replacement_guid, + replacement_rendezvous_info: req.decoded_replacement_rendezvous_info, + replacement_public_key: req.decoded_replacement_public_key + }), + :ok <- + TO0.claim_ownership_voucher( + realm_name, + req.decoded_ownership_voucher, + req.extracted_owner_key + ) do json(conn, %{ data: %{ public_key: req.extracted_owner_key.public_pem, @@ -86,7 +70,7 @@ defmodule Astarte.PairingWeb.OwnershipVoucherController do with {:ok, pem} <- ensure_ownership_voucher_parameter(data), {:ok, voucher} <- OwnershipVoucher.decode_binary_voucher(pem), key_algorithm = OwnershipVoucher.key_algorithm(voucher), - {:ok, keys_map} <- SecretsCore.get_keys_from_algorithm(realm_name, key_algorithm) do + {:ok, keys_map} <- SecretsCore.get_keys(realm_name, key_algorithm) do json(conn, %{data: keys_map}) end end diff --git a/apps/astarte_pairing/lib/astarte_pairing_web/plug/setup_fdo.ex b/apps/astarte_pairing/lib/astarte_pairing_web/plug/setup_fdo.ex index 879ad5764..d686aaf3c 100644 --- a/apps/astarte_pairing/lib/astarte_pairing_web/plug/setup_fdo.ex +++ b/apps/astarte_pairing/lib/astarte_pairing_web/plug/setup_fdo.ex @@ -43,6 +43,7 @@ defmodule Astarte.PairingWeb.Plug.SetupFDO do case read_body(conn) do {:ok, body, conn} -> conn + |> put_resp_header("content-type", "application/cbor") |> put_resp_header("message-type", to_string(next_message_id)) |> assign(:message_id, message_id) |> assign(:cbor_body, body) diff --git a/apps/astarte_pairing/lib/astarte_pairing_web/router.ex b/apps/astarte_pairing/lib/astarte_pairing_web/router.ex index 8641147cb..135c81e29 100644 --- a/apps/astarte_pairing/lib/astarte_pairing_web/router.ex +++ b/apps/astarte_pairing/lib/astarte_pairing_web/router.ex @@ -104,6 +104,7 @@ defmodule Astarte.PairingWeb.Router do post "/owner_keys", OwnerKeyController, :create_or_upload_key get "/owner_keys", OwnerKeyController, :list_keys + get "/owner_keys/:key_algorithm", OwnerKeyController, :get_keys_for_algorithm get "/owner_keys/:key_algorithm/:key_name", OwnerKeyController, :get_key post "/owner_keys_for_voucher", OwnershipVoucherController, :owner_keys_for_voucher post "/ownership_vouchers", OwnershipVoucherController, :register diff --git a/apps/astarte_pairing/test/astarte_pairing_web/controllers/fdo_onboarding_controller_test.exs b/apps/astarte_pairing/test/astarte_pairing_web/controllers/fdo_onboarding_controller_test.exs index 212e1bba9..ad334d26b 100644 --- a/apps/astarte_pairing/test/astarte_pairing_web/controllers/fdo_onboarding_controller_test.exs +++ b/apps/astarte_pairing/test/astarte_pairing_web/controllers/fdo_onboarding_controller_test.exs @@ -156,13 +156,8 @@ defmodule Astarte.PairingWeb.FDOOnboardingControllerTest do conn: conn, create_path: path, message_id: id, - session: session, - realm_name: realm, - owner_key_pem: owner_key_pem, - cbor_ownership_voucher: cbor_ownership_voucher + session: session } do - insert_voucher(realm, owner_key_pem, cbor_ownership_voucher, session.guid) - request_body = Session.encrypt_and_sign(session, CBOR.encode(%{prove: "device"})) conn = post(conn, path, request_body) assert {100, id} == assert_cbor_error(conn) diff --git a/apps/astarte_pairing/test/astarte_pairing_web/controllers/owner_key_controller_test.exs b/apps/astarte_pairing/test/astarte_pairing_web/controllers/owner_key_controller_test.exs index fab1f5d2f..2e8b15040 100644 --- a/apps/astarte_pairing/test/astarte_pairing_web/controllers/owner_key_controller_test.exs +++ b/apps/astarte_pairing/test/astarte_pairing_web/controllers/owner_key_controller_test.exs @@ -206,19 +206,17 @@ defmodule Astarte.PairingWeb.Controllers.OwnerKeyControllerTest do keys = Jason.decode!(keys) - assert keys == [ - %{ - "es256" => [ - "key_to_create", - "key_to_create1", - "key_to_create2", - "key_to_create3" - ] - }, - %{"es384" => []}, - %{"rs256" => []}, - %{"rs384" => []} - ] + assert keys == %{ + "es256" => [ + "key_to_create", + "key_to_create1", + "key_to_create2", + "key_to_create3" + ], + "es384" => [], + "rs256" => [], + "rs384" => [] + } end end diff --git a/apps/astarte_pairing/test/astarte_pairing_web/controllers/ownership_voucher_controller_test.exs b/apps/astarte_pairing/test/astarte_pairing_web/controllers/ownership_voucher_controller_test.exs index 6fd32890c..043cf6ecd 100644 --- a/apps/astarte_pairing/test/astarte_pairing_web/controllers/ownership_voucher_controller_test.exs +++ b/apps/astarte_pairing/test/astarte_pairing_web/controllers/ownership_voucher_controller_test.exs @@ -21,9 +21,7 @@ defmodule Astarte.PairingWeb.Controllers.OwnershipVoucherControllerTest do use Astarte.Cases.Data use Mimic - alias Astarte.FDO.Rendezvous alias Astarte.Pairing.Config - alias Astarte.Pairing.Queries alias Astarte.Secrets alias Astarte.Secrets.Key @@ -31,115 +29,72 @@ defmodule Astarte.PairingWeb.Controllers.OwnershipVoucherControllerTest do @sample_key_name "owner_key" - @sample_owner_public_key_pem """ - -----BEGIN PUBLIC KEY----- - MFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAES+TkA7VtJQv9YQ75yl5btXKR/cso - yfLzYWUTgxViGMfJkvql4W3zrtRaVPU9I06TOHFC2Mwy+9S3A7UWv/EWtg== - -----END PUBLIC KEY----- + @sample_private_key_pem """ + -----BEGIN EC PRIVATE KEY----- + MHcCAQEEIPKdthyV+2F5gPIqHyiQ9Wi1Y01r66/BbvnILFWehTRToAoGCCqGSM49 + AwEHoUQDQgAEGwrCWU3M3/Cxk0jMB4TmyQEaXLp+tqX42GsmeZ+7jeEWjHLFmEYH + LC/GgcMr88CXA3/i64k0iiIMRRQ3osnV/A== + -----END EC PRIVATE KEY----- """ - @sample_params %{ - data: %{ - "ownership_voucher" => sample_voucher(), - "private_key" => sample_private_key() - } - } + @sample_ownership_voucher_pem """ + -----BEGIN OWNERSHIP VOUCHER----- + hRhlWL6GGGVQsfHY8DqfTPi0ayOpTPxm54GEggNDGR+SggJFRH8AAAGCBEMZH5KC + DEEBa3AyNTYtZGV2aWNlgwoBWFswWTATBgcqhkjOPQIBBggqhkjOPQMBBwNCAAQb + CsJZTczf8LGTSMwHhObJARpcun62pfjYayZ5n7uN4RaMcsWYRgcsL8aBwyvzwJcD + f+LriTSKIgxFFDeiydX8gi9YINH8REeqEOOpCpH9Arx8yt1/2ZKICO5s8mo4AW2h + Gfz2ggVYIENgeEjaDkp0mbAiC/jro5IUx9j0jtTEUNLm8M74YleDgVkBHjCCARow + gcGgAwIBAgIDAeJAMAoGCCqGSM49BAMCMBYxFDASBgNVBAMMC1Rlc3QgRGV2aWNl + MB4XDTI0MDEwMTAwMDAwMFoXDTM0MDEwMTAwMDAwMFowFjEUMBIGA1UEAwwLVGVz + dCBEZXZpY2UwWTATBgcqhkjOPQIBBggqhkjOPQMBBwNCAAQbCsJZTczf8LGTSMwH + hObJARpcun62pfjYayZ5n7uN4RaMcsWYRgcsL8aBwyvzwJcDf+LriTSKIgxFFDei + ydX8MAoGCCqGSM49BAMCA0gAMEUCIF+IuxL2kt310Im+OjbA/lNNG73qUX1CH22i + 5/WGPHz8AiEAuzPBY60mRQHyetxjA4Lx+Wkdm7NWWxzdfv+6uDhVhOiB0oRDoQEm + oFichIIvWCBSpkHIqibg9oEqb9tnSTB0ABsn9mji099pWZIcvH0WdoIvWCCLI3XG + 98ZneM2FDlIJC7dH8MZSxltPfu4TkN7z6GIo1vaDCgNYTaUieCAWjHLFmEYHLC/G + gcMr88CXA3/i64k0iiIMRRQ3osnV/CF4IBsKwllNzN/wsZNIzAeE5skBGly6fral + +NhrJnmfu43hIAEBAgMmWEChODtvywnaQNZqZtAi+ukIDh06ZQxYk/BZ72qtX4Qt + KgRKDMZ26gaTvdOSYOiWl+hL0gCbdyhXp5dySgYsHYEn + -----END OWNERSHIP VOUCHER----- + """ @sample_load_params %{ data: %{ "ownership_voucher" => sample_voucher(), - "key_name" => @sample_key_name + "key_name" => @sample_key_name, + "key_algorithm" => "es256" } } setup :verify_on_exit! - describe "/ownership" do - setup :ownership - - test "stores the ownership voucher", context do - %{auth_conn: conn, create_path: path, realm_name: realm_name} = context - - conn - |> post(path, @sample_params) - |> response(200) - - assert {:ok, _} = Queries.get_ownership_voucher(realm_name, sample_device_guid()) - end - - test "stores the owner private key", context do - %{auth_conn: conn, create_path: path, realm_name: realm_name} = context - - conn - |> post(path, @sample_params) - |> response(200) - - assert {:ok, _} = Queries.get_owner_private_key(realm_name, sample_device_guid()) - end - - test "starts the to0 protocol", context do - %{auth_conn: conn, create_path: path} = context - sample_nonce = nonce() |> Enum.at(0) - - Rendezvous - |> expect(:send_hello, fn -> {:ok, %{nonce: sample_nonce, headers: []}} end) - |> expect(:register_ownership, fn _body, _headers -> {:ok, 3600} end) - - conn - |> post(path, @sample_params) - |> response(200) - end - - test "returns a 404 error if FDO feature is disabled", context do - %{auth_conn: conn, create_path: path} = context - - stub(Config, :enable_fdo!, fn -> false end) - - conn - |> post(path, @sample_params) - |> response(404) - end - end - - defp ownership(context) do - %{auth_conn: conn, realm_name: realm_name} = context - create_path = ownership_voucher_path(conn, :create, realm_name) - sample_nonce = nonce() |> Enum.at(0) - - Rendezvous - |> stub(:send_hello, fn -> {:ok, %{nonce: sample_nonce, headers: []}} end) - |> stub(:register_ownership, fn _body, _headers -> {:ok, 3600} end) - - %{create_path: create_path} - end - describe "/fdo/ownership_vouchers" do setup :register_setup test "returns 200 with the owner public key on a valid matching key", context do %{auth_conn: conn, register_path: path, realm_name: realm_name} = context - stub(Secrets, :create_namespace, fn ^realm_name, :es256 -> - {:ok, "fdo_owner_keys/#{realm_name}/ecdsa-p256"} - end) + {:ok, owner_cose_key} = COSE.Keys.from_pem(@sample_private_key_pem) + {:ok, namespace} = Secrets.create_namespace(realm_name, :es256) + :ok = Secrets.import_key(@sample_key_name, :es256, owner_cose_key, namespace: namespace) - stub(Secrets, :get_key, fn @sample_key_name, _opts -> - {:ok, - %Key{ - name: @sample_key_name, - namespace: "fdo_owner_keys/#{realm_name}/ecdsa-p256", - alg: :es256, - public_pem: @sample_owner_public_key_pem - }} - end) + {:ok, %Key{public_pem: expected_public_key}} = + Secrets.get_key(@sample_key_name, namespace: namespace) + + params = %{ + data: %{ + "ownership_voucher" => @sample_ownership_voucher_pem, + "key_name" => @sample_key_name, + "key_algorithm" => "es256" + } + } body = conn - |> post(path, @sample_load_params) + |> post(path, params) |> json_response(200) - assert get_in(body, ["data", "public_key"]) == @sample_owner_public_key_pem - assert get_in(body, ["data", "guid"]) == UUID.binary_to_string!(sample_device_guid()) + assert get_in(body, ["data", "public_key"]) == expected_public_key end test "returns 422 when the ownership_voucher field is missing", context do @@ -241,7 +196,7 @@ defmodule Astarte.PairingWeb.Controllers.OwnershipVoucherControllerTest do assert %{"es256" => [@sample_key_name, "another_key"]} = get_in(body, ["data"]) end - test "returns 200 with an empty list when no keys are registered", context do + test "returns 200 with an empty key list when no keys are registered", context do %{auth_conn: conn, path: path, realm_name: realm_name} = context stub(Secrets, :create_namespace, fn ^realm_name, :es256 -> diff --git a/apps/astarte_pairing/test/support/cases/fdo_session.ex b/apps/astarte_pairing/test/support/cases/fdo_session.ex index b94f8a70c..b29fccb91 100644 --- a/apps/astarte_pairing/test/support/cases/fdo_session.ex +++ b/apps/astarte_pairing/test/support/cases/fdo_session.ex @@ -40,6 +40,7 @@ defmodule Astarte.Cases.FDOSession do alias Astarte.FDO.Core.OwnerOnboarding.SessionKey alias Astarte.FDO.Core.OwnershipVoucher alias Astarte.FDO.OwnerOnboarding.KeyExchangeStrategy + alias Astarte.Secrets alias COSE.Keys.{ECC, RSA} import Astarte.Helpers.Database @@ -75,15 +76,31 @@ defmodule Astarte.Cases.FDOSession do cbor_ownership_voucher = OwnershipVoucher.cbor_encode(ownership_voucher) device_id = Device.random_device_id() - insert_voucher( - context.realm_name, - owner_key_pem, - cbor_ownership_voucher, - device_id - ) + key_alg = + case key_type do + "EC256" -> :es256 + "EC384" -> :es384 + "RSA2048" -> :rs256 + "RSA3072" -> :rs384 + end + + {:ok, namespace} = Secrets.create_namespace(context.realm_name, key_alg) + + Secrets.import_key(key_type, key_alg, owner_key_struct, namespace: namespace) + + {:ok, owner_key} = Secrets.get_key(key_type, namespace: namespace) + + attrs = %{ + key_name: key_type, + key_algorithm: key_alg, + voucher_data: cbor_ownership_voucher, + guid: device_id + } + + insert_voucher(context.realm_name, attrs) %{ - owner_key: owner_key_struct, + owner_key: owner_key, owner_key_pem: owner_key_pem, ownership_voucher: ownership_voucher, cbor_ownership_voucher: cbor_ownership_voucher, @@ -102,7 +119,7 @@ defmodule Astarte.Cases.FDOSession do if kex_name not in @allowed_kex_name_tag_values, do: raise("unsupported kex_name tag value: #{kex_name}") - if KeyExchangeStrategy.validate(kex_name, context.owner_key) != :ok, + if KeyExchangeStrategy.validate(kex_name, context.owner_key.alg) != :ok, do: raise( "unsupported association owner key type #{context.owner_key.alg} <-> KEX alg #{kex_name}" diff --git a/apps/astarte_pairing/test/support/helpers/database.ex b/apps/astarte_pairing/test/support/helpers/database.ex index b9badce87..3d51e5d1e 100644 --- a/apps/astarte_pairing/test/support/helpers/database.ex +++ b/apps/astarte_pairing/test/support/helpers/database.ex @@ -83,9 +83,15 @@ defmodule Astarte.Helpers.Database do @create_ownership_vouchers_table """ CREATE TABLE :keyspace.ownership_vouchers ( - private_key blob, - voucher_data blob, guid blob, + voucher_data blob, + output_voucher blob, + replacement_guid blob, + replacement_rendezvous_info blob, + replacement_public_key blob, + key_name varchar, + key_algorithm int, + user_id blob, PRIMARY KEY (guid) ); """ @@ -112,9 +118,6 @@ defmodule Astarte.Helpers.Database do device_service_info map, blob>, owner_service_info list, last_chunk_sent int, - replacement_guid blob, - replacement_rv_info blob, - replacement_pub_key blob, replacement_hmac blob, PRIMARY KEY (guid) ) diff --git a/apps/astarte_pairing/test/support/helpers/fdo.ex b/apps/astarte_pairing/test/support/helpers/fdo.ex index ed2107bf2..29b6f08ab 100644 --- a/apps/astarte_pairing/test/support/helpers/fdo.ex +++ b/apps/astarte_pairing/test/support/helpers/fdo.ex @@ -24,11 +24,11 @@ defmodule Astarte.Helpers.FDO do import StreamData alias Astarte.DataAccess.FDO.OwnershipVoucher, as: DBOwnershipVoucher - alias Astarte.DataAccess.FDO.OwnershipVoucher.CreateRequest alias Astarte.DataAccess.Realms.Realm alias Astarte.DataAccess.Repo alias Astarte.FDO.Core.Hash alias Astarte.FDO.Core.OwnershipVoucher + alias Astarte.FDO.Core.OwnershipVoucher.CreateRequest alias Astarte.FDO.Core.OwnershipVoucher.RendezvousInfo alias Astarte.FDO.Core.OwnershipVoucher.RendezvousInfo.RendezvousDirective alias Astarte.FDO.Core.OwnershipVoucher.RendezvousInfo.RendezvousInstr @@ -169,12 +169,9 @@ defmodule Astarte.Helpers.FDO do CBOR.encode([%CBOR.Tag{tag: :bytes, value: nonce}]) end - def insert_voucher(realm_name, private_key, cbor_voucher, guid) do - %DBOwnershipVoucher{ - voucher_data: cbor_voucher, - private_key: private_key, - guid: guid - } + def insert_voucher(realm_name, attrs) when is_map(attrs) do + %DBOwnershipVoucher{} + |> DBOwnershipVoucher.changeset(attrs) |> Repo.insert(prefix: Realm.keyspace_name(realm_name)) end @@ -234,22 +231,22 @@ defmodule Astarte.Helpers.FDO do pub_key_body = case encoding do - :x5chain -> [cert_der] - :x509 -> cert_der + :x5chain -> + [cert_der] + + :x509 -> + :public_key.der_encode( + :SubjectPublicKeyInfo, + {:SubjectPublicKeyInfo, + {:AlgorithmIdentifier, {1, 2, 840, 10_045, 2, 1}, + :public_key.der_encode(:EcpkParameters, {:namedCurve, oid})}, device_pub_key_point} + ) end # Entry Payload (COSE Key) - entry_payload_bin = create_cose_key_entry_payload(cose_key, curve) - guid_raw = UUID.uuid4(:raw) - chain_data_to_hash = - case pub_key_body do - list when is_list(list) -> Enum.join(list) - bin -> bin - end - - cert_chain_hash_struct = Hash.new(hash_alg, chain_data_to_hash) + cert_chain_hash_struct = Hash.new(hash_alg, cert_der) rv_info = %RendezvousInfo{ directives: [ @@ -282,6 +279,16 @@ defmodule Astarte.Helpers.FDO do hmac_bytes = :crypto.strong_rand_bytes(hmac_len) hmac_struct = %Hash{type: hmac_type, hash: hmac_bytes} + header_cbor = OwnershipVoucher.Header.cbor_encode(header_struct) + hmac_cbor = Hash.encode_cbor(hmac_struct) + + header_info = guid_raw <> header_struct.device_info + hash_hdr = Hash.new(hash_alg, header_info) + + hash_prev = Hash.new(hash_alg, header_cbor <> hmac_cbor) + + entry_payload_bin = create_cose_key_entry_payload(cose_key, curve, hash_prev, hash_hdr) + protected_header = %{alg: cose_alg} unprotected_header_map = %{} @@ -345,7 +352,7 @@ defmodule Astarte.Helpers.FDO do :public_key.pkix_sign(tbs_cert, priv_key) end - defp create_cose_key_entry_payload(%ECC{x: x, y: y}, curve) do + defp create_cose_key_entry_payload(%ECC{x: x, y: y}, curve, hash_prev, hash_hdr) do {cose_crv, cose_alg} = if curve == :p256, do: {1, -7}, else: {2, -35} @@ -368,7 +375,7 @@ defmodule Astarte.Helpers.FDO do %CBOR.Tag{tag: :bytes, value: key_bytes} ] - entry_list = [<<>>, <<>>, <<>>, fdo_public_key] + entry_list = [Hash.encode(hash_prev), Hash.encode(hash_hdr), nil, fdo_public_key] CBOR.encode(entry_list) end diff --git a/apps/astarte_pairing/test/test_helper.exs b/apps/astarte_pairing/test/test_helper.exs index c7eee2a80..d96ea1be4 100644 --- a/apps/astarte_pairing/test/test_helper.exs +++ b/apps/astarte_pairing/test/test_helper.exs @@ -28,6 +28,7 @@ Mimic.copy(Astarte.FDO.OwnerOnboarding.DeviceAttestation) Mimic.copy(Astarte.FDO.OwnershipVoucher) Mimic.copy(Astarte.FDO.Rendezvous) Mimic.copy(Astarte.FDO.Rendezvous.Client) +Mimic.copy(Astarte.FDO.TO0) Mimic.copy(Astarte.FDO.ServiceInfo) Mimic.copy(Astarte.Pairing.Config) Mimic.copy(Astarte.Pairing.Queries) diff --git a/libs/astarte_data_access/lib/astarte_data_access/fdo/db_record.ex b/libs/astarte_data_access/lib/astarte_data_access/fdo/ownership_voucher.ex similarity index 58% rename from libs/astarte_data_access/lib/astarte_data_access/fdo/db_record.ex rename to libs/astarte_data_access/lib/astarte_data_access/fdo/ownership_voucher.ex index 5441a2f64..aac15a967 100644 --- a/libs/astarte_data_access/lib/astarte_data_access/fdo/db_record.ex +++ b/libs/astarte_data_access/lib/astarte_data_access/fdo/ownership_voucher.ex @@ -21,20 +21,39 @@ defmodule Astarte.DataAccess.FDO.OwnershipVoucher do Ecto schema for persisting ownership voucher binary data to the database. """ use TypedEctoSchema + import Ecto.Changeset + + alias Astarte.DataAccess.FDO.CBOR.Encoded, as: CBOREncoded alias Astarte.DataAccess.FDO.OwnershipVoucher + alias Astarte.FDO.Core.OwnershipVoucher.RendezvousInfo + alias Astarte.FDO.Core.PublicKey @primary_key false typed_schema "ownership_vouchers" do - field :private_key, :binary - field :voucher_data, :binary field :guid, Astarte.DataAccess.UUID, primary_key: true + field :voucher_data, :binary + field :output_voucher, :binary + field :user_id, :binary + field :key_name, :string + field :key_algorithm, Ecto.Enum, values: [es256: 0, es384: 1, rs256: 10, rs384: 11] + field :replacement_guid, :binary + field :replacement_rendezvous_info, CBOREncoded, using: RendezvousInfo + field :replacement_public_key, CBOREncoded, using: PublicKey end @doc false def changeset(%OwnershipVoucher{} = record, attrs) do record - |> cast(attrs, [:private_key, :voucher_data, :guid]) - |> validate_required([:private_key, :voucher_data, :guid]) + |> cast(attrs, [ + :key_name, + :voucher_data, + :guid, + :key_algorithm, + :replacement_guid, + :replacement_rendezvous_info, + :replacement_public_key + ]) + |> validate_required([:key_name, :key_algorithm, :voucher_data, :guid]) end end diff --git a/libs/astarte_data_access/lib/astarte_data_access/fdo/queries.ex b/libs/astarte_data_access/lib/astarte_data_access/fdo/queries.ex index b30e705ad..38cb67abb 100644 --- a/libs/astarte_data_access/lib/astarte_data_access/fdo/queries.ex +++ b/libs/astarte_data_access/lib/astarte_data_access/fdo/queries.ex @@ -25,7 +25,7 @@ defmodule Astarte.DataAccess.FDO.Queries do alias Astarte.DataAccess.Consistency alias Astarte.DataAccess.Device - alias Astarte.DataAccess.FDO.OwnershipVoucher, as: OwnershipVoucher + alias Astarte.DataAccess.FDO.OwnershipVoucher alias Astarte.DataAccess.FDO.TO2Session alias Astarte.DataAccess.Realms.Realm alias Astarte.DataAccess.Repo @@ -58,35 +58,50 @@ defmodule Astarte.DataAccess.FDO.Queries do Repo.fetch(query, guid, consistency: consistency) end - def get_owner_private_key(realm_name, guid) do + def get_owner_key_params(realm_name, guid) do keyspace_name = Realm.keyspace_name(realm_name) query = - from o in OwnershipVoucher, + from OwnershipVoucher, prefix: ^keyspace_name, - select: o.private_key + select: [:key_name, :key_algorithm] consistency = Consistency.domain_model(:read) - Repo.fetch(query, guid, consistency: consistency) + with {:ok, ov} <- Repo.fetch(query, guid, consistency: consistency) do + result = %{name: ov.key_name, algorithm: ov.key_algorithm} + {:ok, result} + end + end + + def get_replacement_data(realm_name, guid) do + keyspace = Realm.keyspace_name(realm_name) + + fields = [:replacement_guid, :replacement_rendezvous_info, :replacement_public_key] + + query = + from OwnershipVoucher, + select: ^fields + + consistency = Consistency.domain_model(:read) + opts = [consistency: consistency, prefix: keyspace] + + with {:ok, data} <- Repo.fetch(query, guid, opts) do + result = Map.take(data, fields) + {:ok, result} + end end def create_ownership_voucher( realm_name, - guid, - cbor_ownership_voucher, - owner_private_key, - ttl + attrs ) do keyspace_name = Realm.keyspace_name(realm_name) - opts = [prefix: keyspace_name, consistency: Consistency.device_info(:write), ttl: ttl] + opts = [prefix: keyspace_name, consistency: Consistency.device_info(:write)] - %OwnershipVoucher{ - voucher_data: cbor_ownership_voucher, - private_key: owner_private_key, - guid: guid - } + %OwnershipVoucher{} + |> OwnershipVoucher.changeset(attrs) |> Repo.insert(opts) end @@ -99,16 +114,21 @@ defmodule Astarte.DataAccess.FDO.Queries do |> Repo.delete(prefix: keyspace) end - def replace_ownership_voucher( + def add_output_voucher( realm_name, guid, - new_voucher, - owner_private_key, - ttl + new_voucher ) do - with {:ok, _} <- delete_ownership_voucher(realm_name, guid) do - create_ownership_voucher(realm_name, guid, new_voucher, owner_private_key, ttl) - end + keyspace = Realm.keyspace_name(realm_name) + consistency = Consistency.device_info(:write) + opts = [prefix: keyspace, consistency: consistency] + + result = + %OwnershipVoucher{guid: guid} + |> Ecto.Changeset.change(output_voucher: new_voucher) + |> Repo.update(opts) + + with {:ok, _} <- result, do: :ok end def store_session(realm_name, guid, session) do @@ -163,14 +183,8 @@ defmodule Astarte.DataAccess.FDO.Queries do update_session(realm_name, guid, updates) end - def session_add_replacement_info(realm_name, guid, replacement_guid, rv_info, pub_key, hmac) do - updates = [ - replacement_guid: replacement_guid, - replacement_rv_info: rv_info, - replacement_pub_key: pub_key, - replacement_hmac: hmac - ] - + def session_add_replacement_hmac(realm_name, guid, hmac) do + updates = [replacement_hmac: hmac] update_session(realm_name, guid, updates) end diff --git a/libs/astarte_data_access/lib/astarte_data_access/fdo/to2_session.ex b/libs/astarte_data_access/lib/astarte_data_access/fdo/to2_session.ex index cecb38eb4..c48431249 100644 --- a/libs/astarte_data_access/lib/astarte_data_access/fdo/to2_session.ex +++ b/libs/astarte_data_access/lib/astarte_data_access/fdo/to2_session.ex @@ -24,8 +24,6 @@ defmodule Astarte.DataAccess.FDO.TO2Session do alias Astarte.DataAccess.FDO.CBOR.Encoded, as: CBOREncoded alias Astarte.FDO.Core.Hash - alias Astarte.FDO.Core.OwnershipVoucher.RendezvousInfo - alias Astarte.FDO.Core.PublicKey alias COSE.Keys.Symmetric @ciphers [ @@ -73,12 +71,6 @@ defmodule Astarte.DataAccess.FDO.TO2Session do field :owner_service_info, {:array, :binary} field :last_chunk_sent, :integer - field :replacement_guid, :binary - - field :replacement_rv_info, CBOREncoded, using: RendezvousInfo - - field :replacement_pub_key, CBOREncoded, using: PublicKey - field :replacement_hmac, CBOREncoded, using: Hash end end diff --git a/libs/astarte_data_access/test/fdo/db_record_test.exs b/libs/astarte_data_access/test/fdo/ownership_voucher/ownership_voucher_test.exs similarity index 65% rename from libs/astarte_data_access/test/fdo/db_record_test.exs rename to libs/astarte_data_access/test/fdo/ownership_voucher/ownership_voucher_test.exs index 3e08e47ec..0185e82a5 100644 --- a/libs/astarte_data_access/test/fdo/db_record_test.exs +++ b/libs/astarte_data_access/test/fdo/ownership_voucher/ownership_voucher_test.exs @@ -16,15 +16,22 @@ # limitations under the License. # -defmodule Astarte.DataAccess.FDO.OwnershipVoucher.DBRecordTest do +defmodule Astarte.DataAccess.FDO.OwnershipVoucher.OwnershipVoucherTest do use ExUnit.Case, async: true alias Astarte.DataAccess.FDO.OwnershipVoucher + alias Astarte.FDO.Core.OwnershipVoucher.RendezvousInfo + alias Astarte.FDO.Core.PublicKey @valid_attrs %{ guid: :crypto.strong_rand_bytes(16), voucher_data: :crypto.strong_rand_bytes(64), - private_key: :crypto.strong_rand_bytes(32) + key_name: "key", + key_algorithm: :es256, + replacement_guid: :crypto.strong_rand_bytes(16), + replacement_rv_info: %RendezvousInfo{directives: []} |> RendezvousInfo.encode_cbor(), + replacement_pub_key: + %PublicKey{type: :secp256r1, encoding: :x5chain, body: []} |> PublicKey.encode_cbor() } describe "changeset/2" do @@ -50,19 +57,27 @@ defmodule Astarte.DataAccess.FDO.OwnershipVoucher.DBRecordTest do assert Keyword.has_key?(changeset.errors, :voucher_data) end - test "is invalid when private_key is missing" do - attrs = Map.delete(@valid_attrs, :private_key) + test "is invalid when key_name is missing" do + attrs = Map.delete(@valid_attrs, :key_name) changeset = OwnershipVoucher.changeset(%OwnershipVoucher{}, attrs) refute changeset.valid? - assert Keyword.has_key?(changeset.errors, :private_key) + assert Keyword.has_key?(changeset.errors, :key_name) + end + + test "is invalid when key_algorithm is missing" do + attrs = Map.delete(@valid_attrs, :key_algorithm) + changeset = OwnershipVoucher.changeset(%OwnershipVoucher{}, attrs) + + refute changeset.valid? + assert Keyword.has_key?(changeset.errors, :key_algorithm) end test "applies changes when valid" do changeset = OwnershipVoucher.changeset(%OwnershipVoucher{}, @valid_attrs) assert changeset.changes.voucher_data == @valid_attrs.voucher_data - assert changeset.changes.private_key == @valid_attrs.private_key + assert changeset.changes.guid == @valid_attrs.guid end end end diff --git a/libs/astarte_data_access/test/fdo/queries_test.exs b/libs/astarte_data_access/test/fdo/queries_test.exs index 3ff339bd4..8f157e1a7 100644 --- a/libs/astarte_data_access/test/fdo/queries_test.exs +++ b/libs/astarte_data_access/test/fdo/queries_test.exs @@ -21,8 +21,15 @@ defmodule Astarte.DataAccess.FDO.QueriesTest do alias Astarte.Core.Device, as: CoreDevice alias Astarte.DataAccess.DatabaseTestHelper + alias Astarte.DataAccess.FDO.OwnershipVoucher alias Astarte.DataAccess.FDO.Queries alias Astarte.DataAccess.FDO.TO2Session + alias Astarte.DataAccess.Realms.Realm + alias Astarte.DataAccess.Repo + alias Astarte.FDO.Core.OwnershipVoucher.RendezvousInfo + alias Astarte.FDO.Core.OwnershipVoucher.RendezvousInfo.RendezvousDirective + alias Astarte.FDO.Core.OwnershipVoucher.RendezvousInfo.RendezvousInstr + alias Astarte.FDO.Core.PublicKey @realm "autotestrealm" @@ -44,25 +51,21 @@ defmodule Astarte.DataAccess.FDO.QueriesTest do defp random_guid, do: :crypto.strong_rand_bytes(16) defp sample_voucher, do: :crypto.strong_rand_bytes(32) - defp sample_private_key, do: :crypto.strong_rand_bytes(64) describe "ownership voucher" do test "create and get voucher data" do guid = random_guid() voucher = sample_voucher() - key = sample_private_key() - assert {:ok, _} = Queries.create_ownership_voucher(@realm, guid, voucher, key, 3600) - assert {:ok, ^voucher} = Queries.get_ownership_voucher(@realm, guid) - end - - test "create and get owner private key" do - guid = random_guid() - voucher = sample_voucher() - key = sample_private_key() + attrs = %{ + guid: guid, + voucher_data: voucher, + key_name: "test_key_name", + key_algorithm: :es256 + } - assert {:ok, _} = Queries.create_ownership_voucher(@realm, guid, voucher, key, 3600) - assert {:ok, ^key} = Queries.get_owner_private_key(@realm, guid) + assert {:ok, _} = Queries.create_ownership_voucher(@realm, attrs) + assert {:ok, ^voucher} = Queries.get_ownership_voucher(@realm, guid) end test "get voucher returns error when not found" do @@ -73,9 +76,15 @@ defmodule Astarte.DataAccess.FDO.QueriesTest do test "delete ownership voucher" do guid = random_guid() voucher = sample_voucher() - key = sample_private_key() - assert {:ok, _} = Queries.create_ownership_voucher(@realm, guid, voucher, key, 3600) + attrs = %{ + guid: guid, + voucher_data: voucher, + key_name: "test_key_name", + key_algorithm: :es256 + } + + assert {:ok, _} = Queries.create_ownership_voucher(@realm, attrs) assert {:ok, _} = Queries.delete_ownership_voucher(@realm, guid) assert {:error, _} = Queries.get_ownership_voucher(@realm, guid) end @@ -84,10 +93,25 @@ defmodule Astarte.DataAccess.FDO.QueriesTest do guid = random_guid() old_voucher = sample_voucher() new_voucher = sample_voucher() - key = sample_private_key() - assert {:ok, _} = Queries.create_ownership_voucher(@realm, guid, old_voucher, key, 3600) - assert {:ok, _} = Queries.replace_ownership_voucher(@realm, guid, new_voucher, key, 3600) + old_attrs = %{ + guid: guid, + voucher_data: old_voucher, + key_name: "test_key_name", + key_algorithm: :es256 + } + + assert {:ok, _} = Queries.create_ownership_voucher(@realm, old_attrs) + + new_attrs = %{ + guid: guid, + voucher_data: new_voucher, + key_name: "test_key_name", + key_algorithm: :es256 + } + + assert {:ok, _} = Queries.create_ownership_voucher(@realm, new_attrs) + assert {:ok, ^new_voucher} = Queries.get_ownership_voucher(@realm, guid) end end @@ -175,26 +199,6 @@ defmodule Astarte.DataAccess.FDO.QueriesTest do assert {:ok, fetched} = Queries.fetch_session(@realm, guid) assert fetched.owner_service_info == owner_service_info end - - test "session_add_replacement_info" do - guid = random_guid() - replacement_guid = random_guid() - - assert :ok = Queries.store_session(@realm, guid, %TO2Session{guid: guid}) - - assert :ok = - Queries.session_add_replacement_info( - @realm, - guid, - replacement_guid, - nil, - nil, - nil - ) - - assert {:ok, fetched} = Queries.fetch_session(@realm, guid) - assert fetched.replacement_guid == replacement_guid - end end describe "remove_device_ttl/2" do @@ -215,4 +219,83 @@ defmodule Astarte.DataAccess.FDO.QueriesTest do assert {:error, :device_not_found} = Queries.remove_device_ttl(@realm, missing_id) end end + + describe "get_owner_key_params/2" do + setup :setup_ov_entry + + test "returns a map with key name and algorithm", context do + %{guid: guid, key_name: key_name, key_algorithm: key_algorithm} = context + assert {:ok, result} = Queries.get_owner_key_params(@realm, guid) + assert %{name: key_name, algorithm: key_algorithm} == result + end + + test "returns :not_found when the guid is not found", context do + %{replacement_guid: non_existing_guid} = context + assert {:error, :not_found} = Queries.get_owner_key_params(@realm, non_existing_guid) + end + end + + describe "get_replacement_data/2" do + setup :setup_ov_entry + + test "returns replacement data", context do + %{ + guid: guid, + replacement_guid: replacement_guid, + replacement_rendezvous_info: replacement_rendezvous_info, + replacement_public_key: replacement_public_key + } = context + + assert {:ok, result} = Queries.get_replacement_data(@realm, guid) + + assert %{ + replacement_guid: replacement_guid, + replacement_rendezvous_info: replacement_rendezvous_info, + replacement_public_key: replacement_public_key + } == result + end + + test "returns :not_found when the guid is not found", context do + %{replacement_guid: non_existing_guid} = context + assert {:error, :not_found} = Queries.get_owner_key_params(@realm, non_existing_guid) + end + end + + defp setup_ov_entry(_context) do + key_name = "key#{System.unique_integer()}" + key_algorithm = :es256 + guid = :crypto.strong_rand_bytes(16) + replacement_guid = :crypto.strong_rand_bytes(16) + + replacement_directive = %RendezvousDirective{ + instructions: [%RendezvousInstr{rv_variable: :dev_only, rv_value: CBOR.encode(true)}] + } + + replacement_rendezvous_info = %RendezvousInfo{directives: [replacement_directive]} + replacement_public_key = %PublicKey{type: :secp256r1, encoding: :x509, body: "sample key"} + + ov = %OwnershipVoucher{ + guid: guid, + key_name: key_name, + key_algorithm: key_algorithm, + replacement_guid: replacement_guid, + replacement_rendezvous_info: replacement_rendezvous_info, + replacement_public_key: replacement_public_key + } + + on_exit(fn -> + Repo.delete(ov, prefix: Realm.keyspace_name(@realm)) + end) + + Repo.insert!(ov, prefix: Realm.keyspace_name(@realm)) + + %{ + guid: guid, + replacement_guid: replacement_guid, + replacement_rendezvous_info: replacement_rendezvous_info, + replacement_public_key: replacement_public_key, + key_name: key_name, + key_algorithm: key_algorithm + } + end end diff --git a/libs/astarte_data_access/test/support/database_test_helper.exs b/libs/astarte_data_access/test/support/database_test_helper.exs index 8f8f47831..923236e5b 100644 --- a/libs/astarte_data_access/test/support/database_test_helper.exs +++ b/libs/astarte_data_access/test/support/database_test_helper.exs @@ -46,10 +46,16 @@ defmodule Astarte.DataAccess.DatabaseTestHelper do @create_ownership_vouchers_table """ CREATE TABLE autotestrealm.ownership_vouchers ( - private_key blob, - voucher_data blob, - guid blob, - PRIMARY KEY (guid) + guid blob, + voucher_data blob, + output_voucher blob, + replacement_guid blob, + replacement_rendezvous_info blob, + replacement_public_key blob, + key_name varchar, + key_algorithm int, + user_id blob, + PRIMARY KEY (guid) ); """ diff --git a/libs/astarte_fdo/config/test.exs b/libs/astarte_fdo/config/test.exs index 8985a199d..1b6c8c3cf 100644 --- a/libs/astarte_fdo/config/test.exs +++ b/libs/astarte_fdo/config/test.exs @@ -17,3 +17,6 @@ # import Config + +config :astarte_secrets, bao_authentication_mechanism: :token +config :astarte_secrets, bao_token: "astarte_token" diff --git a/libs/astarte_fdo/gen_voucher.exs b/libs/astarte_fdo/gen_voucher.exs index 735876c3e..673f61024 100644 --- a/libs/astarte_fdo/gen_voucher.exs +++ b/libs/astarte_fdo/gen_voucher.exs @@ -26,7 +26,14 @@ owner_key_pem = File.read!(key_path) {:ok, owner_key} = COSE.Keys.from_pem(owner_key_pem) -{voucher, _} = Astarte.FDO.Helpers.generate_voucher_data_and_pem(owner_key: owner_key) +{voucher, _} = + case owner_key do + %COSE.Keys.RSA{} -> + Astarte.FDO.Helpers.generate_rsapss_data_and_pem(owner_key: owner_key) + + %COSE.Keys.ECC{} -> + Astarte.FDO.Helpers.generate_voucher_data_and_pem(owner_key: owner_key) + end voucher_pem = Astarte.FDO.Helpers.voucher_to_pem(voucher) diff --git a/libs/astarte_fdo/lib/owner_onboarding.ex b/libs/astarte_fdo/lib/owner_onboarding.ex index d4a38ea6b..e8fe4f3ee 100644 --- a/libs/astarte_fdo/lib/owner_onboarding.ex +++ b/libs/astarte_fdo/lib/owner_onboarding.ex @@ -43,20 +43,20 @@ defmodule Astarte.FDO.OwnerOnboarding do alias Astarte.FDO.OwnerOnboarding.DeviceAttestation alias Astarte.FDO.OwnerOnboarding.KeyExchangeStrategy alias Astarte.FDO.OwnershipVoucher + alias Astarte.Secrets require Logger @max_owner_message_size 65_535 @rsa_public_exponent 65_537 - @one_week 604_800 def hello_device(realm_name, cbor_hello_device) do with {:ok, hello_device} <- HelloDevice.decode(cbor_hello_device), guid = hello_device.guid, {:ok, ownership_voucher} <- OwnershipVoucher.fetch(realm_name, guid), - {:ok, owner_private_key} <- fetch_owner_private_key(realm_name, guid), + {:ok, owner_key} <- Secrets.get_key_for_guid(realm_name, guid), {:ok, pub_key} <- OwnershipVoucher.owner_public_key(ownership_voucher), - :ok <- KeyExchangeStrategy.validate(hello_device.kex_name, owner_private_key), + :ok <- KeyExchangeStrategy.validate(hello_device.kex_name, owner_key.alg), {:ok, token, session} <- Session.new( realm_name, @@ -90,7 +90,7 @@ defmodule Astarte.FDO.OwnerOnboarding do prove_ovh, session.prove_dv_nonce, encoded_pub_key, - owner_private_key + owner_key ) do {:ok, message} -> {:ok, token, message} @@ -106,12 +106,6 @@ defmodule Astarte.FDO.OwnerOnboarding do end end - defp fetch_owner_private_key(realm_name, guid) do - with {:ok, pem_key} <- Queries.get_owner_private_key(realm_name, guid) do - COSE.Keys.from_pem(pem_key) - end - end - def generate_rsa_2048_key do options = {:rsa, 2048, @rsa_public_exponent} @@ -140,21 +134,22 @@ defmodule Astarte.FDO.OwnerOnboarding do def prove_device(realm_name, body, session) do guid = session.guid - # TODO: credential reuse requires also Owner2Key and/or rv info to be changed - # for credential reuse; so far, there is no API to do so, so it-s limited to the guid - with {:ok, ownership_voucher} <- OwnershipVoucher.fetch(realm_name, guid), - {:ok, private_key} <- Queries.get_owner_private_key(realm_name, guid), + {:ok, ov_entry} <- Queries.get_replacement_data(realm_name, guid), + {:ok, owner_key} <- Secrets.get_key_for_guid(realm_name, guid), {:ok, owner_public_key} <- OwnershipVoucher.owner_public_key(ownership_voucher) do - rendezvous_info = ownership_voucher.header.rendezvous_info + next_guid = ov_entry.replacement_guid || guid + + next_rv_info = + ov_entry.replacement_rendezvous_info || ownership_voucher.header.rendezvous_info - {:ok, private_key} = COSE.Keys.from_pem(private_key) + next_owner_pub_key = ov_entry.replacement_public_key || owner_public_key connection_credentials = %{ - guid: guid, - rendezvous_info: rendezvous_info, - owner_pub_key: owner_public_key, - owner_private_key: private_key, + guid: next_guid, + rendezvous_info: next_rv_info, + owner_pub_key: next_owner_pub_key, + owner_private_key: owner_key, device_info: "owned by astarte - realm #{realm_name}.#{Config.base_url_domain!()}" } @@ -164,15 +159,6 @@ defmodule Astarte.FDO.OwnerOnboarding do session, body, connection_credentials - ), - {:ok, session} <- - Session.add_replacement_info( - session, - realm_name, - guid, - rendezvous_info, - owner_public_key, - nil ) do {:ok, session, resp_msg} end @@ -212,35 +198,6 @@ defmodule Astarte.FDO.OwnerOnboarding do end end - def fetch_alg(header_map) when is_map(header_map) do - case Map.get(header_map, 1) do - -7 -> {:ok, :es256} - -8 -> {:ok, :edsdsa} - _ -> {:error, :unsupported_alg} - end - end - - def fetch_alg(binary) when is_binary(binary) do - with {:ok, map, _rest} <- CBOR.decode(binary), do: fetch_alg(map) - end - - def build_sig_structure(protected_bin, payload_bin) do - sig_struct = [ - "Signature1", - protected_bin, - # external_aad empty in FDO - <<>>, - payload_bin - ] - - {:ok, CBOR.encode(sig_struct)} - end - - def der_encode_ecdsa(r, s) do - # assuming ECDSA-Sig-Value record is available - :public_key.der_encode(:"ECDSA-Sig-Value", {:"ECDSA-Sig-Value", r, s}) - end - def build_setup_device_message(creds, setup_dv_nonce) do payload = %SetupDevicePayload{ rendezvous_info: creds.rendezvous_info, @@ -265,12 +222,9 @@ defmodule Astarte.FDO.OwnerOnboarding do {:ok, session} <- Session.add_max_owner_service_info_size(session, realm_name, max_owner_service_info_sz), {:ok, session} <- - Session.add_replacement_info( + Session.add_replacement_hmac( session, realm_name, - session.replacement_guid, - session.replacement_rv_info, - session.replacement_pub_key, replacement_hmac || session.hmac ) do response = @@ -291,33 +245,29 @@ defmodule Astarte.FDO.OwnerOnboarding do DonePayload.decode(body), :ok <- check_prove_dv_nonces_equality(prove_dv_nonce_challenge, to2_session.prove_dv_nonce), - {:ok, _device} <- Queries.remove_device_ttl(realm_name, to2_session.device_id) do + {:ok, _device} <- Queries.remove_device_ttl(realm_name, to2_session.device_id), + {:ok, ov_entry} <- Queries.get_replacement_data(realm_name, to2_session.guid) do + if not OwnershipVoucher.credential_reuse?(ov_entry) do + add_output_voucher(realm_name, ov_entry, to2_session) + end + done2_message = build_done2_message(to2_session.setup_dv_nonce) - maybe_replace_voucher(realm_name, to2_session) {:ok, done2_message} end end - defp maybe_replace_voucher(realm_name, to2_session) do - if not OwnershipVoucher.credential_reuse?(to2_session) do - with {:ok, old_voucher} <- - OwnershipVoucher.fetch(realm_name, to2_session.guid), - {:ok, new_voucher} <- - OwnershipVoucher.generate_replacement_voucher(old_voucher, to2_session), - # TODO: change this line to ensure the retrieval of latest private key - # after exposing an API to do so - {:ok, private_key} <- - Queries.get_owner_private_key(realm_name, to2_session.guid) do - cbor_voucher = CoreOwnershipVoucher.cbor_encode(new_voucher) - - Queries.replace_ownership_voucher( - realm_name, - to2_session.guid, - cbor_voucher, - private_key, - @one_week - ) - end + defp add_output_voucher(realm_name, ov_entry, to2_session) do + with {:ok, old_voucher} <- OwnershipVoucher.fetch(realm_name, to2_session.guid), + # Passiamo sia la ov_entry (per le chiavi) che to2_session (per l'HMAC) + {:ok, new_voucher} <- + OwnershipVoucher.generate_replacement_voucher(old_voucher, ov_entry, to2_session) do + cbor_voucher = CoreOwnershipVoucher.cbor_encode(new_voucher) + + Queries.add_output_voucher( + realm_name, + to2_session.guid, + cbor_voucher + ) end end diff --git a/libs/astarte_fdo/lib/owner_onboarding/key_exchange_strategy.ex b/libs/astarte_fdo/lib/owner_onboarding/key_exchange_strategy.ex index 38519218d..90c1d5b7c 100644 --- a/libs/astarte_fdo/lib/owner_onboarding/key_exchange_strategy.ex +++ b/libs/astarte_fdo/lib/owner_onboarding/key_exchange_strategy.ex @@ -22,8 +22,6 @@ defmodule Astarte.FDO.OwnerOnboarding.KeyExchangeStrategy do is compatible with the Owner's Private Key curve. """ - alias COSE.Keys.{ECC, RSA} - @ecdh256 "ECDH256" @ecdh384 "ECDH384" @dhkex14 "DHKEXid14" @@ -36,21 +34,21 @@ defmodule Astarte.FDO.OwnerOnboarding.KeyExchangeStrategy do ## Parameters - `device_kex_name`: The string identifying the suite chosen by the device (e.g., "ECDH256"). - - `owner_key`: The COSE Key struct representing the Owner's private key. + - `owner_key_alg`: The key algorithm, in [:es256, :es384, :rs256, :rs384]. """ @spec validate(String.t(), struct()) :: :ok | {:error, :invalid_message} - def validate(device_kex_name, owner_key) do - case {device_kex_name, owner_key} do - {dkn, %RSA{alg: :rs256}} when dkn in [@dhkex14, @asymkex2048] -> + def validate(device_kex_name, owner_key_alg) do + case {device_kex_name, owner_key_alg} do + {dkn, :rs256} when dkn in [@dhkex14, @asymkex2048] -> :ok - {dkn, %RSA{alg: :rs384}} when dkn in [@dhkex15, @asymkex3072] -> + {dkn, :rs384} when dkn in [@dhkex15, @asymkex3072] -> :ok - {@ecdh256, %ECC{crv: :p256}} -> + {@ecdh256, :es256} -> :ok - {@ecdh384, %ECC{crv: :p384}} -> + {@ecdh384, :es384} -> :ok _ -> diff --git a/libs/astarte_fdo/lib/owner_onboarding/session.ex b/libs/astarte_fdo/lib/owner_onboarding/session.ex index 970396385..beb3fe794 100644 --- a/libs/astarte_fdo/lib/owner_onboarding/session.ex +++ b/libs/astarte_fdo/lib/owner_onboarding/session.ex @@ -34,8 +34,6 @@ defmodule Astarte.FDO.Core.OwnerOnboarding.Session do alias Astarte.FDO.Core.OwnerOnboarding.Session alias Astarte.FDO.Core.OwnerOnboarding.SessionKey alias Astarte.FDO.Core.OwnerOnboarding.SignatureInfo - alias Astarte.FDO.Core.OwnershipVoucher.RendezvousInfo - alias Astarte.FDO.Core.PublicKey alias Astarte.FDO.OwnerOnboarding.SessionToken alias COSE.Messages.Encrypt0 @@ -59,9 +57,6 @@ defmodule Astarte.FDO.Core.OwnerOnboarding.Session do field :device_service_info, map() | nil field :owner_service_info, [binary()] | nil field :last_chunk_sent, non_neg_integer() | nil - field :replacement_guid, binary() | nil - field :replacement_rv_info, RendezvousInfo.t() | nil - field :replacement_pub_key, PublicKey.t() | nil field :replacement_hmac, Hash.t() | nil end @@ -199,24 +194,29 @@ defmodule Astarte.FDO.Core.OwnerOnboarding.Session do end) end - def add_replacement_info(session, realm_name, replacement_guid, rv_info, pub_key, hmac) do + def add_replacement_hmac(session, realm_name, hmac) do with :ok <- - Queries.session_add_replacement_info( + Queries.session_add_replacement_hmac( realm_name, session.guid, - replacement_guid, - rv_info, - pub_key, hmac ) do - {:ok, - %{ - session - | replacement_guid: replacement_guid, - replacement_rv_info: rv_info, - replacement_pub_key: pub_key, - replacement_hmac: hmac - }} + session = %{session | replacement_hmac: hmac} + {:ok, session} + end + end + + def build_session_secret( + session = %{kex_suite_name: kex, guid: guid}, + realm_name, + %Astarte.Secrets.Key{} = owner_key, + xb + ) + when kex in ["ASYMKEX2048", "ASYMKEX3072"] do + with {:ok, secret} <- + Astarte.Secrets.decrypt(owner_key.name, xb, namespace: owner_key.namespace), + :ok <- Queries.add_session_secret(realm_name, guid, secret) do + {:ok, %{session | secret: secret}} end end @@ -289,9 +289,6 @@ defmodule Astarte.FDO.Core.OwnerOnboarding.Session do device_service_info: device_service_info, owner_service_info: owner_service_info, last_chunk_sent: last_chunk_sent, - replacement_guid: replacement_guid, - replacement_rv_info: replacement_rv_info, - replacement_pub_key: replacement_pub_key, replacement_hmac: replacement_hmac } = database_session @@ -314,9 +311,6 @@ defmodule Astarte.FDO.Core.OwnerOnboarding.Session do device_service_info: device_service_info, owner_service_info: owner_service_info, last_chunk_sent: last_chunk_sent, - replacement_guid: replacement_guid, - replacement_rv_info: replacement_rv_info, - replacement_pub_key: replacement_pub_key, replacement_hmac: replacement_hmac } diff --git a/libs/astarte_fdo/lib/ownership_voucher/load_request.ex b/libs/astarte_fdo/lib/ownership_voucher/load_request.ex index bf23d0027..997543620 100644 --- a/libs/astarte_fdo/lib/ownership_voucher/load_request.ex +++ b/libs/astarte_fdo/lib/ownership_voucher/load_request.ex @@ -25,9 +25,11 @@ defmodule Astarte.FDO.OwnershipVoucher.LoadRequest do alias Astarte.FDO.Core.OwnershipVoucher alias Astarte.FDO.Core.OwnershipVoucher.Core, as: OVCore + alias Astarte.FDO.Core.OwnershipVoucher.RendezvousInfo alias Astarte.FDO.Core.PublicKey alias Astarte.FDO.OwnershipVoucher.LoadRequest alias Astarte.Secrets + alias Astarte.Secrets.Core, as: SecretsCore alias Astarte.Secrets.Key require Logger @@ -38,6 +40,7 @@ defmodule Astarte.FDO.OwnershipVoucher.LoadRequest do field :ownership_voucher, :string field :realm_name, :string field :key_name, :string + field :key_algorithm, Ecto.Enum, values: SecretsCore.key_algorithm_enum() field(:extracted_owner_key, :any, virtual: true) :: Key.t() | nil field :cbor_ownership_voucher, :binary @@ -45,38 +48,112 @@ defmodule Astarte.FDO.OwnershipVoucher.LoadRequest do OwnershipVoucher.decoded_voucher() | nil field(:voucher_struct, :any, virtual: true) :: struct() | nil - field(:owner_key_algorithm, :any, virtual: true) field :device_guid, :binary + field(:owner_voucher_public_key, :any, virtual: true) :: PublicKey.t() | nil + field :replacement_rendezvous_info, :binary + field :replacement_public_key, :string + field :replacement_guid, :binary + field(:decoded_replacement_rendezvous_info, :any, virtual: true) :: RendezvousInfo.t() | nil + field(:decoded_replacement_public_key, :any, virtual: true) :: PublicKey.t() | nil end @spec changeset(t(), map()) :: Ecto.Changeset.t() def changeset(%LoadRequest{} = request, params) do request - |> cast(params, [:ownership_voucher, :realm_name, :key_name]) - |> validate_required([:ownership_voucher, :realm_name, :key_name]) + |> cast(params, [ + :ownership_voucher, + :realm_name, + :key_name, + :key_algorithm, + :replacement_rendezvous_info, + :replacement_public_key, + :replacement_guid + ]) + |> validate_required([:ownership_voucher, :realm_name, :key_name, :key_algorithm]) |> put_device_guid() + |> validate_key_algorithm_compatible() |> fetch_owner_key() |> verify_owner_key_matches() + |> validate_replacement_rendezvous_info() + |> validate_change(:replacement_public_key, fn :replacement_public_key, pem_string -> + case public_key_from_pem(pem_string) do + {:ok, _} -> [] + :error -> [replacement_public_key: "is not a valid PEM public key"] + end + end) + |> validate_change(:replacement_guid, fn :replacement_guid, b64_string -> + case Base.decode64(b64_string) do + {:ok, _} -> [] + :error -> [replacement_guid: "is not valid base64"] + end + end) + |> decode_replacement_fields() end - @doc """ - Extracts the key algorithm required by the last ownership voucher entry. + defp validate_replacement_rendezvous_info(changeset) do + validate_change(changeset, :replacement_rendezvous_info, fn :replacement_rendezvous_info, + b64_string -> + with {:ok, cbor_binary} <- Base.decode64(b64_string), + {:ok, _} <- RendezvousInfo.decode_cbor(cbor_binary) do + [] + else + _ -> [replacement_rendezvous_info: "is not valid base64-encoded CBOR rendezvous info"] + end + end) + end - Accepts a PEM-encoded ownership voucher string and returns - `{:ok, key_algorithm}` where `key_algorithm` is one of `:es256`, `:es384`, - `:rs256`, or `:rs384`, or `:error` if the voucher cannot be parsed or the - algorithm is unsupported. - """ - @spec key_algorithm_from_voucher(String.t()) :: {:ok, atom()} | :error - def key_algorithm_from_voucher(ownership_voucher_pem) do - with {:ok, binary_voucher} <- OwnershipVoucher.binary_voucher(ownership_voucher_pem), - {:ok, decoded_voucher, _rest} <- CBOR.decode(binary_voucher), - {:ok, voucher_struct} <- OwnershipVoucher.decode(decoded_voucher), - {:ok, %PublicKey{type: pubkey_type}} <- - OVCore.entry_private_key(List.last(voucher_struct.entries)) do - pubkey_type_to_algorithm(pubkey_type) - else - _ -> :error + defp decode_replacement_fields(%Ecto.Changeset{valid?: false} = changeset), do: changeset + + defp decode_replacement_fields(changeset) do + changeset + |> decode_replacement_guid() + |> decode_replacement_rendezvous_info() + |> decode_replacement_public_key() + end + + defp decode_replacement_guid(changeset) do + case fetch_change(changeset, :replacement_guid) do + {:ok, b64} -> + case Base.decode64(b64) do + {:ok, bin} -> put_change(changeset, :replacement_guid, bin) + :error -> add_error(changeset, :replacement_guid, "is not valid base64") + end + + :error -> + changeset + end + end + + defp decode_replacement_rendezvous_info(changeset) do + case fetch_change(changeset, :replacement_rendezvous_info) do + {:ok, b64} -> + with {:ok, cbor_bin} <- Base.decode64(b64), + {:ok, rv_info} <- RendezvousInfo.decode_cbor(cbor_bin) do + put_change(changeset, :decoded_replacement_rendezvous_info, rv_info) + else + _ -> + add_error( + changeset, + :replacement_rendezvous_info, + "is not valid base64-encoded CBOR rendezvous info" + ) + end + + :error -> + changeset + end + end + + defp decode_replacement_public_key(changeset) do + case fetch_change(changeset, :replacement_public_key) do + {:ok, pem_string} -> + case public_key_from_pem(pem_string) do + {:ok, public_key} -> put_change(changeset, :decoded_replacement_public_key, public_key) + :error -> add_error(changeset, :replacement_public_key, "is not a valid PEM public key") + end + + :error -> + changeset end end @@ -88,14 +165,13 @@ defmodule Astarte.FDO.OwnershipVoucher.LoadRequest do with {:ok, binary_voucher} <- OwnershipVoucher.binary_voucher(ownership_voucher_pem), {:ok, decoded_voucher, _rest} <- CBOR.decode(binary_voucher), {:ok, voucher_struct} <- OwnershipVoucher.decode(decoded_voucher), - {:ok, %PublicKey{type: pubkey_type}} <- - OVCore.entry_private_key(List.last(voucher_struct.entries)), - {:ok, key_algorithm} <- pubkey_type_to_algorithm(pubkey_type) do + {:ok, %PublicKey{} = owner_public_key} <- + OVCore.entry_public_key(List.last(voucher_struct.entries)) do changeset |> put_change(:cbor_ownership_voucher, binary_voucher) |> put_change(:decoded_ownership_voucher, decoded_voucher) |> put_change(:voucher_struct, voucher_struct) - |> put_change(:owner_key_algorithm, key_algorithm) + |> put_change(:owner_voucher_public_key, owner_public_key) |> put_change(:device_guid, voucher_struct.header.guid) else err -> @@ -107,122 +183,180 @@ defmodule Astarte.FDO.OwnershipVoucher.LoadRequest do end end + defp validate_key_algorithm_compatible(%Ecto.Changeset{valid?: false} = changeset), + do: changeset + + defp validate_key_algorithm_compatible(changeset) do + key_algorithm = fetch_field!(changeset, :key_algorithm) + %PublicKey{type: pubkey_type} = fetch_field!(changeset, :owner_voucher_public_key) + + case OwnershipVoucher.key_algorithm_from_type(pubkey_type) do + {:ok, valid_algorithms} -> + if key_algorithm in valid_algorithms do + changeset + else + add_error( + changeset, + :key_algorithm, + "is not compatible with the ownership voucher's key type" + ) + end + + {:error, _} -> + add_error(changeset, :ownership_voucher, "has an unsupported key type") + end + end + defp fetch_owner_key(%Ecto.Changeset{valid?: false} = changeset), do: changeset defp fetch_owner_key(changeset) do realm_name = fetch_field!(changeset, :realm_name) key_name = fetch_field!(changeset, :key_name) - key_algorithm = fetch_field!(changeset, :owner_key_algorithm) + key_algorithm = fetch_field!(changeset, :key_algorithm) + case fetch_key_for_algorithm(realm_name, key_name, key_algorithm) do + {:ok, key} -> put_change(changeset, :extracted_owner_key, key) + {:error, _} -> add_error(changeset, :key_name, "does not exist in secrets store") + end + end + + defp fetch_key_for_algorithm(realm_name, key_name, key_algorithm) do with {:ok, namespace} <- Secrets.create_namespace(realm_name, key_algorithm), {:ok, key} <- Secrets.get_key(key_name, namespace: namespace) do - put_change(changeset, :extracted_owner_key, key) + {:ok, key} else - err -> - Logger.warning( - "LoadRequest: key_name \"#{key_name}\" not found in secrets store. " <> - "The voucher's last entry requires a #{inspect(key_algorithm)} key, " <> - "but no key with that name exists under that algorithm's namespace. " <> - "Ensure the key is registered with the correct algorithm. " <> - "Error: #{inspect(err)}" - ) - - add_error(changeset, :key_name, "does not exist in secrets store") + _ -> {:error, :not_found} end end defp verify_owner_key_matches(%Ecto.Changeset{valid?: false} = changeset), do: changeset defp verify_owner_key_matches(changeset) do - voucher_struct = fetch_field!(changeset, :voucher_struct) - %Astarte.Secrets.Key{name: key_name, public_pem: pem} = fetch_field!(changeset, :extracted_owner_key) - with {:ok, %PublicKey{encoding: encoding, body: voucher_body}} <- - OVCore.entry_private_key(List.last(voucher_struct.entries)), - true <- public_keys_match?(encoding, voucher_body, pem) do + %PublicKey{encoding: encoding, body: voucher_body} = + fetch_field!(changeset, :owner_voucher_public_key) + + if public_keys_match?(encoding, voucher_body, pem) do changeset else - false -> - Logger.warning( - "LoadRequest: key_name \"#{key_name}\" was found in the secrets store " <> - "but its public key DER bytes do not match " <> - "the public key in the voucher's last entry. " <> - "The voucher was not issued for this key." - ) - - add_error( - changeset, - :key_name, - "does not match the public key in the ownership voucher's last entry" - ) - - err -> - Logger.warning( - "LoadRequest: failed to verify key \"#{key_name}\" against voucher entry: #{inspect(err)}" - ) - - add_error( - changeset, - :key_name, - "does not match the public key in the ownership voucher's last entry" - ) + Logger.warning( + "LoadRequest: key_name \"#{key_name}\" was found in the secrets store " <> + "but its public key DER bytes do not match " <> + "the public key in the voucher's last entry. " <> + "The voucher was not issued for this key." + ) + + add_error( + changeset, + :key_name, + "does not match the public key in the ownership voucher's last entry" + ) end end - # Extract the uncompressed EC point (<<4, x, y>>) from a SPKI PEM string. - defp ec_point_from_pem(pem) do - with [{_type, spki_der, :not_encrypted}] <- :public_key.pem_decode(pem), - {:SubjectPublicKeyInfo, _alg, point} <- - :public_key.der_decode(:SubjectPublicKeyInfo, spki_der) do - {:ok, point} - else - _ -> :error + # :x509 — voucher body is SPKI DER; PEM decodes to SPKI DER. Direct byte comparison. + defp public_keys_match?(:x509, voucher_spki_der, pem) do + case :public_key.pem_decode(pem) do + [{_, pem_spki_der, :not_encrypted}] -> pem_spki_der == voucher_spki_der + _ -> false end end - # Extract the uncompressed EC point from the voucher entry body, depending on encoding. - defp ec_point_from_voucher(:x509, spki_der) do - case :public_key.der_decode(:SubjectPublicKeyInfo, spki_der) do - {:SubjectPublicKeyInfo, _alg, point} -> {:ok, point} - _ -> :error + # :x5chain — extract SPKI DER from the first cert, compare with PEM SPKI DER. + defp public_keys_match?(:x5chain, [first_cert_der | _], pem) do + with {:ok, cert_spki_der} <- spki_der_from_cert(first_cert_der), + [{_, pem_spki_der, :not_encrypted}] <- :public_key.pem_decode(pem) do + cert_spki_der == pem_spki_der + else + _ -> false end end - defp ec_point_from_voucher(:cosekey, cosekey_cbor) do + # :cosekey — decode CBOR map, decode PEM via OTP, compare key material. + defp public_keys_match?(:cosekey, cosekey_cbor, pem) do with {:ok, cose_map, ""} <- CBOR.decode(cosekey_cbor), - x when is_binary(x) <- Map.get(cose_map, -2), - y when is_binary(y) <- Map.get(cose_map, -3) do - {:ok, <<4, x::binary, y::binary>>} + [{_, _, :not_encrypted} = entry] <- :public_key.pem_decode(pem), + key_record <- :public_key.pem_entry_decode(entry) do + cose_record_equal?(cose_map, key_record) else - _ -> :error + _ -> false end end - defp ec_point_from_voucher(_encoding, _body), do: :error + defp public_keys_match?(_, _, _), do: false - defp public_keys_match?(:x5chain, [first_cert_der | _], pem) do - case ec_point_from_pem(pem) do - {:ok, key_point} -> :binary.match(first_cert_der, key_point) != :nomatch - _ -> false - end + # Extract the SubjectPublicKeyInfo DER bytes embedded in an X.509 certificate. + defp spki_der_from_cert(cert_der) do + {:Certificate, {:TBSCertificate, _, _, _, _, _, _, spki, _, _, _}, _, _} = + :public_key.pkix_decode_cert(cert_der, :plain) + + {:ok, :public_key.der_encode(:SubjectPublicKeyInfo, spki)} + rescue + _ -> :error + end + + # EC public key: pem_entry_decode yields {{:ECPoint, <<4,x,y>>}, {:namedCurve, oid}} + # COSE map: -2 => x bytes, -3 => y bytes + defp cose_record_equal?( + cose_map, + {{:ECPoint, <<4, x::binary-size(32), y::binary-size(32)>>}, _} + ) do + Map.get(cose_map, -2) == x and Map.get(cose_map, -3) == y + end + + defp cose_record_equal?( + cose_map, + {{:ECPoint, <<4, x::binary-size(48), y::binary-size(48)>>}, _} + ) do + Map.get(cose_map, -2) == x and Map.get(cose_map, -3) == y end - defp public_keys_match?(encoding, voucher_body, pem) do - with {:ok, voucher_point} <- ec_point_from_voucher(encoding, voucher_body), - {:ok, key_point} <- ec_point_from_pem(pem) do - voucher_point == key_point + # RSA public key: pem_entry_decode yields {:RSAPublicKey, n_int, e_int} + # COSE map: -1 => n bytes, -2 => e bytes + defp cose_record_equal?(cose_map, {:RSAPublicKey, n, e}) do + Map.get(cose_map, -1) == :binary.encode_unsigned(n) and + Map.get(cose_map, -2) == :binary.encode_unsigned(e) + end + + defp cose_record_equal?(_, _), do: false + + defp public_key_from_pem(pem_string) do + with [{:SubjectPublicKeyInfo, spki_der, :not_encrypted} = entry] <- + :public_key.pem_decode(pem_string), + {:ok, key_type} <- key_type_from_spki(spki_der, entry) do + {:ok, %PublicKey{type: key_type, encoding: :x509, body: spki_der}} else - _ -> false + _ -> :error end end - # Maps FDO PublicKey type to the Secrets key algorithm atom. - # The type is taken from the last voucher entry, which identifies the current owner's key. - defp pubkey_type_to_algorithm(:secp256r1), do: {:ok, :es256} - defp pubkey_type_to_algorithm(:secp384r1), do: {:ok, :es384} - defp pubkey_type_to_algorithm(:rsa2048restr), do: {:ok, :rs256} - defp pubkey_type_to_algorithm(:rsapkcs), do: {:ok, :rs256} - defp pubkey_type_to_algorithm(:rsapss), do: {:ok, :rs384} + # For EC: pem_entry_decode resolves the curve OID to a named tuple directly. + # For RSA: der_decode the SPKI to read the algorithm OID (PKCS#1 vs PSS), + # since pem_entry_decode strips it, returning identical {:RSAPublicKey, n, e} for both. + defp key_type_from_spki(spki_der, entry) do + case :public_key.pem_entry_decode(entry) do + {{:ECPoint, _}, {:namedCurve, {1, 2, 840, 10_045, 3, 1, 7}}} -> + {:ok, :secp256r1} + + {{:ECPoint, _}, {:namedCurve, {1, 3, 132, 0, 34}}} -> + {:ok, :secp384r1} + + {:RSAPublicKey, _, _} -> + case :public_key.der_decode(:SubjectPublicKeyInfo, spki_der) do + {:SubjectPublicKeyInfo, {:AlgorithmIdentifier, {1, 2, 840, 113_549, 1, 1, 10}, _}, _} -> + {:ok, :rsapss} + + {:SubjectPublicKeyInfo, {:AlgorithmIdentifier, _, _}, _} -> + {:ok, :rsapkcs} + + _ -> + :error + end + + _ -> + :error + end + end end diff --git a/libs/astarte_fdo/lib/ownership_voucher/ownership_voucher.ex b/libs/astarte_fdo/lib/ownership_voucher/ownership_voucher.ex index 0fff474ee..965eb1734 100644 --- a/libs/astarte_fdo/lib/ownership_voucher/ownership_voucher.ex +++ b/libs/astarte_fdo/lib/ownership_voucher/ownership_voucher.ex @@ -26,17 +26,8 @@ defmodule Astarte.FDO.OwnershipVoucher do alias Astarte.FDO.Core.OwnershipVoucher alias Astarte.FDO.Core.OwnershipVoucher.Core - @one_week 604_800 - - def save_voucher(realm_name, cbor_ownership_voucher, device_guid, owner_private_key) do - with {:ok, _} <- - Queries.create_ownership_voucher( - realm_name, - device_guid, - cbor_ownership_voucher, - owner_private_key, - @one_week - ) do + def save_voucher(realm_name, attrs) do + with {:ok, _} <- Queries.create_ownership_voucher(realm_name, attrs) do :ok end end @@ -55,7 +46,7 @@ defmodule Astarte.FDO.OwnershipVoucher do # N.B.: Checking if there are entries is not necessary, # as by spec the ownership voucher will always have at least one entry List.last(ownership_voucher.entries) - |> Core.entry_private_key() + |> Core.entry_public_key() end def get_ov_entry(%OwnershipVoucher{entries: entries}, entry_num) do @@ -68,12 +59,19 @@ defmodule Astarte.FDO.OwnershipVoucher do end end - def generate_replacement_voucher(ownership_voucher, session) do + def generate_replacement_voucher(ownership_voucher, ov_entry, session) do + guid = ov_entry.replacement_guid || ownership_voucher.header.guid + + rendezvous_info = + ov_entry.replacement_rendezvous_info || ownership_voucher.header.rendezvous_info + + public_key = ov_entry.replacement_public_key || ownership_voucher.header.public_key + new_header = ownership_voucher.header - |> Map.put(:guid, session.replacement_guid) - |> Map.put(:rendezvous_info, session.replacement_rv_info) - |> Map.put(:public_key, session.replacement_pub_key) + |> Map.put(:guid, guid) + |> Map.put(:rendezvous_info, rendezvous_info) + |> Map.put(:public_key, public_key) new_voucher = ownership_voucher @@ -84,11 +82,10 @@ defmodule Astarte.FDO.OwnershipVoucher do {:ok, new_voucher} end - def credential_reuse?(_session) do - # Credential reuse requires also Owner2Key and/or rv info - # to be changed for credential reuse; so far, there is no API to do so, - # so it is limited to the guid - true + def credential_reuse?(ov_entry) do + is_nil(ov_entry.replacement_public_key) and + is_nil(ov_entry.replacement_rendezvous_info) and + is_nil(ov_entry.replacement_guid) end @doc """ @@ -102,17 +99,12 @@ defmodule Astarte.FDO.OwnershipVoucher do end @doc """ - Returns the key algorithm compatible with `Astarte.Secrets.Core` for the - given decoded ownership voucher. + Returns the list of key algorithm atoms compatible with the given ownership voucher. + Returns an empty list if the key type is unsupported. """ - @spec key_algorithm(OwnershipVoucher.t()) :: atom() | [atom()] - def key_algorithm(%OwnershipVoucher{} = voucher) do - fdo_type_to_key_algorithm(voucher.header.public_key.type) + @spec key_algorithm(OwnershipVoucher.t()) :: [atom()] + def key_algorithm(voucher) do + {:ok, algorithms} = OwnershipVoucher.key_algorithm_from_type(voucher.header.public_key.type) + algorithms end - - defp fdo_type_to_key_algorithm(:secp256r1), do: :es256 - defp fdo_type_to_key_algorithm(:secp384r1), do: :es384 - defp fdo_type_to_key_algorithm(:rsa2048restr), do: :rs256 - defp fdo_type_to_key_algorithm(:rsapkcs), do: [:rs256, :rs384] - defp fdo_type_to_key_algorithm(:rsapss), do: [:rs256, :rs384] end diff --git a/libs/astarte_fdo/test/astarte_fdo/onboarding/done_test.exs b/libs/astarte_fdo/test/astarte_fdo/onboarding/done_test.exs index cb7ad9679..46d5fa544 100644 --- a/libs/astarte_fdo/test/astarte_fdo/onboarding/done_test.exs +++ b/libs/astarte_fdo/test/astarte_fdo/onboarding/done_test.exs @@ -25,10 +25,12 @@ defmodule Astarte.FDO.Onboarding.DoneTest do alias Astarte.Core.Device, as: CoreDevice alias Astarte.DataAccess.Device alias Astarte.DataAccess.Devices.Device, as: DeviceDB + alias Astarte.DataAccess.FDO.OwnershipVoucher, as: DataAccessOwnershipVoucher alias Astarte.DataAccess.Realms.Realm alias Astarte.DataAccess.Repo alias Astarte.FDO.Core.Hash alias Astarte.FDO.Core.OwnerOnboarding.Session + alias Astarte.FDO.Core.OwnershipVoucher, as: OVCore alias Astarte.FDO.OwnerOnboarding alias Astarte.FDO.OwnershipVoucher @@ -83,18 +85,9 @@ defmodule Astarte.FDO.Onboarding.DoneTest do } do done_msg = [%CBOR.Tag{tag: :bytes, value: session.prove_dv_nonce}] - {:ok, ownership_voucher} = OwnershipVoucher.fetch(realm_name, session.guid) - {:ok, owner_public_key} = OwnershipVoucher.owner_public_key(ownership_voucher) - rendezvous_info = ownership_voucher.header.rendezvous_info new_hmac = :crypto.strong_rand_bytes(32) - session = %{ - session - | replacement_guid: session.guid, - replacement_hmac: %Hash{type: :hmac_sha256, hash: new_hmac}, - replacement_rv_info: rendezvous_info, - replacement_pub_key: owner_public_key - } + session = %{session | replacement_hmac: %Hash{type: :hmac_sha256, hash: new_hmac}} {:ok, done2_msg_cbor} = OwnerOnboarding.done(realm_name, session, done_msg) @@ -109,18 +102,6 @@ defmodule Astarte.FDO.Onboarding.DoneTest do realm: realm_name, session: session } do - {:ok, ownership_voucher} = OwnershipVoucher.fetch(realm_name, session.guid) - {:ok, owner_public_key} = OwnershipVoucher.owner_public_key(ownership_voucher) - rendezvous_info = ownership_voucher.header.rendezvous_info - - session = %{ - session - | replacement_guid: session.guid, - replacement_hmac: session.hmac, - replacement_rv_info: rendezvous_info, - replacement_pub_key: owner_public_key - } - done_msg = [%CBOR.Tag{tag: :bytes, value: session.prove_dv_nonce}] {:ok, old_voucher} = OwnershipVoucher.fetch(realm_name, session.guid) {:ok, _} = OwnerOnboarding.done(realm_name, session, done_msg) @@ -131,35 +112,41 @@ defmodule Astarte.FDO.Onboarding.DoneTest do assert voucher.hmac == old_voucher.hmac end - @tag :skip - # TODO: re-enable this test when credential reuse logic is implemented. test "ensure old voucher is keep when ProveDv nonces match and TO2.done ends successfully without credential reuse ", %{ realm: realm_name, session: session } do - {:ok, ownership_voucher} = OwnershipVoucher.fetch(realm_name, session.guid) - {:ok, owner_public_key} = OwnershipVoucher.owner_public_key(ownership_voucher) - rendezvous_info = ownership_voucher.header.rendezvous_info new_hmac = :crypto.strong_rand_bytes(32) - session = %{ - session - | replacement_guid: session.guid, - replacement_hmac: %Hash{type: :hmac_sha256, hash: new_hmac}, - replacement_rv_info: rendezvous_info, - replacement_pub_key: owner_public_key - } + session = %{session | replacement_hmac: %Hash{type: :hmac_sha256, hash: new_hmac}} done_msg = [%CBOR.Tag{tag: :bytes, value: session.prove_dv_nonce}] {:ok, old_voucher} = OwnershipVoucher.fetch(realm_name, session.guid) + + keyspace = Realm.keyspace_name(realm_name) + + ov_record_before = Repo.get!(DataAccessOwnershipVoucher, session.guid, prefix: keyspace) + + Ecto.Changeset.change(ov_record_before, %{ + replacement_guid: session.guid, + replacement_public_key: old_voucher.header.public_key, + replacement_rendezvous_info: old_voucher.header.rendezvous_info + }) + |> Repo.update!() + {:ok, _} = OwnerOnboarding.done(realm_name, session, done_msg) - {:ok, voucher} = OwnershipVoucher.fetch(realm_name, session.guid) + ov_record = + Repo.get!(DataAccessOwnershipVoucher, session.guid, prefix: keyspace) + + assert ov_record.output_voucher != nil - assert voucher.entries == [] + {:ok, output_voucher} = + OVCore.decode_cbor(ov_record.output_voucher) - assert voucher.hmac != old_voucher.hmac + assert output_voucher.entries == [] + assert output_voucher.hmac != old_voucher.hmac end test "returns {:error, :invalid_message} when the ProveDv nonces don't match", %{ @@ -176,18 +163,9 @@ defmodule Astarte.FDO.Onboarding.DoneTest do realm: realm_name, session: session } do - {:ok, ownership_voucher} = OwnershipVoucher.fetch(realm_name, session.guid) - {:ok, owner_public_key} = OwnershipVoucher.owner_public_key(ownership_voucher) - rendezvous_info = ownership_voucher.header.rendezvous_info new_hmac = :crypto.strong_rand_bytes(32) - session = %{ - session - | replacement_guid: session.guid, - replacement_hmac: %Hash{type: :hmac_sha256, hash: new_hmac}, - replacement_rv_info: rendezvous_info, - replacement_pub_key: owner_public_key - } + session = %{session | replacement_hmac: %Hash{type: :hmac_sha256, hash: new_hmac}} done_msg = [%CBOR.Tag{tag: :bytes, value: session.prove_dv_nonce}] diff --git a/libs/astarte_fdo/test/astarte_fdo/onboarding/prove_device_test.exs b/libs/astarte_fdo/test/astarte_fdo/onboarding/prove_device_test.exs index 0d6a58f9f..d70596930 100644 --- a/libs/astarte_fdo/test/astarte_fdo/onboarding/prove_device_test.exs +++ b/libs/astarte_fdo/test/astarte_fdo/onboarding/prove_device_test.exs @@ -118,7 +118,7 @@ defmodule Astarte.FDO.OwnerOnboarding.ProveDeviceTest do session: session, device_key: device_key, xb: xb, - owner_key: owner_key + owner_key_struct: owner_key_struct } = context {:ok, prove_device_msg} = @@ -131,7 +131,7 @@ defmodule Astarte.FDO.OwnerOnboarding.ProveDeviceTest do } |> ProveDevice.encode_sign(device_key) - creds = dummy_creds(owner_key) + creds = dummy_creds(owner_key_struct) assert {:ok, %{setup_dv_nonce: @test_setup_dv_nonce, resp: msg_65_payload}} = OwnerOnboarding.verify_and_build_response( @@ -149,7 +149,7 @@ defmodule Astarte.FDO.OwnerOnboarding.ProveDeviceTest do # message is signed with the owner EC256 private key and can be verified using # the related public key - assert :ok == Sign1.verify(setup_device_msg_decoded, owner_key) + assert :ok == Sign1.verify(setup_device_msg_decoded, owner_key_struct) end test "verify ES256 fails if Nonce does not match", context do @@ -158,7 +158,7 @@ defmodule Astarte.FDO.OwnerOnboarding.ProveDeviceTest do session: session, device_key: device_key, xb: xb, - owner_key: owner_key + owner_key_struct: owner_key_struct } = context @@ -174,7 +174,7 @@ defmodule Astarte.FDO.OwnerOnboarding.ProveDeviceTest do } |> ProveDevice.encode_sign(device_key) - creds = dummy_creds(owner_key) + creds = dummy_creds(owner_key_struct) assert {:error, :invalid_message} = OwnerOnboarding.verify_and_build_response( @@ -191,7 +191,7 @@ defmodule Astarte.FDO.OwnerOnboarding.ProveDeviceTest do session: session, device_key: device_key, xb: xb, - owner_key: owner_key + owner_key_struct: owner_key_struct } = context @@ -205,7 +205,7 @@ defmodule Astarte.FDO.OwnerOnboarding.ProveDeviceTest do } |> ProveDevice.encode_sign(device_key) - creds = dummy_creds(owner_key) + creds = dummy_creds(owner_key_struct) assert {:error, :message_body_error} = OwnerOnboarding.verify_and_build_response( @@ -221,7 +221,7 @@ defmodule Astarte.FDO.OwnerOnboarding.ProveDeviceTest do realm_name: realm_name, session: session, xb: xb, - owner_key: owner_key + owner_key_struct: owner_key_struct } = context @@ -237,7 +237,7 @@ defmodule Astarte.FDO.OwnerOnboarding.ProveDeviceTest do } |> ProveDevice.encode_sign(device_key2) - creds = dummy_creds(owner_key) + creds = dummy_creds(owner_key_struct) assert {:error, :invalid_message} = OwnerOnboarding.verify_and_build_response( @@ -251,13 +251,13 @@ defmodule Astarte.FDO.OwnerOnboarding.ProveDeviceTest do describe "verify response SetupDevice is correctly signed" do setup context do %{ - owner_key: owner_key, + owner_key_struct: owner_key_struct, session: session, xb: xb, device_key: device_key } = context - creds = dummy_creds(owner_key) + creds = dummy_creds(owner_key_struct) {:ok, prove_device_msg} = %ProveDevice{ diff --git a/libs/astarte_fdo/test/astarte_fdo/owner_onboarding/kex_exchange_strategy_test.exs b/libs/astarte_fdo/test/astarte_fdo/owner_onboarding/kex_exchange_strategy_test.exs index 7f9edbacb..dbd001190 100644 --- a/libs/astarte_fdo/test/astarte_fdo/owner_onboarding/kex_exchange_strategy_test.exs +++ b/libs/astarte_fdo/test/astarte_fdo/owner_onboarding/kex_exchange_strategy_test.exs @@ -19,78 +19,78 @@ defmodule Astarte.FDO.OwnerOnboarding.KeyExchangeStrategyTest do use ExUnit.Case, async: true alias Astarte.FDO.OwnerOnboarding.KeyExchangeStrategy - alias COSE.Keys.ECC - alias COSE.Keys.RSA describe "validate/2" do test "validates device requesting DHKEXid14 when Owner Key is of type RSA2048" do - owner_key = %RSA{alg: :rs256} - assert :ok = KeyExchangeStrategy.validate("DHKEXid14", owner_key) + owner_key_alg = :rs256 + assert :ok = KeyExchangeStrategy.validate("DHKEXid14", owner_key_alg) end test "validates device requesting DHKEXid15 when Owner Key is of type RSA3072" do - owner_key = %RSA{alg: :rs384} - assert :ok = KeyExchangeStrategy.validate("DHKEXid15", owner_key) + owner_key_alg = :rs384 + assert :ok = KeyExchangeStrategy.validate("DHKEXid15", owner_key_alg) end test "validates device requesting ASYMKEX2048 when Owner Key is of type RSA2048" do - owner_key = %RSA{alg: :rs256} - assert :ok = KeyExchangeStrategy.validate("ASYMKEX2048", owner_key) + owner_key_alg = :rs256 + assert :ok = KeyExchangeStrategy.validate("ASYMKEX2048", owner_key_alg) end test "validates device requesting ASYMKEX3072 when Owner Key is of type RSA3072" do - owner_key = %RSA{alg: :rs384} - assert :ok = KeyExchangeStrategy.validate("ASYMKEX3072", owner_key) + owner_key_alg = :rs384 + assert :ok = KeyExchangeStrategy.validate("ASYMKEX3072", owner_key_alg) end test "returns error if Device requests ASYMKEX2048 but Owner has RSA3072 key (Mismatch)" do - owner_key = %RSA{alg: :rs384} - assert {:error, :invalid_message} = KeyExchangeStrategy.validate("ASYMKEX2048", owner_key) + owner_key_alg = :rs384 + + assert {:error, :invalid_message} = + KeyExchangeStrategy.validate("ASYMKEX2048", owner_key_alg) end test "validates ECDH256 successfully when Owner uses P-256" do - owner_key = %ECC{crv: :p256} - assert :ok = KeyExchangeStrategy.validate("ECDH256", owner_key) + owner_key_alg = :es256 + assert :ok = KeyExchangeStrategy.validate("ECDH256", owner_key_alg) end test "validates ECDH384 successfully when Owner uses P-384" do - owner_key = %ECC{crv: :p384} - assert :ok = KeyExchangeStrategy.validate("ECDH384", owner_key) + owner_key_alg = :es384 + assert :ok = KeyExchangeStrategy.validate("ECDH384", owner_key_alg) end test "returns error if Device requests ECDH384 but Owner has P-256 key (Mismatch)" do - owner_key = %ECC{crv: :p256} + owner_key_alg = :es256 assert {:error, :invalid_message} = - KeyExchangeStrategy.validate("ECDH384", owner_key) + KeyExchangeStrategy.validate("ECDH384", owner_key_alg) end test "returns error if Device requests ECDH256 but Owner has P-384 key (Mismatch)" do - owner_key = %ECC{crv: :p384} + owner_key_alg = :es384 assert {:error, :invalid_message} = - KeyExchangeStrategy.validate("ECDH256", owner_key) + KeyExchangeStrategy.validate("ECDH256", owner_key_alg) end test "returns error for incompatible kex algorithm / owner key type" do - owner_key = %RSA{alg: :rs256} + owner_key_alg = :rs256 assert {:error, :invalid_message} = - KeyExchangeStrategy.validate("ECDH256", owner_key) + KeyExchangeStrategy.validate("ECDH256", owner_key_alg) end test "returns error for incompatible RSA kex algorithm / owner key strength" do - owner_key = %RSA{alg: :rs256} + owner_key_alg = :rs256 assert {:error, :invalid_message} = - KeyExchangeStrategy.validate("DHKEXid15", owner_key) + KeyExchangeStrategy.validate("DHKEXid15", owner_key_alg) end test "returns error for unknown/unsupported device suite" do - owner_key = %ECC{crv: :p256} + owner_key_alg = :es256 assert {:error, :invalid_message} = - KeyExchangeStrategy.validate("UNKNOWN_SUITE", owner_key) + KeyExchangeStrategy.validate("UNKNOWN_SUITE", owner_key_alg) end end end diff --git a/libs/astarte_fdo/test/astarte_fdo/owner_onboarding/owner_onboarding_test.exs b/libs/astarte_fdo/test/astarte_fdo/owner_onboarding/owner_onboarding_test.exs index b2462ce4e..cb60a9881 100644 --- a/libs/astarte_fdo/test/astarte_fdo/owner_onboarding/owner_onboarding_test.exs +++ b/libs/astarte_fdo/test/astarte_fdo/owner_onboarding/owner_onboarding_test.exs @@ -18,49 +18,119 @@ defmodule Astarte.FDO.OwnerOnboarding.OwnerOnboardingTest do use Astarte.Cases.Data, async: true + use Astarte.Cases.FDOSession + alias Astarte.FDO.Core.Hash + alias Astarte.FDO.Core.OwnerOnboarding.DeviceServiceInfoReady alias Astarte.FDO.Core.OwnerOnboarding.HelloDevice - alias Astarte.FDO.Core.OwnershipVoucher + alias Astarte.FDO.Core.OwnerOnboarding.Session + alias Astarte.FDO.Core.OwnershipVoucher, as: OVCore alias Astarte.FDO.OwnerOnboarding + alias Astarte.Secrets + alias COSE.Keys alias COSE.Messages.Sign1 import Astarte.FDO.Helpers + @max_device_service_info_sz 4096 + setup_all %{realm_name: realm_name} do {voucher_p256_x509, key_p256_x509} = generate_p256_x509_data_and_pem() - cbor_p256_x509 = OwnershipVoucher.cbor_encode(voucher_p256_x509) + {:ok, key_p256_x509} = Keys.from_pem(key_p256_x509) + cbor_p256_x509 = OVCore.cbor_encode(voucher_p256_x509) id_p256_x509 = voucher_p256_x509.header.guid - insert_voucher(realm_name, key_p256_x509, cbor_p256_x509, id_p256_x509) + key_alg = :es256 + key_name = "ECDH256_X509_#{System.unique_integer([:positive])}" + {:ok, namespace} = Secrets.create_namespace(realm_name, key_alg) + + :ok = Secrets.import_key(key_name, key_alg, key_p256_x509, namespace: namespace) + {:ok, _key_p256_x509} = Secrets.get_key(key_name, namespace: namespace) + + attrs = %{ + key_name: key_name, + key_algorithm: key_alg, + voucher_data: cbor_p256_x509, + guid: id_p256_x509 + } + + insert_voucher(realm_name, attrs) hello_msg_p256_x509 = HelloDevice.generate(guid: id_p256_x509, kex_name: "ECDH256", easig_info: :es256) cbor_hello_p256_x509 = HelloDevice.cbor_encode(hello_msg_p256_x509) + key_alg = :es384 + key_name = "ECDH384_X509_#{System.unique_integer([:positive])}" + {voucher_p384_x509, key_p384_x509} = generate_p384_x509_data_and_pem() - cbor_p384_x509 = OwnershipVoucher.cbor_encode(voucher_p384_x509) + {:ok, key_p384_x509} = Keys.from_pem(key_p384_x509) + cbor_p384_x509 = OVCore.cbor_encode(voucher_p384_x509) id_p384_x509 = voucher_p384_x509.header.guid - insert_voucher(realm_name, key_p384_x509, cbor_p384_x509, id_p384_x509) + + {:ok, namespace} = Secrets.create_namespace(realm_name, key_alg) + + :ok = Secrets.import_key(key_name, key_alg, key_p384_x509, namespace: namespace) + {:ok, _key_p384_x509} = Secrets.get_key(key_name, namespace: namespace) + + attrs = %{ + key_name: key_name, + key_algorithm: key_alg, + voucher_data: cbor_p384_x509, + guid: id_p384_x509 + } + + insert_voucher(realm_name, attrs) hello_msg_p384_x509 = HelloDevice.generate(guid: id_p384_x509, kex_name: "ECDH384", easig_info: :es384) cbor_hello_p384_x509 = HelloDevice.cbor_encode(hello_msg_p384_x509) + key_alg = :es256 + key_name = "ECDH256_X5CHAIN_#{System.unique_integer([:positive])}" {voucher_p256_chain, key_p256_chain} = generate_p256_x5chain_data_and_pem() - cbor_p256_chain = OwnershipVoucher.cbor_encode(voucher_p256_chain) + {:ok, key_p256_chain} = Keys.from_pem(key_p256_chain) + cbor_p256_chain = OVCore.cbor_encode(voucher_p256_chain) id_p256_chain = voucher_p256_chain.header.guid - insert_voucher(realm_name, key_p256_chain, cbor_p256_chain, id_p256_chain) + {:ok, namespace} = Astarte.Secrets.create_namespace(realm_name, key_alg) + + :ok = Secrets.import_key(key_name, key_alg, key_p256_chain, namespace: namespace) + {:ok, _key_p256_chain} = Secrets.get_key(key_name, namespace: namespace) + + attrs = %{ + key_name: key_name, + key_algorithm: key_alg, + voucher_data: cbor_p256_chain, + guid: id_p256_chain + } + + insert_voucher(realm_name, attrs) hello_msg_p256_x5chain = HelloDevice.generate(guid: id_p256_chain, kex_name: "ECDH256", easig_info: :es256) cbor_hello_p256_chain = HelloDevice.cbor_encode(hello_msg_p256_x5chain) + key_alg = :es384 + key_name = "ECDH384_X5CHAIN_#{System.unique_integer([:positive])}" {voucher_p384_chain, key_p384_chain} = generate_p384_x5chain_data_and_pem() - cbor_p384_chain = OwnershipVoucher.cbor_encode(voucher_p384_chain) + {:ok, key_p384_chain} = Keys.from_pem(key_p384_chain) + cbor_p384_chain = OVCore.cbor_encode(voucher_p384_chain) id_p384_chain = voucher_p384_chain.header.guid - insert_voucher(realm_name, key_p384_chain, cbor_p384_chain, id_p384_chain) + {:ok, namespace} = Astarte.Secrets.create_namespace(realm_name, key_alg) + + :ok = Secrets.import_key(key_name, key_alg, key_p384_chain, namespace: namespace) + {:ok, _key_p384_chain} = Secrets.get_key(key_name, namespace: namespace) + + attrs = %{ + key_name: key_name, + key_algorithm: key_alg, + voucher_data: cbor_p384_chain, + guid: id_p384_chain + } + + insert_voucher(realm_name, attrs) hello_msg_p384_x5chain = HelloDevice.generate(guid: id_p384_chain, kex_name: "ECDH384", easig_info: :es384) @@ -68,10 +138,18 @@ defmodule Astarte.FDO.OwnerOnboarding.OwnerOnboardingTest do cbor_hello_p384_x5chain = HelloDevice.cbor_encode(hello_msg_p384_x5chain) %{ - p256_x509: %{id: id_p256_x509, cbor_hello: cbor_hello_p256_x509}, - p256_chain: %{id: id_p256_chain, cbor_hello: cbor_hello_p256_chain}, - p384_x509: %{id: id_p384_x509, cbor_hello: cbor_hello_p384_x509}, - p384_chain: %{id: id_p384_chain, cbor_hello: cbor_hello_p384_x5chain} + p256_x509: %{id: id_p256_x509, cbor_hello: cbor_hello_p256_x509, key_struct: key_p256_x509}, + p256_chain: %{ + id: id_p256_chain, + cbor_hello: cbor_hello_p256_chain, + key_struct: key_p256_chain + }, + p384_x509: %{id: id_p384_x509, cbor_hello: cbor_hello_p384_x509, key_struct: key_p384_x509}, + p384_chain: %{ + id: id_p384_chain, + cbor_hello: cbor_hello_p384_x5chain, + key_struct: key_p384_chain + } } end @@ -81,7 +159,7 @@ defmodule Astarte.FDO.OwnerOnboarding.OwnerOnboardingTest do OwnerOnboarding.hello_device(realm_name, ctx.cbor_hello) assert is_binary(token) - assert {:ok, sign1_msg} = Sign1.decode_cbor(resp_binary) + assert {:ok, sign1_msg} = Sign1.verify_decode(resp_binary, ctx.key_struct) assert sign1_msg.phdr.alg == :es256 end @@ -90,7 +168,7 @@ defmodule Astarte.FDO.OwnerOnboarding.OwnerOnboardingTest do OwnerOnboarding.hello_device(realm_name, ctx.cbor_hello) assert is_binary(token) - assert {:ok, sign1_msg} = Sign1.decode_cbor(resp_binary) + assert {:ok, sign1_msg} = Sign1.verify_decode(resp_binary, ctx.key_struct) assert sign1_msg.phdr.alg == :es384 end end @@ -103,19 +181,156 @@ defmodule Astarte.FDO.OwnerOnboarding.OwnerOnboardingTest do OwnerOnboarding.hello_device(realm_name, ctx.cbor_hello) assert is_binary(token) - assert {:ok, sign1_msg} = Sign1.decode_cbor(resp_binary) + assert {:ok, sign1_msg} = Sign1.verify_decode(resp_binary, ctx.key_struct) assert sign1_msg.phdr.alg == :es256 end test "P-384 with X5CHAIN: extracts key from P-384 certificate chain", %{ - realm: realm_name, + realm_name: realm_name, p384_chain: ctx } do assert {:ok, token, resp_binary} = OwnerOnboarding.hello_device(realm_name, ctx.cbor_hello) assert is_binary(token) - assert {:ok, sign1_msg} = Sign1.decode_cbor(resp_binary) + assert {:ok, sign1_msg} = Sign1.verify_decode(resp_binary, ctx.key_struct) assert sign1_msg.phdr.alg == :es384 end + + describe "build_owner_service_info_ready/3" do + test "successfully processes DeviceServiceInfoReady, creates new voucher, and returns OwnerServiceInfoReady", + %{ + realm: realm_name, + session: session + } do + new_hmac_value = :crypto.strong_rand_bytes(32) + new_hmac = %Hash{type: :hmac_sha256, hash: new_hmac_value} + device_max_size = 2048 + + assert {:ok, session, response} = + OwnerOnboarding.build_owner_service_info_ready( + realm_name, + session, + %DeviceServiceInfoReady{ + replacement_hmac: new_hmac, + max_owner_service_info_sz: device_max_size + } + ) + + assert session.replacement_hmac == new_hmac + assert response == [@max_device_service_info_sz] + + assert response == [@max_device_service_info_sz] + end + + test "handles nil HMAC correctly", %{ + realm_name: realm_name, + session: session + } do + assert {:ok, _session, _result} = + OwnerOnboarding.build_owner_service_info_ready( + realm_name, + session, + %DeviceServiceInfoReady{ + replacement_hmac: nil, + max_owner_service_info_sz: 2048 + } + ) + end + + test "handles the default recommended limit(nil info size) correctly", %{ + realm_name: realm_name, + session: session + } do + new_hmac = :crypto.strong_rand_bytes(32) + + assert {:ok, _, _result} = + OwnerOnboarding.build_owner_service_info_ready( + realm_name, + session, + %DeviceServiceInfoReady{ + replacement_hmac: %Hash{hash: new_hmac, type: :hmac_sha256}, + max_owner_service_info_sz: nil + } + ) + end + + test "handles the default recommended limit(0 info size) correctly", %{ + realm_name: realm_name, + session: session + } do + new_hmac = :crypto.strong_rand_bytes(32) + + assert {:ok, _, _result} = + OwnerOnboarding.build_owner_service_info_ready( + realm_name, + session, + %DeviceServiceInfoReady{ + replacement_hmac: %Hash{hash: new_hmac, type: :hmac_sha256}, + max_owner_service_info_sz: 0 + } + ) + end + + test "returns error for wrong session", %{ + realm_name: realm_name + } do + new_hmac = :crypto.strong_rand_bytes(32) + + assert {:error, :failed_66} = + OwnerOnboarding.build_owner_service_info_ready( + realm_name, + %Session{guid: :crypto.strong_rand_bytes(16)}, + %DeviceServiceInfoReady{ + replacement_hmac: %Hash{hash: new_hmac, type: :hmac_sha256}, + max_owner_service_info_sz: 0 + } + ) + end + end + + describe "generate_rsa_2048_key/0" do + test "returns an RSA private key record" do + key = OwnerOnboarding.generate_rsa_2048_key() + assert {:RSAPrivateKey, _, _, _, _, _, _, _, _, _, _} = key + end + end + + describe "get_public_key/1" do + test "returns {:ok, RSAPublicKey} for a valid RSA private key record" do + private_key = OwnerOnboarding.generate_rsa_2048_key() + + assert {:ok, {:RSAPublicKey, modulus, public_exponent}} = + OwnerOnboarding.get_public_key(private_key) + + assert is_integer(modulus) + assert is_integer(public_exponent) + end + + test "returns {:error, :invalid_private_key_format} for invalid input" do + assert {:error, :invalid_private_key_format} = OwnerOnboarding.get_public_key("not_a_key") + assert {:error, :invalid_private_key_format} = OwnerOnboarding.get_public_key(nil) + assert {:error, :invalid_private_key_format} = OwnerOnboarding.get_public_key(%{}) + end + end + + describe "ov_next_entry/3" do + test "returns {:ok, entry} for valid entry_num 0", %{realm_name: realm_name, device_id: guid} do + cbor_body = CBOR.encode([0]) + assert {:ok, _entry} = OwnerOnboarding.ov_next_entry(cbor_body, realm_name, guid) + end + + test "returns {:error, :message_body_error} for invalid CBOR body", context do + %{realm_name: realm_name, device_id: guid} = context + + assert {:error, :message_body_error} = + OwnerOnboarding.ov_next_entry(<<0xFF>>, realm_name, guid) + end + + test "returns error when guid does not match any voucher", %{realm_name: realm_name} do + cbor_body = CBOR.encode([0]) + unknown_guid = :crypto.strong_rand_bytes(16) + assert {:error, _} = OwnerOnboarding.ov_next_entry(cbor_body, realm_name, unknown_guid) + end + end end diff --git a/libs/astarte_fdo/test/astarte_fdo/owner_onboarding_test.exs b/libs/astarte_fdo/test/astarte_fdo/owner_onboarding_test.exs deleted file mode 100644 index 41cc6cc28..000000000 --- a/libs/astarte_fdo/test/astarte_fdo/owner_onboarding_test.exs +++ /dev/null @@ -1,323 +0,0 @@ -# -# This file is part of Astarte. -# -# Copyright 2025 SECO Mind Srl -# -# Licensed under the Apache License, Version 2.0 (the "License"); -# you may not use this file except in compliance with the License. -# You may obtain a copy of the License at -# -# http://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, software -# distributed under the License is distributed on an "AS IS" BASIS, -# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -# See the License for the specific language governing permissions and -# limitations under the License. -# - -defmodule Astarte.FDO.OwnerOnboardingTest do - use Astarte.Cases.Data, async: true - use Astarte.Cases.FDOSession - - alias Astarte.FDO.Core.Hash - alias Astarte.FDO.Core.OwnerOnboarding.DeviceServiceInfoReady - alias Astarte.FDO.Core.OwnerOnboarding.HelloDevice - alias Astarte.FDO.Core.OwnerOnboarding.Session - alias Astarte.FDO.OwnerOnboarding - alias Astarte.FDO.OwnershipVoucher - alias COSE.Messages.Sign1 - - @max_device_service_info_sz 4096 - - describe "build_owner_service_info_ready/3" do - setup %{ - realm: realm_name, - session: session, - owner_key_pem: owner_key_pem, - cbor_ownership_voucher: cbor_ownership_voucher - } do - insert_voucher(realm_name, owner_key_pem, cbor_ownership_voucher, session.guid) - %{realm: realm_name, session: session} - end - - @tag :skip - # TODO: re-enable this test when credential reuse logic is implemented. - test "successfully processes DeviceServiceInfoReady, creates new voucher, and returns OwnerServiceInfoReady", - %{ - realm: realm_name, - session: session - } do - new_hmac_value = :crypto.strong_rand_bytes(32) - new_hmac = %Hash{type: :hmac_sha256, hash: new_hmac_value} - device_max_size = 2048 - - assert {:ok, session, response} = - OwnerOnboarding.build_owner_service_info_ready( - realm_name, - session, - %DeviceServiceInfoReady{ - replacement_hmac: new_hmac, - max_owner_service_info_sz: device_max_size - } - ) - - assert session.replacement_hmac == new_hmac - assert OwnershipVoucher.credential_reuse?(session) == false - - assert response == [@max_device_service_info_sz] - end - - test "handles Credential Reuse (nil HMAC) correctly", %{ - realm: realm_name, - session: session - } do - session = %{session | replacement_guid: session.guid} - - assert {:ok, session, _result} = - OwnerOnboarding.build_owner_service_info_ready( - realm_name, - session, - %DeviceServiceInfoReady{ - replacement_hmac: nil, - max_owner_service_info_sz: 2048 - } - ) - - assert OwnershipVoucher.credential_reuse?(session) == true - end - - test "handles the default recommended limit(nil info size) correctly", %{ - realm: realm_name, - session: session - } do - new_hmac = :crypto.strong_rand_bytes(32) - - assert {:ok, _, _result} = - OwnerOnboarding.build_owner_service_info_ready( - realm_name, - session, - %DeviceServiceInfoReady{ - replacement_hmac: %Hash{hash: new_hmac, type: :hmac_sha256}, - max_owner_service_info_sz: nil - } - ) - end - - test "handles the default recommended limit(0 info size) correctly", %{ - realm: realm_name, - session: session - } do - new_hmac = :crypto.strong_rand_bytes(32) - - assert {:ok, _, _result} = - OwnerOnboarding.build_owner_service_info_ready( - realm_name, - session, - %DeviceServiceInfoReady{ - replacement_hmac: %Hash{hash: new_hmac, type: :hmac_sha256}, - max_owner_service_info_sz: 0 - } - ) - end - - test "returns error for wrong session", %{ - realm: realm_name - } do - new_hmac = :crypto.strong_rand_bytes(32) - - assert {:error, :failed_66} = - OwnerOnboarding.build_owner_service_info_ready( - realm_name, - %Session{guid: :crypto.strong_rand_bytes(16)}, - %DeviceServiceInfoReady{ - replacement_hmac: %Hash{hash: new_hmac, type: :hmac_sha256}, - max_owner_service_info_sz: 0 - } - ) - end - end - - describe "hello_device/2" do - setup %{ - realm: realm_name, - owner_key_pem: owner_key_pem, - cbor_ownership_voucher: cbor_ownership_voucher, - session: session, - hello_device: hello_device - } do - insert_voucher(realm_name, owner_key_pem, cbor_ownership_voucher, session.guid) - %{cbor_hello_device: HelloDevice.cbor_encode(hello_device)} - end - - @tag owner_key: "EC256" - test "returns a correct ProveOVHdr message signed with EC256 owner key", - %{ - realm_name: realm_name, - cbor_hello_device: cbor_hello_device, - owner_key: owner_key - } do - assert {:ok, session_key, prove_ovhdr_bin} = - OwnerOnboarding.hello_device(realm_name, cbor_hello_device) - - assert is_binary(session_key) - - assert {:ok, %Sign1{} = prove_ovhdr_dec} = - Sign1.verify_decode(prove_ovhdr_bin, owner_key) - - assert prove_ovhdr_dec.phdr.alg == :es256 - end - - @tag owner_key: "EC384" - test "returns a correct ProveOVHdr message signed with EC384 owner key", - %{ - realm_name: realm_name, - cbor_hello_device: cbor_hello_device, - owner_key: owner_key - } do - assert {:ok, session_key, prove_ovhdr_bin} = - OwnerOnboarding.hello_device(realm_name, cbor_hello_device) - - assert is_binary(session_key) - - assert {:ok, %Sign1{} = prove_ovhdr_dec} = - Sign1.verify_decode(prove_ovhdr_bin, owner_key) - - assert prove_ovhdr_dec.phdr.alg == :es384 - end - - @tag owner_key: "RSA2048" - test "returns a correct ProveOVHdr message signed with RSA2048 owner key", - %{ - realm_name: realm_name, - cbor_hello_device: cbor_hello_device, - owner_key: owner_key - } do - assert {:ok, session_key, prove_ovhdr_bin} = - OwnerOnboarding.hello_device(realm_name, cbor_hello_device) - - assert is_binary(session_key) - - assert {:ok, %Sign1{} = prove_ovhdr_dec} = - Sign1.verify_decode(prove_ovhdr_bin, owner_key) - - assert prove_ovhdr_dec.phdr.alg == :rs256 - end - - @tag owner_key: "RSA3072" - test "returns a correct ProveOVHdr message signed with RSA3072 owner key", - %{ - realm_name: realm_name, - cbor_hello_device: cbor_hello_device, - owner_key: owner_key - } do - assert {:ok, session_key, prove_ovhdr_bin} = - OwnerOnboarding.hello_device(realm_name, cbor_hello_device) - - assert is_binary(session_key) - - assert {:ok, %Sign1{} = prove_ovhdr_dec} = - Sign1.verify_decode(prove_ovhdr_bin, owner_key) - - assert prove_ovhdr_dec.phdr.alg == :rs384 - end - end - - describe "generate_rsa_2048_key/0" do - test "returns an RSA private key record" do - key = OwnerOnboarding.generate_rsa_2048_key() - assert {:RSAPrivateKey, _, _, _, _, _, _, _, _, _, _} = key - end - end - - describe "get_public_key/1" do - test "returns {:ok, RSAPublicKey} for a valid RSA private key record" do - private_key = OwnerOnboarding.generate_rsa_2048_key() - - assert {:ok, {:RSAPublicKey, modulus, public_exponent}} = - OwnerOnboarding.get_public_key(private_key) - - assert is_integer(modulus) - assert is_integer(public_exponent) - end - - test "returns {:error, :invalid_private_key_format} for invalid input" do - assert {:error, :invalid_private_key_format} = OwnerOnboarding.get_public_key("not_a_key") - assert {:error, :invalid_private_key_format} = OwnerOnboarding.get_public_key(nil) - assert {:error, :invalid_private_key_format} = OwnerOnboarding.get_public_key(%{}) - end - end - - describe "fetch_alg/1 with map input" do - test "returns {:ok, :es256} for algorithm -7" do - assert {:ok, :es256} = OwnerOnboarding.fetch_alg(%{1 => -7}) - end - - test "returns {:ok, :edsdsa} for algorithm -8" do - assert {:ok, :edsdsa} = OwnerOnboarding.fetch_alg(%{1 => -8}) - end - - test "returns {:error, :unsupported_alg} for unknown algorithm" do - assert {:error, :unsupported_alg} = OwnerOnboarding.fetch_alg(%{1 => 999}) - end - - test "returns {:error, :unsupported_alg} when alg key is missing" do - assert {:error, :unsupported_alg} = OwnerOnboarding.fetch_alg(%{}) - end - end - - describe "fetch_alg/1 with binary CBOR input" do - test "decodes CBOR and returns the algorithm" do - cbor = CBOR.encode(%{1 => -7}) - assert {:ok, :es256} = OwnerOnboarding.fetch_alg(cbor) - end - - test "returns {:error, :unsupported_alg} for CBOR with unknown algorithm" do - cbor = CBOR.encode(%{1 => 42}) - assert {:error, :unsupported_alg} = OwnerOnboarding.fetch_alg(cbor) - end - end - - describe "build_sig_structure/2" do - test "returns {:ok, CBOR-encoded sig structure}" do - protected = CBOR.encode(%{1 => -7}) - payload = :crypto.strong_rand_bytes(32) - - assert {:ok, cbor} = OwnerOnboarding.build_sig_structure(protected, payload) - assert is_binary(cbor) - - assert {:ok, ["Signature1", ^protected, <<>>, ^payload], ""} = CBOR.decode(cbor) - end - end - - describe "ov_next_entry/3" do - setup %{ - realm: realm_name, - owner_key_pem: owner_key_pem, - cbor_ownership_voucher: cbor_ownership_voucher, - session: session - } do - insert_voucher(realm_name, owner_key_pem, cbor_ownership_voucher, session.guid) - %{realm: realm_name, guid: session.guid} - end - - test "returns {:ok, entry} for valid entry_num 0", %{realm: realm_name, guid: guid} do - cbor_body = CBOR.encode([0]) - assert {:ok, _entry} = OwnerOnboarding.ov_next_entry(cbor_body, realm_name, guid) - end - - test "returns {:error, :message_body_error} for invalid CBOR body", %{ - realm: realm_name, - guid: guid - } do - assert {:error, :message_body_error} = - OwnerOnboarding.ov_next_entry(<<0xFF>>, realm_name, guid) - end - - test "returns error when guid does not match any voucher", %{realm: realm_name} do - cbor_body = CBOR.encode([0]) - unknown_guid = :crypto.strong_rand_bytes(16) - assert {:error, _} = OwnerOnboarding.ov_next_entry(cbor_body, realm_name, unknown_guid) - end - end -end diff --git a/libs/astarte_fdo/test/astarte_fdo/ownership_voucher/create_request_test.exs b/libs/astarte_fdo/test/astarte_fdo/ownership_voucher/create_request_test.exs index 88b767f19..39e7a4aa0 100644 --- a/libs/astarte_fdo/test/astarte_fdo/ownership_voucher/create_request_test.exs +++ b/libs/astarte_fdo/test/astarte_fdo/ownership_voucher/create_request_test.exs @@ -16,10 +16,10 @@ # limitations under the License. # -defmodule Astarte.DataAccess.FDO.OwnershipVoucher.CreateRequestTest do +defmodule Astarte.FDO.Core.OwnershipVoucher.CreateRequestTest do use ExUnit.Case, async: true - alias Astarte.DataAccess.FDO.OwnershipVoucher.CreateRequest + alias Astarte.FDO.Core.OwnershipVoucher.CreateRequest import Astarte.FDO.Helpers diff --git a/libs/astarte_fdo/test/astarte_fdo/ownership_voucher/load_request_test.exs b/libs/astarte_fdo/test/astarte_fdo/ownership_voucher/load_request_test.exs index 597dbf780..835170fa3 100644 --- a/libs/astarte_fdo/test/astarte_fdo/ownership_voucher/load_request_test.exs +++ b/libs/astarte_fdo/test/astarte_fdo/ownership_voucher/load_request_test.exs @@ -20,7 +20,8 @@ defmodule Astarte.FDO.OwnershipVoucher.LoadRequestTest do use ExUnit.Case, async: true use Mimic - alias Astarte.FDO.Core.OwnershipVoucher, as: CoreOwnershipVoucher + alias Astarte.FDO.Core.OwnershipVoucher.Core, as: OVCore + alias Astarte.FDO.Core.PublicKey alias Astarte.FDO.OwnershipVoucher.LoadRequest alias Astarte.Secrets alias Astarte.Secrets.Key @@ -28,7 +29,7 @@ defmodule Astarte.FDO.OwnershipVoucher.LoadRequestTest do import Astarte.FDO.Helpers # The public key PEM that matches the last entry of @sample_voucher. - # Extracted via OVCore.entry_private_key/1 on the sample voucher. + # Extracted via OVCore.entry_public_key/1 on the sample voucher. @sample_owner_public_key_pem """ -----BEGIN PUBLIC KEY----- MFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAES+TkA7VtJQv9YQ75yl5btXKR/cso @@ -37,11 +38,13 @@ defmodule Astarte.FDO.OwnershipVoucher.LoadRequestTest do """ @sample_key_name "owner_key" + @sample_key_algorithm "ecdsa-p256" @sample_realm "test_realm" @sample_params %{ "ownership_voucher" => sample_voucher(), "key_name" => @sample_key_name, + "key_algorithm" => @sample_key_algorithm, "realm_name" => @sample_realm } @@ -82,10 +85,6 @@ defmodule Astarte.FDO.OwnershipVoucher.LoadRequestTest do from_changeset!(@sample_params) end - test "populates `owner_key_algorithm` as `:es256` for a secp256r1 voucher" do - assert %LoadRequest{owner_key_algorithm: :es256} = from_changeset!(@sample_params) - end - test "populates `extracted_owner_key` with the key returned by Secrets" do assert %LoadRequest{extracted_owner_key: key} = from_changeset!(@sample_params) assert key == @sample_secrets_key @@ -130,6 +129,12 @@ defmodule Astarte.FDO.OwnershipVoucher.LoadRequestTest do assert %{key_name: [_ | _]} = errors_on(changeset) end + test "a missing `key_algorithm`" do + params = Map.delete(@sample_params, "key_algorithm") + assert {:error, changeset} = from_changeset(params) + assert %{key_algorithm: [_ | _]} = errors_on(changeset) + end + test "a missing `realm_name`" do params = Map.delete(@sample_params, "realm_name") assert {:error, changeset} = from_changeset(params) @@ -202,23 +207,333 @@ defmodule Astarte.FDO.OwnershipVoucher.LoadRequestTest do Ecto.Changeset.traverse_errors(changeset, fn {msg, _opts} -> msg end) end - describe "CoreOwnershipVoucher.key_algorithm_from_voucher/1" do - test "returns {:ok, :es256} for the sample secp256r1 voucher" do - assert {:ok, :es256} = CoreOwnershipVoucher.key_algorithm_from_voucher(sample_voucher()) + defp sample_replacement_public_key_pem do + @sample_owner_public_key_pem + end + + defp sample_replacement_rendezvous_info_b64 do + CBOR.encode([]) |> Base.encode64() + end + + describe "changeset/2 with replacement fields" do + setup do + stub(Secrets, :create_namespace, fn _realm, _alg -> + {:ok, "fdo_owner_keys/test_realm/ecdsa-p256"} + end) + + stub(Secrets, :get_key, fn _name, _opts -> {:ok, @sample_secrets_key} end) + :ok + end + + test "accepts params without replacement fields" do + assert %LoadRequest{} = from_changeset!(@sample_params) + end + + test "accepts a valid `replacement_rendezvous_info`" do + params = + Map.put( + @sample_params, + "replacement_rendezvous_info", + sample_replacement_rendezvous_info_b64() + ) + + assert %LoadRequest{} = from_changeset!(params) + end + + test "accepts a valid `replacement_public_key`" do + params = + Map.put(@sample_params, "replacement_public_key", sample_replacement_public_key_pem()) + + assert %LoadRequest{} = from_changeset!(params) + end + + test "accepts a valid base64 `replacement_guid`" do + guid = <<1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16>> + params = Map.put(@sample_params, "replacement_guid", Base.encode64(guid)) + + assert %LoadRequest{} = from_changeset!(params) + end + + test "rejects invalid base64 for `replacement_rendezvous_info`" do + params = Map.put(@sample_params, "replacement_rendezvous_info", "not-valid-base64!!!") + + assert {:error, changeset} = from_changeset(params) + assert %{replacement_rendezvous_info: [_ | _]} = errors_on(changeset) + end + + test "rejects invalid CBOR for `replacement_rendezvous_info`" do + params = + Map.put( + @sample_params, + "replacement_rendezvous_info", + Base.encode64(<<0xFF, 0xFE>>) + ) + + assert {:error, changeset} = from_changeset(params) + assert %{replacement_rendezvous_info: [_ | _]} = errors_on(changeset) + end + + test "rejects invalid PEM for `replacement_public_key`" do + params = Map.put(@sample_params, "replacement_public_key", "not a valid PEM") + + assert {:error, changeset} = from_changeset(params) + assert %{replacement_public_key: [_ | _]} = errors_on(changeset) + end + + test "rejects invalid base64 for `replacement_guid`" do + params = Map.put(@sample_params, "replacement_guid", "not-valid-base64!!!") + + assert {:error, changeset} = from_changeset(params) + assert %{replacement_guid: [_ | _]} = errors_on(changeset) + end + end + + describe "changeset/2 with a secp384r1/x509 voucher" do + test "parses successfully and sets key_algorithm to :es384" do + {voucher, private_pem} = generate_p384_x509_data_and_pem() + voucher_pem = voucher_to_pem(voucher) + public_pem = ec_private_pem_to_public_pem(private_pem) + + p384_key = %Key{ + name: @sample_key_name, + namespace: "fdo_owner_keys/#{@sample_realm}/ecdsa-p384", + alg: :es384, + public_pem: public_pem + } + + stub(Secrets, :create_namespace, fn _realm, :es384 -> + {:ok, "fdo_owner_keys/#{@sample_realm}/ecdsa-p384"} + end) + + stub(Secrets, :get_key, fn _name, _opts -> {:ok, p384_key} end) + + params = + @sample_params + |> Map.put("ownership_voucher", voucher_pem) + |> Map.put("key_algorithm", "ecdsa-p384") + + assert %LoadRequest{key_algorithm: :es384} = from_changeset!(params) end + end + + describe "changeset/2 with a secp256r1/x5chain voucher" do + test "parses successfully and populates device_guid" do + {voucher, private_pem} = generate_p256_x5chain_data_and_pem() + voucher_pem = voucher_to_pem(voucher) + public_pem = ec_private_pem_to_public_pem(private_pem) + + x5chain_key = %Key{ + name: @sample_key_name, + namespace: "fdo_owner_keys/#{@sample_realm}/ecdsa-p256", + alg: :es256, + public_pem: public_pem + } + + stub(Secrets, :create_namespace, fn _realm, :es256 -> + {:ok, "fdo_owner_keys/#{@sample_realm}/ecdsa-p256"} + end) + + stub(Secrets, :get_key, fn _name, _opts -> {:ok, x5chain_key} end) + + params = Map.put(@sample_params, "ownership_voucher", voucher_pem) + + result = from_changeset!(params) + assert %LoadRequest{key_algorithm: :es256, device_guid: guid} = result + assert is_binary(guid) and byte_size(guid) == 16 + end + end + + describe "changeset/2 public_keys_match? :x5chain path" do + test "accepts a key whose EC point is embedded in the x5chain certificate" do + # generate_p256_x509_data_and_pem returns a voucher whose cert_chain holds + # a real self-signed DER cert for the device key. + {voucher, private_pem} = generate_p256_x509_data_and_pem() + [cert_der | _] = voucher.cert_chain + public_pem = ec_private_pem_to_public_pem(private_pem) + + stub(OVCore, :entry_public_key, fn _entry -> + {:ok, %PublicKey{encoding: :x5chain, body: [cert_der], type: :secp256r1}} + end) + + matching_key = %Key{ + name: @sample_key_name, + namespace: "fdo_owner_keys/#{@sample_realm}/ecdsa-p256", + alg: :es256, + public_pem: public_pem + } + + stub(Secrets, :create_namespace, fn _realm, :es256 -> + {:ok, "fdo_owner_keys/#{@sample_realm}/ecdsa-p256"} + end) + + stub(Secrets, :get_key, fn _name, _opts -> {:ok, matching_key} end) + + assert %LoadRequest{key_algorithm: :es256} = from_changeset!(@sample_params) + end + + test "rejects a key whose EC point is NOT in the x5chain certificate" do + {voucher, _private_pem} = generate_p256_x509_data_and_pem() + [cert_der | _] = voucher.cert_chain + + {_other_voucher, other_private_pem} = generate_p256_x509_data_and_pem() + wrong_public_pem = ec_private_pem_to_public_pem(other_private_pem) + + stub(OVCore, :entry_public_key, fn _entry -> + {:ok, %PublicKey{encoding: :x5chain, body: [cert_der], type: :secp256r1}} + end) + + wrong_key = %Key{ + name: @sample_key_name, + namespace: "fdo_owner_keys/#{@sample_realm}/ecdsa-p256", + alg: :es256, + public_pem: wrong_public_pem + } + + stub(Secrets, :create_namespace, fn _realm, :es256 -> + {:ok, "fdo_owner_keys/#{@sample_realm}/ecdsa-p256"} + end) + + stub(Secrets, :get_key, fn _name, _opts -> {:ok, wrong_key} end) + + assert {:error, changeset} = from_changeset(@sample_params) + + assert %{key_name: ["does not match the public key in the ownership voucher's last entry"]} = + errors_on(changeset) + end + end + + describe "changeset/2 with a secp384r1/x5chain voucher" do + test "parses successfully and sets key_algorithm to :es384" do + {voucher, private_pem} = generate_p384_x5chain_data_and_pem() + voucher_pem = voucher_to_pem(voucher) + public_pem = ec_private_pem_to_public_pem(private_pem) + + x5chain_p384_key = %Key{ + name: @sample_key_name, + namespace: "fdo_owner_keys/#{@sample_realm}/ecdsa-p384", + alg: :es384, + public_pem: public_pem + } + + stub(Secrets, :create_namespace, fn _realm, :es384 -> + {:ok, "fdo_owner_keys/#{@sample_realm}/ecdsa-p384"} + end) + + stub(Secrets, :get_key, fn _name, _opts -> {:ok, x5chain_p384_key} end) + + params = + @sample_params + |> Map.put("ownership_voucher", voucher_pem) + |> Map.put("key_algorithm", "ecdsa-p384") + + assert %LoadRequest{key_algorithm: :es384} = from_changeset!(params) + end + + test "rejects a mismatched key even with x5chain encoding" do + {voucher, _private_pem} = generate_p384_x5chain_data_and_pem() + voucher_pem = voucher_to_pem(voucher) + + {_other_voucher, other_private_pem} = generate_p384_x5chain_data_and_pem() + wrong_public_pem = ec_private_pem_to_public_pem(other_private_pem) + + wrong_key = %Key{ + name: @sample_key_name, + namespace: "fdo_owner_keys/#{@sample_realm}/ecdsa-p384", + alg: :es384, + public_pem: wrong_public_pem + } + + stub(Secrets, :create_namespace, fn _realm, :es384 -> + {:ok, "fdo_owner_keys/#{@sample_realm}/ecdsa-p384"} + end) + + stub(Secrets, :get_key, fn _name, _opts -> {:ok, wrong_key} end) + + params = + @sample_params + |> Map.put("ownership_voucher", voucher_pem) + |> Map.put("key_algorithm", "ecdsa-p384") + + assert {:error, changeset} = from_changeset(params) + + assert %{key_name: ["does not match the public key in the ownership voucher's last entry"]} = + errors_on(changeset) + end + end + + defp ec_private_pem_to_public_pem(private_pem) do + [{:ECPrivateKey, priv_der, _}] = :public_key.pem_decode(private_pem) + + {:ECPrivateKey, _version, _priv_bytes, named_curve, pub_point, _} = + :public_key.der_decode(:ECPrivateKey, priv_der) + + pub_entry = + :public_key.pem_entry_encode(:SubjectPublicKeyInfo, {{:ECPoint, pub_point}, named_curve}) + + :public_key.pem_encode([pub_entry]) + end + + # P-384 SPKI public key, used to test :secp384r1 detection in replacement_public_key. + @p384_public_key_pem """ + -----BEGIN PUBLIC KEY----- + MHYwEAYHKoZIzj0CAQYFK4EEACIDYgAE/d7hrn2G8xmA/TclvzfKGQt9ZM5QjQv9 + JbK0g342jZIKzixJbi/sm0wmLRETRT3NlEKzes/Yb7sL1PJ0RBaGIWZXtIqLv1Dp + TUcig2gSQptVEOfP15CbfsvMyaQVvvmC + -----END PUBLIC KEY----- + """ + + # RSA PKCS#1 SubjectPublicKeyInfo PEM (OID 1.2.840.113549.1.1.1). + @rsa_pkcs1_public_key_pem """ + -----BEGIN PUBLIC KEY----- + MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAyB/4pewJa3mpxm9SCQBt + 9xfXzntZLcEw3AC5/46+0Smpr9cJP/NcP3maA20jg6KNSMJLb3i97TGkGBdW0Tlf + q9ZTrnI/zJeyM7nARf8O3LOxdlvGcNvvnCN5EYc5MrDnyBiJp6EvMlrHxirfjmP4 + MdEhTNvOdyzojrl7CVWH3EqoqQwC5up/aAWTT15lzGmQght8goLVm7K4UPdxufPO + shbKvVI72J8FdeCbYNXtntQZxvIfHZGKgN5/VjZtrHd40OIUJ7Up/GTACqfqutZi + axmrbJHObcjbeZerF22SYtKRq2QO9falJg/uvutpD6CKV4BNv9l+bfPV0TbJN5Ox + iwIDAQAB + -----END PUBLIC KEY----- + """ + + # RSA-PSS SubjectPublicKeyInfo PEM (OID 1.2.840.113549.1.1.10). + @rsa_pss_public_key_pem """ + -----BEGIN PUBLIC KEY----- + MIIBIDALBgkqhkiG9w0BAQoDggEPADCCAQoCggEBALvFtCJDMblQGzqFu5as4ppG + ILk8Uks0plnPnlmRjpoSKmaMXhEMMEvGVTCBu6DL9NFFDLnOefQnvfDvSCmtBXSk + WDvSQiDYpZhWQLFfcbbSQNYM4R4yqOFajh7zO9SxFULkzFcFP3D4K0s2qggdiKVA + e/Lri8o0pI4IkorN6yipeKBkxByUUDsjbGvbNOK+GDN1+4yEK5waEgFyMx5e3wix + LXyI0UFPKjbmYJ677fptOyvOeLTxwPmM8gx71JG+wjycJ5rA2UpCcqzWWk6XZMlV + PIzy3xRbtDaXQso+p3Iv/GZAkRLYoyA/R0cMevtJ0VDl1l0+X3MrS8XVXdIU+/EC + AwEAAQ== + -----END PUBLIC KEY----- + """ + + describe "changeset/2 replacement_public_key validation" do + setup do + stub(Secrets, :create_namespace, fn _realm, _alg -> + {:ok, "fdo_owner_keys/test_realm/ecdsa-p256"} + end) + + stub(Secrets, :get_key, fn _name, _opts -> {:ok, @sample_secrets_key} end) + :ok + end + + test "accepts a P-384 SPKI PEM as `replacement_public_key`" do + params = Map.put(@sample_params, "replacement_public_key", @p384_public_key_pem) + + assert %LoadRequest{} = from_changeset!(params) + end + + test "accepts an RSA PKCS#1 SPKI PEM as `replacement_public_key`" do + params = Map.put(@sample_params, "replacement_public_key", @rsa_pkcs1_public_key_pem) - test "returns {:error, _} for an invalid PEM string" do - assert {:error, _} = CoreOwnershipVoucher.key_algorithm_from_voucher("not a voucher") + assert %LoadRequest{} = from_changeset!(params) end - test "returns {:error, _} for a malformed base64 body" do - bad_voucher = """ - -----BEGIN OWNERSHIP VOUCHER----- - * not valid base64 * - -----END OWNERSHIP VOUCHER----- - """ + test "accepts an RSA-PSS SPKI PEM as `replacement_public_key`" do + params = Map.put(@sample_params, "replacement_public_key", @rsa_pss_public_key_pem) - assert {:error, _} = CoreOwnershipVoucher.key_algorithm_from_voucher(bad_voucher) + assert %LoadRequest{} = from_changeset!(params) end end end diff --git a/libs/astarte_fdo/test/astarte_fdo/ownership_voucher_test.exs b/libs/astarte_fdo/test/astarte_fdo/ownership_voucher_test.exs index 0bf708866..3cc9fa875 100644 --- a/libs/astarte_fdo/test/astarte_fdo/ownership_voucher_test.exs +++ b/libs/astarte_fdo/test/astarte_fdo/ownership_voucher_test.exs @@ -39,15 +39,20 @@ defmodule Astarte.FDO.OwnershipVoucherTest do device_id: device_id } = ctx - assert :ok = - OwnershipVoucher.save_voucher( - realm_name, - Helpers.sample_voucher(), - device_id, - Helpers.sample_private_key() - ) + key_name = "some_key" + key_alg = :es256 - assert Queries.get_owner_private_key(realm_name, device_id) + attrs = %{ + guid: device_id, + key_name: key_name, + key_algorithm: key_alg, + voucher_data: Helpers.sample_cbor_voucher() + } + + assert :ok = OwnershipVoucher.save_voucher(realm_name, attrs) + + assert {:ok, key_data} = Queries.get_owner_key_params(realm_name, device_id) + assert %{name: key_name, algorithm: key_alg} == key_data end end diff --git a/libs/astarte_fdo/test/astarte_fdo/service_info_test.exs b/libs/astarte_fdo/test/astarte_fdo/service_info_test.exs index a3e058cad..91caa4bfc 100644 --- a/libs/astarte_fdo/test/astarte_fdo/service_info_test.exs +++ b/libs/astarte_fdo/test/astarte_fdo/service_info_test.exs @@ -39,7 +39,22 @@ defmodule Astarte.FDO.ServiceInfoTest do device_key = ECC.generate(:es256) {:ok, device_random, xb} = SessionKey.new(hello_device.kex_name) - insert_voucher(realm_name, sample_private_key(), sample_cbor_voucher(), device_id) + key_alg = :es256 + key_name = "default" + {:ok, namespace} = Astarte.Secrets.create_namespace(realm_name, key_alg) + + Astarte.Secrets.import_key(key_name, key_alg, owner_key, namespace: namespace) + + {:ok, owner_key} = Astarte.Secrets.get_key(key_name, namespace: namespace) + + attrs = %{ + key_name: key_name, + key_algorithm: key_alg, + voucher_data: ownership_voucher, + guid: device_id + } + + insert_voucher(realm_name, attrs) %{ device_id: device_id, diff --git a/libs/astarte_fdo/test/support/cases/fdo_session.ex b/libs/astarte_fdo/test/support/cases/fdo_session.ex index d4d5938c9..b9f5efb0d 100644 --- a/libs/astarte_fdo/test/support/cases/fdo_session.ex +++ b/libs/astarte_fdo/test/support/cases/fdo_session.ex @@ -40,6 +40,7 @@ defmodule Astarte.Cases.FDOSession do alias Astarte.FDO.Core.OwnerOnboarding.SessionKey alias Astarte.FDO.Core.OwnershipVoucher alias Astarte.FDO.OwnerOnboarding.KeyExchangeStrategy + alias Astarte.Secrets alias COSE.Keys.{ECC, RSA} import Astarte.FDO.Helpers.Database @@ -66,6 +67,7 @@ defmodule Astarte.Cases.FDOSession do # use test tag 'owner_key' to select non-default keys # default: EC256 keys key_type = Map.get(context, :owner_key, "EC256") + key_name = :crypto.strong_rand_bytes(8) |> :binary.encode_hex() |> String.downcase() if key_type not in @allowed_owner_key_tag_values, do: raise("unsupported owner_key tag value: #{key_type}") @@ -75,15 +77,32 @@ defmodule Astarte.Cases.FDOSession do cbor_ownership_voucher = OwnershipVoucher.cbor_encode(ownership_voucher) device_id = Device.random_device_id() - insert_voucher( - context.realm_name, - owner_key_pem, - cbor_ownership_voucher, - device_id - ) + key_alg = + case key_type do + "EC256" -> :es256 + "EC384" -> :es384 + "RSA2048" -> :rs256 + "RSA3072" -> :rs384 + end + + {:ok, namespace} = Secrets.create_namespace(context.realm_name, key_alg) + + Secrets.import_key(key_name, key_alg, owner_key_struct, namespace: namespace) + + {:ok, owner_key} = Secrets.get_key(key_name, namespace: namespace) + + attrs = %{ + key_name: key_name, + key_algorithm: key_alg, + voucher_data: cbor_ownership_voucher, + guid: device_id + } + + insert_voucher(context.realm_name, attrs) %{ - owner_key: owner_key_struct, + owner_key: owner_key, + owner_key_struct: owner_key_struct, owner_key_pem: owner_key_pem, ownership_voucher: ownership_voucher, cbor_ownership_voucher: cbor_ownership_voucher, @@ -102,7 +121,7 @@ defmodule Astarte.Cases.FDOSession do if kex_name not in @allowed_kex_name_tag_values, do: raise("unsupported kex_name tag value: #{kex_name}") - if KeyExchangeStrategy.validate(kex_name, context.owner_key) != :ok, + if KeyExchangeStrategy.validate(kex_name, context.owner_key.alg) != :ok, do: raise( "unsupported association owner key type #{context.owner_key.alg} <-> KEX alg #{kex_name}" @@ -189,13 +208,24 @@ defmodule Astarte.Cases.FDOSession do kn when kn in ["ASYMKEX2048", "ASYMKEX3072"] -> {:ok, device_rand, _} = SessionKey.new(kn) + # Owner RSA key used to encrypt/decrypt device rand - pub_key_record = owner_key |> RSA.to_public_record() + pub_key_record = extract_public_record(owner_key) + xb = asymkex_msg_encryption(device_rand, pub_key_record) {:ok, device_rand, xb} end end + defp extract_public_record(%Astarte.Secrets.Key{} = openbao_key) do + [pem_entry] = :public_key.pem_decode(openbao_key.public_pem) + :public_key.pem_entry_decode(pem_entry) + end + + defp extract_public_record(%COSE.Keys.RSA{} = raw_rsa_key) do + RSA.to_public_record(raw_rsa_key) + end + defp asymkex_msg_encryption(msg, pub_rsa_key) do encrypt_opts = [ rsa_padding: :rsa_pkcs1_oaep_padding, diff --git a/libs/astarte_fdo/test/support/helpers/database.ex b/libs/astarte_fdo/test/support/helpers/database.ex index 87ae75654..f16b68e56 100644 --- a/libs/astarte_fdo/test/support/helpers/database.ex +++ b/libs/astarte_fdo/test/support/helpers/database.ex @@ -83,9 +83,15 @@ defmodule Astarte.FDO.Helpers.Database do @create_ownership_vouchers_table """ CREATE TABLE :keyspace.ownership_vouchers ( - private_key blob, - voucher_data blob, guid blob, + voucher_data blob, + output_voucher blob, + replacement_guid blob, + replacement_rendezvous_info blob, + replacement_public_key blob, + key_name varchar, + key_algorithm int, + user_id blob, PRIMARY KEY (guid) ); """ diff --git a/libs/astarte_fdo/test/support/helpers/fdo.ex b/libs/astarte_fdo/test/support/helpers/fdo.ex index 296a24cc5..547cbe444 100644 --- a/libs/astarte_fdo/test/support/helpers/fdo.ex +++ b/libs/astarte_fdo/test/support/helpers/fdo.ex @@ -24,16 +24,17 @@ defmodule Astarte.FDO.Helpers do import StreamData alias Astarte.DataAccess.FDO.OwnershipVoucher, as: DBOwnershipVoucher - alias Astarte.DataAccess.FDO.OwnershipVoucher.CreateRequest alias Astarte.DataAccess.Realms.Realm alias Astarte.DataAccess.Repo alias Astarte.FDO.Core.Hash alias Astarte.FDO.Core.OwnershipVoucher + alias Astarte.FDO.Core.OwnershipVoucher.CreateRequest alias Astarte.FDO.Core.OwnershipVoucher.RendezvousInfo alias Astarte.FDO.Core.OwnershipVoucher.RendezvousInfo.RendezvousDirective alias Astarte.FDO.Core.OwnershipVoucher.RendezvousInfo.RendezvousInstr alias Astarte.FDO.Core.PublicKey alias COSE.Keys.ECC + alias COSE.Keys.RSA alias COSE.Messages.Sign1 @sample_voucher """ @@ -169,12 +170,9 @@ defmodule Astarte.FDO.Helpers do CBOR.encode([%CBOR.Tag{tag: :bytes, value: nonce}]) end - def insert_voucher(realm_name, private_key, cbor_voucher, guid) do - %DBOwnershipVoucher{ - voucher_data: cbor_voucher, - private_key: private_key, - guid: guid - } + def insert_voucher(realm_name, attrs) when is_map(attrs) do + %DBOwnershipVoucher{} + |> DBOwnershipVoucher.changeset(attrs) |> Repo.insert(prefix: Realm.keyspace_name(realm_name)) end @@ -194,6 +192,88 @@ defmodule Astarte.FDO.Helpers do generate_voucher_data_and_pem() end + @doc """ + Generates a voucher with FDO public key type `:rsapss` (compatible with both + `:rs256` and `:rs384` owner keys) and returns `{voucher, owner_rsa_private_key_pem}`. + + Options: + - `:alg` - `:rs256` (2048-bit, default) or `:rs384` (3072-bit) + - `:owner_key` - a `%COSE.Keys.RSA{}` key; generated fresh if not given + """ + def generate_rsapss_data_and_pem(opts \\ []) do + alg = Keyword.get(opts, :alg, :rs256) + owner_key = Keyword.get_lazy(opts, :owner_key, fn -> RSA.generate(alg) end) + + # Use an ECC P-256 device cert for the cert-chain (keeps things simple) + {oid, hash_alg, sig_alg, _, _} = get_curve_params(:p256) + device_ecc_key = ECC.generate(:es256) + device_pub_key_point = <<4, device_ecc_key.x::binary, device_ecc_key.y::binary>> + + device_priv_key = { + :ECPrivateKey, + 1, + device_ecc_key.d, + {:namedCurve, oid}, + device_pub_key_point, + :asn1_NOVALUE + } + + cert_der = generate_self_signed_cert(device_pub_key_point, device_priv_key, oid, sig_alg) + cert_chain_hash_struct = Hash.new(hash_alg, cert_der) + + guid_raw = UUID.uuid4(:raw) + + rv_info = %RendezvousInfo{ + directives: [ + %RendezvousDirective{ + instructions: [ + %RendezvousInstr{rv_variable: :dev_port, rv_value: <<25, 31, 146>>}, + %RendezvousInstr{rv_variable: :ip_address, rv_value: <<68, 127, 0, 0, 1>>}, + %RendezvousInstr{rv_variable: :owner_port, rv_value: <<25, 31, 146>>}, + %RendezvousInstr{rv_variable: :protocol, rv_value: <<1>>} + ] + } + ] + } + + header_struct = %OwnershipVoucher.Header{ + guid: guid_raw, + device_info: "rsapss-device", + public_key: cose_key_to_fdo_public_key(owner_key), + rendezvous_info: rv_info, + cert_chain_hash: cert_chain_hash_struct, + protocol_version: 101 + } + + hmac_bytes = :crypto.strong_rand_bytes(32) + hmac_struct = %Hash{type: :hmac_sha256, hash: hmac_bytes} + + header_cbor = OwnershipVoucher.Header.cbor_encode(header_struct) + hmac_cbor = Hash.encode_cbor(hmac_struct) + + header_info = guid_raw <> "rsapss-device" + hash_hdr = Hash.new(hash_alg, header_info) + + hash_prev = Hash.new(hash_alg, header_cbor <> hmac_cbor) + + entry_payload_bin = create_cose_key_entry_payload(owner_key, hash_prev, hash_hdr) + + sign1_msg = Sign1.build(entry_payload_bin, %{alg: owner_key.alg}, %{}) + {:ok, entry_tag} = Sign1.sign_encode(sign1_msg, owner_key) + + owner_pem = COSE.Keys.to_pem(owner_key) + + voucher = %OwnershipVoucher{ + header: header_struct, + hmac: hmac_struct, + entries: [entry_tag], + protocol_version: 101, + cert_chain: [cert_der] + } + + {voucher, owner_pem} + end + # Generic generator for all supported curves and encodings # Options: # :curve -> :p256 or :p384 (default :p256) @@ -233,24 +313,23 @@ defmodule Astarte.FDO.Helpers do |> List.wrap() |> :public_key.pem_encode() + spki_der = + :public_key.der_encode( + :SubjectPublicKeyInfo, + {:SubjectPublicKeyInfo, + {:AlgorithmIdentifier, {1, 2, 840, 10_045, 2, 1}, + :public_key.der_encode(:EcpkParameters, {:namedCurve, oid})}, device_pub_key_point} + ) + pub_key_body = case encoding do :x5chain -> [cert_der] - :x509 -> cert_der + :x509 -> spki_der end - # Entry Payload (COSE Key) — use owner_key if provided, else device key - entry_payload_bin = create_cose_key_entry_payload(owner_key) - guid_raw = UUID.uuid4(:raw) - chain_data_to_hash = - case pub_key_body do - list when is_list(list) -> Enum.join(list) - bin -> bin - end - - cert_chain_hash_struct = Hash.new(hash_alg, chain_data_to_hash) + cert_chain_hash_struct = Hash.new(hash_alg, cert_der) rv_info = %RendezvousInfo{ directives: [ @@ -283,12 +362,22 @@ defmodule Astarte.FDO.Helpers do hmac_bytes = :crypto.strong_rand_bytes(hmac_len) hmac_struct = %Hash{type: hmac_type, hash: hmac_bytes} - protected_header = %{alg: owner_key.alg} + header_cbor = OwnershipVoucher.Header.cbor_encode(header_struct) + hmac_cbor = Hash.encode_cbor(hmac_struct) + + header_info = guid_raw <> header_struct.device_info + hash_hdr = Hash.new(hash_alg, header_info) + + hash_prev = Hash.new(hash_alg, header_cbor <> hmac_cbor) + + entry_payload_bin = create_cose_key_entry_payload(owner_key, hash_prev, hash_hdr) + + protected_header = %{alg: cose_key.alg} unprotected_header_map = %{} sign1_msg = Sign1.build(entry_payload_bin, protected_header, unprotected_header_map) - {:ok, entry_tag} = Sign1.sign_encode(sign1_msg, owner_key) + {:ok, entry_tag} = Sign1.sign_encode(sign1_msg, cose_key) voucher = %OwnershipVoucher{ header: header_struct, @@ -311,6 +400,25 @@ defmodule Astarte.FDO.Helpers do "-----BEGIN OWNERSHIP VOUCHER-----\n#{wrapped}\n-----END OWNERSHIP VOUCHER-----\n" end + defp rsa_cose_alg_int(:rs256), do: -257 + defp rsa_cose_alg_int(:rs384), do: -258 + + defp cose_key_to_fdo_public_key(%RSA{n: n, e: e, alg: alg}) do + key_bytes = CBOR.encode(%{1 => 3, 3 => rsa_cose_alg_int(alg), -1 => n, -2 => e}) + %PublicKey{type: :rsapss, encoding: :cosekey, body: key_bytes} + end + + defp cose_key_to_fdo_public_key(%ECC{x: x, y: y, alg: alg}) do + {cose_crv, cose_alg, pub_key_type} = + case alg do + :es256 -> {1, -7, :secp256r1} + :es384 -> {2, -35, :secp384r1} + end + + key_bytes = CBOR.encode(%{1 => 2, -1 => cose_crv, 3 => cose_alg, -2 => x, -3 => y}) + %PublicKey{type: pub_key_type, encoding: :cosekey, body: key_bytes} + end + defp get_curve_params(:p256) do # OID, HashAlg, SigAlg, COSE Alg ID (:es256 = -7), PublicKeyType (:secp256r1 = 10) {{1, 2, 840, 10_045, 3, 1, 7}, :sha256, {:sha256, :ecdsa}, -7, :secp256r1} @@ -356,33 +464,14 @@ defmodule Astarte.FDO.Helpers do :public_key.pkix_sign(tbs_cert, priv_key) end - defp create_cose_key_entry_payload(%ECC{x: x, y: y, alg: alg}) do - {cose_crv, cose_alg, type_int} = - case alg do - :es256 -> {1, -7, 10} - :es384 -> {2, -35, 11} - end - - cose_key_map = %{ - 1 => 2, - -1 => cose_crv, - 3 => cose_alg, - -2 => x, - -3 => y - } - - enc_int = 3 - - key_bytes = CBOR.encode(cose_key_map) - - fdo_public_key = [ - type_int, - enc_int, - %CBOR.Tag{tag: :bytes, value: key_bytes} - ] - - entry_list = [<<>>, <<>>, <<>>, fdo_public_key] + defp create_cose_key_entry_payload(key, hash_prev, hash_hdr) do + public_key = cose_key_to_fdo_public_key(key) - CBOR.encode(entry_list) + CBOR.encode([ + Hash.encode(hash_prev), + Hash.encode(hash_hdr), + nil, + PublicKey.encode(public_key) + ]) end end diff --git a/libs/astarte_fdo/test/test_helper.exs b/libs/astarte_fdo/test/test_helper.exs index f233b9caa..89fb24d5d 100644 --- a/libs/astarte_fdo/test/test_helper.exs +++ b/libs/astarte_fdo/test/test_helper.exs @@ -45,4 +45,7 @@ Mimic.copy(Astarte.Secrets) Mimic.copy(DateTime) Mimic.copy(HTTPoison) +# fix flakiness caused by namespaces being created at the same time +Astarte.Secrets.Core.create_nested_namespace(["fdo_owner_keys", "instance"]) + ExUnit.start(capture_log: true) diff --git a/libs/astarte_fdo_core/lib/ownership_voucher/core.ex b/libs/astarte_fdo_core/lib/ownership_voucher/core.ex index e2a8e3e24..9cedfd2c7 100644 --- a/libs/astarte_fdo_core/lib/ownership_voucher/core.ex +++ b/libs/astarte_fdo_core/lib/ownership_voucher/core.ex @@ -71,7 +71,7 @@ defmodule Astarte.FDO.Core.OwnershipVoucher.Core do end end - def entry_private_key(entry) do + def entry_public_key(entry) do with {:ok, entry} <- Sign1.decode(entry), %CBOR.Tag{tag: :bytes, value: payload} <- entry.payload, {:ok, decoded_entry, _} <- CBOR.decode(payload), diff --git a/libs/astarte_data_access/lib/astarte_data_access/fdo/ownership_voucher/create_request.ex b/libs/astarte_fdo_core/lib/ownership_voucher/create_request.ex similarity index 95% rename from libs/astarte_data_access/lib/astarte_data_access/fdo/ownership_voucher/create_request.ex rename to libs/astarte_fdo_core/lib/ownership_voucher/create_request.ex index a4cc3f142..421d84c9e 100644 --- a/libs/astarte_data_access/lib/astarte_data_access/fdo/ownership_voucher/create_request.ex +++ b/libs/astarte_fdo_core/lib/ownership_voucher/create_request.ex @@ -16,12 +16,12 @@ # limitations under the License. # -defmodule Astarte.DataAccess.FDO.OwnershipVoucher.CreateRequest do +defmodule Astarte.FDO.Core.OwnershipVoucher.CreateRequest do @moduledoc false use TypedEctoSchema - alias Astarte.DataAccess.FDO.OwnershipVoucher.CreateRequest alias Astarte.FDO.Core.OwnershipVoucher + alias Astarte.FDO.Core.OwnershipVoucher.CreateRequest require Logger diff --git a/libs/astarte_fdo_core/lib/ownership_voucher/ownership_voucher.ex b/libs/astarte_fdo_core/lib/ownership_voucher/ownership_voucher.ex index d474eb486..4e70f0357 100644 --- a/libs/astarte_fdo_core/lib/ownership_voucher/ownership_voucher.ex +++ b/libs/astarte_fdo_core/lib/ownership_voucher/ownership_voucher.ex @@ -130,27 +130,29 @@ defmodule Astarte.FDO.Core.OwnershipVoucher do @doc """ Decodes a PEM-encoded ownership voucher and returns the key algorithm - compatible with `Astarte.Secrets.Core` (`:es256`, `:es384`, `:rs256`, `:rs384`, - or a list thereof when the FDO key type is ambiguous about the RSA key size). """ @spec key_algorithm_from_voucher(String.t()) :: - {:ok, atom() | [atom()]} | {:error, atom()} + {:ok, [atom()]} | {:error, atom()} def key_algorithm_from_voucher(pem) do with {:ok, binary} <- binary_voucher(pem), {:ok, voucher} <- decode_cbor(binary) do - fdo_type_to_key_algorithm(voucher.header.public_key.type) + key_algorithm_from_type(voucher.header.public_key.type) else {:error, reason} -> {:error, reason} :error -> {:error, :invalid_ownership_voucher} end end - defp fdo_type_to_key_algorithm(:secp256r1), do: {:ok, :es256} - defp fdo_type_to_key_algorithm(:secp384r1), do: {:ok, :es384} - defp fdo_type_to_key_algorithm(:rsa2048restr), do: {:ok, :rs256} - defp fdo_type_to_key_algorithm(:rsapkcs), do: {:ok, [:rs256, :rs384]} - defp fdo_type_to_key_algorithm(:rsapss), do: {:ok, [:rs256, :rs384]} - defp fdo_type_to_key_algorithm(_), do: {:error, :unsupported_key_algorithm} + @doc """ + Returns the key algorithm(s) for a given FDO public key type atom. + """ + @spec key_algorithm_from_type(atom()) :: {:ok, [atom()]} | {:error, atom()} + def key_algorithm_from_type(:secp256r1), do: {:ok, [:es256]} + def key_algorithm_from_type(:secp384r1), do: {:ok, [:es384]} + def key_algorithm_from_type(:rsa2048restr), do: {:ok, [:rs256]} + def key_algorithm_from_type(:rsapkcs), do: {:ok, [:rs256, :rs384]} + def key_algorithm_from_type(:rsapss), do: {:ok, [:rs256, :rs384]} + def key_algorithm_from_type(_), do: {:error, :unsupported_key_algorithm} defp decode_cert(cert_bin) do {:ok, :public_key.pkix_decode_cert(cert_bin, :otp)} diff --git a/libs/astarte_fdo_core/lib/rendezvous/owner_sign/owner_sign.ex b/libs/astarte_fdo_core/lib/rendezvous/owner_sign/owner_sign.ex index 6f79dc8ac..171c42ef9 100644 --- a/libs/astarte_fdo_core/lib/rendezvous/owner_sign/owner_sign.ex +++ b/libs/astarte_fdo_core/lib/rendezvous/owner_sign/owner_sign.ex @@ -41,8 +41,10 @@ defmodule Astarte.FDO.Core.Rendezvous.OwnerSign do to0d_hash = Hash.new(:sha256, to0d) to1d = %{to1d | to0d_hash: to0d_hash} + to0d_bstr = %CBOR.Tag{tag: :bytes, value: to0d} + with {:ok, to1d} <- TO1D.encode_sign(to1d, owner_key) do - {:ok, [to0d, to1d]} + {:ok, [to0d_bstr, to1d]} end end diff --git a/libs/astarte_fdo_core/test/astarte_fdo_core/fdo/owner_onboarding/session_key_test.exs b/libs/astarte_fdo_core/test/astarte_fdo_core/fdo/owner_onboarding/session_key_test.exs index d0bb7e506..e03f573e2 100644 --- a/libs/astarte_fdo_core/test/astarte_fdo_core/fdo/owner_onboarding/session_key_test.exs +++ b/libs/astarte_fdo_core/test/astarte_fdo_core/fdo/owner_onboarding/session_key_test.exs @@ -25,7 +25,6 @@ defmodule Astarte.FDO.Core.OwnerOnboarding.SessionKeyTest do describe "new/2 for ECDH suites" do test "ECDH256 returns random of 16 bytes and xa key exchange material" do - key = Keys.generate(:es256) assert {:ok, random, xa} = SessionKey.new("ECDH256") assert byte_size(random) == 16 @@ -33,7 +32,6 @@ defmodule Astarte.FDO.Core.OwnerOnboarding.SessionKeyTest do end test "ECDH384 returns random of 48 bytes and xa key exchange material" do - key = Keys.generate(:es384) assert {:ok, random, xa} = SessionKey.new("ECDH384") assert byte_size(random) == 48 @@ -75,7 +73,6 @@ defmodule Astarte.FDO.Core.OwnerOnboarding.SessionKeyTest do describe "new/2 produces different values on each call" do test "ECDH256 generates unique randoms" do - key = Keys.generate(:es256) {:ok, random1, _xa1} = SessionKey.new("ECDH256") {:ok, random2, _xa2} = SessionKey.new("ECDH256") @@ -92,7 +89,6 @@ defmodule Astarte.FDO.Core.OwnerOnboarding.SessionKeyTest do describe "compute_shared_secret/4 for ECDH suites" do test "ECDH256 computes a shared secret from device xb" do owner_key = Keys.generate(:es256) - device_key = Keys.generate(:es256) {:ok, owner_random, _xa} = SessionKey.new("ECDH256") {:ok, _device_random, xb} = SessionKey.new("ECDH256") @@ -104,7 +100,6 @@ defmodule Astarte.FDO.Core.OwnerOnboarding.SessionKeyTest do test "ECDH384 computes a shared secret from device xb" do owner_key = Keys.generate(:es384) - device_key = Keys.generate(:es384) {:ok, owner_random, _xa} = SessionKey.new("ECDH384") {:ok, _device_random, xb} = SessionKey.new("ECDH384") @@ -140,13 +135,11 @@ defmodule Astarte.FDO.Core.OwnerOnboarding.SessionKeyTest do describe "derive_key/4" do setup do owner_key = Keys.generate(:es256) - device_key = Keys.generate(:es256) {:ok, owner_random, _} = SessionKey.new("ECDH256") {:ok, _, xb} = SessionKey.new("ECDH256") {:ok, shse256} = SessionKey.compute_shared_secret("ECDH256", owner_key, owner_random, xb) owner_key384 = Keys.generate(:es384) - device_key384 = Keys.generate(:es384) {:ok, owner_random384, _} = SessionKey.new("ECDH384") {:ok, _, xb384} = SessionKey.new("ECDH384") diff --git a/libs/astarte_data_access/test/fdo/ownership_voucher/create_request_test.exs b/libs/astarte_fdo_core/test/astarte_fdo_core/fdo/ownership_voucher/create_request_test.exs similarity index 95% rename from libs/astarte_data_access/test/fdo/ownership_voucher/create_request_test.exs rename to libs/astarte_fdo_core/test/astarte_fdo_core/fdo/ownership_voucher/create_request_test.exs index 6dfcade48..fca7dd7be 100644 --- a/libs/astarte_data_access/test/fdo/ownership_voucher/create_request_test.exs +++ b/libs/astarte_fdo_core/test/astarte_fdo_core/fdo/ownership_voucher/create_request_test.exs @@ -16,11 +16,11 @@ # limitations under the License. # -defmodule Astarte.DataAccess.FDO.OwnershipVoucher.CreateRequestTest do +defmodule Astarte.FDO.Core.OwnershipVoucher.CreateRequestTest do use ExUnit.Case, async: true - alias Astarte.DataAccess.FDO.OwnershipVoucher.CreateRequest - import Astarte.DataAccess.FDO.TestHelpers + alias Astarte.FDO.Core.OwnershipVoucher.CreateRequest + import Astarte.FDO.Core.FDOHelpers @valid_params %{ "ownership_voucher" => sample_voucher(), diff --git a/libs/astarte_data_access/test/support/fdo_helpers.ex b/libs/astarte_fdo_core/test/support/helpers/fdo.ex similarity index 99% rename from libs/astarte_data_access/test/support/fdo_helpers.ex rename to libs/astarte_fdo_core/test/support/helpers/fdo.ex index 2823a5b64..54d6fa3da 100644 --- a/libs/astarte_data_access/test/support/fdo_helpers.ex +++ b/libs/astarte_fdo_core/test/support/helpers/fdo.ex @@ -16,7 +16,7 @@ # limitations under the License. # -defmodule Astarte.DataAccess.FDO.TestHelpers do +defmodule Astarte.FDO.Core.FDOHelpers do @moduledoc """ Shared test fixtures for FDO-related tests in astarte_data_access. """ diff --git a/libs/astarte_secrets/lib/astarte_secrets/astarte_secrets.ex b/libs/astarte_secrets/lib/astarte_secrets/astarte_secrets.ex index 4e5e150f6..89996c6b6 100644 --- a/libs/astarte_secrets/lib/astarte_secrets/astarte_secrets.ex +++ b/libs/astarte_secrets/lib/astarte_secrets/astarte_secrets.ex @@ -2,6 +2,7 @@ defmodule Astarte.Secrets do @moduledoc """ Functionality to interface with OpenBao APIs. """ + alias Astarte.DataAccess.FDO.Queries alias Astarte.Secrets.Client alias Astarte.Secrets.Core alias Astarte.Secrets.Key @@ -19,6 +20,13 @@ defmodule Astarte.Secrets do end end + def get_key_for_guid(realm_name, user_id \\ nil, guid) do + with {:ok, params} <- Queries.get_owner_key_params(realm_name, guid), + {:ok, namespace} <- create_namespace(realm_name, user_id, params.algorithm) do + get_key(params.name, namespace: namespace) + end + end + @spec list_keys_names() :: {:ok, [String.t()]} | :error def list_keys_names(opts \\ []) do namespace = Keyword.fetch!(opts, :namespace) @@ -112,4 +120,40 @@ defmodule Astarte.Secrets do Core.import_key(key_name, key_type_string, ciphertext, opts) end end + + @doc """ + Decrypts the provided ciphertext using OpenBao Transit Engine. + Useful for ASYMKEX where the device encrypts a secret with the owner's RSA public key. + """ + @spec decrypt(String.t(), binary(), list()) :: {:ok, binary()} | :error + def decrypt(key_name, ciphertext, options \\ []) do + namespace = Keyword.fetch!(options, :namespace) + client_opts = [namespace: namespace] ++ Keyword.take(options, [:token]) + + req_body = + %{ + ciphertext: "vault:v1:" <> Base.encode64(ciphertext) + } + |> Jason.encode!() + + headers = [{"Content-Type", "application/json"}] + + case Client.post("/transit/decrypt/#{key_name}", req_body, headers, client_opts) do + {:ok, %HTTPoison.Response{status_code: 200, body: body}} -> + with {:ok, data} <- Core.parse_json_data(body), + plaintext_b64 when is_binary(plaintext_b64) <- Map.get(data, "plaintext"), + {:ok, plaintext} <- Base.decode64(plaintext_b64) do + {:ok, plaintext} + else + _ -> :error + end + + error_resp -> + Logger.error( + "Encountered HTTP error while decrypting with key #{key_name}: #{inspect(error_resp)}" + ) + + :error + end + end end diff --git a/libs/astarte_secrets/lib/astarte_secrets/core.ex b/libs/astarte_secrets/lib/astarte_secrets/core.ex index 3a72cf489..9410328d8 100644 --- a/libs/astarte_secrets/lib/astarte_secrets/core.ex +++ b/libs/astarte_secrets/lib/astarte_secrets/core.ex @@ -359,6 +359,8 @@ defmodule Astarte.Secrets.Core do else "Encountered HTTP error while mounting transit engine in namespace #{namespace}: #{inspect(resp)}" |> Logger.error() + + :error end error_resp -> @@ -449,12 +451,11 @@ defmodule Astarte.Secrets.Core do req_body = build_sign_payload(payload, vault_opts) headers = [{"Content-Type", "application/json"}] - marshaling = Keyword.get(vault_opts, :marshaling_algorithm) with {:ok, %HTTPoison.Response{status_code: 200, body: resp_body}} <- Client.post(url_path, req_body, headers, opts), - {:ok, raw_sig} <- extract_and_decode_signature(resp_body, marshaling) do - {:ok, raw_sig} + {:ok, signature} <- extract_and_decode_signature(resp_body, key_alg) do + {:ok, signature} else error -> Logger.error("Failed to sign payload or decode Vault response: #{inspect(error)}") @@ -472,12 +473,13 @@ defmodule Astarte.Secrets.Core do end # Parses the JSON response, extracts the signature string, and decodes it into a binary - defp extract_and_decode_signature(resp_body, marshaling) do + defp extract_and_decode_signature(resp_body, key_algorithm) do with {:ok, vault_sig} <- parse_data_key(resp_body, "signature"), true <- is_binary(vault_sig), - [_, _, b64_sig] <- String.split(vault_sig, ":", parts: 3) do + [_, _, b64_sig] <- String.split(vault_sig, ":", parts: 3), + {:ok, raw_signature} <- Base.decode64(b64_sig) do # decode_vault_sig returns {:ok, raw_sig} or :error - decode_vault_sig(b64_sig, marshaling) + decode_vault_sig(raw_signature, key_algorithm) else {:error, _reason} = error -> error @@ -487,65 +489,61 @@ defmodule Astarte.Secrets.Core do end end - # Decodes the Base64 signature returned by OpenBao. - # When using the "jws" marshaling algorithm, OpenBao returns a - # URL-safe Base64 string without padding. - defp decode_vault_sig(b64_sig, "jws") do - Base.url_decode64(b64_sig, padding: false) - end - # For "asn1" or default marshaling, OpenBao uses standard Base64 encoding. - defp decode_vault_sig(b64_sig, _other) do - Base.decode64(b64_sig) - end + defp decode_vault_sig(raw_signature, rsa) when rsa in [:rs256, :rs384], do: {:ok, raw_signature} - # Translates Astarte/COSE supported algorithms into OpenBao Transit engine parameters. - defp map_cose_alg_to_vault_opts(:es256) do - [marshaling_algorithm: "jws"] - end - - defp map_cose_alg_to_vault_opts(:es384) do - [marshaling_algorithm: "jws"] - end + defp decode_vault_sig(raw_signature, ecdh) do + size = + case ecdh do + :es256 -> 32 + :es384 -> 48 + end - defp map_cose_alg_to_vault_opts(:rs256) do - [signature_algorithm: "pkcs1v15"] + {:"ECDSA-Sig-Value", r, s} = :public_key.der_decode(:"ECDSA-Sig-Value", raw_signature) + r = pad(r, size) + s = pad(s, size) + {:ok, r <> s} + rescue + _ -> :error end - defp map_cose_alg_to_vault_opts(:rs384) do - [signature_algorithm: "pkcs1v15"] - end + defp pad(value, size) do + bin = :binary.encode_unsigned(value) + bin_size = byte_size(bin) - def get_keys_from_algorithm(realm_name, key_algorithms) when is_list(key_algorithms) do - keys_map = - Enum.flat_map(key_algorithms, fn key_algorithm -> - case Secrets.create_namespace(realm_name, key_algorithm) do - {:ok, namespace} -> - case Secrets.list_keys_names(namespace: namespace) do - {:ok, keys} -> [%{key_algorithm => keys}] - _ -> [] - end + case bin_size do + ^size -> + bin - _ -> - [] - end - end) + s when s < size -> + padding = :binary.copy(<<0>>, size - bin_size) + padding <> bin - {:ok, keys_map} + _ -> + binary_part(bin, bin_size - size, size) + end end - def get_keys_from_algorithm(realm_name, key_algorithm) when is_binary(key_algorithm) do - with {:ok, algorithm_atom} <- string_to_key_type(key_algorithm), - {:ok, namespace} <- Secrets.create_namespace(realm_name, algorithm_atom), - {:ok, keys} <- Secrets.list_keys_names(namespace: namespace) do - {:ok, %{key_algorithm => keys}} + defp map_cose_alg_to_vault_opts(alg) do + case alg do + :rs256 -> [signature_algorithm: "pkcs1v15"] + :rs384 -> [signature_algorithm: "pkcs1v15"] + _ -> [] end end - def get_keys_from_algorithm(realm_name, key_algorithm) when is_atom(key_algorithm) do - with {:ok, namespace} <- Secrets.create_namespace(realm_name, key_algorithm), - {:ok, keys} <- Secrets.list_keys_names(namespace: namespace) do - {:ok, %{key_algorithm => keys}} + def get_keys(realm_name, key_algorithms) do + Enum.reduce_while(key_algorithms, {:ok, %{}}, fn algorithm, {:ok, acc} -> + case list_keys_for_algorithm(realm_name, algorithm) do + {:ok, keys} -> {:cont, {:ok, Map.put(acc, algorithm, keys)}} + error -> {:halt, error} + end + end) + end + + defp list_keys_for_algorithm(realm_name, key_algorithm) do + with {:ok, namespace} <- Secrets.create_namespace(realm_name, key_algorithm) do + Secrets.list_keys_names(namespace: namespace) end end @@ -553,8 +551,12 @@ defmodule Astarte.Secrets.Core do Looks up a key by name within the namespace for the given algorithm. Returns `{:ok, key}` if found, `:not_found` otherwise. """ - def find_key(realm_name, key_algorithm, key_name) do - with {:ok, namespace} <- Secrets.create_namespace(realm_name, key_algorithm) do + def find_key(realm_name, key_name, key_algorithm) do + with {:ok, algorithm} <- key_type_to_string(key_algorithm) do + namespace = + namespace_tokens(realm_name, nil, algorithm) + |> Enum.join("/") + case Secrets.get_key(key_name, namespace: namespace) do {:ok, key} -> {:ok, key} _ -> :not_found diff --git a/libs/astarte_secrets/lib/astarte_secrets/owner_key_initialization.ex b/libs/astarte_secrets/lib/astarte_secrets/owner_key_initialization.ex index 651de32a4..3472b310c 100644 --- a/libs/astarte_secrets/lib/astarte_secrets/owner_key_initialization.ex +++ b/libs/astarte_secrets/lib/astarte_secrets/owner_key_initialization.ex @@ -69,8 +69,14 @@ defmodule Astarte.Secrets.OwnerKeyInitialization do end defp do_upload_key(key_name, key_algorithm, key_body, namespace) do - with :ok <- Secrets.import_key(key_name, key_algorithm, key_body, namespace: namespace) do - {:ok, ""} + case Secrets.get_key(key_name, namespace: namespace) do + {:ok, _key} -> + {:error, :key_already_imported} + + _ -> + with :ok <- Secrets.import_key(key_name, key_algorithm, key_body, namespace: namespace) do + {:ok, ""} + end end end end diff --git a/libs/astarte_secrets/test/astarte_secrets/core_test.exs b/libs/astarte_secrets/test/astarte_secrets/core_test.exs index e10acd026..d7f4cd4f1 100644 --- a/libs/astarte_secrets/test/astarte_secrets/core_test.exs +++ b/libs/astarte_secrets/test/astarte_secrets/core_test.exs @@ -287,4 +287,331 @@ defmodule Astarte.Secrets.CoreTest do :ok end + + describe "string_to_key_type/1" do + test "converts string key types to their atom representation" do + assert {:ok, :es256} = Core.string_to_key_type("ecdsa-p256") + assert {:ok, :es384} = Core.string_to_key_type("ecdsa-p384") + assert {:ok, :rs256} = Core.string_to_key_type("rsa-2048") + assert {:ok, :rs384} = Core.string_to_key_type("rsa-3072") + end + + test "returns :error for unknown string" do + assert :error = Core.string_to_key_type("unknown") + assert :error = Core.string_to_key_type(:es256) + assert :error = Core.string_to_key_type(nil) + end + + test "round-trips with key_type_to_string/1" do + for atom <- [:es256, :es384, :rs256, :rs384] do + {:ok, string} = Core.key_type_to_string(atom) + assert {:ok, ^atom} = Core.string_to_key_type(string) + end + end + end + + describe "key_algorithm_enum/0" do + test "returns a keyword list containing all supported algorithms" do + enum = Core.key_algorithm_enum() + assert Keyword.get(enum, :es256) == "ecdsa-p256" + assert Keyword.get(enum, :es384) == "ecdsa-p384" + assert Keyword.get(enum, :rs256) == "rsa-2048" + assert Keyword.get(enum, :rs384) == "rsa-3072" + end + end + + describe "digest_type/1" do + test "converts known digest atoms to OpenBao strings" do + assert {:ok, "sha1"} = Core.digest_type(:sha) + assert {:ok, "sha2-224"} = Core.digest_type(:sha224) + assert {:ok, "sha2-256"} = Core.digest_type(:sha256) + assert {:ok, "sha2-384"} = Core.digest_type(:sha384) + assert {:ok, "sha2-512"} = Core.digest_type(:sha512) + assert {:ok, "sha3-224"} = Core.digest_type(:sha3_224) + assert {:ok, "sha3-256"} = Core.digest_type(:sha3_256) + assert {:ok, "sha3-384"} = Core.digest_type(:sha3_384) + assert {:ok, "sha3-512"} = Core.digest_type(:sha3_512) + end + + test "returns :error for unknown digest type" do + assert :error = Core.digest_type(:md5) + assert :error = Core.digest_type(:unknown) + end + end + + describe "encode_key_to_pkcs8/1" do + test "encodes an ECC key to PKCS8 DER binary" do + key = ECC.generate(:es256) + pkcs8 = Core.encode_key_to_pkcs8(key) + assert is_binary(pkcs8) + assert byte_size(pkcs8) > 0 + # PKCS8 DER starts with sequence tag 0x30 + assert <<0x30, _rest::binary>> = pkcs8 + end + + test "encodes an RSA key to PKCS8 DER binary" do + key = RSA.generate(:rs256) + pkcs8 = Core.encode_key_to_pkcs8(key) + assert is_binary(pkcs8) + assert byte_size(pkcs8) > 0 + assert <<0x30, _rest::binary>> = pkcs8 + end + end + + describe "prepare_import_ciphertext/2" do + test "returns {:ok, base64_string} for valid key material and wrapping PEM" do + wrapping_pem = + X509.PrivateKey.new_rsa(2048) + |> X509.PublicKey.derive() + |> X509.PublicKey.to_pem() + + key_material = ECC.generate(:es256) |> Core.encode_key_to_pkcs8() + + assert {:ok, ciphertext} = Core.prepare_import_ciphertext(key_material, wrapping_pem) + assert is_binary(ciphertext) + assert {:ok, _} = Base.decode64(ciphertext) + end + + test "returns error for invalid PEM" do + assert {:error, :pem_decode_failed} = + Core.prepare_import_ciphertext(<<1, 2, 3>>, "not a pem") + end + end + + describe "parse_json_data/1" do + test "parses a valid JSON object with a data key" do + json = Jason.encode!(%{"data" => %{"foo" => "bar"}}) + assert {:ok, %{"foo" => "bar"}} = Core.parse_json_data(json) + end + + test "returns error when data key is missing" do + json = Jason.encode!(%{"other" => "value"}) + assert {:error, _} = Core.parse_json_data(json) + end + + test "returns error for non-JSON input" do + assert {:error, _} = Core.parse_json_data("not json") + end + + test "returns error for JSON array (not a map)" do + assert {:error, _} = Core.parse_json_data("[1, 2, 3]") + end + end + + describe "create_keypair/4" do + setup :http_stubs_setup + + test "returns parsed data on HTTP 200" do + body = Jason.encode!(%{"data" => %{"name" => "my-key"}}) + + expect(Client, :post, fn _url, _body, _headers, _opts -> + {:ok, %HTTPoison.Response{status_code: 200, body: body}} + end) + + assert {:ok, %{"name" => "my-key"}} = + Core.create_keypair("my-key", "ecdsa-p256", false, "ns") + end + + test "returns :error on HTTP error response" do + expect(Client, :post, fn _url, _body, _headers, _opts -> + {:ok, %HTTPoison.Response{status_code: 400, body: "bad request"}} + end) + + assert :error = Core.create_keypair("my-key", "ecdsa-p256", false, "ns") + end + end + + describe "get_wrapping_key/1" do + setup :http_stubs_setup + + test "returns {:ok, pem} on 200 with valid body" do + pem = "-----BEGIN PUBLIC KEY-----\nfake\n-----END PUBLIC KEY-----" + body = Jason.encode!(%{"data" => %{"public_key" => pem}}) + + expect(Client, :get, fn _url, _headers, _opts -> + {:ok, %HTTPoison.Response{status_code: 200, body: body}} + end) + + assert {:ok, ^pem} = Core.get_wrapping_key([]) + end + + test "returns {:error, :wrapping_key_parse_failed} when public_key is missing" do + body = Jason.encode!(%{"data" => %{"other" => "value"}}) + + expect(Client, :get, fn _url, _headers, _opts -> + {:ok, %HTTPoison.Response{status_code: 200, body: body}} + end) + + assert {:error, :wrapping_key_parse_failed} = Core.get_wrapping_key([]) + end + + test "returns :error on HTTP error" do + expect(Client, :get, fn _url, _headers, _opts -> + {:error, %HTTPoison.Error{reason: :econnrefused}} + end) + + assert :error = Core.get_wrapping_key([]) + end + end + + describe "get_key/2" do + setup :http_stubs_setup + + test "returns {:ok, body} on HTTP 200" do + body = Jason.encode!(%{"data" => %{"name" => "my-key"}}) + + expect(Client, :get, fn _url, _headers, _opts -> + {:ok, %HTTPoison.Response{status_code: 200, body: body}} + end) + + assert {:ok, ^body} = Core.get_key("my-key", "ns") + end + + test "returns :error on HTTP 404" do + expect(Client, :get, fn _url, _headers, _opts -> + {:ok, %HTTPoison.Response{status_code: 404}} + end) + + assert :error = Core.get_key("missing-key", "ns") + end + + test "returns :error on HTTP error" do + expect(Client, :get, fn _url, _headers, _opts -> + {:error, %HTTPoison.Error{reason: :econnrefused}} + end) + + assert :error = Core.get_key("my-key", "ns") + end + end + + describe "list_keys/1" do + setup :http_stubs_setup + + test "returns {:ok, keys} on HTTP 200 with valid body" do + body = Jason.encode!(%{"data" => %{"keys" => ["key1", "key2"]}}) + + expect(Client, :list, fn _url, _headers, _opts -> + {:ok, %HTTPoison.Response{status_code: 200, body: body}} + end) + + assert {:ok, ["key1", "key2"]} = Core.list_keys("ns") + end + + test "returns {:ok, []} on HTTP 404" do + expect(Client, :list, fn _url, _headers, _opts -> + {:ok, %HTTPoison.Response{status_code: 404}} + end) + + assert {:ok, []} = Core.list_keys("ns") + end + + test "returns :error when response body is malformed" do + expect(Client, :list, fn _url, _headers, _opts -> + {:ok, %HTTPoison.Response{status_code: 200, body: "not json"}} + end) + + assert :error = Core.list_keys("ns") + end + + test "returns :error on HTTP error" do + expect(Client, :list, fn _url, _headers, _opts -> + {:error, %HTTPoison.Error{reason: :econnrefused}} + end) + + assert :error = Core.list_keys("ns") + end + end + + describe "mount_transit_engine/1" do + setup :http_stubs_setup + + test "returns :ok on HTTP 204" do + expect(Client, :post, fn "/sys/mounts/transit", _body, _headers, _opts -> + {:ok, %HTTPoison.Response{status_code: 204}} + end) + + assert :ok = Core.mount_transit_engine("ns") + end + + test "returns :ok on HTTP 400 with 'already in use' message" do + expect(Client, :post, fn "/sys/mounts/transit", _body, _headers, _opts -> + {:ok, %HTTPoison.Response{status_code: 400, body: "path is already in use at transit/"}} + end) + + assert :ok = Core.mount_transit_engine("ns") + end + + test "returns error on HTTP 400 with different message" do + expect(Client, :post, fn "/sys/mounts/transit", _body, _headers, _opts -> + {:ok, + %HTTPoison.Response{ + status_code: 400, + body: "some other error", + headers: [], + request: nil + }} + end) + + assert :error = Core.mount_transit_engine("ns") + end + + test "returns :error on HTTP connection error" do + expect(Client, :post, fn "/sys/mounts/transit", _body, _headers, _opts -> + {:error, %HTTPoison.Error{reason: :econnrefused}} + end) + + assert :error = Core.mount_transit_engine("ns") + end + end + + describe "get_keys/2" do + test "returns a map of algorithm to key names" do + unique_id = System.unique_integer([:positive]) + realm_name = "listtest_#{unique_id}" + + {:ok, ns} = Secrets.create_namespace(realm_name, :es256) + + Secrets.create_keypair("k1", :es256, namespace: ns) + Secrets.create_keypair("k2", :es256, namespace: ns) + + result = Core.get_keys(realm_name, [:es256]) + assert {:ok, %{es256: keys}} = result + assert "k1" in keys + assert "k2" in keys + end + end + + describe "find_key/3" do + test "returns {:ok, key} when the key exists" do + unique_id = System.unique_integer([:positive]) + realm_name = "findtest_#{unique_id}" + + {:ok, ns} = Secrets.create_namespace(realm_name, :es256) + + Secrets.create_keypair("find-me", :es256, namespace: ns) + + assert {:ok, key} = Core.find_key(realm_name, "find-me", :es256) + assert key.name == "find-me" + end + + test "returns :not_found when key does not exist" do + unique_id = System.unique_integer([:positive]) + realm_name = "findtest_missing_#{unique_id}" + + {:ok, _} = Secrets.create_namespace(realm_name, :es256) + + assert :not_found = Core.find_key(realm_name, "no-such-key", :es256) + end + + test "returns :not_found when key exists under a different algorithm" do + unique_id = System.unique_integer([:positive]) + realm_name = "findtest_alg_#{unique_id}" + + {:ok, ns} = Secrets.create_namespace(realm_name, :es256) + + Secrets.create_keypair("find-me", :es256, namespace: ns) + + assert :not_found = Core.find_key(realm_name, "find-me", :es384) + end + end end diff --git a/libs/astarte_secrets/test/astarte_secrets/owner_key_initialization_test.exs b/libs/astarte_secrets/test/astarte_secrets/owner_key_initialization_test.exs new file mode 100644 index 000000000..286c05bc8 --- /dev/null +++ b/libs/astarte_secrets/test/astarte_secrets/owner_key_initialization_test.exs @@ -0,0 +1,325 @@ +# +# This file is part of Astarte. +# +# Copyright 2026 SECO Mind Srl +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# + +defmodule Astarte.Secrets.OwnerKeyInitializationTest do + use ExUnit.Case, async: true + use Mimic + + alias Astarte.Secrets + alias Astarte.Secrets.OwnerKeyInitialization + alias Astarte.Secrets.OwnerKeyInitializationOptions + + setup :verify_on_exit! + + # A P-256 EC private key PEM, parseable by COSE.Keys.from_pem/1 + # (yields %COSE.Keys.ECC{alg: :es256}). + @p256_private_key_pem """ + -----BEGIN EC PRIVATE KEY----- + MHcCAQEEIFlbTEE1Ce+RSqhU8FqxsY7eNb9BaBWOTw6qFv7l0DZtoAoGCCqGSM49 + AwEHoUQDQgAEocPEIHIrn08VRO5zkkDztwp72Sw0BSm0mZeLgOKkHLUPdVFFlc0E + O82b1/S2Cwzwh8MIDDx0CN2b+IBl5bRwOw== + -----END EC PRIVATE KEY----- + """ + + # A SubjectPublicKeyInfo PEM: valid PEM syntax but not a private key, + # so COSE.Keys.from_pem/1 returns :error (used for the invalid-upload path). + @public_key_pem """ + -----BEGIN PUBLIC KEY----- + MFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAES+TkA7VtJQv9YQ75yl5btXKR/cso + yfLzYWUTgxViGMfJkvql4W3zrtRaVPU9I06TOHFC2Mwy+9S3A7UWv/EWtg== + -----END PUBLIC KEY----- + """ + + @sample_namespace "fdo_owner_keys/test_realm/ecdsa-p256" + @sample_realm "test_realm" + @sample_key_name "owner_key" + + describe "create_or_upload/2 with action: \"create\"" do + setup do + stub(Secrets, :create_namespace, fn _realm, _alg -> + {:ok, @sample_namespace} + end) + + :ok + end + + test "returns {:ok, public_key_pem} on success" do + public_key_pem = "-----BEGIN PUBLIC KEY-----\nMFk...\n-----END PUBLIC KEY-----\n" + + stub(Secrets, :create_keypair, fn _key_name, _alg, _opts -> + {:ok, %{"keys" => %{"1" => %{"public_key" => public_key_pem}}}} + end) + + opts = %OwnerKeyInitializationOptions{ + action: "create", + key_name: @sample_key_name, + key_algorithm: "ecdsa-p256" + } + + assert {:ok, ^public_key_pem} = OwnerKeyInitialization.create_or_upload(opts, @sample_realm) + end + + test "calls create_namespace with the realm name and resolved algorithm" do + stub(Secrets, :create_keypair, fn _key_name, _alg, _opts -> + {:ok, %{"keys" => %{"1" => %{"public_key" => "some_pem"}}}} + end) + + expect(Secrets, :create_namespace, fn realm, alg -> + assert realm == @sample_realm + assert alg == :es256 + {:ok, @sample_namespace} + end) + + opts = %OwnerKeyInitializationOptions{ + action: "create", + key_name: @sample_key_name, + key_algorithm: "ecdsa-p256" + } + + OwnerKeyInitialization.create_or_upload(opts, @sample_realm) + end + + test "calls create_keypair with the key name and resolved algorithm" do + expect(Secrets, :create_keypair, fn key_name, alg, _opts -> + assert key_name == @sample_key_name + assert alg == :es256 + {:ok, %{"keys" => %{"1" => %{"public_key" => "pem"}}}} + end) + + opts = %OwnerKeyInitializationOptions{ + action: "create", + key_name: @sample_key_name, + key_algorithm: "ecdsa-p256" + } + + OwnerKeyInitialization.create_or_upload(opts, @sample_realm) + end + + test "works with ecdsa-p384 algorithm" do + public_key_pem = "-----BEGIN PUBLIC KEY-----\np384pem\n-----END PUBLIC KEY-----\n" + + expect(Secrets, :create_namespace, fn _realm, alg -> + assert alg == :es384 + {:ok, "fdo_owner_keys/test_realm/ecdsa-p384"} + end) + + stub(Secrets, :create_keypair, fn _key_name, _alg, _opts -> + {:ok, %{"keys" => %{"1" => %{"public_key" => public_key_pem}}}} + end) + + opts = %OwnerKeyInitializationOptions{ + action: "create", + key_name: @sample_key_name, + key_algorithm: "ecdsa-p384" + } + + assert {:ok, ^public_key_pem} = OwnerKeyInitialization.create_or_upload(opts, @sample_realm) + end + + test "works with rsa-2048 algorithm" do + public_key_pem = "-----BEGIN PUBLIC KEY-----\nrsapem\n-----END PUBLIC KEY-----\n" + + expect(Secrets, :create_namespace, fn _realm, alg -> + assert alg == :rs256 + {:ok, "fdo_owner_keys/test_realm/rsa-2048"} + end) + + stub(Secrets, :create_keypair, fn _key_name, _alg, _opts -> + {:ok, %{"keys" => %{"1" => %{"public_key" => public_key_pem}}}} + end) + + opts = %OwnerKeyInitializationOptions{ + action: "create", + key_name: @sample_key_name, + key_algorithm: "rsa-2048" + } + + assert {:ok, ^public_key_pem} = OwnerKeyInitialization.create_or_upload(opts, @sample_realm) + end + + test "propagates error from create_keypair" do + stub(Secrets, :create_keypair, fn _key_name, _alg, _opts -> + {:error, :some_error} + end) + + opts = %OwnerKeyInitializationOptions{ + action: "create", + key_name: @sample_key_name, + key_algorithm: "ecdsa-p256" + } + + assert {:error, :some_error} = + OwnerKeyInitialization.create_or_upload(opts, @sample_realm) + end + end + + describe "create_or_upload/2 with action: \"upload\", key not yet stored" do + setup do + stub(Secrets, :create_namespace, fn _realm, _alg -> + {:ok, @sample_namespace} + end) + + stub(Secrets, :get_key, fn _key_name, _opts -> + {:error, :not_found} + end) + + stub(Secrets, :import_key, fn _key_name, _alg, _key_body, _opts -> + :ok + end) + + :ok + end + + test "returns {:ok, \"\"} when a valid P-256 PEM is uploaded and key is new" do + opts = %OwnerKeyInitializationOptions{ + action: "upload", + key_name: @sample_key_name, + key_data: @p256_private_key_pem + } + + assert {:ok, ""} = OwnerKeyInitialization.create_or_upload(opts, @sample_realm) + end + + test "calls create_namespace with the realm and the algorithm from the decoded key" do + expect(Secrets, :create_namespace, fn realm, alg -> + assert realm == @sample_realm + assert alg == :es256 + {:ok, @sample_namespace} + end) + + opts = %OwnerKeyInitializationOptions{ + action: "upload", + key_name: @sample_key_name, + key_data: @p256_private_key_pem + } + + OwnerKeyInitialization.create_or_upload(opts, @sample_realm) + end + + test "calls import_key with the correct key_name and namespace" do + expect(Secrets, :import_key, fn key_name, _alg, _key_body, opts -> + assert key_name == @sample_key_name + assert opts[:namespace] == @sample_namespace + :ok + end) + + opts = %OwnerKeyInitializationOptions{ + action: "upload", + key_name: @sample_key_name, + key_data: @p256_private_key_pem + } + + OwnerKeyInitialization.create_or_upload(opts, @sample_realm) + end + + test "propagates error from import_key" do + stub(Secrets, :import_key, fn _key_name, _alg, _key_body, _opts -> + {:error, :vault_error} + end) + + opts = %OwnerKeyInitializationOptions{ + action: "upload", + key_name: @sample_key_name, + key_data: @p256_private_key_pem + } + + assert {:error, :vault_error} = + OwnerKeyInitialization.create_or_upload(opts, @sample_realm) + end + end + + describe "create_or_upload/2 with action: \"upload\", key already stored" do + setup do + stub(Secrets, :create_namespace, fn _realm, _alg -> + {:ok, @sample_namespace} + end) + + :ok + end + + test "returns {:ok, message} without calling import_key" do + expect(Secrets, :get_key, fn key_name, _opts -> + {:ok, %{name: key_name}} + end) + + # import_key should NOT be called; no stub/expect means Mimic will raise if called + opts = %OwnerKeyInitializationOptions{ + action: "upload", + key_name: @sample_key_name, + key_data: @p256_private_key_pem + } + + assert {:error, :key_already_imported} = + OwnerKeyInitialization.create_or_upload(opts, @sample_realm) + end + + test "message contains the key name" do + stub(Secrets, :get_key, fn key_name, _opts -> + {:ok, %{name: key_name}} + end) + + custom_key_name = "my_custom_key" + + opts = %OwnerKeyInitializationOptions{ + action: "upload", + key_name: custom_key_name, + key_data: @p256_private_key_pem + } + + assert {:error, :key_already_imported} = + OwnerKeyInitialization.create_or_upload(opts, @sample_realm) + end + end + + describe "create_or_upload/2 with action: \"upload\" and invalid key_data" do + test "returns {:error, :unprocessable_key} for non-PEM data" do + opts = %OwnerKeyInitializationOptions{ + action: "upload", + key_name: @sample_key_name, + key_data: "this is not a valid PEM key" + } + + assert {:error, :unprocessable_key} = + OwnerKeyInitialization.create_or_upload(opts, @sample_realm) + end + + test "returns {:error, :unprocessable_key} for empty key_data" do + opts = %OwnerKeyInitializationOptions{ + action: "upload", + key_name: @sample_key_name, + key_data: "" + } + + assert {:error, :unprocessable_key} = + OwnerKeyInitialization.create_or_upload(opts, @sample_realm) + end + + test "returns {:error, :unprocessable_key} for a public key PEM (not a private key)" do + # COSE.Keys.from_pem/1 only handles private keys; a SubjectPublicKeyInfo PEM + # parses but does not match any private-key record and returns :error. + opts = %OwnerKeyInitializationOptions{ + action: "upload", + key_name: @sample_key_name, + key_data: @public_key_pem + } + + assert {:error, :unprocessable_key} = + OwnerKeyInitialization.create_or_upload(opts, @sample_realm) + end + end +end diff --git a/libs/astarte_secrets/test/test_helper.exs b/libs/astarte_secrets/test/test_helper.exs index 1f7332af6..c6813da44 100644 --- a/libs/astarte_secrets/test/test_helper.exs +++ b/libs/astarte_secrets/test/test_helper.exs @@ -11,4 +11,8 @@ for module <- modules, do: Mimic.copy(module) Astarte.Secrets.Config.init() +# fix flakiness due to async tests +Astarte.Secrets.Core.create_nested_namespace(["fdo_owner_keys", "default_instance"]) +Astarte.Secrets.Core.create_nested_namespace(["fdo_owner_keys", "instance"]) + ExUnit.start(capture_log: true)