mesh: a cut journal is caught by the receipts the steward handed out, and the receipt chain survives a restart #306
4 changed files with 341 additions and 72 deletions
49
mesh/SPEC.md
49
mesh/SPEC.md
|
|
@ -120,13 +120,22 @@ that can restart MUST therefore keep a durable journal of its grant decisions:
|
|||
backend and the decision time, MUST be written **before** the engine runs. A
|
||||
serve whose authorisation cannot be recorded is refused.
|
||||
- A `Debited` entry, holding the metered energy, is written after the debit.
|
||||
- A `Sealed` entry, holding a receipt's sequence and its envelope's content id,
|
||||
MUST be written for **every** receipt, granted or not, **before** the receipt
|
||||
is handed out. A receipt that cannot be recorded is not handed out, and the
|
||||
receipt chain does not advance past it.
|
||||
|
||||
Each entry is a record of one chain: `{seq, prev, entry}`, where `seq` counts
|
||||
records from 0 and `prev` is the BLAKE3 hash of the previous record's encoding.
|
||||
|
||||
On restart the steward MUST re-establish the spend before serving by replaying
|
||||
the journal through the gate: each authorisation is verified and decided again,
|
||||
at its recorded time, and each debit applied to the serve it belongs to. A
|
||||
replay the gate refuses, a debit with no authorisation, or a presentation spent
|
||||
twice means the journal is not one the steward produced, and the steward MUST
|
||||
NOT serve from it. Replayed presentations count as spent.
|
||||
at its recorded time, and each debit applied to the serve it belongs to. Records
|
||||
that do not form one chain, a replay the gate refuses, a debit with no
|
||||
authorisation, a presentation spent twice, or a receipt out of sequence means the
|
||||
journal is not one the steward produced, and the steward MUST NOT serve from it.
|
||||
Replayed presentations count as spent, and the receipt chain continues from the
|
||||
last `Sealed` entry (§6.3).
|
||||
|
||||
An authorisation with no debit is a serve that was in flight when the steward
|
||||
stopped, and whether the engine finished is not knowable from the journal. The
|
||||
|
|
@ -135,10 +144,24 @@ at its request's joule ceiling, which is what its authorisation admitted, and is
|
|||
surfaced as unresolved, naming the caller and the backend that held it, for an
|
||||
operator to settle.
|
||||
|
||||
The reference journal is an append-only file of one JSON entry per line, each
|
||||
**A cut journal.** Because the records form one chain, a record removed from the
|
||||
middle, edited or moved fails the replay. Records cut from the **end** leave a
|
||||
chain that is whole, and nothing on the steward's own storage can show they
|
||||
existed; but every receipt the steward handed out was recorded first, so the
|
||||
receipts in callers' hands can. Any party may show the steward a receipt it
|
||||
sealed, with its envelope. If the steward's journal does not hold that receipt,
|
||||
records were cut after it left, the spend the steward replayed is less than it
|
||||
signed for, and the steward MUST refuse every grant from then on, until an
|
||||
operator restores the journal. A caller SHOULD show its latest receipt from a
|
||||
steward after the steward restarts. A cut that removes only a serve that
|
||||
produced no receipt, one that was in flight, leaves no evidence outside the
|
||||
steward.
|
||||
|
||||
The reference journal is an append-only file of one JSON record per line, each
|
||||
synced before the append returns. A final line with no newline is an append cut
|
||||
short and is dropped: an authorisation never recorded never started its serve,
|
||||
and a debit never recorded leaves its serve unresolved.
|
||||
short and is dropped: an authorisation never recorded never started its serve, a
|
||||
debit never recorded leaves its serve unresolved, and a seal never recorded was
|
||||
never handed out.
|
||||
|
||||
---
|
||||
|
||||
|
|
@ -431,6 +454,10 @@ A steward keeps a receipt log. Appending a receipt stamps its `sequence` (from 0
|
|||
and `prev` (the content id of the previous sealed envelope; absent at genesis),
|
||||
seals it, and advances the head. The log is tamper-evident end to end: altering
|
||||
any envelope breaks verification of that receipt and the chain link from the next.
|
||||
A steward that restarts continues its chain from its journal (§2.2): the next
|
||||
receipt takes the next sequence and chains to the last envelope sealed, rather
|
||||
than starting a new chain at 0, so a restart is not mistaken for a log that lost
|
||||
its history.
|
||||
|
||||
---
|
||||
|
||||
|
|
@ -446,7 +473,9 @@ A conforming implementation MUST:
|
|||
or has less left than the request's joule ceiling; debit the metered joules
|
||||
against every link after the work, recording a breach on the receipt.
|
||||
2. Where the steward can restart, journal each decision before its serve runs
|
||||
and re-establish spend from the journal before serving again (§2.2).
|
||||
and each receipt before it is handed out, re-establish spend and the receipt
|
||||
chain from the journal before serving again, and refuse grants once shown a
|
||||
receipt the journal does not hold (§2.2).
|
||||
3. Choose the cheapest admissible backend and record every candidate weighed
|
||||
(§4).
|
||||
4. Meter energy around the engine and tag provenance honestly, enforcing the
|
||||
|
|
@ -484,7 +513,9 @@ cryptography. These properties are correctness, not style:
|
|||
replayed through the gate on restart (§2.2), so a restart gives no budget back
|
||||
and a crash mid-serve leaves the serve debited and unresolved rather than
|
||||
free. A journal is only as durable as the storage under it, and a steward
|
||||
with an in-memory journal has no such guarantee.
|
||||
with an in-memory journal has no such guarantee. Records cut from its end are
|
||||
invisible to the steward, but not to whoever holds a receipt it sealed after
|
||||
the cut point: shown one, the steward stops honouring grants.
|
||||
- **Honesty is enforced at seal time.** A receipt cannot claim a measurement it
|
||||
does not have (§3.3). An operator without a hardware meter produces honest
|
||||
`Estimator` receipts.
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
//! The steward's decision journal (SPEC §2.2): where a grant's spend at this
|
||||
//! steward survives the process (JCP §4.11).
|
||||
//! steward, and the steward's receipt chain, survive the process (JCP §4.11).
|
||||
|
||||
use std::fs::{File, OpenOptions};
|
||||
use std::io::{self, Read, Write};
|
||||
|
|
@ -28,6 +28,37 @@ pub enum JournalEntry {
|
|||
},
|
||||
/// The serve's metered joules were debited against the grant.
|
||||
Debited { grant_id: ContentId, nonce: [u8; 16], energy: Energy },
|
||||
/// A receipt was sealed. Written before the receipt is handed out, so
|
||||
/// every receipt that leaves the steward has a record here, and the
|
||||
/// receipt chain continues from it after a restart.
|
||||
Sealed {
|
||||
/// The receipt's position in the steward's receipt chain.
|
||||
sequence: u64,
|
||||
/// The content id of its sealed envelope.
|
||||
envelope: ContentId,
|
||||
},
|
||||
}
|
||||
|
||||
/// One line of the journal: an entry, its position, and the hash of the
|
||||
/// record before it. The records form one chain, so a record removed from the
|
||||
/// middle, or edited, or moved, breaks it; only records cut from the end leave
|
||||
/// a chain that is whole, which is what [`Mesh::witness`](crate::Mesh::witness)
|
||||
/// is for.
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct JournalRecord {
|
||||
pub seq: u64,
|
||||
/// The [`head`](Self::head) of the record before this one; `None` for the
|
||||
/// first.
|
||||
pub prev: Option<ContentId>,
|
||||
pub entry: JournalEntry,
|
||||
}
|
||||
|
||||
impl JournalRecord {
|
||||
/// The record's hash: BLAKE3 over its JSON encoding, which is the bytes a
|
||||
/// [`FileJournal`] stores.
|
||||
pub fn head(&self) -> ContentId {
|
||||
ContentId::of_bytes(&serde_json::to_vec(self).expect("a journal record encodes as JSON"))
|
||||
}
|
||||
}
|
||||
|
||||
/// A serve the journal shows authorised and never debited: the steward stopped
|
||||
|
|
@ -46,11 +77,12 @@ pub struct Unresolved {
|
|||
pub debited_uj: u64,
|
||||
}
|
||||
|
||||
/// Where a steward records its grant decisions.
|
||||
/// Where a steward records its decisions and seals.
|
||||
pub trait Journal {
|
||||
/// Record one entry durably. An error refuses the serve it belongs to: a
|
||||
/// decision that could not be recorded is not acted on.
|
||||
fn append(&mut self, entry: &JournalEntry) -> io::Result<()>;
|
||||
/// Record one record durably. An error refuses what the record was for: a
|
||||
/// decision that could not be recorded is not acted on, and a receipt that
|
||||
/// could not be recorded is not handed out.
|
||||
fn append(&mut self, record: &JournalRecord) -> io::Result<()>;
|
||||
}
|
||||
|
||||
/// A journal in memory, and the default. It is enough for a steward that never
|
||||
|
|
@ -58,24 +90,24 @@ pub trait Journal {
|
|||
/// [`FileJournal`] (SPEC §2.2).
|
||||
#[derive(Clone, Debug, Default)]
|
||||
pub struct MemoryJournal {
|
||||
entries: Vec<JournalEntry>,
|
||||
records: Vec<JournalRecord>,
|
||||
}
|
||||
|
||||
impl MemoryJournal {
|
||||
/// The entries recorded so far, in order.
|
||||
pub fn entries(&self) -> &[JournalEntry] {
|
||||
&self.entries
|
||||
/// The records so far, in order.
|
||||
pub fn records(&self) -> &[JournalRecord] {
|
||||
&self.records
|
||||
}
|
||||
}
|
||||
|
||||
impl Journal for MemoryJournal {
|
||||
fn append(&mut self, entry: &JournalEntry) -> io::Result<()> {
|
||||
self.entries.push(entry.clone());
|
||||
fn append(&mut self, record: &JournalRecord) -> io::Result<()> {
|
||||
self.records.push(record.clone());
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
/// A journal in an append-only file: one JSON entry per line, each synced to
|
||||
/// A journal in an append-only file: one JSON record per line, each synced to
|
||||
/// disk before [`append`](Journal::append) returns.
|
||||
pub struct FileJournal {
|
||||
file: File,
|
||||
|
|
@ -83,16 +115,16 @@ pub struct FileJournal {
|
|||
|
||||
impl FileJournal {
|
||||
/// Open the journal at `path`, creating it if absent. Returns it with the
|
||||
/// entries it already holds, for
|
||||
/// records it already holds, for
|
||||
/// [`Mesh::with_journal`](crate::Mesh::with_journal) to replay.
|
||||
///
|
||||
/// A final line with no newline is an append the steward was interrupted
|
||||
/// in. It is dropped and cut from the file, which is safe either way: an
|
||||
/// authorisation that was never recorded never started a serve, and a debit
|
||||
/// that was never recorded leaves its serve authorised and undebited, which
|
||||
/// resolves as [`Unresolved`]. Any other line that does not parse is an
|
||||
/// error, because a journal that cannot be read cannot re-establish spend.
|
||||
pub fn open(path: impl AsRef<Path>) -> io::Result<(Self, Vec<JournalEntry>)> {
|
||||
/// in. It is dropped and cut from the file, which is safe for every entry:
|
||||
/// an authorisation never recorded never started its serve, a debit never
|
||||
/// recorded leaves its serve [`Unresolved`], and a seal never recorded was
|
||||
/// never handed out. Any other line that does not parse is an error,
|
||||
/// because a journal that cannot be read cannot re-establish spend.
|
||||
pub fn open(path: impl AsRef<Path>) -> io::Result<(Self, Vec<JournalRecord>)> {
|
||||
let mut file = OpenOptions::new().read(true).append(true).create(true).open(path)?;
|
||||
let mut bytes = Vec::new();
|
||||
file.read_to_end(&mut bytes)?;
|
||||
|
|
@ -101,23 +133,23 @@ impl FileJournal {
|
|||
file.set_len(complete as u64)?;
|
||||
file.sync_data()?;
|
||||
}
|
||||
let mut entries = Vec::new();
|
||||
let mut records = Vec::new();
|
||||
for (n, line) in bytes[..complete].split(|&b| b == b'\n').enumerate() {
|
||||
if line.is_empty() {
|
||||
continue;
|
||||
}
|
||||
let entry = serde_json::from_slice(line).map_err(|e| {
|
||||
let record = serde_json::from_slice(line).map_err(|e| {
|
||||
io::Error::new(io::ErrorKind::InvalidData, format!("journal line {}: {e}", n + 1))
|
||||
})?;
|
||||
entries.push(entry);
|
||||
records.push(record);
|
||||
}
|
||||
Ok((FileJournal { file }, entries))
|
||||
Ok((FileJournal { file }, records))
|
||||
}
|
||||
}
|
||||
|
||||
impl Journal for FileJournal {
|
||||
fn append(&mut self, entry: &JournalEntry) -> io::Result<()> {
|
||||
let mut line = serde_json::to_vec(entry)?;
|
||||
fn append(&mut self, record: &JournalRecord) -> io::Result<()> {
|
||||
let mut line = serde_json::to_vec(record)?;
|
||||
line.push(b'\n');
|
||||
self.file.write_all(&line)?;
|
||||
self.file.sync_data()
|
||||
|
|
|
|||
|
|
@ -39,7 +39,7 @@ use jcp_schema::{Caveat, ContentId, DenyReason, Did, Frame, FrameKind, SignedGra
|
|||
mod journal;
|
||||
mod presentation;
|
||||
|
||||
pub use journal::{FileJournal, Journal, JournalEntry, MemoryJournal, Unresolved};
|
||||
pub use journal::{FileJournal, Journal, JournalEntry, JournalRecord, MemoryJournal, Unresolved};
|
||||
pub use presentation::Presentation;
|
||||
use mesh_engine::ServingEngine;
|
||||
use mesh_meter::{Meter, Session};
|
||||
|
|
@ -87,10 +87,24 @@ pub enum MeshError {
|
|||
/// on; or, after the work, its debit could not be.
|
||||
#[error("journal: {0}")]
|
||||
Journal(String),
|
||||
/// A journal being replayed holds an entry the gate would not have
|
||||
/// allowed, or a debit for a serve it never authorised.
|
||||
#[error("journal entry {entry} is inconsistent: {reason}")]
|
||||
/// A journal being replayed is not one this steward produced: its records
|
||||
/// do not form one chain, or it holds an entry the gate would not have
|
||||
/// allowed, a debit for a serve it never authorised, or a receipt out of
|
||||
/// sequence.
|
||||
#[error("journal record {entry} is inconsistent: {reason}")]
|
||||
JournalInconsistent { entry: usize, reason: String },
|
||||
/// A receipt this steward sealed is not in its journal, so records were cut
|
||||
/// from the journal after the receipt was handed out, and the spend
|
||||
/// replayed from it is less than the steward signed for. The steward
|
||||
/// refuses every grant from then on (SPEC §2.2).
|
||||
#[error(
|
||||
"the journal holds {holds} receipt(s), but this steward sealed receipt {witnessed}: \
|
||||
records were cut from it, so it refuses grants"
|
||||
)]
|
||||
JournalCut { holds: u64, witnessed: u64 },
|
||||
/// A receipt shown as a witness is not one this steward sealed.
|
||||
#[error("not a receipt this steward sealed: {0}")]
|
||||
WitnessInvalid(String),
|
||||
/// The engine reported running other weights than the ones it named
|
||||
/// before serving, which are the ones the grant was decided over.
|
||||
#[error("backend `{backend}` named model {named} before serving but reported {reported}")]
|
||||
|
|
@ -145,8 +159,16 @@ pub struct Mesh<'m> {
|
|||
/// The steward's identity as a grant holder: the subject a grant must name
|
||||
/// to be spent here.
|
||||
steward_did: Did,
|
||||
/// Where each grant decision is recorded before its serve runs.
|
||||
/// Where each grant decision is recorded before its serve runs, and each
|
||||
/// receipt before it is handed out.
|
||||
journal: Box<dyn Journal>,
|
||||
/// The head of every journal record, by its position.
|
||||
journal_heads: Vec<ContentId>,
|
||||
/// The envelope id of every receipt the journal holds, by sequence.
|
||||
sealed: Vec<ContentId>,
|
||||
/// Set once a witness shows the journal was cut: how many receipts it
|
||||
/// holds, and the sequence of the receipt that proved it.
|
||||
cut: Option<(u64, u64)>,
|
||||
/// Presentations already spent here, by leaf and nonce.
|
||||
spent_presentations: HashSet<(ContentId, [u8; 16])>,
|
||||
/// Serves a replayed journal showed in flight when the steward stopped.
|
||||
|
|
@ -170,27 +192,35 @@ impl<'m> Mesh<'m> {
|
|||
grants: Runtime::new(),
|
||||
steward_did,
|
||||
journal: Box::new(MemoryJournal::default()),
|
||||
journal_heads: Vec::new(),
|
||||
sealed: Vec::new(),
|
||||
cut: None,
|
||||
spent_presentations: HashSet::new(),
|
||||
unresolved: Vec::new(),
|
||||
clock: system_clock,
|
||||
}
|
||||
}
|
||||
|
||||
/// Record grant decisions in `journal`, first re-establishing the spend
|
||||
/// from `recorded`, the entries it already holds, by replaying each
|
||||
/// decision through the gate (SPEC §2.2, JCP §4.11). Call it on a new mesh,
|
||||
/// before serving.
|
||||
/// Record grant decisions and seals in `journal`, first re-establishing
|
||||
/// the spend and the receipt chain from `recorded`, the records it already
|
||||
/// holds, by replaying each through the gate (SPEC §2.2, JCP §4.11). Call
|
||||
/// it on a new mesh, before serving.
|
||||
///
|
||||
/// A replayed authorisation must still verify and be allowed, and a debit
|
||||
/// must belong to a serve the journal authorised; otherwise the journal is
|
||||
/// The records must form one chain. A replayed authorisation must still
|
||||
/// verify and be allowed, a debit must belong to a serve the journal
|
||||
/// authorised, and seals must run in sequence; otherwise the journal is
|
||||
/// inconsistent and the mesh is not built. A serve authorised and never
|
||||
/// debited was in flight when the steward stopped: it is debited at its
|
||||
/// request's ceiling and listed in [`unresolved`](Self::unresolved).
|
||||
pub fn with_journal(mut self, journal: impl Journal + 'static, recorded: &[JournalEntry]) -> Result<Self, MeshError> {
|
||||
/// request's ceiling and listed in [`unresolved`](Self::unresolved). The
|
||||
/// next receipt continues the chain from the last seal.
|
||||
pub fn with_journal(mut self, journal: impl Journal + 'static, recorded: &[JournalRecord]) -> Result<Self, MeshError> {
|
||||
let inconsistent = |entry: usize, reason: String| MeshError::JournalInconsistent { entry, reason };
|
||||
let mut open: Vec<(&Presentation, &ServeRequest, &str)> = Vec::new();
|
||||
for (i, entry) in recorded.iter().enumerate() {
|
||||
match entry {
|
||||
for (i, record) in recorded.iter().enumerate() {
|
||||
if record.seq != i as u64 || record.prev != self.journal_heads.last().copied() {
|
||||
return Err(inconsistent(i, "the records do not form one chain".into()));
|
||||
}
|
||||
match &record.entry {
|
||||
JournalEntry::Authorised { presentation, request, model, backend, now } => {
|
||||
presentation.verify(request).map_err(|r| inconsistent(i, r))?;
|
||||
if self.spent_presentations.contains(&presentation.spend_key()) {
|
||||
|
|
@ -209,7 +239,14 @@ impl<'m> Mesh<'m> {
|
|||
let (p, ..) = open.remove(at);
|
||||
self.grants.charge_chained(&p.chain, jcp_energy(energy));
|
||||
}
|
||||
JournalEntry::Sealed { sequence, envelope } => {
|
||||
if *sequence != self.sealed.len() as u64 {
|
||||
return Err(inconsistent(i, "a receipt out of sequence".into()));
|
||||
}
|
||||
self.sealed.push(*envelope);
|
||||
}
|
||||
}
|
||||
self.journal_heads.push(record.head());
|
||||
}
|
||||
for (p, request, backend) in open {
|
||||
let ceiling = request.joule_ceiling_uj;
|
||||
|
|
@ -222,10 +259,42 @@ impl<'m> Mesh<'m> {
|
|||
debited_uj: ceiling,
|
||||
});
|
||||
}
|
||||
self.log.resume(self.sealed.len() as u64, self.sealed.last().copied());
|
||||
self.journal = Box::new(journal);
|
||||
Ok(self)
|
||||
}
|
||||
|
||||
/// Show the steward a receipt it sealed, with its envelope. The journal
|
||||
/// records every receipt before it is handed out, so a receipt the journal
|
||||
/// does not hold proves records were cut from it after that receipt left:
|
||||
/// the spend replayed from it is less than the steward signed for. The
|
||||
/// steward then refuses every grant (SPEC §2.2). Anyone holding a receipt
|
||||
/// from this steward can show it; a caller SHOULD show its latest after
|
||||
/// the steward restarts.
|
||||
pub fn witness(&mut self, receipt: &MeshReceipt, envelope: &[u8]) -> Result<(), MeshError> {
|
||||
receipt
|
||||
.verify_envelope(envelope, self.log.steward(), &self.log.verifying_key())
|
||||
.map_err(|e| MeshError::WitnessInvalid(e.to_string()))?;
|
||||
if self.sealed.get(receipt.sequence as usize) == Some(&ContentId::of_bytes(envelope)) {
|
||||
return Ok(());
|
||||
}
|
||||
let cut = (self.sealed.len() as u64, receipt.sequence);
|
||||
self.cut = Some(cut);
|
||||
Err(MeshError::JournalCut { holds: cut.0, witnessed: cut.1 })
|
||||
}
|
||||
|
||||
/// Append `entry` to the journal as the next record of its chain.
|
||||
fn record(&mut self, entry: JournalEntry) -> Result<(), MeshError> {
|
||||
let record = JournalRecord {
|
||||
seq: self.journal_heads.len() as u64,
|
||||
prev: self.journal_heads.last().copied(),
|
||||
entry,
|
||||
};
|
||||
self.journal.append(&record).map_err(|e| MeshError::Journal(e.to_string()))?;
|
||||
self.journal_heads.push(record.head());
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Serves a replayed journal showed in flight when the steward stopped
|
||||
/// (JCP §4.11): each stays debited at its request's ceiling, and none is
|
||||
/// retried.
|
||||
|
|
@ -326,6 +395,9 @@ impl<'m> Mesh<'m> {
|
|||
// presentation for this request, and the presentation must not have
|
||||
// been spent here before.
|
||||
if let Some(p) = grant {
|
||||
if let Some((holds, witnessed)) = self.cut {
|
||||
return Err(MeshError::JournalCut { holds, witnessed });
|
||||
}
|
||||
p.verify(req).map_err(MeshError::CallerNotAuthenticated)?;
|
||||
if self.spent_presentations.contains(&p.spend_key()) {
|
||||
return Err(MeshError::Replayed);
|
||||
|
|
@ -367,15 +439,13 @@ impl<'m> Mesh<'m> {
|
|||
Some(p) => {
|
||||
let now = (self.clock)();
|
||||
let id = self.authorize(req, p, model, now)?;
|
||||
self.journal
|
||||
.append(&JournalEntry::Authorised {
|
||||
presentation: p.clone(),
|
||||
request: req.clone(),
|
||||
model,
|
||||
backend: backend.0.clone(),
|
||||
now,
|
||||
})
|
||||
.map_err(|e| MeshError::Journal(e.to_string()))?;
|
||||
self.record(JournalEntry::Authorised {
|
||||
presentation: p.clone(),
|
||||
request: req.clone(),
|
||||
model,
|
||||
backend: backend.0.clone(),
|
||||
now,
|
||||
})?;
|
||||
self.spent_presentations.insert(p.spend_key());
|
||||
Some(id)
|
||||
}
|
||||
|
|
@ -406,9 +476,7 @@ impl<'m> Mesh<'m> {
|
|||
Some(p) => {
|
||||
let d = self.grants.charge_chained(&p.chain, jcp_energy(&energy));
|
||||
let (grant_id, nonce) = p.spend_key();
|
||||
self.journal
|
||||
.append(&JournalEntry::Debited { grant_id, nonce, energy })
|
||||
.map_err(|e| MeshError::Journal(e.to_string()))?;
|
||||
self.record(JournalEntry::Debited { grant_id, nonce, energy })?;
|
||||
match d.verdict {
|
||||
Verdict::Allow => None,
|
||||
Verdict::Deny(r) => Some(r.label().to_string()),
|
||||
|
|
@ -489,7 +557,13 @@ impl<'m> Mesh<'m> {
|
|||
prev: None, // stamped by the log
|
||||
nonce,
|
||||
};
|
||||
let (receipt, envelope) = self.log.append(receipt)?;
|
||||
// Recorded before it is handed out, so every receipt that leaves the
|
||||
// steward is in the journal, and one that is not proves a cut.
|
||||
let (receipt, envelope) = self.log.seal_next(receipt)?;
|
||||
let id = ContentId::of_bytes(&envelope);
|
||||
self.record(JournalEntry::Sealed { sequence: receipt.sequence, envelope: id })?;
|
||||
self.sealed.push(id);
|
||||
self.log.advance(envelope.clone());
|
||||
|
||||
Ok(ServeResponse {
|
||||
route,
|
||||
|
|
@ -920,11 +994,11 @@ mod tests {
|
|||
|
||||
/// A journal whose entries the test reads after the mesh is gone.
|
||||
#[derive(Clone, Default)]
|
||||
struct SharedJournal(std::rc::Rc<std::cell::RefCell<Vec<JournalEntry>>>);
|
||||
struct SharedJournal(std::rc::Rc<std::cell::RefCell<Vec<JournalRecord>>>);
|
||||
|
||||
impl Journal for SharedJournal {
|
||||
fn append(&mut self, entry: &JournalEntry) -> std::io::Result<()> {
|
||||
self.0.borrow_mut().push(entry.clone());
|
||||
fn append(&mut self, record: &JournalRecord) -> std::io::Result<()> {
|
||||
self.0.borrow_mut().push(record.clone());
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
|
@ -945,7 +1019,7 @@ mod tests {
|
|||
mesh.serve(&req, &[], Some(&present(&sg, &req))).unwrap();
|
||||
}
|
||||
let recorded = journal.0.borrow().clone();
|
||||
assert_eq!(recorded.len(), 4, "two authorisations and two debits");
|
||||
assert_eq!(recorded.len(), 6, "two authorisations, two debits and two seals");
|
||||
|
||||
let mut mesh = mesh_with(&meter).with_journal(journal.clone(), &recorded).unwrap();
|
||||
assert_eq!(mesh.spent(&sg.grant.id()), 6_000_000, "the restart gave no budget back");
|
||||
|
|
@ -992,10 +1066,10 @@ mod tests {
|
|||
before.serve(&req, &[], Some(&present(&sg, &req))).unwrap();
|
||||
drop(before);
|
||||
let recorded = journal.0.borrow().clone();
|
||||
let resume = |entries: &[JournalEntry]| mesh_with(&meter).with_journal(MemoryJournal::default(), entries).err();
|
||||
let resume = |records: &[JournalRecord]| mesh_with(&meter).with_journal(MemoryJournal::default(), records).err();
|
||||
|
||||
let mut forged = recorded.clone();
|
||||
if let JournalEntry::Authorised { presentation, .. } = &mut forged[0] {
|
||||
if let JournalEntry::Authorised { presentation, .. } = &mut forged[0].entry {
|
||||
presentation.nonce[15] ^= 1;
|
||||
}
|
||||
assert!(matches!(resume(&forged), Some(MeshError::JournalInconsistent { entry: 0, .. })));
|
||||
|
|
@ -1028,7 +1102,7 @@ mod tests {
|
|||
drop(file);
|
||||
|
||||
let (journal, recorded) = FileJournal::open(&path).unwrap();
|
||||
assert_eq!(recorded.len(), 2);
|
||||
assert_eq!(recorded.len(), 3);
|
||||
assert!(std::fs::read(&path).unwrap().ends_with(b"\n"), "the torn line was cut");
|
||||
let mesh = mesh_with(&meter).with_journal(journal, &recorded).unwrap();
|
||||
assert_eq!(mesh.spent(&sg.grant.id()), 1_000);
|
||||
|
|
@ -1036,6 +1110,111 @@ mod tests {
|
|||
std::fs::remove_dir_all(&dir).unwrap();
|
||||
}
|
||||
|
||||
/// The journal records every receipt before it is handed out, so a
|
||||
/// receipt the journal does not hold proves records were cut after it
|
||||
/// left. Shown one, the steward refuses grants: the spend it replayed is
|
||||
/// less than it signed for. Requests that ride on no grant still serve.
|
||||
#[test]
|
||||
fn a_receipt_proves_a_cut_journal() {
|
||||
let meter = SteppingMeter::new("test-rapl", Provenance::HwShunt, 3_000_000);
|
||||
let req = request(8, Fidelity::F1, 4_000_000);
|
||||
let sg = grant_over(&req, 10_000_000, 0, vec![]);
|
||||
let journal = SharedJournal::default();
|
||||
let mut before = mesh_with(&meter).with_journal(journal.clone(), &[]).unwrap();
|
||||
let first = before.serve(&req, &[], Some(&present(&sg, &req))).unwrap();
|
||||
let cut_away = present(&sg, &req);
|
||||
let second = before.serve(&req, &[], Some(&cut_away)).unwrap();
|
||||
drop(before);
|
||||
let recorded = journal.0.borrow().clone();
|
||||
|
||||
// The last serve's three records are cut: the chain that is left is
|
||||
// whole, so the replay alone cannot tell, and the grant has 3 J back.
|
||||
let mut mesh = mesh_with(&meter).with_journal(MemoryJournal::default(), &recorded[..3]).unwrap();
|
||||
assert_eq!(mesh.spent(&sg.grant.id()), 3_000_000);
|
||||
mesh.witness(&first.receipt, &first.envelope).expect("the journal holds the first receipt");
|
||||
assert!(matches!(
|
||||
mesh.witness(&second.receipt, &second.envelope),
|
||||
Err(MeshError::JournalCut { holds: 1, witnessed: 1 })
|
||||
));
|
||||
assert!(matches!(mesh.serve(&req, &[], Some(&cut_away)), Err(MeshError::JournalCut { .. })));
|
||||
assert!(matches!(mesh.serve(&req, &[], Some(&present(&sg, &req))), Err(MeshError::JournalCut { .. })));
|
||||
mesh.serve(&req, &[], None).expect("a request on no grant spends no grant");
|
||||
}
|
||||
|
||||
/// After a restart the receipt chain continues from the journal: the next
|
||||
/// receipt takes the next sequence and chains to the last envelope, rather
|
||||
/// than starting a new chain at zero.
|
||||
#[test]
|
||||
fn the_receipt_chain_continues_across_a_restart() {
|
||||
let meter = SteppingMeter::new("test-rapl", Provenance::HwShunt, 1_000);
|
||||
let req = request(8, Fidelity::F1, 4_000_000);
|
||||
let journal = SharedJournal::default();
|
||||
let mut before = mesh_with(&meter).with_journal(journal.clone(), &[]).unwrap();
|
||||
before.serve(&req, &[], None).unwrap();
|
||||
let last = before.serve(&req, &[], Some(&present(&grant_over(&req, 10_000_000, 0, vec![]), &req))).unwrap();
|
||||
drop(before);
|
||||
let recorded = journal.0.borrow().clone();
|
||||
|
||||
let mut mesh = mesh_with(&meter).with_journal(journal.clone(), &recorded).unwrap();
|
||||
mesh.witness(&last.receipt, &last.envelope).expect("the journal holds it");
|
||||
let next = mesh.serve(&req, &[], None).unwrap();
|
||||
assert_eq!(next.receipt.sequence, 2);
|
||||
assert_eq!(next.receipt.prev, Some(ContentId::of_bytes(&last.envelope)));
|
||||
assert_eq!(mesh.head(), Some(ContentId::of_bytes(&next.envelope)));
|
||||
}
|
||||
|
||||
/// The records form one chain, so a record removed from the middle, or
|
||||
/// edited, breaks it: only a cut from the end needs a witness.
|
||||
#[test]
|
||||
fn a_record_removed_from_the_middle_breaks_the_chain() {
|
||||
let meter = SteppingMeter::new("test-rapl", Provenance::HwShunt, 1_000);
|
||||
let req = request(8, Fidelity::F1, 4_000_000);
|
||||
let sg = grant_over(&req, 10_000_000, 0, vec![]);
|
||||
let journal = SharedJournal::default();
|
||||
let mut before = mesh_with(&meter).with_journal(journal.clone(), &[]).unwrap();
|
||||
before.serve(&req, &[], Some(&present(&sg, &req))).unwrap();
|
||||
before.serve(&req, &[], None).unwrap();
|
||||
drop(before);
|
||||
let recorded = journal.0.borrow().clone();
|
||||
let resume = |records: &[JournalRecord]| mesh_with(&meter).with_journal(MemoryJournal::default(), records).err();
|
||||
|
||||
let mut gapped = recorded.clone();
|
||||
gapped.remove(1);
|
||||
assert!(matches!(resume(&gapped), Some(MeshError::JournalInconsistent { entry: 1, .. })));
|
||||
let mut renumbered = gapped.clone();
|
||||
for (i, r) in renumbered.iter_mut().enumerate() {
|
||||
r.seq = i as u64;
|
||||
}
|
||||
assert!(matches!(resume(&renumbered), Some(MeshError::JournalInconsistent { entry: 1, .. })));
|
||||
let mut edited = recorded.clone();
|
||||
if let JournalEntry::Debited { energy, .. } = &mut edited[1].entry {
|
||||
energy.estimated_uj = 1;
|
||||
energy.measured_uj = Some(1);
|
||||
}
|
||||
assert!(matches!(resume(&edited), Some(MeshError::JournalInconsistent { entry: 2, .. })));
|
||||
assert!(resume(&recorded).is_none());
|
||||
}
|
||||
|
||||
/// A witness must be a receipt this steward sealed, unaltered.
|
||||
#[test]
|
||||
fn a_witness_must_be_this_stewards_receipt() {
|
||||
let meter = SteppingMeter::new("test-rapl", Provenance::HwShunt, 1_000);
|
||||
let req = request(8, Fidelity::F1, 4_000_000);
|
||||
let mut mesh = mesh_with(&meter);
|
||||
let resp = mesh.serve(&req, &[], None).unwrap();
|
||||
let mut other = Mesh::new(&meter, SigningKey::from_bytes(&[12u8; 32]), "did:example:mesh-steward");
|
||||
other.register(
|
||||
BackendSpec::new("tiny", "ssm-1b", Fidelity::F1, 50_000),
|
||||
Box::new(ReferenceEngine::new("tiny", "ssm-1b", Fidelity::F1, 50_000, 256)),
|
||||
);
|
||||
let foreign = other.serve(&req, &[], None).unwrap();
|
||||
assert!(matches!(mesh.witness(&foreign.receipt, &foreign.envelope), Err(MeshError::WitnessInvalid(_))));
|
||||
let mut altered = resp.receipt.clone();
|
||||
altered.sequence = 7;
|
||||
assert!(matches!(mesh.witness(&altered, &resp.envelope), Err(MeshError::WitnessInvalid(_))));
|
||||
mesh.witness(&resp.receipt, &resp.envelope).expect("its own receipt");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn measured_end_to_end_seals_and_verifies() {
|
||||
// A HwShunt stepping meter yields a deterministic 3_200_000 µJ span,
|
||||
|
|
|
|||
|
|
@ -308,14 +308,41 @@ impl ReceiptLog {
|
|||
|
||||
/// Chain a receipt: stamp `sequence` + `prev`, seal, advance the head.
|
||||
/// Returns the stamped receipt and its sealed envelope.
|
||||
pub fn append(&mut self, mut receipt: MeshReceipt) -> Result<(MeshReceipt, Vec<u8>), ReceiptError> {
|
||||
pub fn append(&mut self, receipt: MeshReceipt) -> Result<(MeshReceipt, Vec<u8>), ReceiptError> {
|
||||
let (receipt, envelope) = self.seal_next(receipt)?;
|
||||
self.advance(envelope.clone());
|
||||
Ok((receipt, envelope))
|
||||
}
|
||||
|
||||
/// Stamp and seal the next receipt without advancing the log, so a
|
||||
/// steward can record it durably first and [`advance`](Self::advance)
|
||||
/// only once it has. Sealing the same receipt again gives the same bytes.
|
||||
pub fn seal_next(&self, mut receipt: MeshReceipt) -> Result<(MeshReceipt, Vec<u8>), ReceiptError> {
|
||||
receipt.sequence = self.seq;
|
||||
receipt.prev = self.head;
|
||||
let envelope = receipt.seal(&self.steward_sk, &self.steward)?;
|
||||
Ok((receipt, envelope))
|
||||
}
|
||||
|
||||
/// Advance past an envelope [`seal_next`](Self::seal_next) produced.
|
||||
pub fn advance(&mut self, envelope: Vec<u8>) {
|
||||
self.head = Some(ContentId::of_bytes(&envelope));
|
||||
self.seq += 1;
|
||||
self.entries.push(envelope.clone());
|
||||
Ok((receipt, envelope))
|
||||
self.entries.push(envelope);
|
||||
}
|
||||
|
||||
/// Continue a chain an earlier process sealed: the next receipt takes
|
||||
/// `next_sequence` and chains to `head`, the content id of the last
|
||||
/// envelope sealed. Envelopes sealed before are not in
|
||||
/// [`entries`](Self::entries); they were handed out when sealed.
|
||||
pub fn resume(&mut self, next_sequence: u64, head: Option<ContentId>) {
|
||||
self.seq = next_sequence;
|
||||
self.head = head;
|
||||
}
|
||||
|
||||
/// The sequence the next receipt will take.
|
||||
pub fn next_sequence(&self) -> u64 {
|
||||
self.seq
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
Loading…
Reference in a new issue