The examination is the product; the record of it is what a caller can carry away. Every bedikah was already engraved in L3 — output hash and length, source digests, coupling trace, claims, violations, verdict, never the text — and no caller could get theirs back. - bedikot.key_id (migration): a SHA-256 digest of the courtyard key that brought the offering, never the key; threaded as :key_id through Bedikah.examine from every site that goes through the gate (courtyard bedikah, pair, the gate's pass and stream). Unique index on bedikah_ref. - VavPipeline.Ledger: entry (the examination's fields, none of the database's, not the owner's), canonical JSON (keys sorted at every level, no whitespace, UTF-8), Ed25519 sign/verify, public key, render. Seed from BIKORET_LEDGER_SIGNING_KEY (base64 of 32 bytes) in shorashim-secrets; absent → unsigned and says so. - GET /courtyard/ledger?limit&before — this key's bedikot, newest first, each rendered and signed; GET /courtyard/bedikah/<ref> — one, 404 for another key's or none alike; GET /peah/ledger_key — the public key. - Tamid §1.1 "The ledger" (Doorpost pin moved → 02ddf313…), KOHEN "Issuing a key" (the seed, once), secrets.yaml template. Tests: ledger_test (digest, entry, canonical, sign/verify, unsigned rendering, signed rendering); ledger_l3_test (own records listed newest first and only own; 404 alike for another's and none; an examination engraved under the key reads back signed and verifies against /peah/ledger_key). Elixir 642/0; L3 13/0. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
98 lines
3.5 KiB
Elixir
98 lines
3.5 KiB
Elixir
defmodule VavPipeline.LedgerTest do
|
|
@moduledoc "The provenance ledger: canonical form, signature, key digest (2026-10-21)."
|
|
use ExUnit.Case, async: true
|
|
|
|
alias VavPipeline.Ledger
|
|
|
|
@seed :crypto.strong_rand_bytes(32)
|
|
|
|
defp record do
|
|
%VavPipeline.L3.BedikahRecord{
|
|
id: 7,
|
|
bedikah_ref: "bedikah-0123456789abcdef",
|
|
provenance: "claude",
|
|
query: "q",
|
|
output_sha256: String.duplicate("ab", 32),
|
|
output_length: 12,
|
|
vital_content: "",
|
|
propositions: [],
|
|
references: ["Devarim 6:4"],
|
|
authority_claims: [],
|
|
actions: [],
|
|
sources: [%{"id" => "system", "sha256" => "00", "length" => 2}],
|
|
claims: [%{"index" => 1, "sentence" => "x", "anchors" => []}],
|
|
structure: %{"self_validation" => []},
|
|
frames: [],
|
|
rebukes: nil,
|
|
mesorah: [],
|
|
coupling_trace: [],
|
|
violations: [],
|
|
verdict: "tov",
|
|
examined_at: ~U[2026-10-21 12:00:00Z],
|
|
key_id: "deadbeef",
|
|
inserted_at: ~U[2026-10-21 12:00:01Z]
|
|
}
|
|
end
|
|
|
|
test "key_id is a digest of the key, never the key, and nil for none" do
|
|
assert Ledger.key_id(nil) == nil
|
|
id = Ledger.key_id("courtyard-secret-key")
|
|
assert String.length(id) == 32
|
|
refute id =~ "courtyard"
|
|
assert id == Ledger.key_id("courtyard-secret-key")
|
|
refute id == Ledger.key_id("another")
|
|
end
|
|
|
|
test "the entry carries the examination's fields and neither the database's nor the owner's" do
|
|
e = Ledger.entry(record())
|
|
assert Map.keys(e) |> Enum.sort() == Enum.sort(Ledger.ledger_fields())
|
|
refute Map.has_key?(e, :id)
|
|
refute Map.has_key?(e, :key_id)
|
|
refute Map.has_key?(e, :inserted_at)
|
|
assert e.examined_at == "2026-10-21T12:00:00Z"
|
|
end
|
|
|
|
test "canonical form sorts keys at every level and carries no whitespace" do
|
|
a = Ledger.canonical(%{"b" => 1, "a" => %{"z" => [%{"y" => 2, "x" => 1}], "m" => "s"}})
|
|
assert a == ~s({"a":{"m":"s","z":[{"x":1,"y":2}]},"b":1})
|
|
# the same map with keys given in another order canonicalizes the same
|
|
assert a == Ledger.canonical(%{"a" => %{"m" => "s", "z" => [%{"x" => 1, "y" => 2}]}, "b" => 1})
|
|
# atom and string keys meet in the same place
|
|
assert Ledger.canonical(%{b: 1, a: 2}) == ~s({"a":2,"b":1})
|
|
end
|
|
|
|
test "a signature verifies with the public key and fails on one changed byte" do
|
|
bytes = Ledger.canonical(Ledger.entry(record()))
|
|
sig = Ledger.sign(bytes, @seed)
|
|
pub = Ledger.public_key(@seed)
|
|
assert Ledger.verify(bytes, sig, pub)
|
|
refute Ledger.verify(bytes <> " ", sig, pub)
|
|
refute Ledger.verify(bytes, sig, Ledger.public_key(:crypto.strong_rand_bytes(32)))
|
|
refute Ledger.verify(bytes, "not base64!", pub)
|
|
end
|
|
|
|
test "without a seed nothing is signed, and the rendering says so" do
|
|
assert Ledger.sign("x", nil) == nil
|
|
assert Ledger.public_key(nil) == nil
|
|
r = Ledger.render(record())
|
|
assert r.entry.bedikah_ref == "bedikah-0123456789abcdef"
|
|
assert String.length(r.canonical_sha256) == 64
|
|
# the test env has no signing key configured
|
|
assert r.signed == false and r.signature == nil
|
|
end
|
|
|
|
test "a rendering with a seed configured is signed and self-consistent" do
|
|
Application.put_env(:vav_pipeline, :ledger_signing_key, Base.encode64(@seed))
|
|
|
|
try do
|
|
r = Ledger.render(record())
|
|
assert r.signed
|
|
bytes = Ledger.canonical(r.entry)
|
|
assert :crypto.hash(:sha256, bytes) |> Base.encode16(case: :lower) == r.canonical_sha256
|
|
assert Ledger.verify(bytes, r.signature, Ledger.public_key())
|
|
after
|
|
Application.delete_env(:vav_pipeline, :ledger_signing_key)
|
|
end
|
|
end
|
|
end
|