OXYGENHUB
# 76fdeedccf44 oxygenhub / crates / hub / src / tlog.rs
31.8 KiB · Rust · last changed Trusted time on the hub: serve when witnesses first saw an entry 17 days ago source blame history raw
1
//! The hub's transparency log (oxygenhub#238, docs/transparency.md, Oxygen
2
//! spec §14.8): one entry per signed object the hub accepts, in the order
3
//! it accepts them, kept as append-only files and served as C2SP
4
//! tlog-tiles with a signed checkpoint, so anyone can check the hub shows
5
//! everyone the same history.
6
//!
7
//! Files in `<data>/tlog/`:
8
//! - `entries`: each entry as a big-endian u16 length and its bytes;
9
//! - `offsets`: each entry's offset in `entries`, a u64 (little-endian);
10
//! - `hashes`: every hash of the tree in stored order (Oxygen spec §14.8);
11
//! - `checkpoint`: the latest signed checkpoint note (replaced, not
12
//!   appended, as it is only a signature over the others).
13
//!
14
//! Nothing is rewritten: a crash mid-append leaves at most a tail that is
15
//! cut back to the last whole entry on open, and the hashes are rebuilt
16
//! from the entries. The database's `tlog_entries` points from objects to
17
//! entry numbers.
18
19
use crate::{db, Shared};
20
use axum::extract::{Path as UrlPath, State};
21
use axum::http::{header, StatusCode};
22
use axum::response::{IntoResponse, Response};
23
use oxy_core::hash::{KeyId, ObjectRef};
24
use oxy_core::identity::Identity;
25
use oxy_core::repo::{PushReport, Repo};
26
use oxy_core::tlog::{self, Checkpoint, Entry, Hash, HashReader, Tile, TlogError, TILE_WIDTH};
27
use std::fs::{File, OpenOptions};
28
use std::io::Write;
29
use std::os::unix::fs::FileExt;
30
use std::path::{Path, PathBuf};
31
use std::sync::Mutex;
32
33
/// How often a new checkpoint is signed while entries arrive.
34
pub const CHECKPOINT_EVERY: std::time::Duration = std::time::Duration::from_secs(5);
35
36
struct Files {
37
    entries: File,
38
    offsets: File,
39
    hashes: File,
40
    /// Entries in the log.
41
    size: u64,
42
    /// Bytes in `entries`.
43
    entries_len: u64,
44
}
45
46
/// The stored hashes in a file, by stored index.
47
struct FileHashes<'a>(&'a File);
48
49
impl HashReader for FileHashes<'_> {
50
    fn read_hashes(&self, indexes: &[u64]) -> Result<Vec<Hash>, TlogError> {
51
        indexes
52
            .iter()
53
            .map(|&i| {
54
                let mut h = [0u8; 32];
55
                self.0.read_exact_at(&mut h, i * 32).map_err(|e| TlogError::Read(format!("stored hash {i}: {e}")))?;
56
                Ok(h)
57
            })
58
            .collect()
59
    }
