Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions ARCHITECTURE.md
Original file line number Diff line number Diff line change
Expand Up @@ -686,6 +686,7 @@ Subcommands:
| `list-members` | List all relay members |
| `generate-key` | Generate a new Nostr keypair (for bootstrapping) |
| `reconcile-channels` | Emit kind:39000/39002 discovery events for channels missing them (idempotent) |
| `audit verify` | Verify the community's audit-log hash chain genesis-to-head (optional `--expect-head N` catches tail truncation); JSON report on stdout; exit 0 verified / 1 integrity failure / 5 operational error |

The `buzz-admin` binary is shipped in the relay Docker image (`/usr/local/bin/buzz-admin`) and is the recommended way to manage relay membership in production. Use `./run.sh add-member`, `./run.sh remove-member`, and `./run.sh list-members` in Docker Compose deployments.

Expand Down
4 changes: 4 additions & 0 deletions Justfile
Original file line number Diff line number Diff line change
Expand Up @@ -276,6 +276,10 @@ test-unit:
# #[ignore]d, so --lib runs only the infra-free set. Without this gate a
# stray file in migrations/ or a broken lint ships green.
cargo nextest run -p buzz-db --lib
# buzz-audit chain-walk tests: pure in-memory hash-chain verification
# (verify_entries) plus error-text sanitization. The Postgres-backed
# buzz-audit tests are #[ignore]d, so --lib runs only the infra-free set.
cargo nextest run -p buzz-audit --lib
# Multi-tenant conformance gate (buzz-conformance): the independent
# replay checker + golden fixtures. No infra — pure in-process trace
# replay — so it belongs in the unit job. Run all targets (lib + the
Expand Down
88 changes: 88 additions & 0 deletions crates/buzz-admin/src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -93,6 +93,38 @@ enum Command {
#[arg(long)]
relay_key: Option<String>,
},
/// Inspect the tamper-evident audit log.
Audit {
#[command(subcommand)]
command: AuditCommand,
},
}

#[derive(Subcommand)]
enum AuditCommand {
/// Verify this relay's audit hash chain, genesis to head.
///
/// Walks the community's whole chain: the first entry must be the genesis
/// (seq 1, no predecessor), sequence numbers must be contiguous, every
/// entry must link to its predecessor's hash, and every stored hash must
/// match a recomputation over the stored fields. Any deletion or edit of
/// stored entries fails the walk.
///
/// Prints a JSON report with the verified head seq and head hash. Record
/// both somewhere outside the database after each run and pass the
/// recorded seq back via --expect-head on the next run: deleting the
/// newest entries is otherwise invisible, because the head is defined by
/// what is stored.
///
/// Exit codes: 0 = chain verified; 1 = verification failed (report on
/// stdout says why); 5 = operational error (DB unreachable, unmapped
/// host, ...).
Verify {
/// Fail with a tail-truncation error unless the verified head has
/// reached at least this seq (from a previously recorded report).
#[arg(long)]
expect_head: Option<i64>,
},
}

#[derive(Subcommand)]
Expand Down Expand Up @@ -152,6 +184,62 @@ async fn run(cli: Cli) -> Result<i32> {
reconcile_channels(relay_key).await?;
Ok(0)
}
Command::Audit {
command: AuditCommand::Verify { expect_head },
} => cmd_audit_verify(expect_head).await,
}
}

