signed-ulid 0.1.1

Like ULIDs, but based on cryptographic signatures instead of randomness.
Documentation
//! An sUILD is a "signed ULID". It's like a ULID, but works better in distributed systems.
//!
//! # The Problem
//!
//! Normal ULIDs have two parts:
//! 1) A 48-bit timestamp
//! 2) A random 80-bit suffix.
//!
//! Together, these *should* be globally unique. However, in a distributed
//! system composed of peers of varying trustworthiness, things can go wrong.
//! Malicious peers are free to assign their own ULIDs which conflict with those
//! that already exist in other systems. This could be used as a form of Denial of
//! Service attack, if the attacker can cause their ULIDs to supersede or replace an
//! existing ULID.
//!
//! We need something like ULIDs, but with the following properties:
//!
//! 1) Malicious users can not (easily) cause duplicate ULIDs to enter the system.
//! 2) System administrators can not modify the ULID for a message.
//!
//! # The Solution
//!
//! sULIDs solve each of the above needs.
//!
//! 1) The "random" 80 bits of a ULID are replaced by 80 bits derived from
//!    a cryptographic signature. It is non-trivial to generate a signature that
//!    has a collision on these bits.
//! 2) The timestamp portion of the ULID is part of the signed payload, so an
//!    admin can not change the timestamp without breaking the sULID/signature relationship.
//!
//! Additionally, the payload signed by sULIDs contains a blake3 hash of the content being signed.
//! If the content is large, systems can take advantage of blake3 "verified streaming" to
//! verify content bytes as they are being fetched.
//!
//! # Example
//!
//! ```
//! # use std::time::SystemTime;
//! # use signed_ulid::{UnsignedPayload, Sulid};
//! # use ed25519_dalek::{SigningKey};
//! #
//! # pub fn blake3hash(bytes: &[u8]) -> blake3::Hash {
//! #     let mut hasher = blake3::Hasher::new();
//! #     hasher.update(bytes);
//! #     hasher.finalize()
//! # }
//! #
//! # pub fn random_secret() -> SigningKey {
//! #     use getrandom::SysRng;
//! #     use rand_core::UnwrapErr;
//! #     let mut prng = UnwrapErr(SysRng);
//! #     SigningKey::generate(&mut prng)
//! # }
//! # let secret = random_secret();
//! #
//! let message = "This message will be signed and given an sULID.";
//! let app_context = b"my-app".to_vec();
//!
//! let signed = Sulid::sign(
//!     &secret,
//!     UnsignedPayload {
//!         timestamp: SystemTime::now(),
//!         message_hash: blake3hash(message.as_bytes()),
//!         message_length: message.as_bytes().len() as u64,
//!         app_metadata: app_context,
//!     },
//! );
//!
//! println!("Generated sULID: {}", signed.sulid);
//! assert!(signed.is_valid());
//! ```
//!
//! This crate doesn't dictate how you serialize the [`SignedPayload`], only that you must be able to
//! reconstruct it to validate that the sULID and signature are in agreement. For example, the above
//! message and `SignedPayload` might be serialized into plaintext, with an inline message, like this:
//!
//! ```text
//! id: 01M10D78ZBGZNVWHA60G8P95W1
//! by: WchunVqWZD7TfVoYkM1BPCzDpsrKTyN8ur2aZxWwbjQ
//! sig: 46kq3wNwQTjZKH9PjSWJeX9a7SwMkSni2t7hxorRDmgNXqrkYPey7WodyLs1npHBsFcdFGCJcV7dHF2hJtWPcpKR
//!
//! This message will be signed and given an sULID.
//! ```
//!
//! You can reconstructed the `SignedPayload` fields `sulid`, `public_key`, and
//! `signature` directly from the first 3 lines.
//!
//! The message, which begins after the empty line, can be used to recalculate
//! `message_hash` and `message_length`.
//!
//! And `app_metadata` in this case is just hard-coded by our application to
//! distinguish it from other signing schemes.  But, it could be extended to allow
//! more (signed!) fields in the header.

mod implementation;

mod lib_test;

use std::{fmt::Display, io::Write, str::FromStr, time::SystemTime};

// re-export, since its types are part of our public api.
pub use ed25519_dalek;

use ed25519_dalek::{Signature, Signer, SigningKey, VerifyingKey};
use ulid::Ulid;

pub use ulid::DecodeError as UlidDecodeError;

use crate::implementation::{MsSinceEpoch, PayloadBytes as _};

/// A "signed" ULID, whose "random" portion is generated by a cryptographic signature.
///
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
pub struct Sulid {
    ulid: Ulid,
}