60
}
61
62
/// The log on disk.
63
pub struct Log {
64
    dir: PathBuf,
65
    /// The log's name in its checkpoints: the hub's host and `/log`.
66
    pub origin: String,
67
    files: Mutex<Files>,
68
    /// The latest signed checkpoint: its size and its note.
69
    checkpoint: Mutex<Option<(u64, String)>>,
70
    /// Witnesses this log asks to cosign its checkpoints.
71
    witnesses: std::sync::RwLock<Vec<Peer>>,
72
    /// Logs this hub witnesses: their origins and keys.
73
    witness_for: std::sync::RwLock<Vec<(String, Identity)>>,
74
}
75
76
/// A witness of this hub's log.
77
#[derive(Clone, Debug)]
78
pub struct Peer {
79
    pub name: String,
80
    /// Its add-checkpoint address.
81
    pub url: String,
82
    pub identity: Identity,
83
}
84
85
fn read_u64_at(f: &File, at: u64) -> std::io::Result<u64> {
86
    let mut b = [0u8; 8];
87
    f.read_exact_at(&mut b, at)?;
88
    Ok(u64::from_le_bytes(b))
89
}
90
91
/// The origin of a hub's log: its host (and port) and `/log`.
92
pub fn origin_of(base_url: &str) -> String {
93
    let host = base_url.split_once("://").map(|(_, h)| h).unwrap_or(base_url);
94
    format!("{}/log", host.trim_end_matches('/'))
95
}
96
97
impl Log {
98
    /// Open (or start) the log in `dir`, recovering from an interrupted
99
    /// append.
100
    pub fn open(dir: &Path, origin: &str) -> anyhow::Result<Log> {
101
        std::fs::create_dir_all(dir)?;
102
        let open = |name: &str| OpenOptions::new().read(true).append(true).create(true).open(dir.join(name));
103
        let (entries, offsets, hashes) = (open("entries")?, open("offsets")?, open("hashes")?);
104
        let entries_on_disk = entries.metadata()?.len();
105
        // The whole entries: those whose offset was written and whose
106
        // bytes all were.
107
        let mut size = offsets.metadata()?.len() / 8;
108
        let mut entries_len = 0;
109
        while size > 0 {
110
            let off = read_u64_at(&offsets, (size - 1) * 8)?;
111
            let mut len = [0u8; 2];
112
            if off + 2 <= entries_on_disk && entries.read_exact_at(&mut len, off).is_ok() {
113
                let end = off + 2 + u16::from_be_bytes(len) as u64;
114
                if end <= entries_on_disk {
115
                    entries_len = end;
116
                    break;
117
                }
118
            }
119
            size -= 1;
120
        }
121
        offsets.set_len(size * 8)?;
122
        entries.set_len(entries_len)?;
123
        // The hashes of every leaf whose hashes were all written; the rest
124
        // are computed again from the entries.
125
        let have = hashes.metadata()?.len() / 32;
126
        let mut whole = size;
127
        while tlog::stored_hash_count(whole) > have {
128
            whole -= 1;
129
        }
130
        hashes.set_len(tlog::stored_hash_count(whole) * 32)?;
131
        let mut files = Files { entries, offsets, hashes, size: whole, entries_len };
132
        for n in whole..size {
133
            let bytes = Self::entry_bytes(&files, n)?;
134
            let add = tlog::stored_hashes_for_leaf(n, tlog::leaf_hash(&bytes), &FileHashes(&files.hashes))?;
135
            for h in add {
136
                files.hashes.write_all(&h)?;
137
            }
138
            files.size = n + 1;
139
        }
140
        files.size = size;
141
        files.hashes.sync_data()?;
142
        let checkpoint = std::fs::read_to_string(dir.join("checkpoint")).ok().and_then(|note| {
143
            let n = tlog::Note::parse(&note).ok()?;
144
            let c = Checkpoint::parse(&n.text).ok()?;
145
            (c.size <= size).then_some((c.size, note))
146
        });
147
        Ok(Log {
148
            dir: dir.to_path_buf(),
149
            origin: origin.to_string(),
150
            files: Mutex::new(files),
151
            checkpoint: Mutex::new(checkpoint),
152
            witnesses: Default::default(),
153
            witness_for: Default::default(),
154
        })
155
    }
156
157
    fn entry_bytes(f: &Files, n: u64) -> anyhow::Result<Vec<u8>> {
158
        let off = read_u64_at(&f.offsets, n * 8)?;
159
        let mut len = [0u8; 2];
160
        f.entries.read_exact_at(&mut len, off)?;
161
        let mut b = vec![0u8; u16::from_be_bytes(len) as usize];
162
        f.entries.read_exact_at(&mut b, off + 2)?;
163
        Ok(b)
164
    }
165
166
    /// Entry `n`'s bytes.
167
    pub fn entry(&self, n: u64) -> anyhow::Result<Vec<u8>> {
168
        let f = self.files.lock().unwrap();
169
        anyhow::ensure!(n < f.size, "entry {n} is not in the log");
170
        Self::entry_bytes(&f, n)
171
    }
172
173
    /// Entries in the log.
174
    pub fn size(&self) -> u64 {
175
        self.files.lock().unwrap().size
176
    }
177
178
    /// Append entries, durably; their numbers.
179
    pub fn append(&self, entries: &[Entry]) -> anyhow::Result<Vec<u64>> {
180
        let mut f = self.files.lock().unwrap();
181
        let mut out = Vec::with_capacity(entries.len());
182
        for e in entries {
183
            let bytes = e.encode();
184
            let n = f.size;
185
            let add = tlog::stored_hashes_for_leaf(n, tlog::leaf_hash(&bytes), &FileHashes(&f.hashes))?;
186
            let off = f.entries_len;
187
            let mut rec = (bytes.len() as u16).to_be_bytes().to_vec();
188
            rec.extend_from_slice(&bytes);
189
            f.entries.write_all(&rec)?;
190
            f.entries_len += rec.len() as u64;
191
            f.offsets.write_all(&off.to_le_bytes())?;
192
            let mut hs = Vec::with_capacity(add.len() * 32);
193
            for h in add {
194
                hs.extend_from_slice(&h);
195
            }
196
            f.hashes.write_all(&hs)?;
197
            f.size = n + 1;
198
            out.push(n);
199
        }
200
        f.entries.sync_data()?;
201
        f.offsets.sync_data()?;
202
        f.hashes.sync_data()?;
203
        Ok(out)
204
    }
205
206
    /// The root of the first `n` entries.
207
    pub fn tree_hash(&self, n: u64) -> anyhow::Result<Hash> {
208
        let f = self.files.lock().unwrap();
209
        Ok(tlog::tree_hash(n, &FileHashes(&f.hashes))?)
210
    }
211
212
    /// Entry `n`'s inclusion proof in the tree of `t` entries.
213
    pub fn prove_inclusion(&self, t: u64, n: u64) -> anyhow::Result<Vec<Hash>> {
214
        let f = self.files.lock().unwrap();
215
        Ok(tlog::prove_inclusion(t, n, &FileHashes(&f.hashes))?)
216
    }
217
218
    /// The consistency proof from `n` entries to `t`.
219
    pub fn prove_consistency(&self, t: u64, n: u64) -> anyhow::Result<Vec<Hash>> {
220
        let f = self.files.lock().unwrap();
221
        Ok(tlog::prove_consistency(t, n, &FileHashes(&f.hashes))?)
222
    }
223
224
    /// A tile's hashes, when the log holds all of them.
225
    pub fn tile(&self, t: &Tile) -> anyhow::Result<Option<Vec<u8>>> {
226
        let f = self.files.lock().unwrap();
227
        let at_level = f.size >> (tlog::TILE_HEIGHT * t.level);
228
        if at_level < (t.n << tlog::TILE_HEIGHT) + t.width as u64 {
229
            return Ok(None);
230
        }
231
        Ok(Some(t.read_data(&FileHashes(&f.hashes))?))
232
    }
233
234
    /// The entry bundle beside level-0 tile `t`, when the log holds it.
235
    pub fn bundle(&self, t: &Tile) -> anyhow::Result<Option<Vec<u8>>> {
236
        let f = self.files.lock().unwrap();
237
        let first = t.n << tlog::TILE_HEIGHT;
238
        if f.size < first + t.width as u64 {
239
            return Ok(None);
240
        }
241
        let entries: Vec<Vec<u8>> = (first..first + t.width as u64).map(|n| Self::entry_bytes(&f, n)).collect::<anyhow::Result<_>>()?;
242
        Ok(Some(tlog::encode_bundle(&entries)?))
243
    }
244
245
    /// Take `[tlog]` from the configuration: witnesses and witnessed logs.
246
    pub fn configure(&self, c: &crate::config::Tlog) -> anyhow::Result<()> {
247
        let key = |b64: &str, what: &str| -> anyhow::Result<Identity> {
248
            let bytes = oxy_core::dsse::base64_decode(b64.trim()).ok_or_else(|| anyhow::anyhow!("[tlog] {what}: the key is not base64"))?;
249
            Identity::decode(&bytes).map_err(|e| anyhow::anyhow!("[tlog] {what}: {e}"))
250
        };
251
        let mut ws = Vec::new();
252
        for w in &c.witnesses {
253
            ws.push(Peer { name: w.name.clone(), url: w.url.clone(), identity: key(&w.key, &w.name)? });
254
        }
255
        let mut wf = Vec::new();
256
        for l in &c.witness_for {
257
            wf.push((l.origin.clone(), key(&l.key, &l.origin)?));
258
        }
259
        self.set_witnesses(ws);
260
        for (o, k) in wf {
261
            self.add_witness_for(&o, k);
262
        }
263
        Ok(())
264
    }
265
266
    pub fn set_witnesses(&self, peers: Vec<Peer>) {
267
        *self.witnesses.write().unwrap() = peers;
268
    }
269
270
    pub fn witnesses(&self) -> Vec<Peer> {
271
        self.witnesses.read().unwrap().clone()
272
    }
273
274
    /// Witness the log named `origin`, signed with `key`.
275
    pub fn add_witness_for(&self, origin: &str, key: Identity) {
276
        let mut w = self.witness_for.write().unwrap();
277
        w.retain(|(o, _)| o != origin);
278
        w.push((origin.to_string(), key));
279
    }
280
281
    fn witnessed_key(&self, origin: &str) -> Option<Identity> {
282
        self.witness_for.read().unwrap().iter().find(|(o, _)| o == origin).map(|(_, k)| k.clone())
283
    }
284
285
    /// This hub's name as a witness: its log's host.
286
    pub fn witness_name(&self) -> &str {
287
        self.origin.trim_end_matches("/log")
288
    }
289
290
    /// Replace the latest checkpoint's note with `note` (the same
291
    /// checkpoint, with cosignatures added), unless a newer one was signed.
292
    fn set_note(&self, size: u64, note: String) -> anyhow::Result<()> {
293
        let mut cur = self.checkpoint.lock().unwrap();
294
        if cur.as_ref().map(|(s, _)| *s) != Some(size) {
295
            return Ok(());
296
        }
297
        let tmp = self.dir.join("checkpoint.new");
298
        std::fs::write(&tmp, &note)?;
299
        File::open(&tmp)?.sync_all()?;
300
        std::fs::rename(&tmp, self.dir.join("checkpoint"))?;
301
        *cur = Some((size, note));
302
        Ok(())
303
    }
304
305
    /// The latest signed checkpoint note.
306
    pub fn checkpoint(&self) -> Option<String> {
307
        self.checkpoint.lock().unwrap().as_ref().map(|(_, n)| n.clone())
308
    }
309
310
    /// Sign a checkpoint of the log as it is, unless the last one already
311
    /// covers it. True when a new one was signed.
312
    pub fn refresh_checkpoint(&self, identity: &oxy_core::identity::Identity, sk: &oxy_core::identity::SecretKey) -> anyhow::Result<bool> {
313
        let size = self.size();
314
        if self.checkpoint.lock().unwrap().as_ref().is_some_and(|(s, _)| *s >= size) {
315
            return Ok(false);
316
        }
317
        let c = Checkpoint { origin: self.origin.clone(), size, root: self.tree_hash(size)?, extensions: vec![] };
318
        let note = c.sign(identity, sk)?.encode();
319
        let tmp = self.dir.join("checkpoint.new");
320
        std::fs::write(&tmp, &note)?;
321
        File::open(&tmp)?.sync_all()?;
322
        std::fs::rename(&tmp, self.dir.join("checkpoint"))?;
323
        *self.checkpoint.lock().unwrap() = Some((size, note));
324
        Ok(true)
325
    }
326
}
327
328
/// An object to log.
329
#[derive(Clone, Debug)]
330
pub struct Pending {
331
    pub kind: &'static str,
332
    /// The repository's id (zero for none) and its row on this hub.
333
    pub repo: [u8; 16],
334
    pub repo_row: Option<i64>,
335
    pub object: ObjectRef,
336
    pub signers: Vec<KeyId>,
337
}
338
339
/// Log what is not logged yet. A failure is reported, never fatal: the
340
/// object stays accepted (enforcing logging before acceptance comes with
341
/// inclusion proofs in push responses).
342
pub async fn log(app: &Shared, items: Vec<Pending>) -> Vec<u64> {
343
    let mut fresh = Vec::new();
344
    for p in items {
345
        let object = p.object.to_hex();
346
        match db::tlog::entry_of(&app.db, &object, p.kind).await {
347
            Ok(Some(_)) => continue,
348
            Ok(None) => {}
349
            Err(e) => {
350
                eprintln!("tlog: looking up {} {}: {e}", p.kind, &object[..12]);
351
                continue;
352
            }
353
        }
354
        if !fresh.iter().any(|q: &Pending| q.kind == p.kind && q.object == p.object) {
355
            fresh.push(p);
356
        }
357
    }
358
    if fresh.is_empty() {
359
        return vec![];
360
    }
361
    let now = db::now_ms();
362
    let entries: Vec<Entry> = fresh
363
        .iter()
364
        .map(|p| Entry { kind: p.kind.to_string(), repo: p.repo, object: p.object, signers: p.signers.clone(), received: now as u64 })
365
        .collect();
366
    let tl = app.tlog.clone();
367
    let numbers = match tokio::task::spawn_blocking(move || tl.append(&entries)).await {
368
        Ok(Ok(n)) => n,
369
        Ok(Err(e)) => {
370
            eprintln!("tlog: appending {} entries: {e}", fresh.len());
371
            return vec![];
372
        }
373
        Err(e) => {
374
            eprintln!("tlog: appending: {e}");
375
            return vec![];
376
        }
377
    };
378
    for (p, n) in fresh.iter().zip(&numbers) {
379
        if let Err(e) = db::tlog::record(&app.db, *n as i64, &p.object.to_hex(), p.kind, p.repo_row, now).await {
380
            eprintln!("tlog: indexing entry {n}: {e}");
381
        }
382
    }
383
    numbers
384
}
385
386
fn sig_keys(sigs: &[oxy_core::identity::Signature]) -> Vec<KeyId> {
387
    let mut keys: Vec<KeyId> = sigs.iter().map(|s| s.key).collect();
388
    keys.sort();
389
    keys.dedup();
390
    keys
391
}
392
393
/// The certificates of short-lived keys among `signers`, to log beside
394
/// what they signed.
395
fn certificates(repo: &Repo, rid: [u8; 16], row: Option<i64>, signers: &[KeyId], out: &mut Vec<Pending>) {
396
    for k in signers {
397
        if let Ok(Some(c)) = repo.store().certificate_of(k) {
398
            let issuer = repo.store().get_certificate(&c).map(|sc| vec![sc.cert.issuer]).unwrap_or_default();
399
            out.push(Pending { kind: "certificate", repo: rid, repo_row: row, object: c, signers: issuer });
400
        }
401
    }
402
}
403
404
/// What a push brought that the log keeps: the attestations of the
405
/// references it moved (and the hub's acceptance of them), tags, seals,
406
/// the repository's policy, and the certificates of short-lived signers.
407
pub fn push_entries(repo: &Repo, row: Option<i64>, report: &PushReport) -> Vec<Pending> {
408
    let Ok(rid) = repo.id() else { return vec![] };
409
    let mut out = Vec::new();
410
    let add = |out: &mut Vec<Pending>, kind: &'static str, object: ObjectRef, signers: Vec<KeyId>| {
411
        certificates(repo, rid, row, &signers, out);
412
        out.push(Pending { kind, repo: rid, repo_row: row, object, signers });
413
    };
