Trusty: provenance library workspace (initial import)
This commit is contained in:
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