// public impl
impl Sulid {
    /// Create a new sULID by signing a payload.
    ///
    /// Returns a [`SignedPayload`] which includes the generated [`Sulid`], as well
    /// as the payload parts required to verify it.
    ///
    /// The `SignedPayload` generated by this function is guaranteed to be valid. However,
    /// to validate untrusted sULIDs, you can construct a `SignedPayload` and call its `is_valid()`.
    pub fn sign(secret: &SigningKey, payload: UnsignedPayload) -> SignedPayload {
        let signature = secret.sign(&payload.bytes());

        SignedPayload {
            sulid: Sulid::from_parts(payload.timestamp, &signature),
            public_key: secret.verifying_key(),
            signature,
            message_hash: payload.message_hash,
            message_length: payload.message_length,
            app_metadata: payload.app_metadata,
        }
    }

    /// Gets the timestamp portion of the sULID from the first 48 bits.
    pub fn timestamp(&self) -> SystemTime {
        self.ulid.datetime()
    }

    pub fn to_bytes(&self) -> [u8; 16] {
        self.ulid.to_bytes()
    }
}

// private impl
impl Sulid {
    fn from_parts(timestamp: SystemTime, signature: &Signature) -> Self {
        let mut bytes = [0u8; 16];
        let mut writer = bytes.as_mut_slice();

        let ts_bytes = timestamp.ms_since_epoch().to_be_bytes();
        writer
            .write(&ts_bytes[2..8])
            .expect("writing 6 bytes of timestamp");

        let sig_hash = {
            // For our "random" 80 bits, we use the first 80 bits of the *hash* of the signature.
            // This should make it more difficult to force a collision, vs. just grabbing the first 80
            // bits of the signature. (any byte changed in the signature results in big changes to the hash)
            let mut hasher = blake3::Hasher::new();
            hasher.update(&signature.to_bytes());
            hasher.finalize()
        };
        writer
            .write(&sig_hash.as_bytes()[0..10])
            .expect("writing 10 bytes of blake3 hash");

        Self {
            ulid: Ulid::from_bytes(bytes),
        }
    }
}

/// Provides the canonical .to_string() form of sULIDs
impl Display for Sulid {
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        self.ulid.fmt(f)
    }
}

impl FromStr for Sulid {
    type Err = UlidDecodeError;

    /// Note: This only parses/deserializes an sULID from its string representation.
    /// To verify that it has not been tampered with, check it using [`SignedPayload::is_valid()`]
    fn from_str(s: &str) -> Result<Self, Self::Err> {
        Ok(Self { ulid: s.parse()? })
    }
}

impl From<[u8; 16]> for Sulid {
    fn from(value: [u8; 16]) -> Self {
        Self { ulid: value.into() }
    }
}

/// Passed to [`Sulid::sign`] to create an sULID.
pub struct UnsignedPayload {
    /// The timestamp portion of the sULID will be based on this.
    /// It is also part of the signed payload, to prevent tampering after signing.
    pub timestamp: SystemTime,

    /// A hash of the message being signed.
    pub message_hash: blake3::Hash,

    /// The length of the content being signed, in bytes.
    pub message_length: u64,

    /// For basic use, you may leave this field empty.
    ///
    /// You may optionally add extra, application-specific metadata to the
    /// signed payload. This is useful if you want to make sure this data can be
    /// read and verified along with the sULID *before* the main content is fetched/validated/displayed.
    ///
    /// As an example, you may want to include a `Content-Type` style header to distinguish
    /// different types of signed content.
    ///
    /// You might also want to include an application-specific marker to distinguish signatures
    /// in that context from other signatures.
    ///
    /// Note that this crate makes no requriements of app_metadata other than that you must be able to
    /// reproduce it to verify an sULID. Make sure any data included here has a canonical form, so that
    /// you can reproduce it reliably.
    pub app_metadata: Vec<u8>,
}

/// Output of [`Sulid::sign`], also used to verify sULIDs.
///
/// This contains the generated sULID and the necessary context to validate it.
/// The sULID generated by [`Sulid::sign`] will always be valid, so no need to revalidate it.
///
/// However, if you want to validate an untrusted sULID, construct this SignedPayload and check
/// [`SignedPayload::is_valid()`]
#[derive(Debug, Clone)]
pub struct SignedPayload {
    pub sulid: Sulid,
    pub public_key: VerifyingKey,
    pub signature: Signature,
    pub message_hash: blake3::Hash,
    pub message_length: u64,
    pub app_metadata: Vec<u8>,
}

impl SignedPayload {
    /// Checks that the sULID agrees with the rest of the payload.
    /// (This process checks that the signature is valid for the payload as well.)
    pub fn is_valid(&self) -> bool {
        let payload_bytes = self.bytes();

        if !self
            .public_key
            .verify_strict(&payload_bytes, &self.signature)
            .is_ok()
        {
            return false;
        }

        let expected_sulid = Sulid::from_parts(self.sulid.timestamp(), &self.signature);

        self.sulid == expected_sulid
    }
}