/// Run `buzz-admin audit verify [--expect-head N]`.
///
/// Resolves the deployment's community the same way every other buzz-admin
/// command does (RELAY_URL host → durable host map), then verifies that
/// community's full audit chain. Integrity failures are the command's
/// *finding*, not an operational error: they print a JSON verdict on stdout
/// and exit 1, while DB/serialization failures propagate as errors (exit 5).
async fn cmd_audit_verify(expect_head: Option<i64>) -> Result<i32> {
let db = connect_db().await?;
let tenant = resolve_admin_tenant(&db).await?;

// The audit service owns a plain PgPool rather than a Db — mirror the
// relay's own construction (buzz-relay main.rs) with a small dedicated
// pool for this one-shot read.
let db_url = std::env::var("DATABASE_URL")
.unwrap_or_else(|_| "postgres://buzz:buzz_dev@localhost:5432/buzz".to_string());
let audit_pool = sqlx::postgres::PgPoolOptions::new()
.max_connections(2)
.connect(&db_url)
.await
.map_err(|e| anyhow::anyhow!("Audit DB connection failed: {e}"))?;
let audit = buzz_audit::AuditService::new(audit_pool);

match audit
.verify_full_chain(tenant.community(), expect_head)
.await
{
Ok(report) => {
let out = serde_json::json!({
"ok": true,
"community": tenant.community().as_uuid(),
"entries_verified": report.entries_verified,
"head_seq": report.head_seq,
"head_hash": report.head_hash,
});
println!("{}", serde_json::to_string_pretty(&out)?);
Ok(0)
}
Err(
e @ (buzz_audit::AuditError::Database(_) | buzz_audit::AuditError::Serialization(_)),
) => Err(e.into()),
Err(e) => {
let out = serde_json::json!({
"ok": false,
"community": tenant.community().as_uuid(),
"error": e.to_string(),
});
println!("{}", serde_json::to_string_pretty(&out)?);
Ok(1)
}
}
}

Expand Down
47 changes: 47 additions & 0 deletions crates/buzz-audit/src/error.rs
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,43 @@ pub enum AuditError {
seq: i64,
},

/// A verification that should have started at the chain's genesis found a
/// different first entry — the earliest entries have been removed.
#[error("chain does not start at genesis: expected seq 1 with no predecessor, found seq {found_seq} first")]
MissingGenesis {
/// Per-community sequence number of the first entry actually found.
found_seq: i64,
},

/// A mid-chain verification could not find the entry immediately before
/// its range. In an append-only chain every predecessor must exist, so a
/// missing anchor means entries in front of the range were removed.
#[error("missing anchor entry at seq {anchor_seq}: the verified segment cannot be linked to the preceding chain")]
MissingAnchor {
/// Per-community sequence number of the absent anchor entry.
anchor_seq: i64,
},

/// Sequence numbers stopped being contiguous — one or more entries were
/// removed from the middle of the chain.
#[error("chain gap: expected seq {expected_seq} next but found seq {found_seq}")]
SequenceGap {
/// Per-community sequence number that should have come next.
expected_seq: i64,
/// Per-community sequence number actually found.
found_seq: i64,
},

/// The verified chain head is behind the head the caller expected (from an
/// externally recorded report) — the newest entries have been removed.
#[error("chain head at seq {head_seq} is behind the expected head seq {expected_seq}: tail entries are missing")]
TruncatedTail {
/// Per-community sequence number of the verified head.
head_seq: i64,
/// Per-community sequence number the caller expected the head to reach.
expected_seq: i64,
},

/// An unrecognised action string was found in the database.
#[error("unknown audit action in database")]
UnknownAction,
Expand Down Expand Up @@ -68,6 +105,16 @@ mod tests {
let domain_errors = [
AuditError::ChainViolation { seq: 7 },
AuditError::HashMismatch { seq: 42 },
AuditError::MissingGenesis { found_seq: 9 },
AuditError::MissingAnchor { anchor_seq: 4 },
AuditError::SequenceGap {
expected_seq: 5,
found_seq: 8,
},
AuditError::TruncatedTail {
head_seq: 11,
expected_seq: 15,
},
AuditError::UnknownAction,
];

Expand Down
2 changes: 1 addition & 1 deletion crates/buzz-audit/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -32,4 +32,4 @@ pub use action::AuditAction;
pub use entry::{AuditEntry, NewAuditEntry};
pub use error::AuditError;
pub use hash::{compute_hash, GENESIS_HASH};
pub use service::AuditService;
pub use service::{verify_entries, AuditService, ChainVerification};
Loading