Skip to main content

auths_transparency/
writer.rs

1//! The write half of the transparency log: append leaves, sign checkpoints,
2//! and mint inclusion proofs a verifier can replay offline.
3//!
4//! [`LogWriter`] is storage-agnostic: it speaks the same [`TileStore`] port
5//! the read path uses (filesystem via [`crate::FsTileStore`], S3 behind the
6//! `s3` feature), persisting leaf hashes as C2SP level-0 tiles and the
7//! current [`SignedCheckpoint`] (JSON) in the store's checkpoint slot. The
8//! Merkle math is the shared RFC 6962 implementation in
9//! `auths_verifier::tlog`, so every proof minted here is checked by the
10//! exact code all verifier surfaces (native, FFI, browser WASM) run.
11//!
12//! Scale posture: the writer re-derives the full leaf list from tiles on
13//! every call and re-verifies the stored root against it — fail-closed on a
14//! corrupted store. That is O(n) per operation by design: this is the
15//! local-first/operator log (release histories, org evidence), not the
16//! hosted sequencer, which keeps the tree in memory.
17
18use auths_crypto::{CurveType, TypedSeed, TypedSignerKey};
19use auths_verifier::evidence_pack::TransparencyInclusion;
20use auths_verifier::{Ed25519PublicKey, Ed25519Signature};
21use chrono::{DateTime, Utc};
22
23use crate::checkpoint::{Checkpoint, SignedCheckpoint};
24use crate::error::TransparencyError;
25use crate::merkle::{compute_root, prove_inclusion};
26use crate::proof::InclusionProof;
27use crate::store::TileStore;
28use crate::tile::{TILE_WIDTH, leaf_tile, tile_count, tile_path};
29use crate::types::{LogOrigin, MerkleHash};
30
31/// The log operator's Ed25519 checkpoint-signing key, parsed and
32/// curve-checked at construction so signing can never fail on curve drift.
33/// The C2SP signed-note `log_signature` field is Ed25519-pinned by spec;
34/// non-Ed25519 keys are rejected here, once, at the boundary.
35pub struct LogSigningKey {
36    signer: TypedSignerKey,
37    public_key: Ed25519PublicKey,
38}
39
40impl LogSigningKey {
41    /// Generate a fresh Ed25519 signing key from OS randomness.
42    ///
43    /// Usage:
44    /// ```ignore
45    /// let key = LogSigningKey::generate()?;
46    /// std::fs::write(key_path, key.to_pkcs8_der()?)?;
47    /// ```
48    pub fn generate() -> Result<Self, TransparencyError> {
49        use ring::rand::SecureRandom;
50        let rng = ring::rand::SystemRandom::new();
51        let mut seed = [0u8; 32];
52        rng.fill(&mut seed)
53            .map_err(|_| TransparencyError::SigningKey("OS randomness unavailable".into()))?;
54        let signer = TypedSignerKey::from_seed(TypedSeed::Ed25519(seed))
55            .map_err(|e| TransparencyError::SigningKey(e.to_string()))?;
56        Self::from_signer(signer)
57    }
58
59    /// Parse a signing key from PKCS#8 DER bytes.
60    ///
61    /// Args:
62    /// * `der` — PKCS#8 DER as previously produced by [`Self::to_pkcs8_der`].
63    pub fn from_pkcs8_der(der: &[u8]) -> Result<Self, TransparencyError> {
64        let signer = TypedSignerKey::from_pkcs8(der)
65            .map_err(|e| TransparencyError::SigningKey(e.to_string()))?;
66        Self::from_signer(signer)
67    }
68
69    fn from_signer(signer: TypedSignerKey) -> Result<Self, TransparencyError> {
70        if signer.curve() != CurveType::Ed25519 {
71            return Err(TransparencyError::SigningKey(
72                "checkpoint signing key must be Ed25519 (C2SP signed-note pins the curve)".into(),
73            ));
74        }
75        let public_key = Ed25519PublicKey::try_from_slice(signer.public_key())
76            .map_err(|e| TransparencyError::SigningKey(e.to_string()))?;
77        Ok(Self { signer, public_key })
78    }
79
80    /// PKCS#8 DER bytes for persisting the key alongside the log.
81    pub fn to_pkcs8_der(&self) -> Result<Vec<u8>, TransparencyError> {
82        Ok(self
83            .signer
84            .to_pkcs8()
85            .map_err(|e| TransparencyError::SigningKey(e.to_string()))?
86            .as_ref()
87            .to_vec())
88    }
89
90    /// The log's public key — what verifiers pin as the log identity.
91    pub fn public_key(&self) -> Ed25519PublicKey {
92        self.public_key
93    }
94}
95
96/// The outcome of appending one leaf: its assigned index and the new signed
97/// checkpoint covering it.
98#[derive(Debug, Clone)]
99pub struct AppendedLeaf {
100    /// Zero-based index the leaf was sequenced at.
101    pub index: u64,
102    /// The checkpoint signed over the tree that now includes the leaf.
103    pub signed_checkpoint: SignedCheckpoint,
104}
105
106/// Appends leaves to a tile-backed transparency log and mints offline
107/// inclusion proofs against its signed checkpoint.
108///
109/// Args:
110/// * `store` — Any [`TileStore`] (e.g. [`crate::FsTileStore`]).
111/// * `key` — The log operator's [`LogSigningKey`].
112/// * `origin` — The log's origin line; a store whose checkpoint carries a
113///   different origin is rejected on every operation.
114///
115/// Usage:
116/// ```ignore
117/// let writer = LogWriter::new(FsTileStore::new(dir), key, origin);
118/// let leaf = hash_leaf(b"sha256:...");
119/// let appended = writer.append(leaf, now).await?;
120/// let inclusion = writer.prove(&leaf).await?;
121/// ```
122pub struct LogWriter<S: TileStore> {
123    store: S,
124    key: LogSigningKey,
125    origin: LogOrigin,
126}
127
128impl<S: TileStore> LogWriter<S> {
129    /// Create a writer over a tile store with the given signing key + origin.
130    pub fn new(store: S, key: LogSigningKey, origin: LogOrigin) -> Self {
131        Self { store, key, origin }
132    }
133
134    /// Append one leaf hash: persist it to the level-0 tiles, recompute the
135    /// root, and sign a fresh checkpoint over the grown tree.
136    ///
137    /// Args:
138    /// * `leaf_hash` — The RFC 6962 leaf hash (see `hash_leaf`).
139    /// * `now` — Injected checkpoint timestamp (never read from a wall clock
140    ///   here).
141    pub async fn append(
142        &self,
143        leaf_hash: MerkleHash,
144        now: DateTime<Utc>,
145    ) -> Result<AppendedLeaf, TransparencyError> {
146        let mut leaves = match self.read_state().await? {
147            Some((_, leaves)) => leaves,
148            None => Vec::new(),
149        };
150        let index = leaves.len() as u64;
151        leaves.push(leaf_hash);
152
153        // Persist exactly the tile the new leaf lands in. A tile reaching
154        // TILE_WIDTH is written at its full path (write-once); a growing
155        // tile at its partial path (overwritable).
156        let (tile_index, offset) = leaf_tile(index);
157        let width = offset + 1;
158        let start = usize::try_from(tile_index * TILE_WIDTH)
159            .map_err(|_| TransparencyError::StoreError("tile index out of range".into()))?;
160        let mut data = Vec::with_capacity((width as usize) * 32);
161        for leaf in &leaves[start..start + width as usize] {
162            data.extend_from_slice(leaf.as_bytes());
163        }
164        let path = tile_path(0, tile_index, width % TILE_WIDTH)?;
165        self.store.write_tile(&path, &data).await?;
166
167        let checkpoint = Checkpoint {
168            origin: self.origin.clone(),
169            size: leaves.len() as u64,
170            root: compute_root(&leaves),
171            timestamp: now,
172        };
173        let signed_checkpoint = self.sign(checkpoint)?;
174        let bytes = serde_json::to_vec(&signed_checkpoint)
175            .map_err(|e| TransparencyError::StoreError(e.to_string()))?;
176        self.store.write_checkpoint(&bytes).await?;
177
178        Ok(AppendedLeaf {
179            index,
180            signed_checkpoint,
181        })
182    }
183
184    /// Mint the offline inclusion evidence for a leaf already in the log:
185    /// an inclusion proof directly against the current signed checkpoint.
186    pub async fn prove(
187        &self,
188        leaf_hash: &MerkleHash,
189    ) -> Result<TransparencyInclusion, TransparencyError> {
190        let Some((signed_checkpoint, leaves)) = self.read_state().await? else {
191            return Err(TransparencyError::InvalidProof(
192                "the log is empty — nothing has been appended".into(),
193            ));
194        };
195        let index = leaves
196            .iter()
197            .position(|leaf| leaf == leaf_hash)
198            .ok_or_else(|| {
199                TransparencyError::InvalidProof("leaf is not in the log — append it first".into())
200            })? as u64;
201
202        let inclusion_proof = InclusionProof {
203            index,
204            size: signed_checkpoint.checkpoint.size,
205            root: signed_checkpoint.checkpoint.root,
206            hashes: prove_inclusion(&leaves, index)?,
207        };
208        // Never emit evidence we have not replayed through the verifier path.
209        inclusion_proof.verify(leaf_hash)?;
210
211        Ok(TransparencyInclusion {
212            leaf_hash: *leaf_hash,
213            inclusion_proof,
214            signed_checkpoint,
215            consistency_proof: None,
216        })
217    }
218
219    /// Load and re-verify the persisted log state: `None` when no checkpoint
220    /// exists yet, otherwise the checkpoint plus every leaf, with the stored
221    /// root recomputed from the leaves — a store that disagrees with its own
222    /// checkpoint fails closed.
223    async fn read_state(
224        &self,
225    ) -> Result<Option<(SignedCheckpoint, Vec<MerkleHash>)>, TransparencyError> {
226        let Some(bytes) = self.store.read_checkpoint().await? else {
227            return Ok(None);
228        };
229        let signed: SignedCheckpoint = serde_json::from_slice(&bytes)
230            .map_err(|e| TransparencyError::StoreError(format!("checkpoint parse: {e}")))?;
231        if signed.checkpoint.origin != self.origin {
232            return Err(TransparencyError::InvalidOrigin(format!(
233                "log belongs to origin '{}', not '{}'",
234                signed.checkpoint.origin, self.origin
235            )));
236        }
237        let leaves = self.read_leaves(signed.checkpoint.size).await?;
238        let recomputed = compute_root(&leaves);
239        if recomputed != signed.checkpoint.root {
240            return Err(TransparencyError::RootMismatch {
241                expected: signed.checkpoint.root.to_string(),
242                actual: recomputed.to_string(),
243            });
244        }
245        Ok(Some((signed, leaves)))
246    }
247
248    /// Read every leaf hash for a tree of `size` from the level-0 tiles.
249    async fn read_leaves(&self, size: u64) -> Result<Vec<MerkleHash>, TransparencyError> {
250        let (full_tiles, partial_width) = tile_count(size);
251        let mut leaves = Vec::with_capacity(size as usize);
252        for tile_index in 0..full_tiles {
253            let path = tile_path(0, tile_index, 0)?;
254            let data = self.store.read_tile(&path).await?;
255            parse_leaf_tile(&data, TILE_WIDTH, &path, &mut leaves)?;
256        }
257        if partial_width > 0 {
258            let path = tile_path(0, full_tiles, partial_width)?;
259            let data = self.store.read_tile(&path).await?;
260            parse_leaf_tile(&data, partial_width, &path, &mut leaves)?;
261        }
262        Ok(leaves)
263    }
264
265    fn sign(&self, checkpoint: Checkpoint) -> Result<SignedCheckpoint, TransparencyError> {
266        let body = checkpoint.to_note_body();
267        let signature = self
268            .key
269            .signer
270            .sign(body.as_bytes())
271            .map_err(|e| TransparencyError::SigningKey(e.to_string()))?;
272        let log_signature = Ed25519Signature::try_from_slice(&signature)
273            .map_err(|e| TransparencyError::SigningKey(e.to_string()))?;
274        Ok(SignedCheckpoint {
275            checkpoint,
276            log_signature,
277            log_public_key: self.key.public_key,
278            witnesses: Vec::new(),
279            ecdsa_checkpoint_signature: None,
280            ecdsa_checkpoint_key: None,
281        })
282    }
283}
284
285/// Parse one level-0 tile's bytes into leaf hashes, enforcing the exact
286/// expected width so a truncated or padded tile is rejected at the boundary.
287fn parse_leaf_tile(
288    data: &[u8],
289    width: u64,
290    path: &str,
291    out: &mut Vec<MerkleHash>,
292) -> Result<(), TransparencyError> {
293    let expected = (width as usize) * 32;
294    if data.len() != expected {
295        return Err(TransparencyError::StoreError(format!(
296            "tile {path}: expected {expected} bytes ({width} hashes), got {}",
297            data.len()
298        )));
299    }
300    for chunk in data.chunks_exact(32) {
301        let mut bytes = [0u8; 32];
302        bytes.copy_from_slice(chunk);
303        out.push(MerkleHash::from_bytes(bytes));
304    }
305    Ok(())
306}
307
308#[cfg(test)]
309#[allow(clippy::unwrap_used, clippy::expect_used)]
310mod tests {
311    use super::*;
312    use crate::fs_store::FsTileStore;
313    use crate::merkle::hash_leaf;
314    use auths_verifier::evidence_pack::verify_transparency_inclusion;
315
316    fn fixed_now() -> DateTime<Utc> {
317        DateTime::parse_from_rfc3339("2026-06-12T00:00:00Z")
318            .unwrap()
319            .with_timezone(&Utc)
320    }
321
322    fn writer_in(dir: &std::path::Path) -> LogWriter<FsTileStore> {
323        LogWriter::new(
324            FsTileStore::new(dir.to_path_buf()),
325            LogSigningKey::generate().unwrap(),
326            LogOrigin::new("test.example/log").unwrap(),
327        )
328    }
329
330    #[tokio::test]
331    async fn append_then_prove_roundtrips_through_the_verifier() {
332        let dir = tempfile::tempdir().unwrap();
333        let writer = writer_in(dir.path());
334
335        let digests = ["sha256:aa", "sha256:bb", "sha256:cc"];
336        for (i, d) in digests.iter().enumerate() {
337            let appended = writer
338                .append(hash_leaf(d.as_bytes()), fixed_now())
339                .await
340                .unwrap();
341            assert_eq!(appended.index, i as u64);
342            assert_eq!(appended.signed_checkpoint.checkpoint.size, i as u64 + 1);
343        }
344
345        for d in digests {
346            let leaf = hash_leaf(d.as_bytes());
347            let inclusion = writer.prove(&leaf).await.unwrap();
348            assert_eq!(inclusion.signed_checkpoint.checkpoint.size, 3);
349            verify_transparency_inclusion(&inclusion)
350                .expect("the verifier the browser/CLI runs must accept the writer's evidence");
351        }
352    }
353
354    #[tokio::test]
355    async fn checkpoint_signature_verifies_against_the_log_public_key() {
356        let dir = tempfile::tempdir().unwrap();
357        let writer = writer_in(dir.path());
358
359        let appended = writer
360            .append(hash_leaf(b"sha256:aa"), fixed_now())
361            .await
362            .unwrap();
363        let signed = &appended.signed_checkpoint;
364
365        let body = signed.checkpoint.to_note_body();
366        let key = ring::signature::UnparsedPublicKey::new(
367            &ring::signature::ED25519,
368            signed.log_public_key.as_bytes(),
369        );
370        key.verify(body.as_bytes(), signed.log_signature.as_bytes())
371            .expect("checkpoint must be signed over the C2SP note body");
372    }
373
374    #[tokio::test]
375    async fn prove_unknown_leaf_fails() {
376        let dir = tempfile::tempdir().unwrap();
377        let writer = writer_in(dir.path());
378        writer
379            .append(hash_leaf(b"sha256:aa"), fixed_now())
380            .await
381            .unwrap();
382
383        let stranger = hash_leaf(b"sha256:never-appended");
384        assert!(writer.prove(&stranger).await.is_err());
385    }
386
387    #[tokio::test]
388    async fn origin_mismatch_fails_closed() {
389        let dir = tempfile::tempdir().unwrap();
390        let writer = writer_in(dir.path());
391        writer
392            .append(hash_leaf(b"sha256:aa"), fixed_now())
393            .await
394            .unwrap();
395
396        let imposter = LogWriter::new(
397            FsTileStore::new(dir.path().to_path_buf()),
398            LogSigningKey::generate().unwrap(),
399            LogOrigin::new("other.example/log").unwrap(),
400        );
401        assert!(matches!(
402            imposter.append(hash_leaf(b"sha256:bb"), fixed_now()).await,
403            Err(TransparencyError::InvalidOrigin(_))
404        ));
405    }
406
407    #[tokio::test]
408    async fn tampered_tile_fails_closed_on_next_operation() {
409        let dir = tempfile::tempdir().unwrap();
410        let writer = writer_in(dir.path());
411        let leaf = hash_leaf(b"sha256:aa");
412        writer.append(leaf, fixed_now()).await.unwrap();
413        writer
414            .append(hash_leaf(b"sha256:bb"), fixed_now())
415            .await
416            .unwrap();
417
418        // Flip a byte in the partial level-0 tile behind the writer's back.
419        let tile = dir.path().join("tile/0/000.p/2");
420        let mut bytes = std::fs::read(&tile).unwrap();
421        bytes[0] ^= 0xff;
422        std::fs::write(&tile, bytes).unwrap();
423
424        assert!(matches!(
425            writer.prove(&leaf).await,
426            Err(TransparencyError::RootMismatch { .. })
427        ));
428    }
429
430    #[tokio::test]
431    async fn key_roundtrips_through_pkcs8() {
432        let key = LogSigningKey::generate().unwrap();
433        let der = key.to_pkcs8_der().unwrap();
434        let reloaded = LogSigningKey::from_pkcs8_der(&der).unwrap();
435        assert_eq!(key.public_key(), reloaded.public_key());
436    }
437
438    #[tokio::test]
439    async fn appends_roll_over_a_full_tile() {
440        let dir = tempfile::tempdir().unwrap();
441        let writer = writer_in(dir.path());
442
443        let count = TILE_WIDTH + 3;
444        for i in 0..count {
445            writer
446                .append(
447                    hash_leaf(format!("sha256:{i:064x}").as_bytes()),
448                    fixed_now(),
449                )
450                .await
451                .unwrap();
452        }
453
454        // Tile 0 is now a full, write-once tile; tile 1 a 3-wide partial.
455        assert!(dir.path().join("tile/0/000").exists());
456        assert!(dir.path().join("tile/0/001.p/3").exists());
457
458        let first = hash_leaf(format!("sha256:{:064x}", 0).as_bytes());
459        let inclusion = writer.prove(&first).await.unwrap();
460        assert_eq!(inclusion.signed_checkpoint.checkpoint.size, count);
461        verify_transparency_inclusion(&inclusion).expect("proof across tiles verifies");
462    }
463}