Tablet 45: the courtyard gate checks the key, not the shape of the header

/courtyard/* and /chatzer/* admitted any `Authorization: Bearer <x>`.
Every test sent `Bearer test-token`; none sent a wrong one. Every
admitted request spends the day's manna.

VavPipeline.BigdeiKodesh.Gate: keys are a file (Construction §4.6 —
never env, never config, never git), one `tier key` per line, tiers
courtyard < chatzer (the inner ring opens the outer). Constant-time
compare against every line; no file / empty / unreadable → nobody
passes, GATE_NO_KEYS in the log, the caller told only "missing or
invalid". 403 FORBIDDEN for an outer key at an inner gate. Read on
every request so a rotated key is live in ~1 min without a rollout.

Deploy: Secret shorashim-api-keys projected at
/etc/shorashim/secrets/api_keys (vav-pipeline only, 0440),
API_KEYS_FILE in the configmap, template + issue/rotate recipe in
secrets.yaml and KOHEN.md "Issuing a key". Spec: tamid §1.1 (gate,
403), vayakhel §4.6. Doorpost 91d09d8d… → 42b38626….
Tests: tablet45_gate_test (parse, tiers, wrong/near/empty/no key, no
file) — Elixir 527/0.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
Scott KarpandClaude Fable 5.1 committed 2026-10-06 15:16:33 +00:00
1 parent 996a8efe68
commit 79eb34840f
17 files changed
+372 -34

No files matched your search

@@ -0,0 +1,130 @@
defmodule VavPipeline.BigdeiKodesh.Gate do
@moduledoc """
The gate of the courtyard — who may bring an offering past the pe'ah.
Bamidbar 1:51 — *v'hazar hakarev yumat*: the stranger who draws near
the inner service does not pass. The pe'ah (`/peah/*`) is open to the
ger; the courtyard (`/courtyard/*`) and the chatzer (`/chatzer/*`) are
for those who were given a key.
Until 2026-10-06 the gate looked only at the SHAPE of the header: any
`Authorization: Bearer <anything>` was admitted. A gate that opens for
every hand is a wall with a hole in it, and every admitted request
spends the day's manna.
**Keys.** A file (Construction §4.6: secrets are files, mode 0600,
never environment variables, never configuration, never the
repository), one key per line:
# tier key
courtyard 5f2c… # may bring offerings and bedikot
chatzer 9a1e… # may also convene the Sanhedrin
A chatzer key opens the courtyard too — the inner ring contains the
outer. Blank lines and `#` comments are ignored. The file is read on
every request so that a rotated key takes effect at once; it is a few
lines, and the cost is nothing beside a cycle.
**Fail closed.** No file, an empty file, an unreadable file — nobody
passes. The operator sees it in the log (`GATE_NO_KEYS`), never the
caller: the caller is told only that the key is missing or invalid.
Keys are compared in constant time (`Plug.Crypto.secure_compare/2`).
No key, valid or not, is ever written to a log.
"""
require Logger
@tiers [:courtyard, :chatzer]
@type tier :: :courtyard | :chatzer
@doc "Tiers, outermost first. A key of a later tier opens every earlier one."
@spec tiers() :: [tier()]
def tiers, do: @tiers
@doc "Where the keys are read from. `nil` means the gate is shut."
@spec keys_file() :: String.t() | nil
def keys_file, do: Application.get_env(:vav_pipeline, :api_keys_file)
@doc """
Admit the request to `tier`, or say why not.
* `{:ok, granted}` — the key is good for `granted`, which is `tier`
or an inner one.
* `{:error, :no_header}` — no `Authorization: Bearer <key>` header.
* `{:error, :unknown_key}` — the key is not in the file, or there
is no file (logged as GATE_NO_KEYS, once per denial).
* `{:error, :tier_too_low}` — a good key, but for an outer ring.
"""
@spec admit(Plug.Conn.t(), tier()) ::
{:ok, tier()} | {:error, :no_header | :unknown_key | :tier_too_low}
def admit(conn, tier) when tier in @tiers do
with {:ok, key} <- bearer(conn),
{:ok, granted} <- lookup(key, keys_file()) do
if rank(granted) >= rank(tier), do: {:ok, granted}, else: {:error, :tier_too_low}
end
end
@doc "Parse the contents of a keys file into `[{tier, key}]`. Malformed lines are skipped."
@spec parse(String.t()) :: [{tier(), String.t()}]
def parse(contents) do
contents
|> String.split(~r/\r?\n/)
|> Enum.map(&String.trim/1)
|> Enum.reject(&(&1 == "" or String.starts_with?(&1, "#")))
|> Enum.flat_map(fn line ->
case String.split(line, ~r/\s+/, parts: 3) do
[tier, key | _] when tier in ["courtyard", "chatzer"] and key != "" ->
[{String.to_existing_atom(tier), key}]
_ ->
[]
end
end)
end
# ------------------------------------------------------------
defp bearer(conn) do
case Plug.Conn.get_req_header(conn, "authorization") do
["Bearer " <> key] when key != "" -> {:ok, String.trim(key)}
_ -> {:error, :no_header}
end
end
defp lookup(_key, nil) do
Logger.warning("GATE_NO_KEYS: no api_keys_file configured — the courtyard is shut")
{:error, :unknown_key}
end
defp lookup(key, path) do
case File.read(path) do
{:ok, contents} ->
entries = parse(contents)
if entries == [] do
Logger.warning("GATE_NO_KEYS: #{path} holds no keys — the courtyard is shut")
end
# Compare against every entry, not just until the first match:
# the time taken must not say how far down the file the key was.
entries
|> Enum.reduce(nil, fn {tier, candidate}, found ->
if Plug.Crypto.secure_compare(candidate, key), do: best(found, tier), else: found
end)
|> case do
nil -> {:error, :unknown_key}
tier -> {:ok, tier}
end
{:error, reason} ->
Logger.warning("GATE_NO_KEYS: cannot read #{path} (#{reason}) — the courtyard is shut")
{:error, :unknown_key}
end
end
defp best(nil, tier), do: tier
defp best(a, b), do: if(rank(a) >= rank(b), do: a, else: b)
defp rank(tier), do: Enum.find_index(@tiers, &(&1 == tier))
end
@@ -28,6 +28,8 @@ defmodule VavPipeline.BigdeiKodesh.Router do
require Logger
alias VavPipeline.BigdeiKodesh.Gate
# Form 3 architectural enforcement (vehaya §3.2). NoTrackingLogger
# replaces Plug.Logger; sanitized paths on /peah/* honor the
# no-tracking promise made in /peah/ask's own response body.
@@ -128,21 +130,35 @@ defmodule VavPipeline.BigdeiKodesh.Router do
end
# === COURTYARD — Authenticated ===
#
# The key is checked against the keys file (VavPipeline.BigdeiKodesh.Gate),
# not merely for its shape. Until 2026-10-06 any Bearer value passed.
defp through_the_gate(conn, tier, serve) do
case Gate.admit(conn, tier) do
{:ok, _granted} ->
serve.(conn)
post "/courtyard/offering" do
case get_req_header(conn, "authorization") do
["Bearer " <> _token] ->
process_courtyard(conn)
{:error, :tier_too_low} ->
send_error(conn, 403, "FORBIDDEN", %{
issue: "This key does not open the #{tier}",
correction: "Use a key issued for the #{tier} tier",
resubmit: false
})
_ ->
{:error, _} ->
send_error(conn, 401, "UNAUTHORIZED", %{
issue: "Missing or invalid Authorization header",
correction: "Include 'Authorization: Bearer <api-key>' header",
issue: "Missing or invalid API key",
correction: "Include 'Authorization: Bearer <api-key>' with a key issued by the kohen",
resubmit: true
})
end
end
post "/courtyard/offering" do
through_the_gate(conn, :courtyard, &process_courtyard/1)
end
# === COURTYARD — the bedikah (Construction §3.9) ===
#
# An external model's output, brought by hand to be examined against
@@ -152,33 +168,13 @@ defmodule VavPipeline.BigdeiKodesh.Router do
# engraved in L3. See VavPipeline.Bedikah.
post "/courtyard/bedikah" do
case get_req_header(conn, "authorization") do
["Bearer " <> _token] ->
process_bedikah(conn)
_ ->
send_error(conn, 401, "UNAUTHORIZED", %{
issue: "Missing or invalid Authorization header",
correction: "Include 'Authorization: Bearer <api-key>' header",
resubmit: true
})
end
through_the_gate(conn, :courtyard, &process_bedikah/1)
end
# === CHATZER — Full Access (Sanhedrin) ===
post "/chatzer/sanhedrin" do
case get_req_header(conn, "authorization") do
["Bearer " <> _token] ->
process_sanhedrin(conn)
_ ->
send_error(conn, 401, "UNAUTHORIZED", %{
issue: "Missing or invalid Authorization header",
correction: "Include 'Authorization: Bearer <api-key>' header",
resubmit: true
})
end
through_the_gate(conn, :chatzer, &process_sanhedrin/1)
end
# === KODESH — Operator Interface ===
@@ -954,6 +950,7 @@ defmodule VavPipeline.BigdeiKodesh.Router do
defp error_type("CLOUD" <> _), do: "system_transition"
defp error_type("HALT" <> _), do: "cardinal_sin_detected"
defp error_type("UNAUTHORIZED"), do: "authentication_required"
defp error_type("FORBIDDEN"), do: "insufficient_tier"
defp error_type("TEIKU" <> _), do: "unresolvable"
defp error_type("LO_TOV"), do: "specification_mismatch"
defp error_type("CHATAT" <> _), do: "correction_needed"
+1 -1
View File
@@ -175,7 +175,7 @@ defmodule VavPipeline.Doorpost do
# changes; the update is a deliberate operator commit. A bare
# hash-update commit (without a corresponding spec change in the
# same commit) is itself a drift signal.
@expected_hash "91d09d8dfb47f4440e5e02f8ef2695026fade315cfaadf7c72c126bad3903547"
@expected_hash "42b38626c6e2bd3e6fe10ed7f4cb34ee8dae118205379b7d822f278fe7eaa110"
@doc "Return the list of files that constitute the category-grade specification."
@spec files() :: [String.t()]