Skip to content

Commit 1d46845

Browse files
committed
feat(s025): the signer reads its key through the seam, and the boot receipt names the custody
The receipt signing key is sealed by Trinity.Keys before it is written, so the key file is ciphertext and what opens it is whatever the deployment's adapter holds. The registry row records which custody actually held the key rather than the constant "file" it carried before. Two compatibility obligations, both tested. A key written before this slice has no custody field and holds its private bytes directly, and still opens: a slice that made every existing installation unbootable to gain a property would not be an improvement. And when no key source is available the key is written unsealed rather than not at all, because refusing to boot over unconfigured encryption at rest trades a working signer for a stronger one nobody asked for; the registry row says which of the two happened. The boot receipt now names the custody adapter, its source and the key id, so a reader of the chain can tell whether the signing key was held by a passphrase, a systemd credential or a TPM without asking anyone. It carries no key material and a test asserts that by searching the encoded subject for the key's bytes. One defect found and fixed on the way, mine, and the interesting part of this commit. KeyCustody.boot!/1 records its selection in :persistent_term, which outlives the test process and the sandbox. The new test booted custody against a temporary directory, restored the config on exit, deleted the directory, and left the selection pointing at it. Nineteen tests then failed in the membrane, the MCP bridge, provenance and memory, none of which touch keys, all with signer_unavailable. Restoring config was not enough; the selection had to be re-made from it. Confirmed across three seeds. Two false trails are recorded in NOTES rather than dropped: a stale trinity_test.db carrying another test's rows, which was a real and separate problem, and the temp directory named trinity-* that slice 034 warns about, which is a real warning and was not this. What found the actual cause was running the suite on main with a clean database: 580 there against 588 of 607 on the branch turned "something is broken" into "something I added is broken". mix gate exit 0; 607 passed; coverage 80.31%. Signed-off-by: Ayla Croft <aylacroft@proton.me>
1 parent fcc476e commit 1d46845

3 files changed

Lines changed: 186 additions & 4 deletions

File tree

‎lib/trinity/effects/boot.ex‎

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@ defmodule Trinity.Effects.Boot do
2121
subject = %{
2222
"authority" => Trinity.Authority.selected_name(),
2323
"signer" => signer_subject(),
24+
"key_custody" => key_custody_subject(),
2425
"otp_release" => List.to_string(:erlang.system_info(:otp_release)),
2526
"fips" => Atom.to_string(:crypto.info_fips()),
2627
"node" => Atom.to_string(node())
@@ -52,6 +53,29 @@ defmodule Trinity.Effects.Boot do
5253
end
5354
end
5455

56+
# Slice 025 AC1: the boot receipt names the custody adapter and the source it chose, so a reader
57+
# of the chain can tell whether the signing key was held by a passphrase, a systemd credential or
58+
# a TPM without asking anyone. It carries the key id and never key material.
59+
@doc """
60+
What the boot receipt records about key custody. Public so the test that asserts it carries no
61+
key material can call the thing the receipt actually uses rather than a copy of it.
62+
"""
63+
@spec key_custody_subject() :: map()
64+
def key_custody_subject do
65+
case Trinity.Keys.describe() do
66+
%{adapter: adapter, source: source, key_id: key_id, detail: detail} ->
67+
%{
68+
"adapter" => inspect(adapter),
69+
"source" => Atom.to_string(source),
70+
"key_id" => key_id,
71+
"detail" => detail
72+
}
73+
74+
{:error, reason} ->
75+
%{"unavailable" => inspect(reason)}
76+
end
77+
end
78+
5579
defp signer_subject do
5680
case Trinity.Receipts.KeyCustody.selected() do
5781
%{algorithm: a, scheme: s, key_id: k} ->

‎lib/trinity/receipts/key_custody.ex‎

Lines changed: 30 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -161,6 +161,14 @@ defmodule Trinity.Receipts.KeyCustody do
161161
{pub, priv} = impl.generate_key()
162162
jwk = impl.jwk(pub)
163163

164+
# Slice 025: the private half is sealed by the key custody seam before it is written, so the
165+
# file on disk is ciphertext and the thing that opens it is whatever the deployment's adapter
166+
# holds. When no key source is available the key is written unsealed rather than not at all:
167+
# refusing to boot because encryption at rest is unconfigured would trade a working signer for
168+
# a stronger one nobody asked for, and the registry row records which of the two it is.
169+
encoded = impl.encode_private(priv)
170+
{private_b64, custody} = seal_private(encoded)
171+
164172
{key_id, kid_scheme} =
165173
case jwk do
166174
nil -> {:crypto.hash(:sha256, pub) |> Base.url_encode64(padding: false), "sha256-raw"}
@@ -177,14 +185,15 @@ defmodule Trinity.Receipts.KeyCustody do
177185
"fingerprint" => :crypto.hash(:sha256, pub) |> Base.encode16(case: :lower),
178186
"valid_from" => DateTime.utc_now() |> DateTime.to_iso8601(),
179187
"status" => "active",
180-
"custody" => "file"
188+
"custody" => custody
181189
}
182190

