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