Skip to content

Commit c6d8b7f

Browse files
joshkclaude
andauthored
Track which CA signed each device certificate (#3024)
A device certificate names its signer by key id: `device_certificates.aki` holds the CA's `ski`. There's no foreign key, and nothing in the UI followed that link. Opening a device told you it had a certificate but not who signed it, and the Certificate Authorities list gave no way to tell a signer with a fleet behind it from one nothing uses. Closes #659 ## Device settings Each certificate now shows `Signer CA:` alongside its serial and validity, linking to the CA. The lookup is scoped to the device's org, so a certificate signed by another org's CA reads "Unknown" rather than exposing that CA. Certificates whose signer was never registered read "Unknown" too. That includes the NervesHubCA-signed certificates the AKI validation lets through from other orgs. ## A show page for a CA There wasn't one. The only per-CA page was the edit form, whose save is gated on `certificate_authority:update`, so it's the wrong place to send a viewer who just wants to look. `/org/:org/settings/certificates/:serial` shows the CA's details and a Devices card: the total, then a row per product linking to that product's device list filtered by this CA. The device list is scoped to one product and a CA is scoped to the org, so per-product rows are the only shape a working link can take. The list page loses its per-row Edit and Delete buttons and the `Check expiration?` / `JITP Enabled?` columns; the serial links to the show page, and Delete lives there now. It gains a Devices count. ## Filtering A `Signer CA` select in the device list sidebar, built from the CAs that signed a certificate held by a device in this product. That's the same treatment the firmware version filter got in #3020, and it carries the same kind of hint tooltip explaining why a CA you expected might be missing. A "No known CA" option covers shared secret devices and unregistered signers. It goes through the same filter map as every other sidebar filter, so `filters[signer_ca]` works on the device list API. There's a matching `signer_ca` advanced query column supporting `=` and `!=`: ``` signer_ca = "613033017807900175" signer_ca != ":not_set" and connection = "connected" ``` Autosuggest shows the CA's description and sends the serial, the way `firmware` shows a version and sends a UUID. The OpenAPI spec documents both. ## The index `device_certificates` had no index on `aki`, so every count and every filtered device list would sequential-scan it. This adds `(aki, device_id)` concurrently. The device id is in there so the counts can be answered from the index alone. ## Two things in `CAHelpers` that stopped working Both are casualties of #2494, which deleted the old SCSS. The components survived; the CSS they depended on didn't. `check_expiration_tooltip/1` styles nothing: `tooltip-info` and `tooltip-text` have no rules behind them any more, and `display: none` was the line that made it a tooltip. Without it the text is always on, which is the paragraph sitting in the middle of the new and edit CA forms. It's rebuilt on the `ToolTip` hook, the same shape `HealthStatus` uses, so it degrades to hidden rather than to visible. The show page calls it instead of carrying its own copy. `certificate_status/1` had the same problem with more at stake: `.certificate-status-expired` (red) and `.certificate-status-expiring-soon` (amber) went with the SCSS, so an expired CA rendered in the same colour as a current one. It now returns `text-alert-content` / `text-warning-content` / `text-base-400` rather than class names Tailwind's `@source` scanning can't see. Writing the first test for `certificate_status/1` turned up a third problem. "Expiring Soon" was unreachable: ```elixir DateTime.after?(DateTime.shift(DateTime.utc_now(), month: -3), assigns.not_after) ``` A negative shift puts the cutoff three months in the *past*, so the clause only matched certificates that expired over three months ago, which the `Expired` clause above it had already taken. The shift is now positive, and the status has test coverage for all three branches. ## Noticed but not fixed The edit page looks a CA up by serial alone, with no org check. Serials are globally unique, so a member of one org can open and save another org's CA. The show page added here uses `get_ca_certificate_by_org_and_serial/2`. Fixing edit is a separate change. 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
1 parent 870243f commit c6d8b7f

25 files changed

Lines changed: 923 additions & 59 deletions

lib/nerves_hub/devices/advanced_query/compiler.ex

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -111,6 +111,27 @@ defmodule NervesHub.Devices.AdvancedQuery.Compiler do
111111
)
112112
end
113113

