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