diff --git a/examples/src/append_only_audit/archive.rs b/examples/src/append_only_audit/archive.rs new file mode 100644 index 00000000..e605b0d0 --- /dev/null +++ b/examples/src/append_only_audit/archive.rs @@ -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, +} + +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 { + 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!(); + } +} diff --git a/examples/src/append_only_audit/audit.rs b/examples/src/append_only_audit/audit.rs new file mode 100644 index 00000000..228cc2c3 --- /dev/null +++ b/examples/src/append_only_audit/audit.rs @@ -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 { + 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, proof: AppendOnlyProof) -> Result<()> { + akd::auditor::audit_verify::(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."); +} diff --git a/examples/src/append_only_audit/mod.rs b/examples/src/append_only_audit/mod.rs new file mode 100644 index 00000000..f5dae4fe --- /dev/null +++ b/examples/src/append_only_audit/mod.rs @@ -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; + +#[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(()) +} diff --git a/examples/src/append_only_audit/population.rs b/examples/src/append_only_audit/population.rs new file mode 100644 index 00000000..b4d06ed3 --- /dev/null +++ b/examples/src/append_only_audit/population.rs @@ -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() +} diff --git a/examples/src/append_only_audit/tests/mod.rs b/examples/src/append_only_audit/tests/mod.rs new file mode 100644 index 00000000..1e1fb2d5 --- /dev/null +++ b/examples/src/append_only_audit/tests/mod.rs @@ -0,0 +1,176 @@ +// 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. + +//! Tests for the append_only_audit example. + +use akd::append_only_zks::AzksParallelismConfig; +use akd::directory::Directory; +use akd::ecvrf::HardCodedAkdVRF; +use akd::hash::Digest; +use akd::storage::memory::AsyncInMemoryDatabase; +use akd::storage::StorageManager; +use akd::NamedConfiguration; +use akd::{AkdLabel, AkdValue, EpochHash}; + +use crate::test_config; + +// ── Generic multi-configuration tests ──────────────────────────────────────── + +/// Verifies that an AppendOnlyProof generated for a two-epoch range is accepted +/// by `audit_verify` under both supported configurations. +test_config!(test_audit_proof_two_epochs); +async fn test_audit_proof_two_epochs() { + let akd = Directory::::new( + StorageManager::new_no_cache(AsyncInMemoryDatabase::new()), + HardCodedAkdVRF {}, + AzksParallelismConfig::default(), + ) + .await + .expect("directory init failed"); + + // Epoch 1 + let EpochHash(e1, h1) = akd + .publish(vec![ + ( + AkdLabel::from("alice@example.com"), + AkdValue::from("alice_key_v1"), + ), + ( + AkdLabel::from("bob@example.com"), + AkdValue::from("bob_key_v1"), + ), + ]) + .await + .expect("epoch 1 publish failed"); + + // Epoch 2: alice rotates, carol joins. + let EpochHash(e2, h2) = akd + .publish(vec![ + ( + AkdLabel::from("alice@example.com"), + AkdValue::from("alice_key_v2"), + ), + ( + AkdLabel::from("carol@example.com"), + AkdValue::from("carol_key_v1"), + ), + ]) + .await + .expect("epoch 2 publish failed"); + + assert_eq!(e1, 1); + assert_eq!(e2, 2); + + let proof = akd + .audit(e1, e2) + .await + .expect("audit proof generation failed"); + // audit_verify requires hashes in order: [h_start, ..., h_end] + akd::auditor::audit_verify::(vec![h1, h2], proof) + .await + .expect("audit verification failed"); +} + +/// Verifies that a three-epoch audit proof is accepted after a sequence of +/// additions, updates, and new registrations. +test_config!(test_audit_proof_three_epochs); +async fn test_audit_proof_three_epochs() { + let akd = Directory::::new( + StorageManager::new_no_cache(AsyncInMemoryDatabase::new()), + HardCodedAkdVRF {}, + AzksParallelismConfig::default(), + ) + .await + .expect("directory init failed"); + + let mut hashes: Vec = Vec::new(); + + let batches: &[&[(&str, &str)]] = &[ + &[ + ("u1@example.com", "u1_key_v1"), + ("u2@example.com", "u2_key_v1"), + ], + &[ + ("u1@example.com", "u1_key_v2"), + ("u3@example.com", "u3_key_v1"), + ], + &[ + ("u2@example.com", "u2_key_v2"), + ("u4@example.com", "u4_key_v1"), + ], + ]; + + for batch in batches { + let entries: Vec<(AkdLabel, AkdValue)> = batch + .iter() + .map(|(l, v)| (AkdLabel::from(*l), AkdValue::from(*v))) + .collect(); + let EpochHash(_, h) = akd.publish(entries).await.expect("publish failed"); + hashes.push(h); + } + + let proof = akd + .audit(1, 3) + .await + .expect("audit proof generation failed"); + akd::auditor::audit_verify::(hashes, proof) + .await + .expect("audit verification failed for three-epoch range"); +} + +// ── Direct run() integration tests ─────────────────────────────────────────── + +/// Minimum epoch count (2) must complete without error. +#[tokio::test] +async fn test_run_minimum_epochs() { + super::run(super::Args { epochs: 2 }) + .await + .expect("run with 2 epochs failed"); +} + +/// Default epoch count (4) must complete without error. +#[tokio::test] +async fn test_run_default_epochs() { + super::run(super::Args { epochs: 4 }) + .await + .expect("run with 4 epochs failed"); +} + +/// Maximum epoch count (8) must complete without error. +#[tokio::test] +async fn test_run_maximum_epochs() { + super::run(super::Args { epochs: 8 }) + .await + .expect("run with 8 epochs failed"); +} + +// ── AuditorArchive unit tests ───────────────────────────────────────────────── + +/// Verifies that `range_hashes` returns the correct subset of the archive. +#[test] +fn test_archive_range_hashes() { + use super::archive::AuditorArchive; + + let mut archive = AuditorArchive::new(); + let hashes: Vec = (1u8..=5).map(|i| [i; 32]).collect(); + + for (i, h) in hashes.iter().enumerate() { + archive.record(i as u64 + 1, *h, 1, format!("epoch {}", i + 1)); + } + + // Full range + let all = archive.range_hashes(1, 5); + assert_eq!(all.len(), 5); + assert_eq!(all[0], hashes[0]); + assert_eq!(all[4], hashes[4]); + + // Partial range + let partial = archive.range_hashes(2, 4); + assert_eq!(partial.len(), 3); + assert_eq!(partial[0], hashes[1]); // epoch 2 + assert_eq!(partial[2], hashes[3]); // epoch 4 +} diff --git a/examples/src/basic_lookup/client.rs b/examples/src/basic_lookup/client.rs new file mode 100644 index 00000000..3d01df9a --- /dev/null +++ b/examples/src/basic_lookup/client.rs @@ -0,0 +1,75 @@ +// 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. + +//! Client-side proof verification and result display for the basic_lookup example. +//! +//! Everything here runs on the client. The only inputs from the server are the +//! `LookupResponse` (proof + epoch hash) and — fetched once, cached long-term — +//! the VRF public key. The epoch hash itself must come from a trusted third party +//! in production; the server is not a reliable source for its own root hash. + +use super::{proofs::LookupResponse, AkdDir}; +use akd::AkdLabel; +use anyhow::Result; + +/// The validated outcome of a successful lookup verification. +pub(super) struct VerifiedLookup { + /// The human-readable label that was queried. + pub(super) label: String, + /// The epoch in which this label–value binding was established. + pub(super) epoch: u64, + /// The version counter for this label (incremented on each re-publish). + pub(super) version: u64, + /// The value (e.g. public key bytes) associated with the label at `epoch`. + pub(super) value: String, +} + +/// Verifies a server-supplied `LookupResponse` on the client side. +/// +/// Retrieves the server's VRF public key from the directory, then calls +/// `akd::client::lookup_verify` — a pure, synchronous function that performs +/// all cryptographic checks without any network access: +/// +/// 1. **VRF proof** — confirms the label's tree position was derived +/// correctly from the server's private key (checked against the public key). +/// 2. **Merkle inclusion path** — confirms the leaf is committed at +/// `epoch_hash.hash()`. +/// 3. **Freshness** — confirms the proof's version ≤ the current epoch, +/// preventing replay of stale proofs. +pub(super) async fn verify( + dir: &AkdDir, + label: AkdLabel, + response: LookupResponse, +) -> Result { + let label_str = String::from_utf8_lossy(&label.0).to_string(); + let public_key = dir.get_public_key().await?; + + let result = akd::client::lookup_verify::( + public_key.as_bytes(), + response.epoch_hash.hash(), + response.epoch_hash.epoch(), + label, + response.proof, + ) + .map_err(|e| anyhow::anyhow!("Lookup proof verification failed: {e:?}"))?; + + Ok(VerifiedLookup { + label: label_str, + epoch: result.epoch, + version: result.version, + value: String::from_utf8_lossy(&result.value.0).to_string(), + }) +} + +/// Pretty-prints a `VerifiedLookup` to stdout. +pub(super) fn display(v: &VerifiedLookup) { + println!("[client] Proof verified successfully."); + println!(" label : {}", v.label); + println!(" epoch : {}", v.epoch); + println!(" version : {}", v.version); + println!(" value : \"{}\"", v.value); +} diff --git a/examples/src/basic_lookup/mod.rs b/examples/src/basic_lookup/mod.rs new file mode 100644 index 00000000..9205bcc4 --- /dev/null +++ b/examples/src/basic_lookup/mod.rs @@ -0,0 +1,99 @@ +// 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 core AKD client workflow: publish a user pool, request a +//! lookup proof from the server for a chosen label, and verify it on the client +//! side without trusting the server. +//! +//! Module layout: +//! setup.rs — directory initialisation and entry-batch construction +//! proofs.rs — server-side LookupProof generation +//! client.rs — client-side proof verification and result display +//! +//! Run with: +//! cargo run -p examples -- basic-lookup +//! cargo run -p examples -- basic-lookup --label bob@example.com --users 6 + +mod client; +mod proofs; +mod setup; + +#[cfg(test)] +mod tests; + +use anyhow::{bail, Result}; +use clap::Parser; + +/// Concrete directory type shared across this module's sub-files. +/// Using a type alias keeps the generics out of every function signature. +type AkdDir = akd::directory::Directory< + akd::WhatsAppV1Configuration, + akd::storage::memory::AsyncInMemoryDatabase, + akd::ecvrf::HardCodedAkdVRF, +>; + +#[derive(Parser, Debug, Clone)] +#[clap( + author, + about = "Publish a pool of users then verify a client lookup proof for a chosen label" +)] +pub(crate) struct Args { + /// Label (email address) to look up after publishing. + /// Must belong to one of the users seeded by --users. + #[arg(long, default_value = "alice@example.com")] + label: String, + + /// Number of users to seed into the directory (1–10). + /// Entries are drawn in order from a fixed pool of example accounts. + #[arg(long, default_value_t = 5, value_parser = clap::value_parser!(u8).range(1..=10))] + users: u8, +} + +pub(crate) async fn run(args: Args) -> Result<()> { + // ── 1. Directory initialisation ────────────────────────────────────────── + let akd = setup::init_directory().await?; + println!("[setup] Directory initialised (WhatsAppV1Configuration, in-memory storage)."); + + // ── 2. Build and publish the user batch ────────────────────────────────── + let (label_strings, entries) = setup::build_entries(args.users); + + // Guard: the requested label must actually be in the published batch. + if !label_strings.contains(&args.label) { + bail!( + "'{}' is not in the published user pool.\n\ + Published labels: {}", + args.label, + label_strings.join(", ") + ); + } + + let epoch_hash = setup::publish_batch(&akd, entries).await?; + println!( + "[publish] Epoch {} committed — {} users, root hash: {}", + epoch_hash.epoch(), + label_strings.len(), + hex::encode(epoch_hash.hash()) + ); + + // ── 3. Server generates a lookup proof ─────────────────────────────────── + let label = akd::AkdLabel::from(args.label.as_str()); + let response = proofs::request_lookup(&akd, label.clone()).await?; + println!( + "[server] LookupProof generated for '{}' at epoch {}.", + args.label, + response.epoch_hash.epoch() + ); + + // ── 4. Client verifies the proof ───────────────────────────────────────── + // In production the client receives the epoch_hash from a trusted third + // party (transparency log / auditor), not from the server that supplied + // the proof. + let verified = client::verify(&akd, label, response).await?; + client::display(&verified); + + Ok(()) +} diff --git a/examples/src/basic_lookup/proofs.rs b/examples/src/basic_lookup/proofs.rs new file mode 100644 index 00000000..bc3014a5 --- /dev/null +++ b/examples/src/basic_lookup/proofs.rs @@ -0,0 +1,47 @@ +// 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. + +//! Server-side LookupProof generation for the basic_lookup example. +//! +//! In a real deployment these functions run on the server. The proof is then +//! transmitted to the client, which verifies it in `client.rs` without needing +//! to contact the server again. + +use super::AkdDir; +use akd::{AkdLabel, EpochHash, LookupProof}; +use anyhow::Result; + +/// Bundles the server's response to a lookup request. +pub(super) struct LookupResponse { + /// The cryptographic proof that the queried label exists in the directory + /// with the value returned, at the epoch represented by `epoch_hash`. + /// + /// The proof contains two components: + /// 1. A VRF proof — shows the label's position in the Merkle tree was + /// derived correctly from the server's private key, preventing the + /// server from placing the same label at different positions for + /// different clients. + /// 2. A Merkle inclusion path — sibling hashes from the leaf up to the + /// root, proving the entry is committed at the tree root. + pub(super) proof: LookupProof, + + /// The epoch number and root hash the proof is anchored to. + /// Clients must obtain this value from a trusted third party + /// (transparency log, auditor) rather than from the server alone. + pub(super) epoch_hash: EpochHash, +} + +/// Requests a lookup proof from the directory server for `label`. +/// +/// This corresponds to the server-side half of a key-transparency query. +/// After calling this the server transmits `LookupResponse` to the client, +/// which verifies it independently using only the VRF public key and the +/// epoch hash obtained from a trusted source. +pub(super) async fn request_lookup(dir: &AkdDir, label: AkdLabel) -> Result { + let (proof, epoch_hash) = dir.lookup(label).await?; + Ok(LookupResponse { proof, epoch_hash }) +} diff --git a/examples/src/basic_lookup/setup.rs b/examples/src/basic_lookup/setup.rs new file mode 100644 index 00000000..17441707 --- /dev/null +++ b/examples/src/basic_lookup/setup.rs @@ -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. + +//! Directory initialisation and user-entry construction for the basic_lookup example. + +use super::AkdDir; +use akd::append_only_zks::AzksParallelismConfig; +use akd::ecvrf::HardCodedAkdVRF; +use akd::storage::memory::AsyncInMemoryDatabase; +use akd::storage::StorageManager; +use akd::{AkdLabel, AkdValue, EpochHash}; +use anyhow::Result; + +/// Fixed pool of example user accounts. In production, labels would be account +/// identifiers (phone numbers, usernames) and values would be raw public-key bytes +/// exported from a device's secure enclave or key-management system. +const USER_POOL: &[(&str, &str)] = &[ + ("alice@example.com", "alice_public_key_v1"), + ("bob@example.com", "bob_public_key_v1"), + ("carol@example.com", "carol_public_key_v1"), + ("dave@example.com", "dave_public_key_v1"), + ("erin@example.com", "erin_public_key_v1"), + ("frank@example.com", "frank_public_key_v1"), + ("grace@example.com", "grace_public_key_v1"), + ("heidi@example.com", "heidi_public_key_v1"), + ("ivan@example.com", "ivan_public_key_v1"), + ("judy@example.com", "judy_public_key_v1"), +]; + +/// Initialises an in-memory AKD directory with the WhatsApp v1 configuration. +/// +/// `AsyncInMemoryDatabase` stores all Merkle tree nodes in a process-local +/// hash map — sufficient for examples and tests. Replace with a durable +/// `Database` implementation for production (e.g. MySQL, RocksDB). +/// +/// `HardCodedAkdVRF` uses a private key compiled into the binary. **Never ship +/// this in production.** Implement `VRFKeyStorage` backed by a secrets manager +/// (AWS KMS, HashiCorp Vault, etc.) instead. +pub(super) async fn init_directory() -> Result { + let storage_manager = StorageManager::new_no_cache(AsyncInMemoryDatabase::new()); + let vrf = HardCodedAkdVRF {}; + let akd = AkdDir::new(storage_manager, vrf, AzksParallelismConfig::default()).await?; + Ok(akd) +} + +/// Returns the first `count` users from the pool as: +/// - a `Vec` of label strings (for validation and display) +/// - a `Vec<(AkdLabel, AkdValue)>` ready to pass to `Directory::publish` +pub(super) fn build_entries(count: u8) -> (Vec, Vec<(AkdLabel, AkdValue)>) { + let labels: Vec = USER_POOL + .iter() + .take(count as usize) + .map(|(label, _)| label.to_string()) + .collect(); + + let entries: Vec<(AkdLabel, AkdValue)> = USER_POOL + .iter() + .take(count as usize) + .map(|(label, value)| (AkdLabel::from(*label), AkdValue::from(*value))) + .collect(); + + (labels, entries) +} + +/// Publishes `entries` as a single atomic epoch and returns the resulting +/// `EpochHash`. All supplied pairs are committed together, so the returned +/// root hash is a commitment to the entire directory state at that moment. +pub(super) async fn publish_batch( + akd: &AkdDir, + entries: Vec<(AkdLabel, AkdValue)>, +) -> Result { + let epoch_hash = akd.publish(entries).await?; + Ok(epoch_hash) +} diff --git a/examples/src/basic_lookup/tests/mod.rs b/examples/src/basic_lookup/tests/mod.rs new file mode 100644 index 00000000..7c777acd --- /dev/null +++ b/examples/src/basic_lookup/tests/mod.rs @@ -0,0 +1,161 @@ +// 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. + +//! Tests for the basic_lookup example. +//! +//! `test_config!` generates two variants of each generic test — one for +//! `WhatsAppV1Configuration` and one for `ExperimentalConfiguration` — matching +//! the project-wide convention for configuration coverage. + +use akd::append_only_zks::AzksParallelismConfig; +use akd::directory::Directory; +use akd::ecvrf::HardCodedAkdVRF; +use akd::storage::memory::AsyncInMemoryDatabase; +use akd::storage::StorageManager; +use akd::NamedConfiguration; +use akd::{AkdLabel, AkdValue}; + +use crate::test_config; + +// ── Generic multi-configuration tests ──────────────────────────────────────── + +/// Verifies the full publish → lookup → verify cycle works correctly under both +/// supported configurations. +test_config!(test_publish_lookup_verify); +async fn test_publish_lookup_verify() { + let akd = Directory::::new( + StorageManager::new_no_cache(AsyncInMemoryDatabase::new()), + HardCodedAkdVRF {}, + AzksParallelismConfig::default(), + ) + .await + .expect("directory init failed"); + + // Publish two entries in a single epoch. + let entries = vec![ + ( + AkdLabel::from("alice@example.com"), + AkdValue::from("alice_public_key_v1"), + ), + ( + AkdLabel::from("bob@example.com"), + AkdValue::from("bob_public_key_v1"), + ), + ]; + let epoch_hash = akd.publish(entries).await.expect("publish failed"); + assert_eq!(epoch_hash.epoch(), 1); + + // Generate and verify a lookup proof for alice. + let label = AkdLabel::from("alice@example.com"); + let (proof, eh) = akd.lookup(label.clone()).await.expect("lookup failed"); + let pk = akd.get_public_key().await.expect("public key fetch failed"); + + let result = + akd::client::lookup_verify::(pk.as_bytes(), eh.hash(), eh.epoch(), label, proof) + .expect("verification failed"); + + assert_eq!(result.epoch, 1); + assert_eq!(result.version, 1); + assert_eq!(result.value, AkdValue::from("alice_public_key_v1")); +} + +/// Verifies that a lookup proof for a user added in a later epoch references +/// the correct epoch number and version. +test_config!(test_lookup_reflects_correct_epoch); +async fn test_lookup_reflects_correct_epoch() { + let akd = Directory::::new( + StorageManager::new_no_cache(AsyncInMemoryDatabase::new()), + HardCodedAkdVRF {}, + AzksParallelismConfig::default(), + ) + .await + .expect("directory init failed"); + + // Epoch 1: alice only. + akd.publish(vec![( + AkdLabel::from("alice@example.com"), + AkdValue::from("alice_key_v1"), + )]) + .await + .expect("epoch 1 publish failed"); + + // Epoch 2: bob joins. + akd.publish(vec![( + AkdLabel::from("bob@example.com"), + AkdValue::from("bob_key_v1"), + )]) + .await + .expect("epoch 2 publish failed"); + + // Bob's proof should reference epoch 2 (the epoch he was added in). + let label = AkdLabel::from("bob@example.com"); + let (proof, eh) = akd.lookup(label.clone()).await.expect("lookup failed"); + let pk = akd.get_public_key().await.expect("public key fetch failed"); + + let result = + akd::client::lookup_verify::(pk.as_bytes(), eh.hash(), eh.epoch(), label, proof) + .expect("verification failed"); + + assert_eq!(result.epoch, 2, "bob was added in epoch 2"); + assert_eq!(result.version, 1); + assert_eq!(result.value, AkdValue::from("bob_key_v1")); +} + +// ── Direct run() integration tests ─────────────────────────────────────────── + +/// Running with default arguments (alice, 5 users) must succeed. +#[tokio::test] +async fn test_run_default_args() { + super::run(super::Args { + label: "alice@example.com".to_string(), + users: 5, + }) + .await + .expect("run with default args failed"); +} + +/// Every label in the published pool must be individually verifiable. +#[tokio::test] +async fn test_run_each_label_in_pool() { + let labels = [ + "alice@example.com", + "bob@example.com", + "carol@example.com", + "dave@example.com", + "erin@example.com", + ]; + for label in labels { + super::run(super::Args { + label: label.to_string(), + users: 5, + }) + .await + .unwrap_or_else(|e| panic!("run failed for label '{label}': {e}")); + } +} + +/// Requesting a label outside the published pool must return an error. +#[tokio::test] +async fn test_run_rejects_unpublished_label() { + let result = super::run(super::Args { + label: "nobody@example.com".to_string(), + users: 3, + }) + .await; + assert!(result.is_err(), "expected error for unpublished label"); +} + +/// Publishing the maximum pool size (10 users) and looking up the last one. +#[tokio::test] +async fn test_run_max_pool() { + super::run(super::Args { + label: "judy@example.com".to_string(), + users: 10, + }) + .await + .expect("run with max pool failed"); +} diff --git a/examples/src/key_rotation/history.rs b/examples/src/key_rotation/history.rs new file mode 100644 index 00000000..dc859d6f --- /dev/null +++ b/examples/src/key_rotation/history.rs @@ -0,0 +1,99 @@ +// 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. + +//! History proof generation (server side) and verification (client side). +//! +//! `fetch_and_verify` ties the two halves together and enriches each verified +//! entry with the rotation reason stored in the corresponding `PublishedRotation`. + +use super::rotation::PublishedRotation; +use super::AkdDir; +use akd::verify::history::HistoryParams; +use akd::{AkdLabel, HistoryVerificationParams}; +use anyhow::Result; + +/// A fully verified history entry, enriched with the rotation reason from the +/// original publish record so the report can display it alongside the proof data. +pub(super) struct HistoryRecord { + /// Epoch in which this version was published. + pub(super) epoch: u64, + /// Monotonically increasing version counter for this label (1-based). + pub(super) version: u64, + /// The key name stored as AkdValue (human-readable in this example). + pub(super) key_name: String, + /// The business reason recorded at publish time. + pub(super) reason: String, +} + +/// Requests a `HistoryProof` from the server for `label`, verifies it on the +/// client side, and joins each result with the corresponding `PublishedRotation` +/// to produce enriched `HistoryRecord` entries. +/// +/// ## Server side +/// `Directory::key_history` with `HistoryParams::Complete` generates a proof +/// covering every epoch in which the label has a recorded value. The server +/// returns the proof alongside the current `EpochHash`. +/// +/// ## Client side +/// `akd::client::key_history_verify` checks: +/// - VRF proofs for every version (each label position was derived correctly). +/// - Merkle inclusion paths for every version up to the current root hash. +/// - Freshness / ordering invariants across versions. +/// +/// Results come back in **reverse-chronological order** (newest first). +/// We re-order them to chronological order before returning so the caller +/// can index them in the same direction as `published`. +pub(super) async fn fetch_and_verify( + dir: &AkdDir, + label: &AkdLabel, + published: &[PublishedRotation], +) -> Result> { + // ── Server side ────────────────────────────────────────────────────────── + let (history_proof, epoch_hash) = dir.key_history(label, HistoryParams::Complete).await?; + + // ── Client side ────────────────────────────────────────────────────────── + let public_key = dir.get_public_key().await?; + + // HistoryVerificationParams::default() pairs with HistoryParams::Complete. + // If HistoryParams::MostRecent(n) were used above, the params here must + // carry the same n via HistoryVerificationParams::Default { history_params }. + let results = akd::client::key_history_verify::( + public_key.as_bytes(), + epoch_hash.hash(), + epoch_hash.epoch(), + label.clone(), + history_proof, + HistoryVerificationParams::default(), + ) + .map_err(|e| anyhow::anyhow!("History proof verification failed: {e:?}"))?; + + // results[0] is the newest version; reverse so index 0 → oldest rotation. + let num = results.len(); + let records: Vec = results + .into_iter() + .rev() // now chronological order + .enumerate() + .map(|(i, r)| { + // published[i] corresponds to the (i+1)-th rotation (1-based). + // Both slices are now in chronological order, so indices align. + let reason = published + .get(i) + .map(|p| p.event.reason.to_string()) + .unwrap_or_else(|| "unknown".to_string()); + + HistoryRecord { + epoch: r.epoch, + version: r.version, + key_name: String::from_utf8_lossy(&r.value.0).to_string(), + reason, + } + }) + .collect(); + + assert_eq!(records.len(), num, "record count mismatch after reversal"); + Ok(records) +} diff --git a/examples/src/key_rotation/mod.rs b/examples/src/key_rotation/mod.rs new file mode 100644 index 00000000..ddec154a --- /dev/null +++ b/examples/src/key_rotation/mod.rs @@ -0,0 +1,111 @@ +// 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 multi-epoch key rotation and history proof verification. +//! +//! A key-transparency system must let clients audit the *complete history* of +//! public keys for any account — not just the current one. This example simulates +//! a user (alice) rotating her key multiple times for different reasons, then +//! requests a HistoryProof spanning all versions and verifies that the full +//! chronological chain matches what was originally published. +//! +//! Module layout: +//! rotation.rs — rotation event types, planning, and epoch publishing +//! history.rs — history proof request and client-side verification +//! report.rs — formatted output tables for the rotation log and history +//! +//! Run with: +//! cargo run -p examples -- key-rotation +//! cargo run -p examples -- key-rotation --rotations 6 + +mod history; +mod report; +mod rotation; + +#[cfg(test)] +mod tests; + +use akd::append_only_zks::AzksParallelismConfig; +use akd::ecvrf::HardCodedAkdVRF; +use akd::storage::memory::AsyncInMemoryDatabase; +use akd::storage::StorageManager; +use akd::AkdLabel; +use anyhow::Result; +use clap::Parser; + +/// Concrete directory type shared across this module's sub-files. +type AkdDir = + akd::directory::Directory; + +#[derive(Parser, Debug, Clone)] +#[clap( + author, + about = "Simulate key rotations across multiple epochs and verify the complete history proof" +)] +pub(crate) struct Args { + /// Number of key rotations to perform for alice (2–10). + /// Each rotation is published as a new epoch with a distinct reason + /// (device upgrade, security incident, scheduled rotation, account recovery). + #[arg(long, default_value_t = 4, value_parser = clap::value_parser!(u8).range(2..=10))] + rotations: u8, +} + +pub(crate) async fn run(args: Args) -> Result<()> { + let count = args.rotations as usize; + + // ── 1. Directory setup ─────────────────────────────────────────────────── + let akd = AkdDir::new( + StorageManager::new_no_cache(AsyncInMemoryDatabase::new()), + HardCodedAkdVRF {}, + AzksParallelismConfig::default(), + ) + .await?; + + // Other users are also registered in epoch 1 to populate the tree so that + // alice's proofs are non-trivial (i.e. the tree has more than one leaf). + akd.publish(vec![ + ( + AkdLabel::from("bob@example.com"), + akd::AkdValue::from("bob_key_v1"), + ), + ( + AkdLabel::from("carol@example.com"), + akd::AkdValue::from("carol_key_v1"), + ), + ]) + .await?; + + let alice = AkdLabel::from("alice@example.com"); + println!( + "Directory initialised. Simulating {} key rotations for alice.\n", + count + ); + + // ── 2. Plan and publish all rotations ──────────────────────────────────── + // Each rotation is modelled as a RotationEvent with a reason and a unique + // key name. Applying the event calls publish() to advance the directory + // to a new epoch. + let events = rotation::plan_rotations(count); + let mut published: Vec = Vec::with_capacity(count); + + for event in events { + let p = rotation::apply_rotation(&akd, &alice, event).await?; + published.push(p); + } + + report::print_rotation_log(&published); + + // ── 3. Request and verify the history proof ─────────────────────────────── + // The server generates a HistoryProof spanning all epochs in which alice's + // label has a recorded value. The client verifies every version in one shot. + let records = history::fetch_and_verify(&akd, &alice, &published).await?; + + report::print_history_table(&records); + report::print_summary(count, records.len()); + + Ok(()) +} diff --git a/examples/src/key_rotation/report.rs b/examples/src/key_rotation/report.rs new file mode 100644 index 00000000..857e6a2f --- /dev/null +++ b/examples/src/key_rotation/report.rs @@ -0,0 +1,64 @@ +// 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. + +//! Formatted output tables for the key_rotation example. + +use super::history::HistoryRecord; +use super::rotation::PublishedRotation; + +/// Prints the publish-time rotation log: what was committed to the directory +/// and in which epoch, in chronological order. +pub(super) fn print_rotation_log(published: &[PublishedRotation]) { + println!("\n── Rotation publish log ──────────────────────────────────────────"); + println!( + "{:<6} {:<8} {:<24} {:<28} {:<16}", + "Rot.", "Epoch", "Reason", "Key name", "Root hash (12 hex)" + ); + println!("{}", "─".repeat(72)); + for p in published { + println!( + "{:<6} {:<8} {:<24} {:<28} root: {}", + p.event.index, + p.epoch, + p.event.reason.to_string(), + p.event.key_name, + hex::encode(&p.root_hash[..6]), + ); + } + println!(); +} + +/// Prints the client-verified history table: the result of `key_history_verify`, +/// enriched with the rotation reason from the original publish record. +/// +/// Entries are displayed in chronological order (oldest first) so that readers +/// can trace the key lifecycle from registration through each rotation. +pub(super) fn print_history_table(records: &[HistoryRecord]) { + println!("── Verified key history (client-side proof check passed) ─────────"); + println!( + "{:<8} {:<10} {:<24} {:<24}", + "Epoch", "Version", "Reason", "Key name" + ); + println!("{}", "─".repeat(72)); + for r in records { + println!( + "{:<8} {:<10} {:<24} {}", + r.epoch, r.version, r.reason, r.key_name + ); + } + println!(); +} + +/// Prints a one-line summary confirming the counts match. +pub(super) fn print_summary(rotations_applied: usize, entries_verified: usize) { + println!( + "Summary: {} rotation(s) published, {} history entr{} verified.", + rotations_applied, + entries_verified, + if entries_verified == 1 { "y" } else { "ies" } + ); +} diff --git a/examples/src/key_rotation/rotation.rs b/examples/src/key_rotation/rotation.rs new file mode 100644 index 00000000..7e5334fd --- /dev/null +++ b/examples/src/key_rotation/rotation.rs @@ -0,0 +1,112 @@ +// 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. + +//! Rotation event types, planning logic, and epoch-level publishing. + +use super::AkdDir; +use akd::hash::Digest; +use akd::{AkdLabel, AkdValue, EpochHash}; +use anyhow::Result; +use std::fmt; + +/// The business reason behind a key rotation. +/// +/// In a real key-transparency system, clients that have cached a user's old +/// public key should be notified of the reason for the change so they can +/// decide whether to accept the new binding automatically or flag it for +/// manual review. +#[derive(Debug, Clone)] +pub(super) enum RotationReason { + /// The user got a new device and generated a fresh key pair on it. + DeviceUpgrade, + /// The user's device was lost, stolen, or suspected compromised. + SecurityIncident, + /// A periodic rotation mandated by the user's security policy. + ScheduledRotation, + /// The user lost access to their primary device and recovered via backup. + AccountRecovery, +} + +impl fmt::Display for RotationReason { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + RotationReason::DeviceUpgrade => write!(f, "Device upgrade"), + RotationReason::SecurityIncident => write!(f, "Security incident"), + RotationReason::ScheduledRotation => write!(f, "Scheduled rotation"), + RotationReason::AccountRecovery => write!(f, "Account recovery"), + } + } +} + +/// A single planned rotation, before it has been committed to the directory. +pub(super) struct RotationEvent { + /// 1-based index within the sequence of rotations for this user. + pub(super) index: usize, + pub(super) reason: RotationReason, + /// The key name that will be stored as the new AkdValue. + /// In production this would be raw public-key bytes; here we use a + /// human-readable string for clarity. + pub(super) key_name: String, +} + +/// A rotation that has been committed to the directory and assigned an epoch. +pub(super) struct PublishedRotation { + pub(super) event: RotationEvent, + pub(super) epoch: u64, + pub(super) root_hash: Digest, +} + +/// Cycles through the four `RotationReason` variants to assign a realistic +/// motive to each rotation event, then generates a unique key name for each. +pub(super) fn plan_rotations(count: usize) -> Vec { + let reasons = [ + RotationReason::DeviceUpgrade, + RotationReason::SecurityIncident, + RotationReason::ScheduledRotation, + RotationReason::AccountRecovery, + ]; + + (1..=count) + .map(|i| { + let reason = reasons[(i - 1) % reasons.len()].clone(); + let key_name = format!("alice_key_rotation_{i}"); + RotationEvent { + index: i, + reason, + key_name, + } + }) + .collect() +} + +/// Publishes a single rotation event as a new epoch. +/// +/// The label ("alice@example.com") stays constant; only the value changes. +/// Publishing the same label again increments its version counter in the tree — +/// the previous value is NOT deleted: it remains provable in history proofs. +pub(super) async fn apply_rotation( + dir: &AkdDir, + label: &AkdLabel, + event: RotationEvent, +) -> Result { + let value = AkdValue::from(event.key_name.as_str()); + let EpochHash(epoch, root_hash) = dir.publish(vec![(label.clone(), value)]).await?; + + println!( + " rotation {:>2} — epoch {:>2} — {:20} — key: \"{}\"", + event.index, + epoch, + event.reason.to_string(), + event.key_name + ); + + Ok(PublishedRotation { + event, + epoch, + root_hash, + }) +} diff --git a/examples/src/key_rotation/tests/mod.rs b/examples/src/key_rotation/tests/mod.rs new file mode 100644 index 00000000..aaad2000 --- /dev/null +++ b/examples/src/key_rotation/tests/mod.rs @@ -0,0 +1,173 @@ +// 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. + +//! Tests for the key_rotation example. + +use akd::append_only_zks::AzksParallelismConfig; +use akd::directory::Directory; +use akd::ecvrf::HardCodedAkdVRF; +use akd::storage::memory::AsyncInMemoryDatabase; +use akd::storage::StorageManager; +use akd::verify::history::HistoryParams; +use akd::NamedConfiguration; +use akd::{AkdLabel, AkdValue, HistoryVerificationParams}; + +use crate::test_config; + +// ── Generic multi-configuration tests ──────────────────────────────────────── + +/// Verifies that the history proof correctly captures all versions of a key +/// after multiple rotations, under both supported configurations. +test_config!(test_history_covers_all_rotations); +async fn test_history_covers_all_rotations() { + let akd = Directory::::new( + StorageManager::new_no_cache(AsyncInMemoryDatabase::new()), + HardCodedAkdVRF {}, + AzksParallelismConfig::default(), + ) + .await + .expect("directory init failed"); + + let alice = AkdLabel::from("alice@example.com"); + let num_rotations = 3usize; + + // Publish three successive key values for alice. + for i in 1..=num_rotations { + let value = AkdValue::from(format!("alice_key_v{i}").as_str()); + akd.publish(vec![(alice.clone(), value)]) + .await + .unwrap_or_else(|e| panic!("rotation {i} publish failed: {e}")); + } + + // The history proof must span all three epochs. + let (proof, epoch_hash) = akd + .key_history(&alice, HistoryParams::Complete) + .await + .expect("key_history failed"); + + let pk = akd.get_public_key().await.expect("public key fetch failed"); + let history = akd::client::key_history_verify::( + pk.as_bytes(), + epoch_hash.hash(), + epoch_hash.epoch(), + alice, + proof, + HistoryVerificationParams::default(), + ) + .expect("history verification failed"); + + assert_eq!( + history.len(), + num_rotations, + "expected one history entry per rotation" + ); + // Results are newest-first; index 0 is the most recent rotation. + assert_eq!(history[0].version, num_rotations as u64); + assert_eq!(history[0].value, AkdValue::from("alice_key_v3")); + assert_eq!(history[num_rotations - 1].version, 1); + assert_eq!( + history[num_rotations - 1].value, + AkdValue::from("alice_key_v1") + ); +} + +/// Verifies that MostRecent(1) history returns only the latest binding. +test_config!(test_most_recent_history_returns_one_entry); +async fn test_most_recent_history_returns_one_entry() { + let akd = Directory::::new( + StorageManager::new_no_cache(AsyncInMemoryDatabase::new()), + HardCodedAkdVRF {}, + AzksParallelismConfig::default(), + ) + .await + .expect("directory init failed"); + + let alice = AkdLabel::from("alice@example.com"); + + for i in 1..=4u32 { + akd.publish(vec![( + alice.clone(), + AkdValue::from(format!("key_v{i}").as_str()), + )]) + .await + .expect("publish failed"); + } + + let (proof, epoch_hash) = akd + .key_history(&alice, HistoryParams::MostRecent(1)) + .await + .expect("key_history failed"); + + let pk = akd.get_public_key().await.expect("public key fetch failed"); + let history = akd::client::key_history_verify::( + pk.as_bytes(), + epoch_hash.hash(), + epoch_hash.epoch(), + alice, + proof, + HistoryVerificationParams::Default { + history_params: HistoryParams::MostRecent(1), + }, + ) + .expect("history verification failed"); + + assert_eq!( + history.len(), + 1, + "MostRecent(1) must return exactly one entry" + ); + assert_eq!(history[0].version, 4, "must be the latest version"); + assert_eq!(history[0].value, AkdValue::from("key_v4")); +} + +// ── Direct run() integration tests ─────────────────────────────────────────── + +/// Minimum rotation count (2) must complete without error. +#[tokio::test] +async fn test_run_minimum_rotations() { + super::run(super::Args { rotations: 2 }) + .await + .expect("run with 2 rotations failed"); +} + +/// Default rotation count (4) must complete without error. +#[tokio::test] +async fn test_run_default_rotations() { + super::run(super::Args { rotations: 4 }) + .await + .expect("run with 4 rotations failed"); +} + +/// Maximum rotation count (10) must complete without error and produce a +/// history table with 10 entries. +#[tokio::test] +async fn test_run_maximum_rotations() { + super::run(super::Args { rotations: 10 }) + .await + .expect("run with 10 rotations failed"); +} + +/// Rotation reasons must cycle in the expected order across a full cycle of 4. +#[tokio::test] +async fn test_rotation_reasons_cycle() { + use super::rotation::{plan_rotations, RotationReason}; + + let events = plan_rotations(8); + assert_eq!(events.len(), 8); + + // First cycle (indices 0–3) + assert!(matches!(events[0].reason, RotationReason::DeviceUpgrade)); + assert!(matches!(events[1].reason, RotationReason::SecurityIncident)); + assert!(matches!( + events[2].reason, + RotationReason::ScheduledRotation + )); + assert!(matches!(events[3].reason, RotationReason::AccountRecovery)); + // Second cycle (indices 4–7) mirrors the first. + assert!(matches!(events[4].reason, RotationReason::DeviceUpgrade)); + assert!(matches!(events[7].reason, RotationReason::AccountRecovery)); +} diff --git a/examples/src/main.rs b/examples/src/main.rs index a8ea7b67..83069727 100644 --- a/examples/src/main.rs +++ b/examples/src/main.rs @@ -7,7 +7,10 @@ //! A set of example applications and utilities for AKD +mod append_only_audit; +mod basic_lookup; mod fixture_generator; +mod key_rotation; mod mysql_demo; mod test_vectors; mod wasm_client; @@ -27,6 +30,12 @@ pub struct Arguments { #[derive(Subcommand, Debug, Clone)] enum ExampleType { + /// Publish a set of users and verify a lookup proof for one of them + BasicLookup(basic_lookup::Args), + /// Simulate key rotations for a user and verify the full history proof + KeyRotation(key_rotation::Args), + /// Simulate directory growth across multiple epochs and verify append-only integrity + AppendOnlyAudit(append_only_audit::Args), /// WhatsApp Key Transparency Auditor WhatsappKtAuditor(whatsapp_kt_auditor::CliArgs), /// MySQL Demo @@ -43,6 +52,9 @@ async fn main() -> Result<()> { let args = Arguments::parse(); match args.example { + ExampleType::BasicLookup(args) => basic_lookup::run(args).await?, + ExampleType::KeyRotation(args) => key_rotation::run(args).await?, + ExampleType::AppendOnlyAudit(args) => append_only_audit::run(args).await?, ExampleType::WhatsappKtAuditor(args) => whatsapp_kt_auditor::render_cli(args).await?, ExampleType::MysqlDemo(args) => mysql_demo::render_cli(args).await?, ExampleType::FixtureGenerator(args) => fixture_generator::run(args).await,