p2panda_core/
operation.rs

1// SPDX-License-Identifier: MIT OR Apache-2.0
2
3//! Core p2panda data type offering distributed, secure and efficient data transfer between peers.
4//!
5//! Operations are used to carry any data from one peer to another (distributed), while assuming no
6//! reliable network connection (offline-first) and untrusted machines (cryptographically secure).
7//! The author of an operation uses it's [`PrivateKey`] to cryptographically sign every operation.
8//! This can be verified and used for authentication by any other peer.
9//!
10//! Every operation consists of a [`Header`] and an optional [`Body`]. The body holds arbitrary
11//! bytes (up to the application to decide what should be inside). The header is used to
12//! cryptographically secure & authenticate the body and for providing ordered collections of
13//! operations when required.
14//!
15//! Operations have a `backlink` and `seq_num` field in the header. These are used to form a linked
16//! list of operations, where every subsequent operation points to the previous one by referencing
17//! its cryptographically secured hash. The `previous` field can be used to point at operations by
18//! _other_ authors when multi-writer causal partial-ordering is required. The `timestamp` field
19//! can be used when verifiable causal ordering is not required.
20//!
21//! [Header extensions](crate::extensions) can be used to add additional information, like
22//! "pruning" points for removing old or unwanted data, "tombstones" for explicit deletion,
23//! capabilities or group encryption schemes or custom application-related features etc.
24//!
25//! Operations are encoded in CBOR format and use Ed25519 key pairs for digital signatures and
26//! BLAKE3 for hashing.
27//!
28//! ## Examples
29//!
30//! ### Construct and sign a header
31//!
32//! ```
33//! use p2panda_core::{Body, Header, PrivateKey};
34//!
35//! let private_key = PrivateKey::new();
36//!
37//! let body = Body::new("Hello, Sloth!".as_bytes());
38//! let mut header = Header {
39//!     version: 1,
40//!     public_key: private_key.public_key(),
41//!     signature: None,
42//!     payload_size: body.size(),
43//!     payload_hash: Some(body.hash()),
44//!     timestamp: 1733170247,
45//!     seq_num: 0,
46//!     backlink: None,
47//!     previous: vec![],
48//!     extensions: (),
49//! };
50//!
51//! header.sign(&private_key);
52//! ```
53//!
54//! ### Custom extensions
55//!
56//! ```
57//! use p2panda_core::{Body, Extension, Header, PrivateKey, PruneFlag};
58//! use serde::{Serialize, Deserialize};
59//!
60//! let private_key = PrivateKey::new();
61//!
62//! #[derive(Clone, Debug, Default, Serialize, Deserialize)]
63//! struct CustomExtensions {
64//!     prune_flag: PruneFlag,
65//! }
66//!
67//! impl Extension<PruneFlag> for CustomExtensions {
68//!     fn extract(header: &Header<Self>) -> Option<PruneFlag> {
69//!         Some(header.extensions.prune_flag.clone())
70//!     }
71//! }
72//!
73//! let extensions = CustomExtensions {
74//!     prune_flag: PruneFlag::new(true),
75//! };
76//!
77//! let body = Body::new("Prune from here please!".as_bytes());
78//! let mut header = Header {
79//!     version: 1,
80//!     public_key: private_key.public_key(),
81//!     signature: None,
82//!     payload_size: body.size(),
83//!     payload_hash: Some(body.hash()),
84//!     timestamp: 1733170247,
85//!     seq_num: 0,
86//!     backlink: None,
87//!     previous: vec![],
88//!     extensions,
89//! };
90//!
91//! header.sign(&private_key);
92//!
93//! let prune_flag: PruneFlag = header.extension().unwrap();
94//! assert!(prune_flag.is_set())
95//! ```
96use thiserror::Error;
97
98use crate::cbor::{DecodeError, decode_cbor, encode_cbor};
99use crate::hash::Hash;
100use crate::identity::{PrivateKey, PublicKey, Signature};
101use crate::{Extension, Extensions};
102
103/// Encoded bytes of an operation header and optional body.
104pub type RawOperation = (Vec<u8>, Option<Vec<u8>>);
105
106/// Combined [`Header`], [`Body`] and operation [`struct@Hash`] (Operation Id).
107#[derive(Clone, Debug)]
108pub struct Operation<E = ()> {
109    pub hash: Hash,
110    pub header: Header<E>,
111    pub body: Option<Body>,
112}
113
114impl<E> PartialEq for Operation<E> {
115    fn eq(&self, other: &Self) -> bool {
116        self.hash.eq(&other.hash)
117    }
118}
119
120impl<E> Eq for Operation<E> {}
121
122#[allow(clippy::non_canonical_partial_ord_impl)]
123impl<E> PartialOrd for Operation<E> {
124    fn partial_cmp(&self, other: &Self) -> Option<std::cmp::Ordering> {
125        Some(self.hash.cmp(&other.hash))
126    }
127}
128
129impl<E> Ord for Operation<E> {
130    fn cmp(&self, other: &Self) -> std::cmp::Ordering {
131        self.hash.cmp(&other.hash)
132    }
133}
134
135/// Header of a p2panda operation.
136///
137/// The header holds all metadata required to cryptographically secure and authenticate a message
138/// [`Body`] and, if required, apply ordering to collections of messages from the same or many
139/// authors.
140///
141/// ## Example
142///
143/// ```
144/// use p2panda_core::{Body, Header, Operation, PrivateKey};
145///
146/// let private_key = PrivateKey::new();
147///
148/// let body = Body::new("Hello, Sloth!".as_bytes());
149/// let mut header = Header {
150///     version: 1,
151///     public_key: private_key.public_key(),
152///     signature: None,
153///     payload_size: body.size(),
154///     payload_hash: Some(body.hash()),
155///     timestamp: 1733170247,
156///     seq_num: 0,
157///     backlink: None,
158///     previous: vec![],
159///     extensions: (),
160/// };
161///
162/// // Sign the header with the author's private key.
163/// header.sign(&private_key);
164/// ```
165#[derive(Clone, Debug, PartialEq)]
166#[cfg_attr(feature = "arbitrary", derive(arbitrary::Arbitrary))]
167pub struct Header<E = ()> {
168    /// Operation format version, allowing backwards compatibility when specification changes.
169    pub version: u64,
170
171    /// Author of this operation.
172    pub public_key: PublicKey,
173
174    /// Signature by author over all fields in header, providing authenticity.
175    pub signature: Option<Signature>,
176
177    /// Number of bytes of the body of this operation, must be zero if no body is given.
178    pub payload_size: u64,
179
180    /// Hash of the body of this operation, must be included if payload_size is non-zero and
181    /// omitted otherwise.
182    ///
183    /// Keeping the hash here allows us to delete the payload (off-chain data) while retaining the
184    /// ability to check the signature of the header.
185    pub payload_hash: Option<Hash>,
186
187    /// Time in microseconds since the Unix epoch.
188    pub timestamp: u64,
189
190    /// Number of operations this author has published to this log, begins with 0 and is always
191    /// incremented by 1 with each new operation by the same author.
192    pub seq_num: u64,
193
194    /// Hash of the previous operation of the same author and log. Can be omitted if first
195    /// operation in log.
196    pub backlink: Option<Hash>,
197
198    /// List of hashes of the operations we refer to as the "previous" ones. These are operations
199    /// from other authors. Can be left empty if no partial ordering is required or no other
200    /// author has been observed yet.
201    pub previous: Vec<Hash>,
202
203    /// Custom meta data.
204    pub extensions: E,
205}
206
207impl<E: Default> Default for Header<E> {
208    fn default() -> Self {
209        Self {
210            version: 1,
211            public_key: PublicKey::default(),
212            signature: None,
213            payload_size: 0,
214            payload_hash: None,
215            timestamp: 0,
216            seq_num: 0,
217            backlink: None,
218            previous: vec![],
219            extensions: E::default(),
220        }
221    }
222}
223
224impl<E> Header<E>
225where
226    E: Extensions,
227{
228    /// Header encoded to bytes in CBOR format.
229    pub fn to_bytes(&self) -> Vec<u8> {
230        encode_cbor(self)
231            // We can be sure that all values in this module are serializable and _if_ ciborium
232            // still fails then because of something really bad ..
233            .expect("CBOR encoder failed due to an critical IO error")
234    }
235
236    /// Add a signature to the header using the provided `PrivateKey`.
237    ///
238    /// This method signs the byte representation of a header with any existing signature removed
239    /// before adding back the newly generated signature.
240    pub fn sign(&mut self, private_key: &PrivateKey) {
241        // Make sure the signature is not already set before we encode
242        self.signature = None;
243
244        let bytes = self.to_bytes();
245        self.signature = Some(private_key.sign(&bytes));
246    }
247
248    /// Verify that the signature contained in this `Header` was generated by the claimed
249    /// public key.
250    pub fn verify(&self) -> bool {
251        match self.signature {
252            Some(claimed_signature) => {
253                let mut unsigned_header = self.clone();
254                unsigned_header.signature = None;
255                let unsigned_bytes = unsigned_header.to_bytes();
256                self.public_key.verify(&unsigned_bytes, &claimed_signature)
257            }
258            None => false,
259        }
260    }
261
262    /// BLAKE3 hash of the header bytes.
263    ///
264    /// This hash is used as the unique identifier of an operation, aka the Operation Id.
265    pub fn hash(&self) -> Hash {
266        Hash::new(self.to_bytes())
267    }
268
269    /// Extract an extension value from the header.
270    pub fn extension<T>(&self) -> Option<T>
271    where
272        E: Extension<T>,
273    {
274        E::extract(self)
275    }
276}
277
278impl<E> Header<E> {
279    /// Number of fields included in the header.
280    ///
281    /// Fields instantiated with `None` values are excluded from the count.
282    pub(crate) fn field_count(&self) -> usize {
283        // There will always be a minimum of 7 fields in a complete header.
284        // (this counts the `E` extensions field, even if it is zero-sized).
285        let mut count = 7;
286
287        if self.signature.is_some() {
288            count += 1;
289        }
290
291        if self.payload_hash.is_some() {
292            count += 1;
293        }
294
295        if self.backlink.is_some() {
296            count += 1;
297        }
298
299        count
300    }
301}
302
303impl TryFrom<&[u8]> for Header {
304    type Error = DecodeError;
305
306    fn try_from(value: &[u8]) -> Result<Self, Self::Error> {
307        decode_cbor(value)
308    }
309}
310
311/// Body of a p2panda operation containing arbitrary bytes.
312#[derive(Clone, Debug, PartialEq)]
313pub struct Body(pub(super) Vec<u8>);
314
315impl Body {
316    /// Construct a body from a byte slice.
317    pub fn new(bytes: &[u8]) -> Self {
318        Self(bytes.to_vec())
319    }
320
321    /// Access the underlying body bytes.
322    pub fn to_bytes(&self) -> Vec<u8> {
323        self.0.clone()
324    }
325
326    /// BLAKE3 hash of the body bytes.
327    pub fn hash(&self) -> Hash {
328        Hash::new(&self.0)
329    }
330
331    /// Size of body bytes.
332    pub fn size(&self) -> u64 {
333        self.0.len() as u64
334    }
335}
336
337impl From<&[u8]> for Body {
338    fn from(value: &[u8]) -> Self {
339        Body::new(value)
340    }
341}
342
343impl From<Vec<u8>> for Body {
344    fn from(value: Vec<u8>) -> Self {
345        Body(value)
346    }
347}
348
349#[derive(Clone, Debug, Error)]
350pub enum OperationError {
351    #[error("operation version {0} is not supported, needs to be <= {1}")]
352    UnsupportedVersion(u64, u64),
353
354    #[error("operation needs to be signed")]
355    MissingSignature,
356
357    #[error("signature does not match claimed public key")]
358    SignatureMismatch,
359
360    #[error("sequence number can't be 0 when backlink is given")]
361    SeqNumMismatch,
362
363    #[error("payload hash and -size need to be defined together")]
364    InconsistentPayloadInfo,
365
366    #[error("needs payload hash in header when body is given")]
367    MissingPayloadHash,
368
369    #[error("payload hash and size do not match given body")]
370    PayloadMismatch,
371
372    #[error("logs can not contain operations of different authors")]
373    TooManyAuthors,
374
375    #[error("expected sequence number {0} but found {1}")]
376    SeqNumNonIncremental(u64, u64),
377
378    #[error("expected backlink but none was given")]
379    BacklinkMissing,
380
381    #[error("given backlink did not match previous operation")]
382    BacklinkMismatch,
383}
384
385/// Validate the header and body (when provided) of a single operation. All basic header
386/// validation is performed (identical to [`validate_header`]()) and additionally the body bytes
387/// hash and size are checked to be correct.
388///
389/// This method validates that the following conditions are true:
390/// * Signature can be verified against the author public key and unsigned header bytes
391/// * Header version is supported (currently only version 1 is supported)
392/// * If `payload_hash` is set the `payload_size` is > `0` otherwise it is zero
393/// * If `backlink` is set then `seq_num` is > `0` otherwise it is zero
394/// * If provided the body bytes hash and size match those claimed in the header
395pub fn validate_operation<E>(operation: &Operation<E>) -> Result<(), OperationError>
396where
397    E: Extensions,
398{
399    validate_header(&operation.header)?;
400
401    let claimed_payload_size = operation.header.payload_size;
402    let claimed_payload_hash: Option<Hash> = match claimed_payload_size {
403        0 => None,
404        _ => {
405            let hash = operation
406                .header
407                .payload_hash
408                .ok_or(OperationError::MissingPayloadHash)?;
409            Some(hash)
410        }
411    };
412
413    if let Some(body) = &operation.body
414        && (claimed_payload_hash != Some(body.hash()) || claimed_payload_size != body.size())
415    {
416        return Err(OperationError::PayloadMismatch);
417    }
418
419    Ok(())
420}
421
422/// Validate an operation header.
423///
424/// This method validates that the following conditions are true:
425/// * Signature can be verified against the author public key and unsigned header bytes
426/// * Header version is supported (currently only version 1 is supported)
427/// * If `payload_hash` is set the `payload_size` is > `0` otherwise it is zero
428/// * If `backlink` is set then `seq_num` is > `0` otherwise it is zero
429pub fn validate_header<E>(header: &Header<E>) -> Result<(), OperationError>
430where
431    E: Extensions,
432{
433    if !header.verify() {
434        return Err(OperationError::SignatureMismatch);
435    }
436
437    if header.version != 1 {
438        return Err(OperationError::UnsupportedVersion(header.version, 1));
439    }
440
441    if (header.payload_hash.is_some() && header.payload_size == 0)
442        || (header.payload_hash.is_none() && header.payload_size > 0)
443    {
444        return Err(OperationError::InconsistentPayloadInfo);
445    }
446
447    if header.backlink.is_some() && header.seq_num == 0 {
448        return Err(OperationError::SeqNumMismatch);
449    }
450
451    if header.backlink.is_none() && header.seq_num > 0 {
452        return Err(OperationError::BacklinkMissing);
453    }
454
455    Ok(())
456}
457
458/// Validate a backlink contained in a header against a past header which is assumed to have been
459/// retrieved from a local store.
460///
461/// This method validates that the following conditions are true:
462/// * Current and past headers contain the same public key
463/// * Current headers seq number increments from the past one by exactly `1`
464/// * Backlink hash contained in the current header matches the hash of the past header
465pub fn validate_backlink<E>(
466    past_header: &Header<E>,
467    header: &Header<E>,
468) -> Result<(), OperationError>
469where
470    E: Extensions,
471{
472    if past_header.public_key != header.public_key {
473        return Err(OperationError::TooManyAuthors);
474    }
475
476    if past_header.seq_num + 1 != header.seq_num {
477        return Err(OperationError::SeqNumNonIncremental(
478            past_header.seq_num + 1,
479            header.seq_num,
480        ));
481    }
482
483    match header.backlink {
484        Some(backlink) => {
485            if past_header.hash() != backlink {
486                return Err(OperationError::BacklinkMismatch);
487            }
488        }
489        None => {
490            return Err(OperationError::BacklinkMissing);
491        }
492    }
493
494    Ok(())
495}
496
497#[cfg(test)]
498mod tests {
499    use serde::{Deserialize, Serialize};
500
501    use crate::{Extension, PrivateKey};
502
503    use super::*;
504
505    #[test]
506    fn simple_extension_type_parameter() {
507        let private_key = PrivateKey::new();
508        let body = Body::new("Hello, Sloth!".as_bytes());
509        let mut header = Header {
510            version: 1,
511            public_key: private_key.public_key(),
512            signature: None,
513            payload_size: body.size(),
514            payload_hash: Some(body.hash()),
515            timestamp: 0,
516            seq_num: 0,
517            backlink: None,
518            previous: vec![],
519            extensions: (),
520        };
521
522        header.sign(&private_key);
523    }
524
525    #[test]
526    fn sign_and_verify() {
527        let private_key = PrivateKey::new();
528        let body = Body::new("Hello, Sloth!".as_bytes());
529        type CustomExtensions = ();
530
531        let mut header = Header {
532            version: 1,
533            public_key: private_key.public_key(),
534            signature: None,
535            payload_size: body.size(),
536            payload_hash: Some(body.hash()),
537            timestamp: 0,
538            seq_num: 0,
539            backlink: None,
540            previous: vec![],
541            extensions: None::<CustomExtensions>,
542        };
543        assert!(!header.verify());
544
545        header.sign(&private_key);
546        assert!(header.verify());
547
548        let operation = Operation {
549            hash: header.hash(),
550            header,
551            body: Some(body),
552        };
553        assert!(validate_operation(&operation).is_ok());
554    }
555
556    #[test]
557    fn valid_backlink_header() {
558        let private_key = PrivateKey::new();
559
560        let mut header_0 = Header::<()> {
561            version: 1,
562            public_key: private_key.public_key(),
563            signature: None,
564            payload_size: 0,
565            payload_hash: None,
566            timestamp: 0,
567            seq_num: 0,
568            backlink: None,
569            previous: vec![],
570            extensions: (),
571        };
572        header_0.sign(&private_key);
573        assert!(validate_header(&header_0).is_ok());
574
575        let mut header_1 = Header::<()> {
576            version: 1,
577            public_key: private_key.public_key(),
578            signature: None,
579            payload_size: 0,
580            payload_hash: None,
581            timestamp: 0,
582            seq_num: 1,
583            backlink: Some(header_0.hash()),
584            previous: vec![],
585            extensions: (),
586        };
587        header_1.sign(&private_key);
588        assert!(validate_header(&header_1).is_ok());
589
590        assert!(validate_backlink(&header_0, &header_1).is_ok());
591    }
592
593    #[test]
594    fn invalid_operations() {
595        let private_key = PrivateKey::new();
596        let body: Body = Body::new("Hello, Sloth!".as_bytes());
597
598        let header_base = Header::<()> {
599            version: 1,
600            public_key: private_key.public_key(),
601            signature: None,
602            payload_size: body.size(),
603            payload_hash: Some(body.hash()),
604            timestamp: 0,
605            seq_num: 0,
606            backlink: None,
607            previous: vec![],
608            extensions: (),
609        };
610
611        // Incompatible operation format
612        let mut header = header_base.clone();
613        header.version = 0;
614        header.sign(&private_key);
615        assert!(matches!(
616            validate_header(&header),
617            Err(OperationError::UnsupportedVersion(0, 1))
618        ));
619
620        // Signature doesn't match public key
621        let mut header = header_base.clone();
622        header.public_key = PrivateKey::new().public_key();
623        header.sign(&private_key);
624        assert!(matches!(
625            validate_header(&header),
626            Err(OperationError::SignatureMismatch)
627        ));
628
629        // Backlink missing
630        let mut header = header_base.clone();
631        header.seq_num = 1;
632        header.sign(&private_key);
633        assert!(matches!(
634            validate_header(&header),
635            Err(OperationError::BacklinkMissing)
636        ));
637
638        // Backlink given but sequence number indicates none
639        let mut header = header_base.clone();
640        header.backlink = Some(Hash::new(vec![4, 5, 6]));
641        header.sign(&private_key);
642        assert!(matches!(
643            validate_header(&header),
644            Err(OperationError::SeqNumMismatch)
645        ));
646
647        // Payload size does not match
648        let mut header = header_base.clone();
649        header.payload_size = 11;
650        header.sign(&private_key);
651        assert!(matches!(
652            validate_operation(&Operation {
653                hash: header.hash(),
654                header,
655                body: Some(body.clone()),
656            }),
657            Err(OperationError::PayloadMismatch)
658        ));
659
660        // Payload hash does not match
661        let mut header = header_base.clone();
662        header.payload_hash = Some(Hash::new(vec![4, 5, 6]));
663        header.sign(&private_key);
664        assert!(matches!(
665            validate_operation(&Operation {
666                hash: header.hash(),
667                header,
668                body: Some(body.clone()),
669            }),
670            Err(OperationError::PayloadMismatch)
671        ));
672    }
673
674    #[test]
675    fn extensions() {
676        #[derive(Clone, Debug, Serialize, Deserialize)]
677        struct LogId(Hash);
678
679        #[derive(Clone, Debug, Serialize, Deserialize)]
680        struct Expiry(u64);
681
682        #[derive(Clone, Debug, Serialize, Deserialize)]
683        struct CustomExtensions {
684            log_id: Option<LogId>,
685            expires: Expiry,
686        }
687
688        impl Extension<LogId> for CustomExtensions {
689            fn extract(header: &Header<Self>) -> Option<LogId> {
690                if header.seq_num == 0 {
691                    return Some(LogId(header.hash()));
692                };
693
694                header.extensions.log_id.clone()
695            }
696        }
697
698        impl Extension<Expiry> for CustomExtensions {
699            fn extract(header: &Header<Self>) -> Option<Expiry> {
700                Some(header.extensions.expires.clone())
701            }
702        }
703
704        let extensions = CustomExtensions {
705            log_id: None,
706            expires: Expiry(0123456),
707        };
708
709        let private_key = PrivateKey::new();
710        let body: Body = Body::new("Hello, Sloth!".as_bytes());
711
712        let mut header = Header {
713            version: 1,
714            public_key: private_key.public_key(),
715            signature: None,
716            payload_size: body.size(),
717            payload_hash: Some(body.hash()),
718            timestamp: 0,
719            seq_num: 0,
720            backlink: None,
721            previous: vec![],
722            extensions: extensions.clone(),
723        };
724
725        header.sign(&private_key);
726
727        // Thanks to blanket implementation of Extension<T> on Header we can extract the extension
728        // value from the header itself.
729        let log_id: LogId = header.extension().unwrap();
730        let expiry: Expiry = header.extension().unwrap();
731
732        assert_eq!(header.hash(), log_id.0);
733        assert_eq!(extensions.expires.0, expiry.0);
734    }
735}