Trusty: provenance library workspace (initial import)
This commit is contained in:
18
.gitignore
vendored
Normal file
18
.gitignore
vendored
Normal 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
187
CLAUDE.md
Normal 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
1745
Cargo.lock
generated
Normal file
File diff suppressed because it is too large
Load Diff
62
Cargo.toml
Normal file
62
Cargo.toml
Normal 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
74
README.md
Normal 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');
|
||||||
|
```
|
||||||
15
crates/adapters/trusty-memory/Cargo.toml
Normal file
15
crates/adapters/trusty-memory/Cargo.toml
Normal 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"] }
|
||||||
135
crates/adapters/trusty-memory/src/lib.rs
Normal file
135
crates/adapters/trusty-memory/src/lib.rs
Normal 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
8
crates/adapters/trusty-memory/tests/conformance.rs
Normal file
8
crates/adapters/trusty-memory/tests/conformance.rs
Normal 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);
|
||||||
|
}
|
||||||
19
crates/adapters/trusty-policy-audit/Cargo.toml
Normal file
19
crates/adapters/trusty-policy-audit/Cargo.toml
Normal 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"
|
||||||
262
crates/adapters/trusty-policy-audit/src/lib.rs
Normal file
262
crates/adapters/trusty-policy-audit/src/lib.rs
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
18
crates/adapters/trusty-policy-conditions/Cargo.toml
Normal file
18
crates/adapters/trusty-policy-conditions/Cargo.toml
Normal 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 }
|
||||||
528
crates/adapters/trusty-policy-conditions/src/lib.rs
Normal file
528
crates/adapters/trusty-policy-conditions/src/lib.rs
Normal 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:?}"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
303
crates/adapters/trusty-policy-conditions/tests/enforced.rs
Normal file
303
crates/adapters/trusty-policy-conditions/tests/enforced.rs
Normal 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:?}"
|
||||||
|
);
|
||||||
|
}
|
||||||
20
crates/adapters/trusty-policy-postgres/Cargo.toml
Normal file
20
crates/adapters/trusty-policy-postgres/Cargo.toml
Normal 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 = []
|
||||||
78
crates/adapters/trusty-policy-postgres/examples/emit_ddl.rs
Normal file
78
crates/adapters/trusty-policy-postgres/examples/emit_ddl.rs
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
282
crates/adapters/trusty-policy-postgres/src/lib.rs
Normal file
282
crates/adapters/trusty-policy-postgres/src/lib.rs
Normal 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"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
508
crates/adapters/trusty-policy-postgres/src/permits.rs
Normal file
508
crates/adapters/trusty-policy-postgres/src/permits.rs
Normal 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"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
312
crates/adapters/trusty-policy-postgres/src/schema.rs
Normal file
312
crates/adapters/trusty-policy-postgres/src/schema.rs
Normal 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"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
616
crates/adapters/trusty-policy-postgres/src/store.rs
Normal file
616
crates/adapters/trusty-policy-postgres/src/store.rs
Normal 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("ed, &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"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
883
crates/adapters/trusty-policy-postgres/tests/integration.rs
Normal file
883
crates/adapters/trusty-policy-postgres/tests/integration.rs
Normal 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:?}"
|
||||||
|
);
|
||||||
|
}
|
||||||
27
crates/adapters/trusty-postgres/Cargo.toml
Normal file
27
crates/adapters/trusty-postgres/Cargo.toml
Normal 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 = []
|
||||||
39
crates/adapters/trusty-postgres/src/error.rs
Normal file
39
crates/adapters/trusty-postgres/src/error.rs
Normal 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()),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
31
crates/adapters/trusty-postgres/src/lib.rs
Normal file
31
crates/adapters/trusty-postgres/src/lib.rs
Normal 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,
|
||||||
|
};
|
||||||
144
crates/adapters/trusty-postgres/src/schema.rs
Normal file
144
crates/adapters/trusty-postgres/src/schema.rs
Normal 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
|
||||||
|
)
|
||||||
|
"#;
|
||||||
463
crates/adapters/trusty-postgres/src/store.rs
Normal file
463
crates/adapters/trusty-postgres/src/store.rs
Normal 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(),
|
||||||
|
))
|
||||||
|
}
|
||||||
|
}
|
||||||
1520
crates/adapters/trusty-postgres/src/trusty.rs
Normal file
1520
crates/adapters/trusty-postgres/src/trusty.rs
Normal file
File diff suppressed because it is too large
Load Diff
55
crates/adapters/trusty-postgres/tests/conformance.rs
Normal file
55
crates/adapters/trusty-postgres/tests/conformance.rs
Normal 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);
|
||||||
|
}
|
||||||
705
crates/adapters/trusty-postgres/tests/integration.rs
Normal file
705
crates/adapters/trusty-postgres/tests/integration.rs
Normal 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", ®istered.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", ®istered)
|
||||||
|
.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:?}"
|
||||||
|
);
|
||||||
|
}
|
||||||
22
crates/adapters/trusty-sqlite/Cargo.toml
Normal file
22
crates/adapters/trusty-sqlite/Cargo.toml
Normal 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"] }
|
||||||
83
crates/adapters/trusty-sqlite/examples/bench_append.rs
Normal file
83
crates/adapters/trusty-sqlite/examples/bench_append.rs
Normal 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());
|
||||||
|
}
|
||||||
195
crates/adapters/trusty-sqlite/examples/device_signer.rs
Normal file
195
crates/adapters/trusty-sqlite/examples/device_signer.rs
Normal 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());
|
||||||
|
}
|
||||||
186
crates/adapters/trusty-sqlite/examples/twin_coupling.rs
Normal file
186
crates/adapters/trusty-sqlite/examples/twin_coupling.rs
Normal 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());
|
||||||
|
}
|
||||||
30
crates/adapters/trusty-sqlite/src/error.rs
Normal file
30
crates/adapters/trusty-sqlite/src/error.rs
Normal 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>;
|
||||||
26
crates/adapters/trusty-sqlite/src/lib.rs
Normal file
26
crates/adapters/trusty-sqlite/src/lib.rs
Normal 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,
|
||||||
|
};
|
||||||
90
crates/adapters/trusty-sqlite/src/schema.rs
Normal file
90
crates/adapters/trusty-sqlite/src/schema.rs
Normal 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);
|
||||||
|
"#;
|
||||||
315
crates/adapters/trusty-sqlite/src/sqlite_store.rs
Normal file
315
crates/adapters/trusty-sqlite/src/sqlite_store.rs
Normal 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())
|
||||||
|
}
|
||||||
|
}
|
||||||
2363
crates/adapters/trusty-sqlite/src/store.rs
Normal file
2363
crates/adapters/trusty-sqlite/src/store.rs
Normal file
File diff suppressed because it is too large
Load Diff
569
crates/adapters/trusty-sqlite/tests/annotation.rs
Normal file
569
crates/adapters/trusty-sqlite/tests/annotation.rs
Normal 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"
|
||||||
|
);
|
||||||
|
}
|
||||||
8
crates/adapters/trusty-sqlite/tests/conformance.rs
Normal file
8
crates/adapters/trusty-sqlite/tests/conformance.rs
Normal 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);
|
||||||
|
}
|
||||||
16
crates/adapters/trusty-tokio/Cargo.toml
Normal file
16
crates/adapters/trusty-tokio/Cargo.toml
Normal 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"] }
|
||||||
264
crates/adapters/trusty-tokio/src/lib.rs
Normal file
264
crates/adapters/trusty-tokio/src/lib.rs
Normal 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
|
||||||
|
}
|
||||||
|
}
|
||||||
282
crates/adapters/trusty-tokio/src/pool.rs
Normal file
282
crates/adapters/trusty-tokio/src/pool.rs
Normal 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"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
280
crates/adapters/trusty-tokio/tests/runtime.rs
Normal file
280
crates/adapters/trusty-tokio/tests/runtime.rs
Normal 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"
|
||||||
|
);
|
||||||
|
}
|
||||||
39
crates/core/trusty-core/Cargo.toml
Normal file
39
crates/core/trusty-core/Cargo.toml
Normal 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 }
|
||||||
696
crates/core/trusty-core/src/annotation.rs
Normal file
696
crates/core/trusty-core/src/annotation.rs
Normal 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"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
799
crates/core/trusty-core/src/bundle.rs
Normal file
799
crates/core/trusty-core/src/bundle.rs
Normal 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 }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
1742
crates/core/trusty-core/src/chain.rs
Normal file
1742
crates/core/trusty-core/src/chain.rs
Normal file
File diff suppressed because it is too large
Load Diff
334
crates/core/trusty-core/src/conditions.rs
Normal file
334
crates/core/trusty-core/src/conditions.rs
Normal 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)
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}
|
||||||
160
crates/core/trusty-core/src/credential.rs
Normal file
160
crates/core/trusty-core/src/credential.rs
Normal 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"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
198
crates/core/trusty-core/src/crypto.rs
Normal file
198
crates/core/trusty-core/src/crypto.rs
Normal 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))
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}
|
||||||
27
crates/core/trusty-core/src/error.rs
Normal file
27
crates/core/trusty-core/src/error.rs
Normal 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>;
|
||||||
253
crates/core/trusty-core/src/hash.rs
Normal file
253
crates/core/trusty-core/src/hash.rs
Normal 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");
|
||||||
|
}
|
||||||
|
}
|
||||||
75
crates/core/trusty-core/src/lib.rs
Normal file
75
crates/core/trusty-core/src/lib.rs
Normal 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,
|
||||||
|
};
|
||||||
337
crates/core/trusty-core/src/payload.rs
Normal file
337
crates/core/trusty-core/src/payload.rs
Normal 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\""));
|
||||||
|
}
|
||||||
|
}
|
||||||
17
crates/core/trusty-policy/Cargo.toml
Normal file
17
crates/core/trusty-policy/Cargo.toml
Normal 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 = []
|
||||||
171
crates/core/trusty-policy/examples/claude_session.json
Normal file
171
crates/core/trusty-policy/examples/claude_session.json
Normal 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"
|
||||||
|
}
|
||||||
26
crates/core/trusty-policy/examples/pipeline.json
Normal file
26
crates/core/trusty-policy/examples/pipeline.json
Normal 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"
|
||||||
|
}
|
||||||
2904
crates/core/trusty-policy/src/lib.rs
Normal file
2904
crates/core/trusty-policy/src/lib.rs
Normal file
File diff suppressed because it is too large
Load Diff
20
crates/core/trusty-store/Cargo.toml
Normal file
20
crates/core/trusty-store/Cargo.toml
Normal 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 }
|
||||||
|
|
||||||
821
crates/core/trusty-store/src/lib.rs
Normal file
821
crates/core/trusty-store/src/lib.rs
Normal 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")
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
128
crates/core/trusty-store/src/testing.rs
Normal file
128
crates/core/trusty-store/src/testing.rs
Normal 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"
|
||||||
|
);
|
||||||
|
}
|
||||||
827
crates/extensions/trusty-sqlite-ext/Cargo.lock
generated
Normal file
827
crates/extensions/trusty-sqlite-ext/Cargo.lock
generated
Normal 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"
|
||||||
22
crates/extensions/trusty-sqlite-ext/Cargo.toml
Normal file
22
crates/extensions/trusty-sqlite-ext/Cargo.toml
Normal 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"] }
|
||||||
222
crates/extensions/trusty-sqlite-ext/src/lib.rs
Normal file
222
crates/extensions/trusty-sqlite-ext/src/lib.rs
Normal 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")
|
||||||
|
}
|
||||||
18
crates/extensions/trusty-sqlite-ext/tests/smoke.rs
Normal file
18
crates/extensions/trusty-sqlite-ext/tests/smoke.rs
Normal 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);
|
||||||
|
}
|
||||||
196
docs/device-signer-provisioning.md
Normal file
196
docs/device-signer-provisioning.md
Normal 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
315
docs/payload-v2.md
Normal 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
237
docs/product-use-cases.md
Normal 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
96
docs/scope-grammar.md
Normal 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
296
docs/threat-model.md
Normal 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.
|
||||||
1301
docs/trusty-postgres-design.md
Normal file
1301
docs/trusty-postgres-design.md
Normal file
File diff suppressed because it is too large
Load Diff
92
docs/twin-coupling.md
Normal file
92
docs/twin-coupling.md
Normal 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
140
docs/vocabulary.md
Normal 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)
|
||||||
Reference in New Issue
Block a user