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 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 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 pub fn public_key(&self) -> Ed25519PublicKey {
111 self.public_key
112 }
113}
114
115#[derive(Debug, Clone)]
118pub struct AppendedLeaf {
119 pub index: u64,
121 pub signed_checkpoint: SignedCheckpoint,
123}
124
125pub struct LogWriter<S: TileStore> {
142 store: S,
143 key: LogSigningKey,
144 origin: LogOrigin,
145}
146
147impl<S: TileStore> LogWriter<S> {
148 pub fn new(store: S, key: LogSigningKey, origin: LogOrigin) -> Self {
150 Self { store, key, origin }
151 }
152
153 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 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 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 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 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 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
304fn 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 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 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}