1use 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
31pub struct LogSigningKey {
36 signer: TypedSignerKey,
37 public_key: Ed25519PublicKey,
38}
39
40impl LogSigningKey {
41 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 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 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 pub fn public_key(&self) -> Ed25519PublicKey {
92 self.public_key
93 }
94}
95
96#[derive(Debug, Clone)]
99pub struct AppendedLeaf {
100 pub index: u64,
102 pub signed_checkpoint: SignedCheckpoint,
104}
105
106pub struct LogWriter<S: TileStore> {
123 store: S,
124 key: LogSigningKey,
125 origin: LogOrigin,
126}
127
128impl<S: TileStore> LogWriter<S> {
129 pub fn new(store: S, key: LogSigningKey, origin: LogOrigin) -> Self {
131 Self { store, key, origin }
132 }
133
134 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 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 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 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 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 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
285fn 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 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 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}