init: cryptographic append-only ledger
This commit is contained in:
14
crates/ledger-core/Cargo.toml
Normal file
14
crates/ledger-core/Cargo.toml
Normal file
@@ -0,0 +1,14 @@
|
||||
[package]
|
||||
name = "ledger-core"
|
||||
version = "0.1.0"
|
||||
edition = "2024"
|
||||
|
||||
[dependencies]
|
||||
base64 = "0.22"
|
||||
blake3 = "1.6"
|
||||
ed25519-dalek = { version = "2.2", features = ["rand_core"] }
|
||||
rand_core = { version = "0.6", features = ["getrandom"] }
|
||||
serde = { version = "1.0", features = ["derive"] }
|
||||
serde_cbor = "0.11"
|
||||
serde_json = "1.0"
|
||||
thiserror = "2.0"
|
||||
212
crates/ledger-core/src/attestation.rs
Normal file
212
crates/ledger-core/src/attestation.rs
Normal file
@@ -0,0 +1,212 @@
|
||||
use ed25519_dalek::{Signature, Verifier, VerifyingKey};
|
||||
use serde::{Deserialize, Deserializer, Serialize, Serializer};
|
||||
|
||||
use crate::entry::EntryHash;
|
||||
use crate::merkle::MerkleRoot;
|
||||
use crate::storage::LedgerError;
|
||||
|
||||
pub const CHECKPOINT_ATTESTATION_V0_FORMAT: &str = "civ-ledger-checkpoint-attest-v0";
|
||||
pub const CHECKPOINT_ATTESTATION_V0_DOMAIN: &[u8] = b"CIV_LEDGER_CHECKPOINT_ATTEST_V0";
|
||||
|
||||
pub const CHECKPOINT_ATTESTATION_V1_FORMAT: &str = "civ-ledger-checkpoint-attest-v1";
|
||||
pub const CHECKPOINT_ATTESTATION_V1_DOMAIN: &[u8] = b"CIV_LEDGER_CHECKPOINT_ATTEST_V1";
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct CheckpointAttestationV0 {
|
||||
pub format: String,
|
||||
#[serde(with = "serde_hex32")]
|
||||
pub ledger_genesis_hash_hex: EntryHash,
|
||||
pub checkpoint_entry_count: u64,
|
||||
#[serde(with = "serde_hex32")]
|
||||
pub checkpoint_merkle_root_hex: MerkleRoot,
|
||||
#[serde(with = "serde_hex32")]
|
||||
pub checkpoint_head_hash_hex: EntryHash,
|
||||
pub ts_seen_ms: u64,
|
||||
#[serde(with = "serde_hex32")]
|
||||
pub witness_pubkey_hex: [u8; 32],
|
||||
#[serde(with = "serde_hex64")]
|
||||
pub witness_sig_hex: [u8; 64],
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct CheckpointAttestationV1 {
|
||||
pub format: String,
|
||||
#[serde(with = "serde_hex32")]
|
||||
pub ledger_genesis_hash_hex: EntryHash,
|
||||
pub checkpoint_entry_count: u64,
|
||||
#[serde(with = "serde_hex32")]
|
||||
pub checkpoint_merkle_root_hex: MerkleRoot,
|
||||
#[serde(with = "serde_hex32")]
|
||||
pub checkpoint_head_hash_hex: EntryHash,
|
||||
pub checkpoint_ts_ms: u64,
|
||||
pub ts_seen_ms: u64,
|
||||
#[serde(with = "serde_hex32")]
|
||||
pub witness_pubkey_hex: [u8; 32],
|
||||
#[serde(with = "serde_hex64")]
|
||||
pub witness_sig_hex: [u8; 64],
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(untagged)]
|
||||
pub enum CheckpointAttestation {
|
||||
V1(CheckpointAttestationV1),
|
||||
V0(CheckpointAttestationV0),
|
||||
}
|
||||
|
||||
pub fn verify_checkpoint_attestation(att: &CheckpointAttestation) -> Result<(), LedgerError> {
|
||||
match att {
|
||||
CheckpointAttestation::V0(a) => verify_checkpoint_attestation_v0(a),
|
||||
CheckpointAttestation::V1(a) => verify_checkpoint_attestation_v1(a),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn verify_checkpoint_attestation_v0(att: &CheckpointAttestationV0) -> Result<(), LedgerError> {
|
||||
if att.format != CHECKPOINT_ATTESTATION_V0_FORMAT {
|
||||
return Err(LedgerError::InvalidLedger(format!(
|
||||
"unsupported attestation format: {}",
|
||||
att.format
|
||||
)));
|
||||
}
|
||||
|
||||
let vk = VerifyingKey::from_bytes(&att.witness_pubkey_hex)
|
||||
.map_err(|e| LedgerError::InvalidLedger(format!("bad witness_pubkey: {}", e)))?;
|
||||
let sig = Signature::from_bytes(&att.witness_sig_hex);
|
||||
let msg = checkpoint_attestation_signing_message_v0(
|
||||
&att.ledger_genesis_hash_hex,
|
||||
att.checkpoint_entry_count,
|
||||
&att.checkpoint_merkle_root_hex,
|
||||
&att.checkpoint_head_hash_hex,
|
||||
att.ts_seen_ms,
|
||||
);
|
||||
vk.verify(&msg, &sig)
|
||||
.map_err(|e| LedgerError::InvalidLedger(format!("attestation signature invalid: {}", e)))?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn verify_checkpoint_attestation_v1(att: &CheckpointAttestationV1) -> Result<(), LedgerError> {
|
||||
if att.format != CHECKPOINT_ATTESTATION_V1_FORMAT {
|
||||
return Err(LedgerError::InvalidLedger(format!(
|
||||
"unsupported attestation format: {}",
|
||||
att.format
|
||||
)));
|
||||
}
|
||||
|
||||
if att.ts_seen_ms < att.checkpoint_ts_ms {
|
||||
return Err(LedgerError::InvalidLedger(
|
||||
"attestation ts_seen_ms is earlier than checkpoint_ts_ms".into(),
|
||||
));
|
||||
}
|
||||
|
||||
let vk = VerifyingKey::from_bytes(&att.witness_pubkey_hex)
|
||||
.map_err(|e| LedgerError::InvalidLedger(format!("bad witness_pubkey: {}", e)))?;
|
||||
let sig = Signature::from_bytes(&att.witness_sig_hex);
|
||||
let msg = checkpoint_attestation_signing_message_v1(
|
||||
&att.ledger_genesis_hash_hex,
|
||||
att.checkpoint_entry_count,
|
||||
&att.checkpoint_merkle_root_hex,
|
||||
&att.checkpoint_head_hash_hex,
|
||||
att.checkpoint_ts_ms,
|
||||
att.ts_seen_ms,
|
||||
);
|
||||
vk.verify(&msg, &sig)
|
||||
.map_err(|e| LedgerError::InvalidLedger(format!("attestation signature invalid: {}", e)))?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn checkpoint_attestation_signing_message_v0(
|
||||
ledger_genesis_hash: &EntryHash,
|
||||
checkpoint_entry_count: u64,
|
||||
checkpoint_merkle_root: &MerkleRoot,
|
||||
checkpoint_head_hash: &EntryHash,
|
||||
ts_seen_ms: u64,
|
||||
) -> Vec<u8> {
|
||||
let mut msg = Vec::with_capacity(CHECKPOINT_ATTESTATION_V0_DOMAIN.len() + 32 + 8 + 32 + 32 + 8);
|
||||
msg.extend_from_slice(CHECKPOINT_ATTESTATION_V0_DOMAIN);
|
||||
msg.extend_from_slice(ledger_genesis_hash);
|
||||
msg.extend_from_slice(&checkpoint_entry_count.to_le_bytes());
|
||||
msg.extend_from_slice(checkpoint_merkle_root);
|
||||
msg.extend_from_slice(checkpoint_head_hash);
|
||||
msg.extend_from_slice(&ts_seen_ms.to_le_bytes());
|
||||
msg
|
||||
}
|
||||
|
||||
pub fn checkpoint_attestation_signing_message_v1(
|
||||
ledger_genesis_hash: &EntryHash,
|
||||
checkpoint_entry_count: u64,
|
||||
checkpoint_merkle_root: &MerkleRoot,
|
||||
checkpoint_head_hash: &EntryHash,
|
||||
checkpoint_ts_ms: u64,
|
||||
ts_seen_ms: u64,
|
||||
) -> Vec<u8> {
|
||||
let mut msg =
|
||||
Vec::with_capacity(CHECKPOINT_ATTESTATION_V1_DOMAIN.len() + 32 + 8 + 32 + 32 + 8 + 8);
|
||||
msg.extend_from_slice(CHECKPOINT_ATTESTATION_V1_DOMAIN);
|
||||
msg.extend_from_slice(ledger_genesis_hash);
|
||||
msg.extend_from_slice(&checkpoint_entry_count.to_le_bytes());
|
||||
msg.extend_from_slice(checkpoint_merkle_root);
|
||||
msg.extend_from_slice(checkpoint_head_hash);
|
||||
msg.extend_from_slice(&checkpoint_ts_ms.to_le_bytes());
|
||||
msg.extend_from_slice(&ts_seen_ms.to_le_bytes());
|
||||
msg
|
||||
}
|
||||
|
||||
mod serde_hex32 {
|
||||
use super::*;
|
||||
|
||||
pub fn serialize<S>(bytes: &[u8; 32], serializer: S) -> Result<S::Ok, S::Error>
|
||||
where
|
||||
S: Serializer,
|
||||
{
|
||||
serializer.serialize_str(&hex_encode(bytes))
|
||||
}
|
||||
|
||||
pub fn deserialize<'de, D>(deserializer: D) -> Result<[u8; 32], D::Error>
|
||||
where
|
||||
D: Deserializer<'de>,
|
||||
{
|
||||
let s: String = Deserialize::deserialize(deserializer)?;
|
||||
hex_decode_fixed::<32>(&s).map_err(serde::de::Error::custom)
|
||||
}
|
||||
}
|
||||
|
||||
mod serde_hex64 {
|
||||
use super::*;
|
||||
|
||||
pub fn serialize<S>(bytes: &[u8; 64], serializer: S) -> Result<S::Ok, S::Error>
|
||||
where
|
||||
S: Serializer,
|
||||
{
|
||||
serializer.serialize_str(&hex_encode(bytes))
|
||||
}
|
||||
|
||||
pub fn deserialize<'de, D>(deserializer: D) -> Result<[u8; 64], D::Error>
|
||||
where
|
||||
D: Deserializer<'de>,
|
||||
{
|
||||
let s: String = Deserialize::deserialize(deserializer)?;
|
||||
hex_decode_fixed::<64>(&s).map_err(serde::de::Error::custom)
|
||||
}
|
||||
}
|
||||
|
||||
fn hex_encode(bytes: &[u8]) -> String {
|
||||
bytes.iter().map(|b| format!("{:02x}", b)).collect()
|
||||
}
|
||||
|
||||
fn hex_decode_fixed<const N: usize>(s: &str) -> Result<[u8; N], String> {
|
||||
let s = s.trim();
|
||||
let s = s.strip_prefix("0x").unwrap_or(s);
|
||||
if s.len() != N * 2 {
|
||||
return Err(format!(
|
||||
"hex value must be {} bytes ({} hex chars), got {} chars",
|
||||
N,
|
||||
N * 2,
|
||||
s.len()
|
||||
));
|
||||
}
|
||||
let mut out = [0u8; N];
|
||||
for i in 0..N {
|
||||
let idx = i * 2;
|
||||
out[i] = u8::from_str_radix(&s[idx..idx + 2], 16).map_err(|_| "invalid hex".to_string())?;
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
141
crates/ledger-core/src/entry.rs
Normal file
141
crates/ledger-core/src/entry.rs
Normal file
@@ -0,0 +1,141 @@
|
||||
use blake3::Hash;
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
pub type EntryHash = [u8; 32];
|
||||
pub type SignatureBytes = [u8; 64];
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct EntryUnsigned {
|
||||
pub prev_hash: EntryHash,
|
||||
pub ts_ms: u64,
|
||||
pub namespace: String,
|
||||
pub payload_cbor: Vec<u8>,
|
||||
pub author_pubkey: [u8; 32],
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct Entry {
|
||||
pub prev_hash: EntryHash,
|
||||
pub ts_ms: u64,
|
||||
pub namespace: String,
|
||||
pub payload_cbor: Vec<u8>,
|
||||
pub author_pubkey: [u8; 32],
|
||||
#[serde(with = "serde_sig64")]
|
||||
pub sig: SignatureBytes,
|
||||
}
|
||||
|
||||
impl EntryUnsigned {
|
||||
pub fn payload_hash(&self) -> EntryHash {
|
||||
blake3::hash(&self.payload_cbor).into()
|
||||
}
|
||||
|
||||
pub fn signing_message(&self) -> Vec<u8> {
|
||||
let mut msg = Vec::with_capacity(
|
||||
4 + self.prev_hash.len() + 8 + 4 + self.namespace.len() + 32 + self.author_pubkey.len(),
|
||||
);
|
||||
msg.extend_from_slice(b"CLv0");
|
||||
msg.extend_from_slice(&self.prev_hash);
|
||||
msg.extend_from_slice(&self.ts_ms.to_le_bytes());
|
||||
let ns = self.namespace.as_bytes();
|
||||
msg.extend_from_slice(&(ns.len() as u32).to_le_bytes());
|
||||
msg.extend_from_slice(ns);
|
||||
msg.extend_from_slice(&self.payload_hash());
|
||||
msg.extend_from_slice(&self.author_pubkey);
|
||||
msg
|
||||
}
|
||||
|
||||
pub fn to_entry(self, sig: SignatureBytes) -> Entry {
|
||||
Entry {
|
||||
prev_hash: self.prev_hash,
|
||||
ts_ms: self.ts_ms,
|
||||
namespace: self.namespace,
|
||||
payload_cbor: self.payload_cbor,
|
||||
author_pubkey: self.author_pubkey,
|
||||
sig,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Entry {
|
||||
pub fn unsigned(&self) -> EntryUnsigned {
|
||||
EntryUnsigned {
|
||||
prev_hash: self.prev_hash,
|
||||
ts_ms: self.ts_ms,
|
||||
namespace: self.namespace.clone(),
|
||||
payload_cbor: self.payload_cbor.clone(),
|
||||
author_pubkey: self.author_pubkey,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn payload_hash(&self) -> EntryHash {
|
||||
blake3::hash(&self.payload_cbor).into()
|
||||
}
|
||||
|
||||
pub fn hash(&self) -> EntryHash {
|
||||
let mut hasher = blake3::Hasher::new();
|
||||
hasher.update(b"CL-entry-v0");
|
||||
hasher.update(&self.prev_hash);
|
||||
hasher.update(&self.ts_ms.to_le_bytes());
|
||||
hasher.update(&(self.namespace.as_bytes().len() as u32).to_le_bytes());
|
||||
hasher.update(self.namespace.as_bytes());
|
||||
hasher.update(&self.payload_hash());
|
||||
hasher.update(&self.author_pubkey);
|
||||
hasher.update(&(self.sig.len() as u32).to_le_bytes());
|
||||
hasher.update(&self.sig);
|
||||
finalize_32(hasher.finalize())
|
||||
}
|
||||
}
|
||||
|
||||
fn finalize_32(hash: Hash) -> [u8; 32] {
|
||||
*hash.as_bytes()
|
||||
}
|
||||
|
||||
mod serde_sig64 {
|
||||
use std::fmt;
|
||||
|
||||
use serde::Serializer;
|
||||
use serde::de::{self, Deserializer, Visitor};
|
||||
|
||||
pub fn serialize<S>(sig: &[u8; 64], serializer: S) -> Result<S::Ok, S::Error>
|
||||
where
|
||||
S: Serializer,
|
||||
{
|
||||
serializer.serialize_bytes(sig)
|
||||
}
|
||||
|
||||
pub fn deserialize<'de, D>(deserializer: D) -> Result<[u8; 64], D::Error>
|
||||
where
|
||||
D: Deserializer<'de>,
|
||||
{
|
||||
struct SigVisitor;
|
||||
|
||||
impl<'de> Visitor<'de> for SigVisitor {
|
||||
type Value = [u8; 64];
|
||||
|
||||
fn expecting(&self, formatter: &mut fmt::Formatter) -> fmt::Result {
|
||||
formatter.write_str("a 64-byte signature")
|
||||
}
|
||||
|
||||
fn visit_bytes<E>(self, v: &[u8]) -> Result<Self::Value, E>
|
||||
where
|
||||
E: de::Error,
|
||||
{
|
||||
if v.len() != 64 {
|
||||
return Err(E::custom(format!("sig must be 64 bytes, got {}", v.len())));
|
||||
}
|
||||
let mut sig = [0u8; 64];
|
||||
sig.copy_from_slice(v);
|
||||
Ok(sig)
|
||||
}
|
||||
|
||||
fn visit_byte_buf<E>(self, v: Vec<u8>) -> Result<Self::Value, E>
|
||||
where
|
||||
E: de::Error,
|
||||
{
|
||||
self.visit_bytes(&v)
|
||||
}
|
||||
}
|
||||
|
||||
deserializer.deserialize_bytes(SigVisitor)
|
||||
}
|
||||
}
|
||||
40
crates/ledger-core/src/identity.rs
Normal file
40
crates/ledger-core/src/identity.rs
Normal file
@@ -0,0 +1,40 @@
|
||||
use base64::Engine as _;
|
||||
use ed25519_dalek::{SigningKey, VerifyingKey};
|
||||
use rand_core::OsRng;
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::storage::LedgerError;
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct KeyFile {
|
||||
pub seed_b64: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct KeyFilePublic {
|
||||
pub public_hex: String,
|
||||
}
|
||||
|
||||
impl KeyFile {
|
||||
pub fn generate() -> Self {
|
||||
let signing_key = SigningKey::generate(&mut OsRng);
|
||||
let seed: [u8; 32] = signing_key.to_bytes();
|
||||
Self {
|
||||
seed_b64: base64::engine::general_purpose::STANDARD_NO_PAD.encode(seed),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn signing_key(&self) -> Result<SigningKey, LedgerError> {
|
||||
let bytes = base64::engine::general_purpose::STANDARD_NO_PAD
|
||||
.decode(&self.seed_b64)
|
||||
.map_err(|_| LedgerError::InvalidKeyFile("seed_b64 is not valid base64".into()))?;
|
||||
let seed: [u8; 32] = bytes
|
||||
.try_into()
|
||||
.map_err(|_| LedgerError::InvalidKeyFile("seed_b64 must decode to 32 bytes".into()))?;
|
||||
Ok(SigningKey::from_bytes(&seed))
|
||||
}
|
||||
|
||||
pub fn verifying_key(&self) -> Result<VerifyingKey, LedgerError> {
|
||||
Ok(self.signing_key()?.verifying_key())
|
||||
}
|
||||
}
|
||||
21
crates/ledger-core/src/lib.rs
Normal file
21
crates/ledger-core/src/lib.rs
Normal file
@@ -0,0 +1,21 @@
|
||||
pub mod attestation;
|
||||
pub mod entry;
|
||||
pub mod identity;
|
||||
pub mod merkle;
|
||||
pub mod proof;
|
||||
pub mod receipt;
|
||||
pub mod storage;
|
||||
pub mod verify;
|
||||
|
||||
pub use attestation::{
|
||||
CHECKPOINT_ATTESTATION_V0_FORMAT, CHECKPOINT_ATTESTATION_V1_FORMAT, CheckpointAttestation,
|
||||
CheckpointAttestationV0, CheckpointAttestationV1, verify_checkpoint_attestation,
|
||||
verify_checkpoint_attestation_v0, verify_checkpoint_attestation_v1,
|
||||
};
|
||||
pub use entry::{Entry, EntryHash, EntryUnsigned};
|
||||
pub use identity::{KeyFile, KeyFilePublic};
|
||||
pub use merkle::{MerkleRoot, merkle_root};
|
||||
pub use proof::{ReadProofV0, verify_read_proof_v0};
|
||||
pub use receipt::{RECEIPT_V0_FORMAT, ReceiptV0, verify_receipt_v0};
|
||||
pub use storage::{Checkpoint, LedgerDir};
|
||||
pub use verify::{AuditReport, verify_ledger_dir};
|
||||
112
crates/ledger-core/src/merkle.rs
Normal file
112
crates/ledger-core/src/merkle.rs
Normal file
@@ -0,0 +1,112 @@
|
||||
use blake3::Hash;
|
||||
|
||||
pub type MerkleRoot = [u8; 32];
|
||||
|
||||
pub fn merkle_root(leaves: &[MerkleRoot]) -> MerkleRoot {
|
||||
if leaves.is_empty() {
|
||||
return blake3::hash(b"CL-merkle-empty-v0").into();
|
||||
}
|
||||
|
||||
let mut level: Vec<MerkleRoot> = leaves.iter().map(|h| leaf_hash(h)).collect();
|
||||
while level.len() > 1 {
|
||||
let mut next = Vec::with_capacity((level.len() + 1) / 2);
|
||||
let mut i = 0;
|
||||
while i < level.len() {
|
||||
let left = level[i];
|
||||
let right = if i + 1 < level.len() {
|
||||
level[i + 1]
|
||||
} else {
|
||||
level[i]
|
||||
};
|
||||
next.push(node_hash(&left, &right));
|
||||
i += 2;
|
||||
}
|
||||
level = next;
|
||||
}
|
||||
level[0]
|
||||
}
|
||||
|
||||
pub(crate) fn leaf_hash(leaf: &MerkleRoot) -> MerkleRoot {
|
||||
let mut hasher = blake3::Hasher::new();
|
||||
hasher.update(b"CL-merkle-leaf-v0");
|
||||
hasher.update(leaf);
|
||||
finalize_32(hasher.finalize())
|
||||
}
|
||||
|
||||
pub(crate) fn node_hash(left: &MerkleRoot, right: &MerkleRoot) -> MerkleRoot {
|
||||
let mut hasher = blake3::Hasher::new();
|
||||
hasher.update(b"CL-merkle-node-v0");
|
||||
hasher.update(left);
|
||||
hasher.update(right);
|
||||
finalize_32(hasher.finalize())
|
||||
}
|
||||
|
||||
fn finalize_32(hash: Hash) -> [u8; 32] {
|
||||
*hash.as_bytes()
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum MerkleSide {
|
||||
Left,
|
||||
Right,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct MerklePathItem {
|
||||
pub sibling: MerkleRoot,
|
||||
pub sibling_side: MerkleSide,
|
||||
}
|
||||
|
||||
pub fn merkle_inclusion_path(
|
||||
leaves: &[MerkleRoot],
|
||||
index: usize,
|
||||
) -> Result<(MerkleRoot, Vec<MerklePathItem>), String> {
|
||||
if index >= leaves.len() {
|
||||
return Err("index out of range".into());
|
||||
}
|
||||
|
||||
if leaves.is_empty() {
|
||||
return Err("cannot build proof for empty tree".into());
|
||||
}
|
||||
|
||||
let mut path = Vec::new();
|
||||
|
||||
let mut idx = index;
|
||||
let mut level: Vec<MerkleRoot> = leaves.iter().map(|h| leaf_hash(h)).collect();
|
||||
while level.len() > 1 {
|
||||
let is_left = idx % 2 == 0;
|
||||
let sibling_index = if is_left {
|
||||
if idx + 1 < level.len() { idx + 1 } else { idx }
|
||||
} else {
|
||||
idx - 1
|
||||
};
|
||||
|
||||
let sibling_side = if is_left {
|
||||
MerkleSide::Right
|
||||
} else {
|
||||
MerkleSide::Left
|
||||
};
|
||||
path.push(MerklePathItem {
|
||||
sibling: level[sibling_index],
|
||||
sibling_side,
|
||||
});
|
||||
|
||||
let mut next = Vec::with_capacity((level.len() + 1) / 2);
|
||||
let mut i = 0;
|
||||
while i < level.len() {
|
||||
let left = level[i];
|
||||
let right = if i + 1 < level.len() {
|
||||
level[i + 1]
|
||||
} else {
|
||||
level[i]
|
||||
};
|
||||
next.push(node_hash(&left, &right));
|
||||
i += 2;
|
||||
}
|
||||
|
||||
idx /= 2;
|
||||
level = next;
|
||||
}
|
||||
|
||||
Ok((level[0], path))
|
||||
}
|
||||
146
crates/ledger-core/src/proof.rs
Normal file
146
crates/ledger-core/src/proof.rs
Normal file
@@ -0,0 +1,146 @@
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::entry::EntryHash;
|
||||
use crate::merkle::{
|
||||
MerklePathItem, MerkleRoot, MerkleSide, leaf_hash, merkle_inclusion_path, node_hash,
|
||||
};
|
||||
use crate::storage::LedgerError;
|
||||
|
||||
pub const READ_PROOF_V0_FORMAT: &str = "civ-ledger-readproof-v0";
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct ReadProofV0 {
|
||||
pub format: String,
|
||||
pub entry_hash_hex: String,
|
||||
pub entry_index: u64,
|
||||
pub entry_count: u64,
|
||||
pub checkpoint_merkle_root_hex: String,
|
||||
pub path: Vec<PathStepV0>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct PathStepV0 {
|
||||
pub sibling_side: SideV0,
|
||||
pub sibling_hash_hex: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "lowercase")]
|
||||
pub enum SideV0 {
|
||||
Left,
|
||||
Right,
|
||||
}
|
||||
|
||||
impl ReadProofV0 {
|
||||
pub fn from_tree(leaves: &[EntryHash], index: usize) -> Result<Self, LedgerError> {
|
||||
let (root, path) = merkle_inclusion_path(leaves, index)
|
||||
.map_err(|e| LedgerError::InvalidLedger(format!("cannot build merkle proof: {}", e)))?;
|
||||
Ok(Self::new(leaves, index, root, path))
|
||||
}
|
||||
|
||||
fn new(
|
||||
leaves: &[EntryHash],
|
||||
index: usize,
|
||||
root: MerkleRoot,
|
||||
path: Vec<MerklePathItem>,
|
||||
) -> Self {
|
||||
Self {
|
||||
format: READ_PROOF_V0_FORMAT.to_string(),
|
||||
entry_hash_hex: hex_encode(&leaves[index]),
|
||||
entry_index: index as u64,
|
||||
entry_count: leaves.len() as u64,
|
||||
checkpoint_merkle_root_hex: hex_encode(&root),
|
||||
path: path.into_iter().map(step_from_merkle).collect(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn verify_read_proof_v0(proof: &ReadProofV0) -> Result<(), LedgerError> {
|
||||
if proof.format != READ_PROOF_V0_FORMAT {
|
||||
return Err(LedgerError::InvalidLedger(format!(
|
||||
"unsupported proof format: {}",
|
||||
proof.format
|
||||
)));
|
||||
}
|
||||
|
||||
if proof.entry_count == 0 {
|
||||
return Err(LedgerError::InvalidLedger("entry_count must be > 0".into()));
|
||||
}
|
||||
|
||||
if proof.entry_index >= proof.entry_count {
|
||||
return Err(LedgerError::InvalidLedger(
|
||||
"entry_index out of range".into(),
|
||||
));
|
||||
}
|
||||
|
||||
let expected_path_len = expected_merkle_path_len(proof.entry_count);
|
||||
if proof.path.len() != expected_path_len {
|
||||
return Err(LedgerError::InvalidLedger(format!(
|
||||
"unexpected merkle path length: expected {}, got {}",
|
||||
expected_path_len,
|
||||
proof.path.len()
|
||||
)));
|
||||
}
|
||||
|
||||
let entry_hash: [u8; 32] = hex_decode_fixed(&proof.entry_hash_hex)?;
|
||||
let want_root: [u8; 32] = hex_decode_fixed(&proof.checkpoint_merkle_root_hex)?;
|
||||
|
||||
let mut current = leaf_hash(&entry_hash);
|
||||
for step in &proof.path {
|
||||
let sibling: [u8; 32] = hex_decode_fixed(&step.sibling_hash_hex)?;
|
||||
current = match step.sibling_side {
|
||||
SideV0::Left => node_hash(&sibling, ¤t),
|
||||
SideV0::Right => node_hash(¤t, &sibling),
|
||||
};
|
||||
}
|
||||
|
||||
if current != want_root {
|
||||
return Err(LedgerError::InvalidLedger(
|
||||
"merkle proof does not match checkpoint root".into(),
|
||||
));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn step_from_merkle(item: MerklePathItem) -> PathStepV0 {
|
||||
let sibling_side = match item.sibling_side {
|
||||
MerkleSide::Left => SideV0::Left,
|
||||
MerkleSide::Right => SideV0::Right,
|
||||
};
|
||||
PathStepV0 {
|
||||
sibling_side,
|
||||
sibling_hash_hex: hex_encode(&item.sibling),
|
||||
}
|
||||
}
|
||||
|
||||
fn expected_merkle_path_len(mut leaf_count: u64) -> usize {
|
||||
let mut depth = 0usize;
|
||||
while leaf_count > 1 {
|
||||
depth += 1;
|
||||
leaf_count = (leaf_count + 1) / 2;
|
||||
}
|
||||
depth
|
||||
}
|
||||
|
||||
fn hex_encode(bytes: &[u8]) -> String {
|
||||
bytes.iter().map(|b| format!("{:02x}", b)).collect()
|
||||
}
|
||||
|
||||
fn hex_decode_fixed<const N: usize>(s: &str) -> Result<[u8; N], LedgerError> {
|
||||
let s = s.trim();
|
||||
if s.len() != N * 2 {
|
||||
return Err(LedgerError::InvalidLedger(format!(
|
||||
"hex value must be {} bytes ({} hex chars), got {} chars",
|
||||
N,
|
||||
N * 2,
|
||||
s.len()
|
||||
)));
|
||||
}
|
||||
let mut out = [0u8; N];
|
||||
for i in 0..N {
|
||||
let idx = i * 2;
|
||||
out[i] = u8::from_str_radix(&s[idx..idx + 2], 16)
|
||||
.map_err(|_| LedgerError::InvalidLedger("invalid hex".into()))?;
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
127
crates/ledger-core/src/receipt.rs
Normal file
127
crates/ledger-core/src/receipt.rs
Normal file
@@ -0,0 +1,127 @@
|
||||
use base64::Engine as _;
|
||||
use ed25519_dalek::{Signature, Verifier, VerifyingKey};
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::attestation::{CheckpointAttestation, verify_checkpoint_attestation};
|
||||
use crate::entry::Entry;
|
||||
use crate::proof::{ReadProofV0, verify_read_proof_v0};
|
||||
use crate::storage::LedgerError;
|
||||
|
||||
pub const RECEIPT_V0_FORMAT: &str = "civ-ledger-receipt-v0";
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct ReceiptV0 {
|
||||
pub format: String,
|
||||
pub entry_cbor_b64: String,
|
||||
pub entry_hash_hex: String,
|
||||
pub read_proof: ReadProofV0,
|
||||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||
pub attestations: Vec<CheckpointAttestation>,
|
||||
}
|
||||
|
||||
pub fn verify_receipt_v0(
|
||||
receipt: &ReceiptV0,
|
||||
require_attestation: bool,
|
||||
) -> Result<(), LedgerError> {
|
||||
if receipt.format != RECEIPT_V0_FORMAT {
|
||||
return Err(LedgerError::InvalidLedger(format!(
|
||||
"unsupported receipt format: {}",
|
||||
receipt.format
|
||||
)));
|
||||
}
|
||||
|
||||
let entry_bytes = base64::engine::general_purpose::STANDARD_NO_PAD
|
||||
.decode(&receipt.entry_cbor_b64)
|
||||
.map_err(|_| {
|
||||
LedgerError::InvalidLedger("receipt entry_cbor_b64 is not valid base64".into())
|
||||
})?;
|
||||
let entry: Entry = serde_cbor::from_slice(&entry_bytes)
|
||||
.map_err(|e| LedgerError::Cbor(format!("receipt entry cbor decode: {}", e)))?;
|
||||
|
||||
verify_entry_sig(&entry)?;
|
||||
let entry_hash = entry.hash();
|
||||
|
||||
let receipt_hash = hex_decode_fixed::<32>(&receipt.entry_hash_hex)?;
|
||||
if receipt_hash != entry_hash {
|
||||
return Err(LedgerError::InvalidLedger(
|
||||
"receipt entry_hash_hex mismatch".into(),
|
||||
));
|
||||
}
|
||||
|
||||
if hex_decode_fixed::<32>(&receipt.read_proof.entry_hash_hex)? != entry_hash {
|
||||
return Err(LedgerError::InvalidLedger(
|
||||
"receipt read_proof.entry_hash_hex mismatch".into(),
|
||||
));
|
||||
}
|
||||
|
||||
verify_read_proof_v0(&receipt.read_proof)?;
|
||||
|
||||
if require_attestation && receipt.attestations.is_empty() {
|
||||
return Err(LedgerError::InvalidLedger(
|
||||
"receipt missing witness attestations".into(),
|
||||
));
|
||||
}
|
||||
|
||||
if !receipt.attestations.is_empty() {
|
||||
let want_root = hex_decode_fixed::<32>(&receipt.read_proof.checkpoint_merkle_root_hex)?;
|
||||
let want_count = receipt.read_proof.entry_count;
|
||||
|
||||
let mut matched_ok = 0usize;
|
||||
for att in &receipt.attestations {
|
||||
verify_checkpoint_attestation(att)?;
|
||||
match att {
|
||||
CheckpointAttestation::V0(a) => {
|
||||
if a.checkpoint_entry_count == want_count
|
||||
&& a.checkpoint_merkle_root_hex == want_root
|
||||
{
|
||||
matched_ok += 1;
|
||||
}
|
||||
}
|
||||
CheckpointAttestation::V1(a) => {
|
||||
if a.checkpoint_entry_count == want_count
|
||||
&& a.checkpoint_merkle_root_hex == want_root
|
||||
{
|
||||
matched_ok += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if require_attestation && matched_ok == 0 {
|
||||
return Err(LedgerError::InvalidLedger(
|
||||
"no attestation matched receipt checkpoint".into(),
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn verify_entry_sig(entry: &Entry) -> Result<(), LedgerError> {
|
||||
let vk = VerifyingKey::from_bytes(&entry.author_pubkey)
|
||||
.map_err(|e| LedgerError::InvalidLedger(format!("bad author_pubkey: {}", e)))?;
|
||||
let sig = Signature::from_bytes(&entry.sig);
|
||||
let msg = entry.unsigned().signing_message();
|
||||
vk.verify(&msg, &sig)
|
||||
.map_err(|e| LedgerError::InvalidLedger(format!("signature verify failed: {}", e)))?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn hex_decode_fixed<const N: usize>(s: &str) -> Result<[u8; N], LedgerError> {
|
||||
let s = s.trim();
|
||||
let s = s.strip_prefix("0x").unwrap_or(s);
|
||||
if s.len() != N * 2 {
|
||||
return Err(LedgerError::InvalidLedger(format!(
|
||||
"hex value must be {} bytes ({} hex chars), got {} chars",
|
||||
N,
|
||||
N * 2,
|
||||
s.len()
|
||||
)));
|
||||
}
|
||||
let mut out = [0u8; N];
|
||||
for i in 0..N {
|
||||
let idx = i * 2;
|
||||
out[i] = u8::from_str_radix(&s[idx..idx + 2], 16)
|
||||
.map_err(|_| LedgerError::InvalidLedger("invalid hex".into()))?;
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
189
crates/ledger-core/src/storage.rs
Normal file
189
crates/ledger-core/src/storage.rs
Normal file
@@ -0,0 +1,189 @@
|
||||
use std::fs::{File, OpenOptions};
|
||||
use std::io::{BufReader, BufWriter, Write};
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
use thiserror::Error;
|
||||
|
||||
use crate::attestation::CheckpointAttestation;
|
||||
use crate::entry::Entry;
|
||||
use crate::merkle::{MerkleRoot, merkle_root};
|
||||
|
||||
#[derive(Debug, Error)]
|
||||
pub enum LedgerError {
|
||||
#[error("io error: {0}")]
|
||||
Io(#[from] std::io::Error),
|
||||
|
||||
#[error("cbor error: {0}")]
|
||||
Cbor(String),
|
||||
|
||||
#[error("json error: {0}")]
|
||||
Json(#[from] serde_json::Error),
|
||||
|
||||
#[error("invalid key file: {0}")]
|
||||
InvalidKeyFile(String),
|
||||
|
||||
#[error("invalid ledger: {0}")]
|
||||
InvalidLedger(String),
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct LedgerDir {
|
||||
pub root: PathBuf,
|
||||
}
|
||||
|
||||
impl LedgerDir {
|
||||
pub fn new(root: impl Into<PathBuf>) -> Self {
|
||||
Self { root: root.into() }
|
||||
}
|
||||
|
||||
pub fn log_dir(&self) -> PathBuf {
|
||||
self.root.join("log")
|
||||
}
|
||||
|
||||
pub fn entries_path(&self) -> PathBuf {
|
||||
self.log_dir().join("entries.cborseq")
|
||||
}
|
||||
|
||||
pub fn checkpoints_path(&self) -> PathBuf {
|
||||
self.log_dir().join("checkpoints.jsonl")
|
||||
}
|
||||
|
||||
pub fn checkpoint_attestations_path(&self) -> PathBuf {
|
||||
self.log_dir().join("checkpoints.attestations.jsonl")
|
||||
}
|
||||
|
||||
pub fn init(&self) -> Result<(), LedgerError> {
|
||||
std::fs::create_dir_all(self.log_dir())?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn append_entry(&self, entry: &Entry) -> Result<(), LedgerError> {
|
||||
std::fs::create_dir_all(self.log_dir())?;
|
||||
let file = OpenOptions::new()
|
||||
.create(true)
|
||||
.append(true)
|
||||
.open(self.entries_path())?;
|
||||
let mut writer = BufWriter::new(file);
|
||||
serde_cbor::to_writer(&mut writer, entry).map_err(|e| LedgerError::Cbor(e.to_string()))?;
|
||||
writer.flush()?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn read_entries(&self) -> Result<Vec<Entry>, LedgerError> {
|
||||
let path = self.entries_path();
|
||||
if !path.exists() {
|
||||
return Ok(Vec::new());
|
||||
}
|
||||
let file = File::open(path)?;
|
||||
let reader = BufReader::new(file);
|
||||
let mut out = Vec::new();
|
||||
let iter = serde_cbor::Deserializer::from_reader(reader).into_iter::<Entry>();
|
||||
for item in iter {
|
||||
out.push(item.map_err(|e| LedgerError::Cbor(e.to_string()))?);
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
pub fn compute_merkle_root(&self) -> Result<(usize, MerkleRoot), LedgerError> {
|
||||
let entries = self.read_entries()?;
|
||||
let hashes: Vec<[u8; 32]> = entries.iter().map(|e| e.hash()).collect();
|
||||
Ok((hashes.len(), merkle_root(&hashes)))
|
||||
}
|
||||
|
||||
pub fn append_checkpoint(&self, checkpoint: &Checkpoint) -> Result<(), LedgerError> {
|
||||
std::fs::create_dir_all(self.log_dir())?;
|
||||
let file = OpenOptions::new()
|
||||
.create(true)
|
||||
.append(true)
|
||||
.open(self.checkpoints_path())?;
|
||||
let mut writer = BufWriter::new(file);
|
||||
serde_json::to_writer(&mut writer, checkpoint)?;
|
||||
writer.write_all(b"\n")?;
|
||||
writer.flush()?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn read_checkpoints(&self) -> Result<Vec<Checkpoint>, LedgerError> {
|
||||
let path = self.checkpoints_path();
|
||||
if !path.exists() {
|
||||
return Ok(Vec::new());
|
||||
}
|
||||
let content = std::fs::read_to_string(path)?;
|
||||
let mut out = Vec::new();
|
||||
for (idx, line) in content.lines().enumerate() {
|
||||
let line = line.trim();
|
||||
if line.is_empty() {
|
||||
continue;
|
||||
}
|
||||
let cp: Checkpoint = serde_json::from_str(line).map_err(|e| {
|
||||
LedgerError::InvalidLedger(format!(
|
||||
"bad checkpoint json at line {}: {}",
|
||||
idx + 1,
|
||||
e
|
||||
))
|
||||
})?;
|
||||
out.push(cp);
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
pub fn append_checkpoint_attestation(
|
||||
&self,
|
||||
attestation: &CheckpointAttestation,
|
||||
) -> Result<(), LedgerError> {
|
||||
std::fs::create_dir_all(self.log_dir())?;
|
||||
let file = OpenOptions::new()
|
||||
.create(true)
|
||||
.append(true)
|
||||
.open(self.checkpoint_attestations_path())?;
|
||||
let mut writer = BufWriter::new(file);
|
||||
serde_json::to_writer(&mut writer, attestation)?;
|
||||
writer.write_all(b"\n")?;
|
||||
writer.flush()?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn read_checkpoint_attestations(&self) -> Result<Vec<CheckpointAttestation>, LedgerError> {
|
||||
let path = self.checkpoint_attestations_path();
|
||||
if !path.exists() {
|
||||
return Ok(Vec::new());
|
||||
}
|
||||
let content = std::fs::read_to_string(path)?;
|
||||
let mut out = Vec::new();
|
||||
for (idx, line) in content.lines().enumerate() {
|
||||
let line = line.trim();
|
||||
if line.is_empty() {
|
||||
continue;
|
||||
}
|
||||
let att: CheckpointAttestation = serde_json::from_str(line).map_err(|e| {
|
||||
LedgerError::InvalidLedger(format!(
|
||||
"bad checkpoint attestation json at line {}: {}",
|
||||
idx + 1,
|
||||
e
|
||||
))
|
||||
})?;
|
||||
out.push(att);
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct Checkpoint {
|
||||
pub ts_ms: u64,
|
||||
pub entry_count: u64,
|
||||
pub merkle_root_hex: String,
|
||||
pub head_hash_hex: String,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub witness_pubkey_hex: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub witness_sig_hex: Option<String>,
|
||||
}
|
||||
|
||||
pub fn ensure_parent_dir(path: &Path) -> Result<(), LedgerError> {
|
||||
if let Some(parent) = path.parent() {
|
||||
std::fs::create_dir_all(parent)?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
119
crates/ledger-core/src/verify.rs
Normal file
119
crates/ledger-core/src/verify.rs
Normal file
@@ -0,0 +1,119 @@
|
||||
use ed25519_dalek::{Signature, Verifier, VerifyingKey};
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::entry::{Entry, EntryHash};
|
||||
use crate::storage::{Checkpoint, LedgerDir, LedgerError};
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct AuditReport {
|
||||
pub ok: bool,
|
||||
pub entry_count: u64,
|
||||
pub head_hash_hex: String,
|
||||
pub failures: Vec<String>,
|
||||
pub verified_checkpoints: u64,
|
||||
}
|
||||
|
||||
pub fn verify_ledger_dir(
|
||||
dir: &LedgerDir,
|
||||
verify_checkpoints: bool,
|
||||
) -> Result<AuditReport, LedgerError> {
|
||||
let entries = dir.read_entries()?;
|
||||
let mut failures = Vec::new();
|
||||
|
||||
let mut expected_prev: EntryHash = [0u8; 32];
|
||||
let mut hashes: Vec<EntryHash> = Vec::with_capacity(entries.len());
|
||||
for (idx, entry) in entries.iter().enumerate() {
|
||||
if entry.prev_hash != expected_prev {
|
||||
failures.push(format!(
|
||||
"entry {} prev_hash mismatch: expected {}, got {}",
|
||||
idx,
|
||||
hex(&expected_prev),
|
||||
hex(&entry.prev_hash)
|
||||
));
|
||||
break;
|
||||
}
|
||||
|
||||
if let Err(e) = verify_entry_sig(entry) {
|
||||
failures.push(format!("entry {} signature invalid: {}", idx, e));
|
||||
break;
|
||||
}
|
||||
|
||||
let h = entry.hash();
|
||||
hashes.push(h);
|
||||
expected_prev = h;
|
||||
}
|
||||
|
||||
let mut verified_checkpoints_count = 0u64;
|
||||
if failures.is_empty() && verify_checkpoints {
|
||||
let checkpoints = dir.read_checkpoints()?;
|
||||
for cp in checkpoints {
|
||||
if verify_checkpoint(dir, &hashes, &cp).is_ok() {
|
||||
verified_checkpoints_count += 1;
|
||||
} else {
|
||||
failures.push("checkpoint verification failed".into());
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Ok(AuditReport {
|
||||
ok: failures.is_empty(),
|
||||
entry_count: hashes.len() as u64,
|
||||
head_hash_hex: hex(&expected_prev),
|
||||
failures,
|
||||
verified_checkpoints: verified_checkpoints_count,
|
||||
})
|
||||
}
|
||||
|
||||
fn verify_entry_sig(entry: &Entry) -> Result<(), LedgerError> {
|
||||
let vk = VerifyingKey::from_bytes(&entry.author_pubkey)
|
||||
.map_err(|e| LedgerError::InvalidLedger(format!("bad author_pubkey: {}", e)))?;
|
||||
let sig = Signature::from_bytes(&entry.sig);
|
||||
let msg = entry.unsigned().signing_message();
|
||||
vk.verify(&msg, &sig)
|
||||
.map_err(|e| LedgerError::InvalidLedger(format!("signature verify failed: {}", e)))?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn verify_checkpoint(
|
||||
_dir: &LedgerDir,
|
||||
hashes: &[EntryHash],
|
||||
cp: &Checkpoint,
|
||||
) -> Result<(), LedgerError> {
|
||||
let want = cp.entry_count as usize;
|
||||
if want > hashes.len() {
|
||||
return Err(LedgerError::InvalidLedger(
|
||||
"checkpoint entry_count exceeds log length".into(),
|
||||
));
|
||||
}
|
||||
let prefix = &hashes[..want];
|
||||
let root = crate::merkle::merkle_root(prefix);
|
||||
let got_root = cp
|
||||
.merkle_root_hex
|
||||
.as_str()
|
||||
.to_ascii_lowercase()
|
||||
.trim()
|
||||
.to_string();
|
||||
let expect_root = hex(&root);
|
||||
if got_root != expect_root {
|
||||
return Err(LedgerError::InvalidLedger(format!(
|
||||
"checkpoint merkle root mismatch: expected {}, got {}",
|
||||
expect_root, got_root
|
||||
)));
|
||||
}
|
||||
let head = if want == 0 {
|
||||
[0u8; 32]
|
||||
} else {
|
||||
hashes[want - 1]
|
||||
};
|
||||
if cp.head_hash_hex.to_ascii_lowercase() != hex(&head) {
|
||||
return Err(LedgerError::InvalidLedger(
|
||||
"checkpoint head hash mismatch".into(),
|
||||
));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn hex(bytes: &[u8]) -> String {
|
||||
bytes.iter().map(|b| format!("{:02x}", b)).collect()
|
||||
}
|
||||
147
crates/ledger-core/tests/attestation_vectors.rs
Normal file
147
crates/ledger-core/tests/attestation_vectors.rs
Normal file
@@ -0,0 +1,147 @@
|
||||
use ed25519_dalek::{Signer, SigningKey};
|
||||
|
||||
use ledger_core::attestation::{
|
||||
checkpoint_attestation_signing_message_v0, checkpoint_attestation_signing_message_v1,
|
||||
};
|
||||
use ledger_core::{
|
||||
CHECKPOINT_ATTESTATION_V0_FORMAT, CHECKPOINT_ATTESTATION_V1_FORMAT, CheckpointAttestationV0,
|
||||
CheckpointAttestationV1, verify_checkpoint_attestation_v0, verify_checkpoint_attestation_v1,
|
||||
};
|
||||
|
||||
fn hex(bytes: &[u8]) -> String {
|
||||
bytes.iter().map(|b| format!("{:02x}", b)).collect()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn checkpoint_attestation_v0_vectors() {
|
||||
let signing_key = SigningKey::from_bytes(&[0x44u8; 32]);
|
||||
let witness_pubkey = signing_key.verifying_key().to_bytes();
|
||||
|
||||
let genesis = [0x11u8; 32];
|
||||
let entry_count = 7u64;
|
||||
let merkle_root = [0x22u8; 32];
|
||||
let head_hash = [0x33u8; 32];
|
||||
let ts_seen_ms = 123_456_789u64;
|
||||
|
||||
let msg = checkpoint_attestation_signing_message_v0(
|
||||
&genesis,
|
||||
entry_count,
|
||||
&merkle_root,
|
||||
&head_hash,
|
||||
ts_seen_ms,
|
||||
);
|
||||
let sig = signing_key.sign(&msg).to_bytes();
|
||||
|
||||
let att = CheckpointAttestationV0 {
|
||||
format: CHECKPOINT_ATTESTATION_V0_FORMAT.to_string(),
|
||||
ledger_genesis_hash_hex: genesis,
|
||||
checkpoint_entry_count: entry_count,
|
||||
checkpoint_merkle_root_hex: merkle_root,
|
||||
checkpoint_head_hash_hex: head_hash,
|
||||
ts_seen_ms,
|
||||
witness_pubkey_hex: witness_pubkey,
|
||||
witness_sig_hex: sig,
|
||||
};
|
||||
|
||||
verify_checkpoint_attestation_v0(&att).expect("attestation verifies");
|
||||
|
||||
assert_eq!(
|
||||
hex(&witness_pubkey),
|
||||
"d759793bbc13a2819a827c76adb6fba8a49aee007f49f2d0992d99b825ad2c48"
|
||||
);
|
||||
assert_eq!(
|
||||
hex(&msg),
|
||||
"4349565f4c45444745525f434845434b504f494e545f4154544553545f5630111111111111111111111111111111111111111111111111111111111111111107000000000000002222222222222222222222222222222222222222222222222222222222222222333333333333333333333333333333333333333333333333333333333333333315cd5b0700000000"
|
||||
);
|
||||
assert_eq!(
|
||||
hex(&sig),
|
||||
"ebc1627f5f82d848375ca819cad7a95bfd1da7c4eb4ef09478a10129aee5af2ad1096530965efc163742b4b6de11663e9c260f0015053bb6b04e6e00d306b40d"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn checkpoint_attestation_v1_vectors() {
|
||||
let signing_key = SigningKey::from_bytes(&[0x44u8; 32]);
|
||||
let witness_pubkey = signing_key.verifying_key().to_bytes();
|
||||
|
||||
let genesis = [0x11u8; 32];
|
||||
let entry_count = 7u64;
|
||||
let merkle_root = [0x22u8; 32];
|
||||
let head_hash = [0x33u8; 32];
|
||||
let checkpoint_ts_ms = 111_222_333u64;
|
||||
let ts_seen_ms = 123_456_789u64;
|
||||
|
||||
let msg = checkpoint_attestation_signing_message_v1(
|
||||
&genesis,
|
||||
entry_count,
|
||||
&merkle_root,
|
||||
&head_hash,
|
||||
checkpoint_ts_ms,
|
||||
ts_seen_ms,
|
||||
);
|
||||
let sig = signing_key.sign(&msg).to_bytes();
|
||||
|
||||
let att = CheckpointAttestationV1 {
|
||||
format: CHECKPOINT_ATTESTATION_V1_FORMAT.to_string(),
|
||||
ledger_genesis_hash_hex: genesis,
|
||||
checkpoint_entry_count: entry_count,
|
||||
checkpoint_merkle_root_hex: merkle_root,
|
||||
checkpoint_head_hash_hex: head_hash,
|
||||
checkpoint_ts_ms,
|
||||
ts_seen_ms,
|
||||
witness_pubkey_hex: witness_pubkey,
|
||||
witness_sig_hex: sig,
|
||||
};
|
||||
|
||||
verify_checkpoint_attestation_v1(&att).expect("attestation verifies");
|
||||
|
||||
assert_eq!(
|
||||
hex(&witness_pubkey),
|
||||
"d759793bbc13a2819a827c76adb6fba8a49aee007f49f2d0992d99b825ad2c48"
|
||||
);
|
||||
assert_eq!(
|
||||
hex(&msg),
|
||||
"4349565f4c45444745525f434845434b504f494e545f4154544553545f563111111111111111111111111111111111111111111111111111111111111111110700000000000000222222222222222222222222222222222222222222222222222222222222222233333333333333333333333333333333333333333333333333333333333333333d1ea1060000000015cd5b0700000000"
|
||||
);
|
||||
assert_eq!(
|
||||
hex(&sig),
|
||||
"c682053071a6bcf666eb9561dbdb48a9b6e886e4f2e86a540793fbf8b38b165031e61dbbd8788ee75ec56148148a396ea310e6f765d492f060c68d4c68875e09"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn checkpoint_attestation_v1_rejects_seen_before_checkpoint() {
|
||||
let signing_key = SigningKey::from_bytes(&[0x44u8; 32]);
|
||||
let witness_pubkey = signing_key.verifying_key().to_bytes();
|
||||
|
||||
let genesis = [0x11u8; 32];
|
||||
let entry_count = 7u64;
|
||||
let merkle_root = [0x22u8; 32];
|
||||
let head_hash = [0x33u8; 32];
|
||||
let checkpoint_ts_ms = 200u64;
|
||||
let ts_seen_ms = 100u64;
|
||||
|
||||
let msg = checkpoint_attestation_signing_message_v1(
|
||||
&genesis,
|
||||
entry_count,
|
||||
&merkle_root,
|
||||
&head_hash,
|
||||
checkpoint_ts_ms,
|
||||
ts_seen_ms,
|
||||
);
|
||||
let sig = signing_key.sign(&msg).to_bytes();
|
||||
|
||||
let att = CheckpointAttestationV1 {
|
||||
format: CHECKPOINT_ATTESTATION_V1_FORMAT.to_string(),
|
||||
ledger_genesis_hash_hex: genesis,
|
||||
checkpoint_entry_count: entry_count,
|
||||
checkpoint_merkle_root_hex: merkle_root,
|
||||
checkpoint_head_hash_hex: head_hash,
|
||||
checkpoint_ts_ms,
|
||||
ts_seen_ms,
|
||||
witness_pubkey_hex: witness_pubkey,
|
||||
witness_sig_hex: sig,
|
||||
};
|
||||
|
||||
assert!(verify_checkpoint_attestation_v1(&att).is_err());
|
||||
}
|
||||
28
crates/ledger-core/tests/entry_cbor_roundtrip.rs
Normal file
28
crates/ledger-core/tests/entry_cbor_roundtrip.rs
Normal file
@@ -0,0 +1,28 @@
|
||||
use ed25519_dalek::{Signer, SigningKey};
|
||||
|
||||
use ledger_core::EntryUnsigned;
|
||||
|
||||
#[test]
|
||||
fn entry_cbor_roundtrips_with_sig_bytes() {
|
||||
let signing_key = SigningKey::from_bytes(&[7u8; 32]);
|
||||
let author_pubkey = signing_key.verifying_key().to_bytes();
|
||||
|
||||
let unsigned = EntryUnsigned {
|
||||
prev_hash: [0u8; 32],
|
||||
ts_ms: 42,
|
||||
namespace: "law".to_string(),
|
||||
payload_cbor: vec![0x65, b'h', b'e', b'l', b'l', b'o'],
|
||||
author_pubkey,
|
||||
};
|
||||
let sig = signing_key.sign(&unsigned.signing_message()).to_bytes();
|
||||
let entry = unsigned.to_entry(sig);
|
||||
|
||||
let bytes = serde_cbor::to_vec(&entry).expect("serialize entry");
|
||||
let decoded: ledger_core::Entry = serde_cbor::from_slice(&bytes).expect("deserialize entry");
|
||||
|
||||
assert_eq!(decoded.hash(), entry.hash());
|
||||
assert_eq!(decoded.sig, entry.sig);
|
||||
assert_eq!(decoded.namespace, entry.namespace);
|
||||
assert_eq!(decoded.payload_cbor, entry.payload_cbor);
|
||||
assert_eq!(decoded.author_pubkey, entry.author_pubkey);
|
||||
}
|
||||
47
crates/ledger-core/tests/golden_vectors.rs
Normal file
47
crates/ledger-core/tests/golden_vectors.rs
Normal file
@@ -0,0 +1,47 @@
|
||||
use ed25519_dalek::{Signer, SigningKey};
|
||||
|
||||
use ledger_core::EntryUnsigned;
|
||||
|
||||
fn hex(bytes: &[u8]) -> String {
|
||||
bytes.iter().map(|b| format!("{:02x}", b)).collect()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn golden_vectors_v0_signing_message_and_hashes() {
|
||||
let seed = [0u8; 32];
|
||||
let signing_key = SigningKey::from_bytes(&seed);
|
||||
let author_pubkey = signing_key.verifying_key().to_bytes();
|
||||
|
||||
let unsigned = EntryUnsigned {
|
||||
prev_hash: [0u8; 32],
|
||||
ts_ms: 1,
|
||||
namespace: "law".to_string(),
|
||||
payload_cbor: vec![0x65, b'h', b'e', b'l', b'l', b'o'],
|
||||
author_pubkey,
|
||||
};
|
||||
|
||||
let msg = unsigned.signing_message();
|
||||
let sig = signing_key.sign(&msg).to_bytes();
|
||||
let entry = unsigned.to_entry(sig);
|
||||
|
||||
assert_eq!(
|
||||
hex(&author_pubkey),
|
||||
"3b6a27bcceb6a42d62a3a8d02a6f0d73653215771de243a63ac048a18b59da29"
|
||||
);
|
||||
assert_eq!(
|
||||
hex(&entry.payload_hash()),
|
||||
"90eeb71f0d4b768a5d449e30035beb7ffccd75d228e5b38e8e9cbfaa01ddfae9"
|
||||
);
|
||||
assert_eq!(
|
||||
hex(&msg),
|
||||
"434c763000000000000000000000000000000000000000000000000000000000000000000100000000000000030000006c617790eeb71f0d4b768a5d449e30035beb7ffccd75d228e5b38e8e9cbfaa01ddfae93b6a27bcceb6a42d62a3a8d02a6f0d73653215771de243a63ac048a18b59da29"
|
||||
);
|
||||
assert_eq!(
|
||||
hex(&entry.sig),
|
||||
"03c91ecb5ab44a4329c12c1e789d22d685b81a0d16483d869d88fa24dd2c20a8b786066d12c8a238c88cea0b969f1c707df305b1edc56238d5d6166ecf986e05"
|
||||
);
|
||||
assert_eq!(
|
||||
hex(&entry.hash()),
|
||||
"ce13e5272d6705d9cbf2e261d2b9862abb187f737bea6fe3542e8680d689cc8e"
|
||||
);
|
||||
}
|
||||
29
crates/ledger-core/tests/read_proof.rs
Normal file
29
crates/ledger-core/tests/read_proof.rs
Normal file
@@ -0,0 +1,29 @@
|
||||
use ledger_core::{ReadProofV0, verify_read_proof_v0};
|
||||
|
||||
fn leaf(i: u64) -> [u8; 32] {
|
||||
blake3::hash(format!("leaf-{}", i).as_bytes()).into()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn read_proof_verifies_for_all_leaves() {
|
||||
for n in 1..=33usize {
|
||||
let leaves: Vec<[u8; 32]> = (0..n as u64).map(leaf).collect();
|
||||
for idx in 0..n {
|
||||
let proof = ReadProofV0::from_tree(&leaves, idx).expect("build proof");
|
||||
verify_read_proof_v0(&proof).expect("verify proof");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn read_proof_rejects_tampering() {
|
||||
let leaves: Vec<[u8; 32]> = (0..8u64).map(leaf).collect();
|
||||
let mut proof = ReadProofV0::from_tree(&leaves, 3).expect("build proof");
|
||||
assert!(verify_read_proof_v0(&proof).is_ok());
|
||||
|
||||
// Flip one nibble in the first sibling hash.
|
||||
let mut s = proof.path[0].sibling_hash_hex.clone();
|
||||
s.replace_range(0..1, if &s[0..1] == "0" { "1" } else { "0" });
|
||||
proof.path[0].sibling_hash_hex = s;
|
||||
assert!(verify_read_proof_v0(&proof).is_err());
|
||||
}
|
||||
177
crates/ledger-core/tests/receipt.rs
Normal file
177
crates/ledger-core/tests/receipt.rs
Normal file
@@ -0,0 +1,177 @@
|
||||
use base64::Engine as _;
|
||||
use ed25519_dalek::{Signer, SigningKey};
|
||||
|
||||
use ledger_core::attestation::{
|
||||
checkpoint_attestation_signing_message_v0, checkpoint_attestation_signing_message_v1,
|
||||
};
|
||||
use ledger_core::{
|
||||
CHECKPOINT_ATTESTATION_V0_FORMAT, CHECKPOINT_ATTESTATION_V1_FORMAT, CheckpointAttestation,
|
||||
CheckpointAttestationV0, CheckpointAttestationV1, EntryUnsigned, RECEIPT_V0_FORMAT,
|
||||
ReadProofV0, ReceiptV0, verify_receipt_v0,
|
||||
};
|
||||
|
||||
fn hex(bytes: &[u8]) -> String {
|
||||
bytes.iter().map(|b| format!("{:02x}", b)).collect()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn receipt_v0_verifies_with_attestation() {
|
||||
let author_key = SigningKey::from_bytes(&[7u8; 32]);
|
||||
let author_pubkey = author_key.verifying_key().to_bytes();
|
||||
|
||||
let unsigned1 = EntryUnsigned {
|
||||
prev_hash: [0u8; 32],
|
||||
ts_ms: 1,
|
||||
namespace: "law".to_string(),
|
||||
payload_cbor: vec![0x61, 0x41], // "A"
|
||||
author_pubkey,
|
||||
};
|
||||
let sig1 = author_key.sign(&unsigned1.signing_message()).to_bytes();
|
||||
let entry1 = unsigned1.to_entry(sig1);
|
||||
let h1 = entry1.hash();
|
||||
|
||||
let unsigned2 = EntryUnsigned {
|
||||
prev_hash: h1,
|
||||
ts_ms: 2,
|
||||
namespace: "law".to_string(),
|
||||
payload_cbor: vec![0x61, 0x42], // "B"
|
||||
author_pubkey,
|
||||
};
|
||||
let sig2 = author_key.sign(&unsigned2.signing_message()).to_bytes();
|
||||
let entry2 = unsigned2.to_entry(sig2);
|
||||
let h2 = entry2.hash();
|
||||
|
||||
let hashes = vec![h1, h2];
|
||||
let root = ledger_core::merkle::merkle_root(&hashes);
|
||||
let head = h2;
|
||||
let proof = ReadProofV0::from_tree(&hashes, 1).expect("build proof");
|
||||
|
||||
let witness_key = SigningKey::from_bytes(&[0x44u8; 32]);
|
||||
let witness_pubkey = witness_key.verifying_key().to_bytes();
|
||||
let ts_seen_ms = 123_456u64;
|
||||
let msg = checkpoint_attestation_signing_message_v0(&h1, 2, &root, &head, ts_seen_ms);
|
||||
let witness_sig = witness_key.sign(&msg).to_bytes();
|
||||
let att = CheckpointAttestationV0 {
|
||||
format: CHECKPOINT_ATTESTATION_V0_FORMAT.to_string(),
|
||||
ledger_genesis_hash_hex: h1,
|
||||
checkpoint_entry_count: 2,
|
||||
checkpoint_merkle_root_hex: root,
|
||||
checkpoint_head_hash_hex: head,
|
||||
ts_seen_ms,
|
||||
witness_pubkey_hex: witness_pubkey,
|
||||
witness_sig_hex: witness_sig,
|
||||
};
|
||||
|
||||
let entry_cbor = serde_cbor::to_vec(&entry2).expect("encode entry");
|
||||
let receipt = ReceiptV0 {
|
||||
format: RECEIPT_V0_FORMAT.to_string(),
|
||||
entry_cbor_b64: base64::engine::general_purpose::STANDARD_NO_PAD.encode(entry_cbor),
|
||||
entry_hash_hex: hex(&h2),
|
||||
read_proof: proof,
|
||||
attestations: vec![CheckpointAttestation::V0(att)],
|
||||
};
|
||||
|
||||
verify_receipt_v0(&receipt, true).expect("receipt verifies");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn receipt_v0_verifies_with_attestation_v1() {
|
||||
let author_key = SigningKey::from_bytes(&[7u8; 32]);
|
||||
let author_pubkey = author_key.verifying_key().to_bytes();
|
||||
|
||||
let unsigned1 = EntryUnsigned {
|
||||
prev_hash: [0u8; 32],
|
||||
ts_ms: 1,
|
||||
namespace: "law".to_string(),
|
||||
payload_cbor: vec![0x61, 0x41], // "A"
|
||||
author_pubkey,
|
||||
};
|
||||
let sig1 = author_key.sign(&unsigned1.signing_message()).to_bytes();
|
||||
let entry1 = unsigned1.to_entry(sig1);
|
||||
let h1 = entry1.hash();
|
||||
|
||||
let unsigned2 = EntryUnsigned {
|
||||
prev_hash: h1,
|
||||
ts_ms: 2,
|
||||
namespace: "law".to_string(),
|
||||
payload_cbor: vec![0x61, 0x42], // "B"
|
||||
author_pubkey,
|
||||
};
|
||||
let sig2 = author_key.sign(&unsigned2.signing_message()).to_bytes();
|
||||
let entry2 = unsigned2.to_entry(sig2);
|
||||
let h2 = entry2.hash();
|
||||
|
||||
let hashes = vec![h1, h2];
|
||||
let root = ledger_core::merkle::merkle_root(&hashes);
|
||||
let head = h2;
|
||||
let proof = ReadProofV0::from_tree(&hashes, 1).expect("build proof");
|
||||
|
||||
let witness_key = SigningKey::from_bytes(&[0x44u8; 32]);
|
||||
let witness_pubkey = witness_key.verifying_key().to_bytes();
|
||||
let checkpoint_ts_ms = 100u64;
|
||||
let ts_seen_ms = 123_456u64;
|
||||
let msg = checkpoint_attestation_signing_message_v1(
|
||||
&h1,
|
||||
2,
|
||||
&root,
|
||||
&head,
|
||||
checkpoint_ts_ms,
|
||||
ts_seen_ms,
|
||||
);
|
||||
let witness_sig = witness_key.sign(&msg).to_bytes();
|
||||
let att = CheckpointAttestationV1 {
|
||||
format: CHECKPOINT_ATTESTATION_V1_FORMAT.to_string(),
|
||||
ledger_genesis_hash_hex: h1,
|
||||
checkpoint_entry_count: 2,
|
||||
checkpoint_merkle_root_hex: root,
|
||||
checkpoint_head_hash_hex: head,
|
||||
checkpoint_ts_ms,
|
||||
ts_seen_ms,
|
||||
witness_pubkey_hex: witness_pubkey,
|
||||
witness_sig_hex: witness_sig,
|
||||
};
|
||||
|
||||
let entry_cbor = serde_cbor::to_vec(&entry2).expect("encode entry");
|
||||
let receipt = ReceiptV0 {
|
||||
format: RECEIPT_V0_FORMAT.to_string(),
|
||||
entry_cbor_b64: base64::engine::general_purpose::STANDARD_NO_PAD.encode(entry_cbor),
|
||||
entry_hash_hex: hex(&h2),
|
||||
read_proof: proof,
|
||||
attestations: vec![CheckpointAttestation::V1(att)],
|
||||
};
|
||||
|
||||
verify_receipt_v0(&receipt, true).expect("receipt verifies");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn receipt_v0_rejects_tampered_hash() {
|
||||
let author_key = SigningKey::from_bytes(&[7u8; 32]);
|
||||
let author_pubkey = author_key.verifying_key().to_bytes();
|
||||
|
||||
let unsigned = EntryUnsigned {
|
||||
prev_hash: [0u8; 32],
|
||||
ts_ms: 1,
|
||||
namespace: "law".to_string(),
|
||||
payload_cbor: vec![0x61, 0x41], // "A"
|
||||
author_pubkey,
|
||||
};
|
||||
let sig = author_key.sign(&unsigned.signing_message()).to_bytes();
|
||||
let entry = unsigned.to_entry(sig);
|
||||
let h = entry.hash();
|
||||
|
||||
let hashes = vec![h];
|
||||
let proof = ReadProofV0::from_tree(&hashes, 0).expect("build proof");
|
||||
|
||||
let entry_cbor = serde_cbor::to_vec(&entry).expect("encode entry");
|
||||
let mut receipt = ReceiptV0 {
|
||||
format: RECEIPT_V0_FORMAT.to_string(),
|
||||
entry_cbor_b64: base64::engine::general_purpose::STANDARD_NO_PAD.encode(entry_cbor),
|
||||
entry_hash_hex: "00".repeat(32),
|
||||
read_proof: proof,
|
||||
attestations: vec![],
|
||||
};
|
||||
assert!(verify_receipt_v0(&receipt, false).is_err());
|
||||
|
||||
receipt.entry_hash_hex = hex(&h);
|
||||
assert!(verify_receipt_v0(&receipt, false).is_ok());
|
||||
}
|
||||
Reference in New Issue
Block a user