diff --git a/docs/encryption-at-rest.md b/docs/encryption-at-rest.md index a830322..62250da 100644 --- a/docs/encryption-at-rest.md +++ b/docs/encryption-at-rest.md @@ -71,6 +71,52 @@ full-disk encryption costs, and "not much" is not an answer. slower storage, will show a different ratio, and the ratio is what transfers, not the microseconds. - **It is not a claim about the database's own safety.** It measures write cost, nothing else. +## Which layer carries the compliance control, and which does not + +Researched rather than assumed, because the answer decides what this project should turn on by +default and what it must not let a reader mistake for a control. + +**Full-disk encryption is the at-rest control.** For Controlled Unclassified Information, NIST SP +800-171 3.13.16 requires protecting the confidentiality of CUI at rest, and the ordinary +implementation on an endpoint is full-disk encryption. Data at rest needs to be encrypted **once**; +application-level encryption layered over an already-encrypted volume does not satisfy the control +a second time. It protects against a different threat, and is worth having for that reason and not +for a compliance one. + +**The module must be validated, not merely approved.** 3.13.11 requires FIPS-*validated* +cryptography wherever encryption protects CUI, and 3.13.16 inherits that requirement. Validated +means a module with a current CMVP certificate, not an approved algorithm in an unvalidated +library. Trinity's `:crypto` calls run against whatever OpenSSL the host provides; they are +validated only on the FIPS build leg (`docs/fips-leg.md`), which is the leg that exists to make +statements like this one measurable. **So this project's application-level sealing is not a CUI +at-rest control and must not be presented as one.** The volume is. + +**Availability under key loss is a control of its own.** NIST SP 800-53 SC-12(1) requires +maintaining the availability of information when a user loses cryptographic keys. This slice puts +key escrow and recovery explicitly out of scope, which bounds how far encryption should reach by +default: sealing data whose purpose is to leave this machine would trade a confidentiality gain +nothing asks for against an availability failure the framework names. + +**Secure defaults, where they cost nothing.** CISA's secure-by-design guidance asks that the secure +configuration be the baseline and that deviating from it be deliberate. It also asks that products +be resilient out of the box *without end-users having to take additional steps*. Those two pull +against each other exactly where a key source has to be configured first, and the second one wins +on the path where it applies: a default that breaks an unconfigured install is not a secure +default. + +### What that produces + +| class | sealed by default | why | +|---|---|---| +| staged skill changes | **yes** | machine-local and short-lived; discarding a staged change is already supported, so a lost key costs a proposal that can be made again | +| exports | no | an export exists in order to move; its destination is where that data's at-rest control belongs | +| skill files | no | they are meant to be opened in an editor; an encrypted file a person cannot read is a broken skill, not a safer one | + +And where a class is sealed but no key source exists, the blob is written in the clear and the fact +is logged once rather than hidden, because an operator who believed sealing was on should learn it +from a log and not from an incident. `Trinity.Vault.sealed?/1` reports what any given blob actually +is, so the answer never has to be inferred from configuration. + ## Setting up the encrypted volume What a deployment does once. This is the baseline Trinity assumes for everything SQLite indexes. diff --git a/lib/trinity/vault.ex b/lib/trinity/vault.ex index b03d8ec..8b83290 100644 --- a/lib/trinity/vault.ex +++ b/lib/trinity/vault.ex @@ -31,7 +31,12 @@ defmodule Trinity.Vault do alias Trinity.Keys + require Logger + @version "TVLT1" + # See `sealing?/1`: chosen per class against secure-by-default, the "encrypted once" rule for + # data at rest, and SC-12(1)'s requirement to keep information available when keys are lost. + @sealed_by_default [:staged_skills] @iv_bytes 12 @tag_bytes 16 @key_bytes 32 @@ -39,31 +44,113 @@ defmodule Trinity.Vault do @doc """ Whether a class of blob is sealed on write. - Configured per class and **off by default**: + Overridable per class, and the defaults differ by class on purpose: + + config :trinity, :vault, seal: [:staged_skills] # the default, stated + + ## Why not simply on for everything + + CISA's secure-by-default guidance says the secure configuration should be the baseline and that + deviating from it should be a deliberate act. That argues for sealing everything. Three findings + argue against applying it uniformly, and the split below is where they land. + + **Layering adds no compliance value.** For CUI at rest, NIST SP 800-171 3.13.16 is satisfied by + encrypting once; full-disk encryption on the endpoint is the ordinary implementation, and + `docs/encryption-at-rest.md` measures what it costs. Application-level sealing on top of a + volume that is already encrypted protects against a different threat, not the same one twice. + + **Availability under key loss is its own control.** NIST SP 800-53 SC-12(1) requires maintaining + the availability of information when a user loses cryptographic keys. This slice puts key escrow + and recovery explicitly out of scope, so a default that seals data whose whole purpose is to + leave this machine would trade a confidentiality gain the frameworks do not ask for against an + availability failure they name. - config :trinity, :vault, seal: [:staged_skills, :exports] + **This module's cryptography is not FIPS-validated except on the FIPS leg.** 3.13.11 requires a + validated *module*, not merely an approved algorithm, and the requirement is inherited by + 3.13.16 wherever encryption is the means of protecting CUI. A default that sealed everything + would invite a deployment to believe its CUI-at-rest control lives here. It does not; it lives + in the volume, and this module must not be the thing a reader mistakes for it. - Off by default because sealing is not free in ways that matter to the person using this. A - sealed export can only be restored where the key is, which is exactly what a regulated - deployment wants and exactly what someone moving their data to a new laptop does not. A sealed - skill file cannot be edited in a text editor. The mechanism is built, tested and available; which - blobs it applies to is the deployment's call rather than this slice's, and `open/1` passes - unsealed blobs through unchanged so the choice can be made later without a migration. + ## The defaults, and the reason for each + + * `:staged_skills` - **sealed by default.** Machine-local, short-lived, and discarding a staged + change is already a supported operation, so losing the key costs a proposal that can be made + again. Secure-by-default costs nothing here, so it is taken. + * `:exports` - **not sealed by default.** An export exists in order to move to another machine. + Sealing it by default produces exactly the SC-12(1) failure, for no compliance gain, since + the destination is where that data's at-rest control belongs. A deployment that exports only + within its own custody turns it on. + * `:skills` - **not sealed by default.** Skill files are meant to be opened in an editor. An + encrypted file that a person cannot read is not a safer skill, it is a broken one. """ @spec sealing?(atom()) :: boolean() def sealing?(class) when is_atom(class) do - class in (Application.get_env(:trinity, :vault, [])[:seal] || []) + case Application.get_env(:trinity, :vault, [])[:seal] do + nil -> class in @sealed_by_default + configured -> class in configured + end end + @doc "The classes sealed when nothing is configured. See `sealing?/1` for why these and not others." + @spec sealed_by_default() :: [atom()] + def sealed_by_default, do: @sealed_by_default + @doc """ - Seals a blob if its class is configured for sealing, and returns it unchanged if not. + Seals a blob if its class is sealed and a key source exists; returns it unchanged otherwise. The call site reads the same either way, which is the point: a path that has to branch on whether encryption is on is a path where one branch is less tested than the other. + + ## Why this degrades instead of failing + + Sealing `:staged_skills` by default was written first as "seal, and raise if you cannot", which + is what secure-by-default sounds like it should mean. It broke ten tests immediately, all with + the same error: a machine with no passphrase, no systemd credential and no TPM tooling - which + is every developer machine and most first runs - could no longer stage a skill proposal at all. + + That is the wrong reading of the principle. CISA's wording is that a product should be resilient + out of the box **without end-users having to take additional steps**; requiring a key source to + be configured before a core feature works is precisely such a step. A default that turns an + unconfigured install into a broken one is not a secure default, it is an outage with a rationale. + + So: where custody exists, blobs are sealed with no action required, which is the secure default + doing its job. Where it does not, the blob is written in the clear and the fact is logged once + per class rather than hidden, because an operator who believed sealing was on needs to find out + from the logs and not from an incident. The blob format itself carries the answer too - + `sealed?/1` reports what a given blob actually is, so nothing has to be inferred from + configuration. """ @spec maybe_seal!(binary(), atom()) :: binary() def maybe_seal!(plaintext, class) when is_binary(plaintext) and is_atom(class) do - if sealing?(class), do: seal!(plaintext), else: plaintext + if sealing?(class), do: seal_or_warn(plaintext, class), else: plaintext + end + + defp seal_or_warn(plaintext, class) do + case seal(plaintext) do + {:ok, sealed} -> + sealed + + {:error, {:no_key_source, refusals}} -> + warn_once(class, refusals) + plaintext + + {:error, reason} -> + # Any other failure is a real one: a key source exists and sealing still did not work. + raise ArgumentError, "cannot seal #{inspect(class)}: #{inspect(reason)}" + end + end + + defp warn_once(class, refusals) do + key = {__MODULE__, :warned, class} + + if :persistent_term.get(key, nil) == nil do + :persistent_term.put(key, true) + + Logger.warning( + "vault: #{inspect(class)} is configured to be sealed but no key source is available, " <> + "so blobs of this class are being written in the clear. Refusals: #{inspect(refusals)}" + ) + end end @doc "The marker a sealed blob starts with, so a caller can tell one without decrypting it." diff --git a/test/trinity/vault_test.exs b/test/trinity/vault_test.exs index c27f1c3..bc807db 100644 --- a/test/trinity/vault_test.exs +++ b/test/trinity/vault_test.exs @@ -118,21 +118,43 @@ defmodule Trinity.VaultTest do end describe "AC2: the blob classes, sealed only where a deployment asks" do - test "sealing is off unless configured, and the call site reads the same either way" do - refute Vault.sealing?(:staged_skills) - assert Vault.maybe_seal!(@secret, :staged_skills) == @secret + test "the defaults differ by class, and each default is the researched one" do + # Sealed by default: machine-local, discardable, so secure-by-default costs nothing. + assert Vault.sealed_by_default() == [:staged_skills] + assert Vault.sealing?(:staged_skills) - Application.put_env(:trinity, :vault, seal: [:staged_skills]) + # Not sealed by default: an export exists to move to another machine, and sealing it by + # default is the SC-12(1) availability failure for no compliance gain; a skill file is meant + # to be opened in an editor. + refute Vault.sealing?(:exports) + refute Vault.sealing?(:skills) + end + + test "configuration overrides the defaults in both directions" do on_exit(fn -> Application.delete_env(:trinity, :vault) end) - assert Vault.sealing?(:staged_skills) - sealed = Vault.maybe_seal!(@secret, :staged_skills) + # Turning a class on. + Application.put_env(:trinity, :vault, seal: [:staged_skills, :exports]) + assert Vault.sealing?(:exports) + sealed = Vault.maybe_seal!(@secret, :exports) assert Vault.sealed?(sealed) assert {:ok, @secret} = Vault.open(sealed) - # A class that was not named is still untouched, so turning one on does not turn on the rest. - refute Vault.sealing?(:exports) - assert Vault.maybe_seal!(@secret, :exports) == @secret + # And off again, including a class that is on by default: a deployment that cannot afford + # the key-loss risk must be able to say so, or the default is a trap rather than a default. + Application.put_env(:trinity, :vault, seal: []) + refute Vault.sealing?(:staged_skills) + assert Vault.maybe_seal!(@secret, :staged_skills) == @secret + end + + test "the call site reads the same whether sealing is on or off" do + on_exit(fn -> Application.delete_env(:trinity, :vault) end) + + Application.put_env(:trinity, :vault, seal: []) + assert Vault.maybe_seal!(@secret, :staged_skills) == @secret + + Application.put_env(:trinity, :vault, seal: [:staged_skills]) + assert {:ok, @secret} = Vault.open(Vault.maybe_seal!(@secret, :staged_skills)) end test "a staged skill change is ciphertext on disk and reads back through the seam", %{