114+
defp signer_ca_exists() do
115+
dynamic(
116+
[d],
117+
fragment(
118+
"EXISTS (SELECT 1 FROM device_certificates dc JOIN ca_certificates ca ON ca.ski = dc.aki WHERE dc.device_id = ?)",
119+
d.id
120+
)
121+
)
122+
end
123+
124+
defp signer_ca_exists(serial) do
125+
dynamic(
126+
[d],
127+
fragment(
128+
"EXISTS (SELECT 1 FROM device_certificates dc JOIN ca_certificates ca ON ca.ski = dc.aki WHERE dc.device_id = ? AND ca.serial = ?)",
129+
d.id,
130+
^serial
131+
)
132+
)
133+
end
134+
114135
# `like`/`not like` use SQL ILIKE (case-insensitive); the value is the user's
115136
# pattern, so they supply `%`/`_` wildcards themselves.
116137
defp comparison_dynamic("identifier", "like", value), do: dynamic([d], ilike(d.identifier, ^value))
@@ -164,6 +185,20 @@ defmodule NervesHub.Devices.AdvancedQuery.Compiler do
164185
fragment("NOT EXISTS (SELECT 1 FROM deployments dg WHERE dg.id = ? AND dg.name = ?)", d.deployment_id, ^name)
165186
)
166187

188+
# A device certificate records its signer's key id as its AKI, which is the
189+
# CA's SKI - so the join is on that rather than a foreign key. The not-set
190+
# sentinel matches devices holding no certificate from a registered CA, which
191+
# covers shared secret authenticated devices as well as certificates whose
192+
# signer was never registered.
193+
defp comparison_dynamic("signer_ca", "=", @not_set_value), do: dynamic([d], not (^signer_ca_exists()))
194+
195+
defp comparison_dynamic("signer_ca", "!=", @not_set_value), do: signer_ca_exists()
196+
197+
# CA serials are globally unique, so the serial alone identifies the CA.
198+
defp comparison_dynamic("signer_ca", "=", serial), do: signer_ca_exists(serial)
199+
200+
defp comparison_dynamic("signer_ca", "!=", serial), do: dynamic([d], not (^signer_ca_exists(serial)))
201+
167202
defp comparison_dynamic("architecture", "=", value), do: dynamic([d], d.firmware_metadata["architecture"] == ^value)
168203

169204
defp comparison_dynamic("architecture", "!=", value), do: dynamic([d], d.firmware_metadata["architecture"] != ^value)

lib/nerves_hub/devices/advanced_query/schema.ex

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ defmodule NervesHub.Devices.AdvancedQuery.Schema do
1010

1111
alias NervesHub.Devices
1212
alias NervesHub.Devices.Alarms
13+
alias NervesHub.Devices.CACertificates
1314
alias NervesHub.Firmwares
1415
alias NervesHub.ManagedDeployments
1516

