Skip to main content

auths_verifier/tlog/
checkpoint.rs

1//! Log checkpoints (signed tree heads) and witness cosignatures.
2
3use chrono::{DateTime, Utc};
4use serde::{Deserialize, Serialize};
5
6use super::error::TransparencyError;
7use super::types::{LogOrigin, MerkleHash};
8use crate::core::{EcdsaP256PublicKey, EcdsaP256Signature, Ed25519PublicKey, Ed25519Signature};
9
10/// An unsigned transparency log checkpoint.
11///
12/// Args:
13/// * `origin` — Log origin string (e.g., "auths.dev/log").
14/// * `size` — Number of entries in the log at this checkpoint.
15/// * `root` — Merkle root hash of the log at this size.
16/// * `timestamp` — When the checkpoint was created.
17///
18/// Usage:
19/// ```ignore
20/// let cp = Checkpoint {
21///     origin: LogOrigin::new("auths.dev/log")?,
22///     size: 42,
23///     root: merkle_root,
24///     timestamp: Utc::now(),
25/// };
26/// ```
27#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
28#[allow(missing_docs)]
29pub struct Checkpoint {
30    pub origin: LogOrigin,
31    pub size: u64,
32    pub root: MerkleHash,
33    pub timestamp: DateTime<Utc>,
34}
35
36impl Checkpoint {
37    /// Serialize to the C2SP checkpoint body format (three lines: origin, size, base64 hash).
38    pub fn to_note_body(&self) -> String {
39        format!(
40            "{}\n{}\n{}\n",
41            self.origin,
42            self.size,
43            self.root.to_base64()
44        )
45    }
46
47    /// Parse from C2SP checkpoint body lines.
48    pub fn from_note_body(body: &str, timestamp: DateTime<Utc>) -> Result<Self, TransparencyError> {
49        let lines: Vec<&str> = body.lines().collect();
50        if lines.len() < 3 {
51            return Err(TransparencyError::InvalidNote(
52                "checkpoint body must have at least 3 lines".into(),
53            ));
54        }
55        let origin = LogOrigin::new(lines[0])?;
56        let size: u64 = lines[1]
57            .parse()
58            .map_err(|e: std::num::ParseIntError| TransparencyError::InvalidNote(e.to_string()))?;
59        let root = MerkleHash::from_base64(lines[2])?;
60        Ok(Self {
61            origin,
62            size,
63            root,
64            timestamp,
65        })
66    }
67}
68
69/// A checkpoint signed by the log operator (and optionally witnesses).
70///
71/// Args:
72/// * `checkpoint` — The unsigned checkpoint data.
73/// * `log_signature` — Ed25519 signature from the log's signing key.
74/// * `log_public_key` — The log operator's public key.
75/// * `witnesses` — Optional witness cosignatures.
76///
77/// Usage:
78/// ```ignore
79/// let signed = SignedCheckpoint {
80///     checkpoint,
81///     log_signature: sig,
82///     log_public_key: log_pk,
83///     witnesses: vec![],
84/// };
85/// ```
86#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
87#[allow(missing_docs)]
88pub struct SignedCheckpoint {
89    pub checkpoint: Checkpoint,
90    pub log_signature: Ed25519Signature,
91    pub log_public_key: Ed25519PublicKey,
92    #[serde(default, skip_serializing_if = "Vec::is_empty")]
93    pub witnesses: Vec<WitnessCosignature>,
94    /// ECDSA P-256 checkpoint signature (DER-encoded). Present when the log
95    /// uses ECDSA instead of Ed25519 (e.g., Rekor production shard).
96    #[serde(default, skip_serializing_if = "Option::is_none")]
97    pub ecdsa_checkpoint_signature: Option<EcdsaP256Signature>,
98    /// ECDSA P-256 public key for checkpoint verification (PKIX DER).
99    #[serde(default, skip_serializing_if = "Option::is_none")]
100    pub ecdsa_checkpoint_key: Option<EcdsaP256PublicKey>,
101}
102
103impl SignedCheckpoint {
104    /// Verify the log operator's Ed25519 signature over this checkpoint's C2SP
105    /// note body against a **pinned** log key — never against the key the
106    /// checkpoint itself carries (a self-chosen key verifying its own forged
107    /// signature is the classic "trust the key I sent you" anti-pattern).
108    ///
109    /// Two checks, both fail-closed:
110    /// 1. the embedded `log_public_key` must BE the pinned key (constant-time
111    ///    compare) — a checkpoint from a different operator is not this log's,
112    ///    even if its own signature is internally consistent;
113    /// 2. `log_signature` must verify over [`Checkpoint::to_note_body`] under
114    ///    the pinned key.
115    ///
116    /// Pure and synchronous (`ed25519-dalek`), so every surface — native, FFI,
117    /// browser WASM — shares this one implementation.
118    ///
119    /// Args:
120    /// * `pinned_log_key`: The log operator's Ed25519 key, obtained out of band.
121    ///
122    /// Usage:
123    /// ```ignore
124    /// signed.verify_log_signature(&pinned_log_key)?;
125    /// ```
126    pub fn verify_log_signature(
127        &self,
128        pinned_log_key: &Ed25519PublicKey,
129    ) -> Result<(), TransparencyError> {
130        use subtle::ConstantTimeEq;
131        let key_is_pinned: bool = self
132            .log_public_key
133            .as_bytes()
134            .ct_eq(pinned_log_key.as_bytes())
135            .into();
136        if !key_is_pinned {
137            return Err(TransparencyError::InvalidCheckpointSignature);
138        }
139        let verifying_key = ed25519_dalek::VerifyingKey::from_bytes(pinned_log_key.as_bytes())
140            .map_err(|_| TransparencyError::InvalidCheckpointSignature)?;
141        let signature = ed25519_dalek::Signature::from_bytes(self.log_signature.as_bytes());
142        verifying_key
143            .verify_strict(self.checkpoint.to_note_body().as_bytes(), &signature)
144            .map_err(|_| TransparencyError::InvalidCheckpointSignature)
145    }
146}
147
148/// A witness cosignature on a checkpoint.
149///
150/// Witnesses independently verify the checkpoint and add their signature
151/// to increase trust in the log's consistency claims.
152#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
153#[allow(missing_docs)]
154pub struct WitnessCosignature {
155    pub witness_name: String,
156    pub witness_public_key: Ed25519PublicKey,
157    pub signature: Ed25519Signature,
158    pub timestamp: DateTime<Utc>,
159}
160
161#[cfg(test)]
162mod tests {
163    use super::*;
164
165    #[test]
166    fn checkpoint_note_body_roundtrip() {
167        let ts = chrono::DateTime::parse_from_rfc3339("2025-06-01T00:00:00Z")
168            .unwrap()
169            .with_timezone(&Utc);
170        let cp = Checkpoint {
171            origin: LogOrigin::new("auths.dev/log").unwrap(),
172            size: 42,
173            root: MerkleHash::from_bytes([0xab; 32]),
174            timestamp: ts,
175        };
176        let body = cp.to_note_body();
177        let parsed = Checkpoint::from_note_body(&body, ts).unwrap();
178        assert_eq!(cp.origin, parsed.origin);
179        assert_eq!(cp.size, parsed.size);
180        assert_eq!(cp.root, parsed.root);
181    }
182
183    fn signed_by(signing_key: &ed25519_dalek::SigningKey) -> SignedCheckpoint {
184        use ed25519_dalek::Signer;
185        let ts = chrono::DateTime::parse_from_rfc3339("2025-06-01T00:00:00Z")
186            .unwrap()
187            .with_timezone(&Utc);
188        let checkpoint = Checkpoint {
189            origin: LogOrigin::new("auths.dev/log").unwrap(),
190            size: 42,
191            root: MerkleHash::from_bytes([0xab; 32]),
192            timestamp: ts,
193        };
194        let signature = signing_key.sign(checkpoint.to_note_body().as_bytes());
195        SignedCheckpoint {
196            checkpoint,
197            log_signature: Ed25519Signature::from_bytes(signature.to_bytes()),
198            log_public_key: Ed25519PublicKey::from_bytes(signing_key.verifying_key().to_bytes()),
199            witnesses: vec![],
200            ecdsa_checkpoint_signature: None,
201            ecdsa_checkpoint_key: None,
202        }
203    }
204
205    #[test]
206    fn log_signature_verifies_under_the_pinned_key() {
207        let signing_key = ed25519_dalek::SigningKey::from_bytes(&[7u8; 32]);
208        let signed = signed_by(&signing_key);
209        let pinned = Ed25519PublicKey::from_bytes(signing_key.verifying_key().to_bytes());
210        signed
211            .verify_log_signature(&pinned)
212            .expect("operator-signed checkpoint verifies under its pinned key");
213    }
214
215    #[test]
216    fn log_signature_rejects_a_different_pinned_operator() {
217        let signing_key = ed25519_dalek::SigningKey::from_bytes(&[7u8; 32]);
218        let signed = signed_by(&signing_key);
219        let other = ed25519_dalek::SigningKey::from_bytes(&[9u8; 32]);
220        let pinned = Ed25519PublicKey::from_bytes(other.verifying_key().to_bytes());
221        assert!(
222            signed.verify_log_signature(&pinned).is_err(),
223            "a checkpoint from a different operator must fail the pinned-key check"
224        );
225    }
226
227    #[test]
228    fn log_signature_rejects_a_forged_signature() {
229        let signing_key = ed25519_dalek::SigningKey::from_bytes(&[7u8; 32]);
230        let mut signed = signed_by(&signing_key);
231        // Forge: same pinned key claimed, but the signature bytes are garbage.
232        signed.log_signature = Ed25519Signature::from_bytes([0x42; 64]);
233        let pinned = Ed25519PublicKey::from_bytes(signing_key.verifying_key().to_bytes());
234        assert!(
235            signed.verify_log_signature(&pinned).is_err(),
236            "a forged checkpoint signature must fail closed"
237        );
238    }
239
240    #[test]
241    fn log_signature_rejects_a_tampered_checkpoint_body() {
242        let signing_key = ed25519_dalek::SigningKey::from_bytes(&[7u8; 32]);
243        let mut signed = signed_by(&signing_key);
244        // Backdate/rewrite: the root changes but the old signature is replayed.
245        signed.checkpoint.root = MerkleHash::from_bytes([0xcd; 32]);
246        let pinned = Ed25519PublicKey::from_bytes(signing_key.verifying_key().to_bytes());
247        assert!(
248            signed.verify_log_signature(&pinned).is_err(),
249            "a rewritten checkpoint body must not verify under the old signature"
250        );
251    }
252
253    #[test]
254    fn checkpoint_json_roundtrip() {
255        let ts = chrono::DateTime::parse_from_rfc3339("2025-06-01T00:00:00Z")
256            .unwrap()
257            .with_timezone(&Utc);
258        let cp = Checkpoint {
259            origin: LogOrigin::new("auths.dev/log").unwrap(),
260            size: 100,
261            root: MerkleHash::from_bytes([0x01; 32]),
262            timestamp: ts,
263        };
264        let json = serde_json::to_string(&cp).unwrap();
265        let back: Checkpoint = serde_json::from_str(&json).unwrap();
266        assert_eq!(cp, back);
267    }
268}