Skip to main content

signed_ulid/
lib.rs

1//! An sUILD is a "signed ULID". It's like a ULID, but works better in distributed systems.
2//!
3//! # The Problem
4//!
5//! Normal ULIDs have two parts:
6//! 1) A 48-bit timestamp
7//! 2) A random 80-bit suffix.
8//!
9//! Together, these *should* be globally unique. However, in a distributed
10//! system composed of peers of varying trustworthiness, things can go wrong.
11//! Malicious peers are free to assign their own ULIDs which conflict with those
12//! that already exist in other systems. This could be used as a form of Denial of
13//! Service attack, if the attacker can cause their ULIDs to supersede or replace an
14//! existing ULID.
15//!
16//! We need something like ULIDs, but with the following properties:
17//!
18//! 1) Malicious users can not (easily) cause duplicate ULIDs to enter the system.
19//! 2) System administrators can not modify the ULID for a message.
20//!
21//! # The Solution
22//!
23//! sULIDs solve each of the above needs.
24//!
25//! 1) The "random" 80 bits of a ULID are replaced by 80 bits derived from
26//!    a cryptographic signature. It is non-trivial to generate a signature that
27//!    has a collision on these bits.
28//! 2) The timestamp portion of the ULID is part of the signed payload, so an
29//!    admin can not change the timestamp without breaking the sULID/signature relationship.
30//!
31//! Additionally, the payload signed by sULIDs contains a blake3 hash of the content being signed.
32//! If the content is large, systems can take advantage of blake3 "verified streaming" to
33//! verify content bytes as they are being fetched.
34//!
35//! # Example
36//!
37//! ```
38//! # use std::time::SystemTime;
39//! # use signed_ulid::{UnsignedPayload, Sulid};
40//! # use ed25519_dalek::{SigningKey};
41//! #
42//! # pub fn blake3hash(bytes: &[u8]) -> blake3::Hash {
43//! #     let mut hasher = blake3::Hasher::new();
44//! #     hasher.update(bytes);
45//! #     hasher.finalize()
46//! # }
47//! #
48//! # pub fn random_secret() -> SigningKey {
49//! #     use getrandom::SysRng;
50//! #     use rand_core::UnwrapErr;
51//! #     let mut prng = UnwrapErr(SysRng);
52//! #     SigningKey::generate(&mut prng)
53//! # }
54//! # let secret = random_secret();
55//! #
56//! let message = "This message will be signed and given an sULID.";
57//! let app_context = b"my-app".to_vec();
58//!
59//! let signed = Sulid::sign(
60//!     &secret,
61//!     UnsignedPayload {
62//!         timestamp: SystemTime::now(),
63//!         message_hash: blake3hash(message.as_bytes()),
64//!         message_length: message.as_bytes().len() as u64,
65//!         app_metadata: app_context,
66//!     },
67//! );
68//!
69//! println!("Generated sULID: {}", signed.sulid);
70//! assert!(signed.is_valid());
71//! ```
72//!
73//! This crate doesn't dictate how you serialize the [`SignedPayload`], only that you must be able to
74//! reconstruct it to validate that the sULID and signature are in agreement. For example, the above
75//! message and `SignedPayload` might be serialized into plaintext, with an inline message, like this:
76//!
77//! ```text
78//! id: 01M10D78ZBGZNVWHA60G8P95W1
79//! by: WchunVqWZD7TfVoYkM1BPCzDpsrKTyN8ur2aZxWwbjQ
80//! sig: 46kq3wNwQTjZKH9PjSWJeX9a7SwMkSni2t7hxorRDmgNXqrkYPey7WodyLs1npHBsFcdFGCJcV7dHF2hJtWPcpKR
81//!
82//! This message will be signed and given an sULID.
83//! ```
84//!
85//! You can reconstructed the `SignedPayload` fields `sulid`, `public_key`, and
86//! `signature` directly from the first 3 lines.
87//!
88//! The message, which begins after the empty line, can be used to recalculate
89//! `message_hash` and `message_length`.
90//!
91//! And `app_metadata` in this case is just hard-coded by our application to
92//! distinguish it from other signing schemes.  But, it could be extended to allow
93//! more (signed!) fields in the header.
94
95mod implementation;
96
97mod lib_test;
98
99use std::{fmt::Display, io::Write, str::FromStr, time::SystemTime};
100
101// re-export, since its types are part of our public api.
102pub use ed25519_dalek;
103
104use ed25519_dalek::{Signature, Signer, SigningKey, VerifyingKey};
105use ulid::Ulid;
106
107pub use ulid::DecodeError as UlidDecodeError;
108
109use crate::implementation::{MsSinceEpoch, PayloadBytes as _};
110
111/// A "signed" ULID, whose "random" portion is generated by a cryptographic signature.
112///
113#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
114pub struct Sulid {
115    ulid: Ulid,
116}
117
118// public impl
119impl Sulid {
120    /// Create a new sULID by signing a payload.
121    ///
122    /// Returns a [`SignedPayload`] which includes the generated [`Sulid`], as well
123    /// as the payload parts required to verify it.
124    ///
125    /// The `SignedPayload` generated by this function is guaranteed to be valid. However,
126    /// to validate untrusted sULIDs, you can construct a `SignedPayload` and call its `is_valid()`.
127    pub fn sign(secret: &SigningKey, payload: UnsignedPayload) -> SignedPayload {
128        let signature = secret.sign(&payload.bytes());
129
130        SignedPayload {
131            sulid: Sulid::from_parts(payload.timestamp, &signature),
132            public_key: secret.verifying_key(),
133            signature,
134            message_hash: payload.message_hash,
135            message_length: payload.message_length,
136            app_metadata: payload.app_metadata,
137        }
138    }
139
140    /// Gets the timestamp portion of the sULID from the first 48 bits.
141    pub fn timestamp(&self) -> SystemTime {
142        self.ulid.datetime()
143    }
144
145    pub fn to_bytes(&self) -> [u8; 16] {
146        self.ulid.to_bytes()
147    }
148}
149
150// private impl
151impl Sulid {
152    fn from_parts(timestamp: SystemTime, signature: &Signature) -> Self {
153        let mut bytes = [0u8; 16];
154        let mut writer = bytes.as_mut_slice();
155
156        let ts_bytes = timestamp.ms_since_epoch().to_be_bytes();
157        writer
158            .write(&ts_bytes[2..8])
159            .expect("writing 6 bytes of timestamp");
160
161        let sig_hash = {
162            // For our "random" 80 bits, we use the first 80 bits of the *hash* of the signature.
163            // This should make it more difficult to force a collision, vs. just grabbing the first 80
164            // bits of the signature. (any byte changed in the signature results in big changes to the hash)
165            let mut hasher = blake3::Hasher::new();
166            hasher.update(&signature.to_bytes());
167            hasher.finalize()
168        };
169        writer
170            .write(&sig_hash.as_bytes()[0..10])
171            .expect("writing 10 bytes of blake3 hash");
172
173        Self {
174            ulid: Ulid::from_bytes(bytes),
175        }
176    }
177}
178
179/// Provides the canonical .to_string() form of sULIDs
180impl Display for Sulid {
181    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
182        self.ulid.fmt(f)
183    }
184}
185
186impl FromStr for Sulid {
187    type Err = UlidDecodeError;
188
189    /// Note: This only parses/deserializes an sULID from its string representation.
190    /// To verify that it has not been tampered with, check it using [`SignedPayload::is_valid()`]
191    fn from_str(s: &str) -> Result<Self, Self::Err> {
192        Ok(Self { ulid: s.parse()? })
193    }
194}
195
196impl From<[u8; 16]> for Sulid {
197    fn from(value: [u8; 16]) -> Self {
198        Self { ulid: value.into() }
199    }
200}
201
202/// Passed to [`Sulid::sign`] to create an sULID.
203pub struct UnsignedPayload {
204    /// The timestamp portion of the sULID will be based on this.
205    /// It is also part of the signed payload, to prevent tampering after signing.
206    pub timestamp: SystemTime,
207
208    /// A hash of the message being signed.
209    pub message_hash: blake3::Hash,
210
211    /// The length of the content being signed, in bytes.
212    pub message_length: u64,
213
214    /// For basic use, you may leave this field empty.
215    ///
216    /// You may optionally add extra, application-specific metadata to the
217    /// signed payload. This is useful if you want to make sure this data can be
218    /// read and verified along with the sULID *before* the main content is fetched/validated/displayed.
219    ///
220    /// As an example, you may want to include a `Content-Type` style header to distinguish
221    /// different types of signed content.
222    ///
223    /// You might also want to include an application-specific marker to distinguish signatures
224    /// in that context from other signatures.
225    ///
226    /// Note that this crate makes no requriements of app_metadata other than that you must be able to
227    /// reproduce it to verify an sULID. Make sure any data included here has a canonical form, so that
228    /// you can reproduce it reliably.
229    pub app_metadata: Vec<u8>,
230}
231
232/// Output of [`Sulid::sign`], also used to verify sULIDs.
233///
234/// This contains the generated sULID and the necessary context to validate it.
235/// The sULID generated by [`Sulid::sign`] will always be valid, so no need to revalidate it.
236///
237/// However, if you want to validate an untrusted sULID, construct this SignedPayload and check
238/// [`SignedPayload::is_valid()`]
239#[derive(Debug, Clone)]
240pub struct SignedPayload {
241    pub sulid: Sulid,
242    pub public_key: VerifyingKey,
243    pub signature: Signature,
244    pub message_hash: blake3::Hash,
245    pub message_length: u64,
246    pub app_metadata: Vec<u8>,
247}
248
249impl SignedPayload {
250    /// Checks that the sULID agrees with the rest of the payload.
251    /// (This process checks that the signature is valid for the payload as well.)
252    pub fn is_valid(&self) -> bool {
253        let payload_bytes = self.bytes();
254
255        if !self
256            .public_key
257            .verify_strict(&payload_bytes, &self.signature)
258            .is_ok()
259        {
260            return false;
261        }
262
263        let expected_sulid = Sulid::from_parts(self.sulid.timestamp(), &self.signature);
264
265        self.sulid == expected_sulid
266    }
267}