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
95 changes: 95 additions & 0 deletions examples/src/append_only_audit/archive.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,95 @@
// Copyright (c) Meta Platforms, Inc. and affiliates.
//
// This source code is dual-licensed under either the MIT license found in the
// LICENSE-MIT file in the root directory of this source tree or the Apache
// License, Version 2.0 found in the LICENSE-APACHE file in the root directory
// of this source tree. You may select, at your option, one of the above-listed licenses.

//! The auditor's independent hash archive.
//!
//! In a real deployment an auditor collects the root hash published at each
//! epoch from a source that is independent of the directory server — for example
//! a public transparency log, a certificate-transparency-like witness network,
//! or a gossip protocol among peers. The independence is what makes the audit
//! meaningful: if the auditor accepted hashes from the server itself, the server
//! could supply forged hashes that validate a tampered proof.
//!
//! `AuditorArchive` models this independent store. It holds one `EpochRecord`
//! per epoch and provides slice views used by `audit_verify`.

use akd::hash::Digest;

/// One entry in the auditor's archive, corresponding to a single epoch.
pub(super) struct EpochRecord {
/// The epoch number assigned by the directory (1-based, monotonically increasing).
pub(super) epoch: u64,
/// The 256-bit root hash of the Merkle tree at this epoch.
/// This is the cryptographic commitment the auditor obtained from its
/// trusted source and will use to anchor the append-only proof.
pub(super) root_hash: Digest,
/// Number of (label, value) pairs committed in this epoch.
pub(super) change_count: usize,
/// Human-readable description of what changed (for display purposes only).
pub(super) description: String,
}

/// The auditor's archive of epoch root hashes, collected independently from
/// the directory server over the lifetime of the directory.
pub(super) struct AuditorArchive {
records: Vec<EpochRecord>,
}

impl AuditorArchive {
pub(super) fn new() -> Self {
Self {
records: Vec::new(),
}
}

/// Records a newly observed epoch.
pub(super) fn record(
&mut self,
epoch: u64,
root_hash: Digest,
change_count: usize,
description: String,
) {
self.records.push(EpochRecord {
epoch,
root_hash,
change_count,
description,
});
}

/// Returns the root hashes for epochs in `[start_epoch, end_epoch]`
/// in ascending epoch order. The caller passes this slice to `audit_verify`,
/// which requires exactly `(end_epoch - start_epoch + 1)` hashes.
pub(super) fn range_hashes(&self, start_epoch: u64, end_epoch: u64) -> Vec<Digest> {
self.records
.iter()
.filter(|r| r.epoch >= start_epoch && r.epoch <= end_epoch)
.map(|r| r.root_hash)
.collect()
}

/// Prints a tabular view of the archive to stdout.
pub(super) fn print_log(&self) {
println!("\n── Auditor's hash archive ────────────────────────────────────────");
println!(
"{:<8} {:<10} {:<32} {:<24}",
"Epoch", "Changes", "Root hash (first 16 hex)", "Description"
);
println!("{}", "─".repeat(80));
for r in &self.records {
println!(
"{:<8} {:<10} {:<32} {}",
r.epoch,
r.change_count,
hex::encode(&r.root_hash[..8]),
r.description,
);
}
println!();
}
}
78 changes: 78 additions & 0 deletions examples/src/append_only_audit/audit.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,78 @@
// Copyright (c) Meta Platforms, Inc. and affiliates.
//
// This source code is dual-licensed under either the MIT license found in the
// LICENSE-MIT file in the root directory of this source tree or the Apache
// License, Version 2.0 found in the LICENSE-APACHE file in the root directory
// of this source tree. You may select, at your option, one of the above-listed licenses.

//! AppendOnlyProof request (server side) and verification (auditor side).
//!
//! `audit_verify` is an async function because verifying a multi-epoch proof
//! can be CPU-intensive and is designed to be run inside a Tokio task.

use super::AkdDir;
use akd::hash::Digest;
use akd::AppendOnlyProof;
use anyhow::Result;

/// Requests an `AppendOnlyProof` from the directory server for the epoch range
/// `[start_epoch, end_epoch]`.
///
/// The proof contains one sub-proof per epoch transition in the range. Each
/// sub-proof demonstrates that the tree state at the end of the transition
/// is a valid superset of the tree state at the beginning — i.e., no previously
/// committed entry was deleted or overwritten.
///
/// The verifier must supply `(end_epoch - start_epoch + 1)` root hashes when
/// calling `verify_proof`, one per boundary epoch.
pub(super) async fn request_proof(
dir: &AkdDir,
start_epoch: u64,
end_epoch: u64,
) -> Result<AppendOnlyProof> {
println!(
"Requesting AppendOnlyProof from epoch {} to {} ...",
start_epoch, end_epoch
);
let proof = dir.audit(start_epoch, end_epoch).await?;
println!(
" Proof received: {} sub-proof(s) covering {} transition(s).",
proof.proofs.len(),
proof.epochs.len(),
);
Ok(proof)
}

