Files
huskies/crates/bft-json-crdt/src/json_crdt/signed_op.rs
T
2026-05-13 15:11:37 +00:00

144 lines
5.6 KiB
Rust

//! [`SignedOp`], [`OpState`], and the causal queue capacity constant.
use crate::keypair::{Ed25519KeyPair, Ed25519PublicKey, Ed25519Signature};
use ed25519_dalek::Verifier as _;
use serde::{Deserialize, Serialize};
use serde_with::{serde_as, Bytes};
use crate::keypair::{sha256, sign, AuthorId, SignedDigest};
use crate::op::{print_hex, print_path, Op, OpId};
use super::{CrdtNode, JsonValue};
/// Enum representing possible outcomes of applying an operation to a CRDT
#[derive(Debug, PartialEq)]
pub enum OpState {
/// Operation applied successfully
Ok,
/// Tried to apply an operation to a non-CRDT primitive (i.e. f64, bool, etc.)
/// If you would like a mutable primitive, wrap it in a [`LWWRegisterCRDT`]
ErrApplyOnPrimitive,
/// Tried to apply an operation to a static struct CRDT
/// If you would like a mutable object, use a [`Value`]
ErrApplyOnStruct,
/// Tried to apply an operation that contains content of the wrong type.
/// In other words, the content cannot be coerced to the CRDT at the path specified.
ErrMismatchedType,
/// The signed digest of the message did not match the claimed author of the message.
/// This can happen if the message was tampered with during delivery
ErrDigestMismatch,
/// The hash of the message did not match the contents of the message.
/// This can happen if the author tried to perform an equivocation attack by creating an
/// operation and modifying it has already been created
ErrHashMismatch,
/// Tried to apply an operation to a non-existent path. The author may have forgotten to attach
/// a causal dependency
ErrPathMismatch,
/// Trying to modify/delete the sentinel (zero-th) node element that is used for book-keeping
ErrListApplyToEmpty,
/// We have not received all of the causal dependencies of this operation. It has been queued
/// up and will be executed when its causal dependencies have been delivered
MissingCausalDependencies,
/// This op has already been applied (identified by its `signed_digest`).
/// The CRDT state is unchanged — this is a no-op (idempotent self-loop guard).
AlreadySeen,
}
/// Maximum total number of ops that may sit in the causal-order hold queue at any
/// one time, summed across all pending dependency buckets.
///
/// **Overflow policy: drop oldest.**
/// When the limit is reached, the oldest pending op in the largest dependency bucket
/// is silently evicted before the new op is queued. Rationale: a misbehaving or
/// heavily-partitioned peer can send ops whose causal ancestors never arrive, causing
/// unbounded memory growth. Dropping the oldest entry preserves the most recent
/// information and caps memory use. The peer can reconnect and receive a fresh bulk
/// state dump to recover any dropped ops.
pub const CAUSAL_QUEUE_MAX: usize = 256;
/// An [`Op<Value>`] with a few bits of extra metadata
#[serde_as]
#[derive(Clone, Serialize, Deserialize, Debug, PartialEq)]
pub struct SignedOp {
// Note that this can be different from the author of the inner op as the inner op could have been created
// by a different person
author: AuthorId,
/// Signed hash using priv key of author. Effectively [`OpID`] Use this as the ID to figure out what has been delivered already
#[serde_as(as = "Bytes")]
pub signed_digest: SignedDigest,
pub inner: Op<JsonValue>,
/// List of causal dependencies
#[serde_as(as = "Vec<Bytes>")]
pub depends_on: Vec<SignedDigest>,
}
impl SignedOp {
pub fn id(&self) -> OpId {
self.inner.id
}
pub fn author(&self) -> AuthorId {
self.author
}
/// Creates a digest of the following fields. Any changes in the fields will change the signed digest
/// - id (hash of the following)
/// - origin
/// - author
/// - seq
/// - is_deleted
/// - path
/// - dependencies
fn digest(&self) -> [u8; 32] {
let path_string = print_path(self.inner.path.clone());
let dependency_string = self
.depends_on
.iter()
.map(print_hex)
.collect::<Vec<_>>()
.join("");
let fmt_str = format!("{:?},{path_string},{dependency_string}", self.id());
sha256(fmt_str)
}
/// Sign this digest with the given keypair. Shouldn't need to be called manually,
/// just use [`SignedOp::from_op`] instead
fn sign_digest(&mut self, keypair: &Ed25519KeyPair) {
self.signed_digest = sign(keypair, &self.digest()).to_bytes()
}
/// Ensure digest was actually signed by the author it claims to be signed by
pub fn is_valid_digest(&self) -> bool {
let digest = Ed25519Signature::from_bytes(&self.signed_digest);
match Ed25519PublicKey::from_bytes(&self.author()) {
Ok(pubkey) => pubkey.verify(&self.digest(), &digest).is_ok(),
Err(_) => false,
}
}
/// Sign a normal op and add all the needed metadata
pub fn from_op<T: CrdtNode>(
value: Op<T>,
keypair: &Ed25519KeyPair,
depends_on: Vec<SignedDigest>,
) -> Self {
let author = keypair.verifying_key().to_bytes();
let mut new = Self {
inner: Op {
content: value.content.map(|c| c.view()),
origin: value.origin,
author: value.author,
seq: value.seq,
path: value.path,
is_deleted: value.is_deleted,
id: value.id,
},
author,
signed_digest: [0u8; 64],
depends_on,
};
new.sign_digest(keypair);
new
}
}