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}