Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
46 changes: 46 additions & 0 deletions docs/encryption-at-rest.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
109 changes: 98 additions & 11 deletions lib/trinity/vault.ex
Original file line number Diff line number Diff line change
Expand Up @@ -31,39 +31,126 @@ 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

@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."
Expand Down
40 changes: 31 additions & 9 deletions test/trinity/vault_test.exs
Original file line number Diff line number Diff line change
Expand Up @@ -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", %{
Expand Down
Loading