183191
file =
184192
JSON.encode!(%{
185193
"algorithm" => Atom.to_string(impl.algorithm()),
186194
"key_id" => key_id,
187-
"private_b64" => Base.encode64(impl.encode_private(priv)),
195+
"private_b64" => private_b64,
196+
"custody" => custody,
188197
"public_b64" => Base.encode64(pub)
189198
})
190199

@@ -195,13 +204,30 @@ defmodule Trinity.Receipts.KeyCustody do
195204
end
196205
end
197206

207+
# Slice 025. The signer holds no key-opening logic of its own: it hands bytes to the seam and
208+
# takes bytes back. Swapping the custody adapter for a KMS changes `Trinity.Keys` and nothing
209+
# here, which is the whole reason the seam exists.
210+
defp seal_private(encoded) do
211+
case Trinity.Keys.wrap(encoded) do
212+
{:ok, wrapped} -> {Base.encode64(wrapped), "sealed"}
213+
{:error, _reason} -> {Base.encode64(encoded), "file"}
214+
end
215+
end
216+
217+
# A key file written before slice 025 carries no `custody` field and holds the private bytes
218+
# directly. It still opens: a slice that made every existing installation unbootable to gain a
219+
# property would not be an improvement.
220+
defp open_private(stored, %{"custody" => "sealed"}), do: Trinity.Keys.unwrap(stored)
221+
defp open_private(stored, _row), do: {:ok, stored}
222+
198223
# sobelow_skip reason: Traversal.FileModule: `path` is the selection's key path, built by
199224
# key_path/2 at boot from the keys directory and the algorithm, never input.
200225
@sobelow_skip ["Traversal.FileModule"]
201226
defp read_private(path, impl, key_id) do
202227
with {:ok, bin} <- File.read(path),
203-
{:ok, %{"private_b64" => b64, "key_id" => ^key_id}} <- JSON.decode(bin),
204-
{:ok, encoded} <- Base.decode64(b64) do
228+
{:ok, %{"private_b64" => b64, "key_id" => ^key_id} = row} <- JSON.decode(bin),
229+
{:ok, stored} <- Base.decode64(b64),
230+
{:ok, encoded} <- open_private(stored, row) do
205231
{:ok, impl.decode_private(encoded)}
206232
else
207233
{:error, :enoent} -> {:error, :signer_unavailable}
Lines changed: 132 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,132 @@
1+
# SPDX-FileCopyrightText: Sudo Apt Holdings LLC
2+
# SPDX-License-Identifier: Apache-2.0
3+
defmodule Trinity.Keys.SignerSeamTest do
4+
@moduledoc """
5+
Slice 025, AC3 and AC1: the receipt signing key is sealed by the custody seam before it reaches
6+
disk, opens again through it, and the boot receipt names which adapter and source held it.
7+
8+
The retrofit's obligation runs both ways. A key written *before* this slice carries no custody
9+
field and holds its private bytes directly, and it must still open: a slice that made every
10+
existing installation unbootable in order to gain a property would not be an improvement. That
11+
case is tested here explicitly, by writing the old format.
12+
"""
13+
use ExUnit.Case, async: false
14+
15+
alias Trinity.Keys.Local
16+
alias Trinity.Receipts.{KeyCustody, KeyRegistry}
17+
18+
setup do
19+
dir =
20+
Path.join(System.tmp_dir!(), "trinity-signer-seam-#{System.unique_integer([:positive])}")
21+
22+
File.mkdir_p!(dir)
23+
24+
prior_keys = Application.get_env(:trinity, :keys)
25+
prior_receipts = Application.get_env(:trinity, :receipts)
26+
prior_pass = System.get_env("TRINITY_KEYS_PASSPHRASE")
27+
28+
Application.put_env(:trinity, :keys, dir: dir, source: :passphrase)
29+
Application.put_env(:trinity, :receipts, Keyword.put(prior_receipts || [], :keys_dir, dir))
30+
System.put_env("TRINITY_KEYS_PASSPHRASE", "a signer seam passphrase")
31+
:persistent_term.erase({Local, :selection})
32+
33+
on_exit(fn ->
34+
:persistent_term.erase({Local, :selection})
35+
36+
# KeyCustody.boot!/1 records its selection in :persistent_term, which outlives this test's
37+
# process and every sandbox. Without putting the real one back, every later test in the run
38+
# signs against a temporary directory this test has just deleted, and the failure surfaces
39+
# as `signer_unavailable` in tests that have nothing to do with keys. Restoring the config
40+
# is not enough: the selection has to be re-made from it.
41+
restore = fn ->
42+
if prior_receipts,
43+
do: Application.put_env(:trinity, :receipts, prior_receipts),
44+
else: Application.delete_env(:trinity, :receipts)
45+
46+
KeyCustody.boot!()
47+
end
48+
49+
if prior_keys,
50+
do: Application.put_env(:trinity, :keys, prior_keys),
51+
else: Application.delete_env(:trinity, :keys)
52+
53+
if prior_pass,
54+
do: System.put_env("TRINITY_KEYS_PASSPHRASE", prior_pass),
55+
else: System.delete_env("TRINITY_KEYS_PASSPHRASE")
56+
57+
restore.()
58+
File.rm_rf(dir)
59+
end)
60+
61+
{:ok, dir: dir}
62+
end
63+
64+
test "the signing key is ciphertext on disk and the registry says so", %{dir: dir} do
65+
assert {:ok, selection} = KeyCustody.boot!(dir)
66+
67+
raw = File.read!(selection.key_path)
68+
{:ok, row} = JSON.decode(raw)
69+
70+
assert row["custody"] == "sealed"
71+
72+
# The stored private value is what the seam produced, not the key itself: it carries the
73+
# vault's wrapper marker, and the wrapper is longer than the key it holds.
74+
{:ok, stored} = Base.decode64(row["private_b64"])
75+
assert String.starts_with?(stored, "TKW1")
76+
77+
# And the registry row records the custody that actually held it, rather than a constant.
78+
{:ok, rows} = KeyRegistry.read(dir)
79+
assert %{"custody" => "sealed"} = KeyRegistry.lookup(rows, selection.key_id)
80+
end
81+
82+
test "a sealed key still signs, which is the point of sealing it", %{dir: dir} do
83+
assert {:ok, _selection} = KeyCustody.boot!(dir)
84+
assert {:ok, signature} = KeyCustody.sign("some pae bytes")
85+
assert is_binary(signature) and byte_size(signature) > 0
86+
end
87+
88+
test "losing what opens the key makes the signer unavailable rather than silently wrong", %{
89+
dir: dir
90+
} do
91+
assert {:ok, _} = KeyCustody.boot!(dir)
92+
assert {:ok, _} = KeyCustody.sign("before")
93+
94+
File.rm!(Local.salt_path())
95+
:persistent_term.erase({Local, :selection})
96+
97+
assert {:error, _reason} = KeyCustody.sign("after")
98+
end
99+
100+
test "a key file written before this slice still opens", %{dir: dir} do
101+
# Boot once to get a real keypair and registry row, then rewrite the file in the old shape:
102+
# no custody field, private bytes stored directly.
103+
assert {:ok, selection} = KeyCustody.boot!(dir)
104+
{:ok, row} = JSON.decode(File.read!(selection.key_path))
105+
{:ok, wrapped} = Base.decode64(row["private_b64"])
106+
{:ok, plain} = Trinity.Keys.unwrap(wrapped)
107+
108+
old_format =
109+
row
110+
|> Map.delete("custody")
111+
|> Map.put("private_b64", Base.encode64(plain))
112+
|> JSON.encode!()
113+
114+
File.write!(selection.key_path, old_format)
115+
116+
assert {:ok, _} = KeyCustody.boot!(dir)
117+
assert {:ok, signature} = KeyCustody.sign("pae bytes from an old installation")
118+
assert is_binary(signature)
119+
end
120+
121+
test "AC1: the boot receipt subject names the adapter, the source and no key material" do
122+
subject = Trinity.Effects.Boot.key_custody_subject()
123+
124+
assert subject["adapter"] =~ "Trinity.Keys.Local"
125+
assert subject["source"] == "passphrase"
126+
assert is_binary(subject["key_id"])
127+
assert subject["detail"] =~ "PBKDF2"
128+
129+
{:ok, material} = Trinity.Keys.fetch(:receipts)
130+
refute String.contains?(JSON.encode!(subject), Base.encode64(material))
131+
end
132+
end

0 commit comments

Comments
 (0)