Trusty: provenance library workspace (initial import)

This commit is contained in:
2026-09-09 18:31:00 -04:00
commit 693f3bef1e
73 changed files with 26295 additions and 0 deletions

18
.gitignore vendored Normal file
View File

@@ -0,0 +1,18 @@
/target
**/target
/.claude
*.pdb
*.dll.exp
*.dll.lib
.DS_Store
Thumbs.db
*.swp
*.swo
*~
.tk-local/
# APERC session substrate (local-only)
/APERC/
# local credentials
/.secrets/

187
CLAUDE.md Normal file
View File

@@ -0,0 +1,187 @@
# Trusty -- Claude Code Context
## What This Is
Trusty is a pure-Rust provenance system built around the proprietary
chain-break-as-event primitive. It was originally ported from a Python reference
(archived at `~/Downloads/4-5/tkg_complete/tkg/trusty_lib/...`), but that
codebase is historical context only -- the active system is 100% Rust and
the locked v1 byte-shape is pinned by invariant tests in `trusty-core`.
Owner: MM Advisory LLC / Eric Gottlieb. Proprietary license.
Three crates:
- **`trusty-core`** -- pure library. Dual hash (SipHash-2-4 + SHA-256), chain primitives, payload builder, verify_chain, chain-break-as-event. No storage dependency.
- **`trusty-sqlite`** -- rusqlite adapter. Three-table schema (`provenance_envelope`, `hash_index`, `chain_tips`) with KV cross-check. `Trusty::{open, append, verify, anchor, history, integrity}`.
- **`trusty-sqlite-ext`** -- loadable SQLite extension (`cdylib`). `sqlite3_trusty_init()` exposes SQL functions (`trusty_append`, `trusty_verify`, `trusty_anchor`).
## Design Rules
- **v1 byte-shape is locked.** Any change to hash inputs, chain payload format, or anchor semantics must leave the invariant tests green (`s5_invariant_v1_byte_shape_preserved`, `v1_*` payload/chain tests, conformance crate). Do not "simplify" or "optimize" the canonical primitives.
- **Pure Rust.** No Python dependency at runtime. No shelling out. The historical Python port-source is archival only.
- **ASCII-only comments.** Cross-platform patch safety.
- **Functional style where it fits.** Immutable data as design principle. The chain is append-only by definition.
- **Dual hash is non-negotiable.** SipHash-2-4 (keyed fingerprint) + SHA-256 (cryptographic anchor). Do not collapse to single hash.
- **Chain-break-as-event** is the proprietary primitive. Treat as core IP. Do not document its semantics in README-style docs meant for public consumption.
- **Trusty is a library, not a platform.** The product is audit-trail-as-a-service (cheap, low-resource, high-margin commodity; door-opener for IoT/sensor/compliance buyers). Application-layer work (digital twin, KCG, NNsight swarm, agent-mesh) lives in sibling crates/repos that depend on `trusty-core` -- same pattern as `legal-chaingraph/` and `thread-keeper-tauri/`. Do not bundle application logic into the locked crates. When evaluating a feature, prefer those that improve the service surface (throughput, verifier ergonomics, retention, domain separation, bundle contracts) over those that expand trusty into application territory.
## Don't Touch (locked)
- `trusty-core` chain primitives (`append`, `verify_chain`, payload builder). Canonical port.
- `trusty-core` hash functions. Byte-identical to Python reference.
- v1 payload canonical bytes. `compute_record`, `PayloadFields::new_v1`, and the `build_payload` top-level ordering are all v1-stable. v2 is purely additive (`compute_record_signed`, `PAYLOAD_VERSION_V2`, populated `signatures` field via the signing-view pattern). Any v2+ work MUST preserve v1 byte-shape exactly -- see `s5_invariant_v1_byte_shape_preserved` tag and `memory/project_trusty_v2_signing_design.md`.
- Three-table SQLite schema in `trusty-sqlite` -- `provenance_envelope`, `hash_index`, `chain_tips`. Do not collapse, do not drop the KV cross-check.
- Historical Python port-source (`~/Downloads/4-5/tkg_complete/tkg/trusty_lib/...`) -- archival only; the Rust invariant tests are the active gate.
## APERC Session Discipline
Every Claude Code session in this repo follows the APERC loop. No exceptions.
### On session start
1. Read this `CLAUDE.md`.
2. Read `APERC/boot/S<N>_BOOT.md` for the current session's scope (currently `S1_BOOT.md` for the first real session after bootstrap).
3. Read `memory/MEMORY.md` index (always in context) and whichever referenced files are relevant to today's goal.
4. Run a baseline tag count: `tk_kb stats --aperc=APERC` (or count `APERC/tags/*.toml` directly).
5. **Cleanup doublecheck**: quick pass on whatever the prior session's close ritual should have tidied -- stale boot stubs, handoff naming drift, open_audits that should've been closed. If you find something, flag it as the first cycle (hygiene) before the real work.
6. State: session ID, baseline tags, goal, files you plan to touch.
7. Wait for Eric's go before any edit.
### During work
- **Max 5 file edits per cycle.** After 5: stop, show what changed, explain what you did, present plan for next cycle, WAIT for approval.
- Never read large files whole. Use Grep, Glob, offset/limit Reads, head/tail on single files.
- If you touch a file not in your plan, stop and re-plan.
- Commit green states before risky operations (test run, DB migration, hash-primitive edit).
### Before finishing
**Pre-close cleanup pass** (runs before the mechanical close ritual below). Eric's standing request: cleanup is part of close, not an afterthought. Sweep for:
- Stale references in `APERC/boot/S*_BOOT.md` (e.g., auto-stub paths from other projects).
- Handoff block naming drift in `APERC/state/LATEST_HANDOFF.toml`.
- Open_audits lines that got resolved this session -- move to `[closed_audits]` so future boots don't re-read stale noise.
- Memory entries (`MEMORY.md` + referenced files) that need updates for the work you just shipped.
- CLAUDE.md design-invariant notes for new locked surfaces (e.g., v2 additive-only rule).
- Any new session-scope `DELTA_LOG_S*.toml` you wrote as a KB write-ahead log.
Do cleanup at end of session, AND double-check at start of next session as a belt-and-braces pass -- sometimes a cleanup item only becomes obvious on re-read.
**Mechanical close**:
- List every file you changed.
- List every decision you made and why.
- Tag findings as `[LEARN]`, `[MISTAKE]`, `[INVARIANT]`, `[DECISION]`, `[INSIGHT]` -- filed to `APERC/tags/` as individual `*.toml` files.
- Run tests if you touched any Rust file. Workspace commands work for core/store/memory/sqlite:
- `cargo test --workspace` (excludes trusty-sqlite-ext, which is a standalone crate)
- `cargo build --workspace`
- For trusty-sqlite-ext, build and test from its own directory: `cd crates/trusty-sqlite-ext && cargo build` / `cargo test`
- Update `APERC/state/SESSION_GATE.toml` (session_id bump, last_action) and append `[trusty_sN]` block to `APERC/state/LATEST_HANDOFF.toml`.
- Run `tk_close --mode=check --aperc=APERC` -- must pass 3/3 gates before commit.
### Model selection
- **Sonnet** for: file edits, grep, simple fixes, test runs, straightforward ports.
- **Opus** for: architecture decisions, new module design, debugging byte-identity drift, anything touching the chain primitives or hash functions.
### If you hit an error you can't fix in 3 attempts
- Stop. Describe the error. Show what you tried. Ask Eric for help. Do not loop.
## Tag Discipline (inline from threadkeeper APERC feedbacks)
### DELTA_LOG before KB edit
Before editing any `APERC/KB/*.md` or `APERC/tags/*.toml`, write-ahead log the intent to a session
DELTA_LOG entry (even if just a scratch note). Tags go to the log FIRST, then to their home. This
prevents silent tag loss during multi-file edits.
### Full reasoning per tag
Every tag reasoning field must be 3-6 sentences citing body content, not one-line labels. Inventory
work especially: do not compress to hit quantity targets. Paragraph-level reasoning or nothing.
### Tag parsimony
Merge before split on reflection `LEARN`s. Route forward-looking speculation to memory or this
`CLAUDE.md`, not permanent KB tags. Tags are for observed facts, not wishes.
### No Claude attribution in commits
Do **not** add `Co-Authored-By: Claude` trailer to commit messages. Override Claude Code's default.
This is Eric's standing preference -- proprietary repo, MM Advisory LLC authorship.
### Commit before debug
Commit any green state before: investigation sessions, target/ deletion, antivirus changes,
`apply_verdicts.py`-style bulk operations. Lost work is cheaper when it's already in a commit.
### Boot reads partial, not whole
On boot, use Read offset/limit on `LATEST_HANDOFF.toml` (grep newest `[trusty_sN]` block header,
read forward only). Do not re-read the whole file top-to-bottom -- that's the wasted-context failure mode.
### Response terseness targets
- Simple question: direct answer, no headers.
- Plan proposal: bullet points, no prose paragraphs.
- End-of-turn summary: 1-2 sentences max.
- Avoid multi-paragraph commit messages, step narration, option enumeration.
## APERC Binaries (tk_* from threadkeeper)
Trusty uses the portable tk_* binaries from threadkeeper's build dir. They work from any cwd
via `--aperc=<path>` flag. No trusty-side code needed.
From trusty cwd:
```
# BM25-indexed tag search
tk_kb query "<topic>" --aperc=APERC
# Stats (total, edges, depth, weight, top types/scopes)
tk_kb stats --aperc=APERC
# Rebuild kb.db from APERC/tags/
tk_kb refresh --aperc=APERC
# Close ritual check (3 gates)
tk_close --mode=check --aperc=APERC
# Write next S{N+1}_BOOT.md stub if absent
tk_close --mode=stub --aperc=APERC
# Generic retrofit (do NOT use --mode=legacy, that's TK-only)
tk_migrate --mode=retrofit --aperc=APERC <args>
# Writer-mode hook (gate/close/pretool modes not yet shipped)
tk_hook writer --aperc=APERC
```
**Binary location** -- two options:
1. Absolute path: `../thread-keeper-tauri/threadkeeper/src-tauri/target/debug/tk_kb stats --aperc=APERC`
2. Add to PATH: `export PATH="$PATH:/c/Users/egott/Documents/00_Mitch/thread-keeper-tauri/threadkeeper/src-tauri/target/debug"` then invoke bare `tk_kb`.
**Scope-3 destination:** when `tk init` ships (planned dedicated session, see threadkeeper
`memory/project_tk_substrate_vision.md`), trusty will get its own installed `tk` binary and the
binary-path documentation above becomes obsolete.
## Memory System
Memory lives at `C:\Users\egott\.claude\projects\C--Users-egott-Documents-00-Mitch-trusty\memory\`.
`MEMORY.md` is always in context (capped to ~200 lines). Individual memory files are loaded on demand.
**What belongs in memory (not here):**
- User profile, role, preferences (user_eric.md)
- Per-project state (project_trusty.md)
- Session-to-session feedback that outgrows this file
- External-system pointers (reference_*.md)
**What belongs here (not memory):**
- Stable project rules (design invariants, locked files, build commands)
- The APERC discipline itself (copied above for every new session to read at boot)
- Binary invocation recipes
## Sibling Projects
- `thread-keeper-tauri/threadkeeper/` -- the desktop app this substrate was extracted from. Source of tk_* binaries until scope-3 ships.
- `legal-chaingraph/` -- KCG v4 + CH/IR legal graph work. First proof-of-portability (bootstrapped 2026-04-14, commit 46011ff).
## Commit Style
- Short subject line (< 70 chars), imperative.
- Body: why > what. 1-3 bullets if needed.
- **No Claude attribution trailer.**
- Reference session ID in subject when applicable: `TRUSTY-S1: <what>`.
## Cross-session pins (kb-mesh option 1)
In-flight memory shared across parallel Claude Code sessions lives at `../.kb-mesh/pins/`. Pull-model — nothing is auto-injected. Pins are ephemeral (not committed), complementary to APERC tags (durable, committed).
```bash
python ../.kb-mesh/scripts/kb_pin.py <topic> # pin last turn
python ../.kb-mesh/scripts/kb_pin.py <topic> --last 3 # pin last 3 turns
python ../.kb-mesh/scripts/kb_recall.py <topic> # pull a pin into current context
python ../.kb-mesh/scripts/kb_ls.py # list pins
```
See `../.kb-mesh/README.md` for spec.

1745
Cargo.lock generated Normal file

File diff suppressed because it is too large Load Diff

62
Cargo.toml Normal file
View File

@@ -0,0 +1,62 @@
[workspace]
resolver = "2"
members = [
# core primitives + interface
"crates/core/trusty-core",
"crates/core/trusty-store",
"crates/core/trusty-policy",
# storage backends
"crates/adapters/trusty-memory",
"crates/adapters/trusty-sqlite",
"crates/adapters/trusty-postgres",
"crates/adapters/trusty-tokio",
# policy <-> chain wiring
"crates/adapters/trusty-policy-audit",
"crates/adapters/trusty-policy-postgres",
"crates/adapters/trusty-policy-conditions",
]
[workspace.package]
version = "0.1.0"
edition = "2021"
authors = ["MM Advisory LLC / Eric Gottlieb"]
license = "LicenseRef-Proprietary"
repository = "https://github.com/mmadvisory/trusty"
rust-version = "1.75"
# Published only to the PRIVATE Modo-Melius registry on this machine.
# The crates-io default is still refused: see the registry allowlist below.
publish = ["modo-melius"]
[workspace.dependencies]
trusty-core = { path = "crates/core/trusty-core", version = "0.1.0" }
trusty-store = { path = "crates/core/trusty-store", version = "0.1.0" }
trusty-policy = { path = "crates/core/trusty-policy", version = "0.1.0" }
trusty-memory = { path = "crates/adapters/trusty-memory", version = "0.1.0" }
trusty-sqlite = { path = "crates/adapters/trusty-sqlite", version = "0.1.0" }
trusty-postgres = { path = "crates/adapters/trusty-postgres", version = "0.1.0" }
trusty-tokio = { path = "crates/adapters/trusty-tokio", version = "0.1.0" }
trusty-policy-audit = { path = "crates/adapters/trusty-policy-audit", version = "0.1.0" }
trusty-policy-postgres = { path = "crates/adapters/trusty-policy-postgres", version = "0.1.0" }
trusty-policy-conditions = { path = "crates/adapters/trusty-policy-conditions", version = "0.1.0" }
serde = { version = "=1.0.228", features = ["derive"] }
serde_json = { version = "=1.0.149", features = ["preserve_order"] }
sha2 = "0.10"
siphasher = "1"
zeroize = { version = "1", features = ["zeroize_derive"] }
hex = "0.4"
rusqlite = { version = "0.31", features = ["bundled"] }
chrono = { version = "0.4", default-features = false, features = ["clock", "serde"] }
thiserror = "1"
ed25519-dalek = { version = "2", features = ["std", "rand_core"] }
rand = "0.8"
postgres = "0.19"
tokio = { version = "1", default-features = false }
tracing = "0.1"
toml = "0.8"
[profile.release]
lto = "thin"
codegen-units = 1
opt-level = 3
strip = "symbols"

74
README.md Normal file
View File

@@ -0,0 +1,74 @@
# Trusty
Tamper-evident data provenance chain library. Pure Rust. Proprietary.
MM Advisory LLC / Eric Gottlieb.
## Crates
- **`trusty-core`** — pure library. Dual hash (SipHash-2-4 + SHA-256),
canonical payload (v1 + v2), Ed25519 signing trait, `compute_record` /
`compute_record_signed` / `verify_links`, chain-break-as-event primitive,
pluggable `Condition` trait. No storage dependency.
- **`trusty-store`** — backend-agnostic `Store` trait. Data types,
`KvCrossCheckReport`, transactional scope. Implemented by
`trusty-memory` and `trusty-sqlite`.
- **`trusty-memory`** — in-memory `Store` impl. Reference implementation
for tests / fuzzing / conformance. Not intended for persistence.
- **`trusty-sqlite`** — rusqlite adapter. Three-table schema
(`provenance_envelope`, `hash_index`, `chain_tips`) with KV cross-check.
`Trusty::{open, append, append_signed, append_with_method,
append_with_method_signed, verify, anchor, history, integrity}`.
- **`trusty-sqlite-ext`** — loadable SQLite extension (`cdylib`).
`sqlite3_trusty_init()` exposes SQL functions (`trusty_append`,
`trusty_verify`, `trusty_anchor`). Load from any sqlite3 binding in any
language.
## Docs
- [`docs/payload-v2.md`](docs/payload-v2.md) — canonical payload byte
format, v1 + v2, signing-view algorithm, verifier logic, locked
invariants.
- [`docs/threat-model.md`](docs/threat-model.md) — detection surfaces,
Ed25519 trust boundary, key-compromise scenarios, out-of-scope threats.
- [`docs/device-signer-provisioning.md`](docs/device-signer-provisioning.md)
— enroll / rotate / revoke pattern for devices signing into a chain,
with a runnable demo at `crates/trusty-sqlite/examples/device_signer.rs`.
## Layout
```
trusty/
Cargo.toml workspace
crates/
core/
trusty-core/ locked primitives (hash, chain, payload, bundle)
trusty-store/ backend-agnostic Store trait
adapters/
trusty-memory/ in-memory backend (tests / ref impl)
trusty-sqlite/ rusqlite production backend
extensions/
trusty-sqlite-ext/ SQLite loadable extension (cdylib, standalone)
docs/ format + threat-model specs
```
## Build
Workspace-wide `cargo` commands are broken by a rusqlite feature conflict
(`loadable_extension` in `trusty-sqlite-ext` vs `bundled` in
`trusty-sqlite`). Use per-package form:
```
cargo build -p trusty-core -p trusty-store -p trusty-memory -p trusty-sqlite
cargo build -p trusty-sqlite-ext
cargo test -p trusty-core -p trusty-store -p trusty-memory -p trusty-sqlite
cargo test -p trusty-sqlite-ext
```
The extension ships as `trusty_sqlite_ext.{dll,so,dylib}`. Load from
sqlite3:
```
sqlite> .load ./trusty_sqlite_ext
sqlite> SELECT trusty_append('company_acme', '{"status":"active"}', 'https://source');
```

View File

@@ -0,0 +1,15 @@
[package]
name = "trusty-memory"
version.workspace = true
edition.workspace = true
authors.workspace = true
license.workspace = true
repository.workspace = true
rust-version.workspace = true
description = "In-memory Store backend for Trusty. Reference implementation and test harness."
[dependencies]
trusty-store = { workspace = true }
[dev-dependencies]
trusty-store = { workspace = true, features = ["testing"] }

View File

@@ -0,0 +1,135 @@
//! In-memory [`Store`](trusty_store::Store) backend for Trusty.
//!
//! Reference implementation of the `Store` trait. Intended for tests, fuzzing,
//! and conformance checks. Not suitable for persistent production use -- all data
//! is lost when the process exits.
#![deny(clippy::all)]
use std::collections::{BTreeMap, HashMap};
use trusty_store::{
KvCrossCheckReport, LastRow, RowInsert, Store, StoreResult, StoredRow, TipRow, TipUpsert,
};
#[derive(Clone, Default)]
pub struct MemoryStore {
rows: BTreeMap<i64, StoredRow>,
next_id: i64,
tips_by_chain: HashMap<String, TipRow>,
tips_by_entity: HashMap<String, TipRow>,
}
impl MemoryStore {
pub fn new() -> Self {
Self::default()
}
}
impl Store for MemoryStore {
fn last_row(&self, entity_id: &str) -> StoreResult<Option<LastRow>> {
let last = self
.rows
.values()
.rev()
.find(|r| r.entity_id == entity_id && r.event_type != "BREAK" && r.row_hash.is_some())
.map(|r| LastRow {
row_hash: r.row_hash.clone().unwrap(),
sequence: r.sequence,
});
Ok(last)
}
fn insert_row(&mut self, row: &RowInsert) -> StoreResult<i64> {
self.next_id += 1;
let id = self.next_id;
let stored = StoredRow {
id,
entity_id: row.entity_id.clone(),
chain_id: row.chain_id.clone(),
event_type: row.event_type.clone(),
sequence: row.sequence,
prev_hash: row.prev_hash.clone(),
row_hash: row.row_hash.clone(),
anchor_hash: row.anchor_hash.clone(),
raw_payload: row.raw_payload.clone(),
detected_at: row.detected_at.clone(),
received_at: row.received_at.clone(),
source_url: row.source_url.clone(),
extraction_method: row.extraction_method.clone(),
};
self.rows.insert(id, stored);
Ok(id)
}
fn iter_chain<'a>(
&'a self,
entity_id: &str,
) -> StoreResult<Box<dyn Iterator<Item = StoreResult<StoredRow>> + 'a>> {
let entity = entity_id.to_string();
let iter = self
.rows
.values()
.filter(move |r| r.entity_id == entity)
.cloned()
.map(Ok);
Ok(Box::new(iter))
}
fn get_tip(&self, chain_id: &str) -> StoreResult<Option<TipRow>> {
Ok(self.tips_by_chain.get(chain_id).cloned())
}
fn get_tip_for_entity(&self, entity_id: &str) -> StoreResult<Option<TipRow>> {
Ok(self.tips_by_entity.get(entity_id).cloned())
}
fn set_tip(&mut self, tip: &TipUpsert) -> StoreResult<()> {
let row = TipRow {
entity_id: tip.entity_id.clone(),
chain_id: tip.chain_id.clone(),
current_tip_hash: tip.current_tip_hash.clone(),
updated_at: String::new(),
};
self.tips_by_chain.insert(tip.chain_id.clone(), row.clone());
self.tips_by_entity.insert(tip.entity_id.clone(), row);
Ok(())
}
fn kv_cross_check(&self) -> StoreResult<KvCrossCheckReport> {
let kv1 = self.rows.values().filter(|r| r.row_hash.is_some()).count();
let kv2 = self.tips_by_entity.len();
let mut mismatches: Vec<String> = Vec::new();
for (entity, tip) in &self.tips_by_entity {
if let Some(hash) = &tip.current_tip_hash {
let found = self.rows.values().any(|r| {
r.row_hash.as_deref() == Some(hash.as_str()) && r.chain_id == tip.chain_id
});
if !found {
mismatches.push(format!("tip_hash_not_in_rows: {}", entity));
}
}
}
Ok(KvCrossCheckReport {
ok: mismatches.is_empty(),
kv1_total_hashes: kv1,
kv2_total_entities: kv2,
entities_checked: kv2,
mismatches,
})
}
fn with_txn(
&mut self,
f: &mut dyn FnMut(&mut dyn Store) -> StoreResult<()>,
) -> StoreResult<()> {
let snap = self.clone();
match f(self) {
Ok(()) => Ok(()),
Err(e) => {
*self = snap;
Err(e)
}
}
}
}

View File

@@ -0,0 +1,8 @@
use trusty_memory::MemoryStore;
use trusty_store::testing::run_conformance;
#[test]
fn memory_store_passes_conformance() {
let mut store = MemoryStore::new();
run_conformance(&mut store);
}

View File

@@ -0,0 +1,19 @@
[package]
name = "trusty-policy-audit"
version.workspace = true
edition.workspace = true
authors.workspace = true
license.workspace = true
repository.workspace = true
rust-version.workspace = true
description = "Trusty-backed PolicyAuditor adapter. Routes every PolicyEvent (Allow + Deny) into a trusty append-only chain so the policy decision trail is tamper-evident, replayable, and externally verifiable."
[dependencies]
trusty-policy = { workspace = true }
trusty-sqlite = { workspace = true }
serde = { workspace = true }
serde_json = { workspace = true }
[dev-dependencies]
trusty-core = { workspace = true }
tempfile = "3"

View File

@@ -0,0 +1,262 @@
//! Trusty-backed `PolicyAuditor`.
//!
//! Closes the policy <-> chain loop: every `PolicyEvent` emitted by
//! `trusty_policy::PolicyEngine::evaluate` (Allow OR Deny) is appended
//! to a `Trusty` chain. The `entity_id` of the chain row is the policy
//! scope (e.g. `agent.executor.v1`), so each registered FSM gets its
//! own per-entity chain. Tamper-evidence, ordering, and external
//! verifiability are inherited from `trusty-sqlite`.
//!
//! # Why a separate crate
//!
//! `trusty-policy` deliberately has no dependency on `trusty-core` /
//! `trusty-sqlite` (library-not-platform rule). Wiring the two
//! together lives here so consumers who want only the policy gate
//! without an audit chain pay nothing.
//!
//! # Quickstart
//!
//! ```no_run
//! use trusty_policy::{Fsm, PolicyEngine, ScopeKey, Transition};
//! use trusty_sqlite::Trusty;
//! use trusty_core::SecretKey;
//! use trusty_policy_audit::TrustyAuditor;
//!
//! let trusty = Trusty::open_in_memory(SecretKey::new([7u8; 16])).unwrap();
//! let auditor = TrustyAuditor::new(trusty);
//!
//! let scope = ScopeKey::new("agent.executor.v1");
//! let mut fsm: Fsm<&'static str, &'static str> = Fsm::new("idle");
//! fsm.allow(Transition::new("idle", "claim", "claimed"));
//!
//! let mut engine = PolicyEngine::new(auditor);
//! engine.register(scope.clone(), fsm).unwrap();
//! let _ = engine.evaluate(&scope, &"idle", &"claim", Some("agent_001"));
//! // The chain row for entity_id="agent.executor.v1" now contains
//! // the PolicyEvent JSON.
//! ```
#![deny(clippy::all)]
use std::sync::{Arc, Mutex};
use trusty_policy::{PolicyAuditor, PolicyEvent};
use trusty_sqlite::Trusty;
/// Source-URL marker stamped on every chained policy row. Consumers
/// can filter by this to separate policy events from other domain
/// events that may share a chain.
pub const POLICY_SOURCE_URL: &str = "policy://trusty-policy/event";
/// Extraction-method tag used when calling `Trusty::append_with_method`.
/// Mirrors the existing trusty convention; consumers reading the chain
/// can recognize policy rows by `extraction_method = "policy"`.
pub const POLICY_EXTRACTION_METHOD: &str = "policy";
/// `PolicyAuditor` that appends each `PolicyEvent` to a `Trusty` chain.
/// The `Trusty` handle is held behind `Arc<Mutex<_>>` so the auditor
/// is `Send + 'static` (the trait bound `PolicyEngine::new` requires)
/// while still allowing the caller to share the same chain across
/// other writers if needed.
///
/// On audit failure (rare: disk full, schema corruption) the auditor
/// stores the most recent error in `last_error` and silently drops
/// the row. Callers who need hard-stop semantics on audit failure
/// should poll `last_error()` or wrap with their own checking layer.
/// We deliberately do NOT panic: a misbehaving audit sink must not
/// take down the policy engine during a deny path.
pub struct TrustyAuditor {
trusty: Arc<Mutex<Trusty>>,
last_error: Arc<Mutex<Option<String>>>,
}
impl TrustyAuditor {
/// Wrap a `Trusty` handle. The auditor takes ownership; if you
/// need shared access, build the auditor with [`Self::from_shared`].
pub fn new(trusty: Trusty) -> Self {
Self {
trusty: Arc::new(Mutex::new(trusty)),
last_error: Arc::new(Mutex::new(None)),
}
}
/// Wrap an already-shared `Trusty` so the same chain can be
/// appended-to by other writers.
pub fn from_shared(trusty: Arc<Mutex<Trusty>>) -> Self {
Self {
trusty,
last_error: Arc::new(Mutex::new(None)),
}
}
/// Most recent audit error, if any. Cleared by [`Self::clear_error`].
pub fn last_error(&self) -> Option<String> {
self.last_error.lock().ok().and_then(|g| g.clone())
}
pub fn clear_error(&self) {
if let Ok(mut g) = self.last_error.lock() {
*g = None;
}
}
/// Borrow the inner shared handle. Useful for the consumer to
/// run integrity / verification queries after the engine has
/// emitted events.
pub fn shared(&self) -> Arc<Mutex<Trusty>> {
Arc::clone(&self.trusty)
}
}
impl PolicyAuditor for TrustyAuditor {
fn audit(&mut self, event: &PolicyEvent) {
// Serialize the event as JSON. The chain row's `data` column
// is the canonical record; downstream verifiers re-parse it.
let payload = match serde_json::to_value(event) {
Ok(v) => v,
Err(e) => {
self.record_error(format!("serialize PolicyEvent: {e}"));
return;
}
};
// entity_id = scope. One chain per FSM scope.
let entity_id = event.scope.as_str();
let mut guard = match self.trusty.lock() {
Ok(g) => g,
Err(p) => {
self.record_error(format!("trusty mutex poisoned: {p}"));
return;
}
};
if let Err(e) = guard.append_with_method(
entity_id,
&payload,
POLICY_SOURCE_URL,
POLICY_EXTRACTION_METHOD,
) {
self.record_error(format!("append_with_method: {e}"));
}
}
}
impl TrustyAuditor {
fn record_error(&self, msg: String) {
if let Ok(mut g) = self.last_error.lock() {
*g = Some(msg);
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use trusty_core::SecretKey;
use trusty_policy::{Fsm, PolicyEngine, ScopeKey, Transition};
fn key() -> SecretKey {
SecretKey::new([7u8; 16])
}
fn pipeline_fsm() -> Fsm<&'static str, &'static str> {
let mut f = Fsm::new("idle");
f.allow(Transition::new("idle", "claim", "claimed"))
.allow(Transition::new("claimed", "execute", "running"))
.allow(Transition::new("running", "finish", "done"));
f
}
#[test]
fn allow_emits_chain_row() {
let trusty = Trusty::open_in_memory(key()).unwrap();
let auditor = TrustyAuditor::new(trusty);
let shared = auditor.shared();
let scope = ScopeKey::new("agent.executor.v1");
let mut engine: PolicyEngine<&'static str, &'static str> = PolicyEngine::new(auditor);
engine.register(scope.clone(), pipeline_fsm()).unwrap();
let r = engine.evaluate(&scope, &"idle", &"claim", Some("agent_001"));
assert!(r.is_allow());
// Read back the chain via the shared handle. raw_payload is
// the full envelope; the PolicyEvent JSON lives at .data.
let guard = shared.lock().unwrap();
let history = guard.history("agent.executor.v1", 100).unwrap();
assert_eq!(history.len(), 1, "exactly one chained policy row");
let raw = history[0].raw_payload.as_ref().expect("payload present");
let envelope: serde_json::Value = serde_json::from_str(raw).unwrap();
let payload = &envelope["data"];
assert_eq!(payload["scope"], "agent.executor.v1");
assert_eq!(payload["from"], "\"idle\"");
assert_eq!(payload["action"], "\"claim\"");
assert_eq!(payload["actor"], "agent_001");
assert!(payload["result"].get("Allow").is_some());
}
#[test]
fn deny_also_emits_chain_row() {
let trusty = Trusty::open_in_memory(key()).unwrap();
let auditor = TrustyAuditor::new(trusty);
let shared = auditor.shared();
let scope = ScopeKey::new("agent.executor.v1");
let mut engine: PolicyEngine<&'static str, &'static str> = PolicyEngine::new(auditor);
engine.register(scope.clone(), pipeline_fsm()).unwrap();
// illegal: idle -> finish is undeclared
let r = engine.evaluate(&scope, &"idle", &"finish", None);
assert!(!r.is_allow());
let guard = shared.lock().unwrap();
let history = guard.history("agent.executor.v1", 100).unwrap();
assert_eq!(history.len(), 1, "deny is also chained");
let envelope: serde_json::Value =
serde_json::from_str(history[0].raw_payload.as_ref().unwrap()).unwrap();
assert!(envelope["data"]["result"].get("Deny").is_some());
}
#[test]
fn separate_scopes_get_separate_chains() {
let trusty = Trusty::open_in_memory(key()).unwrap();
let auditor = TrustyAuditor::new(trusty);
let shared = auditor.shared();
let s_exec = ScopeKey::new("agent.executor.v1");
let s_audit = ScopeKey::new("agent.auditor.v1");
let mut engine: PolicyEngine<&'static str, &'static str> = PolicyEngine::new(auditor);
engine.register(s_exec.clone(), pipeline_fsm()).unwrap();
engine.register(s_audit.clone(), pipeline_fsm()).unwrap();
let _ = engine.evaluate(&s_exec, &"idle", &"claim", None);
let _ = engine.evaluate(&s_audit, &"idle", &"claim", None);
let guard = shared.lock().unwrap();
assert_eq!(guard.history("agent.executor.v1", 100).unwrap().len(), 1);
assert_eq!(guard.history("agent.auditor.v1", 100).unwrap().len(), 1);
}
#[test]
fn chain_is_tamper_evident() {
let trusty = Trusty::open_in_memory(key()).unwrap();
let auditor = TrustyAuditor::new(trusty);
let shared = auditor.shared();
let scope = ScopeKey::new("agent.executor.v1");
let mut engine: PolicyEngine<&'static str, &'static str> = PolicyEngine::new(auditor);
engine.register(scope.clone(), pipeline_fsm()).unwrap();
let _ = engine.evaluate(&scope, &"idle", &"claim", None);
let _ = engine.evaluate(&scope, &"claimed", &"finish", None); // deny
let _ = engine.evaluate(&scope, &"claimed", &"execute", None);
let _ = engine.evaluate(&scope, &"running", &"finish", None);
let guard = shared.lock().unwrap();
let report = guard.integrity().unwrap();
assert!(report.ok, "chain integrity must hold: {:?}", report);
assert_eq!(guard.history("agent.executor.v1", 100).unwrap().len(), 4);
}
}

View File

@@ -0,0 +1,18 @@
[package]
name = "trusty-policy-conditions"
version.workspace = true
edition.workspace = true
authors.workspace = true
license.workspace = true
repository.workspace = true
rust-version.workspace = true
description = "Lets a declared policy FSM act as a trusty break Condition, so the rules for how data may legally change are authored as data and a deviation records which rule broke."
[dependencies]
trusty-core = { workspace = true }
trusty-policy = { workspace = true }
serde_json = { workspace = true }
sha2 = { workspace = true }
[dev-dependencies]
trusty-sqlite = { workspace = true }

View File

@@ -0,0 +1,528 @@
//! Rules for how data may legally change, authored as data.
//!
//! # The idea
//!
//! Declare the transitions a datum is allowed to make. A compliant
//! mutation is valid and the chain continues. A deviation records a
//! **break naming the rule it violated**, and that break is signed,
//! travels in an exported bundle, and can be acted on by anyone.
//!
//! Almost all of this already existed. [`Condition`] evaluates state at
//! append time, [`BreakReason::PropertyViolated`] already carries a
//! rule's name, and `export_bundle` always includes BREAK rows.
//! Chain-break-as-event *was* this design. The one thing missing was
//! that the rules were compiled in: [`Condition`] is a Rust trait, so a
//! new rule meant a new build.
//!
//! The policy side had already solved that -- [`FsmSpec`] authored in
//! TOML, guards named as strings, and [`GuardRegistry`] refusing to
//! build on an unknown name. This crate is the join.
//!
//! # Guards see the payload
//!
//! The FSM is built over [`ConditionContext`], so a guard receives the
//! **data being appended**, not just chain metadata. A rule can
//! therefore be a real predicate over the value -- a threshold, a shape
//! check, a comparison against the previous row -- rather than only a
//! statement about the shape of the graph.
//!
//! # The envelope
//!
//! A row states the transition it is making, under `data.transition`:
//!
//! ```json
//! { "transition": { "from": "draft", "action": "submit", "to": "review" } }
//! ```
//!
//! The condition checks that transition is declared, that any guard on
//! it passes, and that the stated destination is the one the FSM says it
//! should be. That last check matters: without it a writer could name a
//! legal transition and then record an arbitrary destination.
//!
//! # Rows that claim no transition
//!
//! By default they pass -- this condition judges rows that claim to be
//! transitions, and a chain usually carries other traffic too.
//! [`FsmCondition::strict`] reverses that, so an unlabelled row is
//! itself a violation.
//!
//! Which is right depends on whether the chain is dedicated to the
//! governed process. Getting it wrong in the lenient direction leaves a
//! hole a writer can walk through by omitting a field, so **strict is
//! the right default for a chain that governs one thing**, and the
//! lenient default exists only because most chains do not.
//!
//! # The marker crosses the boundary
//!
//! A break's reason travels in the exported bundle, so a party holding
//! only that bundle -- and no secret key, and no access to the machine
//! that recorded it -- can ask `ProofBundle::stance()` and get one of
//! three answers: clean, violated (with the rule named), or
//! unverifiable. That third answer is the point: a bundle that cannot be
//! checked must not read as clean.
//!
//! # One limitation, stated rather than discovered
//!
//! **A rule cannot be violated by the first row of a chain.**
//! Break-as-event seals an existing chain, and at genesis there is
//! nothing to seal, so the opening row is recorded whatever it claims.
//! If the opening state must also be governed, check it before the
//! first append -- the chain will not do it for you.
//!
//! # Why this crate and not another
//!
//! [`trusty_policy`] is deliberately substrate-free and does not depend
//! on [`trusty_core`]. Putting this in either would give one of them a
//! dependency it was designed not to have -- the same split, for the
//! same reason, as `trusty-policy-audit`.
#![deny(clippy::all)]
use serde_json::Value;
use trusty_core::conditions::{BreakReason, Condition, ConditionContext, ConditionOutcome};
use sha2::{Digest, Sha256};
use trusty_policy::{Fsm, FsmSpec, GuardRegistry, PolicyError};
/// Where the transition envelope lives inside `data`.
pub const TRANSITION_FIELD: &str = "transition";
/// A declared state machine, acting as a break condition.
pub struct FsmCondition {
fsm: Fsm<String, String, ConditionContext>,
rule: String,
/// The rule set's content hash, short form, when it was built from a
/// spec. Stamped into every break so a violation names the rule AND
/// the version of it that produced the verdict.
rule_hash: Option<String>,
strict: bool,
}
impl FsmCondition {
/// Build from an already-constructed FSM.
///
/// `rule` is the name that appears in the recorded break. Make it
/// something an operator reading a chain six months later can look
/// up -- the whole point of `PropertyViolated` carrying a name is
/// that the break says *which* invariant broke.
pub fn new(fsm: Fsm<String, String, ConditionContext>, rule: impl Into<String>) -> Self {
Self {
fsm,
rule: rule.into(),
rule_hash: None,
strict: false,
}
}
/// The content hash of a rule set, hex, first 16 characters.
///
/// Truncated because it travels inside every break reason and a full
/// digest would crowd out the part a human reads. 64 bits is ample
/// for telling two rule sets apart; it is not a security boundary,
/// since the full canonical form is what gets signed.
pub fn hash_of(spec: &FsmSpec) -> Result<String, PolicyError> {
let canonical = spec.canonical_json()?;
Ok(hex_short(&Sha256::digest(canonical.as_bytes())))
}
/// The rule set version this condition enforces, if it was built
/// from a spec.
pub fn rule_hash(&self) -> Option<&str> {
self.rule_hash.as_deref()
}
/// Build from a TOML spec, with guards resolved by name.
///
/// A transition naming a guard the registry does not hold **refuses
/// to build**. That refusal is the feature: a policy that silently
/// runs without its guard is a policy that is not being enforced,
/// and it would look identical to one that is.
pub fn from_json(
json: &str,
registry: &mut GuardRegistry<ConditionContext>,
rule: impl Into<String>,
) -> Result<Self, PolicyError> {
let spec = FsmSpec::from_json(json)?;
let rule_hash = Self::hash_of(&spec)?;
let (_scope, fsm) = spec.build_fsm_with_guards(registry)?;
let mut built = Self::new(fsm, rule);
built.rule_hash = Some(rule_hash);
Ok(built)
}
/// Treat a row that claims no transition as a violation.
///
/// Correct when the chain is dedicated to the governed process.
/// Without it, omitting the envelope is a way around the rule.
pub fn strict(mut self) -> Self {
self.strict = true;
self
}
fn violated(&self, detail: &str) -> ConditionOutcome {
// The rule set version rides along, so a reader of the break can
// tell WHICH `doc.review` produced it. Without it a rule could be
// loosened after the fact and the old marker would still verify
// against a rule that no longer says what it said.
let name = match &self.rule_hash {
Some(h) => format!("{}@{h}", self.rule),
None => self.rule.clone(),
};
ConditionOutcome::Fail(BreakReason::PropertyViolated(format!("{name}:{detail}")))
}
}
/// The declared transition a row claims to be making.
struct Declared {
from: String,
action: String,
to: String,
}
fn read_envelope(data: &Value) -> Option<Declared> {
let t = data.get(TRANSITION_FIELD)?;
Some(Declared {
from: t.get("from")?.as_str()?.to_string(),
action: t.get("action")?.as_str()?.to_string(),
to: t.get("to")?.as_str()?.to_string(),
})
}
impl Condition for FsmCondition {
fn name(&self) -> &'static str {
"FsmCondition"
}
fn evaluate(&self, ctx: &ConditionContext) -> ConditionOutcome {
let Some(declared) = read_envelope(&ctx.data) else {
return if self.strict {
// Distinguished from a malformed one on purpose: "you
// did not say" and "you said something impossible" are
// different operator problems.
self.violated("no-transition-declared")
} else {
ConditionOutcome::Pass
};
};
// Exclusions first, and they cannot be overridden -- the same
// ordering the permission model uses, for the same reason.
if let Some(why) = self.fsm.excluded_by(&declared.from, &declared.action) {
return self.violated(&format!("excluded:{why}"));
}
let Some(expected) = self.fsm.next_state(&declared.from, &declared.action) else {
return self.violated(&format!(
"undeclared-transition:{}--{}",
declared.from, declared.action
));
};
if let Some(guard) = self.fsm.guard(&declared.from, &declared.action) {
if let Err(code) = guard.check(ctx) {
return self.violated(&format!("guard-rejected:{code}"));
}
}
if *expected != declared.to {
// A writer naming a legal transition and then recording a
// destination of its own choosing is exactly what this
// stops.
return self.violated(&format!(
"wrong-destination:{}--{}->{} expected {expected}",
declared.from, declared.action, declared.to
));
}
ConditionOutcome::Pass
}
}
fn hex_short(digest: &[u8]) -> String {
digest.iter().take(8).map(|b| format!("{b:02x}")).collect()
}
/// The transition envelope, for a writer to embed in its payload.
pub fn transition(from: &str, action: &str, to: &str) -> Value {
serde_json::json!({ TRANSITION_FIELD: { "from": from, "action": action, "to": to } })
}
#[cfg(test)]
mod tests {
use super::*;
const SPEC: &str = r#"
{
"scope": "doc.review.v1",
"initial": "draft",
"transitions": [
{
"from": "draft",
"action": "submit",
"to": "review"
},
{
"from": "review",
"action": "approve",
"to": "published",
"guard": "big_enough"
}
]
}
"#;
pub(super) fn ctx(data: Value) -> ConditionContext {
ConditionContext {
entity_id: "e".into(),
chain_id: "c".into(),
sequence: 1,
stored_tip_hash: None,
computed_hash: None,
prev_hash: None,
last_sequence: Some(0),
source_url: String::new(),
extraction_method: "trusty".into(),
data,
}
}
fn registry() -> GuardRegistry<ConditionContext> {
let mut r = GuardRegistry::new();
r.insert("big_enough", |c: &ConditionContext| {
match c.data.get("size").and_then(Value::as_i64) {
Some(n) if n >= 10 => Ok(()),
Some(n) => Err(format!("too-small:{n}")),
None => Err("no-size".to_string()),
}
});
r
}
fn condition() -> FsmCondition {
FsmCondition::from_json(SPEC, &mut registry(), "doc.review").expect("build")
}
/// ConditionOutcome does not derive PartialEq, and adding a derive to
/// a core type to suit a test is the wrong direction of dependency.
fn passed(outcome: ConditionOutcome) -> bool {
matches!(outcome, ConditionOutcome::Pass)
}
fn reason(outcome: ConditionOutcome) -> String {
match outcome {
ConditionOutcome::Fail(BreakReason::PropertyViolated(r)) => r,
other => panic!("expected a property violation, got {other:?}"),
}
}
#[test]
fn a_declared_transition_passes() {
let outcome = condition().evaluate(&ctx(transition("draft", "submit", "review")));
assert!(
passed(outcome),
"a compliant mutation must leave the chain unbroken; that is what \
makes a break mean something"
);
}
#[test]
fn an_undeclared_transition_breaks_the_chain_and_names_the_rule() {
let outcome = condition().evaluate(&ctx(transition("draft", "publish", "published")));
let r = reason(outcome);
assert!(
r.starts_with("doc.review@") && r.contains(":undeclared-transition"),
"the break must name the rule that broke, or an operator reading \
the chain later learns only that something went wrong: {r}"
);
}
#[test]
fn naming_a_legal_transition_does_not_licence_an_arbitrary_destination() {
let outcome = condition().evaluate(&ctx(transition("draft", "submit", "published")));
let r = reason(outcome);
assert!(
r.contains("wrong-destination"),
"submit is legal from draft, but it goes to review; without this \
check a writer could name a legal transition and then record \
wherever it liked: {r}"
);
}
#[test]
fn a_guard_sees_the_payload_not_just_the_chain_metadata() {
let mut small = transition("review", "approve", "published");
small["size"] = Value::from(3);
let r = reason(condition().evaluate(&ctx(small)));
assert!(
r.contains("guard-rejected:too-small:3"),
"the guard must be able to judge the DATA being appended -- a rule \
that can only see chain metadata cannot express most invariants \
worth having: {r}"
);
let mut big = transition("review", "approve", "published");
big["size"] = Value::from(50);
assert!(passed(condition().evaluate(&ctx(big))));
}
#[test]
fn a_missing_guard_input_is_a_rejection_not_a_pass() {
let r = reason(condition().evaluate(&ctx(transition("review", "approve", "published"))));
assert!(
r.contains("guard-rejected:no-size"),
"absent evidence must not read as a satisfied guard, for the same \
reason absent policy is not permission: {r}"
);
}
#[test]
fn an_unregistered_guard_refuses_to_build() {
let mut empty = GuardRegistry::<ConditionContext>::new();
let built = FsmCondition::from_json(SPEC, &mut empty, "doc.review");
assert!(
built.is_err(),
"a policy that silently runs without its guard is a policy that is \
not being enforced, and it looks identical to one that is"
);
}
#[test]
fn an_unlabelled_row_passes_by_default_and_fails_under_strict() {
let plain = ctx(serde_json::json!({ "temperature": 21 }));
assert!(
passed(condition().evaluate(&plain)),
"most chains carry traffic this condition has no opinion about"
);
let r = reason(condition().strict().evaluate(&plain));
assert!(
r.contains("no-transition-declared"),
"on a chain dedicated to the governed process, omitting the \
envelope would otherwise be a way around the rule: {r}"
);
}
#[test]
fn a_malformed_envelope_is_reported_as_undeclared_not_as_absent() {
let half = ctx(serde_json::json!({ TRANSITION_FIELD: { "from": "draft" } }));
assert!(
passed(condition().evaluate(&half)),
"an envelope missing required fields is not a transition claim at \
all; under strict it fails, and the two cases are distinguished \
because 'you did not say' and 'you said something impossible' are \
different operator problems"
);
let r = reason(condition().strict().evaluate(&half));
assert!(r.contains("no-transition-declared"), "{r}");
}
#[test]
fn an_exclusion_cannot_be_overridden_by_a_declared_transition() {
let mut fsm: Fsm<String, String, ConditionContext> = Fsm::new("draft".to_string());
fsm.allow(trusty_policy::Transition::new(
"draft".to_string(),
"submit".to_string(),
"review".to_string(),
));
fsm.exclude("submit".to_string(), "frozen-for-audit");
let cond = FsmCondition::new(fsm, "doc.review");
let r = reason(cond.evaluate(&ctx(transition("draft", "submit", "review"))));
assert!(
r.contains("excluded:frozen-for-audit"),
"exclusions are checked before the transition is looked up, so a \
declared transition cannot reopen what an exclusion shut -- the \
same ordering the permission model uses: {r}"
);
}
}
#[cfg(test)]
mod rule_version_tests {
use super::*;
const NARROW: &str = r#"
{
"scope": "doc.review.v1",
"initial": "draft",
"transitions": [
{ "from": "draft", "action": "submit", "to": "review" }
]
}
"#;
const LOOSENED: &str = r#"
{
"scope": "doc.review.v1",
"initial": "draft",
"transitions": [
{ "from": "draft", "action": "submit", "to": "review" },
{ "from": "draft", "action": "publish", "to": "published" }
]
}
"#;
fn built(spec: &str) -> FsmCondition {
FsmCondition::from_json(spec, &mut GuardRegistry::new(), "doc.review")
.expect("build")
}
#[test]
fn loosening_the_rule_changes_the_version_the_break_cites() {
let narrow = built(NARROW);
let loosened = built(LOOSENED);
assert_ne!(
narrow.rule_hash(),
loosened.rule_hash(),
"a rule set that can be widened without changing its version is a \
rule set a violation marker cannot honestly cite -- loosen it \
after the fact and the old marker still appears to hold"
);
}
#[test]
fn the_same_rule_set_always_yields_the_same_version() {
assert_eq!(
built(NARROW).rule_hash(),
built(NARROW).rule_hash(),
"a version that varied between processes would make every marker \
uncomparable, which is worse than having no version at all"
);
}
#[test]
fn a_hand_built_fsm_has_no_version_rather_than_a_made_up_one() {
let mut fsm: Fsm<String, String, ConditionContext> = Fsm::new("draft".to_string());
fsm.allow(trusty_policy::Transition::new(
"draft".to_string(),
"submit".to_string(),
"review".to_string(),
));
let cond = FsmCondition::new(fsm, "doc.review");
assert_eq!(
cond.rule_hash(),
None,
"an FSM assembled in Rust has no canonical document to hash, and \
inventing a version for it would be a claim nobody can check"
);
}
#[test]
fn a_versionless_rule_still_names_itself_in_the_break() {
let mut fsm: Fsm<String, String, ConditionContext> = Fsm::new("draft".to_string());
fsm.allow(trusty_policy::Transition::new(
"draft".to_string(),
"submit".to_string(),
"review".to_string(),
));
let outcome = FsmCondition::new(fsm, "doc.review")
.evaluate(&super::tests::ctx(transition("draft", "publish", "gone")));
match outcome {
ConditionOutcome::Fail(BreakReason::PropertyViolated(r)) => assert!(
r.starts_with("doc.review:"),
"no version is not the same as no name; the rule must still \
identify itself: {r}"
),
other => panic!("expected a violation, got {other:?}"),
}
}
}

View File

@@ -0,0 +1,303 @@
//! Does a declared rule actually break a real chain?
//!
//! The unit tests check the condition's verdict. These check that the
//! verdict reaches the chain: that a compliant mutation leaves it
//! unbroken, a deviation records a break, and the break says which rule
//! broke rather than merely that something did.
use trusty_core::{Ed25519Signer, SecretKey, Stance};
use trusty_policy::GuardRegistry;
use trusty_policy_conditions::{transition, FsmCondition};
use trusty_sqlite::Trusty;
const SPEC: &str = r#"
{
"scope": "doc.review.v1",
"initial": "draft",
"transitions": [
{
"from": "draft",
"action": "submit",
"to": "review"
},
{
"from": "review",
"action": "approve",
"to": "published",
"guard": "big_enough"
}
]
}
"#;
fn store() -> Trusty {
let mut registry = GuardRegistry::new();
registry.insert("big_enough", |c: &trusty_core::ConditionContext| {
match c.data.get("size").and_then(serde_json::Value::as_i64) {
Some(n) if n >= 10 => Ok(()),
Some(n) => Err(format!("too-small:{n}")),
None => Err("no-size".to_string()),
}
});
let condition =
FsmCondition::from_json(SPEC, &mut registry, "doc.review").expect("build the rule");
// The integrity defaults PLUS the declared rule: a policy rule is an
// addition to tamper detection, never a replacement for it.
let mut conditions = trusty_core::conditions::default_conditions();
conditions.push(Box::new(condition));
Trusty::open_in_memory_with_conditions(SecretKey::new([9u8; 16]), conditions)
.expect("store")
}
/// Every recorded break reason, in order.
///
/// Read through `breaks_since`, not from the exported bundle: a BREAK
/// row's reason lives in the `old_value`/`new_value` columns, and
/// `BundleRow` carries neither. So the reason does **not** travel to a
/// remote party today -- see the sibling issue on making the marker
/// actionable across a boundary.
fn break_reasons(trusty: &Trusty, entity: &str) -> Vec<String> {
trusty
.breaks_since(entity, None)
.expect("breaks")
.into_iter()
.map(|b| b.break_reason)
.collect()
}
/// Establish the chain with a compliant row.
///
/// A rule cannot be violated by the FIRST row of a chain: break-as-event
/// seals an existing chain, and at genesis there is nothing to seal. So
/// every test that wants to observe a violation must first give the
/// chain something to break.
fn genesis(trusty: &mut Trusty, signer: &Ed25519Signer, entity: &str) {
trusty
.append_signed(
entity,
&transition("draft", "submit", "review"),
"test://",
signer,
)
.expect("genesis");
}
#[test]
fn a_compliant_mutation_leaves_the_chain_unbroken() {
let mut trusty = store();
let signer = Ed25519Signer::generate();
trusty
.append_signed(
"doc:1",
&transition("draft", "submit", "review"),
"test://",
&signer,
)
.expect("append");
assert!(
break_reasons(&trusty, "doc:1").is_empty(),
"a declared transition must pass; if compliant data broke the chain, \
a break would stop meaning anything"
);
assert!(
trusty
.export_bundle("doc:1", None, None)
.expect("export")
.verify_public()
.continuous,
"and the chain must read as continuous"
);
}
#[test]
fn an_undeclared_mutation_breaks_the_chain_and_the_break_names_the_rule() {
let mut trusty = store();
let signer = Ed25519Signer::generate();
trusty
.append_signed(
"doc:2",
&transition("draft", "submit", "review"),
"test://",
&signer,
)
.expect("first");
// draft --publish--> published is not declared anywhere.
trusty
.append_signed(
"doc:2",
&transition("draft", "publish", "published"),
"test://",
&signer,
)
.expect("second");
let reasons = break_reasons(&trusty, "doc:2");
assert_eq!(
reasons.len(),
1,
"the deviation must be recorded as an event, not rejected silently -- \
chain-break-as-event is what makes the violation evidence rather than \
an error somebody's log swallowed; got {reasons:?}"
);
assert!(
reasons[0].starts_with("PROPERTY_VIOLATED:doc.review@")
&& reasons[0].contains(":undeclared-transition"),
"the break must carry the rule's name, so an operator reading this \
chain in six months learns WHICH invariant broke; got {}",
reasons[0]
);
}
#[test]
fn a_guard_rejection_names_the_guard_and_its_reason() {
let mut trusty = store();
let signer = Ed25519Signer::generate();
genesis(&mut trusty, &signer, "doc:3");
let mut small = transition("review", "approve", "published");
small["size"] = serde_json::Value::from(2);
trusty
.append_signed("doc:3", &small, "test://", &signer)
.expect("append");
let reasons = break_reasons(&trusty, "doc:3");
assert!(
reasons
.iter()
.any(|r| r.contains("guard-rejected:too-small:2")),
"a rule over the DATA must be able to break the chain and say what it \
saw; a break that only said 'guard failed' would send someone back to \
the source to find out which one; got {reasons:?}"
);
}
#[test]
fn the_marker_crosses_the_boundary_carrying_the_rule_name() {
let mut trusty = store();
let signer = Ed25519Signer::generate();
genesis(&mut trusty, &signer, "doc:4");
trusty
.append_signed(
"doc:4",
&transition("draft", "publish", "published"),
"test://",
&signer,
)
.expect("append");
// Everything below is what a REMOTE party can do: it holds the
// bundle and no secret key, and never talks to the machine that
// recorded the break.
let bundle = trusty.export_bundle("doc:4", None, None).expect("export");
let violations = bundle.violations();
assert_eq!(
violations.len(),
1,
"the violation must be readable off the bundle alone; a marker only \
the writer can read is not something anyone else can act on"
);
assert_eq!(
violations[0]
.rule
.as_deref()
.map(|r| r.split('@').next().unwrap_or(r)),
Some("doc.review"),
"and it must name the RULE, not merely report that something broke. \
Refuse, quarantine or inspect are different decisions and the rule is \
what tells them apart; got {:?}",
violations[0]
);
match bundle.stance() {
Stance::Violated { violations } => {
assert_eq!(violations.len(), 1, "one break, one violation")
}
other => panic!("expected a violation stance, got {other:?}"),
}
assert!(
!bundle.stance().is_clean(),
"a datum carrying a signed violation must never be waved through"
);
}
#[test]
fn a_clean_bundle_reads_as_clean_and_a_tampered_one_does_not() {
let mut trusty = store();
let signer = Ed25519Signer::generate();
genesis(&mut trusty, &signer, "doc:6");
let clean = trusty.export_bundle("doc:6", None, None).expect("export");
assert_eq!(
clean.stance(),
Stance::Clean,
"a compliant chain must read as clean, or the marker means nothing"
);
// The same bundle, edited after export.
let mut forged = clean.clone();
for row in forged.rows.iter_mut() {
if let Some(payload) = row.raw_payload.as_mut() {
*payload = payload.replace("submit", "publish");
}
}
match forged.stance() {
Stance::Unverifiable { .. } => {}
other => panic!(
"a bundle that no longer matches its signatures must report \
Unverifiable -- counting zero violations and looking spotless is \
exactly the failure the third answer exists to prevent; got {other:?}"
),
}
assert!(
!forged.stance().is_clean(),
"and unverifiable is not a permit"
);
}
#[test]
fn a_break_with_no_rule_is_still_reported() {
// Tamper detection and sequence gaps are real breaks with no
// invariant to name. They must still surface, with rule: None,
// rather than being filtered out for lacking a name.
let v = trusty_core::Violation {
entity_id: "e".into(),
chain_id: "c".into(),
rule: None,
reason: "TAMPER_DETECTED".into(),
};
assert!(
v.rule.is_none() && !v.reason.is_empty(),
"a break without a rule name is still a break, and dropping it would \
hide the most serious kind"
);
}
#[test]
fn a_declared_transition_to_the_wrong_destination_still_breaks() {
let mut trusty = store();
let signer = Ed25519Signer::generate();
genesis(&mut trusty, &signer, "doc:5");
trusty
.append_signed(
"doc:5",
&transition("draft", "submit", "published"),
"test://",
&signer,
)
.expect("append");
let reasons = break_reasons(&trusty, "doc:5");
assert!(
reasons.iter().any(|r| r.contains("wrong-destination")),
"submit is legal from draft, but it goes to review. Without this the \
rule would check the move and ignore where it landed; got {reasons:?}"
);
}

View File

@@ -0,0 +1,20 @@
[package]
name = "trusty-policy-postgres"
version.workspace = true
edition.workspace = true
authors.workspace = true
license.workspace = true
repository.workspace = true
rust-version.workspace = true
description = "Compiles a trusty-policy Grant into a Postgres row-level-security decision. Grants are rows and the policy is static, so no SQL is ever built from a grant's contents."
[dependencies]
trusty-policy = { workspace = true }
postgres = { workspace = true }
serde_json = { workspace = true }
sha2 = { workspace = true }
hex = { workspace = true }
thiserror = { workspace = true }
[features]
default = []

View File

@@ -0,0 +1,78 @@
//! Print the permission model's schema as a plain `.sql` migration.
//!
//! A consumer whose migrations are files rather than a Rust `open()`
//! call — Supabase, sqlx, plain psql — needs the same DDL as text.
//! Emitting it from the same constants the library applies is the point:
//! two hand-maintained copies of an authorisation schema will diverge,
//! and the divergence will be discovered by something going wrong.
//!
//! ```text
//! cargo run -p trusty-policy-postgres --example emit_ddl > 0001_permission_model.sql
//! ```
//!
//! Pass a table and its scope column to additionally emit the policies
//! that put that table under enforcement:
//!
//! ```text
//! cargo run -p trusty-policy-postgres --example emit_ddl -- public.calendar resource_scope
//! ```
use trusty_policy_postgres::permits::DDL_FUNCTIONS;
use trusty_policy_postgres::schema::{DDL_SCHEMA_VERSION_UPSERT, DDL_V1, SCHEMA_VERSION};
use trusty_policy_postgres::store::policy_statements;
fn main() {
println!("-- Generated by `cargo run -p trusty-policy-postgres --example emit_ddl`.");
println!("-- Source of truth is trusty-policy-postgres; edit there, regenerate here.");
println!("-- Applying this by hand and by GrantStore::open must agree, so do not");
println!("-- edit this file directly.");
println!();
for stmt in DDL_V1 {
println!("{};", stmt.trim());
println!();
}
for stmt in DDL_FUNCTIONS {
println!("{};", stmt.trim());
println!();
}
// The library upserts this with a bind parameter; a file has no
// parameters, so the version is inlined. It is a constant, not
// input.
println!(
"{};",
DDL_SCHEMA_VERSION_UPSERT
.trim()
.replace("$1", &format!("'{SCHEMA_VERSION}'"))
);
println!();
let args: Vec<String> = std::env::args().skip(1).collect();
match args.as_slice() {
[] => {
println!("-- No table given, so no policies were emitted.");
println!("-- Installing this schema protects nothing on its own: a table is");
println!("-- guarded only once it has ENABLE + FORCE ROW LEVEL SECURITY and the");
println!("-- four policies. Re-run with a table and its scope column, e.g.");
println!("-- ... --example emit_ddl -- public.calendar resource_scope");
}
[table, column] => match policy_statements(table, column) {
Ok(stmts) => {
println!("-- Enforcement for {table}({column}).");
for stmt in stmts {
println!("{stmt}");
}
}
Err(e) => {
eprintln!("error: {e}");
std::process::exit(1);
}
},
_ => {
eprintln!("usage: emit_ddl [<schema.table> <scope_column>]");
std::process::exit(2);
}
}
}

View File

@@ -0,0 +1,282 @@
//! Compiles a [`trusty_policy::Grant`] into a decision Postgres enforces.
//!
//! # The problem this exists to solve
//!
//! `trusty-policy` decides in Rust. Postgres decides in SQL. When those
//! two answers disagree, the database wins and nobody is told. The
//! permission model closes that by making one definition serve both:
//! a grant *compiles* to a row-level-security policy. [`Tier`] was
//! defined as the database's four verbs for exactly this reason, so the
//! compilation needs no translation layer -- and a translation layer is
//! where the two answers drift apart.
//!
//! # Grants are rows; the policy is static
//!
//! One `CREATE POLICY` per (table, verb), written once:
//!
//! ```sql
//! CREATE POLICY calendar_read ON calendar FOR SELECT
//! USING (trusty_policy.permits(
//! current_setting('trusty.subject', true), 'read', resource_scope));
//! ```
//!
//! Issuing a grant is then a parameterized `INSERT`, and revoking one is
//! an `UPDATE`. **No SQL is ever built from a grant's contents**, so a
//! scope string can never become syntax. The alternative -- a
//! `CREATE POLICY` per grant -- would require exactly that, and would
//! introduce the first dynamic SQL into a workspace where every
//! statement today is a static literal with `$1` placeholders.
//!
//! # What compiles, and what does not
//!
//! `scope`, `tier`, `excludes`, `expires_at` and `acts_for` reach the
//! database. `limits` -- rate, irreversible budget, dead man -- has no
//! RLS analogue and stays in [`trusty_policy::PolicyEngine`]. That gap
//! is reported, never silent: see [`CompilationReport`]. A compiler that
//! quietly drops a rate limit is worse than one that refuses.
//!
//! # Why a separate crate
//!
//! `trusty-policy` deliberately has no substrate. `CREATE POLICY` and
//! `current_setting` are Postgres dialect, so putting them in the engine
//! would give a substrate-free crate a substrate. Same split, and the
//! same reason, as `trusty-policy-audit`.
#![deny(clippy::all)]
pub mod permits;
pub mod schema;
pub mod store;
use sha2::{Digest, Sha256};
use trusty_policy::{Grant, Tier};
pub use permits::{
DDL_ENSURE_PRINCIPAL, DDL_FUNCTIONS, DDL_REINSTATE_PRINCIPAL, DDL_PERMITS, MAX_DELEGATION_DEPTH,
SQL_RECONCILE_PRINCIPALS,
};
pub use schema::{DDL_V1, SCHEMA_NAME, SCHEMA_VERSION};
pub use store::{policy_statements, GrantStore, PolicySession, SUBJECT_SETTING};
/// A grant's identity: the SHA-256 of its RFC 8785 canonical JSON, hex
/// encoded.
///
/// Content-addressed on purpose. Issuing the same grant twice is
/// idempotent rather than duplicative, and the id is precisely the bytes
/// a signature over the grant would cover -- so the row, the id and the
/// signed artifact can never disagree about what was granted.
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
pub struct GrantId(String);
impl GrantId {
/// Derive the id from a grant's canonical form.
pub fn of(grant: &Grant) -> Result<Self, PolicyPgError> {
let canonical = grant
.canonical_json()
.map_err(|e| PolicyPgError::Canonicalise(e.to_string()))?;
Ok(Self::of_canonical(&canonical))
}
/// Derive the id from canonical JSON that is already in hand.
pub fn of_canonical(canonical: &str) -> Self {
let digest = Sha256::digest(canonical.as_bytes());
Self(hex::encode(digest))
}
pub fn as_str(&self) -> &str {
&self.0
}
}
impl std::fmt::Display for GrantId {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str(&self.0)
}
}
/// What a grant carried that the database will not be enforcing.
///
/// Returned alongside every issued grant so a caller cannot mistake
/// partial compilation for total. Every field named here is still
/// enforced -- by [`trusty_policy::PolicyEngine`], in the application --
/// but *not* by Postgres, and a caller relying on the database alone
/// would be relying on something that is not there.
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct CompilationReport {
/// Names of grant fields that did not reach the database.
pub uncompiled: Vec<&'static str>,
}
impl CompilationReport {
/// Build the report for a grant.
pub fn of(grant: &Grant) -> Self {
let mut uncompiled = Vec::new();
if grant.limits.rate_per_min.is_some() {
uncompiled.push("limits.rate_per_min");
}
if grant.limits.irreversible_budget.is_some() {
uncompiled.push("limits.irreversible_budget");
}
if grant.limits.dead_man_secs.is_some() {
uncompiled.push("limits.dead_man_secs");
}
Self { uncompiled }
}
/// True when every field of the grant reached the database, so the
/// database alone is a sufficient enforcer of it.
pub fn is_total(&self) -> bool {
self.uncompiled.is_empty()
}
}
/// The lowercase token a [`Tier`] is stored and compared as.
///
/// Distinct from [`Tier::sql_verb`], which gives the SQL keyword. The
/// two are 1:1 -- that is the whole design -- but they are not the same
/// string, and conflating them would put `SELECT` in a data column.
pub fn tier_token(tier: Tier) -> &'static str {
match tier {
Tier::Read => "read",
Tier::Append => "append",
Tier::Update => "update",
Tier::Delete => "delete",
}
}
#[derive(Debug, thiserror::Error)]
pub enum PolicyPgError {
#[error("postgres: {0}")]
Postgres(#[from] postgres::Error),
#[error("could not canonicalise the grant: {0}")]
Canonicalise(String),
#[error(
"no relation named {0:?} exists, so enabling row-level security on it \
would silently protect nothing"
)]
UnknownRelation(String),
#[error(
"no principal named {0:?} is registered, and a key bound to nobody is a key nothing can revoke"
)]
UnknownPrincipal(String),
}
#[cfg(test)]
mod tests {
use super::*;
use trusty_policy::Limits;
fn grant() -> Grant {
Grant {
why: Some("Sam's scheduling agent may propose, never book.".into()),
subject: "agent:01J".into(),
acts_for: Some("user:01H".into()),
scope: "board:9f2/calendar".into(),
tier: Tier::Append,
excludes: vec!["board:9f2/calendar/private".into()],
limits: Limits::default(),
expires_at: 1_788_000_000,
}
}
#[test]
fn the_id_is_the_hash_of_the_canonical_form() {
let g = grant();
let canonical = g.canonical_json().expect("canonical form");
assert_eq!(
GrantId::of(&g).expect("id"),
GrantId::of_canonical(&canonical),
"the id must be derivable from the canonical bytes alone, or a \
verifier holding only the signed artifact could not name the row"
);
}
#[test]
fn issuing_the_same_grant_twice_yields_one_id() {
assert_eq!(
GrantId::of(&grant()).expect("id"),
GrantId::of(&grant()).expect("id"),
"a content-addressed id makes re-issuing idempotent rather than \
quietly accumulating duplicate permissions"
);
}
#[test]
fn changing_why_changes_the_id() {
let mut other = grant();
other.why = Some("a different reason entirely".into());
assert_ne!(
GrantId::of(&grant()).expect("id"),
GrantId::of(&other).expect("id"),
"the justification is inside the hash, not beside it: a grant \
whose stated reason can be edited without changing its identity \
is missing the half an auditor asks about"
);
}
#[test]
fn a_grant_without_limits_compiles_totally() {
let report = CompilationReport::of(&grant());
assert!(
report.is_total(),
"with no limits set there is nothing the database cannot express, \
so it alone is a sufficient enforcer; got {report:?}"
);
}
#[test]
fn a_grant_with_limits_reports_them_as_uncompiled() {
let mut g = grant();
g.limits = Limits {
rate_per_min: Some(20),
irreversible_budget: Some(0),
dead_man_secs: Some(1800),
};
let report = CompilationReport::of(&g);
assert!(
!report.is_total(),
"a rate limit that reached no enforcer must never look enforced"
);
assert_eq!(
report.uncompiled,
vec![
"limits.rate_per_min",
"limits.irreversible_budget",
"limits.dead_man_secs"
],
"each unenforceable field must be named, so the caller knows which \
enforcement it still owes the gate"
);
}
#[test]
fn the_stored_token_is_not_the_sql_verb() {
assert_eq!(tier_token(Tier::Read), "read");
assert_eq!(Tier::Read.sql_verb(), "SELECT");
for tier in [Tier::Read, Tier::Append, Tier::Update, Tier::Delete] {
assert_ne!(
tier_token(tier),
tier.sql_verb(),
"the data token and the SQL keyword are 1:1 but not identical; \
conflating them would put a SQL keyword in a data column"
);
}
}
#[test]
fn every_tier_has_a_distinct_token() {
let tokens: Vec<_> = [Tier::Read, Tier::Append, Tier::Update, Tier::Delete]
.into_iter()
.map(tier_token)
.collect();
let mut sorted = tokens.clone();
sorted.sort_unstable();
sorted.dedup();
assert_eq!(
sorted.len(),
tokens.len(),
"two tiers sharing a token would let a grant for one silently \
authorise the other"
);
}
}

View File

@@ -0,0 +1,508 @@
//! The decision function every row-level-security policy calls.
//!
//! This is the compilation target. A `CREATE POLICY` written once per
//! (table, verb) delegates to [`DDL_PERMITS`], and grants are rows, so
//! issuing or revoking a permission never touches DDL and no SQL is ever
//! built from a grant's contents.
//!
//! The evaluation order is the permission model's, and the order is
//! load-bearing rather than incidental:
//!
//! 1. **Exclusion wins**, checked first, across *all* of the subject's
//! live grants regardless of tier. An exclusion can never be
//! overridden by a wider grant -- so it cannot be evaluated after
//! coverage, or a second grant would reopen what the first shut.
//! 2. **Expiry** is wall clock, independent of whether work finished.
//! 3. **Coverage** -- some live grant's scope contains the resource.
//! 4. **Tier** matches exactly.
//! 5. **Delegation intersects**: an agent is never a principal in its
//! own right, so the principal it acts for must independently permit
//! the same thing.
//! 6. Anything unmatched, NULL or unreadable is **false**. Absent policy
//! is not permission.
/// Scope containment.
///
/// `starts_with`, never `LIKE`: a scope containing `%` or `_` would
/// otherwise widen itself, and the scope string is attacker-influenced
/// in exactly the deployments this is for.
pub const DDL_COVERS: &str = r#"
CREATE OR REPLACE FUNCTION trusty_policy.covers(scope TEXT, resource TEXT)
RETURNS BOOLEAN
LANGUAGE sql IMMUTABLE PARALLEL SAFE
AS $fn$
SELECT scope IS NOT NULL
AND resource IS NOT NULL
AND (resource = scope OR starts_with(resource, scope || '/'))
$fn$
"#;
/// How many `acts_for` hops are followed before the answer is refusal.
///
/// A delegation cycle -- A acts for B, B acts for A -- would otherwise
/// recurse until the stack gave out, and a stack overflow inside a
/// policy predicate is an availability bug in the authorisation path.
/// Refusing past the bound keeps the failure mode deny-by-default.
pub const MAX_DELEGATION_DEPTH: i32 = 4;
/// The decision function. `SECURITY DEFINER` with a pinned
/// `search_path`, so the application role can consult grants without
/// being able to read or write them directly.
pub const DDL_PERMITS: &str = r#"
CREATE OR REPLACE FUNCTION trusty_policy.permits(
subject TEXT,
tier TEXT,
resource TEXT,
depth INT DEFAULT 0
) RETURNS BOOLEAN
LANGUAGE plpgsql
STABLE
SECURITY DEFINER
SET search_path = trusty_policy, pg_temp
AS $fn$
DECLARE
g RECORD;
BEGIN
-- Deny by default. An unreadable or missing subject refuses; it
-- does not fall back to something wider.
IF subject IS NULL OR subject = ''
OR tier IS NULL OR resource IS NULL THEN
RETURN FALSE;
END IF;
-- A delegation cycle is refused, not followed.
IF depth > 4 THEN
RETURN FALSE;
END IF;
-- 0. Identity before permission. A grant held by a principal who
-- was never registered is a grant to nobody, and one held by a
-- revoked principal must stop at once rather than grant by grant
-- -- revoking them one at a time is a race with whoever is using
-- them. This is what makes "kill the agent's key, the person is
-- untouched" true in practice.
IF NOT EXISTS (
SELECT 1
FROM trusty_policy.principal p
WHERE p.id = permits.subject
AND p.revoked_at IS NULL
) THEN
RETURN FALSE;
END IF;
-- 1. Exclusion wins, and is checked before coverage so that a
-- second, wider grant cannot reopen what an exclusion shut.
-- Deliberately NOT filtered by tier: an exclusion is a statement
-- about a resource, not about a verb.
IF EXISTS (
SELECT 1
FROM trusty_policy.permission_grant pgr,
LATERAL unnest(pgr.excludes) AS ex(ref)
WHERE pgr.subject = permits.subject
AND pgr.revoked_at IS NULL
AND trusty_policy.covers(ex.ref, permits.resource)
) THEN
RETURN FALSE;
END IF;
-- 2, 3, 4. A live grant, unexpired by the wall clock, of exactly
-- this tier, whose scope covers the resource.
FOR g IN
SELECT pgr.acts_for
FROM trusty_policy.permission_grant pgr
WHERE pgr.subject = permits.subject
AND pgr.tier = permits.tier
AND pgr.revoked_at IS NULL
AND pgr.expires_at > now()
AND trusty_policy.covers(pgr.scope, permits.resource)
LOOP
-- 5. effective = grant(agent) INTERSECT permission(acts_for).
-- A grant naming no principal stands on its own.
IF g.acts_for IS NULL THEN
RETURN TRUE;
END IF;
IF EXISTS (
SELECT 1
FROM trusty_policy.delegation d
WHERE d.agent_id = permits.subject
AND d.acts_for_id = g.acts_for
AND d.revoked_at IS NULL
AND (d.expires_at IS NULL OR d.expires_at > now())
) AND trusty_policy.permits(
g.acts_for, permits.tier, permits.resource, depth + 1
) THEN
RETURN TRUE;
END IF;
END LOOP;
-- 6. No matching grant is a refusal.
RETURN FALSE;
END;
$fn$
"#;
/// Applied after [`crate::schema::DDL_V1`], in the same transaction.
pub const DDL_FUNCTIONS: &[&str] = &[
DDL_COVERS,
DDL_PERMITS,
DDL_ENSURE_PRINCIPAL,
DDL_REINSTATE_PRINCIPAL,
];
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn the_decision_function_pins_its_search_path() {
assert!(
DDL_PERMITS.contains("SET search_path = trusty_policy, pg_temp"),
"a SECURITY DEFINER function without a pinned search_path is a \
privilege escalation: anyone able to set search_path could shadow \
permission_grant with a table of their own"
);
}
#[test]
fn security_definer_never_appears_without_a_pinned_search_path() {
for ddl in DDL_FUNCTIONS {
if ddl.contains("SECURITY DEFINER") {
assert!(
ddl.contains("SET search_path ="),
"every SECURITY DEFINER function must pin search_path, not \
just the one that happens to be checked above: {ddl}"
);
}
}
}
#[test]
fn scope_containment_never_uses_like() {
for ddl in DDL_FUNCTIONS {
assert!(
!ddl.to_uppercase().contains(" LIKE "),
"LIKE would let a scope containing % or _ widen itself, and the \
scope string is attacker-influenced: {ddl}"
);
}
assert!(
DDL_COVERS.contains("starts_with(resource, scope || '/')"),
"containment must be a literal prefix test on a path separator"
);
}
#[test]
fn a_prefix_that_is_not_a_path_segment_does_not_match() {
// covers('a/b', 'a/bc') must be false: 'a/bc' is not inside
// 'a/b'. The separator in the concatenation is what enforces it.
assert!(
DDL_COVERS.contains("scope || '/'"),
"without the separator, scope 'a/b' would cover 'a/bcd', silently \
granting a sibling resource whose name merely starts the same way"
);
}
#[test]
fn exclusion_is_evaluated_before_coverage() {
let exclusion = DDL_PERMITS
.find("unnest(pgr.excludes)")
.expect("the exclusion check must exist");
let coverage = DDL_PERMITS
.find("FOR g IN")
.expect("the coverage loop must exist");
assert!(
exclusion < coverage,
"an exclusion checked after coverage could be overridden by a wider \
grant, which is exactly what the model forbids"
);
}
#[test]
fn exclusions_are_not_filtered_by_tier() {
let start = DDL_PERMITS
.find("unnest(pgr.excludes)")
.expect("the exclusion check must exist");
let end = DDL_PERMITS[start..]
.find("RETURN FALSE")
.expect("the exclusion check must refuse")
+ start;
assert!(
!DDL_PERMITS[start..end].contains("pgr.tier"),
"an exclusion is a statement about a resource, not about a verb; \
filtering it by tier would let a read grant expose what a write \
grant excluded"
);
}
#[test]
fn an_unregistered_or_revoked_principal_refuses() {
assert!(
DDL_PERMITS.contains("FROM trusty_policy.principal p"),
"a grant whose subject was never registered is a grant to nobody, and deny-by-default says that refuses"
);
let start = DDL_PERMITS
.find("FROM trusty_policy.principal p")
.expect("the principal check must exist");
assert!(
DDL_PERMITS[start..].starts_with(
"FROM trusty_policy.principal p
WHERE p.id = permits.subject
AND p.revoked_at IS NULL"
),
"revoking a principal must stop every grant it holds at once, not leave them to be revoked one at a time"
);
}
#[test]
fn identity_is_checked_before_any_grant_is_read() {
let identity = DDL_PERMITS
.find("FROM trusty_policy.principal p")
.expect("the principal check must exist");
let exclusion = DDL_PERMITS
.find("unnest(pgr.excludes)")
.expect("the exclusion check must exist");
assert!(
identity < exclusion,
"asking who someone is before asking what they may do is both cheaper and the right order to reason about; a revoked principal should never reach the grant table at all"
);
}
#[test]
fn a_delegation_cycle_is_refused_rather_than_followed() {
assert!(
DDL_PERMITS.contains("depth > 4"),
"A acts for B and B acts for A would recurse until the stack gave \
out, and a stack overflow inside a policy predicate is an \
availability bug in the authorisation path"
);
assert_eq!(
MAX_DELEGATION_DEPTH, 4,
"the documented bound and the bound in the SQL must agree"
);
}
#[test]
fn the_delegated_side_is_checked_independently() {
assert!(
DDL_PERMITS.contains("trusty_policy.permits(\n g.acts_for"),
"the principal must be asked the same question, not merely looked \
up: an agent is never a principal in its own right"
);
}
#[test]
fn a_revoked_delegation_stops_permitting() {
let start = DDL_PERMITS
.find("FROM trusty_policy.delegation d")
.expect("the delegation lookup must exist");
let window = &DDL_PERMITS[start..];
assert!(
window.contains("d.revoked_at IS NULL"),
"revocation must take effect at the decision, not merely be recorded"
);
assert!(
window.contains("d.expires_at IS NULL OR d.expires_at > now()"),
"an expired delegation must stop permitting; NULL means no expiry, \
which is not the same as expired"
);
}
#[test]
fn expiry_is_wall_clock_and_revocation_is_immediate() {
assert!(
DDL_PERMITS.contains("pgr.expires_at > now()"),
"expiry is wall clock, independent of whether work finished"
);
assert!(
DDL_PERMITS.contains("pgr.revoked_at IS NULL"),
"a revoked grant must stop permitting without any DDL running"
);
}
#[test]
fn a_null_or_empty_subject_refuses() {
assert!(
DDL_PERMITS.contains("subject IS NULL OR subject = ''"),
"current_setting('trusty.subject', true) returns NULL when unset \
and '' in some paths; either must refuse, because absent policy \
is not permission"
);
}
#[test]
fn the_function_is_stable_not_volatile() {
assert!(
DDL_PERMITS.contains("STABLE"),
"a VOLATILE function cannot be used in an RLS predicate efficiently \
and would be re-evaluated per row without caching"
);
}
#[test]
fn nothing_here_is_created_outside_the_trusty_policy_schema() {
for ddl in DDL_FUNCTIONS {
assert!(
ddl.contains("FUNCTION trusty_policy."),
"an unqualified function would land in whatever search_path \
happened to be set: {ddl}"
);
}
}
}
/// Create a principal if missing, and refresh its display name.
///
/// The portable half of enrolment. A consumer whose users arrive through
/// its own auth table hangs a thin trigger on that table and calls this;
/// the trigger is the consumer's, because only they know which table
/// their users live in, but the logic is here so both sides cannot
/// drift.
///
/// `SECURITY DEFINER` with a pinned `search_path`, for the same reason
/// [`DDL_PERMITS`] is: without the pin, anyone able to set `search_path`
/// could shadow `principal` with a table of their own.
///
/// **It does not un-revoke.** An earlier version did, on the reasoning
/// that re-enrolment is the same decision said again. That reasoning
/// holds for a person deliberately re-enrolling and fails for an
/// automated repair job -- and the function cannot tell them apart.
/// Reconciliation calls this same verb, so un-revoking here meant a
/// revoked principal whose auth row still existed would be resurrected
/// by the next backfill, silently undoing a deliberate act. Bringing
/// someone back is [`DDL_REINSTATE_PRINCIPAL`], which a trigger has no
/// reason to reach.
pub const DDL_ENSURE_PRINCIPAL: &str = r#"
CREATE OR REPLACE FUNCTION trusty_policy.ensure_principal(
p_id TEXT,
p_kind TEXT,
p_display TEXT DEFAULT ''
) RETURNS VOID
LANGUAGE plpgsql
SECURITY DEFINER
SET search_path = trusty_policy, pg_temp
AS $fn$
BEGIN
INSERT INTO trusty_policy.principal (id, kind, display)
VALUES (p_id, p_kind, COALESCE(p_display, ''))
ON CONFLICT (id) DO UPDATE
SET display = EXCLUDED.display;
END;
$fn$
"#;
/// Bring a revoked principal back, deliberately.
///
/// Separate from [`DDL_ENSURE_PRINCIPAL`] because "create if missing"
/// and "bring someone back" are different intentions, and only one of
/// them should be reachable from a trigger. Revocation is an act someone
/// may have to justify; undoing it should be equally deliberate, and
/// equally visible in whatever called it.
///
/// Returns whether a revoked principal was actually reinstated, so a
/// caller reinstating someone already live is told rather than
/// reassured.
pub const DDL_REINSTATE_PRINCIPAL: &str = r#"
CREATE OR REPLACE FUNCTION trusty_policy.reinstate_principal(p_id TEXT)
RETURNS BOOLEAN
LANGUAGE plpgsql
SECURITY DEFINER
SET search_path = trusty_policy, pg_temp
AS $fn$
DECLARE
changed INT;
BEGIN
UPDATE trusty_policy.principal
SET revoked_at = NULL
WHERE id = p_id AND revoked_at IS NOT NULL;
GET DIAGNOSTICS changed = ROW_COUNT;
RETURN changed = 1;
END;
$fn$
"#;
/// Principals that should exist and do not, or exist and are revoked.
///
/// **Must always return zero rows.** `permits()` refuses for a subject
/// that is not a registered live principal, so a missed enrolment is not
/// degraded access -- it is a total lockout, with nothing in any log
/// saying why. This is the query that catches it before a person does.
///
/// Takes a table and column to reconcile against, so a consumer can
/// point it at whatever holds its users. Returns the ids that are
/// missing and the ones that are revoked, separately: they need
/// different fixes.
pub const SQL_RECONCILE_PRINCIPALS: &str = r#"
SELECT src.id,
CASE WHEN p.id IS NULL THEN 'missing' ELSE 'revoked' END AS problem
FROM (SELECT $1::text[] AS ids) AS _
CROSS JOIN LATERAL unnest(_.ids) AS src(id)
LEFT JOIN trusty_policy.principal p
ON p.id = src.id AND p.revoked_at IS NULL
WHERE p.id IS NULL
"#;
#[cfg(test)]
mod enrolment_tests {
use super::*;
#[test]
fn ensure_principal_pins_its_search_path() {
assert!(
DDL_ENSURE_PRINCIPAL.contains("SET search_path = trusty_policy, pg_temp"),
"a SECURITY DEFINER function without a pinned search_path lets \
anyone who can set search_path shadow `principal` with a table of \
their own"
);
}
#[test]
fn enrolment_cannot_resurrect_a_revoked_principal() {
assert!(
!DDL_ENSURE_PRINCIPAL.contains("revoked_at"),
"reconciliation calls this same verb, so un-revoking here would let a backfill resurrect a principal somebody deliberately revoked -- and permits() refuses a revoked principal, so it would restore everything at once"
);
}
#[test]
fn bringing_someone_back_is_a_separate_verb() {
assert!(
DDL_REINSTATE_PRINCIPAL.contains("revoked_at = NULL"),
"there must still be a way back, or revocation is a trapdoor"
);
assert!(
DDL_REINSTATE_PRINCIPAL.contains("revoked_at IS NOT NULL"),
"and it must report whether it actually changed anything, so a caller reinstating someone already live is told rather than reassured"
);
assert!(
DDL_REINSTATE_PRINCIPAL.contains("SET search_path = trusty_policy, pg_temp"),
"a SECURITY DEFINER function without a pinned search_path is a privilege escalation, and this one can restore authority"
);
}
#[test]
fn enrolment_is_idempotent() {
assert!(
DDL_ENSURE_PRINCIPAL.contains("ON CONFLICT (id) DO UPDATE"),
"a trigger fires more than once in practice; a second call must \
not fail and must not duplicate"
);
}
#[test]
fn reconciliation_separates_missing_from_revoked() {
assert!(
SQL_RECONCILE_PRINCIPALS.contains("'missing'")
&& SQL_RECONCILE_PRINCIPALS.contains("'revoked'"),
"the two need different fixes -- one is an enrolment that never \
ran, the other a deliberate act somebody may need to justify -- \
so a query reporting them alike hides the difference"
);
assert!(
SQL_RECONCILE_PRINCIPALS.contains("revoked_at IS NULL"),
"a revoked principal is not a present one as far as permits() is \
concerned, so reconciliation must count it as a problem"
);
}
}

View File

@@ -0,0 +1,312 @@
//! Schema DDL for the grant store.
//!
//! Follows `trusty-postgres`'s idiom exactly: a `&[&str]` of
//! `CREATE ... IF NOT EXISTS` applied in one transaction on every open,
//! followed by a `schema_meta` version upsert. There is no migration
//! runner here and there should not be one -- the consumer's migration
//! tool owns *its* tables; this owns only the permission model's own.
//!
//! Everything lives in its own `trusty_policy` schema so it can never
//! collide with an application table, and so revoking write access to
//! the whole schema is one statement.
pub const SCHEMA_VERSION: &str = "1";
/// The Postgres schema every object below lives in.
pub const SCHEMA_NAME: &str = "trusty_policy";
pub const DDL_V1: &[&str] = &[
r#"CREATE SCHEMA IF NOT EXISTS trusty_policy"#,
// ----------------------------------------------------------------
// principal -- one table, four kinds.
//
// `id` is TEXT holding the full reference ("agent:01J..."), not a
// bare UUID. The permission model's canonical grant JSON carries
// subjects in that prefixed form, and `Grant.subject` is a String;
// storing a bare uuid would mean parsing the prefix off on every
// write and re-attaching it on every read, which is exactly the
// translation layer this design exists to avoid.
//
// The CHECK keeps the prefix honest, so `kind` cannot drift from
// what the id claims.
// ----------------------------------------------------------------
r#"CREATE TABLE IF NOT EXISTS trusty_policy.principal (
id TEXT PRIMARY KEY,
kind TEXT NOT NULL CHECK (kind IN ('org','user','agent','program')),
display TEXT NOT NULL DEFAULT '',
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
-- The `key` edge. Points at trusty's producer registry
-- (producer_identity.producer_oid), which is the agent identity
-- system; there is not to be a second one. Deliberately no
-- foreign key: that table belongs to trusty-postgres, lives in
-- another schema, and may not be installed at all. The edge is
-- recorded here and resolved through lookup_producer.
producer_oid TEXT UNIQUE,
-- Revoking the identity itself, as distinct from revoking any
-- one grant. Kills every grant the principal holds at once,
-- which is what "kill the agent's key, the person is untouched"
-- has to mean in practice.
revoked_at TIMESTAMPTZ,
-- What KIND of thing this is, structurally, is `kind`. What kind
-- of ORGANISATION it is -- nonprofit, agency, prime, sub -- is
-- descriptive and belongs here, not there.
--
-- Kept apart deliberately. Folded into `kind`, every new
-- organisation type becomes a change to permits(), and the
-- authorisation function ends up knowing about tax status. The
-- CHECK on `kind` is what keeps the two from being conflated by
-- accident.
org_type TEXT,
CONSTRAINT principal_id_matches_kind CHECK (starts_with(id, kind || ':')),
-- Only an org has an org type. A user with one is a modelling
-- mistake that would otherwise sit there looking deliberate.
CONSTRAINT org_type_only_on_orgs CHECK (org_type IS NULL OR kind = 'org')
)"#,
// Idempotent columns for an installation that predates them.
// CREATE TABLE IF NOT EXISTS silently does nothing when the table is
// already there, so a new column needs saying twice.
r#"ALTER TABLE trusty_policy.principal
ADD COLUMN IF NOT EXISTS producer_oid TEXT"#,
r#"ALTER TABLE trusty_policy.principal
ADD COLUMN IF NOT EXISTS revoked_at TIMESTAMPTZ"#,
r#"ALTER TABLE trusty_policy.principal
ADD COLUMN IF NOT EXISTS org_type TEXT"#,
r#"CREATE UNIQUE INDEX IF NOT EXISTS idx_principal_producer_oid
ON trusty_policy.principal(producer_oid)"#,
// ----------------------------------------------------------------
// membership -- user belongs to org.
// ----------------------------------------------------------------
r#"CREATE TABLE IF NOT EXISTS trusty_policy.membership (
member_id TEXT NOT NULL REFERENCES trusty_policy.principal(id) ON DELETE CASCADE,
org_id TEXT NOT NULL REFERENCES trusty_policy.principal(id) ON DELETE CASCADE,
role TEXT NOT NULL DEFAULT '',
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
PRIMARY KEY (member_id, org_id)
)"#,
// ----------------------------------------------------------------
// delegation -- agent acts for user | org.
//
// Deliberately NOT cascaded on delete: revoking a delegation is
// `revoked_at`, and losing the row would lose the record that the
// delegation ever existed. An audit asks "who could act for whom
// last March", and a deleted row cannot answer.
// ----------------------------------------------------------------
r#"CREATE TABLE IF NOT EXISTS trusty_policy.delegation (
agent_id TEXT NOT NULL REFERENCES trusty_policy.principal(id),
acts_for_id TEXT NOT NULL REFERENCES trusty_policy.principal(id),
granted_by TEXT REFERENCES trusty_policy.principal(id),
granted_at TIMESTAMPTZ NOT NULL DEFAULT now(),
expires_at TIMESTAMPTZ,
revoked_at TIMESTAMPTZ,
PRIMARY KEY (agent_id, acts_for_id)
)"#,
// ----------------------------------------------------------------
// permission_grant -- the grant itself.
//
// Named `permission_grant`, not `grant`: GRANT is a reserved word in
// SQL, and a table that must be double-quoted at every call site is
// a footgun waiting for the one place someone forgets.
//
// `id` is the SHA-256 of `canonical_json`, so a grant's identity IS
// its content. Issuing the same grant twice is idempotent, and the
// id is exactly what a signature over the grant would cover.
//
// `canonical_json` is stored verbatim -- RFC 8785 bytes, not a JSONB
// column. JSONB renormalises whitespace and key order, which would
// destroy the very property the canonical form exists to provide.
// Same reasoning that keeps `raw_payload` TEXT in trusty-postgres.
// ----------------------------------------------------------------
r#"CREATE TABLE IF NOT EXISTS trusty_policy.permission_grant (
id TEXT PRIMARY KEY,
subject TEXT NOT NULL,
acts_for TEXT,
scope TEXT NOT NULL,
tier TEXT NOT NULL CHECK (tier IN ('read','append','update','delete')),
excludes TEXT[] NOT NULL DEFAULT '{}',
expires_at TIMESTAMPTZ NOT NULL,
why TEXT,
canonical_json TEXT NOT NULL,
issued_at TIMESTAMPTZ NOT NULL DEFAULT now(),
revoked_at TIMESTAMPTZ
)"#,
// Every permits() call filters on subject first, then tier.
r#"CREATE INDEX IF NOT EXISTS idx_grant_subject_tier
ON trusty_policy.permission_grant(subject, tier)"#,
// Exclusions are checked across ALL of a subject's grants, including
// ones whose tier does not match, so they need their own path in.
r#"CREATE INDEX IF NOT EXISTS idx_grant_subject
ON trusty_policy.permission_grant(subject)"#,
r#"CREATE TABLE IF NOT EXISTS trusty_policy.schema_meta (
key TEXT PRIMARY KEY,
value TEXT NOT NULL
)"#,
];
/// Upserts the schema_version row. Runs on every open to declare the
/// version this build expects. Mirrors `trusty-postgres`.
pub const DDL_SCHEMA_VERSION_UPSERT: &str = r#"
INSERT INTO trusty_policy.schema_meta(key, value) VALUES ('schema_version', $1)
ON CONFLICT (key) DO UPDATE SET value = EXCLUDED.value
"#;
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn every_statement_is_idempotent() {
for stmt in DDL_V1 {
let upper = stmt.to_uppercase();
assert!(
upper.contains("IF NOT EXISTS"),
"the schema is applied on every open, so a statement without \
IF NOT EXISTS would fail the second time a process starts: {stmt}"
);
}
}
#[test]
fn nothing_is_created_outside_the_trusty_policy_schema() {
for stmt in DDL_V1 {
if stmt.to_uppercase().starts_with("CREATE SCHEMA") {
continue;
}
assert!(
stmt.contains("trusty_policy."),
"an unqualified object would land in whatever search_path \
happened to be set, which is not a place to keep permissions: {stmt}"
);
}
}
#[test]
fn the_grant_table_avoids_the_reserved_word() {
let ddl = DDL_V1.join("\n").to_uppercase();
assert!(
!ddl.contains("EXISTS TRUSTY_POLICY.GRANT "),
"GRANT is reserved in SQL; the table is permission_grant precisely \
so no call site has to remember to double-quote it"
);
}
#[test]
fn the_canonical_form_is_stored_as_text_not_jsonb() {
let ddl = DDL_V1.join("\n");
assert!(
ddl.contains("canonical_json TEXT"),
"JSONB renormalises whitespace and key order, which would destroy \
the exact property RFC 8785 canonicalisation exists to give"
);
assert!(
!ddl.to_uppercase().contains("JSONB"),
"no column here may be JSONB, for the same reason"
);
}
#[test]
fn tier_is_constrained_to_the_four_verbs() {
let ddl = DDL_V1.join("\n");
assert!(
ddl.contains("tier IN ('read','append','update','delete')"),
"a tier outside the four is a permission nobody can enforce; \
the database should refuse to store one"
);
}
#[test]
fn a_principals_kind_cannot_drift_from_its_id() {
let ddl = DDL_V1.join("\n");
assert!(
ddl.contains("principal_id_matches_kind"),
"if kind and the id prefix can disagree, an audit line reading \
'agent:...' whose kind says 'user' is unreadable"
);
}
#[test]
fn org_type_is_descriptive_and_kind_is_structural() {
let ddl = DDL_V1.join("
");
assert!(
ddl.contains("org_type"),
"an organisation's type is descriptive; folded into `kind` it would make every new organisation type a change to permits()"
);
assert!(
ddl.contains("kind IN ('org','user','agent','program')"),
"and `kind` must stay the closed structural set, or the two collapse back into one column"
);
assert!(
ddl.contains("org_type_only_on_orgs"),
"a user carrying an org type is a modelling mistake that would otherwise sit there looking deliberate"
);
}
#[test]
fn permits_never_needs_to_read_org_type() {
// Structural, not a runtime check: the decision function selects
// its own columns, and org_type is not among them. If it ever
// is, authorisation has started depending on what sort of
// organisation someone is, which is the coupling this split
// exists to prevent.
assert!(
!crate::permits::DDL_PERMITS.contains("org_type"),
"permits() must not read org_type"
);
}
#[test]
fn the_key_edge_points_at_the_producer_registry() {
let ddl = DDL_V1.join("
");
assert!(
ddl.contains("producer_oid"),
"the third edge -- principal has an ed25519 key -- must be recorded, and it must point at trusty's producer registry rather than at a second identity system"
);
assert!(
!ddl.contains("ed25519_pubkey"),
"storing the key material here would BE the second identity system; only the reference to the registry belongs here"
);
}
#[test]
fn a_principal_can_be_revoked_as_a_whole() {
let ddl = DDL_V1.join("
");
assert!(
ddl.contains("revoked_at TIMESTAMPTZ"),
"killing an agent's key must stop every grant it holds at once; revoking them one at a time is a race with whoever is using them"
);
}
#[test]
fn a_column_added_after_v1_is_added_idempotently_too() {
let ddl = DDL_V1.join("
");
for col in ["producer_oid", "revoked_at"] {
assert!(
ddl.contains(&format!("ADD COLUMN IF NOT EXISTS {col}")),
"CREATE TABLE IF NOT EXISTS does nothing when the table already exists, so a column added later never reaches an existing installation unless it is also ALTERed in: {col}"
);
}
}
#[test]
fn a_delegation_is_revoked_not_deleted() {
let delegation = DDL_V1
.iter()
.find(|s| s.contains("trusty_policy.delegation"))
.expect("the delegation table must exist");
assert!(
delegation.contains("revoked_at"),
"revocation must be recorded, not effected by deletion"
);
assert!(
!delegation.contains("ON DELETE CASCADE"),
"cascading a delegation away would erase the record that it ever \
existed, and 'who could act for whom last March' is exactly what \
an audit asks"
);
}
}

View File

@@ -0,0 +1,616 @@
//! Issuing grants, and putting a table under their protection.
//!
//! Three things live here:
//!
//! - [`GrantStore`] — issue and revoke grants. Both are parameterized
//! statements against rows; neither runs DDL.
//! - [`PolicySession`] — set the subject for one transaction, so a
//! policy predicate has someone to ask about.
//! - [`GrantStore::enforce`] — the one place a table name reaches SQL,
//! and the only DDL a consumer runs after install.
use postgres::{Client, NoTls, Transaction};
use trusty_policy::{Grant, Tier};
use crate::permits::DDL_FUNCTIONS;
use crate::schema::{DDL_SCHEMA_VERSION_UPSERT, DDL_V1, SCHEMA_VERSION};
use crate::{tier_token, CompilationReport, GrantId, PolicyPgError};
/// The GUC a policy predicate reads the acting subject from.
///
/// Set transaction-locally by [`PolicySession::begin`]. Transaction
/// local, not session local, so a pooled connection handed to the next
/// request cannot still be carrying the last one's identity.
pub const SUBJECT_SETTING: &str = "trusty.subject";
/// Reads and writes grants.
pub struct GrantStore {
client: Client,
}
impl GrantStore {
/// Connect and apply the schema idempotently.
pub fn open(conn_str: &str) -> Result<Self, PolicyPgError> {
let mut client = Client::connect(conn_str, NoTls)?;
apply_schema(&mut client)?;
Ok(Self { client })
}
/// Adopt an existing connection, applying the schema to it.
pub fn adopt(mut client: Client) -> Result<Self, PolicyPgError> {
apply_schema(&mut client)?;
Ok(Self { client })
}
pub fn client(&mut self) -> &mut Client {
&mut self.client
}
/// Record a grant, and say what about it the database will not be
/// enforcing.
///
/// Idempotent: the id is the hash of the canonical form, so issuing
/// the same grant twice leaves one row rather than two permissions.
/// Re-issuing a previously revoked grant un-revokes it, which is the
/// same decision said again.
pub fn issue(&mut self, grant: &Grant) -> Result<(GrantId, CompilationReport), PolicyPgError> {
let canonical = grant
.canonical_json()
.map_err(|e| PolicyPgError::Canonicalise(e.to_string()))?;
let id = GrantId::of_canonical(&canonical);
let expires = expires_at_sql(grant.expires_at);
self.client.execute(
"INSERT INTO trusty_policy.permission_grant
(id, subject, acts_for, scope, tier, excludes,
expires_at, why, canonical_json)
VALUES ($1, $2, $3, $4, $5, $6, to_timestamp($7), $8, $9)
ON CONFLICT (id) DO UPDATE SET revoked_at = NULL",
&[
&id.as_str(),
&grant.subject,
&grant.acts_for,
&grant.scope,
&tier_token(grant.tier),
&grant.excludes,
&expires,
&grant.why,
&canonical,
],
)?;
Ok((id, CompilationReport::of(grant)))
}
/// Register a principal, or update its display name.
///
/// `kind` must be one of `org`, `user`, `agent`, `program`, and the
/// id must carry it as a prefix — `agent:01J...`. The database
/// enforces both; this returns the error rather than papering over
/// it, because a principal whose kind disagrees with its id makes
/// every audit line that mentions it unreadable.
///
/// Registering an already-revoked principal reinstates it, which is
/// the same decision said again.
pub fn register_principal(
&mut self,
id: &str,
kind: &str,
display: &str,
) -> Result<(), PolicyPgError> {
self.client.execute(
"INSERT INTO trusty_policy.principal (id, kind, display)
VALUES ($1, $2, $3)
ON CONFLICT (id) DO UPDATE
SET display = EXCLUDED.display, revoked_at = NULL",
&[&id, &kind, &display],
)?;
Ok(())
}
/// Record the `key` edge: this principal is that producer.
///
/// `producer_oid` refers to trusty's producer registry —
/// `register_producer` / `lookup_producer` / `revoke_producer` in
/// `trusty-postgres`. That registry is the agent identity system and
/// there is not to be a second one, so no key material is stored
/// here; only the reference.
///
/// There is deliberately no foreign key. `producer_identity` belongs
/// to a different crate and schema and may not be installed at all,
/// and a policy engine that refuses to start without a chain store
/// would be a policy engine with a substrate.
pub fn bind_key(&mut self, principal: &str, producer_oid: &str) -> Result<(), PolicyPgError> {
let n = self.client.execute(
"UPDATE trusty_policy.principal SET producer_oid = $2 WHERE id = $1",
&[&principal, &producer_oid],
)?;
if n == 0 {
return Err(PolicyPgError::UnknownPrincipal(principal.to_string()));
}
Ok(())
}
/// The producer this principal is, if the edge has been recorded.
///
/// Resolving it to an actual public key goes through the producer
/// registry's own `lookup_producer`. Doing that here would mean
/// reimplementing half of it.
pub fn producer_oid_of(&mut self, principal: &str) -> Result<Option<String>, PolicyPgError> {
let row = self.client.query_opt(
"SELECT producer_oid FROM trusty_policy.principal WHERE id = $1",
&[&principal],
)?;
Ok(row.and_then(|r| r.get::<_, Option<String>>(0)))
}
/// Revoke a principal, and with it every grant the principal holds.
///
/// This is the blunt instrument, and it is meant to be. Revoking
/// grants one at a time is a race with whoever is using them; the
/// point of "kill the agent's key, the person is untouched" is that
/// one action ends the agent's authority entirely while leaving the
/// principal it acted for alone.
///
/// The grant rows survive. An audit asks what was permitted last
/// March, and a deleted row cannot answer.
///
/// Returns whether a live principal was actually revoked.
pub fn revoke_principal(&mut self, id: &str) -> Result<bool, PolicyPgError> {
let n = self.client.execute(
"UPDATE trusty_policy.principal
SET revoked_at = now()
WHERE id = $1 AND revoked_at IS NULL",
&[&id],
)?;
Ok(n == 1)
}
/// Record that `agent` may act for `acts_for`.
///
/// The delegation edge. Without it, an agent's grant naming a
/// principal is inert: [`GrantStore::permits`] requires both the
/// edge and the principal's own permission.
pub fn delegate(&mut self, agent: &str, acts_for: &str) -> Result<(), PolicyPgError> {
self.client.execute(
"INSERT INTO trusty_policy.delegation (agent_id, acts_for_id)
VALUES ($1, $2)
ON CONFLICT (agent_id, acts_for_id) DO UPDATE SET revoked_at = NULL",
&[&agent, &acts_for],
)?;
Ok(())
}
/// End a delegation without touching either side's grants.
pub fn revoke_delegation(&mut self, agent: &str, acts_for: &str) -> Result<bool, PolicyPgError> {
let n = self.client.execute(
"UPDATE trusty_policy.delegation SET revoked_at = now()
WHERE agent_id = $1 AND acts_for_id = $2 AND revoked_at IS NULL",
&[&agent, &acts_for],
)?;
Ok(n == 1)
}
/// Stop a grant permitting, without running any DDL.
///
/// Returns whether a live grant was actually revoked, so a caller
/// revoking something already gone is told rather than reassured.
/// The row stays: an audit asks what was permitted last March, and a
/// deleted row cannot answer.
pub fn revoke(&mut self, id: &GrantId) -> Result<bool, PolicyPgError> {
let n = self.client.execute(
"UPDATE trusty_policy.permission_grant
SET revoked_at = now()
WHERE id = $1 AND revoked_at IS NULL",
&[&id.as_str()],
)?;
Ok(n == 1)
}
/// Ask the database the same question a policy predicate asks.
///
/// For diagnostics and tests. Enforcement does not go through here —
/// it goes through the policy — but a caller debugging a refusal
/// wants to ask directly rather than infer from an empty result set.
pub fn permits(
&mut self,
subject: &str,
tier: Tier,
resource: &str,
) -> Result<bool, PolicyPgError> {
let row = self.client.query_one(
"SELECT trusty_policy.permits($1, $2, $3)",
&[&subject, &tier_token(tier), &resource],
)?;
Ok(row.get::<_, Option<bool>>(0).unwrap_or(false))
}
/// Put one table under grant enforcement.
///
/// `scope_column` names the column holding each row's resource
/// reference — the value a grant's scope is tested against.
///
/// This is the only place an identifier reaches SQL text. It is not
/// escaped by hand: the server is asked to resolve and quote it, via
/// `to_regclass` and `format('%I')`, and a name that does not
/// resolve to a real table is refused rather than quoted. Escaping
/// is a thing you can get subtly wrong; existence is not.
///
/// Returns the statements executed, so a caller can log or review
/// exactly what was applied.
pub fn enforce(
&mut self,
table: &str,
scope_column: &str,
) -> Result<Vec<String>, PolicyPgError> {
let row = self
.client
.query_opt(
"SELECT format('%I.%I', n.nspname, c.relname),
format('%I', a.attname),
c.relname
FROM pg_class c
JOIN pg_namespace n ON n.oid = c.relnamespace
JOIN pg_attribute a ON a.attrelid = c.oid
WHERE c.oid = to_regclass($1)
AND c.relkind = 'r'
AND a.attname = $2
AND a.attnum > 0
AND NOT a.attisdropped",
&[&table, &scope_column],
)?
.ok_or_else(|| PolicyPgError::UnknownRelation(format!("{table}.{scope_column}")))?;
let qualified: String = row.get(0);
let column: String = row.get(1);
let bare: String = row.get(2);
let mut applied = Vec::new();
// FORCE is not optional. Plain ENABLE is bypassed by the table
// owner, and the owner is who most connections are. Without
// FORCE every policy below is decorative.
for stmt in [
format!("ALTER TABLE {qualified} ENABLE ROW LEVEL SECURITY"),
format!("ALTER TABLE {qualified} FORCE ROW LEVEL SECURITY"),
] {
self.client.batch_execute(&stmt)?;
applied.push(stmt);
}
for tier in [Tier::Read, Tier::Append, Tier::Update, Tier::Delete] {
let policy = policy_name(&bare, tier);
let quoted: String = self
.client
.query_one("SELECT format('%I', $1::text)", &[&policy])?
.get(0);
// DROP first so enforce() is idempotent: re-running it after
// a definition change must converge, not fail.
let drop = format!("DROP POLICY IF EXISTS {quoted} ON {qualified}");
self.client.batch_execute(&drop)?;
applied.push(drop);
let create = create_policy_sql(&quoted, &qualified, &column, tier);
self.client.batch_execute(&create)?;
applied.push(create);
}
Ok(applied)
}
}
/// The four policy statements that put `table` under grant enforcement,
/// plus the two `ALTER TABLE` lines that make them bite.
///
/// Pure: it touches no database and creates nothing. Use it to generate
/// a migration for a table that does not exist yet, where
/// [`GrantStore::enforce`] cannot help because it resolves the name
/// against `pg_class` first.
///
/// Because there is no catalogue to resolve against, identifiers are
/// checked by shape instead, and refused rather than escaped. The output
/// is SQL text destined for a file a person reviews; that is a good
/// reason to be strict about it, not a reason to relax.
pub fn policy_statements(
table: &str,
scope_column: &str,
) -> Result<Vec<String>, PolicyPgError> {
let qualified = check_identifier(table)?;
let column = check_identifier(scope_column)?;
let bare = table.rsplit('.').next().unwrap_or(table).to_string();
let mut out = vec![
format!("ALTER TABLE {qualified} ENABLE ROW LEVEL SECURITY;"),
format!("ALTER TABLE {qualified} FORCE ROW LEVEL SECURITY;"),
];
for tier in [Tier::Read, Tier::Append, Tier::Update, Tier::Delete] {
let policy = policy_name(&bare, tier);
out.push(format!(
"DROP POLICY IF EXISTS {policy} ON {qualified};"
));
out.push(format!(
"{};",
create_policy_sql(&policy, &qualified, &column, tier)
));
}
Ok(out)
}
/// Accept an unquoted SQL identifier, optionally schema-qualified.
///
/// Deliberately narrow: lowercase ASCII, digits and underscore, not
/// starting with a digit, at most one dot. Anything else is refused
/// rather than quoted, because a name needing quotes in a generated
/// migration is a name worth a human deciding about.
fn check_identifier(name: &str) -> Result<String, PolicyPgError> {
let refuse = || PolicyPgError::UnknownRelation(name.to_string());
if name.is_empty() || name.len() > 128 || name.matches('.').count() > 1 {
return Err(refuse());
}
for part in name.split('.') {
let mut chars = part.chars();
match chars.next() {
Some(c) if c.is_ascii_lowercase() || c == '_' => {}
_ => return Err(refuse()),
}
if !chars.all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '_') {
return Err(refuse());
}
}
Ok(name.to_string())
}
/// The policy statement for one (table, verb).
///
/// `INSERT` takes `WITH CHECK` and no `USING`; `DELETE` and `SELECT`
/// take `USING` and no `WITH CHECK`; `UPDATE` takes both, because a row
/// may be moved out of a permitted scope as easily as into one. Getting
/// this wrong is the classic RLS hole — a policy that guards which rows
/// you may change but not what you may change them into.
fn create_policy_sql(policy: &str, table: &str, column: &str, tier: Tier) -> String {
let predicate = format!(
"trusty_policy.permits(current_setting('{SUBJECT_SETTING}', true), '{}', {column})",
tier_token(tier)
);
let verb = tier.sql_verb();
match tier {
Tier::Append => {
format!("CREATE POLICY {policy} ON {table} FOR {verb} WITH CHECK ({predicate})")
}
Tier::Update => format!(
"CREATE POLICY {policy} ON {table} FOR {verb} \
USING ({predicate}) WITH CHECK ({predicate})"
),
Tier::Read | Tier::Delete => {
format!("CREATE POLICY {policy} ON {table} FOR {verb} USING ({predicate})")
}
}
}
/// Deterministic policy name for a (table, verb), so re-running
/// [`GrantStore::enforce`] replaces rather than accumulates.
fn policy_name(table: &str, tier: Tier) -> String {
format!("trusty_{table}_{}", tier_token(tier))
}
/// Grant expiry crosses as seconds since the epoch and is turned into a
/// `timestamptz` by `to_timestamp` server-side.
///
/// `Grant.expires_at` is an `i64` of unix seconds; the column is
/// `TIMESTAMPTZ`. Converting in Rust would mean picking a timezone, and
/// the one thing worse than a wrong expiry is a wrong expiry that looks
/// right in one region.
fn expires_at_sql(expires_at: i64) -> f64 {
expires_at as f64
}
fn apply_schema(client: &mut Client) -> Result<(), PolicyPgError> {
let mut txn = client.transaction()?;
for stmt in DDL_V1 {
txn.batch_execute(stmt)?;
}
for stmt in DDL_FUNCTIONS {
txn.batch_execute(stmt)?;
}
txn.execute(DDL_SCHEMA_VERSION_UPSERT, &[&SCHEMA_VERSION])?;
txn.commit()?;
Ok(())
}
/// A transaction carrying the identity of whoever is acting.
///
/// Every policy predicate reads `current_setting('trusty.subject')`.
/// Outside a `PolicySession` that setting is unset, the predicate sees
/// NULL, and every policy refuses — which is the correct default: code
/// that forgets to say who it is gets nothing, rather than everything.
pub struct PolicySession<'a> {
txn: Transaction<'a>,
}
impl<'a> PolicySession<'a> {
/// Begin a transaction acting as `subject`.
pub fn begin(client: &'a mut Client, subject: &str) -> Result<Self, PolicyPgError> {
let mut txn = client.transaction()?;
// `true` is is_local: the setting dies with the transaction, so
// a pooled connection cannot carry one request's identity into
// the next.
txn.execute(
"SELECT set_config($1, $2, true)",
&[&SUBJECT_SETTING, &subject],
)?;
Ok(Self { txn })
}
pub fn txn(&mut self) -> &mut Transaction<'a> {
&mut self.txn
}
pub fn commit(self) -> Result<(), PolicyPgError> {
self.txn.commit()?;
Ok(())
}
pub fn rollback(self) -> Result<(), PolicyPgError> {
self.txn.rollback()?;
Ok(())
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn insert_takes_with_check_and_no_using() {
let sql = create_policy_sql("p", "t", "scope", Tier::Append);
assert!(
sql.contains("WITH CHECK") && !sql.contains("USING"),
"an INSERT policy has no existing row to test, so USING is not just \
redundant, it is rejected by Postgres: {sql}"
);
assert!(sql.contains("FOR INSERT"), "got {sql}");
}
#[test]
fn update_takes_both_using_and_with_check() {
let sql = create_policy_sql("p", "t", "scope", Tier::Update);
assert!(
sql.contains("USING") && sql.contains("WITH CHECK"),
"USING alone guards which rows may be changed but not what they may \
be changed INTO, so a row could be moved out of a permitted scope. \
That is the classic RLS hole: {sql}"
);
}
#[test]
fn read_and_delete_take_using_and_no_with_check() {
for tier in [Tier::Read, Tier::Delete] {
let sql = create_policy_sql("p", "t", "scope", tier);
assert!(
sql.contains("USING") && !sql.contains("WITH CHECK"),
"neither reading nor deleting produces a new row to check: {sql}"
);
}
}
#[test]
fn each_policy_asks_about_its_own_tier() {
for tier in [Tier::Read, Tier::Append, Tier::Update, Tier::Delete] {
let sql = create_policy_sql("p", "t", "scope", tier);
assert!(
sql.contains(&format!("'{}'", tier_token(tier))),
"a policy for one verb asking about another would let a grant \
to read authorise a delete: {sql}"
);
assert!(
sql.contains(&format!("FOR {}", tier.sql_verb())),
"the SQL verb and the tier token must stay in step: {sql}"
);
}
}
#[test]
fn the_predicate_reads_the_subject_from_the_transaction() {
let sql = create_policy_sql("p", "t", "scope", Tier::Read);
assert!(
sql.contains("current_setting('trusty.subject', true)"),
"the second argument must be `true` (missing_ok), or an unset \
subject raises instead of refusing, and an error is not a denial: \
{sql}"
);
}
#[test]
fn an_identifier_that_would_need_quoting_is_refused() {
for bad in [
"public.\"; DROP TABLE x; --",
"public.tab le",
"Public.Table",
"9lives",
"a.b.c",
"",
"tab'le",
] {
assert!(
check_identifier(bad).is_err(),
"a name needing quotes in a generated migration is a name worth a human deciding about, not one worth escaping: {bad:?}"
);
}
assert!(check_identifier("public.calendar_event").is_ok());
assert!(check_identifier("calendar").is_ok());
}
#[test]
fn generated_statements_force_row_level_security() {
let stmts = policy_statements("public.calendar", "resource_scope").expect("emit");
assert!(
stmts.iter().any(|s| s.contains("FORCE ROW LEVEL SECURITY")),
"ENABLE alone is bypassed by the table owner, so a migration emitting only ENABLE would read as protection and provide none"
);
assert_eq!(
stmts.len(),
2 + 4 * 2,
"two ALTERs, then a DROP and a CREATE for each of the four verbs"
);
}
#[test]
fn generated_statements_match_what_enforce_would_run() {
let generated = policy_statements("public.calendar", "resource_scope").expect("emit");
// enforce() builds the same CREATE POLICY text, minus the
// trailing semicolons a file needs. If these ever diverge, a
// migration and a live call would mean different things.
let direct = create_policy_sql(
&policy_name("calendar", Tier::Read),
"public.calendar",
"resource_scope",
Tier::Read,
);
assert!(
generated.iter().any(|s| s.trim_end_matches(';') == direct),
"the emitted migration and enforce() must produce the same policy, or the file a reviewer reads is not the rule the database gets"
);
}
#[test]
fn policy_names_are_deterministic_per_table_and_verb() {
assert_eq!(policy_name("calendar", Tier::Read), "trusty_calendar_read");
assert_ne!(
policy_name("calendar", Tier::Read),
policy_name("calendar", Tier::Delete),
"one name per (table, verb) is what makes enforce() converge on \
re-run instead of accumulating policies"
);
}
#[test]
fn a_grants_contents_never_appear_in_policy_sql() {
let sql = create_policy_sql("p", "t", "scope", Tier::Read);
for grant_shaped in ["board:9f2", "agent:01J", "user:01H"] {
assert!(
!sql.contains(grant_shaped),
"the policy is written once and knows nothing about any \
particular grant; if a grant's contents reached this string, \
a scope could become syntax: {sql}"
);
}
}
#[test]
fn the_subject_setting_is_transaction_local() {
// Guards the literal in PolicySession::begin. `false` here would
// leave the identity on a pooled connection after the request
// that set it had finished.
let src = include_str!("store.rs");
assert!(
src.contains("&[&SUBJECT_SETTING, &subject],"),
"set_config must be called with the constant, not an ad-hoc string"
);
assert!(
src.contains("\"SELECT set_config($1, $2, true)\""),
"is_local must be true, or a pooled connection carries one \
request's identity into the next"
);
}
}

View File

@@ -0,0 +1,883 @@
//! Behavioural tests against a real Postgres.
//!
//! Everything in the crate's unit tests asserts properties of SQL
//! *text*. These assert what the database actually does with it, which
//! is the only claim worth making about an authorisation boundary.
//!
//! Skipped, not failed, when `TRUSTY_POLICY_PG_URL` is unset, so
//! `cargo test --workspace` stays green on a machine with no database.
//! Same convention as `trusty-postgres`'s suite.
//!
//! ```text
//! TRUSTY_POLICY_PG_URL=postgresql://postgres:postgres@127.0.0.1:54322/postgres \
//! cargo test -p trusty-policy-postgres --test integration
//! ```
//!
//! **The tests act as a non-superuser role.** A superuser bypasses row
//! level security entirely, so a suite that ran as `postgres` would pass
//! against a table with no protection at all.
use postgres::Client;
use trusty_policy::{Grant, Limits, Tier};
use trusty_policy_postgres::{CompilationReport, GrantId, GrantStore, PolicyPgError};
fn url() -> Option<String> {
std::env::var("TRUSTY_POLICY_PG_URL").ok()
}
/// Serialises the suite against one database.
///
/// Every `GrantStore::open` applies the schema, and concurrent
/// `CREATE OR REPLACE FUNCTION` on the same function collides in
/// `pg_proc` with "tuple concurrently updated". The tests also create
/// and drop roles, which are cluster-global however carefully the table
/// names are scoped.
///
/// Taken here rather than left to `--test-threads=1`, because a suite
/// that is only green when invoked a particular way is one plain
/// `cargo test` away from looking broken.
fn db_lock() -> std::sync::MutexGuard<'static, ()> {
static LOCK: std::sync::OnceLock<std::sync::Mutex<()>> = std::sync::OnceLock::new();
LOCK.get_or_init(|| std::sync::Mutex::new(()))
.lock()
.unwrap_or_else(|poisoned| poisoned.into_inner())
}
/// Distinct names per test, so tests neither collide nor depend on
/// order.
struct Fixture {
table: String,
role: String,
}
impl Fixture {
fn new(tag: &str) -> Self {
Self {
table: format!("rls_probe_{tag}"),
role: format!("rls_role_{tag}"),
}
}
/// A protected table, a non-superuser role that can reach it, and no
/// grants at all.
fn set_up(&self, store: &mut GrantStore) -> Vec<String> {
let (table, role) = (&self.table, &self.role);
let drop_role = drop_role_sql(role);
store
.client()
.batch_execute(&format!(
"DROP TABLE IF EXISTS public.{table};
CREATE TABLE public.{table} (
id SERIAL PRIMARY KEY,
resource_scope TEXT NOT NULL,
note TEXT NOT NULL
);
INSERT INTO public.{table}(resource_scope, note) VALUES
('board:9f2/calendar/team', 'visible'),
('board:9f2/calendar/private', 'excluded'),
('board:other/calendar', 'someone else');
{drop_role}
CREATE ROLE {role} NOSUPERUSER NOBYPASSRLS;
-- SET ROLE requires membership. Supabase's `postgres` is
-- not a true superuser, so the connecting user has to be
-- granted the role it intends to act as.
GRANT {role} TO CURRENT_USER;
GRANT USAGE ON SCHEMA public TO {role};
GRANT SELECT, INSERT, UPDATE, DELETE ON public.{table} TO {role};
GRANT USAGE, SELECT ON SEQUENCE public.{table}_id_seq TO {role};
GRANT USAGE ON SCHEMA trusty_policy TO {role};"
))
.expect("fixture set-up");
store
.enforce(&format!("public.{table}"), "resource_scope")
.expect("enforce")
}
fn tear_down(&self, store: &mut GrantStore) {
let (table, role) = (&self.table, &self.role);
let drop_role = drop_role_sql(role);
let _ = store.client().batch_execute(&format!(
"DROP TABLE IF EXISTS public.{table};
{drop_role}"
));
let _ = store
.client()
.batch_execute("DELETE FROM trusty_policy.permission_grant");
}
/// Read the table the way the application would: as the unprivileged
/// role, with a subject set for the transaction only.
fn read_as(&self, client: &mut Client, subject: &str) -> Vec<String> {
let mut txn = client.transaction().expect("txn");
txn.execute(&format!("SET LOCAL ROLE {}", self.role), &[])
.expect("assume role");
txn.execute(
"SELECT set_config('trusty.subject', $1, true)",
&[&subject],
)
.expect("set subject");
let rows = txn
.query(
&format!("SELECT note FROM public.{} ORDER BY id", self.table),
&[],
)
.expect("select");
let notes = rows.iter().map(|r| r.get::<_, String>(0)).collect();
txn.rollback().expect("rollback");
notes
}
}
/// Drop a role only if it exists.
///
/// `DROP ROLE IF EXISTS` tolerates absence, but `DROP OWNED BY` does not
/// -- and the grants a role holds must go before the role can. So both
/// have to sit behind one existence check rather than relying on
/// IF EXISTS.
fn drop_role_sql(role: &str) -> String {
format!(
"DO $do$ BEGIN
IF EXISTS (SELECT 1 FROM pg_roles WHERE rolname = '{role}') THEN
-- In PG16+, ADMIN OPTION on a role is not the same as
-- holding its privileges, and DROP OWNED BY needs the
-- latter. Creating the role is not enough.
EXECUTE 'GRANT {role} TO CURRENT_USER';
EXECUTE 'DROP OWNED BY {role}';
EXECUTE 'DROP ROLE {role}';
END IF;
END $do$;"
)
}
/// Issue a grant, registering its principals first.
///
/// `permits()` refuses for a subject that is not a registered, live
/// principal — identity before permission — so a test that only issued
/// a grant would be testing the registration check rather than the rule
/// it names.
trait Registering {
fn issue_registered(
&mut self,
grant: &Grant,
) -> Result<(GrantId, CompilationReport), PolicyPgError>;
}
impl Registering for GrantStore {
fn issue_registered(
&mut self,
grant: &Grant,
) -> Result<(GrantId, CompilationReport), PolicyPgError> {
for who in [Some(&grant.subject), grant.acts_for.as_ref()].into_iter().flatten() {
let kind = who.split(':').next().unwrap_or("agent");
self.register_principal(who, kind, "")?;
}
self.issue(grant)
}
}
fn grant(subject: &str, scope: &str, tier: Tier) -> Grant {
Grant {
why: Some("integration fixture".into()),
subject: subject.into(),
acts_for: None,
scope: scope.into(),
tier,
excludes: Vec::new(),
limits: Limits::default(),
// Far future; the expiry test overrides this deliberately.
expires_at: 4_102_444_800,
}
}
// ---------------------------------------------------------------------
// The acceptance criterion.
// ---------------------------------------------------------------------
#[test]
fn a_read_the_grant_excludes_fails_at_the_database() {
let Some(url) = url() else {
eprintln!("skipped: TRUSTY_POLICY_PG_URL unset");
return;
};
let _db = db_lock();
let mut store = GrantStore::open(&url).expect("open");
let fx = Fixture::new("excl");
fx.set_up(&mut store);
let mut g = grant("agent:excl", "board:9f2/calendar", Tier::Read);
g.excludes = vec!["board:9f2/calendar/private".into()];
store.issue_registered(&g).expect("issue");
let seen = fx.read_as(store.client(), "agent:excl");
fx.tear_down(&mut store);
assert!(
!seen.contains(&"excluded".to_string()),
"an excluded resource must be unreadable AT THE DATABASE, not merely \
refused by an application check that could be bypassed; saw {seen:?}"
);
}
#[test]
fn an_exclusion_is_not_overridden_by_a_wider_grant() {
let Some(url) = url() else {
eprintln!("skipped: TRUSTY_POLICY_PG_URL unset");
return;
};
let _db = db_lock();
let mut store = GrantStore::open(&url).expect("open");
let fx = Fixture::new("wider");
fx.set_up(&mut store);
let mut narrow = grant("agent:wider", "board:9f2/calendar/team", Tier::Read);
narrow.excludes = vec!["board:9f2/calendar/private".into()];
store.issue_registered(&narrow).expect("issue narrow");
// Issued afterwards, covering strictly more, and carrying no
// exclusion of its own.
let wide = grant("agent:wider", "board:9f2", Tier::Read);
store.issue_registered(&wide).expect("issue wide");
let seen = fx.read_as(store.client(), "agent:wider");
fx.tear_down(&mut store);
assert!(
!seen.contains(&"excluded".to_string()),
"adding a wider grant later must not reopen what an exclusion shut; \
saw {seen:?}"
);
assert!(
seen.contains(&"visible".to_string()),
"the wider grant should still permit everything it covers that is not \
excluded; saw {seen:?}"
);
}
#[test]
fn absent_policy_is_not_permission() {
let Some(url) = url() else {
eprintln!("skipped: TRUSTY_POLICY_PG_URL unset");
return;
};
let _db = db_lock();
let mut store = GrantStore::open(&url).expect("open");
let fx = Fixture::new("deny");
fx.set_up(&mut store);
let with_no_grant = fx.read_as(store.client(), "agent:nobody");
let with_no_subject = fx.read_as(store.client(), "");
fx.tear_down(&mut store);
assert!(
with_no_grant.is_empty(),
"a subject with no grant must see nothing, not fall back to something \
wider; saw {with_no_grant:?}"
);
assert!(
with_no_subject.is_empty(),
"code that forgets to say who it is must get nothing rather than \
everything; saw {with_no_subject:?}"
);
}
#[test]
fn a_scope_containing_a_like_metacharacter_does_not_widen_itself() {
let Some(url) = url() else {
eprintln!("skipped: TRUSTY_POLICY_PG_URL unset");
return;
};
let _db = db_lock();
let mut store = GrantStore::open(&url).expect("open");
let fx = Fixture::new("meta");
fx.set_up(&mut store);
// Under LIKE semantics '%' matches everything and this would read
// the whole table.
store
.issue(&grant("agent:meta", "%", Tier::Read))
.expect("issue");
let wildcard = fx.read_as(store.client(), "agent:meta");
store
.issue(&grant("agent:meta2", "board:9f2/calendar/_eam", Tier::Read))
.expect("issue");
let underscore = fx.read_as(store.client(), "agent:meta2");
fx.tear_down(&mut store);
assert!(
wildcard.is_empty(),
"a scope of '%' is a scope literally named '%', not a wildcard; saw \
{wildcard:?}"
);
assert!(
underscore.is_empty(),
"'_' must not match an arbitrary character, or a near-miss scope would \
silently grant its neighbour; saw {underscore:?}"
);
}
#[test]
fn a_sibling_whose_name_merely_starts_the_same_is_not_covered() {
let Some(url) = url() else {
eprintln!("skipped: TRUSTY_POLICY_PG_URL unset");
return;
};
let _db = db_lock();
let mut store = GrantStore::open(&url).expect("open");
let fx = Fixture::new("sib");
fx.set_up(&mut store);
// 'board:9f2/calendar' must NOT cover 'board:9f2/calendar/team' via a
// bare prefix test that ignores the separator -- it does cover it
// here, legitimately -- but 'board:o' must not cover 'board:other'.
store
.issue(&grant("agent:sib", "board:o", Tier::Read))
.expect("issue");
let seen = fx.read_as(store.client(), "agent:sib");
fx.tear_down(&mut store);
assert!(
seen.is_empty(),
"containment must break on a path separator; without it, 'board:o' \
would grant 'board:other/calendar'; saw {seen:?}"
);
}
#[test]
fn an_expired_grant_stops_permitting_at_the_wall_clock() {
let Some(url) = url() else {
eprintln!("skipped: TRUSTY_POLICY_PG_URL unset");
return;
};
let _db = db_lock();
let mut store = GrantStore::open(&url).expect("open");
let fx = Fixture::new("exp");
fx.set_up(&mut store);
let mut g = grant("agent:exp", "board:9f2/calendar", Tier::Read);
g.expires_at = 1_000_000_000; // 2001, comfortably past
store.issue_registered(&g).expect("issue");
let seen = fx.read_as(store.client(), "agent:exp");
fx.tear_down(&mut store);
assert!(
seen.is_empty(),
"expiry is wall clock and independent of whether work finished; saw \
{seen:?}"
);
}
#[test]
fn revoking_a_grant_takes_effect_without_ddl() {
let Some(url) = url() else {
eprintln!("skipped: TRUSTY_POLICY_PG_URL unset");
return;
};
let _db = db_lock();
let mut store = GrantStore::open(&url).expect("open");
let fx = Fixture::new("rev");
fx.set_up(&mut store);
let g = grant("agent:rev", "board:9f2/calendar", Tier::Read);
let (id, _) = store.issue_registered(&g).expect("issue");
let before = fx.read_as(store.client(), "agent:rev");
let revoked = store.revoke(&id).expect("revoke");
let after = fx.read_as(store.client(), "agent:rev");
let twice = store.revoke(&id).expect("revoke again");
fx.tear_down(&mut store);
assert!(
!before.is_empty(),
"the grant must permit something before revocation, or this test \
proves nothing"
);
assert!(revoked, "revoking a live grant must report that it did so");
assert!(
after.is_empty(),
"revocation must take effect at the next decision, with no DDL run; \
saw {after:?}"
);
assert!(
!twice,
"revoking what is already revoked must report that nothing changed, \
rather than reassuring the caller"
);
}
#[test]
fn an_agent_cannot_exceed_the_principal_it_acts_for() {
let Some(url) = url() else {
eprintln!("skipped: TRUSTY_POLICY_PG_URL unset");
return;
};
let _db = db_lock();
let mut store = GrantStore::open(&url).expect("open");
let fx = Fixture::new("deleg");
fx.set_up(&mut store);
store
.client()
.batch_execute(
"INSERT INTO trusty_policy.principal(id, kind) VALUES
('agent:d1', 'agent'), ('user:d1', 'user')
ON CONFLICT DO NOTHING;
INSERT INTO trusty_policy.delegation(agent_id, acts_for_id) VALUES
('agent:d1', 'user:d1')
ON CONFLICT DO NOTHING;",
)
.expect("principals");
// The agent's own grant is wider than the principal's.
let mut wide = grant("agent:d1", "board:9f2", Tier::Read);
wide.acts_for = Some("user:d1".into());
store.issue_registered(&wide).expect("issue agent");
store
.issue(&grant("user:d1", "board:9f2/calendar/team", Tier::Read))
.expect("issue principal");
let seen = fx.read_as(store.client(), "agent:d1");
// Now revoke the delegation and try again.
store
.client()
.batch_execute(
"UPDATE trusty_policy.delegation SET revoked_at = now()
WHERE agent_id = 'agent:d1'",
)
.expect("revoke delegation");
let after_revoke = fx.read_as(store.client(), "agent:d1");
store
.client()
.batch_execute("DELETE FROM trusty_policy.delegation; DELETE FROM trusty_policy.principal")
.expect("clean principals");
fx.tear_down(&mut store);
assert_eq!(
seen,
vec!["visible".to_string()],
"the agent must be held to the intersection: its own grant covers \
board:9f2 entirely, but the principal only permits the team \
calendar; saw {seen:?}"
);
assert!(
after_revoke.is_empty(),
"revoking the delegation must stop the agent acting at all, without \
touching either grant; saw {after_revoke:?}"
);
}
#[test]
fn a_grant_for_one_verb_does_not_authorise_another() {
let Some(url) = url() else {
eprintln!("skipped: TRUSTY_POLICY_PG_URL unset");
return;
};
let _db = db_lock();
let mut store = GrantStore::open(&url).expect("open");
let fx = Fixture::new("verb");
fx.set_up(&mut store);
// Delete is the most destructive tier. If tiers implied one another
// by ordering, this would also confer read.
store
.issue(&grant("agent:verb", "board:9f2/calendar", Tier::Delete))
.expect("issue");
let seen = fx.read_as(store.client(), "agent:verb");
fx.tear_down(&mut store);
assert!(
seen.is_empty(),
"tier maps 1:1 onto the SQL verbs and deny-by-default governs the rest, \
so a grant to delete must not silently confer the ability to read \
everything deletable; saw {seen:?}"
);
}
#[test]
fn enforce_is_idempotent_and_forces_row_level_security() {
let Some(url) = url() else {
eprintln!("skipped: TRUSTY_POLICY_PG_URL unset");
return;
};
let _db = db_lock();
let mut store = GrantStore::open(&url).expect("open");
let fx = Fixture::new("force");
let first = fx.set_up(&mut store);
// Re-running must converge, not fail on an existing policy.
let second = store
.enforce(&format!("public.{}", fx.table), "resource_scope")
.expect("second enforce");
let forced: bool = store
.client()
.query_one(
"SELECT relforcerowsecurity FROM pg_class WHERE oid = to_regclass($1)",
&[&format!("public.{}", fx.table)],
)
.expect("pg_class")
.get(0);
let policies: i64 = store
.client()
.query_one(
"SELECT count(*) FROM pg_policies
WHERE schemaname = 'public' AND tablename = $1",
&[&fx.table],
)
.expect("pg_policies")
.get(0);
fx.tear_down(&mut store);
assert_eq!(first, second, "enforce must be deterministic across runs");
assert!(
forced,
"without FORCE, the table owner bypasses every policy and the \
protection is decorative"
);
assert_eq!(
policies, 4,
"one policy per verb, replaced rather than accumulated on re-run"
);
}
#[test]
fn an_unknown_relation_is_refused_rather_than_quoted() {
let Some(url) = url() else {
eprintln!("skipped: TRUSTY_POLICY_PG_URL unset");
return;
};
let _db = db_lock();
let mut store = GrantStore::open(&url).expect("open");
assert!(
store.enforce("public.no_such_table_here", "resource_scope").is_err(),
"enabling row level security on a table that does not exist would \
silently protect nothing"
);
assert!(
store.enforce("public.no_such_table_here", "'; DROP TABLE x; --").is_err(),
"an identifier that does not resolve must be refused, never escaped \
and used"
);
}
#[test]
fn issuing_the_same_grant_twice_leaves_one_row() {
let Some(url) = url() else {
eprintln!("skipped: TRUSTY_POLICY_PG_URL unset");
return;
};
let _db = db_lock();
let mut store = GrantStore::open(&url).expect("open");
let g = grant("agent:idem", "board:9f2/calendar", Tier::Read);
let (first, report) = store.issue_registered(&g).expect("issue");
let (second, _) = store.issue_registered(&g).expect("issue again");
let count: i64 = store
.client()
.query_one(
"SELECT count(*) FROM trusty_policy.permission_grant WHERE subject = $1",
&[&"agent:idem"],
)
.expect("count")
.get(0);
let _ = store
.client()
.batch_execute("DELETE FROM trusty_policy.permission_grant");
assert_eq!(first, second, "identity is content, so it must not vary");
assert_eq!(
count, 1,
"a content-addressed id makes re-issuing idempotent rather than \
quietly accumulating duplicate permissions"
);
assert!(
report.is_total(),
"this grant sets no limits, so nothing about it went unenforced"
);
}
#[test]
fn the_stored_canonical_form_reproduces_the_id() {
let Some(url) = url() else {
eprintln!("skipped: TRUSTY_POLICY_PG_URL unset");
return;
};
let _db = db_lock();
let mut store = GrantStore::open(&url).expect("open");
let g = grant("agent:canon", "board:9f2/calendar", Tier::Read);
let (id, _) = store.issue_registered(&g).expect("issue");
let stored: String = store
.client()
.query_one(
"SELECT canonical_json FROM trusty_policy.permission_grant WHERE id = $1",
&[&id.as_str()],
)
.expect("select")
.get(0);
let _ = store
.client()
.batch_execute("DELETE FROM trusty_policy.permission_grant");
assert_eq!(
GrantId::of_canonical(&stored),
id,
"the bytes in the row must be the bytes that were hashed, or a \
verifier holding the signed grant could not find or check its row"
);
}
#[test]
fn a_registered_principal_with_no_grant_still_sees_nothing() {
let Some(url) = url() else {
eprintln!("skipped: TRUSTY_POLICY_PG_URL unset");
return;
};
let _db = db_lock();
let mut store = GrantStore::open(&url).expect("open");
let fx = Fixture::new("reg");
fx.set_up(&mut store);
// Registration is identity, not permission. Without this test,
// `absent_policy_is_not_permission` would pass merely because the
// subject was unregistered, and the deny-by-default rule it is named
// for would go untested.
store
.register_principal("agent:registered", "agent", "has an identity, no grant")
.expect("register");
let seen = fx.read_as(store.client(), "agent:registered");
fx.tear_down(&mut store);
assert!(
seen.is_empty(),
"being someone is not the same as being allowed something; saw {seen:?}"
);
}
#[test]
fn an_unregistered_principal_refuses_even_holding_a_grant() {
let Some(url) = url() else {
eprintln!("skipped: TRUSTY_POLICY_PG_URL unset");
return;
};
let _db = db_lock();
let mut store = GrantStore::open(&url).expect("open");
let fx = Fixture::new("unreg");
fx.set_up(&mut store);
// Issued WITHOUT registering: a grant to nobody.
store
.issue(&grant("agent:ghost", "board:9f2/calendar", Tier::Read))
.expect("issue");
let seen = fx.read_as(store.client(), "agent:ghost");
fx.tear_down(&mut store);
assert!(
seen.is_empty(),
"a grant held by a principal who was never registered is a grant to \
nobody, and absent policy is not permission; saw {seen:?}"
);
}
#[test]
fn revoking_a_principal_stops_every_grant_it_holds_at_once() {
let Some(url) = url() else {
eprintln!("skipped: TRUSTY_POLICY_PG_URL unset");
return;
};
let _db = db_lock();
let mut store = GrantStore::open(&url).expect("open");
let fx = Fixture::new("prev");
fx.set_up(&mut store);
// Two separate grants, so this tests the blunt instrument rather
// than a single revocation wearing a different hat.
store
.issue_registered(&grant("agent:doomed", "board:9f2/calendar", Tier::Read))
.expect("issue one");
store
.issue_registered(&grant("agent:doomed", "board:other", Tier::Read))
.expect("issue two");
let before = fx.read_as(store.client(), "agent:doomed");
let revoked = store.revoke_principal("agent:doomed").expect("revoke");
let after = fx.read_as(store.client(), "agent:doomed");
let twice = store.revoke_principal("agent:doomed").expect("revoke again");
let survivors: i64 = store
.client()
.query_one(
"SELECT count(*) FROM trusty_policy.permission_grant WHERE subject = $1",
&[&"agent:doomed"],
)
.expect("count")
.get(0);
fx.tear_down(&mut store);
assert_eq!(
before.len(),
3,
"both grants must permit something first, or this proves nothing; \
saw {before:?}"
);
assert!(revoked, "revoking a live principal must report that it did so");
assert!(
after.is_empty(),
"killing the key must end the agent's authority entirely, not grant \
by grant -- revoking them one at a time races whoever is using them; \
saw {after:?}"
);
assert!(!twice, "revoking what is already revoked must report no change");
assert_eq!(
survivors, 2,
"the grant rows must survive revocation; an audit asks what was \
permitted last March and a deleted row cannot answer"
);
}
#[test]
fn revoking_an_agent_leaves_the_principal_it_acted_for_untouched() {
let Some(url) = url() else {
eprintln!("skipped: TRUSTY_POLICY_PG_URL unset");
return;
};
let _db = db_lock();
let mut store = GrantStore::open(&url).expect("open");
let fx = Fixture::new("sever");
fx.set_up(&mut store);
let mut agent = grant("agent:s1", "board:9f2", Tier::Read);
agent.acts_for = Some("user:s1".into());
store.issue_registered(&agent).expect("issue agent");
store
.issue_registered(&grant("user:s1", "board:9f2/calendar/team", Tier::Read))
.expect("issue principal");
store.delegate("agent:s1", "user:s1").expect("delegate");
let agent_before = fx.read_as(store.client(), "agent:s1");
store.revoke_principal("agent:s1").expect("revoke agent");
let agent_after = fx.read_as(store.client(), "agent:s1");
let principal_after = fx.read_as(store.client(), "user:s1");
store
.client()
.batch_execute("DELETE FROM trusty_policy.delegation")
.expect("clean");
fx.tear_down(&mut store);
assert!(!agent_before.is_empty(), "the agent must start able to act");
assert!(
agent_after.is_empty(),
"killing the agent's key must end its authority; saw {agent_after:?}"
);
assert_eq!(
principal_after,
vec!["visible".to_string()],
"and the person behind it must be untouched -- that asymmetry is the \
entire point of an agent never being a principal in its own right; \
saw {principal_after:?}"
);
}
#[test]
fn the_key_edge_records_a_reference_and_not_a_second_registry() {
let Some(url) = url() else {
eprintln!("skipped: TRUSTY_POLICY_PG_URL unset");
return;
};
let _db = db_lock();
let mut store = GrantStore::open(&url).expect("open");
store
.register_principal("agent:keyed", "agent", "")
.expect("register");
assert_eq!(
store.producer_oid_of("agent:keyed").expect("lookup"),
None,
"an unbound principal has no producer, and None is not the same as \
a producer named empty string"
);
store
.bind_key("agent:keyed", "1.3.6.1.4.1.99999.1")
.expect("bind");
let bound = store.producer_oid_of("agent:keyed").expect("lookup");
let unknown = store.bind_key("agent:missing", "1.3.6.1.4.1.99999.2");
let _ = store
.client()
.batch_execute("DELETE FROM trusty_policy.principal WHERE id = 'agent:keyed'");
assert_eq!(
bound.as_deref(),
Some("1.3.6.1.4.1.99999.1"),
"the edge must round-trip; resolving it to a public key is the \
producer registry's job, not this crate's"
);
assert!(
unknown.is_err(),
"a key bound to a principal that does not exist is a key nothing can \
revoke, so binding it must fail rather than create a dangling edge"
);
}
#[test]
fn a_delegation_must_be_recorded_before_an_agent_can_use_it() {
let Some(url) = url() else {
eprintln!("skipped: TRUSTY_POLICY_PG_URL unset");
return;
};
let _db = db_lock();
let mut store = GrantStore::open(&url).expect("open");
let fx = Fixture::new("edge");
fx.set_up(&mut store);
let mut agent = grant("agent:e1", "board:9f2/calendar", Tier::Read);
agent.acts_for = Some("user:e1".into());
store.issue_registered(&agent).expect("issue agent");
store
.issue_registered(&grant("user:e1", "board:9f2/calendar", Tier::Read))
.expect("issue principal");
// Both grants exist and agree. The edge does not.
let without_edge = fx.read_as(store.client(), "agent:e1");
store.delegate("agent:e1", "user:e1").expect("delegate");
let with_edge = fx.read_as(store.client(), "agent:e1");
store
.revoke_delegation("agent:e1", "user:e1")
.expect("revoke delegation");
let after_revoke = fx.read_as(store.client(), "agent:e1");
store
.client()
.batch_execute("DELETE FROM trusty_policy.delegation")
.expect("clean");
fx.tear_down(&mut store);
assert!(
without_edge.is_empty(),
"two matching grants are not a delegation; the authority to act for \
someone has to be recorded, or it is merely claimed; saw \
{without_edge:?}"
);
assert!(
!with_edge.is_empty(),
"with the edge recorded, the intersection should permit; saw {with_edge:?}"
);
assert!(
after_revoke.is_empty(),
"and withdrawing it must take effect without touching either grant; \
saw {after_revoke:?}"
);
}

View File

@@ -0,0 +1,27 @@
[package]
name = "trusty-postgres"
version.workspace = true
edition.workspace = true
authors.workspace = true
license.workspace = true
repository.workspace = true
rust-version.workspace = true
description = "PostgreSQL adapter for Trusty. Three-table schema mirroring trusty-sqlite with optional TimescaleDB feature, OID-anchored producer identity, and CHECKPOINT verification primitive."
[dependencies]
trusty-core = { workspace = true }
trusty-store = { workspace = true }
postgres = { workspace = true }
serde = { workspace = true }
serde_json = { workspace = true }
chrono = { workspace = true }
thiserror = { workspace = true }
tracing = { workspace = true }
hex = { workspace = true }
[dev-dependencies]
trusty-store = { workspace = true, features = ["testing"] }
[features]
default = []
timescale = []

View File

@@ -0,0 +1,39 @@
use thiserror::Error;
use trusty_store::StoreError;
#[derive(Debug, Error)]
pub enum PostgresError {
#[error("postgres driver error: {0}")]
Driver(#[from] postgres::Error),
#[error("serialization error: {0}")]
Serialization(#[from] serde_json::Error),
#[error("malformed chain_id: {0}")]
MalformedChainId(String),
#[error("schema migration error: {0}")]
Migration(String),
#[error("integrity violation: {0}")]
IntegrityViolation(String),
#[error("not found: {0}")]
NotFound(String),
#[error("trusty-core error: {0}")]
Core(#[from] trusty_core::TrustyError),
}
pub type PostgresResult<T> = Result<T, PostgresError>;
impl From<PostgresError> for StoreError {
fn from(err: PostgresError) -> Self {
match err {
PostgresError::NotFound(s) => StoreError::NotFound(s),
PostgresError::IntegrityViolation(s) => StoreError::IntegrityViolation(s),
PostgresError::Serialization(e) => StoreError::Serialization(e.to_string()),
other => StoreError::Backend(other.to_string()),
}
}
}

View File

@@ -0,0 +1,31 @@
//! PostgreSQL adapter for Trusty.
//!
//! Implements the `trusty_store::Store` trait against PostgreSQL 14+ using
//! the synchronous `postgres` crate. Mirrors the three-table schema of
//! `trusty-sqlite` (`provenance_envelope`, `hash_index`, `chain_tips`) with
//! Postgres dialect and adds:
//! - `chain_checkpoint` table for verification-layer checkpoints (#13).
//! - `producer_identity` table for OID-anchored producer identity (#14, #22).
//! - `schema_meta` table for planned schema migration versioning.
//! - Optional TimescaleDB hypertable on `received_at` via `timescale` feature.
//!
//! Design memo: `docs/trusty-postgres-design.md`.
//!
//! TCB note: the trusted computing base added by this adapter is Postgres +
//! libpq + the `postgres` Rust driver. Trusty chain math is backend-agnostic;
//! v1 byte-shape is preserved by copying `raw_payload` verbatim as `TEXT`
//! (never `JSONB`, which would normalize whitespace and key order).
#![deny(clippy::all)]
pub mod error;
pub mod schema;
pub mod store;
pub mod trusty;
pub use error::{PostgresError, PostgresResult};
pub use store::TrustyPostgres;
pub use trusty::{
AnchorInfo, AppendResult, BreakEvent, CheckpointRow, HistoryRow, IntegrityMismatch,
IntegrityReport, ProducerIdentity, RevocationRecord, RowSpec, SilentEntity, Trusty,
};

View File

@@ -0,0 +1,144 @@
//! Schema DDL for trusty-postgres.
//!
//! Mirrors the three-table layout of trusty-sqlite (provenance_envelope,
//! hash_index, chain_tips) in Postgres dialect. Adds chain_checkpoint,
//! producer_identity, and schema_meta. See docs/trusty-postgres-design.md.
//!
//! raw_payload MUST be TEXT, never JSONB -- JSONB normalizes whitespace
//! and key order, which would break v1 byte-shape verification.
pub const SCHEMA_VERSION: &str = "1";
pub const DDL_V1: &[&str] = &[
// ----------------------------------------------------------------
// Core three-table schema (mirrors trusty-sqlite).
// ----------------------------------------------------------------
r#"CREATE TABLE IF NOT EXISTS provenance_envelope (
id BIGINT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
chain_id TEXT NOT NULL,
entity_id TEXT NOT NULL,
event_type TEXT NOT NULL,
field_name TEXT,
old_value TEXT,
new_value TEXT,
source_url TEXT NOT NULL DEFAULT '',
detected_at TEXT NOT NULL,
received_at TEXT NOT NULL,
extraction_method TEXT NOT NULL DEFAULT 'trusty',
prev_hash TEXT NOT NULL,
raw_payload TEXT,
row_hash TEXT,
anchor_hash TEXT,
sequence_int BIGINT NOT NULL DEFAULT 0
)"#,
r#"CREATE INDEX IF NOT EXISTS idx_pe_entity ON provenance_envelope(entity_id)"#,
r#"CREATE INDEX IF NOT EXISTS idx_pe_chain ON provenance_envelope(chain_id)"#,
r#"CREATE INDEX IF NOT EXISTS idx_pe_entity_chain ON provenance_envelope(entity_id, chain_id)"#,
r#"CREATE INDEX IF NOT EXISTS idx_pe_row_hash ON provenance_envelope(row_hash)"#,
// Partial index accelerates breaks_since on Postgres (SQLite scans).
r#"CREATE INDEX IF NOT EXISTS idx_pe_break ON provenance_envelope(entity_id, detected_at)
WHERE event_type = 'BREAK'"#,
r#"CREATE TABLE IF NOT EXISTS hash_index (
id BIGINT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
row_hash TEXT NOT NULL UNIQUE,
chain_id TEXT NOT NULL,
sequence_int BIGINT NOT NULL,
entity_id TEXT NOT NULL,
recorded_at TEXT NOT NULL
)"#,
r#"CREATE INDEX IF NOT EXISTS idx_hi_entity ON hash_index(entity_id)"#,
r#"CREATE INDEX IF NOT EXISTS idx_hi_chain ON hash_index(chain_id)"#,
r#"CREATE TABLE IF NOT EXISTS chain_tips (
entity_id TEXT PRIMARY KEY,
current_tip_hash TEXT,
chain_id TEXT,
tip_state TEXT NOT NULL DEFAULT 'WARM'
CHECK (tip_state IN ('UNKNOWN','COLD','WARM')),
updated_at TEXT NOT NULL
)"#,
r#"CREATE INDEX IF NOT EXISTS idx_ct_chain ON chain_tips(chain_id)"#,
// ----------------------------------------------------------------
// Producer identity (#14, #22) -- OID-anchored. Nullable in the
// sense that rows are optional; table always exists.
// ----------------------------------------------------------------
r#"CREATE TABLE IF NOT EXISTS producer_identity (
producer_oid TEXT PRIMARY KEY,
-- Key material in the format `key_format` declares. Named for
-- the raw Ed25519 case it was built for; a COSE credential from
-- a WebAuthn authenticator lives here too, and key_format is
-- what says which. Renaming the column would break every
-- existing installation for a cosmetic gain.
ed25519_pubkey BYTEA NOT NULL,
siphash_key BYTEA NOT NULL,
registered_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
display_name TEXT,
metadata JSONB,
-- How ed25519_pubkey is encoded, and therefore how a signature
-- from it must be checked. A user enrolling through WebAuthn
-- registers a COSE key whose signature covers
-- authenticatorData || SHA-256(clientDataJSON), NOT the message,
-- so it cannot be checked as a bare signature. One column and
-- one verifier made that mistake available.
key_format TEXT NOT NULL DEFAULT 'ed25519-raw'
CHECK (key_format IN ('ed25519-raw','cose'))
)"#,
// Existing installations predate the column; CREATE TABLE IF NOT
// EXISTS does nothing for them, so it needs saying twice.
r#"ALTER TABLE producer_identity
ADD COLUMN IF NOT EXISTS key_format TEXT NOT NULL DEFAULT 'ed25519-raw'"#,
// ----------------------------------------------------------------
// Producer revocation (memo §6 / learn-LCG-ARCH-06) -- forward-
// only revocation per the Cert-Transparency stance: rows signed
// BEFORE revoked_at remain verifiable; rows signed AT or AFTER
// are rejected. The revocation event itself is also written as a
// chain envelope (event_type='REVOCATION') so the act is part of
// the audit chain. revocation_row_hash references that envelope.
// ----------------------------------------------------------------
r#"CREATE TABLE IF NOT EXISTS producer_revocation (
producer_oid TEXT PRIMARY KEY,
revoked_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
revoked_pubkey BYTEA NOT NULL,
reason TEXT NOT NULL,
attested_by_pubkey BYTEA NOT NULL,
revocation_row_hash TEXT NOT NULL
)"#,
// ----------------------------------------------------------------
// Checkpoints (#13) -- verification-layer only; no row deletion.
// ----------------------------------------------------------------
r#"CREATE TABLE IF NOT EXISTS chain_checkpoint (
id BIGINT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
chain_id TEXT NOT NULL,
sequence_int BIGINT NOT NULL,
row_hash TEXT NOT NULL,
checkpoint_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
attested_by TEXT NOT NULL,
attestation_sig BYTEA NOT NULL,
UNIQUE (chain_id, sequence_int)
)"#,
r#"CREATE INDEX IF NOT EXISTS idx_ckpt_chain ON chain_checkpoint(chain_id)"#,
// ----------------------------------------------------------------
// Schema meta (planned migration versioning).
// ----------------------------------------------------------------
r#"CREATE TABLE IF NOT EXISTS schema_meta (
key TEXT PRIMARY KEY,
value TEXT NOT NULL
)"#,
];
/// Upserts the schema_version row. Runs on every open to declare the
/// version this build expects.
pub const DDL_SCHEMA_VERSION_UPSERT: &str = r#"
INSERT INTO schema_meta(key, value) VALUES ('schema_version', $1)
ON CONFLICT (key) DO UPDATE SET value = EXCLUDED.value
"#;
/// TimescaleDB hypertable creation (guarded by the `timescale` feature
/// flag at the call site). Uses `if_not_exists` so it is idempotent.
#[cfg(feature = "timescale")]
pub const DDL_TIMESCALE_HYPERTABLE: &str = r#"
SELECT create_hypertable(
'provenance_envelope', 'received_at',
chunk_time_interval => INTERVAL '7 days',
if_not_exists => TRUE
)
"#;

View File

@@ -0,0 +1,463 @@
//! `Store` trait implementation for PostgreSQL.
//!
//! Uses the synchronous `postgres` crate (libpq-backed, not tokio-postgres)
//! per the `with_txn` sync-trait decision in docs/trusty-postgres-design.md
//! section 7. Advisory locks serialize per-entity appends; different entities
//! run concurrently.
//!
//! Interior mutability: the `postgres` crate's `Client::query*` methods
//! require `&mut Client`, but the `Store` trait exposes several read-only
//! methods (`last_row`, `iter_chain`, `get_tip`, `get_tip_for_entity`,
//! `kv_cross_check`) that take `&self`. We wrap `Client` in `RefCell` to
//! reconcile. This matches how `rusqlite::Connection` provides `&self`
//! query APIs internally; we just make the bookkeeping explicit.
//! `RefCell<Client>` is `Send` (not `Sync`), which satisfies the
//! `Store: Send` bound.
use std::cell::RefCell;
use chrono::SecondsFormat;
use postgres::{Client, NoTls, Transaction};
use tracing::instrument;
use trusty_store::{
KvCrossCheckReport, LastRow, RowInsert, Store, StoreError, StoreResult, StoredRow, TipRow,
TipUpsert,
};
use crate::error::PostgresError;
use crate::schema::{DDL_SCHEMA_VERSION_UPSERT, DDL_V1, SCHEMA_VERSION};
pub struct TrustyPostgres {
client: RefCell<Client>,
}
impl TrustyPostgres {
/// Open a connection and apply the v1 schema idempotently. Accepts any
/// standard libpq connection string, e.g.
/// `host=localhost user=postgres dbname=trusty password=...`.
pub fn open(conn_str: &str) -> Result<Self, PostgresError> {
let mut client = Client::connect(conn_str, NoTls)?;
apply_schema(&mut client)?;
Ok(Self {
client: RefCell::new(client),
})
}
}
fn apply_schema(client: &mut Client) -> Result<(), PostgresError> {
let mut txn = client.transaction()?;
for stmt in DDL_V1 {
txn.batch_execute(stmt)?;
}
txn.execute(DDL_SCHEMA_VERSION_UPSERT, &[&SCHEMA_VERSION])?;
txn.commit()?;
#[cfg(feature = "timescale")]
{
use crate::schema::DDL_TIMESCALE_HYPERTABLE;
client.batch_execute(DDL_TIMESCALE_HYPERTABLE)?;
}
Ok(())
}
fn now_iso_micros() -> String {
chrono::Utc::now().to_rfc3339_opts(SecondsFormat::Micros, true)
}
fn backend<E: std::fmt::Display>(e: E) -> StoreError {
StoreError::Backend(e.to_string())
}
// ------------------------------------------------------------
// Row mapping helpers
// ------------------------------------------------------------
fn row_to_last(row: &postgres::Row) -> LastRow {
LastRow {
row_hash: row.get::<_, String>(0),
sequence: row.get::<_, i64>(1),
}
}
fn row_to_tip(row: &postgres::Row) -> TipRow {
TipRow {
entity_id: row.get::<_, String>(0),
chain_id: row.get::<_, Option<String>>(1).unwrap_or_default(),
current_tip_hash: row.get::<_, Option<String>>(2),
updated_at: row.get::<_, String>(3),
}
}
fn row_to_stored(row: &postgres::Row) -> StoredRow {
StoredRow {
id: row.get::<_, i64>(0),
entity_id: row.get::<_, String>(1),
chain_id: row.get::<_, String>(2),
event_type: row.get::<_, String>(3),
sequence: row.get::<_, i64>(4),
prev_hash: row.get::<_, String>(5),
row_hash: row.get::<_, Option<String>>(6),
anchor_hash: row.get::<_, Option<String>>(7),
raw_payload: row.get::<_, Option<String>>(8),
detected_at: row.get::<_, String>(9),
received_at: row.get::<_, String>(10),
source_url: row.get::<_, String>(11),
extraction_method: row.get::<_, String>(12),
}
}
const SQL_SELECT_LAST_ROW: &str = "
SELECT row_hash, sequence_int FROM provenance_envelope
WHERE entity_id = $1 AND event_type != 'BREAK' AND row_hash IS NOT NULL
ORDER BY id DESC LIMIT 1";
const SQL_SELECT_CHAIN: &str = "
SELECT id, entity_id, chain_id, event_type, sequence_int, prev_hash,
row_hash, anchor_hash, raw_payload, detected_at, received_at,
source_url, extraction_method
FROM provenance_envelope
WHERE entity_id = $1
ORDER BY id ASC";
const SQL_SELECT_TIP_BY_CHAIN: &str = "
SELECT entity_id, chain_id, current_tip_hash, updated_at
FROM chain_tips WHERE chain_id = $1";
const SQL_SELECT_TIP_BY_ENTITY: &str = "
SELECT entity_id, chain_id, current_tip_hash, updated_at
FROM chain_tips WHERE entity_id = $1";
const SQL_INSERT_ROW: &str = "
INSERT INTO provenance_envelope (
chain_id, entity_id, event_type, source_url, detected_at,
received_at, extraction_method, prev_hash, raw_payload,
row_hash, anchor_hash, sequence_int
) VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12)
RETURNING id";
const SQL_INSERT_HASH_INDEX: &str = "
INSERT INTO hash_index (row_hash, chain_id, sequence_int, entity_id, recorded_at)
VALUES ($1,$2,$3,$4,$5)
ON CONFLICT (row_hash) DO NOTHING";
const SQL_UPSERT_TIP: &str = "
INSERT INTO chain_tips (entity_id, current_tip_hash, chain_id, updated_at)
VALUES ($1, $2, $3, $4)
ON CONFLICT (entity_id) DO UPDATE SET
current_tip_hash = EXCLUDED.current_tip_hash,
chain_id = EXCLUDED.chain_id,
updated_at = EXCLUDED.updated_at";
// ------------------------------------------------------------
// Store trait impl
// ------------------------------------------------------------
impl Store for TrustyPostgres {
#[instrument(skip(self), fields(entity_id = %entity_id))]
fn last_row(&self, entity_id: &str) -> StoreResult<Option<LastRow>> {
let mut client = self.client.borrow_mut();
let row = client
.query_opt(SQL_SELECT_LAST_ROW, &[&entity_id])
.map_err(backend)?;
Ok(row.as_ref().map(row_to_last))
}
#[instrument(skip(self, row), fields(entity_id = %row.entity_id, seq = row.sequence))]
fn insert_row(&mut self, row: &RowInsert) -> StoreResult<i64> {
let client = self.client.get_mut();
let id: i64 = client
.query_one(
SQL_INSERT_ROW,
&[
&row.chain_id,
&row.entity_id,
&row.event_type,
&row.source_url,
&row.detected_at,
&row.received_at,
&row.extraction_method,
&row.prev_hash,
&row.raw_payload,
&row.row_hash,
&row.anchor_hash,
&row.sequence,
],
)
.map_err(backend)?
.get(0);
if let Some(rh) = &row.row_hash {
client
.execute(
SQL_INSERT_HASH_INDEX,
&[
rh,
&row.chain_id,
&row.sequence,
&row.entity_id,
&row.received_at,
],
)
.map_err(backend)?;
}
Ok(id)
}
fn iter_chain<'a>(
&'a self,
entity_id: &str,
) -> StoreResult<Box<dyn Iterator<Item = StoreResult<StoredRow>> + 'a>> {
let mut client = self.client.borrow_mut();
let rows = client
.query(SQL_SELECT_CHAIN, &[&entity_id])
.map_err(backend)?;
let mapped: Vec<StoreResult<StoredRow>> =
rows.iter().map(|r| Ok(row_to_stored(r))).collect();
Ok(Box::new(mapped.into_iter()))
}
fn get_tip(&self, chain_id: &str) -> StoreResult<Option<TipRow>> {
let mut client = self.client.borrow_mut();
let row = client
.query_opt(SQL_SELECT_TIP_BY_CHAIN, &[&chain_id])
.map_err(backend)?;
Ok(row.as_ref().map(row_to_tip))
}
fn get_tip_for_entity(&self, entity_id: &str) -> StoreResult<Option<TipRow>> {
let mut client = self.client.borrow_mut();
let row = client
.query_opt(SQL_SELECT_TIP_BY_ENTITY, &[&entity_id])
.map_err(backend)?;
Ok(row.as_ref().map(row_to_tip))
}
#[instrument(skip(self, tip), fields(entity_id = %tip.entity_id, chain_id = %tip.chain_id))]
fn set_tip(&mut self, tip: &TipUpsert) -> StoreResult<()> {
let client = self.client.get_mut();
client
.execute(
SQL_UPSERT_TIP,
&[
&tip.entity_id,
&tip.current_tip_hash,
&tip.chain_id,
&now_iso_micros(),
],
)
.map_err(backend)?;
Ok(())
}
fn kv_cross_check(&self) -> StoreResult<KvCrossCheckReport> {
let mut client = self.client.borrow_mut();
// Count sides.
let kv1_total: i64 = client
.query_one("SELECT COUNT(*) FROM hash_index", &[])
.map_err(backend)?
.get(0);
let kv2_total: i64 = client
.query_one("SELECT COUNT(*) FROM chain_tips", &[])
.map_err(backend)?
.get(0);
// Cross-check: every chain_tip's current_tip_hash must exist in hash_index
// under the same (entity_id, chain_id).
let rows = client
.query(
"SELECT ct.entity_id, ct.chain_id, ct.current_tip_hash
FROM chain_tips ct
WHERE ct.current_tip_hash IS NOT NULL",
&[],
)
.map_err(backend)?;
let mut mismatches: Vec<String> = Vec::new();
let mut entities_checked = 0usize;
for row in rows.iter() {
entities_checked += 1;
let entity_id: String = row.get(0);
let chain_id: Option<String> = row.get(1);
let tip_hash: String = row.get(2);
let chain_id = chain_id.unwrap_or_default();
let ok: Option<i64> = client
.query_opt(
"SELECT 1 FROM hash_index
WHERE row_hash = $1 AND entity_id = $2 AND chain_id = $3",
&[&tip_hash, &entity_id, &chain_id],
)
.map_err(backend)?
.map(|r| r.get(0));
if ok.is_none() {
mismatches.push(format!(
"entity={} chain={} tip={} not in hash_index",
entity_id, chain_id, tip_hash
));
}
}
Ok(KvCrossCheckReport {
ok: mismatches.is_empty(),
kv1_total_hashes: kv1_total as usize,
kv2_total_entities: kv2_total as usize,
entities_checked,
mismatches,
})
}
#[instrument(skip(self, f))]
fn with_txn(
&mut self,
f: &mut dyn FnMut(&mut dyn Store) -> StoreResult<()>,
) -> StoreResult<()> {
let client = self.client.get_mut();
let mut txn = client.transaction().map_err(backend)?;
let result = {
let mut wrapper = TxnStore {
txn: RefCell::new(&mut txn),
};
f(&mut wrapper)
// `wrapper` dropped here, releasing the &mut txn borrow.
};
match result {
Ok(()) => txn.commit().map_err(backend),
Err(e) => {
let _ = txn.rollback();
Err(e)
}
}
}
}
// ------------------------------------------------------------
// Transaction-scoped Store wrapper (for with_txn)
//
// Two lifetimes: 'a is how long this wrapper holds the &mut, 'c is
// the underlying connection's lifetime (parameter on Transaction).
// Conflating them (impl<'t> with Transaction<'t>) causes the borrow
// checker to think the outer Transaction is still borrowed when we
// try to commit() after f() returns. Splitting them lets the
// wrapper's lifetime end at scope close while Transaction keeps
// living on the outer stack frame.
// ------------------------------------------------------------
struct TxnStore<'t, 'c: 't> {
txn: RefCell<&'t mut Transaction<'c>>,
}
impl<'t, 'c: 't> Store for TxnStore<'t, 'c> {
fn last_row(&self, entity_id: &str) -> StoreResult<Option<LastRow>> {
let mut txn = self.txn.borrow_mut();
let row = txn
.query_opt(SQL_SELECT_LAST_ROW, &[&entity_id])
.map_err(backend)?;
Ok(row.as_ref().map(row_to_last))
}
fn insert_row(&mut self, row: &RowInsert) -> StoreResult<i64> {
let mut txn = self.txn.borrow_mut();
let id: i64 = txn
.query_one(
SQL_INSERT_ROW,
&[
&row.chain_id,
&row.entity_id,
&row.event_type,
&row.source_url,
&row.detected_at,
&row.received_at,
&row.extraction_method,
&row.prev_hash,
&row.raw_payload,
&row.row_hash,
&row.anchor_hash,
&row.sequence,
],
)
.map_err(backend)?
.get(0);
if let Some(rh) = &row.row_hash {
txn.execute(
SQL_INSERT_HASH_INDEX,
&[
rh,
&row.chain_id,
&row.sequence,
&row.entity_id,
&row.received_at,
],
)
.map_err(backend)?;
}
Ok(id)
}
fn iter_chain<'a>(
&'a self,
entity_id: &str,
) -> StoreResult<Box<dyn Iterator<Item = StoreResult<StoredRow>> + 'a>> {
let mut txn = self.txn.borrow_mut();
let rows = txn
.query(SQL_SELECT_CHAIN, &[&entity_id])
.map_err(backend)?;
let mapped: Vec<StoreResult<StoredRow>> =
rows.iter().map(|r| Ok(row_to_stored(r))).collect();
Ok(Box::new(mapped.into_iter()))
}
fn get_tip(&self, chain_id: &str) -> StoreResult<Option<TipRow>> {
let mut txn = self.txn.borrow_mut();
let row = txn
.query_opt(SQL_SELECT_TIP_BY_CHAIN, &[&chain_id])
.map_err(backend)?;
Ok(row.as_ref().map(row_to_tip))
}
fn get_tip_for_entity(&self, entity_id: &str) -> StoreResult<Option<TipRow>> {
let mut txn = self.txn.borrow_mut();
let row = txn
.query_opt(SQL_SELECT_TIP_BY_ENTITY, &[&entity_id])
.map_err(backend)?;
Ok(row.as_ref().map(row_to_tip))
}
fn set_tip(&mut self, tip: &TipUpsert) -> StoreResult<()> {
let mut txn = self.txn.borrow_mut();
txn.execute(
SQL_UPSERT_TIP,
&[
&tip.entity_id,
&tip.current_tip_hash,
&tip.chain_id,
&now_iso_micros(),
],
)
.map_err(backend)?;
Ok(())
}
fn kv_cross_check(&self) -> StoreResult<KvCrossCheckReport> {
// Inside a txn we skip the cross-check -- the relevant checks happen
// at txn commit time via Postgres constraints. Return an OK stub.
Ok(KvCrossCheckReport {
ok: true,
kv1_total_hashes: 0,
kv2_total_entities: 0,
entities_checked: 0,
mismatches: Vec::new(),
})
}
fn with_txn(
&mut self,
_f: &mut dyn FnMut(&mut dyn Store) -> StoreResult<()>,
) -> StoreResult<()> {
Err(StoreError::Backend(
"nested with_txn on an already-open transaction is not supported".into(),
))
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,55 @@
//! Runs the shared `Store` conformance suite against a live Postgres.
//!
//! Gated behind the TRUSTY_PG_URL env var. Set it to a libpq connection
//! string that points at a disposable test DB, e.g.:
//!
//! TRUSTY_PG_URL="host=localhost user=postgres password=postgres dbname=trusty_test"
//! cargo test -p trusty-postgres --test conformance -- --nocapture
//!
//! If TRUSTY_PG_URL is not set, the test prints a skip notice and exits
//! clean. This keeps the default `cargo test --workspace` hermetic.
use trusty_postgres::TrustyPostgres;
use trusty_store::testing::run_conformance;
const ENV_URL: &str = "TRUSTY_PG_URL";
fn reset_schema(store: &mut TrustyPostgres) {
// Drop-and-recreate so the suite starts from a known-empty state.
// Done by connecting to the same DB and issuing the drops directly.
let url = std::env::var(ENV_URL).expect("TRUSTY_PG_URL must be set");
let mut client = postgres::Client::connect(&url, postgres::NoTls).expect("connect");
client
.batch_execute(
"DROP TABLE IF EXISTS chain_checkpoint CASCADE;
DROP TABLE IF EXISTS producer_identity CASCADE;
DROP TABLE IF EXISTS chain_tips CASCADE;
DROP TABLE IF EXISTS hash_index CASCADE;
DROP TABLE IF EXISTS provenance_envelope CASCADE;
DROP TABLE IF EXISTS schema_meta CASCADE;",
)
.expect("drop tables");
let _ = store; // store gets recreated below
}
#[test]
fn postgres_passes_store_conformance() {
let url = match std::env::var(ENV_URL) {
Ok(v) => v,
Err(_) => {
eprintln!(
"SKIP: set {} to run the conformance suite against a live Postgres",
ENV_URL
);
return;
}
};
// Initial open to ensure the tables exist.
let mut store = TrustyPostgres::open(&url).expect("open");
reset_schema(&mut store);
// Re-open so apply_schema runs against a clean DB.
let mut store = TrustyPostgres::open(&url).expect("re-open");
run_conformance(&mut store);
}

View File

@@ -0,0 +1,705 @@
//! TRUSTY_PG_URL-gated integration tests for the higher-level
//! `Trusty` surface on Postgres. Pays down the test debt accumulated
//! across S16 cycles 4-7 (append_signed, append_batch + advisory
//! lock, anchor, anchors_since, export_bundle, checkpoint,
//! latest_checkpoint, verify_since_checkpoint).
//!
//! Setup mirrors `tests/conformance.rs`: when TRUSTY_PG_URL is unset
//! the tests print SKIP and exit clean so `cargo test --workspace`
//! stays hermetic. Set it to a libpq connection string pointing at a
//! disposable test DB to actually run them.
use serde_json::json;
use trusty_core::{Ed25519Signer, ProofBundle, SecretKey, Signer};
use trusty_postgres::{RowSpec, Trusty};
const ENV_URL: &str = "TRUSTY_PG_URL";
fn maybe_url() -> Option<String> {
std::env::var(ENV_URL).ok()
}
fn drop_tables(url: &str) {
let mut client = postgres::Client::connect(url, postgres::NoTls).expect("connect");
client
.batch_execute(
"DROP TABLE IF EXISTS chain_checkpoint CASCADE;
DROP TABLE IF EXISTS producer_revocation CASCADE;
DROP TABLE IF EXISTS producer_identity CASCADE;
DROP TABLE IF EXISTS chain_tips CASCADE;
DROP TABLE IF EXISTS hash_index CASCADE;
DROP TABLE IF EXISTS provenance_envelope CASCADE;
DROP TABLE IF EXISTS schema_meta CASCADE;",
)
.expect("drop tables");
}
fn open_clean(url: &str) -> Trusty {
drop_tables(url);
let key = SecretKey::from_bytes(b"0123456789abcdef").expect("key");
Trusty::open(url, key).expect("open Trusty")
}
fn signer() -> Ed25519Signer {
let seed = [9u8; 32];
Ed25519Signer::from_secret_bytes(&seed).expect("signer")
}
fn skip_if_no_url() -> Option<String> {
let url = maybe_url();
if url.is_none() {
eprintln!(
"SKIP: set {} to a Postgres connection string to run integration tests",
ENV_URL
);
}
url
}
#[test]
fn append_signed_writes_v2_payload_and_verifies() {
let Some(url) = skip_if_no_url() else { return };
let mut t = open_clean(&url);
let s = signer();
let r = t
.append_signed("dev1", &json!({"v": 1}), "src", &s)
.expect("append_signed");
assert!(!r.chain_break);
assert_eq!(r.sequence, 0);
assert!(r.is_genesis);
// Payload should be v2 with a populated signature object. Read
// raw_payload back from the row directly to confirm.
let mut client = postgres::Client::connect(&url, postgres::NoTls).expect("connect");
let row = client
.query_one(
"SELECT raw_payload FROM provenance_envelope
WHERE entity_id = $1 ORDER BY id ASC LIMIT 1",
&[&"dev1"],
)
.expect("read row");
let raw: String = row.get(0);
assert!(raw.contains("\"payload_version\":2"), "raw = {}", raw);
assert!(raw.contains("\"alg\":\"ed25519\""), "raw = {}", raw);
let report = t.verify("dev1").expect("verify");
assert!(report.rows_valid, "{:?}", report.errors);
assert_eq!(report.row_count, 1);
}
#[test]
fn append_batch_is_all_or_nothing_and_advisory_lock_present() {
let Some(url) = skip_if_no_url() else { return };
let mut t = open_clean(&url);
let d0 = json!({"v": 0});
let d1 = json!({"v": 1});
let d2 = json!({"v": 2});
let specs = vec![
RowSpec {
entity_id: "fleet-a",
data: &d0,
source_url: "src",
},
RowSpec {
entity_id: "fleet-a",
data: &d1,
source_url: "src",
},
RowSpec {
entity_id: "fleet-b",
data: &d2,
source_url: "src",
},
];
let results = t.append_batch(&specs).expect("append_batch");
assert_eq!(results.len(), 3);
assert!(t.verify("fleet-a").expect("verify a").rows_valid);
assert!(t.verify("fleet-b").expect("verify b").rows_valid);
// Empty batch is a no-op.
let empty: Vec<RowSpec> = Vec::new();
let none = t.append_batch(&empty).expect("empty batch");
assert!(none.is_empty());
}
#[test]
fn anchor_and_anchors_since_round_trip() {
let Some(url) = skip_if_no_url() else { return };
let mut t = open_clean(&url);
for i in 0..3 {
t.append("dev2", &json!({"i": i}), "src").expect("append");
}
let anchor = t.anchor("dev2").expect("anchor").expect("some");
assert_eq!(anchor.entity_id, "dev2");
assert!(anchor.chain_id.starts_with("chain_dev2_"));
assert!(anchor.tip_hash.is_some());
let entries = t.anchors_since("dev2", None).expect("anchors_since");
// Three appends -> three rows -> three anchor commitments.
assert_eq!(entries.len(), 3);
// Each entry has a distinct sequence and non-empty hash.
for (i, e) in entries.iter().enumerate() {
assert_eq!(e.sequence, i as i64);
assert!(!e.anchor_hash.is_empty());
}
// since-filter uses strict-greater-than on detected_at; a value
// beyond all rows yields the empty list.
let none = t
.anchors_since("dev2", Some("9999-01-01T00:00:00Z"))
.expect("anchors_since since=future");
assert!(none.is_empty());
}
#[test]
fn export_bundle_round_trips_through_proof_bundle_verify() {
let Some(url) = skip_if_no_url() else { return };
let mut t = open_clean(&url);
let key = SecretKey::from_bytes(b"0123456789abcdef").expect("key");
for i in 0..4 {
t.append("dev3", &json!({"i": i}), "src").expect("append");
}
let bundle: ProofBundle = t.export_bundle("dev3", None, None).expect("export_bundle");
let report = bundle.verify(&key);
assert!(
report.rows_valid,
"bundle verify failed: {:?}",
report.errors
);
assert_eq!(report.row_count, 4);
// Sequence-bounded slice still verifies.
let slice = t
.export_bundle("dev3", Some(1), Some(2))
.expect("export_bundle slice");
let slice_report = slice.verify(&key);
assert!(slice_report.rows_valid, "{:?}", slice_report.errors);
assert_eq!(slice_report.row_count, 2);
}
#[test]
fn checkpoint_writes_then_latest_returns_it() {
let Some(url) = skip_if_no_url() else { return };
let mut t = open_clean(&url);
let s = signer();
for i in 0..3 {
t.append("dev4", &json!({"i": i}), "src").expect("append");
}
let ckpt = t.checkpoint("dev4", "oid:1.2.3", &s).expect("checkpoint");
assert_eq!(ckpt.attested_by, "oid:1.2.3");
assert_eq!(ckpt.sequence_int, 2);
assert!(!ckpt.row_hash.is_empty());
assert!(!ckpt.attestation_sig.is_empty());
let latest = t
.latest_checkpoint(&ckpt.chain_id)
.expect("latest_checkpoint")
.expect("some");
assert_eq!(latest.id, ckpt.id);
assert_eq!(latest.sequence_int, ckpt.sequence_int);
assert_eq!(latest.row_hash, ckpt.row_hash);
assert_eq!(latest.attestation_sig, ckpt.attestation_sig);
}
#[test]
fn verify_since_checkpoint_falls_back_when_no_checkpoint() {
let Some(url) = skip_if_no_url() else { return };
let mut t = open_clean(&url);
for i in 0..2 {
t.append("dev5", &json!({"i": i}), "src").expect("append");
}
// No checkpoint written -> verify_since_checkpoint must return
// the same shape as a full verify.
let full = t.verify("dev5").expect("verify");
let pruned = t.verify_since_checkpoint("dev5").expect("vsc");
assert_eq!(full.rows_valid, pruned.rows_valid);
assert_eq!(full.row_count, pruned.row_count);
}
#[test]
fn verify_since_checkpoint_after_checkpoint_still_passes() {
let Some(url) = skip_if_no_url() else { return };
let mut t = open_clean(&url);
let s = signer();
// S16 cycle 9: verify_since_checkpoint authenticates the
// checkpoint against producer_identity before trusting the
// window-prune. Register the attestor first.
t.register_producer(
"oid:test",
&s.public_key_bytes(),
b"siphash_key_bytes_16",
Some("test attestor"),
)
.expect("register producer");
for i in 0..3 {
t.append("dev6", &json!({"i": i}), "src").expect("append");
}
t.checkpoint("dev6", "oid:test", &s).expect("checkpoint");
// Append more rows after the checkpoint.
for i in 3..5 {
t.append("dev6", &json!({"i": i}), "src").expect("append");
}
let report = t.verify_since_checkpoint("dev6").expect("vsc");
assert!(report.rows_valid, "{:?}", report.errors);
// Verify covers checkpoint.sequence (=2) forward -> rows 2,3,4
// -> 3 rows.
assert_eq!(report.row_count, 3);
}
#[test]
fn producer_identity_register_and_lookup_round_trip() {
let Some(url) = skip_if_no_url() else { return };
let mut t = open_clean(&url);
let s = signer();
let pk = s.public_key_bytes();
t.register_producer("oid:1.2.3", &pk, b"sip-key-1", Some("agent-a"))
.expect("register");
let got = t
.lookup_producer("oid:1.2.3")
.expect("lookup")
.expect("some");
assert_eq!(got.producer_oid, "oid:1.2.3");
assert_eq!(got.ed25519_pubkey, pk);
assert_eq!(got.siphash_key, b"sip-key-1");
assert_eq!(got.display_name.as_deref(), Some("agent-a"));
// Re-register same OID is an UPSERT.
let s2 = trusty_core::Ed25519Signer::from_secret_bytes(&[3u8; 32]).expect("s2");
let pk2 = s2.public_key_bytes();
t.register_producer("oid:1.2.3", &pk2, b"sip-key-2", None)
.expect("re-register");
let updated = t
.lookup_producer("oid:1.2.3")
.expect("lookup2")
.expect("some");
assert_eq!(updated.ed25519_pubkey, pk2);
assert_eq!(updated.siphash_key, b"sip-key-2");
assert!(updated.display_name.is_none());
// Unknown OID yields None.
assert!(t.lookup_producer("oid:nope").expect("lookup3").is_none());
}
#[test]
fn verify_since_checkpoint_rejects_unknown_attestor() {
let Some(url) = skip_if_no_url() else { return };
let mut t = open_clean(&url);
let s = signer();
// Append rows + write checkpoint WITHOUT registering the
// attestor first. The checkpoint row is in the table but
// attested_by has no producer_identity row.
for i in 0..2 {
t.append("dev7", &json!({"i": i}), "src").expect("append");
}
t.checkpoint("dev7", "oid:ghost", &s)
.expect("checkpoint write succeeds (lookup happens at verify)");
let report = t.verify_since_checkpoint("dev7").expect("vsc");
assert!(
!report.rows_valid,
"expected failure for unregistered attestor"
);
assert_eq!(report.errors.len(), 1);
let detail = match &report.errors[0].kind {
trusty_core::VerifyErrorType::SignatureInvalid { detail } => detail.clone(),
other => panic!("expected SignatureInvalid, got {:?}", other),
};
assert!(
detail.contains("not in producer_identity"),
"detail = {}",
detail
);
}
#[test]
fn verify_with_producer_accepts_signed_rows_by_registered_producer() {
let Some(url) = skip_if_no_url() else { return };
let mut t = open_clean(&url);
let s = signer();
t.register_producer("oid:test", &s.public_key_bytes(), b"sip-key", None)
.expect("register");
// Three signed rows by the registered producer.
for i in 0..3 {
t.append_signed("dev-rsa1", &json!({"i": i}), "src", &s)
.expect("append_signed");
}
let report = t
.verify_with_producer("dev-rsa1", "oid:test")
.expect("verify_with_producer");
assert!(report.rows_valid, "{:?}", report.errors);
assert_eq!(report.row_count, 3);
}
#[test]
fn verify_with_producer_rejects_row_signed_by_unauthorized_pubkey() {
let Some(url) = skip_if_no_url() else { return };
let mut t = open_clean(&url);
let registered = signer();
let imposter = trusty_core::Ed25519Signer::from_secret_bytes(&[77u8; 32]).expect("imp");
t.register_producer("oid:test", &registered.public_key_bytes(), b"sip-key", None)
.expect("register");
// Row 0 signed by the registered producer (legitimate).
t.append_signed("dev-rsa2", &json!({"i": 0}), "src", &registered)
.expect("append_signed legit");
// Row 1 signed by the imposter -- chain math still verifies, but
// verify_with_producer must flag it because the pk doesn't match
// producer_identity for oid:test.
t.append_signed("dev-rsa2", &json!({"i": 1}), "src", &imposter)
.expect("append_signed imposter");
let report = t
.verify_with_producer("dev-rsa2", "oid:test")
.expect("verify_with_producer");
assert!(!report.rows_valid, "expected rejection of unauthorized pk");
let unauthorized = report
.errors
.iter()
.find(|e| match &e.kind {
trusty_core::VerifyErrorType::SignatureInvalid { detail } => {
detail.contains("unauthorized pubkey")
}
_ => false,
})
.expect("expected an 'unauthorized pubkey' SignatureInvalid");
assert_eq!(unauthorized.sequence, 1);
}
#[test]
fn verify_with_producer_rejects_row_after_revocation() {
let Some(url) = skip_if_no_url() else { return };
let mut t = open_clean(&url);
let s = signer();
t.register_producer("oid:test", &s.public_key_bytes(), b"sip-key", None)
.expect("register");
// Revoke FIRST, then append. The row's detected_at will be
// strictly greater than revoked_at, so it must reject.
t.revoke_producer("oid:test", "compromise", &s)
.expect("revoke");
std::thread::sleep(std::time::Duration::from_millis(50));
t.append_signed("dev-rsa3", &json!({"i": 0}), "src", &s)
.expect("append_signed after revoke");
let report = t
.verify_with_producer("dev-rsa3", "oid:test")
.expect("verify_with_producer");
assert!(!report.rows_valid);
let after_rev = report.errors.iter().find(|e| match &e.kind {
trusty_core::VerifyErrorType::SignatureInvalid { detail } => {
detail.contains("AT or AFTER revocation")
}
_ => false,
});
assert!(
after_rev.is_some(),
"expected post-revocation rejection; errors: {:?}",
report.errors
);
}
#[test]
fn verify_with_producer_accepts_rows_signed_before_revocation() {
let Some(url) = skip_if_no_url() else { return };
let mut t = open_clean(&url);
let s = signer();
t.register_producer("oid:test", &s.public_key_bytes(), b"sip-key", None)
.expect("register");
// Append FIRST, then revoke. CT stance: past rows stay valid.
for i in 0..2 {
t.append_signed("dev-rsa4", &json!({"i": i}), "src", &s)
.expect("append_signed");
}
std::thread::sleep(std::time::Duration::from_millis(50));
t.revoke_producer("oid:test", "later compromise", &s)
.expect("revoke");
let report = t
.verify_with_producer("dev-rsa4", "oid:test")
.expect("verify_with_producer");
assert!(
report.rows_valid,
"expected pre-revocation rows to remain valid; errors: {:?}",
report.errors
);
assert_eq!(report.row_count, 2);
}
#[test]
fn revoke_producer_round_trip_and_lookup() {
let Some(url) = skip_if_no_url() else { return };
let mut t = open_clean(&url);
let s = signer();
t.register_producer("oid:test", &s.public_key_bytes(), b"sip-key", None)
.expect("register");
// Not revoked yet.
assert!(t.lookup_revocation("oid:test").expect("lookup").is_none());
// Revoke. Returns RevocationRecord; producer_revocation row exists.
let rec = t
.revoke_producer("oid:test", "key compromise", &s)
.expect("revoke");
assert_eq!(rec.producer_oid, "oid:test");
assert_eq!(rec.reason, "key compromise");
assert_eq!(rec.revoked_pubkey, s.public_key_bytes());
assert_eq!(rec.attested_by_pubkey, s.public_key_bytes());
assert!(!rec.revocation_row_hash.is_empty());
// Lookup returns the same record.
let got = t
.lookup_revocation("oid:test")
.expect("lookup")
.expect("some");
assert_eq!(got.producer_oid, rec.producer_oid);
assert_eq!(got.revocation_row_hash, rec.revocation_row_hash);
// The REVOCATION envelope is on the per-user 'revocations' chain
// and verifies via the standard verify path.
let report = t.verify("revocations").expect("verify revocations chain");
assert!(report.rows_valid, "{:?}", report.errors);
assert_eq!(report.row_count, 1);
// Re-revoking the same OID UPSERTs (single row per OID).
let rec2 = t
.revoke_producer("oid:test", "second reason", &s)
.expect("re-revoke");
assert_eq!(rec2.reason, "second reason");
// Two REVOCATION envelopes on chain (append-only); one
// producer_revocation index row (UPSERT).
let report2 = t
.verify("revocations")
.expect("verify revocations chain v2");
assert!(report2.rows_valid);
assert_eq!(report2.row_count, 2);
}
#[test]
fn revoke_producer_unknown_oid_errors() {
let Some(url) = skip_if_no_url() else { return };
let mut t = open_clean(&url);
let s = signer();
let err = t.revoke_producer("oid:ghost", "no such producer", &s);
assert!(err.is_err(), "expected NotFound for unregistered OID");
}
#[test]
fn verify_since_checkpoint_rejects_revoked_attestor_after_revocation() {
let Some(url) = skip_if_no_url() else { return };
let mut t = open_clean(&url);
let s = signer();
t.register_producer("oid:test", &s.public_key_bytes(), b"sip-key", None)
.expect("register");
for i in 0..3 {
t.append("dev-rev1", &json!({"i": i}), "src")
.expect("append");
}
// Revoke FIRST, then write checkpoint. Per CT stance, this
// checkpoint's checkpoint_at >= revoked_at, so it must reject.
t.revoke_producer("oid:test", "compromise", &s)
.expect("revoke");
t.checkpoint("dev-rev1", "oid:test", &s)
.expect("checkpoint write succeeds (verify happens later)");
let report = t.verify_since_checkpoint("dev-rev1").expect("vsc");
assert!(!report.rows_valid, "expected rejection of revoked attestor");
let detail = match &report.errors[0].kind {
trusty_core::VerifyErrorType::SignatureInvalid { detail } => detail.clone(),
other => panic!("expected SignatureInvalid, got {:?}", other),
};
assert!(detail.contains("revoked at"), "detail = {}", detail);
}
#[test]
fn verify_since_checkpoint_accepts_attestor_revoked_after_checkpoint() {
let Some(url) = skip_if_no_url() else { return };
let mut t = open_clean(&url);
let s = signer();
t.register_producer("oid:test", &s.public_key_bytes(), b"sip-key", None)
.expect("register");
for i in 0..3 {
t.append("dev-rev2", &json!({"i": i}), "src")
.expect("append");
}
// Checkpoint FIRST, then revoke. Per CT stance, this checkpoint's
// checkpoint_at < revoked_at, so it must still accept.
t.checkpoint("dev-rev2", "oid:test", &s)
.expect("checkpoint");
// Sleep briefly to ensure a strictly-greater revoked_at.
std::thread::sleep(std::time::Duration::from_millis(50));
t.revoke_producer("oid:test", "later compromise", &s)
.expect("revoke");
let report = t.verify_since_checkpoint("dev-rev2").expect("vsc");
assert!(
report.rows_valid,
"expected past checkpoint to remain valid; errors: {:?}",
report.errors
);
}
#[test]
fn verify_since_checkpoint_rejects_tampered_attestation_sig() {
let Some(url) = skip_if_no_url() else { return };
let mut t = open_clean(&url);
let attestor = signer();
let imposter = trusty_core::Ed25519Signer::from_secret_bytes(&[42u8; 32]).expect("imp");
// Register the *attestor*'s pubkey under the OID, but write the
// checkpoint signed by the *imposter*. Sig verification must
// fail.
t.register_producer(
"oid:test",
&attestor.public_key_bytes(),
b"siphash-key-XYZ",
None,
)
.expect("register");
for i in 0..2 {
t.append("dev8", &json!({"i": i}), "src").expect("append");
}
t.checkpoint("dev8", "oid:test", &imposter)
.expect("checkpoint with imposter signer");
let report = t.verify_since_checkpoint("dev8").expect("vsc");
assert!(!report.rows_valid, "expected failure for tampered sig");
let detail = match &report.errors[0].kind {
trusty_core::VerifyErrorType::SignatureInvalid { detail } => detail.clone(),
other => panic!("expected SignatureInvalid, got {:?}", other),
};
assert!(
detail.contains("attestation_sig invalid"),
"detail = {}",
detail
);
}
// ---------------------------------------------------------------------
// Per-entity lock on the single-append path.
// ---------------------------------------------------------------------
/// Two writers, two connections, one entity, at the same time.
///
/// Appending is read-tip, compute, insert, and those three steps are not
/// atomic. Without a per-entity lock both writers read the same tip and
/// both build on it, producing two rows that claim the same predecessor
/// -- a fork in a structure whose whole promise is that there is one
/// line.
///
/// The batch path has held a lock since it was written. The single
/// path never did; the module header said "add before any multi-writer
/// deployment", and nothing was enforcing it.
#[test]
fn concurrent_appends_to_one_entity_do_not_fork_the_chain() {
let Some(url) = maybe_url() else {
eprintln!("SKIP: {ENV_URL} unset");
return;
};
let entity = "concurrency-probe";
// A unique entity per run, so a previous run cannot make this pass
// and no cleanup accessor is needed on the public surface.
let entity = &format!(
"{entity}-{}",
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap()
.as_nanos()
);
// Apply the schema ONCE. Four threads each running
// CREATE TABLE IF NOT EXISTS collide in the catalog with "tuple
// concurrently updated" -- a real property of concurrent DDL, and
// not what this test is about.
{
let key = SecretKey::from_bytes(b"0123456789abcdef").expect("key");
Trusty::open(&url, key).expect("apply schema");
}
const WRITERS: usize = 4;
const EACH: usize = 6;
let handles: Vec<_> = (0..WRITERS)
.map(|w| {
let url = url.clone();
let entity = entity.clone();
std::thread::spawn(move || {
let key = SecretKey::from_bytes(b"0123456789abcdef").expect("key");
let mut t = Trusty::open(&url, key).expect("open");
for i in 0..EACH {
t.append(&entity, &json!({ "writer": w, "i": i }), "test://")
.expect("append");
}
})
})
.collect();
for h in handles {
h.join().expect("writer panicked");
}
let key = SecretKey::from_bytes(b"0123456789abcdef").expect("key");
let mut t = Trusty::open(&url, key).expect("open");
let bundle = t.export_bundle(entity, None, None).expect("export");
let rows: Vec<_> = bundle
.rows
.iter()
.filter(|r| r.event_type != "BREAK")
.collect();
assert_eq!(
rows.len(),
WRITERS * EACH,
"every append must land exactly once"
);
// The real assertion: no two rows may claim the same predecessor.
// That is what a lost update looks like from the outside.
let mut prevs: Vec<&str> = rows.iter().map(|r| r.prev_hash.as_str()).collect();
prevs.sort_unstable();
let before = prevs.len();
prevs.dedup();
assert_eq!(
prevs.len(),
before,
"two rows claim the same prev_hash, so two writers read the same tip \
and both built on it -- the chain forked, which is exactly what the \
per-entity lock exists to prevent"
);
let report = t.verify(entity).expect("verify");
assert!(
report.rows_valid,
"and the chain must still verify end to end: {report:?}"
);
}

View File

@@ -0,0 +1,22 @@
[package]
name = "trusty-sqlite"
version.workspace = true
edition.workspace = true
authors.workspace = true
license.workspace = true
repository.workspace = true
rust-version.workspace = true
description = "SQLite adapter for Trusty. Three-table schema (provenance_envelope, hash_index, chain_tips) with KV cross-check."
[dependencies]
trusty-core = { workspace = true }
trusty-store = { workspace = true }
rusqlite = { workspace = true }
serde = { workspace = true }
serde_json = { workspace = true }
chrono = { workspace = true }
thiserror = { workspace = true }
hex = { workspace = true }
[dev-dependencies]
trusty-store = { workspace = true, features = ["testing"] }

View File

@@ -0,0 +1,83 @@
// ============================================================
// EXAMPLE: bench_append
// PURPOSE: Measure fsync-amortization speedup of append_batch over
// a loop of append() calls on a file-backed SQLite DB.
// Intended for ad-hoc inspection, not CI regression
// tracking (no criterion / no gate). Prints rows/sec +
// speedup ratio.
//
// RUN: cargo run -p trusty-sqlite --release --example bench_append
// (release mode matters: debug rusqlite is much slower)
// ============================================================
use serde_json::json;
use std::time::Instant;
use trusty_core::SecretKey;
use trusty_sqlite::{RowSpec, Trusty};
const N: usize = 1000;
fn tmp_db_path(tag: &str) -> std::path::PathBuf {
let mut p = std::env::temp_dir();
let nanos = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_nanos())
.unwrap_or(0);
p.push(format!("trusty_bench_{tag}_{nanos}.db"));
p
}
fn key() -> SecretKey {
SecretKey::new(*b"0123456789abcdef")
}
fn bench_single_row(path: &std::path::Path) -> f64 {
let mut t = Trusty::open(path, key()).expect("open");
// Warmup: one genesis so we are measuring mutations, not first-row setup.
t.append("acme", &json!({"warmup": true}), "s")
.expect("warmup");
let start = Instant::now();
for i in 0..N {
t.append("acme", &json!({"i": i}), "s").expect("append ok");
}
let elapsed = start.elapsed().as_secs_f64();
N as f64 / elapsed
}
fn bench_batch(path: &std::path::Path) -> f64 {
let mut t = Trusty::open(path, key()).expect("open");
t.append("acme", &json!({"warmup": true}), "s")
.expect("warmup");
let values: Vec<serde_json::Value> = (0..N).map(|i| json!({"i": i})).collect();
let specs: Vec<RowSpec> = values
.iter()
.map(|v| RowSpec {
entity_id: "acme",
data: v,
source_url: "s",
})
.collect();
let start = Instant::now();
t.append_batch(&specs).expect("batch ok");
let elapsed = start.elapsed().as_secs_f64();
N as f64 / elapsed
}
fn main() {
let p1 = tmp_db_path("single");
let p2 = tmp_db_path("batch");
println!("trusty-sqlite append benchmark (N = {N}, file-backed, WAL + FULL sync)");
println!("single-row db : {}", p1.display());
println!("batch db : {}", p2.display());
println!();
let single_rps = bench_single_row(&p1);
let batch_rps = bench_batch(&p2);
let speedup = batch_rps / single_rps;
println!("single-row append : {single_rps:>10.1} rows/sec");
println!("append_batch : {batch_rps:>10.1} rows/sec");
println!("speedup : {speedup:>10.2}x");
println!();
println!("(cleanup: rm {} {})", p1.display(), p2.display());
}

View File

@@ -0,0 +1,195 @@
// ============================================================
// EXAMPLE: device_signer
// PURPOSE: Walk the device-signer provisioning pattern end to end.
// Enroll -> sign -> rotate -> revoke. Demonstrates how an
// application keeps a signer_registry alongside trusty's
// chain tables, using a second rusqlite::Connection to
// the same file.
//
// See `docs/device-signer-provisioning.md` for the conceptual doc.
//
// RUN: cargo run -p trusty-sqlite --example device_signer
// ============================================================
use rusqlite::{params, Connection};
use serde_json::{json, Value};
use std::path::PathBuf;
use trusty_core::{Ed25519Signer, Ed25519Verifier, SecretKey, Signer, Verifier};
use trusty_sqlite::Trusty;
fn tmp_db_path() -> PathBuf {
let mut p = std::env::temp_dir();
let nanos = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_nanos())
.unwrap_or(0);
p.push(format!("trusty_device_signer_demo_{nanos}.db"));
p
}
fn now_iso() -> String {
chrono::Utc::now().to_rfc3339_opts(chrono::SecondsFormat::Secs, true)
}
fn init_registry(conn: &Connection) {
conn.execute_batch(
"CREATE TABLE IF NOT EXISTS signer_registry (
device_id TEXT PRIMARY KEY,
public_key TEXT NOT NULL,
enrolled_at TEXT NOT NULL,
rotated_at TEXT,
revoked_at TEXT,
note TEXT
);
CREATE INDEX IF NOT EXISTS idx_sr_pk ON signer_registry(public_key);",
)
.expect("registry schema");
}
fn enroll(conn: &Connection, device_id: &str, pk_hex: &str, note: &str) {
conn.execute(
"INSERT INTO signer_registry (device_id, public_key, enrolled_at, note)
VALUES (?1, ?2, ?3, ?4)",
params![device_id, pk_hex, now_iso(), note],
)
.expect("enroll");
println!(" [enrolled] device={device_id} pk={pk_hex:.16}... ({note})");
}
fn rotate(conn: &Connection, old_device_id: &str, new_device_id: &str, new_pk_hex: &str) {
conn.execute(
"UPDATE signer_registry SET rotated_at = ?1 WHERE device_id = ?2",
params![now_iso(), old_device_id],
)
.expect("mark rotated");
enroll(conn, new_device_id, new_pk_hex, "post-rotation");
println!(" [rotated] {old_device_id} -> {new_device_id}");
}
fn revoke(conn: &Connection, device_id: &str, reason: &str) {
conn.execute(
"UPDATE signer_registry SET revoked_at = ?1, note = ?2 WHERE device_id = ?3",
params![now_iso(), format!("REVOKED: {reason}"), device_id],
)
.expect("revoke");
println!(" [revoked] device={device_id} reason={reason}");
}
fn trust_status_for_pk(conn: &Connection, pk_hex: &str) -> String {
conn.query_row(
"SELECT device_id, enrolled_at, rotated_at, revoked_at
FROM signer_registry WHERE public_key = ?1",
params![pk_hex],
|r| {
let dev: String = r.get(0)?;
let enrolled: String = r.get(1)?;
let rotated: Option<String> = r.get(2)?;
let revoked: Option<String> = r.get(3)?;
Ok(match (rotated, revoked) {
(_, Some(rev)) => format!("REVOKED({dev}, at={rev})"),
(Some(rot), _) => format!("ROTATED({dev}, at={rot}) -- old-key rows still verify"),
_ => format!("ACTIVE({dev}, since={enrolled})"),
})
},
)
.unwrap_or_else(|_| "UNKNOWN (pk not in registry)".to_string())
}
fn sign_and_append(t: &mut Trusty, entity: &str, data: &Value, signer: &Ed25519Signer) {
let r = t
.append_signed(entity, data, "sensor://demo", signer)
.expect("signed append");
println!(
" [appended] seq={} row_hash={:.16}... pk={:.16}...",
r.sequence,
r.row_hash,
hex::encode(signer.public_key_bytes())
);
}
fn main() {
let path = tmp_db_path();
println!("trusty device-signer provisioning demo");
println!("db: {}\n", path.display());
// Trusty owns one Connection; the app opens a second one for the
// registry. WAL mode (set by trusty-sqlite's schema) allows this
// cleanly.
let mut t = Trusty::open(&path, SecretKey::new(*b"0123456789abcdef")).expect("open trusty");
let app_conn = Connection::open(&path).expect("open app conn");
init_registry(&app_conn);
// --- 1. Enroll device_001 -------------------------------------
println!("step 1: enroll device_001");
let signer_v1 = Ed25519Signer::generate();
let pk_v1_hex = hex::encode(signer_v1.public_key_bytes());
enroll(&app_conn, "device_001", &pk_v1_hex, "initial enrollment");
// --- 2. Sign and append with v1 key ---------------------------
println!("\nstep 2: append two signed rows with v1 key");
sign_and_append(&mut t, "sensor_a", &json!({"temp": 21.4}), &signer_v1);
sign_and_append(&mut t, "sensor_a", &json!({"temp": 21.5}), &signer_v1);
// --- 3. Rotate: new key, new registry entry -------------------
println!("\nstep 3: rotate device_001 -> device_001_v2");
let signer_v2 = Ed25519Signer::generate();
let pk_v2_hex = hex::encode(signer_v2.public_key_bytes());
rotate(&app_conn, "device_001", "device_001_v2", &pk_v2_hex);
// --- 4. Append more rows with v2 key --------------------------
println!("\nstep 4: append two more signed rows with v2 key");
sign_and_append(&mut t, "sensor_a", &json!({"temp": 21.7}), &signer_v2);
sign_and_append(&mut t, "sensor_a", &json!({"temp": 21.9}), &signer_v2);
// --- 5. Revoke v1 (simulated compromise) ----------------------
println!("\nstep 5: simulated compromise of v1 secret, revoke it");
revoke(&app_conn, "device_001", "secret leaked from retired device");
// --- 6. Verify chain: math is still sound ---------------------
println!("\nstep 6: chain verify -- cryptography is unaffected by registry policy");
let report = t.verify("sensor_a").expect("verify");
println!(
" chain verify: ok={} row_count={} (revocation is app-policy, not chain-policy)",
report.rows_valid, report.row_count
);
// --- 7. Registry overlay: per-row trust report ----------------
println!("\nstep 7: registry-overlay trust report for each chain row");
let bundle = t.export_bundle("sensor_a", None, None).expect("bundle");
for row in &bundle.rows {
if let Some(raw) = &row.raw_payload {
let parsed: Value = serde_json::from_str(raw).expect("payload json");
let pk_hex = parsed
.get("signatures")
.and_then(|s| s.as_array())
.and_then(|a| a.first())
.and_then(|o| o.get("pk"))
.and_then(|v| v.as_str());
match pk_hex {
Some(pk) => {
println!(
" seq={} event={} -> {}",
row.sequence,
row.event_type,
trust_status_for_pk(&app_conn, pk)
);
}
None => {
println!(
" seq={} event={} -> (unsigned v1 row)",
row.sequence, row.event_type
);
}
}
}
}
// --- 8. Sanity: Ed25519Verifier round-trips on an isolated sig
println!("\nstep 8: sanity -- Ed25519Verifier on the v2 public key works end-to-end");
let msg = b"independent verifier check";
let sig = signer_v2.sign(msg);
Ed25519Verifier::verify(&signer_v2.public_key_bytes(), msg, &sig).expect("independent verify");
println!(" standalone verify: ok");
println!("\ndone. (cleanup: rm {})", path.display());
}

View File

@@ -0,0 +1,186 @@
// ============================================================
// EXAMPLE: twin_coupling
// PURPOSE: Demonstrate the data-convention pattern for linking a
// digital-twin computation row back to the sensor rows
// that fed it. Sensor chain and twin chain are independent
// trusty entities. The twin row carries a `source_refs`
// array in its `data` field so an external verifier can
// check the linkage from two ProofBundles alone -- no
// extra tables, no schema changes.
//
// See `docs/twin-coupling.md` for the conceptual doc.
//
// RUN: cargo run -p trusty-sqlite --example twin_coupling
// ============================================================
use serde_json::{json, Value};
use std::path::PathBuf;
use trusty_core::SecretKey;
use trusty_sqlite::Trusty;
fn tmp_db_path() -> PathBuf {
let mut p = std::env::temp_dir();
let nanos = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_nanos())
.unwrap_or(0);
p.push(format!("trusty_twin_coupling_demo_{nanos}.db"));
p
}
fn main() {
let path = tmp_db_path();
println!("trusty twin-coupling demo");
println!("db: {}\n", path.display());
let sip_key = SecretKey::new(*b"0123456789abcdef");
let mut t = Trusty::open(&path, sip_key.clone()).expect("open trusty");
// ----------------------------------------------------------------
// step 1: append sensor readings to the sensor chain
// ----------------------------------------------------------------
println!("step 1: append three sensor readings");
let r0 = t
.append("sensor-001", &json!({"temp_c": 21.1}), "iot://sensor-001")
.expect("append r0");
println!(
" seq={} chain_id={:.16}... row_hash={}",
r0.sequence, r0.chain_id, r0.row_hash
);
let r1 = t
.append("sensor-001", &json!({"temp_c": 21.4}), "iot://sensor-001")
.expect("append r1");
println!(
" seq={} chain_id={:.16}... row_hash={}",
r1.sequence, r1.chain_id, r1.row_hash
);
let r2 = t
.append("sensor-001", &json!({"temp_c": 21.6}), "iot://sensor-001")
.expect("append r2");
println!(
" seq={} chain_id={:.16}... row_hash={}",
r2.sequence, r2.chain_id, r2.row_hash
);
// ----------------------------------------------------------------
// step 2: twin computation cites the three sensor rows by
// chain_id + sequence + row_hash (data convention)
// ----------------------------------------------------------------
println!("\nstep 2: append twin computation row with source_refs");
let avg = (21.1_f64 + 21.4 + 21.6) / 3.0;
let twin_data = json!({
"computation": "rolling_avg_temp_c",
"result": (avg * 100.0).round() / 100.0,
"source_refs": [
{"chain_id": r0.chain_id, "sequence": r0.sequence, "row_hash": r0.row_hash},
{"chain_id": r1.chain_id, "sequence": r1.sequence, "row_hash": r1.row_hash},
{"chain_id": r2.chain_id, "sequence": r2.sequence, "row_hash": r2.row_hash},
]
});
let tw = t
.append("twin-001", &twin_data, "omniverse://twin-001")
.expect("append twin");
println!(" seq={} row_hash={}", tw.sequence, tw.row_hash);
// ----------------------------------------------------------------
// step 3: verify both chains independently
// ----------------------------------------------------------------
println!("\nstep 3: verify both chains");
let sensor_report = t.verify("sensor-001").expect("verify sensor");
println!(
" sensor-001: ok={} rows={}",
sensor_report.rows_valid, sensor_report.row_count
);
assert!(sensor_report.rows_valid, "sensor chain verification failed");
let twin_report = t.verify("twin-001").expect("verify twin");
println!(
" twin-001: ok={} rows={}",
twin_report.rows_valid, twin_report.row_count
);
assert!(twin_report.rows_valid, "twin chain verification failed");
// ----------------------------------------------------------------
// step 4: external-verifier cross-chain check
// Export both ProofBundles; walk twin source_refs and
// confirm each referenced row_hash is present and matches
// in the sensor bundle.
// ----------------------------------------------------------------
println!("\nstep 4: cross-chain verify from ProofBundles");
let sensor_bundle = t
.export_bundle("sensor-001", None, None)
.expect("sensor bundle");
let twin_bundle = t
.export_bundle("twin-001", None, None)
.expect("twin bundle");
let sensor_verify = sensor_bundle.verify(&sip_key);
let twin_verify = twin_bundle.verify(&sip_key);
println!(
" sensor bundle verify: ok={} rows={}",
sensor_verify.rows_valid, sensor_verify.row_count
);
println!(
" twin bundle verify: ok={} rows={}",
twin_verify.rows_valid, twin_verify.row_count
);
assert!(sensor_verify.rows_valid);
assert!(twin_verify.rows_valid);
// Walk twin rows; for each source_ref, confirm the sensor bundle
// contains a row at that sequence with a matching row_hash.
let mut refs_checked = 0usize;
for twin_row in twin_bundle.rows.iter().filter(|r| r.sequence >= 0) {
let payload: Value = twin_row
.raw_payload
.as_ref()
.and_then(|p| serde_json::from_str(p).ok())
.expect("twin row has no payload");
let data = payload.get("data").expect("payload has no data");
let refs = match data.get("source_refs").and_then(|v| v.as_array()) {
Some(r) => r,
None => continue,
};
for sr in refs {
let ref_chain_id = sr["chain_id"].as_str().expect("chain_id");
let ref_seq = sr["sequence"].as_i64().expect("sequence");
let ref_hash = sr["row_hash"].as_str().expect("row_hash");
let matched = sensor_bundle.rows.iter().find(|r| {
r.chain_id == ref_chain_id
&& r.sequence == ref_seq
&& r.row_hash.as_deref() == Some(ref_hash)
});
match matched {
Some(m) => {
println!(
" source_ref ok: seq={} row_hash={} (sensor chain_id={:.16}...)",
m.sequence, ref_hash, ref_chain_id
);
refs_checked += 1;
}
None => {
panic!(
"source_ref not found in sensor bundle: chain_id={} seq={} row_hash={}",
ref_chain_id, ref_seq, ref_hash
);
}
}
}
}
println!("\n {refs_checked} source_refs verified across ProofBundles");
assert_eq!(refs_checked, 3, "expected 3 source_refs checked");
println!("\ndone. (cleanup: rm {})", path.display());
}

View File

@@ -0,0 +1,30 @@
use thiserror::Error;
#[derive(Debug, Error)]
pub enum SqliteError {
#[error("sqlite error: {0}")]
Sqlite(#[from] rusqlite::Error),
#[error("trusty-core error: {0}")]
Core(#[from] trusty_core::TrustyError),
#[error("json error: {0}")]
Json(#[from] serde_json::Error),
#[error("chain tip missing row_hash for entity {0}")]
MissingTipHash(String),
#[error("malformed chain id: {0}")]
MalformedChainId(String),
#[error("not found: {0}")]
NotFound(String),
/// The producer enrolled a credential this path cannot check.
///
/// Separate from a failed verification on purpose: a failure invites
/// "wrong key, try another", while this says the check itself is the
/// wrong one for that credential.
#[error("unsupported credential: {0}")]
UnsupportedCredential(String),
#[error(
"store is signed-only: an unsigned append to entity {0} was refused. Use append_signed / append_with_method_signed."
)]
UnsignedAppendRefused(String),
}
pub type SqliteResult<T> = Result<T, SqliteError>;

View File

@@ -0,0 +1,26 @@
//! SQLite-backed Trusty adapter -- the production storage backend.
//!
//! Three-table schema (`provenance_envelope`, `hash_index`, `chain_tips`) with
//! KV cross-check. Entry point is [`Trusty`], which wraps a rusqlite connection
//! and exposes the full append / verify / query surface.
//!
//! # Key APIs
//! - [`Trusty::append`] / [`Trusty::append_batch`] -- write records (batch gives ~21x throughput)
//! - [`Trusty::verify`] -- check chain integrity for an entity
//! - [`Trusty::breaks_since`] -- poll chain-break events (tamper / gap alerts)
//! - [`Trusty::export_bundle`] -- package a chain slice as a [`ProofBundle`](trusty_core::ProofBundle) for external verification
//! - [`Trusty::append_signed`] -- v2 Ed25519-signed append
#![deny(clippy::all)]
pub mod error;
pub mod schema;
pub mod sqlite_store;
pub mod store;
pub use error::{SqliteError, SqliteResult};
pub use sqlite_store::SqliteStore;
pub use store::{
AnchorInfo, AppendResult, BreakEvent, CheckpointRow, HistoryRow, IntegrityMismatch,
IntegrityReport, ProducerIdentity, RevocationRecord, RowSpec, SilentEntity, Trusty,
};

View File

@@ -0,0 +1,90 @@
pub const SCHEMA_SQL: &str = r#"
PRAGMA journal_mode = WAL;
PRAGMA synchronous = FULL;
PRAGMA foreign_keys = ON;
CREATE TABLE IF NOT EXISTS provenance_envelope (
id INTEGER PRIMARY KEY AUTOINCREMENT,
chain_id TEXT NOT NULL,
entity_id TEXT NOT NULL,
event_type TEXT NOT NULL,
field_name TEXT,
old_value TEXT,
new_value TEXT,
source_url TEXT NOT NULL DEFAULT '',
detected_at TEXT NOT NULL,
extraction_method TEXT NOT NULL DEFAULT 'trusty',
prev_hash TEXT NOT NULL,
raw_payload TEXT,
row_hash TEXT,
anchor_hash TEXT,
sequence_int INTEGER NOT NULL DEFAULT 0
);
CREATE INDEX IF NOT EXISTS idx_pe_entity ON provenance_envelope(entity_id);
CREATE INDEX IF NOT EXISTS idx_pe_chain ON provenance_envelope(chain_id);
CREATE INDEX IF NOT EXISTS idx_pe_entity_chain ON provenance_envelope(entity_id, chain_id);
CREATE INDEX IF NOT EXISTS idx_pe_row_hash ON provenance_envelope(row_hash);
CREATE TABLE IF NOT EXISTS hash_index (
id INTEGER PRIMARY KEY AUTOINCREMENT,
row_hash TEXT NOT NULL UNIQUE,
chain_id TEXT NOT NULL,
sequence_int INTEGER NOT NULL,
entity_id TEXT NOT NULL,
recorded_at TEXT NOT NULL
);
CREATE INDEX IF NOT EXISTS idx_hi_entity ON hash_index(entity_id);
CREATE INDEX IF NOT EXISTS idx_hi_chain ON hash_index(chain_id);
CREATE TABLE IF NOT EXISTS chain_tips (
entity_id TEXT PRIMARY KEY,
current_tip_hash TEXT,
chain_id TEXT,
updated_at TEXT NOT NULL
);
CREATE INDEX IF NOT EXISTS idx_ct_chain ON chain_tips(chain_id);
-- Federation primitives mirrored from trusty-postgres (S17 cycle 4).
-- BLOB replaces BYTEA; TEXT (RFC3339) replaces TIMESTAMPTZ; INTEGER
-- PK AUTOINCREMENT replaces BIGINT IDENTITY. Timestamps set by
-- application code via now_iso() (no DEFAULT CURRENT_TIMESTAMP --
-- format would differ from RFC3339).
CREATE TABLE IF NOT EXISTS producer_identity (
producer_oid TEXT PRIMARY KEY,
ed25519_pubkey BLOB NOT NULL,
siphash_key BLOB NOT NULL,
registered_at TEXT NOT NULL,
display_name TEXT,
metadata TEXT,
-- See trusty-postgres: a COSE credential cannot be
-- checked as a bare signature, and this is what says which.
key_format TEXT NOT NULL DEFAULT 'ed25519-raw'
CHECK (key_format IN ('ed25519-raw','cose'))
);
CREATE TABLE IF NOT EXISTS producer_revocation (
producer_oid TEXT PRIMARY KEY,
revoked_at TEXT NOT NULL,
revoked_pubkey BLOB NOT NULL,
reason TEXT NOT NULL,
attested_by_pubkey BLOB NOT NULL,
revocation_row_hash TEXT NOT NULL
);
CREATE TABLE IF NOT EXISTS chain_checkpoint (
id INTEGER PRIMARY KEY AUTOINCREMENT,
chain_id TEXT NOT NULL,
sequence_int INTEGER NOT NULL,
row_hash TEXT NOT NULL,
checkpoint_at TEXT NOT NULL,
attested_by TEXT NOT NULL,
attestation_sig BLOB NOT NULL,
UNIQUE (chain_id, sequence_int)
);
CREATE INDEX IF NOT EXISTS idx_ckpt_chain ON chain_checkpoint(chain_id);
"#;

View File

@@ -0,0 +1,315 @@
// ============================================================
// MODULE: sqlite_store
// PURPOSE: Implements `trusty_store::Store` on top of the existing
// three-table schema used by the `Trusty` struct. Exposed
// so any code that wants a backend-agnostic handle can use
// it; `Trusty` itself still holds its own Connection for
// now and will be genericized in a follow-up.
// ============================================================
use rusqlite::{params, Connection, OptionalExtension};
use std::path::Path;
use trusty_store::{
KvCrossCheckReport, LastRow, RowInsert, Store, StoreError, StoreResult, StoredRow, TipRow,
TipUpsert,
};
use crate::error::{SqliteError, SqliteResult};
use crate::schema;
pub struct SqliteStore {
conn: Connection,
}
impl SqliteStore {
pub fn open<P: AsRef<Path>>(path: P) -> SqliteResult<Self> {
let conn = Connection::open(path)?;
conn.execute_batch(schema::SCHEMA_SQL)?;
Ok(Self { conn })
}
pub fn open_in_memory() -> SqliteResult<Self> {
let conn = Connection::open_in_memory()?;
conn.execute_batch(schema::SCHEMA_SQL)?;
Ok(Self { conn })
}
pub fn conn(&self) -> &Connection {
&self.conn
}
pub fn conn_mut(&mut self) -> &mut Connection {
&mut self.conn
}
}
fn backend<E: std::fmt::Display>(e: E) -> StoreError {
StoreError::Backend(e.to_string())
}
impl Store for SqliteStore {
fn last_row(&self, entity_id: &str) -> StoreResult<Option<LastRow>> {
let row = self
.conn
.query_row(
"SELECT row_hash, sequence_int FROM provenance_envelope
WHERE entity_id = ? AND event_type != 'BREAK' AND row_hash IS NOT NULL
ORDER BY id DESC LIMIT 1",
params![entity_id],
|r| {
Ok(LastRow {
row_hash: r.get(0)?,
sequence: r.get(1)?,
})
},
)
.optional()
.map_err(backend)?;
Ok(row)
}
fn insert_row(&mut self, row: &RowInsert) -> StoreResult<i64> {
self.conn
.execute(
"INSERT INTO provenance_envelope (
chain_id, entity_id, event_type,
field_name, old_value, new_value,
source_url, detected_at, extraction_method,
prev_hash, row_hash, anchor_hash, sequence_int, raw_payload
) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?)",
params![
row.chain_id,
row.entity_id,
row.event_type,
Option::<&str>::None,
Option::<&str>::None,
Option::<&str>::None,
row.source_url,
row.detected_at,
row.extraction_method,
row.prev_hash,
row.row_hash,
row.anchor_hash,
row.sequence,
row.raw_payload,
],
)
.map_err(backend)?;
let id = self.conn.last_insert_rowid();
if let Some(hash) = &row.row_hash {
self.conn
.execute(
"INSERT OR IGNORE INTO hash_index
(row_hash, chain_id, sequence_int, entity_id, recorded_at)
VALUES (?, ?, ?, ?, ?)",
params![
hash,
row.chain_id,
row.sequence,
row.entity_id,
row.received_at,
],
)
.map_err(backend)?;
}
Ok(id)
}
fn iter_chain<'a>(
&'a self,
entity_id: &str,
) -> StoreResult<Box<dyn Iterator<Item = StoreResult<StoredRow>> + 'a>> {
let mut stmt = self
.conn
.prepare(
"SELECT id, entity_id, chain_id, event_type, sequence_int, prev_hash,
row_hash, anchor_hash, raw_payload, detected_at, source_url,
extraction_method
FROM provenance_envelope
WHERE entity_id = ?
ORDER BY id ASC",
)
.map_err(backend)?;
let rows: Vec<StoreResult<StoredRow>> = stmt
.query_map(params![entity_id], |r| {
Ok(StoredRow {
id: r.get(0)?,
entity_id: r.get(1)?,
chain_id: r.get(2)?,
event_type: r.get(3)?,
sequence: r.get(4)?,
prev_hash: r.get(5)?,
row_hash: r.get(6)?,
anchor_hash: r.get(7)?,
raw_payload: r.get(8)?,
detected_at: r.get(9)?,
received_at: String::new(),
source_url: r.get(10)?,
extraction_method: r.get(11)?,
})
})
.map_err(backend)?
.map(|r| r.map_err(backend))
.collect();
Ok(Box::new(rows.into_iter()))
}
fn get_tip(&self, chain_id: &str) -> StoreResult<Option<TipRow>> {
let row = self
.conn
.query_row(
"SELECT entity_id, chain_id, current_tip_hash, updated_at
FROM chain_tips WHERE chain_id = ?",
params![chain_id],
|r| {
Ok(TipRow {
entity_id: r.get(0)?,
chain_id: r.get::<_, Option<String>>(1)?.unwrap_or_default(),
current_tip_hash: r.get(2)?,
updated_at: r.get(3)?,
})
},
)
.optional()
.map_err(backend)?;
Ok(row)
}
fn get_tip_for_entity(&self, entity_id: &str) -> StoreResult<Option<TipRow>> {
let row = self
.conn
.query_row(
"SELECT entity_id, chain_id, current_tip_hash, updated_at
FROM chain_tips WHERE entity_id = ?",
params![entity_id],
|r| {
Ok(TipRow {
entity_id: r.get(0)?,
chain_id: r.get::<_, Option<String>>(1)?.unwrap_or_default(),
current_tip_hash: r.get(2)?,
updated_at: r.get(3)?,
})
},
)
.optional()
.map_err(backend)?;
Ok(row)
}
fn set_tip(&mut self, tip: &TipUpsert) -> StoreResult<()> {
let updated_at = chrono::Utc::now().to_rfc3339_opts(chrono::SecondsFormat::Micros, true);
self.conn
.execute(
"INSERT INTO chain_tips (entity_id, current_tip_hash, chain_id, updated_at)
VALUES (?, ?, ?, ?)
ON CONFLICT(entity_id) DO UPDATE SET
current_tip_hash = excluded.current_tip_hash,
chain_id = excluded.chain_id,
updated_at = excluded.updated_at",
params![
tip.entity_id,
tip.current_tip_hash,
tip.chain_id,
updated_at,
],
)
.map_err(backend)?;
Ok(())
}
fn kv_cross_check(&self) -> StoreResult<KvCrossCheckReport> {
let kv1_total_hashes: i64 = self
.conn
.query_row("SELECT COUNT(*) FROM hash_index", [], |r| r.get(0))
.map_err(backend)?;
let mut tips_stmt = self
.conn
.prepare("SELECT entity_id, current_tip_hash, chain_id FROM chain_tips")
.map_err(backend)?;
let tip_rows: Vec<(String, Option<String>, Option<String>)> = tips_stmt
.query_map([], |r| {
Ok((
r.get::<_, String>(0)?,
r.get::<_, Option<String>>(1)?,
r.get::<_, Option<String>>(2)?,
))
})
.map_err(backend)?
.collect::<Result<Vec<_>, _>>()
.map_err(backend)?;
let mut mismatches: Vec<String> = Vec::new();
let mut entities_checked: usize = 0;
let kv2_total_entities = tip_rows.len();
for (entity_id, tip_hash, chain_id) in tip_rows {
let tip_hash = match tip_hash {
Some(h) => h,
None => continue,
};
let located: Option<String> = self
.conn
.query_row(
"SELECT chain_id FROM hash_index WHERE row_hash = ?",
params![tip_hash],
|r| r.get(0),
)
.optional()
.map_err(backend)?;
match located {
None => mismatches.push(format!(
"tip_hash_not_in_index: entity={} hash={}",
entity_id, tip_hash
)),
Some(idx_chain) => {
if Some(idx_chain.as_str()) != chain_id.as_deref() {
mismatches.push(format!(
"chain_id_mismatch: entity={} expected={:?} got={}",
entity_id, chain_id, idx_chain
));
}
}
}
entities_checked += 1;
}
Ok(KvCrossCheckReport {
ok: mismatches.is_empty(),
kv1_total_hashes: kv1_total_hashes as usize,
kv2_total_entities,
entities_checked,
mismatches,
})
}
fn with_txn(
&mut self,
f: &mut dyn FnMut(&mut dyn Store) -> StoreResult<()>,
) -> StoreResult<()> {
self.conn
.execute_batch("BEGIN IMMEDIATE")
.map_err(backend)?;
match f(self) {
Ok(()) => {
self.conn.execute_batch("COMMIT").map_err(backend)?;
Ok(())
}
Err(e) => {
let _ = self.conn.execute_batch("ROLLBACK");
Err(e)
}
}
}
}
// Allow callers that already hold a SqliteError-returning function
// to map into StoreError trivially.
impl From<SqliteError> for StoreError {
fn from(e: SqliteError) -> Self {
StoreError::Backend(e.to_string())
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,569 @@
//! Custody annotations across real, separately-owned chains.
//!
//! Each node here is a genuinely separate store with its own signing key
//! and its own chain. Nothing writes to anybody else's, which is the
//! property the design rests on: a node cannot forge an annotation it did not
//! perform, because it can only sign its own.
use trusty_core::{
Layer,
entity_id_for, verify_annotations, Ed25519Signer, Annotation, SecretKey, SourceRef,
AnnotationProblem,
};
use trusty_sqlite::Trusty;
const TAG: &str = "sha256:d41d8cd9";
/// A node: its own store, its own key, its own chain.
struct Node {
name: String,
store: Trusty,
signer: Ed25519Signer,
}
impl Node {
fn new(name: &str, seed: u8) -> Self {
Self {
name: name.to_string(),
store: Trusty::open_in_memory(SecretKey::new([seed; 16])).expect("store"),
signer: Ed25519Signer::generate(),
}
}
/// Record that this node holds the data, and say where it came from.
/// Returns the reference a downstream node will cite.
fn record(&mut self, from: Option<SourceRef>, note: &str) -> SourceRef {
let mut annotation = Annotation::custody(TAG, &self.name).because(note);
annotation.received_from = from;
let entity = entity_id_for(TAG, &self.name);
let appended = self
.store
.append_signed(
&entity,
&annotation.to_data(),
"custody://test",
&self.signer,
)
.expect("append");
let bundle = self.store.export_bundle(&entity, None, None).expect("export");
let row = bundle
.rows
.iter()
.find(|r| r.row_hash.as_deref() == Some(appended.row_hash.as_str()))
.expect("the row just written must be in the bundle");
SourceRef {
chain_id: row.chain_id.clone(),
sequence: row.sequence,
row_hash: appended.row_hash.clone(),
}
}
fn bundle(&self) -> trusty_core::ProofBundle {
self.store
.export_bundle(&entity_id_for(TAG, &self.name), None, None)
.expect("export")
}
}
#[test]
fn a_path_across_three_nodes_reads_in_order() {
let mut origin = Node::new("origin", 1);
let mut relay = Node::new("relay", 2);
let mut sink = Node::new("sink", 3);
let a = origin.record(None, "produced here");
let b = relay.record(Some(a), "forwarded");
sink.record(Some(b), "delivered");
let report = verify_annotations(&[origin.bundle(), relay.bundle(), sink.bundle()], TAG);
assert!(
report.intact(),
"three honest nodes must produce a clean path; problems: {:?}",
report.problems
);
assert_eq!(
report.custody_line(),
vec!["origin", "relay", "sink"],
"the order is reconstructed from the links, not from the order the \
bundles happened to be passed in"
);
assert_eq!(report.found, 3);
}
#[test]
fn the_order_does_not_depend_on_the_order_bundles_arrive_in() {
let mut origin = Node::new("origin", 1);
let mut relay = Node::new("relay", 2);
let mut sink = Node::new("sink", 3);
let a = origin.record(None, "produced here");
let b = relay.record(Some(a), "forwarded");
sink.record(Some(b), "delivered");
let shuffled = verify_annotations(&[sink.bundle(), origin.bundle(), relay.bundle()], TAG);
assert_eq!(
shuffled.custody_line(),
vec!["origin", "relay", "sink"],
"a merge that depended on argument order would be reporting the \
caller's filing habits rather than the history"
);
}
#[test]
fn a_missing_middle_node_is_reported_not_papered_over() {
let mut origin = Node::new("origin", 1);
let mut relay = Node::new("relay", 2);
let mut sink = Node::new("sink", 3);
let a = origin.record(None, "produced here");
let b = relay.record(Some(a), "forwarded");
sink.record(Some(b), "delivered");
// The relay's bundle is withheld -- exactly what a relay that would
// rather not be in the record would do.
let report = verify_annotations(&[origin.bundle(), sink.bundle()], TAG);
assert!(
!report.intact(),
"a gap must not read as a clean history; problems: {:?}",
report.problems
);
assert!(
report
.problems
.iter()
.any(|p| matches!(p, AnnotationProblem::DanglingPredecessor { node, .. } if node == "sink")),
"the sink cites a row nobody can produce, and that is the finding; \
got {:?}",
report.problems
);
}
#[test]
fn a_hop_citing_a_row_that_was_altered_does_not_link() {
let mut origin = Node::new("origin", 1);
let mut relay = Node::new("relay", 2);
let a = origin.record(None, "produced here");
// The relay cites the right position but the wrong content -- what a
// forged provenance claim looks like when the citation is guessed.
relay.record(
Some(SourceRef {
row_hash: "00".repeat(32),
..a
}),
"claims to have had it from origin",
);
let report = verify_annotations(&[origin.bundle(), relay.bundle()], TAG);
assert!(
report
.problems
.iter()
.any(|p| matches!(p, AnnotationProblem::DanglingPredecessor { .. })),
"naming a real position with the wrong hash must not link; the hash \
is what makes the citation a citation; got {:?}",
report.problems
);
}
#[test]
fn two_nodes_receiving_from_the_same_hop_is_reported_as_a_fork() {
let mut origin = Node::new("origin", 1);
let mut left = Node::new("left", 2);
let mut right = Node::new("right", 3);
let a = origin.record(None, "produced here");
left.record(Some(a.clone()), "one copy");
right.record(Some(a), "another copy");
let report = verify_annotations(&[origin.bundle(), left.bundle(), right.bundle()], TAG);
assert!(
report
.problems
.iter()
.any(|p| matches!(p, AnnotationProblem::Fork { .. })),
"fanning out is legitimate, but a caller expecting a line must be \
told it got a tree; got {:?}",
report.problems
);
assert!(
!report.intact(),
"intact() is the strict reading; a caller who accepts forks reads \
problems and decides"
);
}
#[test]
fn two_claimed_origins_are_reported() {
let mut one = Node::new("one", 1);
let mut two = Node::new("two", 2);
one.record(None, "I made this");
two.record(None, "no, I made this");
let report = verify_annotations(&[one.bundle(), two.bundle()], TAG);
assert!(
report
.problems
.iter()
.any(|p| matches!(p, AnnotationProblem::MultipleOrigins { .. })),
"the same data cannot originate twice, and which claim is false is \
not something the log can settle -- but it can refuse to pick; got {:?}",
report.problems
);
assert!(report.hops.is_empty(), "with no single origin there is no order");
}
#[test]
fn traffic_about_other_data_is_ignored() {
let mut origin = Node::new("origin", 1);
let mut relay = Node::new("relay", 2);
// The relay's chain also carries an annotation about something else.
let other = Annotation::custody("sha256:something-else", &relay.name);
relay
.store
.append_signed(
&entity_id_for(TAG, &relay.name),
&other.to_data(),
"custody://test",
&relay.signer,
)
.expect("append other");
let a = origin.record(None, "produced here");
relay.record(Some(a), "forwarded");
let report = verify_annotations(&[origin.bundle(), relay.bundle()], TAG);
assert!(
report.intact(),
"a bundle may carry unrelated traffic; problems: {:?}",
report.problems
);
assert_eq!(
report.found, 2,
"only annotations about the tag asked for count, or a busy node would \
appear to have handled everything"
);
}
#[test]
fn nothing_recorded_is_not_a_clean_history() {
let origin = Node::new("origin", 1);
let report = verify_annotations(&[origin.bundle()], TAG);
assert!(
!report.intact(),
"no records at all must not read as an unbroken chain of custody; \
the difference matters most exactly when someone is asking"
);
assert_eq!(report.found, 0);
}
#[test]
fn a_tampered_bundle_takes_no_part_in_the_path() {
let mut origin = Node::new("origin", 1);
let mut relay = Node::new("relay", 2);
let a = origin.record(None, "produced here");
relay.record(Some(a), "forwarded");
// Rewrite what the relay claims it did, after export. The signature
// no longer covers it.
let mut forged = relay.bundle();
for row in forged.rows.iter_mut() {
if let Some(payload) = row.raw_payload.as_mut() {
*payload = payload.replace("forwarded", "never touched it");
}
}
let report = verify_annotations(&[origin.bundle(), forged], TAG);
assert!(
report
.problems
.iter()
.any(|p| matches!(p, AnnotationProblem::BundleUnverified { .. })),
"a bundle whose rows no longer match their signatures must be \
reported as such; got {:?}",
report.problems
);
assert_eq!(
report.found, 1,
"and its rows must not contribute hops -- a tidy custody report \
built over forged rows is worse than no report at all"
);
assert!(!report.intact());
}
#[test]
fn an_honest_bundle_beside_a_forged_one_still_counts() {
let mut origin = Node::new("origin", 1);
let mut relay = Node::new("relay", 2);
let a = origin.record(None, "produced here");
relay.record(Some(a), "forwarded");
let mut forged = relay.bundle();
for row in forged.rows.iter_mut() {
if let Some(payload) = row.raw_payload.as_mut() {
*payload = payload.replace("forwarded", "never touched it");
}
}
let report = verify_annotations(&[origin.bundle(), forged], TAG);
// The origin is untouched and still says what it said. One party
// lying must not erase another party's honest record.
assert_eq!(
report.custody_line(),
vec!["origin"],
"the surviving path is what the verified bundles support, no more \
and no less; got {:?}",
report.custody_line()
);
}
// ---------------------------------------------------------------------
// Kinds beyond custody.
// ---------------------------------------------------------------------
impl Node {
/// Record that this node ran a check, and what it concluded.
fn checked(&mut self, ok: bool, note: &str) {
let annotation = Annotation::integrity(TAG, &self.name, ok)
.at_layer(Layer::Device)
.because(note);
self.store
.append_signed(
&entity_id_for(TAG, &self.name),
&annotation.to_data(),
"custody://test",
&self.signer,
)
.expect("append");
}
}
#[test]
fn a_failed_check_becomes_an_actionable_marker() {
let mut origin = Node::new("origin", 1);
let mut relay = Node::new("relay", 2);
let a = origin.record(None, "produced here");
relay.record(Some(a), "forwarded");
relay.checked(false, "checksum mismatch on receipt");
let report = verify_annotations(&[origin.bundle(), relay.bundle()], TAG);
assert_eq!(
report.failed_checks().len(),
1,
"a node that ran a check and found a problem must surface it \
separately from structural findings, so a peer can decide to \
refuse, quarantine or inspect; got {:?}",
report.problems
);
assert!(
!report.intact(),
"a datum somebody found wrong must not read as clean"
);
assert_eq!(
report.custody_line(),
vec!["origin", "relay"],
"the custody path is still readable; a failed check is a finding \
about the datum, not a reason to lose the history"
);
}
#[test]
fn a_passed_check_is_recorded_without_becoming_a_problem() {
let mut origin = Node::new("origin", 1);
let mut relay = Node::new("relay", 2);
let a = origin.record(None, "produced here");
relay.record(Some(a), "forwarded");
relay.checked(true, "checksum matched");
let report = verify_annotations(&[origin.bundle(), relay.bundle()], TAG);
assert!(
report.intact(),
"a check that passed is evidence in favour, not a finding; \
problems: {:?}",
report.problems
);
assert_eq!(
report.found, 3,
"the passing check must still be counted -- knowing a check RAN is \
the difference between verified and merely unexamined"
);
}
#[test]
fn a_custody_claim_is_not_read_as_a_check_that_passed() {
let mut origin = Node::new("origin", 1);
origin.record(None, "produced here");
let report = verify_annotations(&[origin.bundle()], TAG);
assert!(
report.failed_checks().is_empty(),
"holding data is not a claim about its correctness"
);
assert_eq!(
report.found, 1,
"and it is still an annotation, counted as one"
);
}
#[test]
fn an_annotation_of_an_unknown_kind_is_reported_not_ignored() {
let mut origin = Node::new("origin", 1);
origin.record(None, "produced here");
let mut odd = Annotation::custody(TAG, &origin.name);
odd.kind = trusty_core::AnnotationKind::Other("future.kind.v9".into());
origin
.store
.append_signed(
&entity_id_for(TAG, &origin.name),
&odd.to_data(),
"custody://test",
&origin.signer,
)
.expect("append");
let report = verify_annotations(&[origin.bundle()], TAG);
assert!(
report.problems.iter().any(|p| matches!(
p,
AnnotationProblem::UnrecognisedKind { kind, .. } if kind == "future.kind.v9"
)),
"silence would let a future kind carrying a failed check pass an old \
verifier unnoticed; got {:?}",
report.problems
);
}
#[test]
fn a_forged_bundle_cannot_erase_a_failed_check() {
let mut origin = Node::new("origin", 1);
let mut relay = Node::new("relay", 2);
let a = origin.record(None, "produced here");
relay.record(Some(a), "forwarded");
relay.checked(false, "checksum mismatch on receipt");
// The relay rewrites its own bundle to say the check passed.
let mut forged = relay.bundle();
for row in forged.rows.iter_mut() {
if let Some(payload) = row.raw_payload.as_mut() {
*payload = payload.replace("\"satisfied\":false", "\"satisfied\":true");
}
}
let report = verify_annotations(&[origin.bundle(), forged], TAG);
assert!(
report.problems.iter().any(|p| matches!(
p,
AnnotationProblem::BundleUnverified { .. }
)),
"editing a finding out of your own bundle must break its signature, \
not quietly succeed; got {:?}",
report.problems
);
assert!(
!report.intact(),
"and the result must not read as clean just because the evidence was \
removed"
);
}
// ---------------------------------------------------------------------
// Credential formats. A WebAuthn credential must never be checked as a
// bare signature.
// ---------------------------------------------------------------------
#[test]
fn a_cose_producer_is_refused_by_the_bare_signature_path() {
use trusty_core::KeyFormat;
let mut trusty = Trusty::open_in_memory(SecretKey::new([4u8; 16])).expect("store");
let signer = Ed25519Signer::generate();
trusty
.append_signed("doc", &serde_json::json!({ "v": 1 }), "t://", &signer)
.expect("append");
// A user enrolled through WebAuthn: the credential is a COSE key,
// and its signatures cover authenticatorData ||
// SHA-256(clientDataJSON) rather than the message.
trusty
.register_producer_with_format("user:01H", &[0u8; 32], &[0u8; 16], None, KeyFormat::Cose)
.expect("enrol");
let err = trusty
.verify_with_producer("doc", "user:01H")
.expect_err("must refuse");
let msg = err.to_string();
assert!(
msg.contains("clientDataJSON"),
"the refusal must explain WHY, or the next person reads it as a wrong key and loosens the check until it passes: {msg}"
);
assert!(
!msg.contains("verification failed"),
"and it must not look like a failed verification -- that confusion is what the change exists to remove: {msg}"
);
}
#[test]
fn a_raw_producer_is_still_verified_normally() {
use trusty_core::KeyFormat;
let mut trusty = Trusty::open_in_memory(SecretKey::new([6u8; 16])).expect("store");
let signer = Ed25519Signer::generate();
trusty
.append_signed("doc", &serde_json::json!({ "v": 1 }), "t://", &signer)
.expect("append");
trusty
.register_producer_with_format(
"agent:01J",
&[0u8; 32],
&[0u8; 16],
None,
KeyFormat::Ed25519Raw,
)
.expect("enrol");
// May or may not verify -- the key is a placeholder. What matters is
// that it is not REFUSED: the raw path must be untouched, or this
// change would have broken every existing producer.
match trusty.verify_with_producer("doc", "agent:01J") {
Ok(_) => {}
Err(e) => assert!(
!e.to_string().contains("unsupported credential"),
"a raw Ed25519 producer must reach the signature check, not be turned away before it: {e}"
),
}
}
#[test]
fn a_producer_enrolled_before_the_column_existed_reads_as_raw() {
let mut trusty = Trusty::open_in_memory(SecretKey::new([5u8; 16])).expect("store");
trusty
.register_producer("agent:old", &[0u8; 32], &[0u8; 16], None)
.expect("enrol");
let found = trusty
.lookup_producer("agent:old")
.expect("lookup")
.expect("row");
assert_eq!(
found.key_format,
trusty_core::KeyFormat::Ed25519Raw,
"every credential enrolled before the column existed WAS a raw Ed25519 key, so the schema default is correct -- applied at write time, never as a parse fallback"
);
}

View File

@@ -0,0 +1,8 @@
use trusty_sqlite::SqliteStore;
use trusty_store::testing::run_conformance;
#[test]
fn sqlite_store_passes_conformance() {
let mut store = SqliteStore::open_in_memory().expect("open in-memory sqlite store");
run_conformance(&mut store);
}

View File

@@ -0,0 +1,16 @@
[package]
name = "trusty-tokio"
version.workspace = true
edition.workspace = true
authors.workspace = true
license.workspace = true
repository.workspace = true
rust-version.workspace = true
description = "Runs a blocking trusty Store on a tokio runtime's blocking pool, so an async caller does not park a worker thread. An adapter, not a core concern: a runtime is a substrate."
[dependencies]
trusty-store = { workspace = true }
tokio = { workspace = true, features = ["rt", "sync"] }
[dev-dependencies]
tokio = { workspace = true, features = ["rt", "rt-multi-thread", "macros", "time", "sync"] }

View File

@@ -0,0 +1,264 @@
#![deny(clippy::all)]
//! An async face for a blocking [`Store`].
//!
//! # Why this is an adapter and not a core concern
//!
//! A runtime is a substrate. `trusty-core` touches no filesystem, clock,
//! thread, socket or process API, and `trusty-store` is four pure
//! dependencies; putting an optional tokio in either would give a crate
//! designed to have no platform a platform -- even switched off, it is
//! in the manifest and in the lockfile.
//!
//! So this lives beside `trusty-sqlite` and `trusty-postgres`, named for
//! its substrate the same way they are. A consumer on async-std, smol,
//! or no runtime at all pays nothing and is not told tokio is the
//! answer.
//!
//! # What this is, and what it is not
//!
//! This is async **ergonomics**, not async **I/O**. Every call still runs
//! the same blocking code on the same backend; it simply runs it on a
//! blocking pool instead of on the async runtime's worker threads.
//! SQLite writes still serialise, one at a time. A genuinely concurrent
//! backend would be a different adapter, not this one.
//!
//! Saying so plainly matters, because the failure this prevents and the
//! failure it does not prevent look alike from outside.
//!
//! # The problem it does solve
//!
//! An async runtime has a small pool of worker threads running every
//! task. A blocking call inside a task **parks a whole worker** for the
//! duration of a write and its fsync, during which that thread runs
//! nothing else. Enough concurrent appends and the runtime starves:
//! tasks with nothing to do with trusty simply stop making progress.
//!
//! Distant Star is the consumer this was waiting for -- it is tokio
//! throughout, and a relay writing a custody annotation does so inside
//! an async task while it is also moving packets.
//!
//! # Two things deliberately do not cross
//!
//! [`Store::iter_chain`] returns an iterator borrowing the store, and
//! [`Store::with_txn`] takes a closure that is not `'static`. Neither can
//! be sent to another thread, so neither is mirrored here:
//!
//! - **`chain_rows`** materialises the chain into a `Vec` instead of
//! streaming it. That is a real difference and not a free one -- the
//! result is bounded by memory rather than by patience -- so
//! [`AsyncStore::chain_rows_bounded`] exists for callers who would
//! rather cap it than find out.
//! - **`with_txn_owned`** takes an owned `FnOnce` that can be moved to
//! the blocking pool. Callers return values out of it directly rather
//! than capturing outer state by reference, because a reference to
//! outer state is exactly what cannot travel.
pub mod pool;
pub use pool::BlockingPool;
use std::sync::{Arc, Mutex};
use trusty_store::{
KvCrossCheckReport, LastRow, RowInsert, Store, StoreError, StoreResult, StoredRow, TipRow,
TipUpsert,
};
/// The async surface. Mirrors [`Store`] where mirroring is possible, and
/// says so where it is not.
///
/// Not dyn-compatible: `async fn` in a trait cannot be. Consumers that
/// need dynamic dispatch should hold [`BlockingStore`] concretely, or
/// wrap it themselves.
#[allow(async_fn_in_trait)]
pub trait AsyncStore {
async fn last_row(&self, entity_id: &str) -> StoreResult<Option<LastRow>>;
async fn insert_row(&self, row: RowInsert) -> StoreResult<i64>;
async fn get_tip(&self, chain_id: &str) -> StoreResult<Option<TipRow>>;
async fn get_tip_for_entity(&self, entity_id: &str) -> StoreResult<Option<TipRow>>;
async fn set_tip(&self, tip: TipUpsert) -> StoreResult<()>;
async fn kv_cross_check(&self) -> StoreResult<KvCrossCheckReport>;
/// The whole chain, materialised.
///
/// Unlike [`Store::iter_chain`] this cannot stream: the iterator
/// borrows the store, and a borrow cannot be sent to a blocking
/// thread. Prefer [`AsyncStore::chain_rows_bounded`] on any chain
/// whose length is not known.
async fn chain_rows(&self, entity_id: &str) -> StoreResult<Vec<StoredRow>>;
/// At most `limit` rows, and whether more were left behind.
///
/// Returning the "there is more" flag rather than silently stopping
/// is the whole point: a truncated history that looks complete is
/// how a verifier concludes a chain is intact when it has only seen
/// the part that is.
async fn chain_rows_bounded(
&self,
entity_id: &str,
limit: usize,
) -> StoreResult<(Vec<StoredRow>, bool)>;
}
/// Runs a blocking [`Store`] on the runtime's blocking pool.
///
/// Cloneable and shareable: every clone talks to the same store behind
/// the same lock, so concurrent callers serialise rather than race. That
/// is not a limitation this type imposes -- it is the backend's, made
/// visible instead of hidden.
pub struct BlockingStore<S: Store + Send + 'static> {
inner: Arc<Mutex<S>>,
}
impl<S: Store + Send + 'static> Clone for BlockingStore<S> {
fn clone(&self) -> Self {
Self {
inner: Arc::clone(&self.inner),
}
}
}
impl<S: Store + Send + 'static> BlockingStore<S> {
pub fn new(store: S) -> Self {
Self {
inner: Arc::new(Mutex::new(store)),
}
}
/// Take the store back. Fails if any clone is still alive.
pub fn into_inner(self) -> Result<S, Self> {
match Arc::try_unwrap(self.inner) {
Ok(mutex) => Ok(mutex.into_inner().unwrap_or_else(|e| e.into_inner())),
Err(inner) => Err(Self { inner }),
}
}
/// Run an owned closure against the store on the blocking pool.
///
/// The escape hatch for everything this facade does not mirror,
/// including transactions. The closure must be `'static` and `Send`,
/// which is not a formality: a closure borrowing local state is
/// precisely what cannot be moved to another thread.
pub async fn with_store<T, F>(&self, f: F) -> StoreResult<T>
where
F: FnOnce(&mut S) -> StoreResult<T> + Send + 'static,
T: Send + 'static,
{
let inner = Arc::clone(&self.inner);
run_blocking(move || {
let mut guard = inner.lock().unwrap_or_else(|e| e.into_inner());
f(&mut guard)
})
.await
}
/// A transaction, with its result carried out by value.
///
/// [`Store::with_txn`] captures results through outer state because
/// its closure cannot return one. Here the closure is owned, so it
/// can -- which removes the need for the `Option` dance the blocking
/// API requires, and with it the chance of reading the captured slot
/// on a path where the transaction rolled back.
pub async fn with_txn_owned<T, F>(&self, f: F) -> StoreResult<T>
where
F: FnOnce(&mut dyn Store) -> StoreResult<T> + Send + 'static,
T: Send + 'static,
{
self.with_store(move |store| {
let mut captured: Option<T> = None;
let mut once = Some(f);
store.with_txn(&mut |txn: &mut dyn Store| {
let f = once.take().ok_or_else(|| {
StoreError::Backend(
"the backend invoked the transaction body more than once, which \
this facade cannot support: an owned closure can only run once"
.to_string(),
)
})?;
captured = Some(f(txn)?);
Ok(())
})?;
captured.ok_or_else(|| {
StoreError::Backend(
"the transaction committed without the body having run".to_string(),
)
})
})
.await
}
}
/// Hand work to the blocking pool, and turn a panic there into an error
/// rather than letting it take the caller down.
///
/// A panic inside a store operation is a bug, but a bug that unwinds
/// into an unrelated task is a much harder bug to find than one that
/// arrives as a `Backend` error at the call site that caused it.
async fn run_blocking<T, F>(f: F) -> StoreResult<T>
where
F: FnOnce() -> StoreResult<T> + Send + 'static,
T: Send + 'static,
{
match tokio::task::spawn_blocking(f).await {
Ok(result) => result,
Err(join) => Err(StoreError::Backend(format!(
"the store operation did not finish: {join}"
))),
}
}
impl<S: Store + Send + 'static> AsyncStore for BlockingStore<S> {
async fn last_row(&self, entity_id: &str) -> StoreResult<Option<LastRow>> {
let entity_id = entity_id.to_string();
self.with_store(move |s| s.last_row(&entity_id)).await
}
async fn insert_row(&self, row: RowInsert) -> StoreResult<i64> {
self.with_store(move |s| s.insert_row(&row)).await
}
async fn get_tip(&self, chain_id: &str) -> StoreResult<Option<TipRow>> {
let chain_id = chain_id.to_string();
self.with_store(move |s| s.get_tip(&chain_id)).await
}
async fn get_tip_for_entity(&self, entity_id: &str) -> StoreResult<Option<TipRow>> {
let entity_id = entity_id.to_string();
self.with_store(move |s| s.get_tip_for_entity(&entity_id))
.await
}
async fn set_tip(&self, tip: TipUpsert) -> StoreResult<()> {
self.with_store(move |s| s.set_tip(&tip)).await
}
async fn kv_cross_check(&self) -> StoreResult<KvCrossCheckReport> {
self.with_store(|s| s.kv_cross_check()).await
}
async fn chain_rows(&self, entity_id: &str) -> StoreResult<Vec<StoredRow>> {
let entity_id = entity_id.to_string();
self.with_store(move |s| s.iter_chain(&entity_id)?.collect())
.await
}
async fn chain_rows_bounded(
&self,
entity_id: &str,
limit: usize,
) -> StoreResult<(Vec<StoredRow>, bool)> {
let entity_id = entity_id.to_string();
self.with_store(move |s| {
let mut iter = s.iter_chain(&entity_id)?;
let mut rows = Vec::new();
for row in iter.by_ref().take(limit) {
rows.push(row?);
}
// Asking for one more is how "there is more" is learned
// without walking the rest of a chain nobody asked for.
let more = iter.next().is_some();
Ok((rows, more))
})
.await
}
}

View File

@@ -0,0 +1,282 @@
//! A pool of stores, so concurrent callers are not queued behind one.
//!
//! # Why a pool rather than removing the lock
//!
//! [`crate::BlockingStore`] holds one store behind one mutex, so every
//! caller serialises. That looks like needless queuing to delete, and it
//! is not: `Store::insert_row(&mut self)` requires exclusive access by
//! its signature. The mutex is the trait's demand, not this type's
//! choice.
//!
//! Concurrency therefore needs **more stores**, not fewer locks. Each
//! holds its own connection, so two calls genuinely run at once.
//!
//! # The correctness this depends on
//!
//! Two connections appending to the same entity would race the chain
//! tip: appending is read-tip, compute, insert, and those are not
//! atomic. `trusty-postgres` holds a per-entity advisory lock across
//! that sequence, so same-entity writers serialise in the database while
//! different entities proceed in parallel.
//!
//! **That lock is a precondition of this type, not a detail.** Before it
//! existed, the single mutex was the only thing making concurrent
//! appends safe, and pooling without it would have converted a slowness
//! into a lost update -- a trade that reads as a performance win in a
//! diff.
//!
//! A backend with no such lock, or one writer per file like SQLite,
//! should keep using [`crate::BlockingStore`].
use std::sync::atomic::{AtomicUsize, Ordering};
use std::sync::{Arc, Mutex};
use tokio::sync::Semaphore;
use trusty_store::{Store, StoreError, StoreResult};
/// Builds a fresh store, each with its own connection.
pub type Factory<S> = Arc<dyn Fn() -> StoreResult<S> + Send + Sync>;
/// Runs blocking store work across several connections.
pub struct BlockingPool<S: Store + Send + 'static> {
idle: Arc<Mutex<Vec<S>>>,
make: Factory<S>,
/// Bounds concurrency to the connection budget. Without it, load
/// would open connections until the database refused them, and the
/// refusal would arrive as a failed append rather than as waiting.
permits: Arc<Semaphore>,
live: Arc<AtomicUsize>,
max: usize,
}
impl<S: Store + Send + 'static> Clone for BlockingPool<S> {
fn clone(&self) -> Self {
Self {
idle: Arc::clone(&self.idle),
make: Arc::clone(&self.make),
permits: Arc::clone(&self.permits),
live: Arc::clone(&self.live),
max: self.max,
}
}
}
impl<S: Store + Send + 'static> BlockingPool<S> {
/// A pool of at most `max` connections, opened on demand.
///
/// Connections are created lazily rather than up front: a pool that
/// opens its full budget at startup turns a misconfigured connection
/// string into a failure at boot for capacity nobody has asked for
/// yet.
pub fn new(max: usize, make: impl Fn() -> StoreResult<S> + Send + Sync + 'static) -> Self {
let max = max.max(1);
Self {
idle: Arc::new(Mutex::new(Vec::new())),
make: Arc::new(make),
permits: Arc::new(Semaphore::new(max)),
live: Arc::new(AtomicUsize::new(0)),
max,
}
}
pub fn capacity(&self) -> usize {
self.max
}
/// Connections currently open. Rises to `capacity` under load and
/// does not fall; a pool that closed idle connections would pay
/// reconnection cost exactly when traffic returned.
pub fn open_connections(&self) -> usize {
self.live.load(Ordering::SeqCst)
}
/// Run `f` against a store from the pool.
///
/// The store is moved into the blocking closure and handed back with
/// the result, so ownership never has to be shared and no lock is
/// held while the work runs.
///
/// A store whose work returned an error is still returned to the
/// pool: a failed append usually means a rejected row, not a broken
/// connection, and discarding the connection would turn every
/// validation error into a reconnect.
pub async fn with<T, F>(&self, f: F) -> StoreResult<T>
where
F: FnOnce(&mut S) -> StoreResult<T> + Send + 'static,
T: Send + 'static,
{
let _permit = self
.permits
.acquire()
.await
.map_err(|_| StoreError::Backend("the store pool has been closed".into()))?;
let mut store = match self.idle.lock().unwrap_or_else(|e| e.into_inner()).pop() {
Some(s) => s,
None => {
let s = (self.make)()?;
self.live.fetch_add(1, Ordering::SeqCst);
s
}
};
let joined = tokio::task::spawn_blocking(move || {
let out = f(&mut store);
(store, out)
})
.await;
match joined {
Ok((store, out)) => {
self.idle
.lock()
.unwrap_or_else(|e| e.into_inner())
.push(store);
out
}
Err(join) => {
// The store went down with the panicking task, so the
// connection is gone rather than merely idle.
self.live.fetch_sub(1, Ordering::SeqCst);
Err(StoreError::Backend(format!(
"the store operation did not finish and its connection was lost: {join}"
)))
}
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::time::Duration;
use trusty_store::{KvCrossCheckReport, LastRow, RowInsert, StoredRow, TipRow, TipUpsert};
struct Slow {
id: usize,
delay: Duration,
}
impl Store for Slow {
fn last_row(&self, _e: &str) -> StoreResult<Option<LastRow>> {
std::thread::sleep(self.delay);
Ok(None)
}
fn insert_row(&mut self, _r: &RowInsert) -> StoreResult<i64> {
std::thread::sleep(self.delay);
Ok(self.id as i64)
}
fn iter_chain<'a>(
&'a self,
_e: &str,
) -> StoreResult<Box<dyn Iterator<Item = StoreResult<StoredRow>> + 'a>> {
Ok(Box::new(std::iter::empty()))
}
fn get_tip(&self, _c: &str) -> StoreResult<Option<TipRow>> {
Ok(None)
}
fn get_tip_for_entity(&self, _e: &str) -> StoreResult<Option<TipRow>> {
Ok(None)
}
fn set_tip(&mut self, _t: &TipUpsert) -> StoreResult<()> {
Ok(())
}
fn kv_cross_check(&self) -> StoreResult<KvCrossCheckReport> {
Err(StoreError::Backend("not implemented".into()))
}
fn with_txn(
&mut self,
f: &mut dyn FnMut(&mut dyn Store) -> StoreResult<()>,
) -> StoreResult<()> {
let mut scratch = Slow {
id: self.id,
delay: Duration::ZERO,
};
f(&mut scratch)
}
}
fn pool(max: usize, delay_ms: u64) -> BlockingPool<Slow> {
let next = Arc::new(AtomicUsize::new(0));
BlockingPool::new(max, move || {
Ok(Slow {
id: next.fetch_add(1, Ordering::SeqCst),
delay: Duration::from_millis(delay_ms),
})
})
}
/// **The claim.** Four slow calls across four connections finish in
/// far less than four times one call.
#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
async fn calls_run_concurrently_rather_than_queueing() {
let p = pool(4, 250);
let start = std::time::Instant::now();
let mut hs = Vec::new();
for _ in 0..4 {
let p = p.clone();
hs.push(tokio::spawn(async move { p.with(|s| s.last_row("e")).await }));
}
for h in hs {
h.await.expect("join").expect("call");
}
let elapsed = start.elapsed();
assert!(
elapsed < Duration::from_millis(700),
"four 250ms calls on four connections took {elapsed:?}. Serialised \
they would take about a second, which is what a single store \
behind one mutex does and what this type exists to stop"
);
}
#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
async fn concurrency_is_bounded_by_the_connection_budget() {
let p = pool(2, 60);
let mut hs = Vec::new();
for _ in 0..8 {
let p = p.clone();
hs.push(tokio::spawn(async move { p.with(|s| s.last_row("e")).await }));
}
for h in hs {
h.await.expect("join").expect("call");
}
assert!(
p.open_connections() <= 2,
"eight callers opened {} connections against a budget of 2. Without \
the bound, load opens connections until the database refuses, and \
the refusal arrives as a failed append rather than as waiting",
p.open_connections()
);
}
#[tokio::test]
async fn connections_are_reused_not_opened_per_call() {
let p = pool(4, 0);
for _ in 0..20 {
p.with(|s| s.last_row("e")).await.expect("call");
}
assert_eq!(
p.open_connections(),
1,
"sequential calls must reuse one connection; opening one per call \
would make the pool slower than the mutex it replaces"
);
}
#[tokio::test]
async fn a_failed_call_returns_its_connection_to_the_pool() {
let p = pool(2, 0);
let _ = p
.with(|s| s.kv_cross_check().map(|_| ()))
.await
.expect_err("this store returns an error for that call");
p.with(|s| s.last_row("e")).await.expect("still usable");
assert_eq!(
p.open_connections(),
1,
"a failed append usually means a rejected row, not a broken \
connection; discarding it would turn every validation error into \
a reconnect"
);
}
}

View File

@@ -0,0 +1,280 @@
//! Does the async facade actually keep the runtime free?
//!
//! That is the only claim it makes, so it is the one worth testing.
//! Everything else here exists because it is a way the facade could be
//! quietly wrong: a lost value, a swallowed panic, a truncation that
//! reads as a complete chain.
use std::sync::atomic::{AtomicUsize, Ordering};
use std::sync::Arc;
use std::time::Duration;
use trusty_store::{
KvCrossCheckReport, LastRow, RowInsert, Store, StoreError, StoreResult, StoredRow, TipRow,
TipUpsert,
};
use trusty_tokio::{AsyncStore, BlockingStore};
/// A store that is deliberately slow, so blocking is observable.
struct SlowStore {
delay: Duration,
rows: usize,
inserts: Arc<AtomicUsize>,
panic_on_insert: bool,
}
impl SlowStore {
fn new(delay_ms: u64) -> Self {
Self {
delay: Duration::from_millis(delay_ms),
rows: 0,
inserts: Arc::new(AtomicUsize::new(0)),
panic_on_insert: false,
}
}
fn with_rows(mut self, rows: usize) -> Self {
self.rows = rows;
self
}
}
fn stored_row(sequence: i64) -> StoredRow {
StoredRow {
id: sequence + 1,
entity_id: "e".into(),
chain_id: "c".into(),
event_type: "MUTATION".into(),
sequence,
prev_hash: String::new(),
row_hash: Some(format!("{sequence:064}")),
anchor_hash: None,
raw_payload: None,
detected_at: String::new(),
received_at: String::new(),
source_url: String::new(),
extraction_method: "trusty".into(),
}
}
impl Store for SlowStore {
fn last_row(&self, _entity_id: &str) -> StoreResult<Option<LastRow>> {
std::thread::sleep(self.delay);
Ok(Some(LastRow {
row_hash: "aa".into(),
sequence: 7,
}))
}
fn insert_row(&mut self, _row: &RowInsert) -> StoreResult<i64> {
std::thread::sleep(self.delay);
if self.panic_on_insert {
panic!("a bug inside a store operation");
}
Ok(self.inserts.fetch_add(1, Ordering::SeqCst) as i64 + 1)
}
fn iter_chain<'a>(
&'a self,
_entity_id: &str,
) -> StoreResult<Box<dyn Iterator<Item = StoreResult<StoredRow>> + 'a>> {
Ok(Box::new((0..self.rows as i64).map(|i| Ok(stored_row(i)))))
}
fn get_tip(&self, _chain_id: &str) -> StoreResult<Option<TipRow>> {
Ok(None)
}
fn get_tip_for_entity(&self, _entity_id: &str) -> StoreResult<Option<TipRow>> {
Ok(None)
}
fn set_tip(&mut self, _tip: &TipUpsert) -> StoreResult<()> {
Ok(())
}
fn kv_cross_check(&self) -> StoreResult<KvCrossCheckReport> {
Err(StoreError::Backend("not implemented for this test".into()))
}
fn with_txn(
&mut self,
f: &mut dyn FnMut(&mut dyn Store) -> StoreResult<()>,
) -> StoreResult<()> {
let mut scratch = SlowStore::new(0);
f(&mut scratch)
}
}
fn row_insert() -> RowInsert {
RowInsert {
entity_id: "e".into(),
chain_id: "c".into(),
event_type: "MUTATION".into(),
sequence: 0,
prev_hash: String::new(),
row_hash: None,
anchor_hash: None,
raw_payload: None,
detected_at: String::new(),
received_at: String::new(),
source_url: String::new(),
extraction_method: "trusty".into(),
}
}
/// **The claim.** A single-worker runtime, one slow store call, and an
/// unrelated task that must keep running while it is in flight.
///
/// Single-threaded on purpose: with more than one worker the test would
/// pass even if the call blocked, because another worker would pick the
/// second task up. One worker is the only configuration where blocking
/// is fatal and therefore the only one that proves anything.
#[tokio::test(flavor = "current_thread")]
async fn a_slow_store_call_does_not_park_the_runtime() {
let store = BlockingStore::new(SlowStore::new(300));
let ticks = Arc::new(AtomicUsize::new(0));
let counter = Arc::clone(&ticks);
let ticker = tokio::spawn(async move {
for _ in 0..20 {
tokio::time::sleep(Duration::from_millis(10)).await;
counter.fetch_add(1, Ordering::SeqCst);
}
});
store.insert_row(row_insert()).await.expect("insert");
let progressed = ticks.load(Ordering::SeqCst);
ticker.abort();
assert!(
progressed > 5,
"the other task must have kept running while the store was busy. \
It ticked {progressed} times; blocking the worker would have left \
it at or near zero, which is exactly the runtime starvation this \
facade exists to prevent"
);
}
#[tokio::test(flavor = "current_thread")]
async fn concurrent_callers_serialise_rather_than_race() {
let store = BlockingStore::new(SlowStore::new(20));
let mut handles = Vec::new();
for _ in 0..8 {
let s = store.clone();
handles.push(tokio::spawn(async move { s.insert_row(row_insert()).await }));
}
let mut ids = Vec::new();
for h in handles {
ids.push(h.await.expect("join").expect("insert"));
}
ids.sort_unstable();
assert_eq!(
ids,
(1..=8).collect::<Vec<i64>>(),
"every caller must get its own row and none may be lost. The \
backend serialises writes; that is its property, made visible \
here rather than hidden"
);
}
#[tokio::test]
async fn a_panic_in_the_store_becomes_an_error_not_a_dead_task() {
let mut slow = SlowStore::new(0);
slow.panic_on_insert = true;
let store = BlockingStore::new(slow);
let result = store.insert_row(row_insert()).await;
assert!(
matches!(result, Err(StoreError::Backend(_))),
"a panic inside a store operation is a bug, but one that unwinds \
into an unrelated task is far harder to find than one arriving as \
an error at the call site that caused it"
);
}
#[tokio::test]
async fn a_bounded_read_says_when_it_left_something_behind() {
let store = BlockingStore::new(SlowStore::new(0).with_rows(10));
let (rows, more) = store.chain_rows_bounded("e", 4).await.expect("bounded");
assert_eq!(rows.len(), 4);
assert!(
more,
"a truncated history that looks complete is how a verifier \
concludes a chain is intact having seen only the part that is"
);
let (all, more) = store.chain_rows_bounded("e", 50).await.expect("bounded");
assert_eq!(all.len(), 10);
assert!(!more, "with nothing left behind it must not claim there is");
}
#[tokio::test]
async fn the_whole_chain_can_still_be_read_when_that_is_wanted() {
let store = BlockingStore::new(SlowStore::new(0).with_rows(5));
let rows = store.chain_rows("e").await.expect("chain");
assert_eq!(
rows.len(),
5,
"iter_chain cannot cross a thread boundary, so this materialises \
instead of streaming -- but it must still return everything"
);
}
#[tokio::test]
async fn a_transaction_carries_its_result_out_by_value() {
let store = BlockingStore::new(SlowStore::new(0));
let answer = store
.with_txn_owned(|txn| {
txn.insert_row(&row_insert())?;
Ok("committed".to_string())
})
.await
.expect("txn");
assert_eq!(
answer, "committed",
"the blocking with_txn captures results through outer state \
because its closure cannot return one; an owned closure can, \
which removes the chance of reading a captured slot on a path \
where the transaction rolled back"
);
}
#[tokio::test]
async fn a_failing_transaction_reports_the_failure_not_a_missing_value() {
let store = BlockingStore::new(SlowStore::new(0));
let result: StoreResult<String> = store
.with_txn_owned(|_txn| Err(StoreError::Backend("deliberate".into())))
.await;
match result {
Err(StoreError::Backend(msg)) => assert_eq!(
msg, "deliberate",
"the body's own error must survive, not be replaced by a \
complaint that no value was produced"
),
other => panic!("expected the body's error, got {other:?}"),
}
}
#[tokio::test]
async fn clones_share_one_store() {
let store = BlockingStore::new(SlowStore::new(0));
let clone = store.clone();
store.insert_row(row_insert()).await.expect("first");
let second = clone.insert_row(row_insert()).await.expect("second");
assert_eq!(
second, 2,
"a clone must talk to the same store, or two halves of an \
application would be appending to different chains while \
believing they shared one"
);
}

View File

@@ -0,0 +1,39 @@
[package]
name = "trusty-core"
version.workspace = true
edition.workspace = true
authors.workspace = true
license.workspace = true
repository.workspace = true
rust-version.workspace = true
description = "Tamper-evident data provenance chain primitives. Dual hash (SipHash-2-4 + SHA-256), chain-break-as-event. Framework neutral, no storage dependency."
[features]
# On by default, so every existing consumer is unaffected and `rand` resolves
# exactly as before. Verified across MITCH-stack, AI-Playbooks, kirbo and
# good-ol-googz: nothing sets `default-features = false` on trusty-core, so
# this gate is invisible to all of them.
default = ["keygen"]
# Generating a fresh Ed25519 key pair. This is the ONLY item in trusty-core
# that needs an operating-system RNG, and therefore the only thing standing
# between this crate and a `wasm32-unknown-unknown` build -- the rest of the
# crate uses no filesystem, clock, thread, socket, or process API at all.
#
# Verification needs no RNG, which is exactly what a browser peer wants. Build
# with `default-features = false` to get hashing, canonicalisation, chain
# construction, signature verification, `from_secret_bytes`, and ProofBundle,
# leaving key generation to the host (Web Crypto, a hardware token, or a key
# supplied from elsewhere).
keygen = ["dep:rand"]
[dependencies]
serde = { workspace = true }
serde_json = { workspace = true }
sha2 = { workspace = true }
siphasher = { workspace = true }
zeroize = { workspace = true }
hex = { workspace = true }
thiserror = { workspace = true }
ed25519-dalek = { workspace = true }
rand = { workspace = true, optional = true }

View File

@@ -0,0 +1,696 @@
//! Signed claims a node makes about a piece of data.
//!
//! # What this is for
//!
//! Encryption in Distant Star is per hop, not end to end -- a relaying
//! peer decrypts a chunk and re-encrypts it onward, so it holds
//! plaintext. Data crosses machines nobody controls, and without a
//! record there is no answer to "which systems touched this, and what
//! did they find?". That is the first question after a spill, and the
//! one DFARS 252.204-7012 and NIST SP 800-171 require an answer to for
//! CUI.
//!
//! # Kinds
//!
//! A node may attest **several different properties** about the same
//! datum, each checkable on its own:
//!
//! - [`AnnotationKind::Custody`] -- I held a copy, and had it from there.
//! - [`AnnotationKind::Integrity`] -- I ran a check; here is the result.
//! - [`AnnotationKind::Derivation`] -- I produced this from that.
//! - [`AnnotationKind::Source`] -- I attest where this originated.
//!
//! The shape follows Project Alvarium's annotation model (`kind`,
//! `is_satisfied`, `layer`). Alvarium's confidence **score** is
//! deliberately not followed: collapsing heterogeneous evidence into one
//! number says how much to worry but not *what failed*, and weighting a
//! TPM attestation against a checksum is a judgement dressed as
//! arithmetic. A consumer wanting a routing signal can score these; the
//! core reports per-property results.
//!
//! What is kept that Alvarium does not have: a **backward link**.
//! Alvarium annotations are independent facts whose ordering comes from
//! a ledger underneath. These carry [`SourceRef`], and trusty's
//! signature covers `prev_hash`, `sequence`, `entity_id` and `chain_id`
//! -- which is why a **gap** is detectable here, and why an annotation
//! cannot be replayed at a different position.
//!
//! # Annotation, not ledger
//!
//! Each node annotates its own copy on a chain of its own,
//! `entity_id = "<tag>:<node>"`. Nobody writes to anybody else's, so a
//! node cannot forge an annotation it did not write. Recovering the
//! picture is a merge across bundles.
//!
//! A custody *chain* in the evidentiary sense presumes exclusivity --
//! the bag is in exactly one place. Copies destroy that: when a node
//! passes data on, the sender still has it. So fan-out is the ordinary
//! case and the shape is a tree rooted at the origin. An annotation is a
//! claim by one party about itself, which fans out without
//! contradiction. (Unrelated to CUSTODY the containment framework, which
//! is not used here.)
//!
//! # Evidence, not enforcement
//!
//! Nothing here stops a node copying data, and a node that annotates
//! nothing is invisible -- no append-only log can record what was never
//! written. What an outside party holding only bundles does get:
//! annotations cannot be **forged**, gaps are **visible**, and history
//! cannot be **rewritten**.
use std::collections::{HashMap, HashSet};
use serde::{Deserialize, Serialize};
use serde_json::{json, Value};
use crate::bundle::{BundleRow, ProofBundle, SourceRef};
/// Discriminator written into `data.kind_of`.
///
/// Versioned in the string: a reader must be able to tell which shape it
/// is holding before it trusts the fields.
pub const ANNOTATION_KIND: &str = "annotation.v1";
/// What property an annotation is about.
///
/// `Other` exists rather than a parse failure so an annotation of a kind
/// this build does not know is **visible** instead of silently dropped.
/// A future kind carrying a failed check must not vanish because an old
/// verifier did not recognise the word.
#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum AnnotationKind {
/// This node held a copy, and had it from `received_from`.
Custody,
/// This node ran an integrity check; `satisfied` carries the result.
Integrity,
/// This node produced the datum from the cited source.
Derivation,
/// This node attests where the datum originated.
Source,
/// A kind this build does not know. Reported, never assumed benign.
#[serde(untagged)]
Other(String),
}
impl AnnotationKind {
pub fn as_str(&self) -> &str {
match self {
Self::Custody => "custody",
Self::Integrity => "integrity",
Self::Derivation => "derivation",
Self::Source => "source",
Self::Other(s) => s,
}
}
/// Whether this build understands the kind well enough to act on it.
pub fn is_known(&self) -> bool {
!matches!(self, Self::Other(_))
}
}
/// Where in the stack the claim was inserted.
///
/// Tells a verifier what an annotation is worth: a check run in the
/// application is a weaker statement than the same check run in
/// hardware, and the two should not read alike.
#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum Layer {
Device,
Host,
Os,
App,
Network,
#[serde(untagged)]
Other(String),
}
impl Layer {
pub fn as_str(&self) -> &str {
match self {
Self::Device => "device",
Self::Host => "host",
Self::Os => "os",
Self::App => "app",
Self::Network => "network",
Self::Other(s) => s,
}
}
}
impl Default for Layer {
fn default() -> Self {
Self::App
}
}
/// One node's signed claim about one datum.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct Annotation {
/// What property this claims. See [`AnnotationKind`].
pub kind: AnnotationKind,
/// What the claim is about. Opaque; a content hash is the obvious
/// choice.
pub content_tag: String,
/// Who is claiming. The owner of the chain this row is on.
pub node: String,
/// Where in the stack the claim was inserted.
#[serde(default)]
pub layer: Layer,
/// Whether a check ran, and what it concluded.
///
/// `None` means **no check was performed**, which is not the same as
/// a check that failed. A custody claim asserts holding, not
/// correctness, and must not read as a passed check.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub satisfied: Option<bool>,
/// The upstream row this claim refers to. `None` at an origin.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub received_from: Option<SourceRef>,
/// Free text, in the `_why` tradition.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub note: Option<String>,
}
impl Annotation {
/// A custody claim: this node held a copy.
pub fn custody(content_tag: impl Into<String>, node: impl Into<String>) -> Self {
Self {
kind: AnnotationKind::Custody,
content_tag: content_tag.into(),
node: node.into(),
layer: Layer::default(),
satisfied: None,
received_from: None,
note: None,
}
}
/// An integrity claim: this node ran a check and it came out `ok`.
pub fn integrity(
content_tag: impl Into<String>,
node: impl Into<String>,
ok: bool,
) -> Self {
Self {
kind: AnnotationKind::Integrity,
satisfied: Some(ok),
..Self::custody(content_tag, node)
}
}
pub fn from(mut self, source: SourceRef) -> Self {
self.received_from = Some(source);
self
}
pub fn at_layer(mut self, layer: Layer) -> Self {
self.layer = layer;
self
}
pub fn because(mut self, note: impl Into<String>) -> Self {
self.note = Some(note.into());
self
}
/// The value to write into the row's `data` field.
pub fn to_data(&self) -> Value {
let mut v = json!({
"kind_of": ANNOTATION_KIND,
"kind": self.kind.as_str(),
"content_tag": self.content_tag,
"node": self.node,
"layer": self.layer.as_str(),
});
if let Some(satisfied) = self.satisfied {
v["satisfied"] = Value::Bool(satisfied);
}
if let Some(from) = &self.received_from {
v["received_from"] = serde_json::to_value(from).unwrap_or(Value::Null);
}
if let Some(note) = &self.note {
v["note"] = Value::String(note.clone());
}
v
}
/// Recover an annotation from a bundle row, if it is one.
///
/// `None` for any row that is not a well-formed annotation of a
/// version this code knows. A row that is *nearly* an annotation is
/// not treated as one: a partially understood claim is worse than an
/// unread one, because it looks like evidence.
///
/// An unrecognised **kind** is different, and is kept as
/// [`AnnotationKind::Other`] so it can be reported.
pub fn from_row(row: &BundleRow) -> Option<Self> {
let payload: Value = serde_json::from_str(row.raw_payload.as_deref()?).ok()?;
let data = payload.get("data")?;
if data.get("kind_of").and_then(Value::as_str)? != ANNOTATION_KIND {
return None;
}
let kind = match data.get("kind").and_then(Value::as_str)? {
"custody" => AnnotationKind::Custody,
"integrity" => AnnotationKind::Integrity,
"derivation" => AnnotationKind::Derivation,
"source" => AnnotationKind::Source,
other => AnnotationKind::Other(other.to_string()),
};
let layer = match data.get("layer").and_then(Value::as_str).unwrap_or("app") {
"device" => Layer::Device,
"host" => Layer::Host,
"os" => Layer::Os,
"app" => Layer::App,
"network" => Layer::Network,
other => Layer::Other(other.to_string()),
};
Some(Self {
kind,
content_tag: data.get("content_tag")?.as_str()?.to_string(),
node: data.get("node")?.as_str()?.to_string(),
layer,
satisfied: data.get("satisfied").and_then(Value::as_bool),
received_from: data
.get("received_from")
.and_then(|v| serde_json::from_value(v.clone()).ok()),
note: data.get("note").and_then(Value::as_str).map(str::to_string),
})
}
}
/// The `entity_id` a node uses for its own claims about one datum.
///
/// Both halves matter: without the tag a node would keep one chain for
/// everything it ever handled, and a bundle exported for one recipient
/// would carry every other recipient's traffic with it.
pub fn entity_id_for(content_tag: &str, node: &str) -> String {
format!("{content_tag}:{node}")
}
/// One custody hop on a reconstructed path.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct CustodyHop {
pub node: String,
pub chain_id: String,
pub sequence: i64,
pub row_hash: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub received_from: Option<SourceRef>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub note: Option<String>,
}
/// Something wrong, named precisely enough to act on.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(tag = "problem", rename_all = "snake_case")]
pub enum AnnotationProblem {
/// A bundle failed its own verification, so nothing it says counts.
BundleUnverified { entity_id: String, detail: String },
/// **A node ran a check and it did not pass.** The actionable
/// marker: whoever reads this can refuse, quarantine, or inspect.
CheckFailed {
node: String,
kind: String,
layer: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
note: Option<String>,
},
/// An annotation of a kind this build does not understand. Reported
/// rather than ignored: silence would let a future kind carrying a
/// failed check pass an old verifier unnoticed.
UnrecognisedKind { node: String, kind: String },
/// A claim naming an upstream row absent from every bundle given, or
/// present with a different `row_hash`.
DanglingPredecessor { node: String, source_ref: SourceRef },
/// Two nodes claim to have received the datum from the same row. Not
/// wrong -- data fans out -- but the path is a tree, not a line.
Fork {
source_ref: SourceRef,
claimants: Vec<String>,
},
/// No custody claim is an origin: a cycle, or a missing first claim.
NoOrigin,
/// More than one node claims to be the origin of the same datum.
MultipleOrigins { nodes: Vec<String> },
/// A hop not reachable from the origin by following links.
Unreachable { node: String },
}
/// What the bundles, taken together, say about a datum.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct AnnotationReport {
pub content_tag: String,
/// Custody hops reachable from the origin, in order. Empty when
/// there is no single origin to order them from.
pub hops: Vec<CustodyHop>,
/// Every annotation found, of every kind.
pub found: usize,
pub problems: Vec<AnnotationProblem>,
}
impl AnnotationReport {
/// One origin, every link resolving, nothing unreachable, no fork,
/// no failed check, and every bundle verifying on its own.
///
/// A method rather than a stored field: a caller who accepts forks
/// can read `problems` and decide, and a stored boolean invites
/// reading the verdict without the reasons.
pub fn intact(&self) -> bool {
self.problems.is_empty() && !self.hops.is_empty()
}
/// The nodes that held the datum, in order.
pub fn custody_line(&self) -> Vec<&str> {
self.hops.iter().map(|h| h.node.as_str()).collect()
}
/// Every check that a node reported as failed.
///
/// The marker another node acts on: refuse to serve, quarantine, or
/// route for inspection.
pub fn failed_checks(&self) -> Vec<&AnnotationProblem> {
self.problems
.iter()
.filter(|p| matches!(p, AnnotationProblem::CheckFailed { .. }))
.collect()
}
}
/// Read every annotation about a datum, and check what they say together.
///
/// **Verifies each bundle first.** A structural finding drawn from rows
/// nobody checked is not a finding, and a tidy report over forged rows
/// is worse than no report. A bundle that fails is named and its rows
/// take no further part -- but one party lying does not erase another's
/// honest record.
pub fn verify_annotations(bundles: &[ProofBundle], content_tag: &str) -> AnnotationReport {
let mut problems = Vec::new();
let mut hops: Vec<CustodyHop> = Vec::new();
let mut rows_by_key: HashMap<(String, i64), String> = HashMap::new();
let mut found = 0usize;
for bundle in bundles {
let report = bundle.verify_public();
if !report.intact() {
problems.push(AnnotationProblem::BundleUnverified {
entity_id: bundle.entity_id.clone(),
detail: format!(
"rows_valid={}, fully_signed={}, continuous={}",
report.rows_valid, report.fully_signed, report.continuous
),
});
continue;
}
for row in &bundle.rows {
if let Some(hash) = &row.row_hash {
rows_by_key.insert((row.chain_id.clone(), row.sequence), hash.clone());
}
let Some(annotation) = Annotation::from_row(row) else {
continue;
};
if annotation.content_tag != content_tag {
continue;
}
found += 1;
if annotation.satisfied == Some(false) {
problems.push(AnnotationProblem::CheckFailed {
node: annotation.node.clone(),
kind: annotation.kind.as_str().to_string(),
layer: annotation.layer.as_str().to_string(),
note: annotation.note.clone(),
});
}
if !annotation.kind.is_known() {
problems.push(AnnotationProblem::UnrecognisedKind {
node: annotation.node.clone(),
kind: annotation.kind.as_str().to_string(),
});
}
if annotation.kind == AnnotationKind::Custody {
hops.push(CustodyHop {
node: annotation.node,
chain_id: row.chain_id.clone(),
sequence: row.sequence,
row_hash: row.row_hash.clone(),
received_from: annotation.received_from,
note: annotation.note,
});
}
}
}
// A claimed predecessor must exist, at that position, with that hash.
for hop in &hops {
if let Some(sr) = &hop.received_from {
match rows_by_key.get(&(sr.chain_id.clone(), sr.sequence)) {
Some(hash) if *hash == sr.row_hash => {}
_ => problems.push(AnnotationProblem::DanglingPredecessor {
node: hop.node.clone(),
source_ref: sr.clone(),
}),
}
}
}
// Fan-out: two hops naming the same upstream row.
let mut by_predecessor: HashMap<(String, i64), Vec<String>> = HashMap::new();
for hop in &hops {
if let Some(sr) = &hop.received_from {
by_predecessor
.entry((sr.chain_id.clone(), sr.sequence))
.or_default()
.push(hop.node.clone());
}
}
for ((chain_id, sequence), claimants) in &by_predecessor {
if claimants.len() > 1 {
problems.push(AnnotationProblem::Fork {
source_ref: SourceRef {
chain_id: chain_id.clone(),
sequence: *sequence,
row_hash: rows_by_key
.get(&(chain_id.clone(), *sequence))
.cloned()
.unwrap_or_default(),
},
claimants: claimants.clone(),
});
}
}
let origins: Vec<&CustodyHop> = hops.iter().filter(|h| h.received_from.is_none()).collect();
match origins.len() {
0 => {
if !hops.is_empty() {
problems.push(AnnotationProblem::NoOrigin);
}
}
1 => {}
_ => problems.push(AnnotationProblem::MultipleOrigins {
nodes: origins.iter().map(|h| h.node.clone()).collect(),
}),
}
let ordered = if origins.len() == 1 {
let mut successors: HashMap<(String, i64), Vec<CustodyHop>> = HashMap::new();
for hop in &hops {
if let Some(sr) = &hop.received_from {
successors
.entry((sr.chain_id.clone(), sr.sequence))
.or_default()
.push(hop.clone());
}
}
let mut ordered = vec![origins[0].clone()];
let mut seen: HashSet<(String, i64)> = HashSet::new();
let mut cursor = (origins[0].chain_id.clone(), origins[0].sequence);
// Follows one line. A fork is already reported; taking the first
// branch leaves the caller a readable prefix rather than nothing.
while seen.insert(cursor.clone()) {
let Some(next) = successors.get(&cursor).and_then(|v| v.first()) else {
break;
};
ordered.push(next.clone());
cursor = (next.chain_id.clone(), next.sequence);
}
ordered
} else {
Vec::new()
};
let reached: HashSet<(String, i64)> = ordered
.iter()
.map(|h| (h.chain_id.clone(), h.sequence))
.collect();
if !ordered.is_empty() {
for hop in &hops {
if !reached.contains(&(hop.chain_id.clone(), hop.sequence)) {
problems.push(AnnotationProblem::Unreachable {
node: hop.node.clone(),
});
}
}
}
AnnotationReport {
content_tag: content_tag.to_string(),
hops: ordered,
found,
problems,
}
}
#[cfg(test)]
mod tests {
use super::*;
fn row(chain: &str, seq: i64, hash: &str, a: &Annotation) -> BundleRow {
BundleRow {
entity_id: entity_id_for(&a.content_tag, &a.node),
chain_id: chain.to_string(),
event_type: "MUTATION".to_string(),
sequence: seq,
prev_hash: String::new(),
row_hash: Some(hash.to_string()),
anchor_hash: None,
raw_payload: Some(
serde_json::to_string(&json!({ "data": a.to_data() })).expect("payload"),
),
break_reason: None,
}
}
#[test]
fn an_entity_id_carries_both_the_datum_and_the_node() {
assert_eq!(entity_id_for("sha256:abc", "node-1"), "sha256:abc:node-1");
assert_ne!(
entity_id_for("sha256:abc", "node-1"),
entity_id_for("sha256:abc", "node-2"),
"one chain per (data, node) is what lets a node sign only its own \
claims; sharing a chain would need a writer they both trust"
);
}
#[test]
fn every_field_round_trips_through_the_data_field() {
let a = Annotation::integrity("sha256:abc", "node-2", false)
.at_layer(Layer::Device)
.because("checksum mismatch on receipt")
.from(SourceRef {
chain_id: "c1".into(),
sequence: 4,
row_hash: "aa".into(),
});
let parsed = Annotation::from_row(&row("c2", 1, "bb", &a)).expect("parse");
assert_eq!(parsed, a, "what is written must be what is read back");
}
#[test]
fn no_check_is_not_the_same_as_a_passed_check() {
let custody = Annotation::custody("sha256:abc", "n");
assert_eq!(
custody.satisfied, None,
"a custody claim asserts holding, not correctness, and must not \
read as a check that passed"
);
assert_eq!(Annotation::integrity("sha256:abc", "n", true).satisfied, Some(true));
assert_eq!(Annotation::integrity("sha256:abc", "n", false).satisfied, Some(false));
}
#[test]
fn an_unknown_kind_survives_parsing_so_it_can_be_reported() {
let mut a = Annotation::custody("sha256:abc", "n");
a.kind = AnnotationKind::Other("future.kind".into());
let parsed = Annotation::from_row(&row("c", 0, "aa", &a)).expect("parse");
assert_eq!(parsed.kind, AnnotationKind::Other("future.kind".into()));
assert!(
!parsed.kind.is_known(),
"a kind an old verifier does not recognise must be visible, or a \
future kind carrying a failed check would pass unnoticed"
);
}
#[test]
fn an_unknown_layer_is_kept_rather_than_flattened_to_the_default() {
let a = Annotation::custody("sha256:abc", "n").at_layer(Layer::Other("enclave".into()));
let parsed = Annotation::from_row(&row("c", 0, "aa", &a)).expect("parse");
assert_eq!(
parsed.layer,
Layer::Other("enclave".into()),
"silently reporting an unknown layer as `app` would understate \
where the claim came from, and layer exists precisely to say \
what a claim is worth"
);
}
#[test]
fn a_row_that_is_not_an_annotation_is_not_read_as_one() {
let a = Annotation::custody("sha256:abc", "n");
for payload in [
r#"{"data":{"kind_of":"something.else"}}"#,
r#"{"data":{"kind_of":"annotation.v1"}}"#,
"not json at all",
] {
let mut r = row("c", 0, "aa", &a);
r.raw_payload = Some(payload.to_string());
assert!(
Annotation::from_row(&r).is_none(),
"a partially understood claim is worse than an unread one, \
because it looks like evidence: {payload}"
);
}
}
#[test]
fn the_version_is_inside_the_discriminator() {
assert!(
ANNOTATION_KIND.ends_with(".v1"),
"a reader must be able to tell which shape it is holding before \
it trusts the fields"
);
}
#[test]
fn an_empty_report_is_not_an_intact_one() {
let report = AnnotationReport {
content_tag: "sha256:abc".into(),
hops: vec![],
found: 0,
problems: vec![],
};
assert!(
!report.intact(),
"no records at all is not the same as a clean history, and the \
difference matters most exactly when someone is asking"
);
}
#[test]
fn failed_checks_are_singled_out_for_acting_on() {
let report = AnnotationReport {
content_tag: "t".into(),
hops: vec![],
found: 2,
problems: vec![
AnnotationProblem::NoOrigin,
AnnotationProblem::CheckFailed {
node: "relay".into(),
kind: "integrity".into(),
layer: "device".into(),
note: None,
},
],
};
assert_eq!(
report.failed_checks().len(),
1,
"a caller deciding whether to refuse, quarantine or inspect needs \
the failed checks separated from the structural findings"
);
}
}

View File

@@ -0,0 +1,799 @@
// ============================================================
// MODULE: bundle
// PURPOSE: Self-contained proof packages for external verification.
// A ProofBundle lets a party without database access
// (auditor, insurer, carbon-credit buyer) reverify a
// slice of a chain, given the operator's SipHash key
// (shared out-of-band with trusted verifiers) and/or
// the Ed25519 public keys embedded inline in v2 rows.
//
// DESIGN NOTES:
// - Wire format is JSON via serde. The bundle itself is NOT
// hashed as a whole; integrity comes from the row_hash +
// anchor_hash + signature fields of each contained row.
// - ChainLink stays internal (no serde derives) to keep the
// chain primitive untouched. BundleRow mirrors its shape and
// carries the serde derives. Conversion is free.
// - SipHash key is NEVER embedded. Key distribution is the
// operator's concern.
// ============================================================
//
// v1 SHAPE (this module is additive, does not alter payload bytes):
// AnchorEntry { sequence, anchor_hash, detected_at }
// BundleRow { entity_id, chain_id, event_type, sequence,
// prev_hash, row_hash, anchor_hash, raw_payload }
// ProofBundle { entity_id, generated_at, row_count,
// sequence_range, rows, anchors }
//
// ============================================================
use serde::{Deserialize, Serialize};
use serde_json::Value;
use crate::chain::{
verify_links, verify_links_public, verify_row_signatures, ChainLink, PublicVerifyReport,
VerifyReport,
};
use crate::hash::SecretKey;
/// One per-row anchor commitment, as emitted by
/// `Trusty::anchors_since`. `anchor_hash` is the SHA-256 commitment
/// written at append time; `detected_at` is the ISO-8601 timestamp
/// the operator recorded. Anchor entries are what an operator
/// publishes (or pushes to a buyer / insurer) for external spot
/// checks without exposing the row contents themselves.
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct AnchorEntry {
pub sequence: i64,
pub anchor_hash: String,
pub detected_at: String,
}
/// Wire-format row carried inside a `ProofBundle`. Mirrors the
/// in-memory `ChainLink` exactly; the only reason for a separate
/// type is to carry serde derives without touching the chain
/// primitive. Round-trip: `From<&ChainLink>` to serialize, then
/// `to_chain_link` on the verifier side to feed `verify_links`.
#[non_exhaustive]
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct BundleRow {
pub entity_id: String,
pub chain_id: String,
pub event_type: String,
pub sequence: i64,
pub prev_hash: String,
pub row_hash: Option<String>,
pub anchor_hash: Option<String>,
pub raw_payload: Option<String>,
/// Why a BREAK row exists, for BREAK rows only.
///
/// Additive, and `None` everywhere else. Without it a reader holding
/// only a bundle learns THAT a chain broke and not WHICH rule broke
/// -- the reason lives in a column the bundle did not carry, so it
/// stopped at the machine that recorded it. A marker nobody else can
/// read is not a marker anybody can act on.
///
/// Safe to add: BREAK rows are skipped in hash verification, so this
/// was never part of any hashed payload, and `skip_serializing_if`
/// keeps bundles without one byte-identical to before.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub break_reason: Option<String>,
}
impl BundleRow {
/// Build a row from the fields every backend has.
///
/// `BundleRow` is `#[non_exhaustive]`, so it cannot be built with a
/// struct literal outside this crate. That is deliberate and it is
/// the lesson of 2026-08-25: adding `break_reason` broke a consumer
/// that constructs one, while this workspace's own tests stayed
/// green, because the consumer depends on trusty by PATH and nothing
/// pins a version to warn anybody.
///
/// Adding a field is additive on the wire -- serde skips what is
/// absent -- and NOT additive for struct literals. Those are
/// different compatibility questions, and conflating them is what
/// made the break silent. A constructor plus setters means the next
/// field costs downstream nothing.
pub fn new(
entity_id: impl Into<String>,
chain_id: impl Into<String>,
event_type: impl Into<String>,
sequence: i64,
prev_hash: impl Into<String>,
) -> Self {
Self {
entity_id: entity_id.into(),
chain_id: chain_id.into(),
event_type: event_type.into(),
sequence,
prev_hash: prev_hash.into(),
row_hash: None,
anchor_hash: None,
raw_payload: None,
break_reason: None,
}
}
pub fn with_hashes(mut self, row_hash: Option<String>, anchor_hash: Option<String>) -> Self {
self.row_hash = row_hash;
self.anchor_hash = anchor_hash;
self
}
pub fn with_payload(mut self, raw_payload: Option<String>) -> Self {
self.raw_payload = raw_payload;
self
}
/// Why a BREAK row exists. Ignored for any other event type, so a
/// caller threading it through generically cannot mislabel a row.
pub fn with_break_reason(mut self, reason: Option<String>) -> Self {
if self.event_type == "BREAK" {
self.break_reason = reason;
}
self
}
}
impl BundleRow {
pub fn to_chain_link(&self) -> ChainLink {
ChainLink {
entity_id: self.entity_id.clone(),
chain_id: self.chain_id.clone(),
event_type: self.event_type.clone(),
sequence: self.sequence,
prev_hash: self.prev_hash.clone(),
row_hash: self.row_hash.clone(),
anchor_hash: self.anchor_hash.clone(),
raw_payload: self.raw_payload.clone(),
}
}
}
impl From<&ChainLink> for BundleRow {
fn from(link: &ChainLink) -> Self {
Self {
entity_id: link.entity_id.clone(),
chain_id: link.chain_id.clone(),
event_type: link.event_type.clone(),
sequence: link.sequence,
prev_hash: link.prev_hash.clone(),
row_hash: link.row_hash.clone(),
anchor_hash: link.anchor_hash.clone(),
raw_payload: link.raw_payload.clone(),
// A ChainLink has no break reason to give; only a store that
// read the row from its columns can supply one.
break_reason: None,
}
}
}
/// Self-contained proof package. Packages a slice of a chain plus
/// its anchor commitments into a single serializable blob. The
/// bundle alone is not cryptographically self-verifying -- the
/// caller must supply the operator's SipHash key to reproduce the
/// keyed row_hash values. v2 rows additionally carry their own
/// Ed25519 public keys inline, so their signatures are verifiable
/// with no external input.
///
/// `sequence_range` reports the min/max `sequence` seen across the
/// non-BREAK rows in the bundle. BREAK rows carry sequence -1 and
/// are excluded from the range but included in the bundle so
/// chain-break-as-event proof survives export.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ProofBundle {
pub entity_id: String,
pub generated_at: String,
pub row_count: usize,
pub sequence_range: (Option<i64>, Option<i64>),
pub rows: Vec<BundleRow>,
pub anchors: Vec<AnchorEntry>,
}
/// One `source_refs` entry embedded in a twin computation row's data field.
/// Carries enough info to locate and validate the referenced sensor row
/// in a separate `ProofBundle` without database access.
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct SourceRef {
pub chain_id: String,
pub sequence: i64,
pub row_hash: String,
}
/// One failed cross-chain reference found by `ProofBundle::verify_source_refs`.
/// `reason` is either `"not_found"` (no sensor row at that sequence) or
/// `"hash_mismatch"` (row found but row_hash differs).
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct SourceRefMismatch {
pub twin_sequence: i64,
pub source_ref: SourceRef,
pub reason: String,
}
/// Result of `ProofBundle::verify_source_refs`. `ok` is `true` when every
/// source_ref in the twin bundle resolves to a matching row in the sensor bundle.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SourceRefReport {
pub checked: usize,
pub matched: usize,
pub mismatches: Vec<SourceRefMismatch>,
pub ok: bool,
}
/// Proof that one producer signed two different rows at the same position.
///
/// Both rows carry a valid signature from the same key over the same
/// `(chain_id, sequence)`, and their `row_hash` values differ. That cannot
/// happen by accident: the signature commits to `prev_hash`, `sequence`,
/// `entity_id` and `chain_id`, so producing two is a deliberate act of showing
/// different histories to different parties.
///
/// This is a stronger result than "the chain looks wrong". A broken chain might
/// be corruption; an equivocation is attributable to a public key, and the
/// evidence is self-contained -- a third party can re-verify both signatures
/// without trusting whoever reported it.
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct EquivocationProof {
pub chain_id: String,
pub sequence: i64,
/// The public key, hex-encoded, that signed both rows.
pub producer_pk: String,
pub row_a: BundleRow,
pub row_b: BundleRow,
}
/// Compare two bundles for the same entity and return proof of every position
/// where the producer signed two different rows.
///
/// **Signature validity is required on both sides.** An unsigned or
/// invalidly-signed row proves nothing about its author, and admitting one
/// would let anyone manufacture a "proof" against a producer by fabricating the
/// second half. Rows that do not verify are skipped rather than reported.
///
/// Detects nothing when the two bundles are the same history, which is the
/// common case; the cost is one signature verification per row per side.
pub fn detect_equivocation(a: &ProofBundle, b: &ProofBundle) -> Vec<EquivocationProof> {
use std::collections::HashMap;
// Index one side by position. BREAK rows carry no row_hash and sequence -1,
// so they cannot equivocate and are excluded.
let mut left: HashMap<(&str, i64), &BundleRow> = HashMap::new();
for row in &a.rows {
if row.event_type == "BREAK" || row.row_hash.is_none() {
continue;
}
left.insert((row.chain_id.as_str(), row.sequence), row);
}
let mut out = Vec::new();
for right in &b.rows {
if right.event_type == "BREAK" || right.row_hash.is_none() {
continue;
}
let Some(&leftr) = left.get(&(right.chain_id.as_str(), right.sequence)) else {
continue;
};
if leftr.row_hash == right.row_hash {
continue; // same row; nothing to see
}
// Both must be validly signed BY THE SAME KEY, or this proves nothing.
let (Some(raw_a), Some(raw_b)) = (&leftr.raw_payload, &right.raw_payload) else {
continue;
};
if !verify_row_signatures(raw_a).is_empty() || !verify_row_signatures(raw_b).is_empty() {
continue;
}
let (Some(pk_a), Some(pk_b)) = (first_signature_pk(raw_a), first_signature_pk(raw_b))
else {
continue;
};
if pk_a != pk_b {
continue; // two producers disagreeing is not one producer equivocating
}
out.push(EquivocationProof {
chain_id: right.chain_id.clone(),
sequence: right.sequence,
producer_pk: pk_a,
row_a: (*leftr).clone(),
row_b: right.clone(),
});
}
out
}
// Hex public key of the first signature entry in a raw payload, if any.
fn first_signature_pk(raw: &str) -> Option<String> {
let parsed = crate::chain::parse_raw_payload(raw).ok()?;
parsed
.signatures
.first()?
.as_object()?
.get("pk")?
.as_str()
.map(str::to_string)
}
impl ProofBundle {
/// Reverify the bundle's chain slice. Runs the same
/// `verify_links` pipeline that `Trusty::verify` runs on a live
/// database.
///
/// CHECK TWO FIELDS, NOT ONE. `report.rows_valid` says the rows hashed and
/// signed correctly; `report.continuous` says no break was recorded
/// in the range, with `report.breaks` giving the positions. A bundle
/// can be `ok` and NOT continuous -- the segments either side of a
/// break are well-formed, and a break is written precisely because a
/// monitored condition failed at append time.
///
/// This doc comment used to say "inspect `report.rows_valid` for the
/// summary", which is how a third party ended up able to export a
/// bundle, verify it, read `ok: true` and conclude a chain was
/// unbroken when it carried the system's own record of detected
/// tampering. `report.errors` still holds the detail when `ok` is
/// false.
pub fn verify(&self, key: &SecretKey) -> VerifyReport {
let links: Vec<ChainLink> = self.rows.iter().map(BundleRow::to_chain_link).collect();
verify_links(&self.entity_id, &links, key)
}
/// Reverify using only what travels inside the bundle -- no secret key.
///
/// This is the path this module header promises to a party holding a
/// bundle and nothing else: Ed25519 signatures (public keys are inline in
/// v2+ rows), plus linkage and anchor consistency. It previously had no
/// public entry point, so a recipient without the operator SipHash key
/// could not verify a bundle at all -- which is the audience a bundle
/// exists for.
///
/// Read `ok` for whether everything is accounted for and `continuous`
/// for whether a break was recorded, exactly as with [`Self::verify`].
/// Note `ok` is false when any row is unsigned: without the key such a
/// row is unattributable.
pub fn verify_public(&self) -> PublicVerifyReport {
let links: Vec<ChainLink> = self.rows.iter().map(BundleRow::to_chain_link).collect();
verify_links_public(&self.entity_id, &links)
}
/// Cross-chain reference verification. `self` is the twin bundle;
/// `sensor_bundle` is the bundle that should contain the referenced rows.
///
/// Walks every twin row whose `raw_payload` contains a `data.source_refs`
/// array (the twin coupling convention from docs/twin-coupling.md). For each
/// `{chain_id, sequence, row_hash}` ref, confirms a matching sensor row
/// exists in `sensor_bundle` at that sequence with an identical `row_hash`.
///
/// Returns a `SourceRefReport`. `ok` is `true` when all refs resolve.
/// Unresolved refs appear in `mismatches` with reason `"not_found"` or
/// `"hash_mismatch"`.
pub fn verify_source_refs(&self, sensor_bundle: &ProofBundle) -> SourceRefReport {
// Index sensor rows by sequence for O(1) lookup.
let sensor_index: std::collections::HashMap<i64, &BundleRow> = sensor_bundle
.rows
.iter()
.filter(|r| r.sequence >= 0)
.map(|r| (r.sequence, r))
.collect();
let mut checked = 0usize;
let mut matched = 0usize;
let mut mismatches = Vec::new();
for twin_row in self.rows.iter().filter(|r| r.sequence >= 0) {
let payload: Value = match twin_row
.raw_payload
.as_deref()
.and_then(|p| serde_json::from_str(p).ok())
{
Some(v) => v,
None => continue,
};
let refs = match payload
.get("data")
.and_then(|d| d.get("source_refs"))
.and_then(|v| v.as_array())
{
Some(r) => r,
None => continue,
};
for sr in refs {
let chain_id = match sr.get("chain_id").and_then(|v| v.as_str()) {
Some(s) => s.to_string(),
None => continue,
};
let sequence = match sr.get("sequence").and_then(|v| v.as_i64()) {
Some(s) => s,
None => continue,
};
let row_hash = match sr.get("row_hash").and_then(|v| v.as_str()) {
Some(s) => s.to_string(),
None => continue,
};
checked += 1;
let source_ref = SourceRef {
chain_id,
sequence,
row_hash: row_hash.clone(),
};
match sensor_index.get(&sequence) {
None => mismatches.push(SourceRefMismatch {
twin_sequence: twin_row.sequence,
source_ref,
reason: "not_found".into(),
}),
Some(sensor_row) => {
if sensor_row.row_hash.as_deref() == Some(&row_hash) {
matched += 1;
} else {
mismatches.push(SourceRefMismatch {
twin_sequence: twin_row.sequence,
source_ref,
reason: "hash_mismatch".into(),
});
}
}
}
}
}
let ok = mismatches.is_empty();
SourceRefReport {
checked,
matched,
mismatches,
ok,
}
}
/// Convenience constructor. Computes `row_count` and
/// `sequence_range` from `rows`; callers do not need to
/// pre-compute them. Non-BREAK rows (sequence >= 0) contribute
/// to the range.
pub fn new(
entity_id: String,
generated_at: String,
rows: Vec<BundleRow>,
anchors: Vec<AnchorEntry>,
) -> Self {
let row_count = rows.len();
let (mut lo, mut hi): (Option<i64>, Option<i64>) = (None, None);
for r in rows.iter().filter(|r| r.sequence >= 0) {
lo = Some(lo.map_or(r.sequence, |x| x.min(r.sequence)));
hi = Some(hi.map_or(r.sequence, |x| x.max(r.sequence)));
}
Self {
entity_id,
generated_at,
row_count,
sequence_range: (lo, hi),
rows,
anchors,
}
}
}
#[cfg(test)]
mod tests {
use super::*;
fn make_row(sequence: i64, row_hash: &str, raw_payload: Option<&str>) -> BundleRow {
BundleRow {
entity_id: "test".into(),
chain_id: "chain_test_000001".into(),
event_type: if sequence == 0 {
"GENESIS".into()
} else {
"MUTATION".into()
},
sequence,
prev_hash: "0".into(),
row_hash: Some(row_hash.into()),
anchor_hash: None,
raw_payload: raw_payload.map(|s| s.into()),
break_reason: None,
}
}
fn sensor_bundle(rows: Vec<BundleRow>) -> ProofBundle {
ProofBundle::new("sensor".into(), "2026-01-01T00:00:00Z".into(), rows, vec![])
}
fn twin_bundle(rows: Vec<BundleRow>) -> ProofBundle {
ProofBundle::new("twin".into(), "2026-01-01T00:00:00Z".into(), rows, vec![])
}
#[test]
fn verify_source_refs_all_match() {
let s0 = make_row(0, "hash_aaa", None);
let s1 = make_row(1, "hash_bbb", None);
let sensor = sensor_bundle(vec![s0.clone(), s1.clone()]);
let twin_payload = serde_json::json!({
"data": {
"source_refs": [
{"chain_id": "chain_test_000001", "sequence": 0, "row_hash": "hash_aaa"},
{"chain_id": "chain_test_000001", "sequence": 1, "row_hash": "hash_bbb"},
]
}
})
.to_string();
let twin_row = make_row(0, "hash_twin", Some(&twin_payload));
let twin = twin_bundle(vec![twin_row]);
let report = twin.verify_source_refs(&sensor);
assert!(report.ok);
assert_eq!(report.checked, 2);
assert_eq!(report.matched, 2);
assert!(report.mismatches.is_empty());
}
#[test]
fn verify_source_refs_missing_row() {
let sensor = sensor_bundle(vec![make_row(0, "hash_aaa", None)]);
let twin_payload = serde_json::json!({
"data": {
"source_refs": [
{"chain_id": "chain_test_000001", "sequence": 99, "row_hash": "hash_zzz"},
]
}
})
.to_string();
let twin = twin_bundle(vec![make_row(0, "hash_twin", Some(&twin_payload))]);
let report = twin.verify_source_refs(&sensor);
assert!(!report.ok);
assert_eq!(report.checked, 1);
assert_eq!(report.matched, 0);
assert_eq!(report.mismatches[0].reason, "not_found");
}
#[test]
fn verify_source_refs_hash_mismatch() {
let sensor = sensor_bundle(vec![make_row(0, "hash_aaa", None)]);
let twin_payload = serde_json::json!({
"data": {
"source_refs": [
{"chain_id": "chain_test_000001", "sequence": 0, "row_hash": "hash_WRONG"},
]
}
})
.to_string();
let twin = twin_bundle(vec![make_row(0, "hash_twin", Some(&twin_payload))]);
let report = twin.verify_source_refs(&sensor);
assert!(!report.ok);
assert_eq!(report.mismatches[0].reason, "hash_mismatch");
}
#[test]
fn verify_source_refs_no_refs_in_twin_is_ok() {
let sensor = sensor_bundle(vec![make_row(0, "hash_aaa", None)]);
let twin = twin_bundle(vec![make_row(0, "hash_twin", None)]);
let report = twin.verify_source_refs(&sensor);
assert!(report.ok);
assert_eq!(report.checked, 0);
}
}
#[cfg(all(test, feature = "keygen"))]
mod equivocation_tests {
use super::*;
use crate::chain::{compute_record_signed, make_chain_id, ComputedRecord, EventType};
use crate::crypto::{Ed25519Signer, Signer};
use crate::hash::SecretKey;
use crate::payload::{PayloadFields, PAYLOAD_VERSION_V2};
use serde_json::json;
fn key() -> SecretKey {
SecretKey::from_bytes(b"0123456789abcdef").unwrap()
}
/// One signed row at `sequence`, whose contents depend on `marker` -- two
/// different markers at the same sequence is the equivocation.
fn signed(signer: &Ed25519Signer, sequence: i64, marker: i64) -> BundleRow {
let chain_id = make_chain_id("acme", 1);
let mut f = PayloadFields::new_v1(
"acme",
chain_id.clone(),
json!({ "v": marker }),
"s",
"2026-03-08T00:00:00Z",
"2026-03-08T00:00:00.123456Z",
"trusty",
"genesis",
sequence,
);
f.payload_version = PAYLOAD_VERSION_V2;
let rec: ComputedRecord = compute_record_signed(&f, &key(), signer).unwrap();
BundleRow {
entity_id: "acme".into(),
chain_id,
event_type: EventType::Mutation.as_str().into(),
sequence,
prev_hash: "genesis".into(),
row_hash: Some(rec.row_hash),
anchor_hash: Some(rec.anchor_hash),
raw_payload: Some(rec.raw_payload),
break_reason: None,
}
}
fn bundle(rows: Vec<BundleRow>) -> ProofBundle {
ProofBundle::new("acme".into(), "2026-01-01T00:00:00Z".into(), rows, vec![])
}
#[test]
fn two_signed_rows_at_one_sequence_are_proof() {
let signer = Ed25519Signer::generate();
let a = bundle(vec![signed(&signer, 1, 100)]);
let b = bundle(vec![signed(&signer, 1, 999)]);
let proofs = detect_equivocation(&a, &b);
assert_eq!(proofs.len(), 1, "one producer, one position, two histories");
assert_eq!(proofs[0].sequence, 1);
assert_ne!(proofs[0].row_a.row_hash, proofs[0].row_b.row_hash);
assert_eq!(
proofs[0].producer_pk,
hex::encode(signer.public_key_bytes()),
"the proof must name the key that signed both"
);
}
#[test]
fn the_same_history_twice_proves_nothing() {
let signer = Ed25519Signer::generate();
let row = signed(&signer, 1, 100);
let a = bundle(vec![row.clone()]);
let b = bundle(vec![row]);
assert!(detect_equivocation(&a, &b).is_empty());
}
#[test]
fn two_different_producers_are_not_equivocating() {
// Two keys signing different content at the same sequence is a
// disagreement between parties, not one party showing two faces.
let one = Ed25519Signer::generate();
let two = Ed25519Signer::generate();
let a = bundle(vec![signed(&one, 1, 100)]);
let b = bundle(vec![signed(&two, 1, 999)]);
assert!(
detect_equivocation(&a, &b).is_empty(),
"differing keys must never be reported as equivocation"
);
}
#[test]
fn a_forged_second_row_cannot_manufacture_a_proof() {
// The attack this guards: fabricate a row at someone's sequence to
// "prove" they equivocated. Without a valid signature it proves nothing,
// so it must be skipped rather than reported.
let signer = Ed25519Signer::generate();
let a = bundle(vec![signed(&signer, 1, 100)]);
let mut forged = signed(&signer, 1, 999);
// Break the signature by rewriting the payload after signing.
let raw = forged.raw_payload.clone().unwrap();
forged.raw_payload = Some(raw.replace("\"v\":999", "\"v\":1234"));
let b = bundle(vec![forged]);
assert!(
detect_equivocation(&a, &b).is_empty(),
"an unsigned or tampered row must never be admitted as proof"
);
}
#[test]
fn break_rows_cannot_equivocate() {
let signer = Ed25519Signer::generate();
let mut brk = signed(&signer, -1, 1);
brk.event_type = EventType::Break.as_str().into();
brk.row_hash = None;
let a = bundle(vec![brk.clone()]);
let b = bundle(vec![brk]);
assert!(detect_equivocation(&a, &b).is_empty());
}
}
/// A recorded rule violation, read off a bundle.
///
/// Self-contained on purpose: everything needed to say what broke, and
/// where, without going back to the machine that recorded it.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct Violation {
pub entity_id: String,
pub chain_id: String,
/// The rule's name, when the break named one.
///
/// `None` for a break with a category but no rule -- tamper
/// detection, a sequence gap. Those are real breaks and are still
/// reported; they simply have no invariant to name.
pub rule: Option<String>,
/// The reason exactly as recorded.
pub reason: String,
}
/// What a bundle says about whether its data broke a rule.
///
/// Three answers, not two. **Absent evidence must never read as clean**
/// -- the same rule as "absent policy is not permission" -- so a bundle
/// that cannot be verified reports [`Stance::Unverifiable`] rather than
/// quietly counting zero violations and looking spotless.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(tag = "stance", rename_all = "snake_case")]
pub enum Stance {
/// Verified, and records no break.
Clean,
/// Verified, and records at least one break.
Violated { violations: Vec<Violation> },
/// Could not be verified, so it says nothing either way. Treating
/// this as clean is the mistake this variant exists to prevent.
Unverifiable { detail: String },
}
impl Stance {
/// Whether it is safe to proceed on this bundle alone.
///
/// Only [`Stance::Clean`]. An unverifiable bundle is not a permit.
pub fn is_clean(&self) -> bool {
matches!(self, Stance::Clean)
}
}
impl ProofBundle {
/// Every rule violation this bundle records.
///
/// Reads the BREAK rows only. Says nothing about whether the bundle
/// verifies -- use [`ProofBundle::stance`] for the question a caller
/// deciding whether to act should actually be asking.
pub fn violations(&self) -> Vec<Violation> {
self.rows
.iter()
.filter(|r| r.event_type == "BREAK")
.map(|r| {
let reason = r.break_reason.clone().unwrap_or_default();
Violation {
entity_id: r.entity_id.clone(),
chain_id: r.chain_id.clone(),
rule: reason
.strip_prefix("PROPERTY_VIOLATED:")
.map(str::to_string),
reason,
}
})
.collect()
}
/// Verify, then say whether anything broke.
///
/// The question a peer asks before serving, quarantining, or
/// inspecting: *is this datum carrying a signed violation?* Needs no
/// secret key, so any party holding the bundle can ask it.
pub fn stance(&self) -> Stance {
let report = self.verify_public();
if !report.rows_valid {
return Stance::Unverifiable {
detail: "a signature did not verify or linkage did not hold, so \
nothing this bundle says can be relied on"
.to_string(),
};
}
if !report.fully_signed {
return Stance::Unverifiable {
detail: "a row carries no signature, so it is not merely \
unchecked but unattributable"
.to_string(),
};
}
let violations = self.violations();
if violations.is_empty() {
Stance::Clean
} else {
Stance::Violated { violations }
}
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,334 @@
// Pluggable break conditions.
//
// A condition evaluates the state at append time and votes Pass or
// Fail(reason). First failure wins — subsequent conditions do not
// run. Default set: HashIntegrity, SequenceContinuity. SourceDomain
// exists but is opt-in (requires a domain list).
//
// Byte-identical vote outcomes to the Python reference at
// trusty_core_watermark_proxy/core/trusty/conditions/defaults.py
#[derive(Debug, Clone)]
pub struct ConditionContext {
pub entity_id: String,
pub chain_id: String,
pub sequence: i64,
pub stored_tip_hash: Option<String>,
pub computed_hash: Option<String>,
pub prev_hash: Option<String>,
pub last_sequence: Option<i64>,
pub source_url: String,
pub extraction_method: String,
/// The MEASURED DATA being appended (the payload). S126 gap B: this is what
/// lets a condition monitor the DATA PLANE, not just chain metadata -- e.g. a
/// threshold over a measured metric (`latency_ms > 500`) evaluated at append
/// time, recording a break that names the variable and the observed value.
/// `Value::Null` when the backend supplies no data (older callers, tests).
pub data: serde_json::Value,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum BreakReason {
TamperDetected,
SequenceGap,
SourceChange,
ManualReset,
SchemaMigration,
ConditionEvaluatorError,
/// A declared RUN RULE / invariant was violated at append time -- carries the
/// rule's name so the recorded break says WHICH invariant broke, instead of
/// being mislabelled `SequenceGap` (the S126 gap A). ADDITIVE and safe: BREAK
/// rows are skipped in hash verification (`chain::verify_links`), so the break
/// reason was never part of the hashed payload -- existing chains are
/// unaffected. The rule name travels via `code()`, never via the frozen
/// `as_code()`.
PropertyViolated(String),
}
impl BreakReason {
/// The FROZEN category code (`&'static str`). Unchanged for existing variants
/// so any historical use is byte-stable; `PropertyViolated` reports its
/// category here and its rule name via [`code`](Self::code).
pub fn as_code(&self) -> &'static str {
match self {
BreakReason::TamperDetected => "TAMPER_DETECTED",
BreakReason::SequenceGap => "SEQUENCE_GAP",
BreakReason::SourceChange => "SOURCE_CHANGE",
BreakReason::ManualReset => "MANUAL_RESET",
BreakReason::SchemaMigration => "SCHEMA_MIGRATION",
BreakReason::ConditionEvaluatorError => "CONDITION_EVALUATOR_ERROR",
BreakReason::PropertyViolated(_) => "PROPERTY_VIOLATED",
}
}
/// The recorded reason string: the category, plus the rule name for a
/// `PropertyViolated` (`PROPERTY_VIOLATED:<rule>`). This is what a break row
/// stores, so the answer to "which invariant broke?" travels on the chain.
pub fn code(&self) -> String {
match self {
BreakReason::PropertyViolated(rule) => format!("PROPERTY_VIOLATED:{rule}"),
other => other.as_code().to_string(),
}
}
/// The rule name, when this break is a `PropertyViolated`. `None` otherwise.
pub fn rule(&self) -> Option<&str> {
match self {
BreakReason::PropertyViolated(rule) => Some(rule),
_ => None,
}
}
}
#[derive(Debug, Clone)]
pub enum ConditionOutcome {
Pass,
Fail(BreakReason),
}
pub trait Condition: Send + Sync {
fn name(&self) -> &'static str;
fn evaluate(&self, ctx: &ConditionContext) -> ConditionOutcome;
}
pub struct HashIntegrity;
impl Condition for HashIntegrity {
fn name(&self) -> &'static str {
"hash_integrity"
}
fn evaluate(&self, ctx: &ConditionContext) -> ConditionOutcome {
let stored = &ctx.stored_tip_hash;
let computed = &ctx.computed_hash;
// Genesis — first ingestion for this entity.
if stored.is_none() && ctx.sequence == 0 {
return ConditionOutcome::Pass;
}
// Stored matches computed — chain intact.
if let (Some(s), Some(c)) = (stored.as_ref(), computed.as_ref()) {
if s == c {
return ConditionOutcome::Pass;
}
return ConditionOutcome::Fail(BreakReason::TamperDetected);
}
// stored present, computed missing — cannot compare, treat as fail.
if stored.is_some() && computed.is_none() {
return ConditionOutcome::Fail(BreakReason::TamperDetected);
}
// stored=None, sequence>0 — unusual but recoverable.
ConditionOutcome::Pass
}
}
pub struct SequenceContinuity;
impl Condition for SequenceContinuity {
fn name(&self) -> &'static str {
"sequence_continuity"
}
fn evaluate(&self, ctx: &ConditionContext) -> ConditionOutcome {
match ctx.last_sequence {
None => ConditionOutcome::Pass,
Some(last) => {
if ctx.sequence == last + 1 {
ConditionOutcome::Pass
} else {
ConditionOutcome::Fail(BreakReason::SequenceGap)
}
}
}
}
}
pub struct SourceDomain {
pub trusted_domains: Vec<String>,
pub hard: bool,
}
impl SourceDomain {
pub fn new(trusted_domains: Vec<String>, hard: bool) -> Self {
Self {
trusted_domains,
hard,
}
}
}
impl Condition for SourceDomain {
fn name(&self) -> &'static str {
"source_domain"
}
fn evaluate(&self, ctx: &ConditionContext) -> ConditionOutcome {
if ctx.source_url.is_empty() || self.trusted_domains.is_empty() {
return ConditionOutcome::Pass;
}
let ok = self
.trusted_domains
.iter()
.any(|d| ctx.source_url.contains(d));
if ok {
ConditionOutcome::Pass
} else if self.hard {
ConditionOutcome::Fail(BreakReason::SourceChange)
} else {
ConditionOutcome::Pass
}
}
}
pub fn default_conditions() -> Vec<Box<dyn Condition>> {
vec![Box::new(HashIntegrity), Box::new(SequenceContinuity)]
}
pub fn evaluate(conditions: &[Box<dyn Condition>], ctx: &ConditionContext) -> ConditionOutcome {
for c in conditions {
match c.evaluate(ctx) {
ConditionOutcome::Pass => continue,
ConditionOutcome::Fail(reason) => return ConditionOutcome::Fail(reason),
}
}
ConditionOutcome::Pass
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn property_violated_carries_the_rule_name_via_code_not_the_frozen_as_code() {
let r = BreakReason::PropertyViolated("anchor-before-commit".into());
assert_eq!(r.as_code(), "PROPERTY_VIOLATED", "frozen category");
assert_eq!(
r.code(),
"PROPERTY_VIOLATED:anchor-before-commit",
"recorded reason names the rule"
);
assert_eq!(r.rule(), Some("anchor-before-commit"));
// Existing variants are byte-stable: code() == as_code() for them, so no
// historical break row changes meaning.
assert_eq!(BreakReason::SequenceGap.code(), "SEQUENCE_GAP");
assert_eq!(BreakReason::TamperDetected.rule(), None);
}
fn ctx_genesis() -> ConditionContext {
ConditionContext {
data: serde_json::Value::Null,
entity_id: "acme".into(),
chain_id: "chain_acme_001".into(),
sequence: 0,
stored_tip_hash: None,
computed_hash: None,
prev_hash: None,
last_sequence: None,
source_url: "s".into(),
extraction_method: "trusty".into(),
}
}
#[test]
fn hash_integrity_passes_on_genesis() {
let c = HashIntegrity;
matches!(c.evaluate(&ctx_genesis()), ConditionOutcome::Pass);
}
#[test]
fn hash_integrity_fails_on_mismatch() {
let mut ctx = ctx_genesis();
ctx.sequence = 1;
ctx.stored_tip_hash = Some("abc".into());
ctx.computed_hash = Some("def".into());
ctx.last_sequence = Some(0);
let out = HashIntegrity.evaluate(&ctx);
assert!(matches!(
out,
ConditionOutcome::Fail(BreakReason::TamperDetected)
));
}
#[test]
fn hash_integrity_passes_when_match() {
let mut ctx = ctx_genesis();
ctx.sequence = 1;
ctx.stored_tip_hash = Some("same".into());
ctx.computed_hash = Some("same".into());
ctx.last_sequence = Some(0);
assert!(matches!(
HashIntegrity.evaluate(&ctx),
ConditionOutcome::Pass
));
}
#[test]
fn sequence_continuity_passes_first_row() {
assert!(matches!(
SequenceContinuity.evaluate(&ctx_genesis()),
ConditionOutcome::Pass
));
}
#[test]
fn sequence_continuity_fails_on_gap() {
let mut ctx = ctx_genesis();
ctx.last_sequence = Some(5);
ctx.sequence = 7;
let out = SequenceContinuity.evaluate(&ctx);
assert!(matches!(
out,
ConditionOutcome::Fail(BreakReason::SequenceGap)
));
}
#[test]
fn sequence_continuity_passes_on_next() {
let mut ctx = ctx_genesis();
ctx.last_sequence = Some(5);
ctx.sequence = 6;
assert!(matches!(
SequenceContinuity.evaluate(&ctx),
ConditionOutcome::Pass
));
}
#[test]
fn evaluate_returns_first_failure() {
let conditions: Vec<Box<dyn Condition>> =
vec![Box::new(HashIntegrity), Box::new(SequenceContinuity)];
let mut ctx = ctx_genesis();
ctx.sequence = 7;
ctx.last_sequence = Some(5);
ctx.stored_tip_hash = Some("abc".into());
ctx.computed_hash = Some("def".into());
// Both conditions fail — HashIntegrity runs first, wins.
let out = evaluate(&conditions, &ctx);
assert!(matches!(
out,
ConditionOutcome::Fail(BreakReason::TamperDetected)
));
}
#[test]
fn source_domain_soft_does_not_break() {
let cond = SourceDomain::new(vec!["sec.gov".into()], false);
let mut ctx = ctx_genesis();
ctx.source_url = "http://evil.example/data".into();
assert!(matches!(cond.evaluate(&ctx), ConditionOutcome::Pass));
}
#[test]
fn source_domain_hard_breaks_on_mismatch() {
let cond = SourceDomain::new(vec!["sec.gov".into()], true);
let mut ctx = ctx_genesis();
ctx.source_url = "http://evil.example/data".into();
assert!(matches!(
cond.evaluate(&ctx),
ConditionOutcome::Fail(BreakReason::SourceChange)
));
}
}

View File

@@ -0,0 +1,160 @@
//! What kind of key a producer enrolled, and what may be done with it.
//!
//! # The failure this prevents
//!
//! A `user` principal authenticates with WebAuthn. The credential is a
//! **COSE key**, and the authenticator does not sign the message a
//! relying party cares about -- it signs
//! `authenticatorData ‖ SHA-256(clientDataJSON)`, with the challenge
//! buried inside the client data.
//!
//! An `agent` or `program` holds a raw Ed25519 key and produces a bare
//! signature over the message itself.
//!
//! These are not interchangeable, and the dangerous part is how they
//! fail together. Handing a COSE key and a WebAuthn assertion to a bare
//! Ed25519 verifier does not produce a clean error -- it produces a
//! verification failure that looks like a wrong key, which invites
//! somebody to "fix" it by loosening the check. A registry with one
//! column and one verifier makes that mistake available; this makes it
//! unavailable.
//!
//! # Where the boundary is
//!
//! Trusty stores the format and **refuses** to verify what it cannot
//! verify. It does not verify WebAuthn assertions, deliberately: doing
//! that requires the relying party's RP ID, origin, and challenge
//! policy, none of which trusty has any business knowing. A half-built
//! WebAuthn verifier is worse than none, because it looks like one.
//!
//! The consumer that *is* the relying party verifies the assertion and
//! tells trusty the outcome.
use serde::{Deserialize, Serialize};
use crate::crypto::CryptoError;
/// How a producer's enrolled key material is encoded, and therefore how
/// a signature from it must be checked.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize, Default)]
#[serde(rename_all = "kebab-case")]
pub enum KeyFormat {
/// A raw 32-byte Ed25519 public key. The signature covers the
/// message directly. This is what an agent or program enrols.
#[default]
Ed25519Raw,
/// A COSE_Key from a WebAuthn authenticator. The signature covers
/// `authenticatorData ‖ SHA-256(clientDataJSON)`, not the message,
/// so it cannot be checked without the assertion that produced it.
/// This is what a user enrols.
Cose,
}
impl KeyFormat {
pub fn as_str(self) -> &'static str {
match self {
Self::Ed25519Raw => "ed25519-raw",
Self::Cose => "cose",
}
}
/// Parse a stored format string.
///
/// An unrecognised value is an error, not a default. Defaulting to
/// `Ed25519Raw` would silently route a credential of unknown shape
/// into the bare-signature path -- the exact failure this type
/// exists to prevent.
pub fn parse(s: &str) -> Result<Self, CryptoError> {
match s {
"ed25519-raw" => Ok(Self::Ed25519Raw),
"cose" => Ok(Self::Cose),
other => Err(CryptoError::UnsupportedCredential(format!(
"unknown key format {other:?}; refusing rather than guessing, \
because guessing wrong means checking a WebAuthn credential \
as a bare signature"
))),
}
}
/// Whether a bare signature over the message is the right check.
pub fn is_bare_signature(self) -> bool {
matches!(self, Self::Ed25519Raw)
}
}
/// Refuse to verify a credential whose format is not bare-signature.
///
/// Call this before any `Verifier::verify` that uses a key from the
/// producer registry. Returns the error a caller should surface rather
/// than working around.
pub fn require_bare_signature(format: KeyFormat, producer_oid: &str) -> Result<(), CryptoError> {
if format.is_bare_signature() {
return Ok(());
}
Err(CryptoError::UnsupportedCredential(format!(
"producer {producer_oid:?} enrolled a {} credential, which signs \
`authenticatorData || SHA-256(clientDataJSON)` rather than the message. \
It cannot be checked as a bare signature. Verify the WebAuthn assertion \
at the relying party -- which holds the RP ID, origin and challenge -- \
and record the outcome; trusty does not verify assertions and will not \
pretend to.",
format.as_str()
)))
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn a_cose_credential_cannot_be_checked_as_a_bare_signature() {
let err = require_bare_signature(KeyFormat::Cose, "user:01H")
.expect_err("must refuse");
let msg = err.to_string();
assert!(
msg.contains("clientDataJSON"),
"the refusal must say WHY, or the next person will assume a wrong \
key and loosen the check to make it pass: {msg}"
);
assert!(
msg.contains("relying party"),
"and it must say who should verify instead, or the reader is left \
with a refusal and nowhere to go: {msg}"
);
}
#[test]
fn a_raw_ed25519_credential_is_allowed_through() {
assert!(require_bare_signature(KeyFormat::Ed25519Raw, "agent:01J").is_ok());
}
#[test]
fn an_unknown_format_refuses_rather_than_defaulting() {
assert!(
KeyFormat::parse("something-new").is_err(),
"defaulting an unrecognised format to raw Ed25519 would route a \
credential of unknown shape into the bare-signature path, which \
is the whole failure this type prevents"
);
// Default is still Ed25519Raw for a row written before the
// column existed -- but that is a schema default applied at
// write time, never a parse fallback.
assert_eq!(KeyFormat::default(), KeyFormat::Ed25519Raw);
}
#[test]
fn the_stored_strings_round_trip() {
for f in [KeyFormat::Ed25519Raw, KeyFormat::Cose] {
assert_eq!(KeyFormat::parse(f.as_str()).expect("round trip"), f);
}
}
#[test]
fn the_two_formats_are_distinguishable_in_storage() {
assert_ne!(
KeyFormat::Ed25519Raw.as_str(),
KeyFormat::Cose.as_str(),
"two formats sharing a stored value would make the column useless"
);
}
}

View File

@@ -0,0 +1,198 @@
// ============================================================
// MODULE: trusty_core::crypto
// PURPOSE: Signer/Verifier trait surface + Ed25519 default impl.
// Reserved for payload signatures (v2 payload.signatures field).
// STATUS: Wired. `chain::compute_record_signed` embeds a signature (with the
// public key inline) into `payload.signatures`, and
// `chain::verify_links` verifies it via `verify_row_signatures`.
// v1 payloads carry `signatures = []` by definition, so verification
// is a no-op for them; v2 and v3 are signed and checked.
//
// This read "Scaffold. Chain-payload integration deferred to a later
// session." -- true when written, no longer. Corrected because a
// reader who trusts the header concludes signatures do not work, and
// they are the one part of a chain that a party WITHOUT the SipHash
// key can verify.
//
// DESIGN:
// - Trait surface is byte-oriented (Vec<u8> / &[u8]) so chain
// code can remain free of signature-crate type leakage.
// - Ed25519Signer wraps ed25519-dalek's SigningKey. Generation
// uses OsRng (rand crate). Deterministic reconstruction via
// from_secret_bytes for test vectors and key-escrow flows.
// - Ed25519Verifier is stateless (verify is a pure function of
// pk, msg, sig bytes). Kept as a unit struct to carry the
// Verifier trait impl.
// ============================================================
use ed25519_dalek::{
Signature as DalekSignature, Signer as DalekSigner, SigningKey, Verifier as DalekVerifier,
VerifyingKey, PUBLIC_KEY_LENGTH, SECRET_KEY_LENGTH, SIGNATURE_LENGTH,
};
#[cfg(feature = "keygen")]
use rand::rngs::OsRng;
#[derive(Debug, thiserror::Error)]
#[non_exhaustive]
pub enum CryptoError {
#[error("invalid secret key length (expected 32, got {0})")]
InvalidSecretLen(usize),
#[error("invalid public key: {0}")]
InvalidPublicKey(String),
#[error("invalid signature length (expected 64, got {0})")]
InvalidSignatureLen(usize),
#[error("signature verification failed")]
VerifyFailed,
/// The credential is well-formed but cannot be checked this way.
///
/// Distinct from `VerifyFailed` on purpose: a failed check invites
/// "wrong key, try another", while this says the check itself is the
/// wrong one. Conflating them is how a WebAuthn credential ends up
/// being verified as a bare signature.
#[error("unsupported credential: {0}")]
UnsupportedCredential(String),
}
pub trait Signer {
fn sign(&self, msg: &[u8]) -> Vec<u8>;
fn public_key_bytes(&self) -> Vec<u8>;
}
pub trait Verifier {
fn verify(pk_bytes: &[u8], msg: &[u8], sig_bytes: &[u8]) -> Result<(), CryptoError>;
}
pub struct Ed25519Signer {
key: SigningKey,
}
impl Ed25519Signer {
/// Generate a fresh key pair from the operating-system RNG.
///
/// Requires the `keygen` feature (on by default). It is the only
/// RNG-dependent item in this crate; a `default-features = false` build
/// keeps every verification and reconstruction path and drops just this,
/// which is what makes the crate usable on `wasm32`.
#[cfg(feature = "keygen")]
pub fn generate() -> Self {
Self {
key: SigningKey::generate(&mut OsRng),
}
}
pub fn from_secret_bytes(bytes: &[u8]) -> Result<Self, CryptoError> {
if bytes.len() != SECRET_KEY_LENGTH {
return Err(CryptoError::InvalidSecretLen(bytes.len()));
}
let mut arr = [0u8; SECRET_KEY_LENGTH];
arr.copy_from_slice(bytes);
Ok(Self {
key: SigningKey::from_bytes(&arr),
})
}
pub fn secret_bytes(&self) -> [u8; SECRET_KEY_LENGTH] {
self.key.to_bytes()
}
}
impl Signer for Ed25519Signer {
fn sign(&self, msg: &[u8]) -> Vec<u8> {
DalekSigner::sign(&self.key, msg).to_bytes().to_vec()
}
fn public_key_bytes(&self) -> Vec<u8> {
self.key.verifying_key().to_bytes().to_vec()
}
}
pub struct Ed25519Verifier;
impl Verifier for Ed25519Verifier {
fn verify(pk_bytes: &[u8], msg: &[u8], sig_bytes: &[u8]) -> Result<(), CryptoError> {
if pk_bytes.len() != PUBLIC_KEY_LENGTH {
return Err(CryptoError::InvalidPublicKey(format!(
"expected {} bytes, got {}",
PUBLIC_KEY_LENGTH,
pk_bytes.len()
)));
}
if sig_bytes.len() != SIGNATURE_LENGTH {
return Err(CryptoError::InvalidSignatureLen(sig_bytes.len()));
}
let mut pk_arr = [0u8; PUBLIC_KEY_LENGTH];
pk_arr.copy_from_slice(pk_bytes);
let vk = VerifyingKey::from_bytes(&pk_arr)
.map_err(|e| CryptoError::InvalidPublicKey(e.to_string()))?;
let mut sig_arr = [0u8; SIGNATURE_LENGTH];
sig_arr.copy_from_slice(sig_bytes);
let sig = DalekSignature::from_bytes(&sig_arr);
vk.verify(msg, &sig).map_err(|_| CryptoError::VerifyFailed)
}
}
#[cfg(test)]
mod tests {
use super::*;
#[cfg(feature = "keygen")]
#[test]
fn generated_key_roundtrip() {
let signer = Ed25519Signer::generate();
let msg = b"trusty chain payload v2";
let sig = signer.sign(msg);
let pk = signer.public_key_bytes();
assert_eq!(sig.len(), SIGNATURE_LENGTH);
assert_eq!(pk.len(), PUBLIC_KEY_LENGTH);
assert!(Ed25519Verifier::verify(&pk, msg, &sig).is_ok());
}
#[test]
fn deterministic_signature_from_fixed_seed() {
// Ed25519 is deterministic: same key + same msg -> same signature.
// This invariant underwrites reproducible chain verification.
let seed = [7u8; 32];
let signer = Ed25519Signer::from_secret_bytes(&seed).unwrap();
assert_eq!(signer.secret_bytes(), seed);
let msg = b"deterministic";
let sig1 = signer.sign(msg);
let sig2 = signer.sign(msg);
assert_eq!(sig1, sig2);
assert!(Ed25519Verifier::verify(&signer.public_key_bytes(), msg, &sig1).is_ok());
}
#[test]
fn invalid_secret_len_rejected() {
assert!(matches!(
Ed25519Signer::from_secret_bytes(&[0u8; 16]),
Err(CryptoError::InvalidSecretLen(16))
));
assert!(matches!(
Ed25519Signer::from_secret_bytes(&[0u8; 64]),
Err(CryptoError::InvalidSecretLen(64))
));
}
#[test]
fn tampered_message_fails_verify() {
let signer = Ed25519Signer::from_secret_bytes(&[9u8; 32]).unwrap();
let sig = signer.sign(b"hello");
let pk = signer.public_key_bytes();
assert!(matches!(
Ed25519Verifier::verify(&pk, b"hello!", &sig),
Err(CryptoError::VerifyFailed)
));
}
#[cfg(feature = "keygen")]
#[test]
fn malformed_signature_rejected() {
let signer = Ed25519Signer::generate();
let pk = signer.public_key_bytes();
let short_sig = vec![0u8; 32];
assert!(matches!(
Ed25519Verifier::verify(&pk, b"x", &short_sig),
Err(CryptoError::InvalidSignatureLen(32))
));
}
}

View File

@@ -0,0 +1,27 @@
use thiserror::Error;
#[derive(Debug, Error)]
pub enum TrustyError {
#[error("secret key must be exactly 16 bytes, got {0}")]
InvalidKeyLength(usize),
#[error("payload serialization failed: {0}")]
PayloadSerialization(String),
#[error("payload is not valid JSON: {0}")]
InvalidPayloadJson(String),
#[error("raw_payload missing for row at sequence {0}")]
MissingRawPayload(i64),
#[error("chain id malformed: {0}")]
MalformedChainId(String),
#[error("payload data too large: {size} bytes exceeds max {max}")]
DataTooLarge { size: usize, max: usize },
#[error("unsupported payload_version: {0}")]
UnsupportedPayloadVersion(u32),
}
pub type TrustyResult<T> = Result<T, TrustyError>;

View File

@@ -0,0 +1,253 @@
// Dual-hash primitives.
//
// row_hash — SipHash-2-4 over canonical payload bytes, 16-byte secret key,
// rendered as lowercase hex of the 64-bit digest (16 chars).
// anchor_hash — SHA-256 over row_hash || 0x00 || entity_id || 0x00 || chain_id,
// rendered as lowercase hex (64 chars).
//
// Semantics are byte-identical to the Python reference at
// trusty_core_watermark_proxy/core/trusty/hashing/{siphash,sha256}.py.
use sha2::{Digest, Sha256};
use siphasher::sip::SipHasher24;
use std::hash::Hasher;
use zeroize::{Zeroize, ZeroizeOnDrop};
use crate::error::{TrustyError, TrustyResult};
pub const SECRET_KEY_LEN: usize = 16;
/// A SipHash key.
///
/// Wiped on drop. Key material that merely goes out of scope stays in
/// freed memory until something reuses the page, and from there reaches
/// a core dump, a swap file, or a hibernation image -- none of which the
/// process controls, and all of which outlive it.
///
/// `ZeroizeOnDrop` rather than a hand-written `Drop`: the compiler is
/// free to elide a plain `write` to memory nobody reads again, and the
/// crate's whole purpose is to use the primitives that are not elided.
///
/// Not `Copy`, deliberately. A `Copy` key produces silent duplicates
/// that no `Drop` ever runs for, which would make the wipe decorative.
/// It stays `Clone`, because a store legitimately holds one, but each
/// clone now cleans up after itself.
#[derive(Clone, Zeroize, ZeroizeOnDrop)]
pub struct SecretKey([u8; SECRET_KEY_LEN]);
impl std::fmt::Debug for SecretKey {
/// Never print key material.
///
/// A derived `Debug` puts the key in any log line, panic message or
/// error report that formats a struct containing one -- the most
/// common way a secret escapes a process that never intended to
/// disclose it.
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str("SecretKey(<redacted>)")
}
}
impl SecretKey {
pub fn new(bytes: [u8; SECRET_KEY_LEN]) -> Self {
SecretKey(bytes)
}
pub fn from_bytes(bytes: &[u8]) -> TrustyResult<Self> {
if bytes.len() != SECRET_KEY_LEN {
return Err(TrustyError::InvalidKeyLength(bytes.len()));
}
let mut out = [0u8; SECRET_KEY_LEN];
out.copy_from_slice(bytes);
Ok(SecretKey(out))
}
pub fn as_bytes(&self) -> &[u8; SECRET_KEY_LEN] {
&self.0
}
fn k0(&self) -> u64 {
u64::from_le_bytes(self.0[0..8].try_into().unwrap())
}
fn k1(&self) -> u64 {
u64::from_le_bytes(self.0[8..16].try_into().unwrap())
}
}
pub trait RowHasher {
fn hash(&self, key: &SecretKey, data: &[u8]) -> String;
}
pub trait AnchorHasher {
fn hash(&self, row_hash: &str, entity_id: &str, chain_id: &str) -> String;
}
pub struct SipHash24Hasher;
impl RowHasher for SipHash24Hasher {
fn hash(&self, key: &SecretKey, data: &[u8]) -> String {
let mut hasher = SipHasher24::new_with_keys(key.k0(), key.k1());
hasher.write(data);
format!("{:016x}", hasher.finish())
}
}
pub struct Sha256Hasher;
impl AnchorHasher for Sha256Hasher {
fn hash(&self, row_hash: &str, entity_id: &str, chain_id: &str) -> String {
let mut h = Sha256::new();
h.update(row_hash.as_bytes());
h.update(b"\x00");
h.update(entity_id.as_bytes());
h.update(b"\x00");
h.update(chain_id.as_bytes());
hex::encode(h.finalize())
}
}
pub fn row_hash(key: &SecretKey, data: &[u8]) -> String {
SipHash24Hasher.hash(key, data)
}
pub fn anchor_hash(row_hash: &str, entity_id: &str, chain_id: &str) -> String {
Sha256Hasher.hash(row_hash, entity_id, chain_id)
}
// v3 hashes: identical algorithms, domain-tagged inputs.
use crate::payload::{DOMAIN_TAG_ANCHOR_V3, DOMAIN_TAG_ROW_V3};
pub fn row_hash_v3(key: &SecretKey, data: &[u8]) -> String {
let mut buf = Vec::with_capacity(DOMAIN_TAG_ROW_V3.len() + data.len());
buf.extend_from_slice(DOMAIN_TAG_ROW_V3);
buf.extend_from_slice(data);
SipHash24Hasher.hash(key, &buf)
}
pub fn anchor_hash_v3(row_hash: &str, entity_id: &str, chain_id: &str) -> String {
let mut h = Sha256::new();
h.update(DOMAIN_TAG_ANCHOR_V3);
h.update(row_hash.as_bytes());
h.update(b"\x00");
h.update(entity_id.as_bytes());
h.update(b"\x00");
h.update(chain_id.as_bytes());
hex::encode(h.finalize())
}
pub fn row_hash_for_version(version: u32, key: &SecretKey, data: &[u8]) -> TrustyResult<String> {
match version {
1 | 2 => Ok(row_hash(key, data)),
3 => Ok(row_hash_v3(key, data)),
other => Err(TrustyError::UnsupportedPayloadVersion(other)),
}
}
pub fn anchor_hash_for_version(
version: u32,
row_hash_hex: &str,
entity_id: &str,
chain_id: &str,
) -> TrustyResult<String> {
match version {
1 | 2 => Ok(anchor_hash(row_hash_hex, entity_id, chain_id)),
3 => Ok(anchor_hash_v3(row_hash_hex, entity_id, chain_id)),
other => Err(TrustyError::UnsupportedPayloadVersion(other)),
}
}
#[cfg(test)]
mod tests {
use super::*;
fn test_key() -> SecretKey {
SecretKey::from_bytes(b"0123456789abcdef").unwrap()
}
#[test]
fn row_hash_is_16_hex_chars() {
let h = row_hash(&test_key(), b"hello world");
assert_eq!(h.len(), 16);
assert!(h.chars().all(|c| c.is_ascii_hexdigit()));
}
#[test]
fn row_hash_is_deterministic() {
let k = test_key();
assert_eq!(row_hash(&k, b"abc"), row_hash(&k, b"abc"));
}
#[test]
fn row_hash_differs_with_key() {
let k1 = SecretKey::from_bytes(b"0123456789abcdef").unwrap();
let k2 = SecretKey::from_bytes(b"fedcba9876543210").unwrap();
assert_ne!(row_hash(&k1, b"data"), row_hash(&k2, b"data"));
}
#[test]
fn anchor_hash_is_64_hex_chars() {
let h = anchor_hash("0123456789abcdef", "acme", "chain_acme_001");
assert_eq!(h.len(), 64);
assert!(h.chars().all(|c| c.is_ascii_hexdigit()));
}
#[test]
fn anchor_hash_depends_on_row_hash() {
let a = anchor_hash("0000000000000000", "acme", "chain_acme_001");
let b = anchor_hash("1111111111111111", "acme", "chain_acme_001");
assert_ne!(a, b);
}
#[test]
fn anchor_hash_depends_on_entity() {
let a = anchor_hash("0000000000000000", "acme", "chain_acme_001");
let b = anchor_hash("0000000000000000", "widgets", "chain_acme_001");
assert_ne!(a, b);
}
#[test]
fn bad_key_length_rejected() {
assert!(matches!(
SecretKey::from_bytes(b"short"),
Err(TrustyError::InvalidKeyLength(5))
));
}
}
#[cfg(test)]
mod secret_key_tests {
use super::*;
#[test]
fn a_key_is_never_printed() {
let k = SecretKey::new([0xAB; SECRET_KEY_LEN]);
let shown = format!("{k:?}");
assert!(
!shown.contains("ab") && !shown.contains("171"),
"a derived Debug puts key material into any log line or panic message that formats a struct containing one, which is the most common way a secret leaves a process that never meant to disclose it: {shown}"
);
assert_eq!(shown, "SecretKey(<redacted>)");
}
#[test]
fn a_key_is_not_copy() {
// Structural: if SecretKey were Copy, every use would leave a
// silent duplicate that no Drop runs for, and the wipe would be
// decorative. This asserts the property by construction -- the
// function below only compiles for non-Copy types being moved.
fn assert_not_copy<T: Clone>(_: &T) {}
let k = SecretKey::new([1u8; SECRET_KEY_LEN]);
assert_not_copy(&k);
let moved = k;
assert_eq!(moved.as_bytes()[0], 1);
}
#[test]
fn a_key_still_clones_because_a_store_holds_one() {
let k = SecretKey::new([7u8; SECRET_KEY_LEN]);
let c = k.clone();
assert_eq!(k.as_bytes(), c.as_bytes(), "cloning must preserve the key");
}
}

View File

@@ -0,0 +1,75 @@
//! Format-agnostic tamper-evidence primitives for the Trusty chain.
//!
//! Dual hash: SipHash-2-4 keyed fingerprint (`row_hash`) + SHA-256 cryptographic
//! anchor (`anchor_hash`). Chain-break-as-event primitive. No storage dependency.
//!
//! # Design invariants (ERC002)
//! 1. `raw_payload` is stored verbatim -- verification recomputes from it.
//! 2. The chain is append-only.
//! 3. `prev_hash` of row N equals `row_hash` of row N-1 on the same chain.
//!
//! # Payload format (v1 -- locked)
//! Canonical JSON with sorted keys and compact separators. Fields in alphabetical
//! order: `chain_id`, `data`, `detected_at`, `entity_id`, `extraction_method`,
//! `hash_alg`, `payload_version`, `prev_hash`, `received_at`, `sequence`,
//! `signatures`, `source_url`. v2 is additive only -- v1 byte shape is never
//! altered. See `docs/payload-v2.md`.
#![deny(clippy::all)]
pub mod bundle;
pub mod chain;
pub mod conditions;
pub mod credential;
pub mod crypto;
pub mod error;
pub mod hash;
pub mod payload;
pub mod annotation;
pub use bundle::{
Stance, Violation,
AnchorEntry, BundleRow, ProofBundle, SourceRef, SourceRefMismatch, SourceRefReport,
};
pub use chain::{
checkpoint_signing_bytes,
compute_record,
compute_record_signed,
make_chain_id,
parse_generation,
parse_raw_payload,
verify_links,
verify_links_from,
// Verification WITHOUT the operator secret key: the path available to a
// holder of a ProofBundle. See `chain::verify_links_public`.
verify_links_public,
verify_links_public_from,
verify_row_signatures,
BreakPoint,
ChainLink,
EventType,
PublicVerifyReport,
VerifyError,
VerifyErrorType,
VerifyReport,
GENESIS_SENTINEL,
};
pub use conditions::{
default_conditions, evaluate, BreakReason, Condition, ConditionContext, ConditionOutcome,
HashIntegrity, SequenceContinuity,
};
pub use credential::{require_bare_signature, KeyFormat};
pub use crypto::{CryptoError, Ed25519Signer, Ed25519Verifier, Signer, Verifier};
pub use error::{TrustyError, TrustyResult};
pub use hash::{
anchor_hash, row_hash, AnchorHasher, RowHasher, SecretKey, Sha256Hasher, SipHash24Hasher,
};
pub use payload::{
build_payload, signing_view_bytes, PayloadFields, DOMAIN_TAG_CHECKPOINT_V1, PAYLOAD_VERSION,
PAYLOAD_VERSION_V1, PAYLOAD_VERSION_V2,
};
pub use annotation::{
entity_id_for, verify_annotations, Annotation, AnnotationKind, AnnotationProblem,
AnnotationReport, CustodyHop, Layer, ANNOTATION_KIND,
};

View File

@@ -0,0 +1,337 @@
// Canonical payload serializer — PAYLOAD VERSIONS 1 and 2.
//
// The canonical form is JSON, sorted keys recursively, no whitespace.
// The exact byte sequence emitted here is what row_hash (SipHash-2-4)
// is computed over, so this file is consensus-critical: any change
// to the v1 byte-shape is a breaking format change.
//
// Fields are shared across versions (same schema + ordering); only the
// `payload_version` discriminator and the population of `signatures`
// differ. v1 leaves `signatures` empty; v2 emits one object per signer
// with shape `{alg, pk, sig}`, hex-encoded, keys alphabetical.
//
// Fields in canonical (alphabetical) order:
// chain_id, data, detected_at, entity_id, extraction_method,
// hash_alg, payload_version, prev_hash, received_at, sequence,
// signatures, source_url.
use serde_json::{Map, Value};
use std::collections::BTreeMap;
use crate::error::{TrustyError, TrustyResult};
/// Legacy constant preserved for existing call sites. Prefer
/// `PAYLOAD_VERSION_V1` in new code.
pub const PAYLOAD_VERSION: u32 = 1;
/// v1 payload format (signatures reserved []). Locked byte-shape.
pub const PAYLOAD_VERSION_V1: u32 = 1;
/// v2 payload format (signatures populated with one `{alg,pk,sig}`
/// per signer). Emitted only when a caller opts in via
/// `chain::compute_record_signed`.
pub const PAYLOAD_VERSION_V2: u32 = 2;
/// v3 payload format. Identical on-wire byte-shape to v1/v2 (same
/// canonical JSON serializer, same 12 fields in alphabetical order),
/// but hash inputs are prefixed with domain tags to eliminate
/// cross-protocol collision risk. Dispatched by payload_version at
/// the hash step, not at the payload step.
pub const PAYLOAD_VERSION_V3: u32 = 3;
/// Domain tag prefixed to the canonical payload bytes before SipHash
/// when payload_version == 3.
pub const DOMAIN_TAG_ROW_V3: &[u8] = b"trusty.chain.v3\x00";
/// Domain tag prefixed to the SHA-256 input when payload_version == 3.
pub const DOMAIN_TAG_ANCHOR_V3: &[u8] = b"trusty.anchor.v3\x00";
/// Domain tag prefixed to the canonical bytes a checkpoint attestor
/// signs. Distinct from the chain row and anchor domains so a
/// checkpoint signature cannot be replayed as a row or anchor
/// signature. v1 because the checkpoint envelope is verification-
/// layer only and lives outside the v1/v2/v3 payload byte-shape lock
/// (memo §5: "MUST NOT change v1 byte-shape").
pub const DOMAIN_TAG_CHECKPOINT_V1: &[u8] = b"trusty.checkpoint.v1\x00";
/// Hash algorithm dispatch string. Verifiers read this before
/// recomputing row_hash so future algorithm agility is trivial.
pub const HASH_ALG: &str = "siphash24+sha256";
/// Hard cap on the canonicalized `data` field (bytes). Blobs belong
/// in object storage; put a hash + URI in `data`, not the blob
/// itself. 64 KiB is generous for metadata and keeps parse/verify
/// cost bounded per row.
pub const MAX_DATA_BYTES: usize = 65_536;
#[derive(Debug, Clone)]
pub struct PayloadFields {
pub entity_id: String,
pub chain_id: String,
pub data: Value,
pub source_url: String,
pub detected_at: String,
pub received_at: String,
pub extraction_method: String,
pub prev_hash: String,
pub sequence: i64,
pub hash_alg: String,
pub payload_version: u32,
pub signatures: Vec<Value>,
}
impl PayloadFields {
/// Construct a v1 `PayloadFields` with `hash_alg`, `payload_version`,
/// and `signatures` set to their v1 defaults.
#[allow(clippy::too_many_arguments)]
pub fn new_v1(
entity_id: impl Into<String>,
chain_id: impl Into<String>,
data: Value,
source_url: impl Into<String>,
detected_at: impl Into<String>,
received_at: impl Into<String>,
extraction_method: impl Into<String>,
prev_hash: impl Into<String>,
sequence: i64,
) -> Self {
Self {
entity_id: entity_id.into(),
chain_id: chain_id.into(),
data,
source_url: source_url.into(),
detected_at: detected_at.into(),
received_at: received_at.into(),
extraction_method: extraction_method.into(),
prev_hash: prev_hash.into(),
sequence,
hash_alg: HASH_ALG.to_string(),
payload_version: PAYLOAD_VERSION,
signatures: Vec::new(),
}
}
/// Construct a v3 `PayloadFields`. Same shape as `new_v1`; only
/// `payload_version` differs. The hash step (not this builder)
/// is what applies the v3 domain tag.
#[allow(clippy::too_many_arguments)]
pub fn new_v3(
entity_id: impl Into<String>,
chain_id: impl Into<String>,
data: Value,
source_url: impl Into<String>,
detected_at: impl Into<String>,
received_at: impl Into<String>,
extraction_method: impl Into<String>,
prev_hash: impl Into<String>,
sequence: i64,
) -> Self {
Self {
entity_id: entity_id.into(),
chain_id: chain_id.into(),
data,
source_url: source_url.into(),
detected_at: detected_at.into(),
received_at: received_at.into(),
extraction_method: extraction_method.into(),
prev_hash: prev_hash.into(),
sequence,
hash_alg: HASH_ALG.to_string(),
payload_version: PAYLOAD_VERSION_V3,
signatures: Vec::new(),
}
}
}
/// Canonical bytes of a v2 payload with `signatures` forced empty --
/// this is the "signing view" a Signer signs over. The final emitted
/// payload replaces `signatures` with the signer output and is then
/// re-canonicalized; row_hash commits to that final form. Verification
/// reconstructs this view by stripping `signatures` back to `[]`.
pub fn signing_view_bytes(fields: &PayloadFields) -> TrustyResult<String> {
let mut view = fields.clone();
view.signatures = Vec::new();
build_payload(&view)
}
pub fn build_payload(fields: &PayloadFields) -> TrustyResult<String> {
let data_canonical = canonicalize(&fields.data);
let data_bytes = serde_json::to_string(&data_canonical)
.map_err(|e| TrustyError::PayloadSerialization(e.to_string()))?;
if data_bytes.len() > MAX_DATA_BYTES {
return Err(TrustyError::DataTooLarge {
size: data_bytes.len(),
max: MAX_DATA_BYTES,
});
}
let mut top: BTreeMap<&'static str, Value> = BTreeMap::new();
top.insert("chain_id", Value::String(fields.chain_id.clone()));
top.insert("data", data_canonical);
top.insert("detected_at", Value::String(fields.detected_at.clone()));
top.insert("entity_id", Value::String(fields.entity_id.clone()));
top.insert(
"extraction_method",
Value::String(fields.extraction_method.clone()),
);
top.insert("hash_alg", Value::String(fields.hash_alg.clone()));
top.insert(
"payload_version",
Value::Number(fields.payload_version.into()),
);
top.insert("prev_hash", Value::String(fields.prev_hash.clone()));
top.insert("received_at", Value::String(fields.received_at.clone()));
top.insert("sequence", Value::Number(fields.sequence.into()));
// Canonicalize nested signature objects so v2 byte-shape is
// independent of how the caller built the JSON values. v1 always
// passes [], so this is a no-op for v1.
top.insert(
"signatures",
canonicalize(&Value::Array(fields.signatures.clone())),
);
top.insert("source_url", Value::String(fields.source_url.clone()));
let mut sorted = Map::new();
for (k, v) in top.into_iter() {
sorted.insert(k.to_string(), v);
}
serde_json::to_string(&Value::Object(sorted))
.map_err(|e| TrustyError::PayloadSerialization(e.to_string()))
}
fn canonicalize(value: &Value) -> Value {
match value {
Value::Object(map) => {
let mut sorted: BTreeMap<String, Value> = BTreeMap::new();
for (k, v) in map.iter() {
sorted.insert(k.clone(), canonicalize(v));
}
let mut out = Map::new();
for (k, v) in sorted.into_iter() {
out.insert(k, v);
}
Value::Object(out)
}
Value::Array(arr) => Value::Array(arr.iter().map(canonicalize).collect()),
other => other.clone(),
}
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
fn sample() -> PayloadFields {
PayloadFields::new_v1(
"acme",
"chain_acme_000001",
json!({"name": "widget", "count": 3}),
"src://x",
"2026-03-08T00:00:00Z",
"2026-03-08T00:00:00.123456Z",
"trusty",
"genesis",
0,
)
}
#[test]
fn keys_are_sorted_alphabetically() {
let out = build_payload(&sample()).unwrap();
let positions = [
"chain_id",
"data",
"detected_at",
"entity_id",
"extraction_method",
"hash_alg",
"payload_version",
"prev_hash",
"received_at",
"sequence",
"signatures",
"source_url",
];
let mut last: usize = 0;
for key in positions {
let needle = format!("\"{}\":", key);
let idx = out
.find(&needle)
.unwrap_or_else(|| panic!("missing {}", key));
assert!(idx >= last, "field {} out of canonical order", key);
last = idx;
}
}
#[test]
fn no_whitespace_in_output() {
let out = build_payload(&sample()).unwrap();
assert!(!out.contains(' '));
assert!(!out.contains('\n'));
}
#[test]
fn nested_data_keys_are_sorted() {
let fields = PayloadFields {
data: json!({"z": 1, "a": 2, "m": 3}),
..sample()
};
let out = build_payload(&fields).unwrap();
// The data object is the only place keys "a","m","z" appear.
let a = out.find("\"a\":").unwrap();
let m = out.find("\"m\":").unwrap();
let z = out.find("\"z\":").unwrap();
assert!(a < m);
assert!(m < z);
}
#[test]
fn deterministic_across_input_order() {
let fields_a = PayloadFields {
data: json!({"a": 1, "b": 2}),
..sample()
};
let fields_b = PayloadFields {
data: json!({"b": 2, "a": 1}),
..sample()
};
assert_eq!(
build_payload(&fields_a).unwrap(),
build_payload(&fields_b).unwrap()
);
}
#[test]
fn v1_constant_fields_present() {
let out = build_payload(&sample()).unwrap();
assert!(out.contains("\"hash_alg\":\"siphash24+sha256\""));
assert!(out.contains("\"payload_version\":1"));
assert!(out.contains("\"received_at\":"));
assert!(out.contains("\"signatures\":[]"));
}
#[test]
fn data_size_cap_enforced() {
let huge = "x".repeat(MAX_DATA_BYTES + 1);
let fields = PayloadFields {
data: json!({ "blob": huge }),
..sample()
};
let err = build_payload(&fields).unwrap_err();
assert!(matches!(err, TrustyError::DataTooLarge { .. }));
}
#[test]
fn detected_and_received_are_distinct_fields() {
// Setting received_at far from detected_at must reach the
// canonical bytes verbatim; this guards against code that
// accidentally collapses the two values.
let mut f = sample();
f.detected_at = "2024-01-01T00:00:00Z".to_string();
f.received_at = "2026-12-31T23:59:59.999999Z".to_string();
let out = build_payload(&f).unwrap();
assert!(out.contains("\"detected_at\":\"2024-01-01T00:00:00Z\""));
assert!(out.contains("\"received_at\":\"2026-12-31T23:59:59.999999Z\""));
}
}

View File

@@ -0,0 +1,17 @@
[package]
name = "trusty-policy"
version.workspace = true
edition.workspace = true
authors.workspace = true
license.workspace = true
repository.workspace = true
rust-version.workspace = true
description = "Scoped FSM + policy engine. Constrains agent / tool-chain action surfaces; emits a structured audit event per evaluation. Substrate-independent (no trusty-core dep) -- consumers wire a PolicyAuditor sink (trusty for production, no-op for tests, file logger for dev)."
[dependencies]
serde = { workspace = true }
serde_json = { workspace = true }
thiserror = { workspace = true }
[features]
default = []

View File

@@ -0,0 +1,171 @@
{
"transitions": [
{
"from": "idle",
"action": "boot",
"to": "booted"
},
{
"from": "booted",
"action": "present_plan",
"to": "planned"
},
{
"from": "planned",
"action": "approved",
"to": "working"
},
{
"from": "working",
"action": "read_file",
"to": "working"
},
{
"from": "working",
"action": "edit_file",
"to": "working"
},
{
"from": "working",
"action": "write_new_file",
"to": "working"
},
{
"from": "working",
"action": "bash_safe",
"to": "working"
},
{
"from": "working",
"action": "delegate_agent",
"to": "working"
},
{
"from": "working",
"action": "hit_edit_limit",
"to": "cycle_pause"
},
{
"from": "cycle_pause",
"action": "approved",
"to": "working"
},
{
"from": "cycle_pause",
"action": "rollback_cycle",
"to": "working"
},
{
"from": "working",
"action": "run_tests",
"to": "testing"
},
{
"from": "testing",
"action": "tests_green",
"to": "working"
},
{
"from": "testing",
"action": "tests_red",
"to": "working"
},
{
"from": "working",
"action": "begin_close",
"to": "closing"
},
{
"from": "closing",
"action": "write_delta_log",
"to": "closing"
},
{
"from": "closing",
"action": "write_tag",
"to": "closing"
},
{
"from": "closing",
"action": "write_handoff",
"to": "closing"
},
{
"from": "closing",
"action": "update_session_gate",
"to": "closing"
},
{
"from": "closing",
"action": "run_close_check",
"to": "closing"
},
{
"from": "closing",
"action": "commit_branch",
"to": "closed"
},
{
"from": "working",
"action": "halt_destructive_op",
"to": "await_human"
},
{
"from": "working",
"action": "halt_main_branch_write",
"to": "await_human"
},
{
"from": "working",
"action": "halt_skip_hooks",
"to": "await_human"
},
{
"from": "working",
"action": "halt_unplanned_file",
"to": "await_human"
},
{
"from": "working",
"action": "halt_external_publish",
"to": "await_human"
},
{
"from": "working",
"action": "halt_three_errors",
"to": "await_human"
},
{
"from": "working",
"action": "halt_secret_in_diff",
"to": "await_human"
},
{
"from": "working",
"action": "halt_dependency_change",
"to": "await_human"
},
{
"from": "testing",
"action": "halt_tests_3_attempts",
"to": "await_human"
},
{
"from": "await_human",
"action": "human_resolves",
"to": "working"
},
{
"from": "await_human",
"action": "human_aborts",
"to": "idle"
},
{
"from": "closed",
"action": "next_session",
"to": "idle"
}
],
"scope": "claude.code.session.v1",
"initial": "idle"
}

View File

@@ -0,0 +1,26 @@
{
"transitions": [
{
"from": "idle",
"action": "claim",
"to": "claimed"
},
{
"from": "claimed",
"action": "execute",
"to": "running"
},
{
"from": "running",
"action": "finish",
"to": "done"
},
{
"from": "running",
"action": "abort",
"to": "aborted"
}
],
"scope": "agent.executor.v1",
"initial": "idle"
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,20 @@
[package]
name = "trusty-store"
version.workspace = true
edition.workspace = true
authors.workspace = true
license.workspace = true
repository.workspace = true
rust-version.workspace = true
description = "Storage trait for Trusty backends. Defines the contract any backend (sqlite, memory, postgres, etc.) must satisfy."
[features]
default = []
testing = []
[dependencies]
trusty-core = { workspace = true }
serde = { workspace = true }
serde_json = { workspace = true }
thiserror = { workspace = true }

View File

@@ -0,0 +1,821 @@
//! Backend-agnostic `Store` trait for Trusty.
//!
//! Defines the contract any storage implementation must satisfy. Core logic in
//! `trusty-core` is unchanged regardless of which backend is in use -- callers
//! can swap SQLite for an in-memory store (or a future Postgres backend) without
//! touching hashing, canonicalization, or verification.
//!
//! Implementations: [`trusty-memory`] (ref impl / tests), [`trusty-sqlite`] (production).
#![deny(clippy::all)]
use serde::{Deserialize, Serialize};
use thiserror::Error;
#[cfg(feature = "testing")]
pub mod testing;
// ------------------------------------------------------------
// Trait
// ------------------------------------------------------------
pub trait Store: Send {
fn last_row(&self, entity_id: &str) -> StoreResult<Option<LastRow>>;
fn insert_row(&mut self, row: &RowInsert) -> StoreResult<i64>;
fn iter_chain<'a>(
&'a self,
entity_id: &str,
) -> StoreResult<Box<dyn Iterator<Item = StoreResult<StoredRow>> + 'a>>;
fn get_tip(&self, chain_id: &str) -> StoreResult<Option<TipRow>>;
fn get_tip_for_entity(&self, entity_id: &str) -> StoreResult<Option<TipRow>>;
fn set_tip(&mut self, tip: &TipUpsert) -> StoreResult<()>;
fn kv_cross_check(&self) -> StoreResult<KvCrossCheckReport>;
// Transactional scope. Closure returns `StoreResult<()>`; on Err
// the backend rolls back. Return-value capture uses outer state.
// We use `&mut dyn FnMut` instead of a generic closure because
// trait-object methods cannot be generic.
fn with_txn(&mut self, f: &mut dyn FnMut(&mut dyn Store) -> StoreResult<()>)
-> StoreResult<()>;
}
// ------------------------------------------------------------
// Types
// ------------------------------------------------------------
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct LastRow {
pub row_hash: String,
pub sequence: i64,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct TipRow {
pub entity_id: String,
pub chain_id: String,
pub current_tip_hash: Option<String>,
pub updated_at: String,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct StoredRow {
pub id: i64,
pub entity_id: String,
pub chain_id: String,
pub event_type: String,
pub sequence: i64,
pub prev_hash: String,
pub row_hash: Option<String>,
pub anchor_hash: Option<String>,
pub raw_payload: Option<String>,
pub detected_at: String,
pub received_at: String,
pub source_url: String,
pub extraction_method: String,
}
#[derive(Debug, Clone)]
pub struct RowInsert {
pub entity_id: String,
pub chain_id: String,
pub event_type: String,
pub sequence: i64,
pub prev_hash: String,
pub row_hash: Option<String>,
pub anchor_hash: Option<String>,
pub raw_payload: Option<String>,
pub detected_at: String,
pub received_at: String,
pub source_url: String,
pub extraction_method: String,
}
#[derive(Debug, Clone)]
pub struct TipUpsert {
pub entity_id: String,
pub chain_id: String,
pub current_tip_hash: Option<String>,
}
#[derive(Debug, Clone, Serialize)]
pub struct KvCrossCheckReport {
pub ok: bool,
pub kv1_total_hashes: usize,
pub kv2_total_entities: usize,
pub entities_checked: usize,
pub mismatches: Vec<String>,
}
// ------------------------------------------------------------
// Higher-level result types
//
// Promoted from `trusty-sqlite` (S16, resolves §12 OQ #16 /
// `s14_insight_general_library_improvements_from_postgres_impl`
// items 1-2). Backend-agnostic shape; every adapter constructs and
// returns these. trusty-sqlite and trusty-postgres re-export them
// for convenience.
// ------------------------------------------------------------
/// Result of a single `append` (or `append_signed`). Encodes the
/// chain-break-as-event primitive: when a break is detected,
/// `chain_break = true`, `break_reason` is set, and `sealed_chain`
/// names the chain the break sealed.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct AppendResult {
pub entity_id: String,
pub chain_id: String,
pub event_type: String,
pub chain_break: bool,
pub break_reason: Option<String>,
pub sealed_chain: Option<String>,
pub row_hash: String,
pub anchor_hash: String,
pub sequence: i64,
pub is_genesis: bool,
}
/// Operator-facing view of one BREAK envelope row. Surface for
/// `breaks_since` -- treats chain-break-as-event as a query result,
/// not an exception.
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct BreakEvent {
pub entity_id: String,
pub chain_id: String,
pub sealed_at_hash: String,
pub break_reason: String,
pub detected_at: String,
}
/// An entity that has not appended a non-BREAK row within
/// `max_gap_secs`. Returned by `liveness_check`.
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub struct SilentEntity {
pub entity_id: String,
pub last_seen: String,
pub gap_secs: f64,
}
/// One row of `history(entity_id, ...)` output.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct HistoryRow {
pub id: i64,
pub chain_id: String,
pub entity_id: String,
pub event_type: String,
pub sequence: i64,
pub detected_at: String,
pub source_url: String,
pub row_hash: Option<String>,
pub anchor_hash: Option<String>,
pub raw_payload: Option<String>,
}
/// `integrity()` cross-check report. Aggregates KV cross-check
/// outcomes and any envelope/index drifts into one record.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct IntegrityReport {
pub ok: bool,
pub entities_checked: usize,
pub kv1_total_hashes: usize,
pub kv2_total_entities: usize,
pub mismatches: Vec<IntegrityMismatch>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct IntegrityMismatch {
pub entity_id: String,
pub reason: String,
pub detail: Option<String>,
}
/// Borrowed input row for `Trusty::append_batch`. Cheap to construct
/// from caller-owned data; one allocation per call, not per row.
/// For a signed batch, use per-row `append_signed` in a loop until a
/// signed-batch surface lands. Promoted from trusty-sqlite in S16
/// alongside the other result types.
#[derive(Debug, Clone, Copy)]
pub struct RowSpec<'a> {
pub entity_id: &'a str,
pub data: &'a serde_json::Value,
pub source_url: &'a str,
}
/// Lightweight tip handle returned by `Trusty::anchor`. Captures the
/// most recent anchor commitment for an entity (chain_id + tip_hash)
/// plus a server-side `anchored_at` timestamp at query time. For the
/// full per-row anchor stream use `anchors_since`.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct AnchorInfo {
pub entity_id: String,
pub chain_id: String,
pub tip_hash: Option<String>,
pub anchored_at: String,
}
/// One row in the `producer_identity` table. OID-anchored producer
/// identity per `s14_insight_oid_anchored_chain_identity`. The OID
/// is the trust spine: a chain's `attested_by` references this row,
/// and the registered `ed25519_pubkey` is the only key trusty trusts
/// for verifying that attestation. `siphash_key` is the producer's
/// per-row SipHash key (see hash.rs::SecretKey); `metadata` is
/// optional caller-supplied JSON.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ProducerIdentity {
pub producer_oid: String,
/// Key material, encoded per `key_format`.
pub ed25519_pubkey: Vec<u8>,
pub siphash_key: Vec<u8>,
pub registered_at: String,
pub display_name: Option<String>,
pub metadata: Option<serde_json::Value>,
/// How `ed25519_pubkey` is encoded, and therefore how a signature
/// from it must be checked. Defaults to raw Ed25519 for a row
/// written before the column existed -- a schema default at write
/// time, never a parse fallback.
#[serde(default)]
pub key_format: trusty_core::KeyFormat,
}
/// One row in the `producer_revocation` table. Forward-only
/// revocation per memo §6 / learn-LCG-ARCH-06: rows signed BEFORE
/// `revoked_at` remain verifiable; rows signed AT or AFTER are
/// rejected by `verify_since_checkpoint`. `revocation_row_hash`
/// references the REVOCATION envelope written to the per-user
/// `revocations` chain at revoke time -- the act of revoking is
/// itself a chained event.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct RevocationRecord {
pub producer_oid: String,
pub revoked_at: String,
pub revoked_pubkey: Vec<u8>,
pub reason: String,
pub attested_by_pubkey: Vec<u8>,
pub revocation_row_hash: String,
}
/// One row in the `chain_checkpoint` table. A checkpoint is a
/// verification-layer construct (memo §5, Gitea #13): a signed
/// `(chain_id, sequence_int, row_hash)` tuple that lets verifiers
/// prune their verify window to "from the most recent valid
/// checkpoint forward". `attested_by` names the attestor (typically
/// an OID from `producer_identity`); `attestation_sig` is Ed25519
/// over `trusty_core::checkpoint_signing_bytes(...)`. `checkpoint_at`
/// is the server-side timestamp at write time.
///
/// Chain payload bytes are NEVER touched by this primitive -- v1
/// byte-shape lock preserved.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct CheckpointRow {
pub id: i64,
pub chain_id: String,
pub sequence_int: i64,
pub row_hash: String,
pub checkpoint_at: String,
pub attested_by: String,
pub attestation_sig: Vec<u8>,
}
// ------------------------------------------------------------
// Error surface
// ------------------------------------------------------------
#[derive(Debug, Error)]
pub enum StoreError {
#[error("not found: {0}")]
NotFound(String),
#[error("io error: {0}")]
Io(String),
#[error("integrity violation: {0}")]
IntegrityViolation(String),
#[error("serialization error: {0}")]
Serialization(String),
#[error("backend error: {0}")]
Backend(String),
}
pub type StoreResult<T> = Result<T, StoreError>;
// ------------------------------------------------------------
// TrustyExt -- Tier 1 Phase B (S20 cycle 1)
//
// Default-method extension trait for any `T: Store`. Promotes
// higher-level read paths (history, counts, latest hash) into a
// substrate-shared implementation so trusty-sqlite, trusty-postgres,
// trusty-memory, and any future backend gain them automatically by
// implementing only the Store trait.
//
// Cycle-1 scope is read-only and additive: existing backend Trusty
// types keep their direct implementations untouched. A subsequent
// cycle migrates them to delegate to TrustyExt and removes the
// duplication. Methods that depend on break-specific fields not yet
// in StoredRow (breaks_since with sealed_at_hash + break_reason,
// integrity with KV cross-check) follow in S21+ once the StoredRow
// shape is extended OR break-specific Store methods are added.
// ------------------------------------------------------------
/// Default-method extension trait for any `T: Store`. Implemented
/// blanket-style; no consumer action required beyond using the
/// methods.
pub trait TrustyExt: Store {
/// History view of an entity's chain: oldest-first, BREAK rows
/// excluded, rows without a `row_hash` excluded. `limit` capped
/// at `i64::MAX` when negative.
///
/// Mirrors the existing per-backend `history(entity_id, limit)`
/// surface, sourced from `Store::iter_chain` so the result shape
/// is uniform across backends.
fn history(&self, entity_id: &str, limit: i64) -> StoreResult<Vec<HistoryRow>> {
let cap = if limit < 0 {
i64::MAX as usize
} else {
limit as usize
};
let mut out: Vec<HistoryRow> = Vec::new();
for row_res in self.iter_chain(entity_id)? {
let row = row_res?;
if row.event_type == "BREAK" || row.row_hash.is_none() {
continue;
}
out.push(stored_to_history(row));
if out.len() >= cap {
break;
}
}
Ok(out)
}
/// Count of rows for an entity (BREAK rows included). Cheaper
/// than collecting `history` when the caller only wants the
/// length.
fn count_rows(&self, entity_id: &str) -> StoreResult<usize> {
let mut n: usize = 0;
for row_res in self.iter_chain(entity_id)? {
let _ = row_res?;
n += 1;
}
Ok(n)
}
/// Latest non-BREAK row hash for an entity. Wraps
/// [`Store::last_row`] for the common single-field-of-interest
/// case.
fn latest_row_hash(&self, entity_id: &str) -> StoreResult<Option<String>> {
Ok(self.last_row(entity_id)?.map(|r| r.row_hash))
}
/// Latest non-BREAK sequence number for an entity. Useful for
/// "how far has this chain advanced" reporting without loading
/// full history.
fn latest_sequence(&self, entity_id: &str) -> StoreResult<Option<i64>> {
Ok(self.last_row(entity_id)?.map(|r| r.sequence))
}
/// Count of BREAK rows for an entity. Health-check primitive:
/// nonzero indicates the chain has been sealed at least once.
fn count_breaks(&self, entity_id: &str) -> StoreResult<usize> {
let mut n: usize = 0;
for row_res in self.iter_chain(entity_id)? {
let row = row_res?;
if row.event_type == "BREAK" {
n += 1;
}
}
Ok(n)
}
/// Whether the entity has at least one row of any kind. Cheaper
/// than counting; short-circuits on first hit.
fn entity_seen(&self, entity_id: &str) -> StoreResult<bool> {
// `.next()` rather than a `for` that returns on its first pass: the loop
// form read as an iteration and was not one, which `#![deny(clippy::all)]`
// at the top of this file rejects as `never_loop`. Behaviour is
// unchanged -- first row wins, and its error still propagates.
match self.iter_chain(entity_id)?.next() {
Some(row_res) => {
row_res?;
Ok(true)
}
None => Ok(false),
}
}
/// Current chain id for an entity, if a tip exists. Wraps
/// [`Store::get_tip_for_entity`] for the common id-only case.
fn chain_id_for(&self, entity_id: &str) -> StoreResult<Option<String>> {
Ok(self.get_tip_for_entity(entity_id)?.map(|t| t.chain_id))
}
/// Current tip hash for an entity, if a tip exists and has been
/// hashed. Wraps [`Store::get_tip_for_entity`].
fn tip_hash(&self, entity_id: &str) -> StoreResult<Option<String>> {
Ok(self
.get_tip_for_entity(entity_id)?
.and_then(|t| t.current_tip_hash))
}
/// All BREAK rows for an entity, oldest-first, as raw
/// `StoredRow`s. Consumers parse `raw_payload` for break-specific
/// fields (sealed-at hash, reason, etc.) since those live in the
/// JSON envelope rather than in dedicated columns of every
/// backend.
///
/// Backend-portable replacement for the per-backend
/// `breaks_since(...)` surface; richer typed views (with
/// `BreakEvent`) stay backend-side until break-specific column
/// access lands on the `Store` trait.
fn iter_breaks(&self, entity_id: &str) -> StoreResult<Vec<StoredRow>> {
let mut out: Vec<StoredRow> = Vec::new();
for row_res in self.iter_chain(entity_id)? {
let row = row_res?;
if row.event_type == "BREAK" {
out.push(row);
}
}
Ok(out)
}
/// Most recent BREAK row for an entity, if any. Cheaper than
/// loading the full break list when only the latest seal is of
/// interest (e.g. health checks).
fn latest_break(&self, entity_id: &str) -> StoreResult<Option<StoredRow>> {
Ok(self.iter_breaks(entity_id)?.pop())
}
/// Count of rows for an entity matching a given `event_type`.
/// Useful for "how many APPEND vs BREAK vs CHECKPOINT" reporting
/// without parsing JSON. `event_type` is case-sensitive,
/// matching the canonical envelope vocabulary.
fn count_events(&self, entity_id: &str, event_type: &str) -> StoreResult<usize> {
let mut n: usize = 0;
for row_res in self.iter_chain(entity_id)? {
let row = row_res?;
if row.event_type == event_type {
n += 1;
}
}
Ok(n)
}
/// All rows for an entity matching a given `event_type`,
/// oldest-first. Generic filter -- callers asking for "EVENT" get
/// the non-BREAK chain; "BREAK" mirrors [`Self::iter_breaks`];
/// future event types (CHECKPOINT, REVOCATION, etc.) work the
/// same way.
fn iter_by_event_type(&self, entity_id: &str, event_type: &str) -> StoreResult<Vec<StoredRow>> {
let mut out: Vec<StoredRow> = Vec::new();
for row_res in self.iter_chain(entity_id)? {
let row = row_res?;
if row.event_type == event_type {
out.push(row);
}
}
Ok(out)
}
}
impl<T: Store + ?Sized> TrustyExt for T {}
fn stored_to_history(row: StoredRow) -> HistoryRow {
HistoryRow {
id: row.id,
chain_id: row.chain_id,
entity_id: row.entity_id,
event_type: row.event_type,
sequence: row.sequence,
detected_at: row.detected_at,
source_url: row.source_url,
row_hash: row.row_hash,
anchor_hash: row.anchor_hash,
raw_payload: row.raw_payload,
}
}
#[cfg(test)]
mod trusty_ext_tests {
use super::*;
use std::collections::{BTreeMap, HashMap};
/// Minimal in-test Store impl. Mirrors trusty-memory but lives
/// inside trusty-store to avoid a dev-dep cycle (trusty-memory
/// depends on trusty-store).
#[derive(Clone, Default)]
struct TestStore {
rows: BTreeMap<i64, StoredRow>,
next_id: i64,
tips_by_chain: HashMap<String, TipRow>,
tips_by_entity: HashMap<String, TipRow>,
}
impl Store for TestStore {
fn last_row(&self, entity_id: &str) -> StoreResult<Option<LastRow>> {
Ok(self
.rows
.values()
.rev()
.find(|r| {
r.entity_id == entity_id && r.event_type != "BREAK" && r.row_hash.is_some()
})
.map(|r| LastRow {
row_hash: r.row_hash.clone().unwrap(),
sequence: r.sequence,
}))
}
fn insert_row(&mut self, row: &RowInsert) -> StoreResult<i64> {
self.next_id += 1;
let id = self.next_id;
self.rows.insert(
id,
StoredRow {
id,
entity_id: row.entity_id.clone(),
chain_id: row.chain_id.clone(),
event_type: row.event_type.clone(),
sequence: row.sequence,
prev_hash: row.prev_hash.clone(),
row_hash: row.row_hash.clone(),
anchor_hash: row.anchor_hash.clone(),
raw_payload: row.raw_payload.clone(),
detected_at: row.detected_at.clone(),
received_at: row.received_at.clone(),
source_url: row.source_url.clone(),
extraction_method: row.extraction_method.clone(),
},
);
Ok(id)
}
fn iter_chain<'a>(
&'a self,
entity_id: &str,
) -> StoreResult<Box<dyn Iterator<Item = StoreResult<StoredRow>> + 'a>> {
let entity = entity_id.to_string();
Ok(Box::new(
self.rows
.values()
.filter(move |r| r.entity_id == entity)
.cloned()
.map(Ok),
))
}
fn get_tip(&self, chain_id: &str) -> StoreResult<Option<TipRow>> {
Ok(self.tips_by_chain.get(chain_id).cloned())
}
fn get_tip_for_entity(&self, entity_id: &str) -> StoreResult<Option<TipRow>> {
Ok(self.tips_by_entity.get(entity_id).cloned())
}
fn set_tip(&mut self, tip: &TipUpsert) -> StoreResult<()> {
let row = TipRow {
entity_id: tip.entity_id.clone(),
chain_id: tip.chain_id.clone(),
current_tip_hash: tip.current_tip_hash.clone(),
updated_at: String::new(),
};
self.tips_by_chain.insert(tip.chain_id.clone(), row.clone());
self.tips_by_entity.insert(tip.entity_id.clone(), row);
Ok(())
}
fn kv_cross_check(&self) -> StoreResult<KvCrossCheckReport> {
Ok(KvCrossCheckReport {
ok: true,
kv1_total_hashes: self.rows.values().filter(|r| r.row_hash.is_some()).count(),
kv2_total_entities: self.tips_by_entity.len(),
entities_checked: self.tips_by_entity.len(),
mismatches: vec![],
})
}
fn with_txn(
&mut self,
f: &mut dyn FnMut(&mut dyn Store) -> StoreResult<()>,
) -> StoreResult<()> {
let snap = self.clone();
match f(self) {
Ok(()) => Ok(()),
Err(e) => {
*self = snap;
Err(e)
}
}
}
}
fn insert(store: &mut TestStore, entity: &str, seq: i64, event: &str, hash: Option<&str>) {
let row = RowInsert {
entity_id: entity.into(),
chain_id: format!("chain_{entity}_001"),
event_type: event.into(),
sequence: seq,
prev_hash: "genesis".into(),
row_hash: hash.map(str::to_string),
anchor_hash: hash.map(str::to_string),
raw_payload: Some(format!("{{\"seq\":{}}}", seq)),
detected_at: format!("2026-05-09T00:00:{:02}Z", seq),
received_at: format!("2026-05-09T00:00:{:02}.000Z", seq),
source_url: "test://".into(),
extraction_method: "trusty".into(),
};
store.insert_row(&row).unwrap();
store
.set_tip(&TipUpsert {
entity_id: entity.into(),
chain_id: row.chain_id.clone(),
current_tip_hash: hash.map(str::to_string),
})
.unwrap();
}
#[test]
fn history_excludes_break_rows_and_unhashed_rows() {
let mut store = TestStore::default();
insert(&mut store, "ent_a", 1, "EVENT", Some("h1"));
insert(&mut store, "ent_a", 2, "BREAK", Some("h2"));
insert(&mut store, "ent_a", 3, "EVENT", Some("h3"));
insert(&mut store, "ent_a", 4, "EVENT", None); // unhashed -- excluded
let hist = store.history("ent_a", 100).unwrap();
assert_eq!(hist.len(), 2, "BREAK + unhashed excluded: {:?}", hist);
assert_eq!(hist[0].sequence, 1);
assert_eq!(hist[1].sequence, 3);
assert!(hist.iter().all(|h| h.event_type != "BREAK"));
assert!(hist.iter().all(|h| h.row_hash.is_some()));
}
#[test]
fn history_respects_positive_limit() {
let mut store = TestStore::default();
for i in 1..=5 {
insert(&mut store, "ent_b", i, "EVENT", Some(&format!("h{i}")));
}
let hist = store.history("ent_b", 3).unwrap();
assert_eq!(hist.len(), 3);
assert_eq!(hist[0].sequence, 1);
assert_eq!(hist[2].sequence, 3);
}
#[test]
fn history_treats_negative_limit_as_unbounded() {
let mut store = TestStore::default();
for i in 1..=5 {
insert(&mut store, "ent_c", i, "EVENT", Some(&format!("h{i}")));
}
let hist = store.history("ent_c", -1).unwrap();
assert_eq!(hist.len(), 5);
}
#[test]
fn count_rows_includes_break_rows() {
let mut store = TestStore::default();
insert(&mut store, "ent_d", 1, "EVENT", Some("h1"));
insert(&mut store, "ent_d", 2, "BREAK", Some("h2"));
insert(&mut store, "ent_d", 3, "EVENT", Some("h3"));
let n = store.count_rows("ent_d").unwrap();
assert_eq!(n, 3);
}
#[test]
fn latest_row_hash_returns_last_non_break_hash() {
let mut store = TestStore::default();
insert(&mut store, "ent_e", 1, "EVENT", Some("h1"));
insert(&mut store, "ent_e", 2, "EVENT", Some("h2"));
insert(&mut store, "ent_e", 3, "BREAK", Some("hbreak"));
let h = store.latest_row_hash("ent_e").unwrap();
// last_row excludes BREAK + unhashed per Store::last_row contract,
// so we expect h2 (the latest non-BREAK).
assert_eq!(h.as_deref(), Some("h2"));
}
#[test]
fn empty_entity_returns_empty_results() {
let store = TestStore::default();
assert_eq!(store.history("nope", 10).unwrap().len(), 0);
assert_eq!(store.count_rows("nope").unwrap(), 0);
assert_eq!(store.latest_row_hash("nope").unwrap(), None);
}
#[test]
fn latest_sequence_returns_last_non_break_seq() {
let mut store = TestStore::default();
insert(&mut store, "ent_g", 1, "EVENT", Some("h1"));
insert(&mut store, "ent_g", 2, "EVENT", Some("h2"));
insert(&mut store, "ent_g", 3, "BREAK", Some("hb"));
assert_eq!(store.latest_sequence("ent_g").unwrap(), Some(2));
assert_eq!(store.latest_sequence("nope").unwrap(), None);
}
#[test]
fn count_breaks_counts_only_break_rows() {
let mut store = TestStore::default();
insert(&mut store, "ent_h", 1, "EVENT", Some("h1"));
insert(&mut store, "ent_h", 2, "BREAK", Some("hb"));
insert(&mut store, "ent_h", 3, "EVENT", Some("h3"));
insert(&mut store, "ent_h", 4, "BREAK", Some("hb2"));
assert_eq!(store.count_breaks("ent_h").unwrap(), 2);
assert_eq!(store.count_breaks("nope").unwrap(), 0);
}
#[test]
fn entity_seen_short_circuits() {
let mut store = TestStore::default();
insert(&mut store, "ent_i", 1, "EVENT", Some("h1"));
assert!(store.entity_seen("ent_i").unwrap());
assert!(!store.entity_seen("nope").unwrap());
}
#[test]
fn chain_id_for_and_tip_hash_track_current_tip() {
let mut store = TestStore::default();
insert(&mut store, "ent_j", 1, "EVENT", Some("h1"));
insert(&mut store, "ent_j", 2, "EVENT", Some("h2"));
assert_eq!(
store.chain_id_for("ent_j").unwrap().as_deref(),
Some("chain_ent_j_001")
);
assert_eq!(store.tip_hash("ent_j").unwrap().as_deref(), Some("h2"));
assert_eq!(store.chain_id_for("nope").unwrap(), None);
assert_eq!(store.tip_hash("nope").unwrap(), None);
}
#[test]
fn iter_breaks_returns_only_break_rows_oldest_first() {
let mut store = TestStore::default();
insert(&mut store, "ent_k", 1, "EVENT", Some("h1"));
insert(&mut store, "ent_k", 2, "BREAK", Some("hb1"));
insert(&mut store, "ent_k", 3, "EVENT", Some("h3"));
insert(&mut store, "ent_k", 4, "BREAK", Some("hb2"));
let breaks = store.iter_breaks("ent_k").unwrap();
assert_eq!(breaks.len(), 2);
assert_eq!(breaks[0].sequence, 2);
assert_eq!(breaks[1].sequence, 4);
assert!(breaks.iter().all(|r| r.event_type == "BREAK"));
}
#[test]
fn latest_break_returns_most_recent() {
let mut store = TestStore::default();
insert(&mut store, "ent_l", 1, "BREAK", Some("hb1"));
insert(&mut store, "ent_l", 2, "EVENT", Some("h2"));
insert(&mut store, "ent_l", 3, "BREAK", Some("hb2"));
let latest = store.latest_break("ent_l").unwrap().unwrap();
assert_eq!(latest.sequence, 3);
assert_eq!(latest.row_hash.as_deref(), Some("hb2"));
assert!(store.latest_break("nope").unwrap().is_none());
}
#[test]
fn count_events_matches_event_type_exactly() {
let mut store = TestStore::default();
insert(&mut store, "ent_m", 1, "EVENT", Some("h1"));
insert(&mut store, "ent_m", 2, "BREAK", Some("hb"));
insert(&mut store, "ent_m", 3, "CHECKPOINT", Some("hc"));
insert(&mut store, "ent_m", 4, "EVENT", Some("h2"));
assert_eq!(store.count_events("ent_m", "EVENT").unwrap(), 2);
assert_eq!(store.count_events("ent_m", "BREAK").unwrap(), 1);
assert_eq!(store.count_events("ent_m", "CHECKPOINT").unwrap(), 1);
assert_eq!(store.count_events("ent_m", "UNKNOWN").unwrap(), 0);
}
#[test]
fn iter_by_event_type_returns_filtered_rows_in_order() {
let mut store = TestStore::default();
insert(&mut store, "ent_n", 1, "EVENT", Some("h1"));
insert(&mut store, "ent_n", 2, "CHECKPOINT", Some("hc1"));
insert(&mut store, "ent_n", 3, "EVENT", Some("h2"));
insert(&mut store, "ent_n", 4, "CHECKPOINT", Some("hc2"));
let cps = store.iter_by_event_type("ent_n", "CHECKPOINT").unwrap();
assert_eq!(cps.len(), 2);
assert_eq!(cps[0].sequence, 2);
assert_eq!(cps[1].sequence, 4);
let evs = store.iter_by_event_type("ent_n", "EVENT").unwrap();
assert_eq!(evs.len(), 2);
assert!(evs.iter().all(|r| r.event_type == "EVENT"));
}
#[test]
fn trusty_ext_is_object_safe_via_dyn_store() {
// The whole point of TrustyExt is that it's blanket-impl'd
// for `T: Store + ?Sized`, including `dyn Store`. Verify by
// calling through a trait object.
let mut store = TestStore::default();
insert(&mut store, "ent_f", 1, "EVENT", Some("h1"));
let dyn_store: &dyn Store = &store;
assert_eq!(dyn_store.count_rows("ent_f").unwrap(), 1);
assert_eq!(
dyn_store.latest_row_hash("ent_f").unwrap().as_deref(),
Some("h1")
);
}
}

View File

@@ -0,0 +1,128 @@
// Shared conformance harness for Store implementations.
//
// Enabled via the `testing` feature. Each backend crate depends on
// trusty-store with features = ["testing"] in its dev-dependencies
// and calls `run_conformance` from its own integration test.
//
// Adding a new Store backend? Pass its fresh, empty instance here;
// if this function returns without panicking, the backend satisfies
// the basic contract Trusty needs.
use crate::{RowInsert, Store, StoreError, StoreResult, TipUpsert};
pub fn run_conformance<S: Store>(store: &mut S) {
insert_and_read_back(store);
iter_chain_ordered(store);
tip_roundtrip(store);
kv_cross_check_clean(store);
with_txn_commit(store);
with_txn_rollback(store);
}
fn mk_row(entity: &str, chain: &str, seq: i64, row_hash: &str) -> RowInsert {
RowInsert {
entity_id: entity.to_string(),
chain_id: chain.to_string(),
event_type: if seq == 0 {
"GENESIS".into()
} else {
"MUTATION".into()
},
sequence: seq,
prev_hash: if seq == 0 {
"genesis".to_string()
} else {
format!("prev_{}", seq - 1)
},
row_hash: Some(row_hash.to_string()),
anchor_hash: Some("a".repeat(64)),
raw_payload: Some(format!("{{\"v\":{}}}", seq)),
detected_at: "2026-04-15T00:00:00Z".to_string(),
received_at: "2026-04-15T00:00:00.001000Z".to_string(),
source_url: "s://test".to_string(),
extraction_method: "trusty".to_string(),
}
}
fn insert_and_read_back<S: Store>(store: &mut S) {
let row = mk_row("ent_conf_a", "chain_conf_a", 0, "000000000000000a");
store.insert_row(&row).expect("insert ok");
let last = store
.last_row("ent_conf_a")
.expect("last ok")
.expect("some");
assert_eq!(last.row_hash, "000000000000000a");
assert_eq!(last.sequence, 0);
}
fn iter_chain_ordered<S: Store>(store: &mut S) {
for i in 0..3i64 {
let h = format!("{:016x}", 0xB00_u64 + i as u64);
let row = mk_row("ent_conf_b", "chain_conf_b", i, &h);
store.insert_row(&row).expect("insert ok");
}
let rows: Vec<_> = store
.iter_chain("ent_conf_b")
.expect("iter ok")
.collect::<Result<Vec<_>, _>>()
.expect("collect ok");
assert_eq!(rows.len(), 3, "expected 3 rows, got {:?}", rows.len());
for (i, row) in rows.iter().enumerate() {
assert_eq!(row.sequence, i as i64, "out of order at index {}", i);
}
}
fn tip_roundtrip<S: Store>(store: &mut S) {
let row = mk_row("ent_conf_c", "chain_conf_c", 0, "cafebabecafebabe");
store.insert_row(&row).expect("insert for tip roundtrip");
let tip = TipUpsert {
entity_id: "ent_conf_c".to_string(),
chain_id: "chain_conf_c".to_string(),
current_tip_hash: Some("cafebabecafebabe".to_string()),
};
store.set_tip(&tip).expect("set tip");
let got = store
.get_tip("chain_conf_c")
.expect("get_tip ok")
.expect("some");
assert_eq!(got.current_tip_hash.as_deref(), Some("cafebabecafebabe"));
let got_ent = store
.get_tip_for_entity("ent_conf_c")
.expect("get_tip_for_entity ok")
.expect("some");
assert_eq!(got_ent.chain_id, "chain_conf_c");
}
fn kv_cross_check_clean<S: Store>(store: &mut S) {
let report = store.kv_cross_check().expect("cross check ok");
assert!(report.ok, "expected clean store ok: {:?}", report);
}
fn with_txn_commit<S: Store>(store: &mut S) {
store
.with_txn(&mut |s: &mut dyn Store| {
let row = mk_row("ent_conf_d", "chain_conf_d", 0, "d000000000000001");
s.insert_row(&row)?;
Ok(())
})
.expect("txn commit ok");
let last = store
.last_row("ent_conf_d")
.expect("last ok")
.expect("committed row visible");
assert_eq!(last.row_hash, "d000000000000001");
}
fn with_txn_rollback<S: Store>(store: &mut S) {
let result = store.with_txn(&mut |s: &mut dyn Store| {
let row = mk_row("ent_conf_e", "chain_conf_e", 0, "e000000000000001");
s.insert_row(&row)?;
Err(StoreError::Backend("rollback test".into()))
});
assert!(result.is_err(), "closure returning Err must propagate");
let last: StoreResult<Option<_>> = store.last_row("ent_conf_e");
assert!(
last.expect("last ok").is_none(),
"rolled-back row must not be visible after txn rollback"
);
}

View File

@@ -0,0 +1,827 @@
# This file is automatically @generated by Cargo.
# It is not intended for manual editing.
version = 3
[[package]]
name = "ahash"
version = "0.8.12"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5a15f179cd60c4584b8a8c596927aadc462e27f2ca70c04e0071964a73ba7a75"
dependencies = [
"cfg-if",
"once_cell",
"version_check",
"zerocopy",
]
[[package]]
name = "android_system_properties"
version = "0.1.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "819e7219dbd41043ac279b19830f2efc897156490d7fd6ea916720117ee66311"
dependencies = [
"libc",
]
[[package]]
name = "autocfg"
version = "1.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8"
[[package]]
name = "base64ct"
version = "1.8.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2af50177e190e07a26ab74f8b1efbfe2ef87da2116221318cb1c2e82baf7de06"
[[package]]
name = "bitflags"
version = "2.11.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c4512299f36f043ab09a583e57bceb5a5aab7a73db1805848e8fef3c9e8c78b3"
[[package]]
name = "block-buffer"
version = "0.10.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71"
dependencies = [
"generic-array",
]
[[package]]
name = "bumpalo"
version = "3.20.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5d20789868f4b01b2f2caec9f5c4e0213b41e3e5702a50157d699ae31ced2fcb"
[[package]]
name = "cc"
version = "1.2.60"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "43c5703da9466b66a946814e1adf53ea2c90f10063b86290cc9eb67ce3478a20"
dependencies = [
"find-msvc-tools",
"shlex",
]
[[package]]
name = "cfg-if"
version = "1.0.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801"
[[package]]
name = "chrono"
version = "0.4.44"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c673075a2e0e5f4a1dde27ce9dee1ea4558c7ffe648f576438a20ca1d2acc4b0"
dependencies = [
"iana-time-zone",
"num-traits",
"serde",
"windows-link",
]
[[package]]
name = "const-oid"
version = "0.9.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c2459377285ad874054d797f3ccebf984978aa39129f6eafde5cdc8315b612f8"
[[package]]
name = "core-foundation-sys"
version = "0.8.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b"
[[package]]
name = "cpufeatures"
version = "0.2.17"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280"
dependencies = [
"libc",
]
[[package]]
name = "crypto-common"
version = "0.1.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a"
dependencies = [
"generic-array",
"typenum",
]
[[package]]
name = "curve25519-dalek"
version = "4.1.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "97fb8b7c4503de7d6ae7b42ab72a5a59857b4c937ec27a3d4539dba95b5ab2be"
dependencies = [
"cfg-if",
"cpufeatures",
"curve25519-dalek-derive",
"digest",
"fiat-crypto",
"rustc_version",
"subtle",
"zeroize",
]
[[package]]
name = "curve25519-dalek-derive"
version = "0.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f46882e17999c6cc590af592290432be3bce0428cb0d5f8b6715e4dc7b383eb3"
dependencies = [
"proc-macro2",
"quote",
"syn",
]
[[package]]
name = "der"
version = "0.7.10"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e7c1832837b905bbfb5101e07cc24c8deddf52f93225eee6ead5f4d63d53ddcb"
dependencies = [
"const-oid",
"zeroize",
]
[[package]]
name = "digest"
version = "0.10.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292"
dependencies = [
"block-buffer",
"crypto-common",
]
[[package]]
name = "ed25519"
version = "2.2.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "115531babc129696a58c64a4fef0a8bf9e9698629fb97e9e40767d235cfbcd53"
dependencies = [
"pkcs8",
"signature",
]
[[package]]
name = "ed25519-dalek"
version = "2.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "70e796c081cee67dc755e1a36a0a172b897fab85fc3f6bc48307991f64e4eca9"
dependencies = [
"curve25519-dalek",
"ed25519",
"rand_core",
"serde",
"sha2",
"subtle",
"zeroize",
]
[[package]]
name = "equivalent"
version = "1.0.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f"
[[package]]
name = "fallible-iterator"
version = "0.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2acce4a10f12dc2fb14a218589d4f1f62ef011b2d0cc4b3cb1bba8e94da14649"
[[package]]
name = "fallible-streaming-iterator"
version = "0.1.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7360491ce676a36bf9bb3c56c1aa791658183a54d2744120f27285738d90465a"
[[package]]
name = "fiat-crypto"
version = "0.2.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "28dea519a9695b9977216879a3ebfddf92f1c08c05d984f8996aecd6ecdc811d"
[[package]]
name = "find-msvc-tools"
version = "0.1.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582"
[[package]]
name = "generic-array"
version = "0.14.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a"
dependencies = [
"typenum",
"version_check",
]
[[package]]
name = "getrandom"
version = "0.2.17"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0"
dependencies = [
"cfg-if",
"libc",
"wasi",
]
[[package]]
name = "hashbrown"
version = "0.14.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e5274423e17b7c9fc20b6e7e208532f9b19825d82dfd615708b70edd83df41f1"
dependencies = [
"ahash",
]
[[package]]
name = "hashbrown"
version = "0.17.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4f467dd6dccf739c208452f8014c75c18bb8301b050ad1cfb27153803edb0f51"
[[package]]
name = "hashlink"
version = "0.9.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6ba4ff7128dee98c7dc9794b6a411377e1404dba1c97deb8d1a55297bd25d8af"
dependencies = [
"hashbrown 0.14.5",
]
[[package]]
name = "hex"
version = "0.4.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70"
[[package]]
name = "iana-time-zone"
version = "0.1.65"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e31bc9ad994ba00e440a8aa5c9ef0ec67d5cb5e5cb0cc7f8b744a35b389cc470"
dependencies = [
"android_system_properties",
"core-foundation-sys",
"iana-time-zone-haiku",
"js-sys",
"log",
"wasm-bindgen",
"windows-core",
]
[[package]]
name = "iana-time-zone-haiku"
version = "0.1.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f31827a206f56af32e590ba56d5d2d085f558508192593743f16b2306495269f"
dependencies = [
"cc",
]
[[package]]
name = "indexmap"
version = "2.14.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d466e9454f08e4a911e14806c24e16fba1b4c121d1ea474396f396069cf949d9"
dependencies = [
"equivalent",
"hashbrown 0.17.0",
]
[[package]]
name = "itoa"
version = "1.0.18"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682"
[[package]]
name = "js-sys"
version = "0.3.95"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2964e92d1d9dc3364cae4d718d93f227e3abb088e747d92e0395bfdedf1c12ca"
dependencies = [
"once_cell",
"wasm-bindgen",
]
[[package]]
name = "libc"
version = "0.2.185"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "52ff2c0fe9bc6cb6b14a0592c2ff4fa9ceb83eea9db979b0487cd054946a2b8f"
[[package]]
name = "libsqlite3-sys"
version = "0.28.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0c10584274047cb335c23d3e61bcef8e323adae7c5c8c760540f73610177fc3f"
dependencies = [
"cc",
"pkg-config",
"prettyplease",
"quote",
"syn",
"vcpkg",
]
[[package]]
name = "log"
version = "0.4.29"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897"
[[package]]
name = "memchr"
version = "2.8.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f8ca58f447f06ed17d5fc4043ce1b10dd205e060fb3ce5b979b8ed8e59ff3f79"
[[package]]
name = "num-traits"
version = "0.2.19"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841"
dependencies = [
"autocfg",
]
[[package]]
name = "once_cell"
version = "1.21.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50"
[[package]]
name = "pkcs8"
version = "0.10.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f950b2377845cebe5cf8b5165cb3cc1a5e0fa5cfa3e1f7f55707d8fd82e0a7b7"
dependencies = [
"der",
"spki",
]
[[package]]
name = "pkg-config"
version = "0.3.33"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "19f132c84eca552bf34cab8ec81f1c1dcc229b811638f9d283dceabe58c5569e"
[[package]]
name = "ppv-lite86"
version = "0.2.21"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "85eae3c4ed2f50dcfe72643da4befc30deadb458a9b590d720cde2f2b1e97da9"
dependencies = [
"zerocopy",
]
[[package]]
name = "prettyplease"
version = "0.2.37"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "479ca8adacdd7ce8f1fb39ce9ecccbfe93a3f1344b3d0d97f20bc0196208f62b"
dependencies = [
"proc-macro2",
"syn",
]
[[package]]
name = "proc-macro2"
version = "1.0.106"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934"
dependencies = [
"unicode-ident",
]
[[package]]
name = "quote"
version = "1.0.45"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "41f2619966050689382d2b44f664f4bc593e129785a36d6ee376ddf37259b924"
dependencies = [
"proc-macro2",
]
[[package]]
name = "rand"
version = "0.8.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5ca0ecfa931c29007047d1bc58e623ab12e5590e8c7cc53200d5202b69266d8a"
dependencies = [
"libc",
"rand_chacha",
"rand_core",
]
[[package]]
name = "rand_chacha"
version = "0.3.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e6c10a63a0fa32252be49d21e7709d4d4baf8d231c2dbce1eaa8141b9b127d88"
dependencies = [
"ppv-lite86",
"rand_core",
]
[[package]]
name = "rand_core"
version = "0.6.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c"
dependencies = [
"getrandom",
]
[[package]]
name = "rusqlite"
version = "0.31.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b838eba278d213a8beaf485bd313fd580ca4505a00d5871caeb1457c55322cae"
dependencies = [
"bitflags",
"fallible-iterator",
"fallible-streaming-iterator",
"hashlink",
"libsqlite3-sys",
"smallvec",
]
[[package]]
name = "rustc_version"
version = "0.4.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cfcb3a22ef46e85b45de6ee7e79d063319ebb6594faafcf1c225ea92ab6e9b92"
dependencies = [
"semver",
]
[[package]]
name = "rustversion"
version = "1.0.22"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d"
[[package]]
name = "semver"
version = "1.0.28"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8a7852d02fc848982e0c167ef163aaff9cd91dc640ba85e263cb1ce46fae51cd"
[[package]]
name = "serde"
version = "1.0.228"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e"
dependencies = [
"serde_core",
"serde_derive",
]
[[package]]
name = "serde_core"
version = "1.0.228"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad"
dependencies = [
"serde_derive",
]
[[package]]
name = "serde_derive"
version = "1.0.228"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79"
dependencies = [
"proc-macro2",
"quote",
"syn",
]
[[package]]
name = "serde_json"
version = "1.0.149"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "83fc039473c5595ace860d8c4fafa220ff474b3fc6bfdb4293327f1a37e94d86"
dependencies = [
"indexmap",
"itoa",
"memchr",
"serde",
"serde_core",
"zmij",
]
[[package]]
name = "sha2"
version = "0.10.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283"
dependencies = [
"cfg-if",
"cpufeatures",
"digest",
]
[[package]]
name = "shlex"
version = "1.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64"
[[package]]
name = "signature"
version = "2.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "77549399552de45a898a580c1b41d445bf730df867cc44e6c0233bbc4b8329de"
dependencies = [
"rand_core",
]
[[package]]
name = "siphasher"
version = "1.0.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b2aa850e253778c88a04c3d7323b043aeda9d3e30d5971937c1855769763678e"
[[package]]
name = "smallvec"
version = "1.15.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03"
[[package]]
name = "spki"
version = "0.7.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d91ed6c858b01f942cd56b37a94b3e0a1798290327d1236e4d9cf4eaca44d29d"
dependencies = [
"base64ct",
"der",
]
[[package]]
name = "subtle"
version = "2.6.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292"
[[package]]
name = "syn"
version = "2.0.117"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e665b8803e7b1d2a727f4023456bbbbe74da67099c585258af0ad9c5013b9b99"
dependencies = [
"proc-macro2",
"quote",
"unicode-ident",
]
[[package]]
name = "thiserror"
version = "1.0.69"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b6aaf5339b578ea85b50e080feb250a3e8ae8cfcdff9a461c9ec2904bc923f52"
dependencies = [
"thiserror-impl",
]
[[package]]
name = "thiserror-impl"
version = "1.0.69"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1"
dependencies = [
"proc-macro2",
"quote",
"syn",
]
[[package]]
name = "trusty-core"
version = "0.1.0"
dependencies = [
"ed25519-dalek",
"hex",
"rand",
"serde",
"serde_json",
"sha2",
"siphasher",
"thiserror",
]
[[package]]
name = "trusty-sqlite"
version = "0.1.0"
dependencies = [
"chrono",
"hex",
"rusqlite",
"serde",
"serde_json",
"thiserror",
"trusty-core",
"trusty-store",
]
[[package]]
name = "trusty-sqlite-ext"
version = "0.1.0"
dependencies = [
"rusqlite",
"serde_json",
"trusty-core",
"trusty-sqlite",
]
[[package]]
name = "trusty-store"
version = "0.1.0"
dependencies = [
"serde",
"serde_json",
"thiserror",
"trusty-core",
]
[[package]]
name = "typenum"
version = "1.19.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "562d481066bde0658276a35467c4af00bdc6ee726305698a55b86e61d7ad82bb"
[[package]]
name = "unicode-ident"
version = "1.0.24"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75"
[[package]]
name = "vcpkg"
version = "0.2.15"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "accd4ea62f7bb7a82fe23066fb0957d48ef677f6eeb8215f372f52e48bb32426"
[[package]]
name = "version_check"
version = "0.9.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a"
[[package]]
name = "wasi"
version = "0.11.1+wasi-snapshot-preview1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b"
[[package]]
name = "wasm-bindgen"
version = "0.2.118"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0bf938a0bacb0469e83c1e148908bd7d5a6010354cf4fb73279b7447422e3a89"
dependencies = [
"cfg-if",
"once_cell",
"rustversion",
"wasm-bindgen-macro",
"wasm-bindgen-shared",
]
[[package]]
name = "wasm-bindgen-macro"
version = "0.2.118"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "eeff24f84126c0ec2db7a449f0c2ec963c6a49efe0698c4242929da037ca28ed"
dependencies = [
"quote",
"wasm-bindgen-macro-support",
]
[[package]]
name = "wasm-bindgen-macro-support"
version = "0.2.118"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9d08065faf983b2b80a79fd87d8254c409281cf7de75fc4b773019824196c904"
dependencies = [
"bumpalo",
"proc-macro2",
"quote",
"syn",
"wasm-bindgen-shared",
]
[[package]]
name = "wasm-bindgen-shared"
version = "0.2.118"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5fd04d9e306f1907bd13c6361b5c6bfc7b3b3c095ed3f8a9246390f8dbdee129"
dependencies = [
"unicode-ident",
]
[[package]]
name = "windows-core"
version = "0.62.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b8e83a14d34d0623b51dce9581199302a221863196a1dde71a7663a4c2be9deb"
dependencies = [
"windows-implement",
"windows-interface",
"windows-link",
"windows-result",
"windows-strings",
]
[[package]]
name = "windows-implement"
version = "0.60.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "053e2e040ab57b9dc951b72c264860db7eb3b0200ba345b4e4c3b14f67855ddf"
dependencies = [
"proc-macro2",
"quote",
"syn",
]
[[package]]
name = "windows-interface"
version = "0.59.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3f316c4a2570ba26bbec722032c4099d8c8bc095efccdc15688708623367e358"
dependencies = [
"proc-macro2",
"quote",
"syn",
]
[[package]]
name = "windows-link"
version = "0.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5"
[[package]]
name = "windows-result"
version = "0.4.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7781fa89eaf60850ac3d2da7af8e5242a5ea78d1a11c49bf2910bb5a73853eb5"
dependencies = [
"windows-link",
]
[[package]]
name = "windows-strings"
version = "0.5.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7837d08f69c77cf6b07689544538e017c1bfcf57e34b4c0ff58e6c2cd3b37091"
dependencies = [
"windows-link",
]
[[package]]
name = "zerocopy"
version = "0.8.48"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "eed437bf9d6692032087e337407a86f04cd8d6a16a37199ed57949d415bd68e9"
dependencies = [
"zerocopy-derive",
]
[[package]]
name = "zerocopy-derive"
version = "0.8.48"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "70e3cd084b1788766f53af483dd21f93881ff30d7320490ec3ef7526d203bad4"
dependencies = [
"proc-macro2",
"quote",
"syn",
]
[[package]]
name = "zeroize"
version = "1.8.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b97154e67e32c85465826e8bcc1c59429aaaf107c1e4a9e53c8d8ccd5eff88d0"
[[package]]
name = "zmij"
version = "1.0.21"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa"

View File

@@ -0,0 +1,22 @@
[package]
name = "trusty-sqlite-ext"
version = "0.1.0"
edition = "2021"
authors = ["MM Advisory LLC / Eric Gottlieb"]
license = "LicenseRef-Proprietary"
repository = "https://github.com/mmadvisory/trusty"
rust-version = "1.75"
description = "Trusty as a SQLite loadable extension. Drop-in tamper-evidence for any SQLite database."
publish = false
[workspace]
[lib]
name = "trusty_sqlite_ext"
crate-type = ["cdylib"]
[dependencies]
trusty-core = { path = "../../core/trusty-core" }
trusty-sqlite = { path = "../../adapters/trusty-sqlite" }
rusqlite = { version = "0.31", features = ["loadable_extension", "functions", "trace"] }
serde_json = { version = "=1.0.149", features = ["preserve_order"] }

View File

@@ -0,0 +1,222 @@
//! Trusty as a SQLite loadable extension (`cdylib`).
//!
//! Load into any SQLite database to get tamper-evidence without changing your
//! schema or language stack. Exposes SQL scalar functions backed by a shared,
//! lazily-initialized [`Trusty`](trusty_sqlite::Trusty) instance.
//!
//! # Loading
//! ```sql
//! .load ./trusty_sqlite_ext
//! SELECT trusty_init('/path/to/trusty.db', 'hex-encoded-secret-key');
//! ```
//!
//! # SQL functions
//! - `trusty_init(db_path, secret_key_hex)` -- initialize (call once)
//! - `trusty_append(entity_id, data_json, source_url)` -- append a record
//! - `trusty_verify(entity_id)` -- verify chain integrity, returns JSON report
//! - `trusty_anchor(entity_id)` -- write a SHA-256 anchor commitment
//! - `trusty_version()` -- returns crate version string
//!
//! # Safety invariants
//! Never panics across the FFI boundary. All fallible paths use rusqlite error
//! propagation. The only permitted unwraps are `Mutex::lock()` (poisoning is
//! terminal by design) and `serde_json::to_string` of always-serializable types.
// - Global Trusty state guarded by a Mutex; append is mutable.
// - Extension is non-persistent (re-registers per connection).
// ============================================================
use std::os::raw::{c_char, c_int};
use std::sync::Mutex;
use rusqlite::ffi;
use rusqlite::functions::FunctionFlags;
use rusqlite::{Connection, Error, Result};
use trusty_core::SecretKey;
use trusty_sqlite::Trusty;
// ------------------------------------------------------------
// Global extension state.
// One Trusty per process. Mutex because SQLite function callbacks
// are invoked in arbitrary threading contexts and append mutates.
// ------------------------------------------------------------
static STATE: Mutex<Option<Trusty>> = Mutex::new(None);
// ------------------------------------------------------------
// Extension entry point.
//
// SQLite's auto-loader for a library named `trusty_sqlite_ext`
// looks for `sqlite3_trustysqliteext_init` (underscores stripped,
// lowercased). We hand-write the entry point rather than rely on
// a macro that varies across rusqlite versions.
// ------------------------------------------------------------
/// # Safety
/// Called by sqlite3 at load time with valid, non-null `db` and
/// `p_api` pointers. `_pz_err_msg` is unused — we return
/// SQLITE_ERROR on failure and let sqlite3 surface a generic msg.
#[no_mangle]
pub unsafe extern "C" fn sqlite3_trustysqliteext_init(
db: *mut ffi::sqlite3,
_pz_err_msg: *mut *mut c_char,
p_api: *mut ffi::sqlite3_api_routines,
) -> c_int {
extension_entry(db, p_api)
}
/// Alias entry point so the extension also loads under the short
/// name `trusty_ext` (e.g. if a user renames the artifact). SQLite
/// would derive `sqlite3_trustyext_init` from `trusty_ext`.
///
/// # Safety
/// Same contract as [`sqlite3_trustysqliteext_init`].
#[no_mangle]
pub unsafe extern "C" fn sqlite3_trustyext_init(
db: *mut ffi::sqlite3,
_pz_err_msg: *mut *mut c_char,
p_api: *mut ffi::sqlite3_api_routines,
) -> c_int {
extension_entry(db, p_api)
}
/// Shared entry-point body. rusqlite 0.31's `extension_init2`
/// installs the SQLITE_EXTENSION_INIT2 api-routines table and
/// returns a non-owning `Connection`. We then register our
/// functions on it. The connection is `forget`-ed because SQLite
/// owns the underlying handle — dropping would double-close.
///
/// # Safety
/// `db` and `p_api` must be the valid pointers passed by SQLite at
/// extension-load time.
unsafe fn extension_entry(
db: *mut ffi::sqlite3,
p_api: *mut ffi::sqlite3_api_routines,
) -> c_int {
match Connection::extension_init2(db, p_api) {
Ok(conn) => match register_functions(&conn) {
Ok(()) => {
std::mem::forget(conn);
ffi::SQLITE_OK
}
Err(_) => ffi::SQLITE_ERROR,
},
Err(_) => ffi::SQLITE_ERROR,
}
}
// ------------------------------------------------------------
// Function registration.
// ------------------------------------------------------------
fn register_functions(db: &Connection) -> Result<()> {
// trusty_init(db_path TEXT, secret_key_hex TEXT) -> TEXT
db.create_scalar_function(
"trusty_init",
2,
FunctionFlags::SQLITE_UTF8,
|ctx| {
let path: String = ctx.get(0)?;
let key_hex: String = ctx.get(1)?;
let key_bytes = hex_decode_16(&key_hex).map_err(sqlite_err)?;
let key = SecretKey::new(key_bytes);
let trusty = Trusty::open(&path, key).map_err(sqlite_err)?;
*STATE.lock().unwrap() = Some(trusty);
Ok("ok".to_string())
},
)?;
// trusty_append(entity_id TEXT, data_json TEXT, source_url TEXT) -> TEXT
// NOT deterministic — each call mutates chain state.
db.create_scalar_function(
"trusty_append",
3,
FunctionFlags::SQLITE_UTF8,
|ctx| {
let entity_id: String = ctx.get(0)?;
let data_json: String = ctx.get(1)?;
let source_url: String = ctx.get(2)?;
let data: serde_json::Value =
serde_json::from_str(&data_json).map_err(sqlite_err)?;
let mut guard = STATE.lock().unwrap();
let trusty = guard.as_mut().ok_or_else(not_initialized)?;
let result = trusty
.append(&entity_id, &data, &source_url)
.map_err(sqlite_err)?;
// AppendResult is defined by trusty-sqlite and is always
// serializable; unwrap is documented-safe.
Ok(serde_json::to_string(&result).unwrap())
},
)?;
// trusty_verify(entity_id TEXT) -> TEXT
// Deterministic for a given chain state (read-only).
db.create_scalar_function(
"trusty_verify",
1,
FunctionFlags::SQLITE_UTF8,
|ctx| {
let entity_id: String = ctx.get(0)?;
let guard = STATE.lock().unwrap();
let trusty = guard.as_ref().ok_or_else(not_initialized)?;
let report = trusty.verify(&entity_id).map_err(sqlite_err)?;
Ok(serde_json::to_string(&report).unwrap())
},
)?;
// trusty_anchor(entity_id TEXT) -> TEXT
// Returns JSON of AnchorInfo, or the literal string "null" if
// no chain exists for entity_id.
db.create_scalar_function(
"trusty_anchor",
1,
FunctionFlags::SQLITE_UTF8,
|ctx| {
let entity_id: String = ctx.get(0)?;
let guard = STATE.lock().unwrap();
let trusty = guard.as_ref().ok_or_else(not_initialized)?;
let info = trusty.anchor(&entity_id).map_err(sqlite_err)?;
Ok(serde_json::to_string(&info).unwrap())
},
)?;
// trusty_version() -> TEXT
db.create_scalar_function(
"trusty_version",
0,
FunctionFlags::SQLITE_UTF8 | FunctionFlags::SQLITE_DETERMINISTIC,
|_ctx| Ok(env!("CARGO_PKG_VERSION").to_string()),
)?;
Ok(())
}
// ------------------------------------------------------------
// Helpers.
// ------------------------------------------------------------
/// Decode a 32-character hex string into a 16-byte key.
fn hex_decode_16(s: &str) -> std::result::Result<[u8; 16], String> {
if s.len() != 32 {
return Err(format!(
"secret key hex must be 32 chars, got {}",
s.len()
));
}
let mut out = [0u8; 16];
for i in 0..16 {
out[i] = u8::from_str_radix(&s[2 * i..2 * i + 2], 16)
.map_err(|e| format!("invalid hex at byte {}: {}", i, e))?;
}
Ok(out)
}
/// Wrap any Display error into a rusqlite user-function error.
fn sqlite_err<E: std::fmt::Display>(e: E) -> Error {
Error::UserFunctionError(Box::new(std::io::Error::new(
std::io::ErrorKind::Other,
e.to_string(),
)))
}
fn not_initialized() -> Error {
sqlite_err("trusty_init must be called before using trusty functions")
}

View File

@@ -0,0 +1,18 @@
// ============================================================
// Smoke test for trusty-sqlite-ext.
//
// A loadable SQLite extension is only meaningfully exercised by
// actually dlopen'ing the built .dll / .so from a host SQLite
// binary — that is out of scope for `cargo test`. Here we settle
// for a compile-time smoke test: if this file builds, the cdylib
// has linked and exports are resolvable by name.
// ============================================================
#[test]
fn extension_builds_as_cdylib() {
// Intentionally trivial. The real verification is:
// cargo build -p trusty-sqlite-ext
// producing target/debug/trusty_sqlite_ext.dll with
// sqlite3_trustysqliteext_init exported.
assert!(true);
}

View File

@@ -0,0 +1,196 @@
# Device-Signer Provisioning
Pattern for enrolling, using, rotating, and revoking Ed25519 signing
keys on devices (IoT sensors, edge nodes, agents) that append data to a
trusty chain.
---
## Problem
Many trusty deployments have more than one source of writes:
- A carbon-credit SaaS ingesting readings from hundreds of IoT sensors.
- An AI-agent audit log receiving tool-call records from many running
agents.
- A monitoring/logging API collecting events from thousands of customer
services.
Each source needs a persistent cryptographic identity. The chain needs
to record those signatures in a way external verifiers can check. The
operator needs a way to recognize an enrolled device, retire a
compromised one, or rotate a scheduled key.
Trusty keeps chain verification self-contained (signature + embedded
public key per row). Trust *policy* — which devices are still trusted
today — lives in the operator's application layer, not the chain.
---
## Core Idea
Trusty's v2 payload embeds each row's Ed25519 public key **inline** in
the signature object (see `docs/payload-v2.md` §4). This means:
- A proof bundle is **cryptographically self-verifying**: a consumer
with the payload bytes can check `verify(pk, payload, sig)` without
any registry lookup.
- Whether a particular pk *should have been trusted* at append time is a
separate question. That question is answered by an application-level
**signer registry**.
Two layers, cleanly separated:
```
+------------------------------------+
| Chain layer (trusty): |
| raw_payload, row_hash, |
| anchor_hash, signatures[pk,sig] |
| -> verifies math, not policy |
+------------------------------------+
| App layer (you): |
| signer_registry table |
| device_id, pk, enrolled_at, |
| revoked_at, metadata |
| -> verifies policy (was this |
| device trusted at time T?) |
+------------------------------------+
```
---
## Lifecycle
### 1. Enroll
On the device:
```rust
use trusty_core::{Ed25519Signer, Signer};
let signer = Ed25519Signer::generate(); // OS RNG
let secret = signer.secret_bytes(); // persist to
// device secure
// storage
let pk_hex = hex::encode(signer.public_key_bytes()); // send to
// operator
```
On the operator side (app), insert into a registry:
```sql
INSERT INTO signer_registry (device_id, public_key, enrolled_at)
VALUES (?, ?, ?);
```
The secret never leaves the device. Only the public key is transmitted.
### 2. Sign and append
```rust
let signer = Ed25519Signer::from_secret_bytes(&secret)?;
trusty.append_signed("device_42", &data, "sensor://42", &signer)?;
```
The resulting row carries the signature with the pk embedded inline.
### 3. Rotate
When a device's key reaches its scheduled lifetime (or for routine
hygiene), generate a new keypair on the device and register a new entry
(or update the existing one with a new pk + `rotated_at` timestamp).
**Old rows remain fully verifiable** — the old pk lives inline in those
rows. Only new rows use the new key.
### 4. Revoke
If a device is decommissioned, lost, or suspected of compromise:
```sql
UPDATE signer_registry SET revoked_at = ? WHERE device_id = ?;
```
Revocation is **not a chain event**. Revoked rows still carry valid
signatures (the math is unchanged). The app decides to distrust them:
- A proof-bundle consumer joins the bundle's rows against the registry
and flags any row where the pk's `revoked_at` precedes the row's
`detected_at`.
- An auditor treats revoked-key rows as quarantined, not missing.
---
## Compromise Scenarios
Per `docs/threat-model.md` §6 (per-key compromise), trusty's surface
already addresses the crypto side. The registry pattern closes the
policy side.
| Scenario | Chain behavior | Registry response |
|--------------------------------------- |---------------------------------|---------------------------------------|
| Device secret leaked; attacker signs | Row sig verifies mathematically | Revoke pk; downstream flags the row |
| Old key rotated out | Old-key rows still verify | Mark `rotated_at`; retain the entry |
| Device destroyed | Future writes just stop | Mark `revoked_at` with reason |
| Clone/replay across devices | Chain sequence gate detects it | Registry has no further role |
---
## Registry Schema (Reference, Not Mandatory)
Trusty does **not** prescribe a registry schema. Apps vary: a carbon-
credit platform cares about `device_type` and `location`; a monitoring
SaaS cares about `customer_id` and `plan`; an agent log cares about
`agent_version` and `operator_account`.
A minimal schema:
```sql
CREATE TABLE signer_registry (
device_id TEXT PRIMARY KEY,
public_key TEXT NOT NULL, -- hex-encoded 32 bytes
enrolled_at TEXT NOT NULL, -- ISO-8601
rotated_at TEXT, -- NULL until rotated
revoked_at TEXT, -- NULL until revoked
note TEXT
);
CREATE INDEX idx_sr_pk ON signer_registry(public_key);
```
The pk index exists so a verifier can look up registry status given a
pk extracted from a chain row.
---
## Worked Example
See `crates/trusty-sqlite/examples/device_signer.rs` for a runnable
demo that walks enroll → sign → rotate → revoke end to end on the same
database file trusty uses, using a second `rusqlite::Connection` for
the registry table.
Run:
```
cargo run -p trusty-sqlite --example device_signer
```
---
## What Trusty Does NOT Do (and Why)
- **No built-in registry.** Schema needs vary per product; a prescribed
schema would either over-serve (unused fields) or under-serve (missing
fields). Apps roll their own table.
- **No revocation as chain event.** Chain rows are immutable by design.
Introducing a "this pk is revoked" row would conflate cryptographic
truth (the sig is valid) with operational policy (we no longer trust
it). Those belong in different places.
- **No rotation ceremony.** Each v2 row carries its own pk inline;
switching to a new signer at append time is a one-line change on the
device. No chain-side mechanism needed.
The `Signer` trait accepts any backing implementation (HSM, TPM, cloud
KMS, file-backed). `Ed25519Signer` is the default software impl;
production deployments commonly substitute a hardware-backed signer.

315
docs/payload-v2.md Normal file
View File

@@ -0,0 +1,315 @@
# Trusty payload format -- v1 + v2
Canonical byte-level specification of what goes into `raw_payload`, what
`row_hash` commits to, and how v2 signatures are produced and verified.
This document is consensus-critical. v1 byte-shape is locked by the
invariant tests in `trusty-core` (see tag `s5_invariant_v1_byte_shape_preserved`
and the `v1_*` tests in `payload.rs` / `chain.rs`). v2 is purely additive
and must preserve v1 bytes exactly when `payload_version=1`. The original
Python port-source is archival only and no longer gates changes here.
Source of truth in-tree:
- `crates/trusty-core/src/payload.rs` -- canonical serializer, constants.
- `crates/trusty-core/src/chain.rs` -- `compute_record`, `compute_record_signed`,
`verify_links`, `verify_row_signatures`.
- `crates/trusty-core/src/hash.rs` -- `row_hash`, `anchor_hash`.
- `crates/trusty-core/src/crypto.rs` -- `Signer`, `Verifier`, Ed25519 impls.
---
## 1. Canonical form
Every `raw_payload` is a JSON object. Its canonical byte form is:
1. **Keys sorted alphabetically**, recursively (every nested object gets the
same treatment). Implemented via `BTreeMap` at the top level and a
recursive `canonicalize` on all nested `serde_json::Value::Object` nodes.
2. **No whitespace** -- `serde_json::to_string` (not `to_string_pretty`).
3. **UTF-8**. No BOM.
4. **Array order preserved**. Only object keys are sorted; arrays keep caller
order (this matters for `signatures`, which is an array -- but each entry
inside is itself an object and gets its keys sorted).
### 1.1 Field list (alphabetical -- the on-wire order)
| Field | Type | Notes |
|---------------------|------------|-----------------------------------------------------------------------|
| `chain_id` | string | `chain_<entity_id>_<6-digit-generation>` (e.g. `chain_acme_000001`). |
| `data` | any JSON | Caller-provided. Canonicalized recursively. Capped at 65,536 bytes after canonicalization. |
| `detected_at` | string | ISO-8601 UTC. Time trusty observed the event. |
| `entity_id` | string | Opaque caller identifier. |
| `extraction_method` | string | Caller tag for the provenance source (e.g. `trusty`, `scraper-v3`). |
| `hash_alg` | string | Always `"siphash24+sha256"` today. Verifiers read this before recomputing `row_hash`. |
| `payload_version` | number | `1` (v1) or `2` (v2). |
| `prev_hash` | string | Previous row's `row_hash` (16 hex chars), or the literal `"genesis"` on a segment's first row. |
| `received_at` | string | ISO-8601 UTC with sub-second precision. Distinct from `detected_at`; never collapse. |
| `sequence` | number | Per-chain monotonically increasing `i64`. Genesis = 0. |
| `signatures` | array | v1: always `[]`. v2: one object per signer (see Section 3). |
| `source_url` | string | Where the data came from. |
### 1.2 `data` size cap
`MAX_DATA_BYTES = 65_536` enforced after canonicalization. Exceeding it
raises `TrustyError::DataTooLarge`. Put blobs in object storage and reference
them by `{uri, sha256}` inside `data` -- do not inline them.
---
## 2. Dual hash
Each row carries two hashes. Both are computed in `trusty-core/src/hash.rs`
and are byte-identical to the Python reference.
### 2.1 `row_hash` -- SipHash-2-4
- Keyed with the 16-byte `SecretKey` (split into `k0` / `k1` as two
little-endian `u64`s, `bytes[0..8]` and `bytes[8..16]`).
- Input: the exact canonical payload bytes (`raw_payload` above).
- Output: 64-bit digest rendered as **lowercase hex**, **16 chars**, wire
representation derived from `format!("{:016x}", digest)`. This is a
big-endian-style hex rendering of the integer value. **BE hex is
canonical; any legacy LE rendering (e.g. earlier threadkeeper code) is
non-canonical and must be adapter-converted when crossing the boundary.**
See tag `s2_decision_be_encoding_canonical`.
### 2.2 `anchor_hash` -- SHA-256
Input byte layout:
```
row_hash_bytes || 0x00 || entity_id_bytes || 0x00 || chain_id_bytes
```
All three text fields are UTF-8. Output is lowercase hex, **64 chars**.
Null bytes are domain separators between fields -- do not drop them.
The dual-hash shape (keyed SipHash + keyless SHA-256 with null-byte domain
separation) is locked in `trusty-core/src/hash.rs` and covered by the
`anchor_hash_*` tests there. It is the only hash layout trusty recognises.
### 2.3 Why two hashes
SipHash-2-4 is a keyed fingerprint: cheap, fast, tamper-evident against
adversaries who do not hold the key. It is the chain-integrity primitive.
SHA-256 is the cryptographic anchor: public, collision-resistant, suitable
for external receipts and cross-system reference. The anchor is what you
publish or notarize; the row hash is what you use to link the chain.
Dual hash is non-negotiable -- do not collapse to one. See
`CLAUDE.md#design-rules`.
---
## 3. v2 signatures
v2 adds authenticated signatures over the payload. v1 is unchanged.
### 3.1 Shape of a signature entry
```json
{"alg":"ed25519","pk":"<hex>","sig":"<hex>"}
```
- Keys alphabetical (enforced by recursive canonicalization).
- `alg`: today always `"ed25519"`. Future algorithms add new literal values,
not OIDs.
- `pk`: hex-encoded public key bytes (32 bytes for Ed25519 -> 64 hex chars).
- `sig`: hex-encoded signature bytes (64 bytes for Ed25519 -> 128 hex chars).
`signatures` is an array so multi-signer is a pure extension -- just append
additional entries. Verifier iterates and validates each independently.
### 3.2 The signing-view algorithm
The core problem: if `row_hash` commits to the payload, and the payload
contains the signature, the signature cannot sign the final bytes (it would
have to contain itself).
Solution -- sign a canonical view with `signatures` forced empty, then
splice the signature in, re-canonicalize, and hash the spliced bytes. Both
sides of the envelope commit to each other:
**Sign (emission)**:
1. Start from caller's `PayloadFields`.
2. Force `payload_version = 2`, `signatures = []`.
3. Build the **signing view**: canonical bytes with `signatures=[]`.
`payload::signing_view_bytes(&fields)`.
4. Signer produces `sig = sign(signing_view_bytes)`.
5. Splice `{alg, pk, sig}` (all hex-encoded) into `signatures`.
6. Re-canonicalize -- these are the **final stored bytes** (`raw_payload`).
7. `row_hash = SipHash-2-4(key, raw_payload)`.
8. `anchor_hash = SHA-256(row_hash || 0x00 || entity_id || 0x00 || chain_id)`.
The row_hash therefore commits to the signature; the signature commits to
everything *except* itself (via the signing view).
**Verify (consumption)**:
1. Parse `raw_payload` back into `PayloadFields` (`chain::parse_raw_payload`).
2. If `payload_version < 2` or `signatures` is empty -> skip signature
check. v1 rows and unsigned v2 rows pass the signature stage by design.
3. Rebuild the signing view by replacing `signatures` with `[]` and
re-canonicalizing.
4. For each signature entry:
- Dispatch on `alg`. Unknown -> `UnknownSignatureAlg { alg }`.
- Hex-decode `pk` and `sig`. Decode failure -> `SignatureMalformed`.
- `Ed25519Verifier::verify(&pk, signing_view_bytes, &sig)`. Failure ->
`SignatureInvalid`.
5. `row_hash` is checked **independently** against the full stored bytes.
Tampering with just the sig bytes fires `RowHashMismatch`; recomputing
row_hash to hide the sig-tamper still fires `SignatureInvalid`. Attackers
lose both ways.
### 3.3 Worked example (conceptual)
Starting fields (abbreviated):
```json
{
"chain_id": "chain_acme_000001",
"data": {"temp_c": 21},
"entity_id": "acme",
"payload_version": 2,
"prev_hash": "genesis",
"sequence": 0,
"signatures": [],
...
}
```
Signing view = canonical bytes of that object exactly as shown (with all
other canonical fields populated).
Signer produces `sig` over those bytes. The final stored bytes:
```json
{
"chain_id": "chain_acme_000001",
"data": {"temp_c": 21},
"entity_id": "acme",
"payload_version": 2,
"prev_hash": "genesis",
"sequence": 0,
"signatures": [{"alg":"ed25519","pk":"ab...cd","sig":"12...ef"}],
...
}
```
`row_hash = siphash24(key, <final bytes>)`. This is what lands in the DB.
### 3.4 Nested object canonicalization (why it matters for v2)
Signature entries are JSON objects. They MUST be canonicalized (keys sorted)
before serialization, otherwise `serde_json::preserve_order` leaks caller
insertion order into the canonical bytes and different emitters produce
different `row_hash` for the same logical signature. `build_payload`
explicitly canonicalizes the signatures array via the recursive
`canonicalize` helper. See tag
`s5_learn_preserve_order_requires_canonicalize_nested`.
---
## 4. VerifyError taxonomy
`verify_links` returns `VerifyReport { ok, row_count, errors }`. Errors
carry `{sequence, chain_id, kind}`; the kinds relevant to payload format:
| Kind | Trigger |
|------------------------|---------------------------------------------------------------------|
| `PrevHashMismatch` | `link.prev_hash` != previous row's `row_hash` (or `genesis` on first row). |
| `MissingRawPayload` | Non-BREAK row with no `raw_payload`. |
| `InvalidRawPayload` | `raw_payload` does not parse as JSON or is missing required fields. |
| `MissingRowHash` | Non-BREAK row with no `row_hash`. |
| `RowHashMismatch` | Recomputed SipHash != stored `row_hash`. |
| `AnchorHashMismatch` | Recomputed SHA-256 != stored `anchor_hash` (when stored). |
| `SignatureInvalid` | v2: Ed25519 verify failed against signing view. |
| `SignatureMalformed` | v2: signature entry not a JSON object, pk/sig hex decode failed. |
| `UnknownSignatureAlg` | v2: `alg` field not recognized (only `ed25519` today). |
BREAK markers carry no hashes and reset `prev` to `genesis` for the next
segment. They are not counted in `row_count`.
---
## 5. API surface
### 5.1 `trusty-core` (pure library)
```rust
use trusty_core::{
compute_record, compute_record_signed,
Signer, Ed25519Signer,
PayloadFields, SecretKey,
verify_links,
};
// v1 path -- unsigned.
let rec = compute_record(&fields, &key)?;
// rec.raw_payload has "payload_version":1, "signatures":[]
// v2 path -- signed with any Signer impl.
let signer = Ed25519Signer::generate();
let rec = compute_record_signed(&fields, &key, &signer)?;
// rec.raw_payload has "payload_version":2 and one signatures entry.
// Caller's fields.payload_version and fields.signatures are overridden.
// Verify (version-agnostic).
let report = verify_links(&entity_id, &links, &key);
assert!(report.ok);
```
The `Signer` trait is byte-oriented:
```rust
pub trait Signer {
fn sign(&self, msg: &[u8]) -> Vec<u8>;
fn public_key_bytes(&self) -> Vec<u8>;
}
```
HSM or remote-signer integrations implement this trait directly; no
ed25519-dalek dependency leaks into chain code.
### 5.2 `trusty-sqlite` (storage adapter)
```rust
let store = Trusty::open(path, key)?;
// v1 append
let receipt = store.append(entity, data, source_url)?;
// v2 append -- threads Option<&dyn Signer> through the append pipeline
let receipt = store.append_signed(entity, data, source_url, &signer)?;
// Custom extraction_method
store.append_with_method(entity, data, source_url, method)?;
store.append_with_method_signed(entity, data, source_url, method, &signer)?;
```
Mixed v1/v2 chains verify fine; dispatch is per-row on stored
`payload_version`. You can flip signing on/off per-row at will.
---
## 6. Locked invariants
- **v1 byte-shape is frozen.** Same field set, same canonical ordering, same
`hash_alg` string, `signatures:[]`, `payload_version:1`. Tag:
`s1_invariant_payload_v1_canonical`.
- **v1 is preserved under v2.** v2 was shipped additive-only in S5 --
calling `compute_record` on a v1 `PayloadFields` emits exactly the same
bytes it did before v2 existed. Tag: `s5_invariant_v1_byte_shape_preserved`.
- **row_hash BE-hex encoding is canonical.** Decision `s2_decision_be_encoding_canonical`.
- **Dual hash inputs are locked.** SipHash-2-4 keyed + SHA-256 unkeyed with
null-byte domain separation. Tag: `s1_invariant_payload_v1_canonical`.
- **Signature algorithm string is a literal.** No OID dance, no algorithm
negotiation. Adding an algorithm = adding a new literal value in verify
dispatch.
- **row_hash commits to the signature bytes** (v2). Anything that would make
the signature not affect `row_hash` is a regression.
New algorithms, new fields, new versions must be purely additive and must
leave v1 bytes byte-identical. Run `cargo test -p trusty-core` and inspect
the `v1_*` tests before and after any payload-layer change.

237
docs/product-use-cases.md Normal file
View File

@@ -0,0 +1,237 @@
# Trusty — Product Use Cases and Beneficiaries
A non-technical overview of what Trusty enables, the products it unlocks, and
who benefits. This is the "what it does for the customer" view, not the
"how it works under the hood" view.
---
## What Trusty Is, In One Paragraph
Trusty is a tamper-evident audit layer. Any data that flows through it — a
sensor reading, a state transition, a decision, an agent action — gets sealed
into a chain that cannot be silently edited after the fact. If anyone changes,
inserts, deletes, or reorders a single record, the chain visibly breaks. That
break is itself an event the system can alert on. It is deliberately small,
cheap to run, and embeddable anywhere data needs to be trustworthy.
The business model is commodity-priced, high-margin, volume-driven. Trusty is
the door-opener. Every application that sits on top of it — carbon-credit
SaaS, compliance monitoring, AI agent accountability, data marketplaces, and
Threadkeeper — rides the same audit spine.
---
## 1. Carbon Credits You Can Actually Insure
**The problem.** Carbon credits are only worth what someone will pay for them,
and insurers will not underwrite credits backed by sensor data that could have
been quietly edited after the fact.
**What Trusty enables.** Every environmental reading from every sensor gets a
tamper-proof timestamp and fingerprint the moment it is recorded. If anyone
changes a single number later, it shows immediately and publicly. Climate
models (via NVIDIA Omniverse / Earth-2) then ingest data that is provably
untampered.
**Who benefits.**
- Carbon-credit SaaS operators — can now sell credits that insurers will
underwrite.
- Insurers — can price carbon-credit policies with actual evidentiary
foundation.
- Buyers / offset purchasers — know the credit represents real, measured
reduction, not a spreadsheet.
- Regulators — can audit the full chain without trusting the operator.
---
## 2. IoT Sensor Networks That Prove Themselves
**The problem.** With thousands of sensors in the field — pipelines, factories,
farms, power grids, cold chains, emissions monitors — you cannot watch them
all. A malfunctioning, spoofed, or tampered sensor can go undetected for
months, and the financial, regulatory, and reputational cost is enormous.
**What Trusty enables.** Every sensor reading is chained to the one before
it. The absence of a valid reading is itself the alarm — you do not need a
separate intrusion detector. Trusty handles fleet-scale workloads
(approximately 19,000 readings per second per host), so thousands of devices
can write into the same audit trail without slowing anything down.
**Who benefits.**
- Industrial operators (oil and gas, manufacturing, utilities) — catch
tampering and device failure in real time.
- Insurance and reinsurance — underwrite sensor-driven policies with
confidence.
- Supply chain and cold-chain operators — prove goods were handled correctly
end to end.
- Compliance and safety teams — turn "we think the sensors worked" into "we
can prove it."
---
## 3. Active Compliance Monitoring and the Liability Flip
**The problem.** In regulated industries — pharma, aviation, finance, food
safety, medical devices — disputes come down to "what did your system
record?" Most enterprise logs are editable, which leaves the company
carrying the liability.
**What Trusty enables.** Pair Trusty with a rule set that defines which
transitions are *legal* ("a dose can only be administered after a
verification step"). Every legitimate transition is sealed into the chain.
Anything outside the rules cannot produce a valid entry. The chain becomes
legal-grade evidence of what actually happened.
**The liability flip.** "It is in the chain" or "it is not in the chain" is
a stronger claim in a dispute than any editable database log. The burden of
proof moves from the operator (who currently has to defend the integrity of
their logs) onto the claimant. This is the single strongest commercial
story Trusty has.
**Who benefits.**
- Pharma and medical device manufacturers — audit-ready by default for FDA,
EMA, and equivalent regulators.
- Aviation and aerospace — tamper-evident maintenance and flight-ops records.
- Financial services — provable trade, order, and settlement history.
- Legal teams — reduce discovery cost and win disputes on evidentiary
strength.
- Insurers of the above — lower claim cost when the audit trail is
unambiguous.
---
## 4. Audit-Ready by Default: The Proof Bundle
**The problem.** Audits, regulatory reviews, insurance claims, and legal
disputes all require handing evidence to someone who does not trust you.
Today that is a scramble of spreadsheets, screenshots, and lawyers, and the
recipient still has to take your word for most of it.
**What Trusty enables.** Export a single sealed bundle covering any time
window or entity. The recipient — auditor, insurer, regulator, opposing
counsel — verifies it independently on their own equipment, with no access
to your systems and no need to trust the operator. The bundle carries
everything required: the records, the cryptographic anchors, and the
verification keys.
**Who benefits.**
- Compliance and audit teams — turn compliance from a project into a button.
- Insurers and claims adjusters — verify losses without site visits or
vendor cooperation.
- Regulators — receive evidence in a standard, self-verifying format.
- Law firms — deliver and receive discovery material that is demonstrably
authentic.
- Any business with "prove it" as part of its sales cycle — verifiability
becomes the product.
---
## 5. Data Marketplace Provenance
**The problem.** In peer-to-peer and decentralized data marketplaces,
publishers can silently swap individual pieces of a dataset even when each
piece is individually signed — because signatures do not prove ordering or
history.
**What Trusty enables.** Every slice of the dataset is linked to its
neighbors so any substitution, deletion, or reordering is immediately
visible. You can prove not just "this piece is authentic" but "this is the
complete history, in order, unchanged."
**Who benefits.**
- Distant Star (Eric's decentralized data-network protocol) — gains a
provenance layer it currently lacks.
- Data publishers — can charge a premium for verifiable datasets.
- Data buyers (enterprise, scientific, government) — can finally adopt
decentralized data sources for regulated work.
- Validators and data curators — their attestations now anchor to a chain
state that proves what they reviewed has not changed.
---
## 6. Provable AI Agents and Agent Swarms
**The problem.** When AI agents make decisions — trading, legal, medical,
autonomous systems, customer-facing automation — regulators, customers, and
insurers increasingly need to prove not just *what the agent did* but *what
it was paying attention to* when it did it. Without that, AI cannot be
adopted at scale in regulated environments.
**What Trusty enables.** Every agent carries parallel audit chains — one
per dimension being monitored (attention, uncertainty, inter-agent
interaction, input context, output action). Any drift, hallucination, or
off-policy behavior shows up as a chain break on the relevant dimension.
You get an audit trail not just of agent outputs but of agent *state*.
**Who benefits.**
- AI platform operators — ship AI into regulated markets they currently
cannot enter.
- Enterprises adopting AI — get an accountability layer their compliance
teams will accept.
- Regulators — gain a standard way to inspect agent behavior after the fact.
- AI safety and alignment researchers — have a production-grade substrate
for behavioral monitoring.
---
## 7. Threadkeeper
**What Threadkeeper is.** Threadkeeper is Eric's desktop knowledge /
conversation-capture application. It records, organizes, and reasons over
long-running threads of work — transcripts, notes, decisions, research
artifacts — across sessions and projects.
**The problem Threadkeeper has.** A knowledge tool is only as trustworthy as
the history it presents. If a user, a sync bug, or an automated process can
silently rewrite past entries, the tool cannot be used for anything where
"what was said and when" matters — legal matters, investigative work,
research that will later be published, client-facing deliverables, or
regulated professional practice.
**What Trusty enables for Threadkeeper.**
- Every captured entry is sealed on write. The historical record is
demonstrably the record that was originally created.
- Any external party — a client, a court, a reviewer — can be handed a
Proof Bundle and verify the thread's integrity independently.
- Cross-thread references (entry A cites entry B at a specific point in its
history) are themselves tamper-evident, so citation integrity is
preserved.
- When AI assistance is layered into Threadkeeper (summarization,
reasoning, agentic workflows), the agent's activity rides the same chain
— so "what the assistant did, when, and why" is part of the same audit
trail as the human-authored content.
**Who benefits inside the Threadkeeper story.**
- Lawyers, consultants, and advisors — deliverables built on Threadkeeper
are audit-ready and defensible by default.
- Investigators and journalists — source material and working notes carry
provable provenance.
- Researchers — lab notebooks, experiment logs, and authorship trails are
tamper-evident.
- Solo professionals and small firms — get enterprise-grade evidentiary
rigor without enterprise-grade tooling cost.
- Anyone handing work product to a skeptical third party — the product
ships with its own proof of integrity.
**Why this matters commercially.** Threadkeeper plus Trusty is the
reference implementation of "knowledge work that proves itself." It is the
most visible, user-facing demonstration of the Trusty substrate, and it is
the story that translates the technology into something non-technical
buyers intuitively understand.
---
## The Common Thread
Trusty is deliberately narrow — a commodity audit layer, not a platform.
Low per-unit cost, high margin, easy to embed anywhere trustworthy data
matters. Once embedded, every application layer on top — carbon credits,
compliance monitoring, agent accountability, data marketplaces,
Threadkeeper — rides the same audit spine and inherits the same
evidentiary guarantees.
The consistent benefit across every use case: **the burden of trust shifts
away from the operator and onto the math.** Customers stop having to
convince counterparties their logs are honest, because the logs prove
themselves.

96
docs/scope-grammar.md Normal file
View File

@@ -0,0 +1,96 @@
# Scope grammar
The string format `trusty_policy.permits()` matches against. Two repos
depend on it, so it is written down here rather than inferred from the
implementation.
## The rule
```sql
CREATE OR REPLACE FUNCTION trusty_policy.covers(scope TEXT, resource TEXT)
RETURNS BOOLEAN
LANGUAGE sql IMMUTABLE PARALLEL SAFE
AS $fn$
SELECT scope IS NOT NULL
AND resource IS NOT NULL
AND (resource = scope OR starts_with(resource, scope || '/'))
$fn$
```
A scope covers a resource when they are **equal**, or when the resource
begins with the scope **followed by a `/`**.
That is the whole grammar. There are no wildcards, no globs, and no
pattern syntax — deliberately.
## Shape
```
<class>:<id>[/<segment>]*
```
| part | example | rule |
|---|---|---|
| class | `board` | opaque to trusty |
| id | `9f2` | opaque to trusty |
| segment | `calendar`, `private` | separated by `/` |
Only the `/` is load-bearing. `<class>:<id>` is a convention the
consumer chooses; trusty treats everything before the first `/` as one
opaque string.
## What follows from it
**`/` is the only separator.** `board:9f2/calendar` covers
`board:9f2/calendar/private`. It does **not** cover
`board:9f2/calendar-private`, because the character after the scope is
`-`, not `/`.
**A near-miss prefix does not match.** `board:o` does not cover
`board:other/calendar`. Without the separator in the concatenation it
would, which is why the separator is in there and not an afterthought.
**No wildcards.** A scope of `%` is a scope literally named `%`, and a
scope containing `_` matches only a literal underscore. `starts_with` is
used rather than `LIKE` precisely so an attacker-influenced scope cannot
widen itself into a pattern.
**Case and Unicode are exact.** `Board:9f2` is a different scope from
`board:9f2`. There is no normalisation, folding, or trimming anywhere in
the path. Emit exactly what you mean to match.
**No trailing slash.** `board:9f2/calendar/` is a *different* scope from
`board:9f2/calendar`, and covers `board:9f2/calendar//private` rather
than anything useful. Do not emit one.
**Exclusions use the same rule.** An exclusion of
`board:9f2/calendar/private` shuts that resource and everything beneath
it, checked before coverage and across every live grant of the subject
regardless of tier.
## What a producer must guarantee
Anything writing a `resource_scope` column, or issuing a grant, owes the
following. Trusty cannot check them, because the parts are opaque to it:
1. Segments are emitted in the same shape both sides expect. An
exclusion of `board:9f2/calendar/private` protects nothing if rows
carry `board/9f2/calendar/private`.
2. No segment contains a `/` of its own. A segment that does silently
becomes two levels, and a grant one level up now covers it.
3. Ids are not user-controlled free text. `board:<user-supplied>` lets
someone name a board `9f2/calendar` and inherit a scope they were
never granted.
Point 3 is the one that bites. If ids can contain `/`, the grammar has
no boundary and coverage becomes forgeable.
## What trusty deliberately does not do
- Parse, validate or normalise a scope
- Attach meaning to `class` or `id`
- Enforce a maximum depth
The engine takes two opaque strings and asks whether one contains the
other. Keeping it that narrow is what lets the consumer own the
namespace — and is why the guarantees above are the consumer's to keep.

296
docs/threat-model.md Normal file
View File

@@ -0,0 +1,296 @@
# Trusty threat model
What trusty detects, what it does not detect, what the trust boundaries
are, and how the system behaves under key compromise. Read alongside
`docs/payload-v2.md` for the on-wire format details this doc references.
This is a living document. If you add a primitive (new condition, new
signature algorithm, new break reason) that changes any property here, this
file gets updated in the same session.
---
## 1. Assets under protection
| Asset | What it represents |
|-----------------------|------------------------------------------------------------------|
| Chain integrity | The append-only link from genesis through every row's `prev_hash`. |
| Row authenticity (v2) | The claim that a specific signer endorsed a specific row payload. |
| Event history | That every mutation -- benign or adversarial -- was recorded. |
| External anchors | SHA-256 `anchor_hash` values suitable for publication / notary. |
Trusty is an **integrity and provenance** layer. It is not a confidentiality
layer, not an availability layer, and not an access-control layer.
---
## 2. Adversary model
We consider an attacker who has one or more of:
- **Read access to the SQLite file** (or the underlying bytes).
- **Write access to the SQLite file** (can modify rows, re-order, delete,
insert fabricated rows).
- **Control of the upstream source** feeding `data` into `append()`.
- **Control of `detected_at` / `received_at` values** when submitting.
- **Possession of one or both cryptographic keys** -- the 16-byte SipHash
`SecretKey` used for `row_hash`, and/or the Ed25519 signing key used for
v2 signatures.
We do not consider:
- Side-channel attacks on SipHash / Ed25519 implementations. The underlying
crates (`siphasher`, `ed25519-dalek`) are treated as correct.
- Compromise of the host OS kernel or memory bus -- below the trust
boundary.
- Quantum-era attacks on Ed25519. Out of scope; algorithm agility is
reserved for a future version bump.
---
## 3. The chain-break-as-event primitive
Trusty's central design choice differs from most provenance systems: when a
break condition fires, trusty **records the break as a row** (`event_type =
"BREAK"`, `break_reason` set) and **starts a new chain segment** from
genesis for the next append. It does not refuse the write and does not
silently repair.
Implications for the threat model:
- Tampering cannot erase its own trace. Even an attacker with write access
who corrupts a row triggers a BREAK at the next honest append, which is
itself an auditable event.
- Verification after the fact surfaces the break: `verify_links` returns
`ok = true` for a chain with BREAK markers (they are valid structural
separators), but the presence of BREAK rows is itself the signal.
Consumers of verify reports MUST inspect BREAK markers and their
`break_reason`, not treat a passing verify as clean history.
- An attacker who controls writes AND holds both keys can forge an entire
new segment that looks benign. BREAK markers detect tampering only when
a subsequent honest append occurs. See Section 6 for the defense.
---
## 4. Detection surfaces
### 4.1 Chain structural checks (`verify_links`)
| Surface | Detects | Miss condition |
|-----------------------|----------------------------------------------------------|----------------------------------------------------------------|
| `PrevHashMismatch` | Row reordering, deletion mid-chain, fabricated row with wrong link. | Attacker re-stitches prev_hash and also forges row_hash (requires SecretKey). |
| `RowHashMismatch` | In-place payload mutation without re-hashing. | Attacker recomputes row_hash (requires SecretKey). |
| `AnchorHashMismatch` | Tampering with stored anchor when row_hash/entity/chain is intact. | Attacker recomputes anchor (requires access to entity_id + chain_id only, no key). |
| `MissingRowHash` / `MissingRawPayload` | Rows with structural fields dropped. | None at this layer. |
### 4.2 Break conditions (at append time, `crates/trusty-core/src/conditions.rs`)
| Condition | Fires on | `BreakReason` |
|---------------------|--------------------------------------------------------------------------|--------------------------------|
| `HashIntegrity` | Stored tip hash != computed chain-tip hash (pre-append). | `TAMPER_DETECTED` |
| `SequenceContinuity`| `sequence` is not `last_sequence + 1`. | `SEQUENCE_GAP` |
| `SourceDomain` (opt-in) | `source_url` not in configured trusted-domains list, `hard = true`. | `SOURCE_CHANGE` |
First failing condition wins; remaining conditions do not run. Custom
conditions compose via the `Condition` trait.
### 4.3 v2 signature checks (`verify_row_signatures`)
Only applied when `payload_version >= 2` and `signatures` is non-empty.
| Surface | Detects |
|-------------------------|-------------------------------------------------------------------|
| `SignatureInvalid` | Signature does not verify against the reconstructed signing view. |
| `SignatureMalformed` | Signature entry not a JSON object; pk/sig hex decode failure. |
| `UnknownSignatureAlg` | `alg` field is not `ed25519` (only algorithm supported today). |
### 4.4 Cross-check KV store (`trusty-sqlite`)
`trusty-sqlite` mirrors `(anchor_hash -> row_id)` into a `hash_index` table
separate from the main `provenance_envelope` table. A modified anchor on
the primary row is caught when verification walks the cross-check.
Byte-layout matches the Python reference `cross_check_kv_store`.
---
## 5. Ed25519 trust boundary (v2)
The v2 signing-view algorithm establishes a specific trust relationship:
- **Signer claims**: "at the time of signing, this `{entity_id, chain_id,
sequence, prev_hash, data, detected_at, received_at, source_url,
extraction_method, hash_alg, payload_version}` tuple was true." The
signature covers the **signing view** (canonical bytes with
`signatures=[]`).
- **Signer does not claim** row_hash correctness. row_hash is produced by
the infrastructure holding the SipHash `SecretKey`, which may be a
different party.
- **Infrastructure claims** (via `SecretKey`): "I received these exact bytes
and they chain from the previous tip." Infrastructure cannot forge the
Ed25519 signature without the signing key.
This split is deliberate: it lets data-producing entities sign with
hardware-bound Ed25519 keys while the trusty storage layer runs with a
commodity SipHash key. Compromising one key is not enough to silently
rewrite history. See Section 6.
### 5.1 Row-hash-commits-to-signature property
`row_hash = SipHash(SecretKey, canonical_bytes_with_signature_spliced_in)`.
Therefore row_hash is a function of the signature bytes themselves.
Tampering strategies and their failure modes:
| Attacker action | What fires |
|-----------------------------------------------|---------------------------------------------|
| Change sig bytes, keep row_hash unchanged. | `RowHashMismatch`. |
| Change sig bytes, recompute row_hash. | `SignatureInvalid` (sig no longer verifies over signing view). |
| Change data, recompute sig (no Ed25519 key). | Cannot produce sig -> `SignatureInvalid`. |
| Change data + sig + row_hash (has both keys). | **Passes.** See Section 6. |
| Strip signatures array to empty. | `RowHashMismatch` (canonical bytes changed). Downstream also loses authenticity claim. |
| Downgrade `payload_version` to 1. | `RowHashMismatch`. v1 re-emit is a different byte sequence. |
---
## 6. Key compromise scenarios
Trusty has two key materials with different blast radii:
### 6.1 SipHash `SecretKey` compromise (one key, symmetric, per-installation)
Attacker can:
- Recompute `row_hash` for any fabricated payload.
- Walk the chain forward: craft a new row with correct `prev_hash` and
correct `row_hash`, pass `verify_links`.
- Generate valid `anchor_hash` values (anchor_hash requires no key -- it is
public SHA-256; attacker can always do this step).
Attacker cannot (in a v2 chain):
- Forge an Ed25519 signature. The `SignatureInvalid` error will fire on any
row they fabricate claiming a signer they do not control.
- **Therefore: v2 signed chains survive SipHash key compromise.** The break
surfaces as `SignatureInvalid`, not structural mismatch, but it is still
detected.
Mitigation: rotate SipHash key on suspected exposure. Old rows remain
verifiable with the old key; new rows use the new key. Document rotation
boundary in operations.
### 6.2 Ed25519 signing key compromise (per-signer, asymmetric)
Attacker can:
- Sign arbitrary data under the compromised public key. Any verifier who
trusts that `pk` will accept the forged signatures.
Attacker cannot:
- Compute `row_hash` without the SipHash key.
- **Therefore: signed chains survive Ed25519 key compromise.** The break
surfaces as `RowHashMismatch` when the storage-layer key is different
from the attacker's compromised key.
Mitigation: revoke compromised `pk`; verifiers maintain a revocation list
and reject signatures from revoked keys after the revocation timestamp.
Trusty does not ship a revocation mechanism today -- this is a downstream
consumer concern.
### 6.3 Dual compromise (both keys)
Attacker can forge a self-consistent chain end-to-end. This is the only
scenario where trusty's local primitives fail silently.
Defense-in-depth:
- **Publish `anchor_hash` externally.** Any row whose anchor was published
(blockchain, transparency log, notary, etc.) before the compromise
window cannot be retroactively rewritten. Post-publication tampering is
caught by comparing local anchor to the external record.
- **Distribute keys across trust boundaries.** SipHash key belongs to the
storage operator; Ed25519 key belongs to the data-producing entity. An
attacker who wants both must compromise both parties.
- **Cross-check with siblings.** Where a chain is mirrored (threadkeeper,
Distant Star), discrepancies between the local chain and the mirror are
evidence of tampering on one side, independent of local key material.
### 6.4 What dual compromise still cannot do
Even with both keys, an attacker cannot:
- Forge a row whose `anchor_hash` has already been published externally and
whose copy is archived outside the trusty DB. Anchor publication is the
ultimate integrity witness.
- Hide a historical BREAK marker if any consumer has previously read and
cached the chain (independent copies surface the diff).
---
## 7. Out-of-scope threats
Trusty does **not** protect against these and callers must layer additional
controls where they matter:
- **Backdating.** `detected_at` and `received_at` are caller-provided
strings. They are canonical bytes inside the payload and covered by
row_hash + signature, but they are unverifiable claims about when
something happened. Use a trusted time source (TSA, blockchain timestamp)
externally if the timing claim is load-bearing.
- **Data authenticity upstream of ingest.** `data` is whatever the caller
hands `append()`. Trusty certifies that the byte sequence was received
and chained; it does not certify that the byte sequence reflects reality.
IoT sensor integrity, API-to-DB pipelines, etc. are upstream concerns.
- **Availability / DoS.** Trusty makes no liveness guarantees. An attacker
with write access can flood the DB or corrupt the file; trusty detects
tampering on verify but does not prevent service degradation.
- **Confidentiality.** Payloads are stored in cleartext canonical JSON.
Encrypt the DB file (or the `data` field via caller-side envelope
encryption) if confidentiality is required. `row_hash` is computed over
the cleartext.
- **Access control.** Any process with file-system access can call
`append()`. Gate ingestion at a higher layer (API, auth middleware).
- **Replay.** A signed row can be inserted into a different chain if the
attacker also holds the SipHash key for that target chain. Chain-id and
entity-id are in the signing view, so cross-chain replay requires
fabricating anchor_hash with matching entity + chain strings -- detected
by `AnchorHashMismatch` only if the original row has a different anchor.
Defense is at the insertion gate.
---
## 8. Operational guidance for integrators
Short checklist for consumers (threadkeeper, Distant Star, direct users):
1. **Verify reports are not pass/fail.** Always inspect `errors` and walk
BREAK markers. A chain with five BREAK events and `ok = true` is an
integrity event, not a clean bill.
2. **Publish anchor_hash early and often.** Anchor publication is the
single strongest defense against dual-key compromise.
3. **Separate key custody.** SipHash key lives with the storage operator;
Ed25519 keys live with signing entities. Do not collocate them in the
same process or HSM slot unless you have a specific reason.
4. **Maintain signer-key registries.** Consumers decide which `pk` values
they trust. Trusty does not; it only verifies signatures against the
pk field in the payload.
5. **Treat `detected_at` / `received_at` as claims, not facts.** If timing
is adversarial, pair with an external TSA.
6. **Do not compare row_hash across installations.** The 16-byte SipHash
key differs per installation, so the same logical payload has different
row_hash values across deployments. Compare `anchor_hash` for
cross-installation equivalence, since that is SHA-256 and keyless.
---
## 9. Related locked surfaces
Changes to these surfaces require a threat-model review and must leave the
listed invariant tests green:
- Dual-hash primitive (SipHash-2-4 + SHA-256). Tag
`s1_invariant_payload_v1_canonical`.
- v1 canonical byte shape. Tag `s5_invariant_v1_byte_shape_preserved`.
- row_hash BE-hex encoding. Tag `s2_decision_be_encoding_canonical`.
- Signing-view algorithm (row_hash commits to signature). Tag
`s5_decision_v2_signatures_inline_row_hash_commits`.
- Ed25519 determinism (no randomized-sig variant). Tag
`s4_invariant_ed25519_determinism`.
- Three-table SQLite schema + `hash_index` cross-check.
Anything that weakens a detection surface is a regression.

File diff suppressed because it is too large Load Diff

92
docs/twin-coupling.md Normal file
View File

@@ -0,0 +1,92 @@
# Twin Coupling -- Data Convention
## Problem
A digital-twin computation ingests readings from one or more sensor
chains and produces a derived result. The result is appended to its own
trusty chain (separate `entity_id`). How does an external verifier
confirm which sensor rows fed the computation?
## Convention
The twin row carries a `source_refs` array inside its `data` field:
```json
{
"computation": "rolling_avg_temp_c",
"result": 21.37,
"source_refs": [
{"chain_id": "chain_sensor-001_...", "sequence": 0, "row_hash": "ae1cfe445cda1f6d"},
{"chain_id": "chain_sensor-001_...", "sequence": 1, "row_hash": "dee9856d170787cf"},
{"chain_id": "chain_sensor-001_...", "sequence": 2, "row_hash": "26cd125f051243cf"}
]
}
```
Each entry is:
| Field | Source | Purpose |
|------------|---------------------------------|--------------------------------------------|
| `chain_id` | `AppendResult.chain_id` | Identifies which sensor chain |
| `sequence` | `AppendResult.sequence` | Row position within that chain |
| `row_hash` | `AppendResult.row_hash` | Tamper-evident fingerprint of the row |
Because `data` is already part of the payload that `row_hash` commits
to, the `source_refs` array is itself hash-protected. Altering any
reference after the fact invalidates the twin row's `row_hash` and
fails `verify_chain`.
## Verification flow (two ProofBundles)
An external verifier needs no database access -- only two
`ProofBundle` blobs exported via `Trusty::export_bundle`:
1. `sensor_bundle.verify(&sip_key)` -- confirms the sensor chain is
internally consistent.
2. `twin_bundle.verify(&sip_key)` -- confirms the twin chain is
internally consistent.
3. Walk twin rows; for each `source_refs` entry, look up the matching
row in `sensor_bundle.rows` by `chain_id` + `sequence` and assert
`row_hash` matches.
If all three checks pass, the verifier knows:
- The sensor readings are unaltered.
- The twin computation row is unaltered.
- The twin row commits to exactly those sensor row_hashes at write time.
See `crates/trusty-sqlite/examples/twin_coupling.rs` for a runnable
end-to-end demo.
## Design decisions
**Why `data` field, not a new `break_reason` variant.**
`break_reason` marks loss-of-continuity (tamper, gap, domain mismatch).
A twin derivation is a planned event, not an anomaly. Mixing the two
pollutes `breaks_since()` as an alerting surface and requires callers
to filter by break kind.
**Why not a separate `twin_reference` table.**
A join table outside the hash surface cannot be verified from a
ProofBundle alone. The verifier would need database access, breaking
the self-contained export contract. `source_refs` in `data` is
verifiable anywhere the bundle travels.
**What this does NOT prove.**
The convention proves the twin row committed to those row_hashes at
write time. It does not prove the computation is correct (the twin
operator could have computed a wrong average and still referenced the
right sensor rows). Computation correctness is out of scope for trusty;
it belongs to a separate audit or reproducibility layer.
## Multi-sensor / multi-chain refs
`source_refs` can mix `chain_id` values from different sensor entities.
The verifier simply exports a ProofBundle per sensor entity and checks
each ref against the appropriate bundle.
```json
"source_refs": [
{"chain_id": "chain_sensor-001_...", "sequence": 5, "row_hash": "..."},
{"chain_id": "chain_sensor-002_...", "sequence": 3, "row_hash": "..."}
]
```

140
docs/vocabulary.md Normal file
View File

@@ -0,0 +1,140 @@
# Vocabulary crosswalk
Our words, the standard words, and which is which. Written so an
assessor can follow their own vocabulary into this system, and so we can
keep a term where ours is genuinely sharper without that being an
obstacle.
This is the ordinary compliance practice of a **mapping**: NIST
publishes the same thing between 800-171 and 800-53. The value is not in
picking a winner. Picking one would be wrong anyway — XCCDF is
configuration scanning, C2PA is media provenance, in-toto is software
supply chain, and none was built for a P2P data mesh.
**On sourcing.** Rows marked ✓ were checked against the specification
during this session. Rows marked ~ are from working knowledge and want
confirming before they go in front of an assessor. A term cited wrongly
is worse than a term not cited, because a citation reads as authority.
---
## 1. Where we already use the standard word
Nothing to do here. Listed so a reader can stop looking.
| ours | standard | source |
|---|---|---|
| `principal` | principal | Kerberos, AWS IAM, Java security, NIST RBAC ~ |
| `delegation` | delegation | NIST RBAC, OAuth 2.0 ~ |
| `scope` | scope | OAuth 2.0 ~ |
| `grant` | grant | OAuth 2.0, XACML ~ |
| `Tier::sql_verb()` → SELECT/INSERT/UPDATE/DELETE | the SQL verbs | ISO SQL ✓ |
| `canonical_json()` | JSON Canonicalization Scheme | RFC 8785 ✓ |
| Ed25519, SHA-256, SipHash-2-4 | same | RFC 8032, FIPS 180-4 ✓ |
---
## 2. Where we invented a word and a standard exists
This is the list that matters.
### 2.1 `Stance` — the verdict
**Adopt the standard.** XCCDF is NIST's own, it is what a SCAP-literate
assessor reads, and our three values map exactly onto three of its nine.
| ours | XCCDF `rule-result` ✓ |
|---|---|
| `Clean` | `pass` |
| `Violated` | `fail` |
| `Unverifiable` | `unknown` |
XCCDF's full set is `pass`, `fail`, `error`, `unknown`, `notapplicable`,
`notchecked`, `notselected`, `informational`, `fixed`.
**The one reservation.** `Unverifiable` says *why* nothing is known —
the evidence could not be checked — where `unknown` covers that and
several other cases. Ours is more precise; theirs is recognisable. For a
verdict an outsider reads, recognisable wins.
**Prior art worth knowing:** OCSP answers the same shape with `good` /
`revoked` / `unknown` ~. Three values, and the third is "we cannot say"
— the same design decision, reached independently, which is a good sign
the three-value shape is right.
### 2.2 `Annotation` — a claim about a datum
| ours | C2PA ✓ | in-toto ✓ |
|---|---|---|
| `Annotation` | **assertion** — a statement the signer makes about the asset | **predicate** |
| the signed row carrying it | **claim** — the signed container grouping assertions | **Statement** (`_type`, `subject`, `predicateType`, `predicate`) |
| `ProofBundle` | **manifest** — assertions + claim + signature as one verifiable unit | — |
| `content_tag` | the asset | **subject** |
**Keep ours, and say what it maps to.** C2PA's *assertion* is the closest
single word, and the C2PA shape is genuinely similar: individual
statements, grouped and signed together. But their *claim* and *manifest*
have media-provenance meanings that would mislead here, and adopting one
word of a three-word system imports the confusion without the benefit.
The mapping is the deliverable, not a rename.
### 2.3 The hash names
| ours | standard | note |
|---|---|---|
| `row_hash` | Merkle leaf hash ~ | ours is a keyed MAC, theirs usually is not |
| `anchor_hash` | Signed Tree Head, RFC 6962 ~ | needs checking before it goes in a filing |
**Do not rename.** These are frozen byte shapes; the field names feed
hashed payloads, and renaming a key that feeds a hash silently breaks
every artifact already signed under the old spelling. See the lesson
`freeze-tokens-for-hashed-keys`.
### 2.4 Smaller ones
| ours | standard | verdict |
|---|---|---|
| `producer registry` | PKI **issuer** ~; C2PA **claim generator** ✓ | keep — ours is a registry of keys, not an issuer of certificates |
| `Tier` | NIST RBAC **operation** ~ | keep — ours is a *damage* taxonomy that happens to map 1:1 onto the SQL verbs, which "operation" does not convey |
| `content_tag` | in-toto **subject** ✓; content-addressing **CID** ~ | keep, map to `subject` |
---
## 3. Genuinely ours
No standard equivalent found. These are the ideas worth defending as
novel rather than translating.
- **chain-break-as-event.** A monitored condition failing is recorded as
a first-class row rather than raised as an error, so the violation is
evidence rather than something a log swallowed.
- **One chain per `(data, node)`.** Nobody writes to anybody else's
chain, so a node cannot forge a record it did not make, and the picture
is recovered by merging rather than by agreeing.
- **Tier → RLS with no translation layer.** The four tiers *are* the
four SQL verbs, so a grant compiles to a row-level-security predicate
directly. The translation layer is where an application's idea of a
permission and the database's drift apart.
- **`rows_valid` / `fully_signed` / `continuous` / `intact()`.** Four
separate questions instead of one boolean, because "did the signatures
check" and "was anything unsigned" and "was a break recorded" have
different answers and different consequences.
---
## 4. What to do with this
| | |
|---|---|
| **Rename now** | `Stance` → XCCDF's `pass` / `fail` / `unknown`. Days old, one consumer, cheap. |
| **Map, don't rename** | `Annotation`, `ProofBundle`, `content_tag`, `Tier`, `producer registry`. Load-bearing across crates and, for some, already in a committed migration. |
| **Never rename** | `row_hash`, `anchor_hash`, and any other key feeding a hashed payload. |
| **Defend as novel** | section 3. |
**Sources checked this session:** [NIST XCCDF 1.2
schema](https://csrc.nist.gov/schema/xccdf/1.2/xccdf_1.2.xsd) ·
[OpenSCAP manual](https://static.open-scap.org/openscap-1.2/oscap_user_manual.html) ·
[C2PA specification](https://spec.c2pa.org/specifications/specifications/2.4/specs/C2PA_Specification.html) ·
[in-toto Statement
layer](https://github.com/in-toto/attestation/blob/main/spec/v1/statement.md)