/// Verifies an `AppendOnlyProof` against `hashes`.
///
/// `hashes` must be in ascending epoch order and must contain exactly
/// `(end_epoch - start_epoch + 1)` entries — one for each boundary epoch,
/// including both endpoints.
///
/// `akd::auditor::audit_verify` checks each consecutive pair `(hashes[i],
/// hashes[i+1])` against `proof.proofs[i]`. If any sub-proof is invalid the
/// call returns an error, indicating that the directory is **not** append-only
/// between those epochs.
///
/// The hashes must originate from the auditor's own archive (see `archive.rs`),
/// not from the server, so that the server cannot supply forged anchors.
pub(super) async fn verify_proof(hashes: Vec<Digest>, proof: AppendOnlyProof) -> Result<()> {
akd::auditor::audit_verify::<akd::WhatsAppV1Configuration>(hashes, proof)
.await
.map_err(|e| {
anyhow::anyhow!(
"Append-only verification FAILED — directory may have been tampered with: {e:?}"
)
})
}

/// Prints a success summary after a passed audit.
pub(super) fn print_result(start_epoch: u64, end_epoch: u64, num_epochs: usize) {
println!("── Audit result ──────────────────────────────────────────────────");
println!(
" Append-only proof PASSED for epoch {} → {} ({} epoch(s)).",
start_epoch, end_epoch, num_epochs
);
println!(" No entries were deleted or rewritten across these epochs.");
println!(" The directory is tamper-evident within the audited range.");
}
107 changes: 107 additions & 0 deletions examples/src/append_only_audit/mod.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,107 @@
// Copyright (c) Meta Platforms, Inc. and affiliates.
//
// This source code is dual-licensed under either the MIT license found in the
// LICENSE-MIT file in the root directory of this source tree or the Apache
// License, Version 2.0 found in the LICENSE-APACHE file in the root directory
// of this source tree. You may select, at your option, one of the above-listed licenses.

//! Demonstrates the auditor role: verifying that a directory evolved in an
//! append-only manner across a configurable range of epochs.
//!
//! An AKD guarantees that entries can only be added or updated — never silently
//! removed. An independent auditor enforces this by archiving the root hash
//! published at each epoch and periodically requesting an AppendOnlyProof from
//! the server. If any entry was deleted or rewritten between two epochs the
//! server cannot produce a valid proof, and verification fails.
//!
//! Module layout:
//! population.rs — epoch content definitions and publish logic (server side)
//! archive.rs — the auditor's independent hash archive
//! audit.rs — AppendOnlyProof request and verification
//!
//! Run with:
//! cargo run -p examples -- append-only-audit
//! cargo run -p examples -- append-only-audit --epochs 7

mod archive;
mod audit;
mod population;

#[cfg(test)]
mod tests;

use akd::append_only_zks::AzksParallelismConfig;
use akd::ecvrf::HardCodedAkdVRF;
use akd::storage::memory::AsyncInMemoryDatabase;
use akd::storage::StorageManager;
use anyhow::Result;
use clap::Parser;

/// Concrete directory type shared across this module's sub-files.
type AkdDir =
akd::directory::Directory<akd::WhatsAppV1Configuration, AsyncInMemoryDatabase, HardCodedAkdVRF>;

#[derive(Parser, Debug, Clone)]
#[clap(
author,
about = "Populate the directory over multiple epochs and verify append-only integrity as an auditor"
)]
pub(crate) struct Args {
/// Number of epochs to publish before auditing (2–8).
/// Each epoch adds new users or updates existing ones.
#[arg(long, default_value_t = 4, value_parser = clap::value_parser!(u8).range(2..=8))]
epochs: u8,
}