414
    for u in &report.updated {
415
        if u.new_state.is_zero() || !report.results.iter().any(|r| r.ok && r.name == u.name) {
416
            continue;
417
        }
418
        if let Some(tag) = u.name.strip_prefix("tags/") {
419
            if let Ok(Some((r, sa))) = repo.tag_attest(tag) {
420
                add(&mut out, "tag", r, sig_keys(&sa.sigs));
421
            }
422
        } else if let Ok(Some((r, sa))) = repo.latest_ref_attest(&u.name) {
423
            add(&mut out, "ref", r, sig_keys(&sa.sigs));
424
            if let Ok(Some(acc)) = repo.acceptance_of(&r) {
425
                add(&mut out, "accepted", acc.attest.hash(), sig_keys(&acc.sigs));
426
            }
427
        }
428
    }
429
    let mut seals = std::collections::BTreeSet::new();
430
    for h in &report.imported {
431
        for s in repo.store().seals_for(h).unwrap_or_default() {
432
            seals.insert(s);
433
        }
434
    }
435
    for s in seals {
436
        let signers = sig_keys(&repo.store().signatures(&s).unwrap_or_default());
437
        add(&mut out, "seal", s, signers);
438
    }
439
    if let Ok(Some((r, sp))) = repo.current_policy() {
440
        add(&mut out, "policy", r, sig_keys(&sp.sigs));
441
    }
