6.5 KiB
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:
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:
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
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:
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_atprecedes the row'sdetected_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:
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.