@@ -65,6 +66,12 @@ defmodule NervesHub.Devices.AdvancedQuery.Schema do
6566
operators: ["=", "!="],
6667
values: &__MODULE__.firmware_validation_status_values/1
6768
},
69+
# The value is the signer CA's serial (or the not-set sentinel); the live
70+
# view's schema JSON shows the CA's description in the autosuggest.
71+
"signer_ca" => %{
72+
operators: ["=", "!="],
73+
values: &__MODULE__.signer_ca_values/1
74+
},
6875
"connection" => %{
6976
operators: ["=", "!="],
7077
values: &__MODULE__.connection_values/1
@@ -242,6 +249,14 @@ defmodule NervesHub.Devices.AdvancedQuery.Schema do
242249
@doc false
243250
def tag_values(product_id), do: Devices.distinct_tags(product_id) ++ [@not_set_value]
244251

252+
@doc false
253+
def signer_ca_values(product_id) do
254+
product_id
255+
|> CACertificates.signer_cas_for_product()
256+
|> Enum.map(& &1.serial)
257+
|> Kernel.++([@not_set_value])
258+
end
259+
245260
@doc false
246261
def deployment_group_values(product_id) do
247262
ManagedDeployments.deployment_group_names(product_id) ++ [@not_set_value]

lib/nerves_hub/devices/ca_certificates.ex

Lines changed: 87 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,8 @@ defmodule NervesHub.Devices.CACertificates do
1212
alias NervesHub.Accounts.Org
1313
alias NervesHub.Certificate
1414
alias NervesHub.Devices.CACertificate
15+
alias NervesHub.Devices.DeviceCertificate
16+
alias NervesHub.Products.Product
1517
alias NervesHub.Repo
1618

1719
@spec create_ca_certificate(Org.t(), map()) ::
@@ -94,6 +96,91 @@ defmodule NervesHub.Devices.CACertificates do
9496
|> Repo.fetch()
9597
end
9698

99+
@doc """
100+
Counts the devices in the org whose certificates were signed by each of its
101+
CAs, keyed by the CA's SKI.
102+
103+
A device certificate records its signer's key id as its AKI, so a CA's SKI is
104+
what ties the two together. Devices with more than one certificate from the
105+
same CA are only counted once, and soft deleted devices are left out so the
106+
count matches what the device list shows by default.
107+
"""
108+
@spec device_counts_by_ski(Org.t()) :: %{binary() => non_neg_integer()}
109+
def device_counts_by_ski(%Org{id: org_id}) do
110+
from(dc in DeviceCertificate,
111+
join: d in assoc(dc, :device),
112+
where: dc.org_id == ^org_id,
113+
where: is_nil(d.deleted_at),
114+
group_by: dc.aki,
115+
select: {dc.aki, count(dc.device_id, :distinct)}
116+
)
117+
|> Repo.all()
118+
|> Map.new()
119+
end
120+
121+
@doc """
122+
The products which have devices signed by the CA, along with each product's
123+
device count, ordered by product name.
124+
125+
The device list is scoped to a single product, so this is what a "devices
126+
using this CA" link has to be broken down by.
127+
"""
128+
@spec device_counts_by_product(CACertificate.t()) ::
129+
[%{product: Product.t(), device_count: non_neg_integer()}]
130+
def device_counts_by_product(%CACertificate{ski: ski, org_id: org_id}) do
131+
from(dc in DeviceCertificate,
132+
join: d in assoc(dc, :device),
133+
join: p in assoc(d, :product),
134+
where: dc.aki == ^ski,
135+
where: dc.org_id == ^org_id,
136+
where: is_nil(d.deleted_at),
137+
where: is_nil(p.deleted_at),
138+
group_by: p.id,
139+
order_by: p.name,
140+
select: %{product: p, device_count: count(d.id, :distinct)}
141+
)
142+
|> Repo.all()
143+
end
144+
145+
@doc """
146+
The org's CAs which signed a certificate held by a device in the product,
147+
ordered by description then serial.
148+
149+
Built from the devices rather than from the org's CA list so the device list's
150+
signer CA filter only offers CAs that can actually match something.
151+
"""
152+
@spec signer_cas_for_product(pos_integer()) :: [CACertificate.t()]
153+
def signer_cas_for_product(product_id) do
154+
from(ca in CACertificate,
155+
join: dc in DeviceCertificate,
156+
on: dc.aki == ca.ski,
157+
join: d in assoc(dc, :device),
158+
where: d.product_id == ^product_id,
159+
where: ca.org_id == d.org_id,
160+
distinct: true,
161+
order_by: [asc: ca.description, asc: ca.serial],
162+
select: ca
163+
)
164+
|> Repo.all()
165+
end
166+
167+
@doc """
168+
The org's CAs matching the given SKIs, keyed by SKI.
169+
170+
Scoped to the org so a certificate signed by another org's CA reads as
171+
unknown rather than exposing that CA.
172+
"""
173+
@spec by_ski(Org.t() | pos_integer(), [binary()]) :: %{binary() => CACertificate.t()}
174+
def by_ski(%Org{id: org_id}, skis), do: by_ski(org_id, skis)
175+
176+
def by_ski(org_id, skis) when is_integer(org_id) do
177+
skis = Enum.reject(skis, &is_nil/1)
178+
179+
from(ca in CACertificate, where: ca.org_id == ^org_id and ca.ski in ^skis)
180+
|> Repo.all()
181+
|> Map.new(&{&1.ski, &1})
182+
end
183+
97184
def update_ca_certificate(%CACertificate{} = certificate, params) do
98185
certificate
99186
|> CACertificate.update_changeset(params)

lib/nerves_hub/devices/device_filtering.ex

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ defmodule NervesHub.Devices.DeviceFiltering do
1414
connection_type: "",
1515
firmware_version: "",
1616
firmware_validation_status: "",
17+
signer_ca: "",
1718
platform: "",
1819
healthy: "",
1920
health_status: "",
@@ -39,6 +40,7 @@ defmodule NervesHub.Devices.DeviceFiltering do
3940
connection_type: :string,
4041
firmware_version: :string,
4142
firmware_validation_status: :string,
43+
signer_ca: :string,
4244
platform: :string,
4345
healthy: :string,
4446
health_status: :string,
@@ -189,6 +191,12 @@ defmodule NervesHub.Devices.DeviceFiltering do
189191
advanced(query, "firmware_validation_status", "=", value)
190192
end
191193

194+
# The value is the signer CA's serial, or the not-set sentinel for devices
195+
# with no certificate from a CA registered with this org.
196+
def filter(query, _filters, :signer_ca, value) do
197+
advanced(query, "signer_ca", "=", value)
198+
end
199+
192200
def filter(query, _filters, :platform, "Unknown") do
193201
where(query, [d], is_nil(d.firmware_metadata["platform"]))
194202
end
Lines changed: 33 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,32 @@
11
defmodule NervesHubWeb.Components.CAHelpers do
22
use NervesHubWeb, :component
33

4+
alias NervesHub.Devices.CACertificate
5+
alias NervesHubWeb.Components.Utils
6+
7+
@doc """
8+
How to refer to a CA in a sentence: its description, or its formatted serial
9+
when it has none (the description is optional).
10+
"""
11+
@spec label(CACertificate.t()) :: String.t()
12+
def label(%CACertificate{description: description, serial: serial}) do
13+
if description in [nil, ""], do: Utils.format_serial(serial), else: description
14+
end
15+
16+
attr(:id, :string, default: "check-expiration-tooltip")
17+
attr(:placement, :string, default: "right")
18+
419
def check_expiration_tooltip(assigns) do
520
~H"""
6-
<span class="tooltip-info"></span>
7-
<span class="tooltip-text">
8-
By default, the time validity of CA certificates is unchecked. You can
9-
toggle this to check expiration to prevent device certificates
10-
from being created from an expired signing CA certificate.
11-
</span>
21+
<div class="relative z-20 flex items-center" id={@id} phx-hook="ToolTip" data-placement={@placement}>
22+
<.icon name="info" class="stroke-base-400" />
23+
<div class="bg-surface-muted border-base-700 tooltip-content absolute top-0 left-0 z-20 hidden w-max max-w-72 rounded border px-2 py-1.5 text-xs">
24+
By default, the time validity of CA certificates is unchecked. You can
25+
toggle this to check expiration to prevent device certificates
26+
from being created from an expired signing CA certificate.
27+
<div class="bg-surface-muted border-base-700 tooltip-arrow absolute size-2 origin-center rotate-45"></div>
28+
</div>
29+
</div>
1230
"""
1331
end
1432

@@ -18,7 +36,10 @@ defmodule NervesHubWeb.Components.CAHelpers do
1836
DateTime.after?(DateTime.utc_now(), assigns.not_after) ->
1937
"Expired"
2038

21-
DateTime.after?(DateTime.shift(DateTime.utc_now(), month: -3), assigns.not_after) ->
39+
# Expires within the next three months. The shift was negative, which
40+
# put the cutoff three months in the past - a window the `Expired`
41+
# clause above has already taken, so this never matched.
42+
DateTime.after?(DateTime.shift(DateTime.utc_now(), month: 3), assigns.not_after) ->
2243
"Expiring Soon"
2344

2445
true ->
@@ -37,12 +58,9 @@ defmodule NervesHubWeb.Components.CAHelpers do
3758
"""
3859
end
3960

40-
defp certificate_status_class(status) do
41-
formatted =
42-
status
43-
|> String.downcase()
44-
|> String.replace(" ", "-")
45-
46-
"certificate-status-#{formatted}"
47-
end
61+
# The `-content` colours are the ones defined for both themes; a bare
62+
# `text-alert` stays red-500 on the light theme, which is too hot.
63+
defp certificate_status_class("Expired"), do: "text-alert-content"
64+
defp certificate_status_class("Expiring Soon"), do: "text-warning-content"
65+
defp certificate_status_class("Current"), do: "text-base-400"
4866
end

lib/nerves_hub_web/components/device_page/settings_tab.ex

Lines changed: 33 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,10 +3,12 @@ defmodule NervesHubWeb.Components.DevicePage.SettingsTab do
33

44
alias NervesHub.Certificate
55
alias NervesHub.Devices
6+
alias NervesHub.Devices.CACertificates
67
alias NervesHub.Devices.Certificates
78
alias NervesHub.Devices.Device
89
alias NervesHub.Devices.Updates
910
alias NervesHub.Extensions
11+
alias NervesHubWeb.Components.CAHelpers
1012
alias NervesHubWeb.Components.Utils
1113
alias NervesHubWeb.LayoutView.DateTimeFormat
1214

@@ -28,7 +30,15 @@ defmodule NervesHubWeb.Components.DevicePage.SettingsTab do
2830
def render(assigns) do
2931
device = Certificates.preload_device_certificates(assigns.device, force: true)
3032

31-
assigns = Map.put(assigns, :device, device)
33+
# A device certificate names its signer by key id (its AKI, the CA's SKI)
34+
# rather than by a foreign key, and the signer may never have been
35+
# registered - so the lookup can come up empty.
36+
signer_cas = CACertificates.by_ski(device.org_id, Enum.map(device.device_certificates, & &1.aki))
37+
38+
assigns =
39+
assigns
40+
|> Map.put(:device, device)
41+
|> Map.put(:signer_cas, signer_cas)
3242

3343
~H"""
3444
<div
@@ -201,6 +211,11 @@ defmodule NervesHubWeb.Components.DevicePage.SettingsTab do
201211
<span>Not after:</span>
202212
<.local_datetime at={certificate.not_after} time_zone={@time_zone} format={:date} zone_label={false} />
203213
</div>
214+
215+
<div class="text-base-400 text-xs tracking-wide">
216+
<span>Signer CA:</span>
217+
<.signer_ca signer_ca={@signer_cas[certificate.aki]} org={@org} />
218+
</div>
204219
</div>
205220
</div>
206221
<div class="flex gap-2">
@@ -309,6 +324,23 @@ defmodule NervesHubWeb.Components.DevicePage.SettingsTab do
309324
"""
310325
end
311326

327+
attr(:signer_ca, :any, required: true)
328+
attr(:org, :map, required: true)
329+
330+
defp signer_ca(%{signer_ca: nil} = assigns) do
331+
~H"""
332+
<span>Unknown</span>
333+
"""
334+
end
335+
336+
defp signer_ca(assigns) do
337+
~H"""
338+
<.link navigate={~p"/org/#{@org}/settings/certificates/#{@signer_ca}"} class="hover:text-base-300 underline">
339+
{CAHelpers.label(@signer_ca)}
340+
</.link>
341+
"""
342+
end
343+
312344
def hooked_event("validate-device-settings", %{"device" => device_params}, socket) do
313345
changeset = Device.changeset(socket.assigns.device, device_params)
314346

lib/nerves_hub_web/controllers/api/openapi/device_controller_specs.ex

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -54,6 +54,7 @@ defmodule NervesHubWeb.API.OpenAPI.DeviceControllerSpecs do
5454
* `health_status != "healthy" or alarm_status = "with"`
5555
* `metric:battery_soc < 20 and updates = "enabled"`
5656
* `firmware_validation_status = "not_validated"`
57+
* `signer_ca = "1234567890"`
5758
""",
5859
example: ~s|metric:cpu_temp > 70 and connection = "connected"|
5960
},
@@ -67,6 +68,12 @@ defmodule NervesHubWeb.API.OpenAPI.DeviceControllerSpecs do
6768
enum: ["validated", "not_validated", "unknown"]
6869
},
6970
firmware_version: %OpenApiSpex.Schema{type: :string, example: "1.10.0"},
71+
signer_ca: %OpenApiSpex.Schema{
72+
type: :string,
73+
description:
74+
"The serial of the CA which signed the device's certificate, or `:not_set` for devices with no certificate from a registered CA.",
75+
example: "1234567890"
76+
},
7077
has_no_tags: %OpenApiSpex.Schema{type: :string, enum: ["true", "false"]},
7178
health_status: %OpenApiSpex.Schema{
7279
type: :string,

0 commit comments

Comments
 (0)