pub(crate) async fn run(args: Args) -> Result<()> {
let num_epochs = args.epochs as usize;

// ── 1. Directory setup ───────────────────────────────────────────────────
let akd = AkdDir::new(
StorageManager::new_no_cache(AsyncInMemoryDatabase::new()),
HardCodedAkdVRF {},
AzksParallelismConfig::default(),
)
.await?;

println!(
"Directory initialised. Publishing {} epochs of user-key data.\n",
num_epochs
);

// ── 2. Publish epochs and build the auditor's archive ────────────────────
// The auditor collects the root hash of every epoch it observes. These
// hashes must come from a source independent of the server (e.g. a public
// transparency log) so the server cannot forge them retroactively.
let mut archive = archive::AuditorArchive::new();

for epoch_idx in 0..num_epochs {
let (epoch, root_hash, change_count) = population::publish_epoch(&akd, epoch_idx).await?;
let description = population::describe_epoch(epoch_idx);
archive.record(epoch, root_hash, change_count, description.clone());
println!(
" Epoch {:>2} — {} change(s) — {} — root: {}",
epoch,
change_count,
description,
hex::encode(&root_hash[..8])
);
}

// ── 3. Print the auditor's archive ───────────────────────────────────────
archive.print_log();

// ── 4. Request and verify the append-only proof ───────────────────────────
let start_epoch = 1u64;
let end_epoch = num_epochs as u64;

let proof = audit::request_proof(&akd, start_epoch, end_epoch).await?;

// The auditor supplies its archived hashes — not hashes from the server —
// so the verification is independent of the party being audited.
let hashes = archive.range_hashes(start_epoch, end_epoch);
audit::verify_proof(hashes, proof).await?;
audit::print_result(start_epoch, end_epoch, num_epochs);

Ok(())
}
108 changes: 108 additions & 0 deletions examples/src/append_only_audit/population.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,108 @@
// Copyright (c) Meta Platforms, Inc. and affiliates.
//
// This source code is dual-licensed under either the MIT license found in the
// LICENSE-MIT file in the root directory of this source tree or the Apache
// License, Version 2.0 found in the LICENSE-APACHE file in the root directory
// of this source tree. You may select, at your option, one of the above-listed licenses.

//! Epoch content definitions and server-side publish logic.
//!
//! Each epoch in a key-transparency deployment is a mix of new registrations
//! and key rotations by existing users. The batches here are intentionally
//! varied so that the append-only proof spans a non-trivial series of tree
//! mutations — making it a realistic test for the auditor.

use super::AkdDir;
use akd::hash::Digest;
use akd::{AkdLabel, AkdValue, EpochHash};
use anyhow::Result;

/// One epoch's worth of changes: a list of (label, value) pairs to publish.
/// An existing label increments its version; a new label is registered fresh.
struct EpochContent {
/// Human-readable description for display purposes.
description: &'static str,
/// The (label, value) entries to commit in this epoch.
entries: &'static [(&'static str, &'static str)],
}

/// Eight pre-defined epoch batches. The `--epochs` flag selects how many of
/// these to publish before running the audit, from the front of the list.
const EPOCH_CONTENTS: &[EpochContent] = &[
EpochContent {
description: "alice, bob, carol register",
entries: &[
("alice@example.com", "alice_key_v1"),
("bob@example.com", "bob_key_v1"),
("carol@example.com", "carol_key_v1"),
],
},
EpochContent {
description: "alice rotates; dave joins",
entries: &[
("alice@example.com", "alice_key_v2"),
("dave@example.com", "dave_key_v1"),
],
},
EpochContent {
description: "bob rotates; erin joins",
entries: &[
("bob@example.com", "bob_key_v2"),
("erin@example.com", "erin_key_v1"),
],
},
EpochContent {
description: "carol rotates; frank joins",
entries: &[
("carol@example.com", "carol_key_v2"),
("frank@example.com", "frank_key_v1"),
],
},
EpochContent {
description: "dave and erin both rotate",
entries: &[
("dave@example.com", "dave_key_v2"),
("erin@example.com", "erin_key_v2"),
],
},
EpochContent {
description: "alice (3rd key), frank rotates",
entries: &[
("alice@example.com", "alice_key_v3"),
("frank@example.com", "frank_key_v2"),
],
},
EpochContent {
description: "grace joins; bob gets 3rd key",
entries: &[
("grace@example.com", "grace_key_v1"),
("bob@example.com", "bob_key_v3"),
],
},
EpochContent {
description: "heidi joins",
entries: &[("heidi@example.com", "heidi_key_v1")],
},
];

/// Publishes epoch number `idx` (0-based) and returns:
/// - the assigned epoch number (1-based, assigned by the directory)
/// - the resulting root hash
/// - the number of changes committed in this epoch
pub(super) async fn publish_epoch(dir: &AkdDir, idx: usize) -> Result<(u64, Digest, usize)> {
let content = &EPOCH_CONTENTS[idx];
let entries: Vec<(AkdLabel, AkdValue)> = content
.entries
.iter()
.map(|(label, value)| (AkdLabel::from(*label), AkdValue::from(*value)))
.collect();

let change_count = entries.len();
let EpochHash(epoch, root_hash) = dir.publish(entries).await?;
Ok((epoch, root_hash, change_count))
}

/// Returns the human-readable description for epoch index `idx`.
pub(super) fn describe_epoch(idx: usize) -> String {
EPOCH_CONTENTS[idx].description.to_string()
}
Loading