Trusty: provenance library workspace (initial import)
This commit is contained in:
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);
|
||||
}
|
||||
Reference in New Issue
Block a user