Skip to main content

recall_wire/
audit.rs

1//! `GET /v1/audit/checkpoint`, `GET /v1/audit/entries` and
2//! `GET /v1/audit/consistency` — the Merkle tree over every authenticated
3//! action. See `docs/design/part5-plan.md`'s "PR 1: audit" for the design,
4//! and `docs/reference/api.md` for the authoritative shape of what shipped.
5//!
6//! A leaf's own JSON — `v`, `seq`, `at`, `action`, `actor`, `subject`,
7//! `request` — is not typed here: it varies by `action`, it is written once
8//! by `recall_server::audit::leaf` and never re-serialized (a verifier
9//! hashes the exact bytes it was given), and every consumer of it — this
10//! crate's fixtures, the entries route, an export — treats it as an opaque
11//! string. [`AuditEntriesResponse::entries`] is `Vec<String>` for exactly
12//! that reason.
13//!
14//! Two submodules hold what both halves compute over those strings:
15//! [`merkle`], the tree hash and its proofs, and [`verify`], the offline
16//! check of an exported log that `recall audit verify` runs.
17
18use serde::{Deserialize, Serialize};
19
20pub mod merkle;
21pub mod verify;
22
23/// `GET`: the tree's current size and root.
24pub const CHECKPOINT_PATH: &str = "/v1/audit/checkpoint";
25
26/// `GET ?start=&end=`: leaves `start` to `end - 1`.
27pub const ENTRIES_PATH: &str = "/v1/audit/entries";
28
29/// `GET ?first=&second=`: the RFC 9162 §2.1.4 proof that `second` extends
30/// `first`.
31pub const CONSISTENCY_PATH: &str = "/v1/audit/consistency";
32
33/// The most leaves [`ENTRIES_PATH`] answers with in one page.
34pub const MAX_PAGE: u32 = 1000;
35
36/// The most bytes of leaves [`ENTRIES_PATH`] answers with in one page, 2
37/// MiB. A page whose leaves would come to more stops before the one that
38/// would cross it — never before its first — and its `end` says where it
39/// stopped. A typical leaf is under a kilobyte, so a full page of 1,000
40/// seldom meets it.
41pub const MAX_PAGE_BYTES: usize = 2 << 20;
42
43/// The leaf format this build writes and reads. Carried in the discovery
44/// document's `audit` capability so a client — or a future server version —
45/// knows which rules a leaf without its own `v` field long gone would have
46/// followed; every leaf this version writes carries `v` itself regardless.
47pub const LEAF_VERSION: u32 = 1;
48
49/// The response header every `GET /sync` answer also carries: `<tree_size>
50/// <root_hash>`, e.g. `1042 CsUYapGGPo4dkMgIAUqom/Xajj7h2fB2MPA3j2jxq2I=` —
51/// the same two fields as [`AuditCheckpoint`], so a pull leaves the client a
52/// checkpoint without another request.
53pub const CHECKPOINT_HEADER: &str = "recall-audit-checkpoint";
54
55/// `GET /v1/audit/checkpoint`.
56#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
57pub struct AuditCheckpoint {
58    /// How many leaves the tree has.
59    pub tree_size: u64,
60    /// The tree's root hash, standard base64 (as in a C2SP checkpoint —
61    /// unlike `base_sha256` and the other file hashes on this API, which
62    /// stay lowercase hex).
63    pub root_hash: String,
64}
65
66impl AuditCheckpoint {
67    /// The [`CHECKPOINT_HEADER`] value for this checkpoint.
68    pub fn to_header_value(&self) -> String {
69        format!("{} {}", self.tree_size, self.root_hash)
70    }
71
72    /// Reads a [`CHECKPOINT_HEADER`] value back. [`None`] if it is not
73    /// `<tree_size> <root_hash>`.
74    pub fn parse_header_value(value: &str) -> Option<Self> {
75        let (size, root) = value.trim().split_once(' ')?;
76        Some(Self {
77            tree_size: size.parse().ok()?,
78            root_hash: root.to_string(),
79        })
80    }
81
82    /// [`AuditCheckpoint::root_hash`] as the 32 bytes it encodes. [`None`]
83    /// unless it is exactly their canonical standard base64: a root with a
84    /// second spelling could be saved in one and compared in the other.
85    pub fn root(&self) -> Option<merkle::Hash> {
86        verify::root_hash(&self.root_hash)
87    }
88}
89
90/// `GET /v1/audit/entries`.
91#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
92pub struct AuditEntriesResponse {
93    /// Echoed from the query.
94    pub start: u64,
95    /// One past the last leaf in `entries`: the query's `end`, unless the
96    /// page stopped early at [`MAX_PAGE_BYTES`], when it is where to ask
97    /// from next.
98    pub end: u64,
99    /// The tree's size when this was answered, so a caller paging through
100    /// knows where the end really is without a second request.
101    pub tree_size: u64,
102    /// Leaves `start` to `end - 1`, each exactly as stored: compact JSON,
103    /// never re-serialized.
104    pub entries: Vec<String>,
105}
106
107/// `GET /v1/audit/consistency`.
108#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
109pub struct AuditConsistencyResponse {
110    /// Echoed from the query.
111    pub first: u64,
112    /// Echoed from the query.
113    pub second: u64,
114    /// The proof nodes, standard base64, in the order RFC 9162 §2.1.4
115    /// builds them.
116    pub proof: Vec<String>,
117}
118
119/// The `audit` capability in the discovery document.
120#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
121pub struct AuditCapability {
122    /// The leaf format this server writes: [`LEAF_VERSION`].
123    pub leaf_version: u32,
124    /// The most entries one page of [`ENTRIES_PATH`] holds: [`MAX_PAGE`].
125    pub max_page: u32,
126    /// The most bytes of leaves one page holds: [`MAX_PAGE_BYTES`].
127    pub max_page_bytes: u64,
128}
129
130#[cfg(test)]
131mod tests {
132    use super::*;
133
134    #[test]
135    fn the_checkpoint_header_round_trips() {
136        let cp = AuditCheckpoint {
137            tree_size: 1042,
138            root_hash: "CsUYapGGPo4dkMgIAUqom/Xajj7h2fB2MPA3j2jxq2I=".to_string(),
139        };
140        assert_eq!(
141            cp.to_header_value(),
142            "1042 CsUYapGGPo4dkMgIAUqom/Xajj7h2fB2MPA3j2jxq2I="
143        );
144        assert_eq!(
145            AuditCheckpoint::parse_header_value(&cp.to_header_value()),
146            Some(cp)
147        );
148        assert_eq!(
149            AuditCheckpoint::parse_header_value("not a checkpoint"),
150            None
151        );
152        assert_eq!(AuditCheckpoint::parse_header_value("abc CsUY"), None);
153    }
154}