442
    out
443
}
444
445
/// Add the log to a refs answer (Oxygen spec §14.8): the latest
446
/// checkpoint, and for each bookmark's attestation its entry and inclusion
447
/// proof against it. An attestation the hub serves but never logged (one
448
/// pushed before the log began) is logged now, and proved on a later fetch.
449
pub fn add_log(app: &Shared, row: Option<i64>, refs: &mut oxy_core::protocol::RefsResponse) -> Vec<Pending> {
450
    let mut backfill = Vec::new();
451
    let Some(note) = app.tlog.checkpoint() else { return backfill };
452
    let Some(size) = tlog::Note::parse(&note).ok().and_then(|n| Checkpoint::parse(&n.text).ok()).map(|c| c.size) else {
453
        return backfill;
454
    };
455
    refs.log = Some(note);
456
    // The witnesses a client may pin, for integrated times (#240).
457
    refs.witnesses = app.tlog.witnesses().iter().map(|p| (p.name.clone(), p.identity.encode())).collect();
458
    for r in refs.refs.iter_mut() {
459
        let Some(a) = &r.attestation else { continue };
460
        let object = a.attest.hash();
461
        let idx = tokio::task::block_in_place(|| {
462
            tokio::runtime::Handle::current().block_on(db::tlog::entry_of(&app.db, &object.to_hex(), "ref"))
463
        });
464
        match idx {
465
            Ok(Some(i)) if (i as u64) < size => {
466
                let (Ok(entry), Ok(proof)) = (app.tlog.entry(i as u64), app.tlog.prove_inclusion(size, i as u64)) else { continue };
467
                r.logged = Some(oxy_core::protocol::Logged { index: i as u64, entry, proof });
468
                // When witnesses first saw it (#240): the first cosigned
469
                // checkpoints that hold it, each with the entry's proof.
470
                let notes = tokio::task::block_in_place(|| {
471
                    tokio::runtime::Handle::current().block_on(db::tlog::cosigned_notes_after(&app.db, i, 2))
472
                })
473
                .unwrap_or_default();
474
                for (at, text) in notes {
475
                    if let Ok(proof) = app.tlog.prove_inclusion(at as u64, i as u64) {
476
                        r.integration.push(oxy_core::protocol::Integration { note: text, proof });
477
                    }
478
                }
479
            }
480
            Ok(Some(_)) => {}
481
            Ok(None) => backfill.push(Pending { kind: "ref", repo: a.attest.repo, repo_row: row, object, signers: sig_keys(&a.sigs) }),
482
            Err(e) => eprintln!("tlog: looking up {}: {e}", &object.to_hex()[..12]),
483
        }
484
    }
485
    backfill
486
}
487
488
/// Ask each witness to cosign the latest checkpoint, and serve their
489
/// cosignatures with it. A witness that answers 409 is told again from the
490
/// size it says it has; one that refuses is logged.
491
pub async fn collect(app: &Shared) {
492
    let peers = app.tlog.witnesses();
493
    let Some(text) = app.tlog.checkpoint() else { return };
494
    if peers.is_empty() {
495
        return;
496
    }
497
    let Ok(mut note) = tlog::Note::parse(&text) else { return };
498
    let Ok(cp) = Checkpoint::parse(&note.text) else { return };
499
    // What a witness checks: the note as the log signed it.
500
    let own = tlog::Note { text: note.text.clone(), sigs: note.sigs.iter().filter(|s| s.name == cp.origin).cloned().collect() };
501
    let mut changed = false;
502
    for p in peers {
503
        if note.cosigned_by(&p.name, &p.identity).is_ok() {
504
            continue;
505
        }
506
        let mut old = db::tlog::cosigned_size(&app.db, &p.name).await.unwrap_or(0) as u64;
507
        for _ in 0..2 {
508
            let proof = if old == 0 || old >= cp.size { Vec::new() } else { app.tlog.prove_consistency(cp.size, old).unwrap_or_default() };
509
            let body = tlog::AddCheckpoint { old_size: old, proof, note: own.encode() }.encode();
510
            let client = match crate::net::client(&p.url, std::slice::from_ref(&p.url), std::time::Duration::from_secs(20)).await {
511
                Ok(c) => c,
512
                Err(e) => {
513
                    eprintln!("tlog: witness {}: {e}", p.name);
514
                    break;
515
                }
516
            };
517
            let res = match client.post(&p.url).header(header::CONTENT_TYPE, "text/plain").body(body).send().await {
518
                Ok(r) => r,
519
                Err(e) => {
520
                    eprintln!("tlog: witness {}: {e}", p.name);
521
                    break;
522
                }
523
            };
524
            let status = res.status();
525
            let answer = res.text().await.unwrap_or_default();
526
            if status == StatusCode::CONFLICT {
527
                match answer.trim().parse() {
528
                    Ok(n) if n != old => {
529
                        old = n;
530
                        continue;
531
                    }
532
                    _ => {
533
                        eprintln!("tlog: witness {}: a conflict without a size: {answer}", p.name);
534
                        break;
535
                    }
536
                }
537
            }
538
            if !status.is_success() {
539
                eprintln!("tlog: witness {} refused the checkpoint of {} entries ({status}): {}", p.name, cp.size, answer.trim());
540
                break;
541
            }
542
            let lines = tlog::Note::parse(&format!("{}\n{answer}", note.text)).map(|n| n.sigs).unwrap_or_default();
543
            let theirs: Vec<_> = lines.into_iter().filter(|s| s.name == p.name).collect();
544
            let check = tlog::Note { text: note.text.clone(), sigs: theirs.clone() };
545
            match check.cosigned_by(&p.name, &p.identity) {
546
                Ok(_) => {
547
                    note.sigs.extend(theirs);
548
                    db::tlog::set_cosigned(&app.db, &p.name, cp.size as i64).await.ok();
549
                    changed = true;
550
                }
551
                Err(e) => eprintln!("tlog: witness {} answered a cosignature that does not verify: {e}", p.name),
552
            }
553
            break;
554
        }
555
    }
556
    if changed {
557
        // Kept by size too, for integrated times (#240).
558
        db::tlog::keep_cosigned_note(&app.db, cp.size as i64, &note.encode()).await.ok();
559
        if let Err(e) = app.tlog.set_note(cp.size, note.encode()) {
560
            eprintln!("tlog: keeping the cosigned checkpoint: {e}");
561
        }
562
    }
563
}
564
565
/// `POST /log/witness/add-checkpoint` (C2SP tlog-witness): cosign a
566
/// checkpoint of a log this hub witnesses, if it extends the last one
567
/// cosigned.
568
pub async fn add_checkpoint(State(app): State<Shared>, body: String) -> Response {
569
    let Ok(req) = tlog::AddCheckpoint::parse(&body) else {
570
        return plain(StatusCode::BAD_REQUEST, "not an add-checkpoint request");
571
    };
572
    let Some(origin) = tlog::Note::parse(&req.note).ok().and_then(|n| Checkpoint::parse(&n.text).ok()).map(|c| c.origin) else {
573
        return plain(StatusCode::BAD_REQUEST, "the checkpoint does not parse");
574
    };
575
    let Some(key) = app.tlog.witnessed_key(&origin) else {
576
        return plain(StatusCode::NOT_FOUND, &format!("this hub does not witness {origin}"));
577
    };
578
    let latest = match db::tlog::witnessed(&app.db, &origin).await {
579
        Ok(l) => l.and_then(|(size, root)| Some((size as u64, ObjectRef::from_hex(&root)?.0))),
580
        Err(e) => return plain(StatusCode::INTERNAL_SERVER_ERROR, &e.to_string()),
581
    };
582
    let sk = match crate::keys::load_secret(&app.config.keys_dir()) {
583
        Ok((_, sk)) => sk,
584
        Err(e) => return plain(StatusCode::INTERNAL_SERVER_ERROR, &e.to_string()),
585
    };
586
    let me = tlog::WitnessKey { name: app.tlog.witness_name(), identity: &app.hub_key.identity, sk: &sk };
587
    let now = (db::now_ms() / 1000) as u64;
588
    let conflict = |n: u64| ([(header::CONTENT_TYPE, "text/x.tlog.size")], format!("{n}\n"));
589
    match tlog::witness(&req, &origin, &key, latest, &me, now) {
590
        Ok(tlog::Witnessed::Cosigned { checkpoint, lines }) => {
591
            let root = ObjectRef(checkpoint.root).to_hex();
592
            match db::tlog::witness_advance(&app.db, &origin, req.old_size as i64, checkpoint.size as i64, &root).await {
593
                Ok(true) => {
594
                    let body: String = tlog::Note { text: String::new(), sigs: lines }.encode().trim_start_matches('\n').to_string();
595
                    ([(header::CONTENT_TYPE, "text/plain; charset=utf-8")], body).into_response()
596
                }
597
                Ok(false) => {
598
                    let now = db::tlog::witnessed(&app.db, &origin).await.ok().flatten().map(|(s, _)| s as u64).unwrap_or(0);
599
                    (StatusCode::CONFLICT, conflict(now)).into_response()
600
                }
601
                Err(e) => plain(StatusCode::INTERNAL_SERVER_ERROR, &e.to_string()),
602
            }
603
        }
604
        Ok(tlog::Witnessed::Conflict(n)) => (StatusCode::CONFLICT, conflict(n)).into_response(),
605
        Err(tlog::WitnessError::Unsigned(e)) => plain(StatusCode::FORBIDDEN, &e),
606
        Err(tlog::WitnessError::Inconsistent(e)) => plain(StatusCode::UNPROCESSABLE_ENTITY, &e),
607
        Err(tlog::WitnessError::Bad(e)) => plain(StatusCode::BAD_REQUEST, &e),
608
    }
609
}
610
611
/// Sign a new checkpoint every few seconds while entries arrive.
612
pub async fn checkpointer(app: Shared) {
613
    let mut tick = tokio::time::interval(CHECKPOINT_EVERY);
614
    loop {
615
        tick.tick().await;
616
        let (identity, keys) = (app.hub_key.identity.clone(), app.config.keys_dir());
617
        let tl = app.tlog.clone();
618
        let done = tokio::task::spawn_blocking(move || -> anyhow::Result<bool> {
619
            let (_, sk) = crate::keys::load_secret(&keys)?;
620
            tl.refresh_checkpoint(&identity, &sk)
621
        })
622
        .await;
623
        match done {
624
            Ok(Err(e)) => eprintln!("tlog: signing a checkpoint: {e}"),
625
            // A new checkpoint, or one some witness has not cosigned yet.
626
            _ => collect(&app).await,
627
        }
628
    }
629
}
630
631
fn plain(status: StatusCode, why: &str) -> Response {
632
    (status, [(header::CONTENT_TYPE, "text/plain; charset=utf-8")], format!("{why}\n")).into_response()
633
}
634
635
/// `GET /log/checkpoint`: the latest signed checkpoint (C2SP
636
/// tlog-checkpoint, a signed note).
637
pub async fn checkpoint(State(app): State<Shared>) -> Response {
638
    match app.tlog.checkpoint() {
639
        Some(note) => ([(header::CONTENT_TYPE, "text/plain; charset=utf-8"), (header::CACHE_CONTROL, "no-cache")], note).into_response(),
640
        None => plain(StatusCode::SERVICE_UNAVAILABLE, "no checkpoint yet: the log signs one within seconds"),
641
    }
642
}
643
644
/// `GET /log/witnesses` (#240): this log's witnesses, one `NAME BASE64`
645
/// line each (the identity object), which `oxy tlog verify` pins.
646
pub async fn witnesses(State(app): State<Shared>) -> Response {
647
    let body: String =
648
        app.tlog.witnesses().iter().map(|p| format!("{} {}\n", p.name, oxy_core::dsse::base64(&p.identity.encode()))).collect();
649
    ([(header::CONTENT_TYPE, "text/plain; charset=utf-8"), (header::CACHE_CONTROL, "no-cache")], body).into_response()
650
}
651
652
/// `GET /log/tile/{L}/{N}[.p/{W}]` and `/log/tile/entries/{N}[.p/{W}]`:
653
/// a tile of hashes or an entry bundle (C2SP tlog-tiles). Full tiles never
654
/// change and are cached for good.
655
pub async fn tile(State(app): State<Shared>, UrlPath(path): UrlPath<String>) -> Response {
656
    let (entries, rest) = match path.strip_prefix("entries/") {
657
        Some(r) => (true, format!("0/{r}")),
658
        None => (false, path.clone()),
659
    };
660
    let Some(t) = Tile::parse(&rest) else {
661
        return plain(StatusCode::NOT_FOUND, "no such tile");
662
    };
663
    let tl = app.tlog.clone();
664
    let got = tokio::task::spawn_blocking(move || if entries { tl.bundle(&t) } else { tl.tile(&t) }).await;
665
    match got {
666
        Ok(Ok(Some(data))) => {
667
            let cache = if t.width as u64 == TILE_WIDTH { "public, max-age=31536000, immutable" } else { "public, max-age=5" };
668
            ([(header::CONTENT_TYPE, "application/octet-stream"), (header::CACHE_CONTROL, cache)], data).into_response()
669
        }
670
        Ok(Ok(None)) => plain(StatusCode::NOT_FOUND, "the log does not hold that tile yet"),
671
        Ok(Err(e)) => plain(StatusCode::INTERNAL_SERVER_ERROR, &e.to_string()),
672
        Err(e) => plain(StatusCode::INTERNAL_SERVER_ERROR, &e.to_string()),
673
    }
674
}
675
676
#[cfg(test)]
677
mod tests {
678
    use super::*;
679
680
    fn entry(i: u64) -> Entry {
681
        Entry { kind: "ref".into(), repo: [1; 16], object: ObjectRef([i as u8; 32]), signers: vec![], received: i }
682
    }
683
684
    #[test]
685
    fn the_log_survives_a_torn_append_and_keeps_its_root() {
686
        let dir = tempfile::tempdir().unwrap();
687
        let log = Log::open(dir.path(), "hub.example/log").unwrap();
688
        let first: Vec<Entry> = (0..300).map(entry).collect();
689
        assert_eq!(log.append(&first).unwrap(), (0..300).collect::<Vec<_>>());
690
        let root = log.tree_hash(300).unwrap();
691
        // Leaves alone, as RFC 6962 roots them.
692
        let mut stored: Vec<Hash> = Vec::new();
693
        for (n, e) in first.iter().enumerate() {
694
            stored.extend(tlog::stored_hashes_for_leaf(n as u64, e.leaf_hash(), &stored).unwrap());
695
        }
696
        assert_eq!(root, tlog::tree_hash(300, &stored).unwrap());
697
        drop(log);
698
        // A crash after the entry and part of the hashes were written.
699
        let torn = dir.path().join("hashes");
700
        let len = std::fs::metadata(&torn).unwrap().len();
701
        OpenOptions::new().write(true).open(&torn).unwrap().set_len(len - 40).unwrap();
702
        let mut f = OpenOptions::new().append(true).open(dir.path().join("entries")).unwrap();
703
        f.write_all(&[0, 200, 1, 2]).unwrap(); // a partial entry
704
        let log = Log::open(dir.path(), "hub.example/log").unwrap();
705
        assert_eq!(log.size(), 300);
706
        assert_eq!(log.tree_hash(300).unwrap(), root);
707
        log.append(&[entry(300)]).unwrap();
708
        assert_eq!(log.size(), 301);
709
        let p = log.prove_inclusion(301, 7).unwrap();
710
        tlog::check_inclusion(&p, 301, &log.tree_hash(301).unwrap(), 7, &first[7].leaf_hash()).unwrap();
711
        let c = log.prove_consistency(301, 300).unwrap();
712
        tlog::check_consistency(&c, 301, &log.tree_hash(301).unwrap(), 300, &root).unwrap();
713
        // Tiles and bundles: the full level-0 tile and the partial one.
714
        let full = log.tile(&Tile { level: 0, n: 0, width: 256 }).unwrap().unwrap();
715
        assert_eq!(full.len(), 256 * 32);
716
        assert!(log.tile(&Tile { level: 0, n: 1, width: 45 }).unwrap().is_some());
717
        assert!(log.tile(&Tile { level: 0, n: 1, width: 46 }).unwrap().is_none(), "not yet");
718
        let bundle = log.bundle(&Tile { level: 0, n: 1, width: 45 }).unwrap().unwrap();
719
        assert_eq!(tlog::decode_bundle(&bundle).unwrap()[0], first[256].encode());
720
    }
721
722
    #[test]
723
    fn checkpoints_are_signed_when_the_log_grows() {
724
        let dir = tempfile::tempdir().unwrap();
725
        let sk = oxy_core::identity::SecretKey::generate();
726
        let hub = sk.identity("hub", "[email protected]", 1);
727
        let log = Log::open(dir.path(), &origin_of("https://hub.example:7343/")).unwrap();
728
        assert_eq!(log.origin, "hub.example:7343/log");
729
        assert!(log.refresh_checkpoint(&hub, &sk).unwrap(), "the empty log's");
730
        assert!(!log.refresh_checkpoint(&hub, &sk).unwrap(), "nothing new");
731
        log.append(&[entry(1), entry(2)]).unwrap();
732
        assert!(log.refresh_checkpoint(&hub, &sk).unwrap());
733
        let note = tlog::Note::parse(&log.checkpoint().unwrap()).unwrap();
734
        let c = Checkpoint::verify(&note, "hub.example:7343/log", &hub).unwrap();
735
        assert_eq!((c.size, c.root), (2, log.tree_hash(2).unwrap()));
736
        // Kept across a restart.
737
        drop(log);
738
        let log = Log::open(dir.path(), "hub.example:7343/log").unwrap();
739
        assert_eq!(log.checkpoint().map(|n| n.contains("\n2\n")), Some(true));
740
    }
741
}