lys_core/checkpoint/note.rs
1//! Signing and verifying C2SP signed notes, mirroring the Go `sumdb/note`
2//! reference implementation line-for-line.
3//!
4//! # Invariants
5//!
6//! - The signature line prefix is the U+2014 em dash followed by one ASCII
7//! space — bytes `E2 80 94 20`, never `--` or a lookalike.
8//! - The Ed25519 signature covers the body **including its trailing
9//! `'\n'`**, excluding the blank line and signature lines.
10//! - Key ID = first 4 bytes of `SHA-256(keyname ‖ 0x0A ‖ 0x01 ‖ pubkey)`.
11//! - [`sign_note`] enforces preconditions (valid name; body non-empty,
12//! `'\n'`-terminated, no `"\n\n"`, no ASCII control character below
13//! `0x20` other than `'\n'`; emitted note within `MAX_NOTE_BYTES`)
14//! that guarantee the emitted note always re-verifies under
15//! [`verify_note`] AND under Go `note.Open`.
16//! - [`verify_note`] splits at the LAST `"\n\n"` (Go `bytes.LastIndex`),
17//! rejects any malformed signature line, caps at 100 signature lines
18//! (Go's exact cap) and 1 MiB total (a `lys` defensive cap that
19//! [`sign_note`] also enforces), and mirrors Go `note.Open` candidate
20//! semantics exactly: the FIRST signature line matching the verifier's
21//! `(name, key ID)` decides the whole note — strict Ed25519 success
22//! accepts, failure rejects (later lines are never consulted, matching
23//! Go's duplicate-signer skip and hard `InvalidSignatureError` reject;
24//! the C2SP signed-note spec likewise says a failed known-key signature
25//! rejects the whole note).
26//! - Every verification failure collapses to the single non-oracle
27//! [`TrustError::NoteVerification`] value.
28//!
29//! [`TrustError::NoteVerification`]: crate::error::TrustError::NoteVerification
30
31use base64::Engine;
32use base64::engine::general_purpose::STANDARD;
33use sha2::{Digest, Sha256};
34
35use crate::error::{TrustError, TrustResult};
36use crate::keys::Ed25519Identity;
37
38use super::body::CheckpointBody;
39use super::verifier_key::{NoteVerifierKey, validate_note_name};
40
41/// Defensive cap on total note size (1 MiB). Go has no cap; real
42/// checkpoints are a few hundred bytes, so no legitimate note is affected.
43/// Enforced by BOTH [`sign_note`] (refusing to emit an oversized note) and
44/// [`verify_note`] (rejecting one), so the sign-then-verify invariant
45/// holds for every note this module emits.
46const MAX_NOTE_BYTES: usize = 1024 * 1024;
47
48/// Maximum number of signature lines, adopted verbatim from Go
49/// (`note.Open` rejects past 100).
50const MAX_SIGNATURE_LINES: usize = 100;
51
52/// The signature-line prefix: U+2014 em dash then one ASCII space
53/// (bytes `E2 80 94 20`, Go `sigPrefix`).
54const SIG_PREFIX: &str = "\u{2014} ";
55
56/// Computes the signed-note key ID:
57/// `SHA-256(name ‖ 0x0A ‖ 0x01 ‖ pubkey)[..4]`.
58///
59/// The `0x01` is the Ed25519 algorithm byte; the key ID therefore binds
60/// the key name, the algorithm, and the public key together.
61///
62/// # Errors
63///
64/// Returns [`TrustError::VerifierKey`] if `name` violates the key-name
65/// rules.
66pub fn key_id(name: &str, public_key: &[u8; 32]) -> TrustResult<[u8; 4]> {
67 validate_note_name(name)?;
68 let mut hasher = Sha256::new();
69 hasher.update(name.as_bytes());
70 hasher.update([0x0a]);
71 hasher.update([0x01]);
72 hasher.update(public_key);
73 let digest = hasher.finalize();
74 let mut id = [0u8; 4];
75 id.copy_from_slice(&digest[..4]);
76 Ok(id)
77}
78
79/// Signs `body` under `name` and emits the complete note text:
80/// body ‖ blank line ‖ one signature line.
81///
82/// The signature line is
83/// `"\u{2014} {name} {base64(key_id ‖ signature)}\n"` and the Ed25519
84/// signature covers the body bytes including the trailing `'\n'`.
85///
86/// # Errors
87///
88/// Returns [`TrustError::CheckpointEncoding`] if the name is invalid; if
89/// the body is empty, does not end with `'\n'`, contains `"\n\n"`, or
90/// contains an ASCII control character below `0x20` other than `'\n'`; or
91/// if the complete note would exceed the 1 MiB `MAX_NOTE_BYTES` cap that
92/// [`verify_note`] enforces. These preconditions guarantee the emitted
93/// note always re-verifies under [`verify_note`] and under Go `note.Open`.
94pub fn sign_note(body: &str, name: &str, identity: &Ed25519Identity) -> TrustResult<String> {
95 validate_note_name(name).map_err(|e| TrustError::CheckpointEncoding {
96 reason: format!("invalid note key name: {e}"),
97 })?;
98 if body.is_empty() {
99 return Err(TrustError::CheckpointEncoding {
100 reason: "note body must not be empty".to_string(),
101 });
102 }
103 if !body.ends_with('\n') {
104 return Err(TrustError::CheckpointEncoding {
105 reason: "note body must end with a newline".to_string(),
106 });
107 }
108 if body.contains("\n\n") {
109 return Err(TrustError::CheckpointEncoding {
110 reason: "note body must not contain a blank line".to_string(),
111 });
112 }
113 if body.chars().any(|c| c < ' ' && c != '\n') {
114 return Err(TrustError::CheckpointEncoding {
115 reason: "note body must not contain ASCII control characters other than newline"
116 .to_string(),
117 });
118 }
119 let id =
120 key_id(name, &identity.public_key_bytes()).map_err(|e| TrustError::CheckpointEncoding {
121 reason: format!("invalid note key name: {e}"),
122 })?;
123 let signature = identity.sign(body.as_bytes());
124 let mut blob = Vec::with_capacity(4 + 64);
125 blob.extend_from_slice(&id);
126 blob.extend_from_slice(&signature);
127 let note = format!("{body}\n{SIG_PREFIX}{name} {}\n", STANDARD.encode(&blob));
128 // Enforce the verify-side size cap at signing time too, so the
129 // documented invariant — every emitted note re-verifies under
130 // `verify_note` — holds without exception.
131 if note.len() > MAX_NOTE_BYTES {
132 return Err(TrustError::CheckpointEncoding {
133 reason: format!(
134 "note would be {} bytes, exceeding the {MAX_NOTE_BYTES}-byte cap",
135 note.len()
136 ),
137 });
138 }
139 Ok(note)
140}
141
142/// A parsed candidate signature line: the signer name, the declared 4-byte
143/// key ID, and the remaining signature bytes.
144struct SignatureLine<'a> {
145 name: &'a str,
146 key_id: [u8; 4],
147 signature: Vec<u8>,
148}
149
150/// Parses and verifies an untrusted note; returns the verified body text
151/// (including its trailing newline).
152///
153/// Mirrors Go `note.Open`: total-size cap, UTF-8 with no ASCII control
154/// characters below `0x20` except `'\n'`, split at the last `"\n\n"`,
155/// every signature line structurally valid (any malformed line rejects
156/// the whole note), at most 100 signature lines. The FIRST signature line
157/// matching the verifier's `(name, key ID)` decides the whole note: strict
158/// Ed25519 verification over the body bytes accepts on success and rejects
159/// the note on failure — later matching lines are never consulted. This is
160/// exactly Go's behavior (duplicate signatures by one signer are skipped
161/// after the first, and a failed known-key signature is a hard
162/// `InvalidSignatureError`) and the C2SP signed-note rule that a failed
163/// signature from a known key rejects the whole note.
164///
165/// # Errors
166///
167/// Returns [`TrustError::NoteVerification`] on every failure mode — size,
168/// UTF-8, structure, no candidate, bad signature — with no distinguishing
169/// detail (non-oracle).
170pub fn verify_note(note_bytes: &[u8], verifier: &NoteVerifierKey) -> TrustResult<String> {
171 let (body, signature_lines) = parse_note(note_bytes)?;
172 let public_key = verifier.public_key();
173 for line in &signature_lines {
174 if line.name != verifier.name() || line.key_id != verifier.key_id() {
175 continue;
176 }
177 // First matching (name, key ID) candidate decides the whole note,
178 // mirroring Go note.Open: repeated signatures by one signer are
179 // skipped after the first, and a known-key signature that fails
180 // strict verification rejects the note outright.
181 if line.signature.len() == 64
182 && Ed25519Identity::verify(&public_key, body.as_bytes(), &line.signature).is_ok()
183 {
184 return Ok(body.to_string());
185 }
186 return Err(TrustError::NoteVerification);
187 }
188 Err(TrustError::NoteVerification)
189}
190
191/// [`verify_note`] followed by [`CheckpointBody::parse`] and the
192/// origin-binding check `body.origin() == verifier.name()`.
193///
194/// The binding check closes origin confusion: a key that signs two logs
195/// cannot have a checkpoint for one log accepted by a verifier configured
196/// for the other, because `lys` verifiers are named by the origin they
197/// trust.
198///
199/// # Errors
200///
201/// Returns [`TrustError::NoteVerification`] on any failure, including body
202/// parsing and the binding check (non-oracle).
203pub fn verify_checkpoint(
204 note_bytes: &[u8],
205 verifier: &NoteVerifierKey,
206) -> TrustResult<CheckpointBody> {
207 let body_text = verify_note(note_bytes, verifier)?;
208 let body = CheckpointBody::parse(&body_text).map_err(|_err| TrustError::NoteVerification)?;
209 if body.origin() != verifier.name() {
210 return Err(TrustError::NoteVerification);
211 }
212 Ok(body)
213}
214
215/// Structural parse of a note: returns the body text (with its trailing
216/// newline) and every parsed signature line. Any structural violation is
217/// [`TrustError::NoteVerification`].
218fn parse_note(note_bytes: &[u8]) -> TrustResult<(&str, Vec<SignatureLine<'_>>)> {
219 if note_bytes.len() > MAX_NOTE_BYTES {
220 return Err(TrustError::NoteVerification);
221 }
222 let text = std::str::from_utf8(note_bytes).map_err(|_err| TrustError::NoteVerification)?;
223 if text.chars().any(|c| c < ' ' && c != '\n') {
224 return Err(TrustError::NoteVerification);
225 }
226 // Split at the LAST blank line (Go bytes.LastIndex): the body keeps its
227 // trailing '\n'; the signature block is everything after the blank line
228 // and must be non-empty and '\n'-terminated.
229 let split = text.rfind("\n\n").ok_or(TrustError::NoteVerification)?;
230 let body = &text[..=split];
231 let sig_block = &text[split + 2..];
232 if sig_block.is_empty() || !sig_block.ends_with('\n') {
233 return Err(TrustError::NoteVerification);
234 }
235 let mut lines = Vec::new();
236 for line in sig_block[..sig_block.len() - 1].split('\n') {
237 lines.push(parse_signature_line(line)?);
238 if lines.len() > MAX_SIGNATURE_LINES {
239 return Err(TrustError::NoteVerification);
240 }
241 }
242 Ok((body, lines))
243}
244
245/// Parses one signature line: `"\u{2014} <name> <base64 blob>"` where the
246/// blob decodes canonically to at least 5 bytes (4-byte key ID plus a
247/// non-empty signature). Any violation is [`TrustError::NoteVerification`].
248fn parse_signature_line(line: &str) -> TrustResult<SignatureLine<'_>> {
249 let rest = line
250 .strip_prefix(SIG_PREFIX)
251 .ok_or(TrustError::NoteVerification)?;
252 let (name, blob_b64) = rest.split_once(' ').ok_or(TrustError::NoteVerification)?;
253 if blob_b64.is_empty() || validate_note_name(name).is_err() {
254 return Err(TrustError::NoteVerification);
255 }
256 let blob = STANDARD
257 .decode(blob_b64)
258 .map_err(|_err| TrustError::NoteVerification)?;
259 if blob.len() < 5 {
260 return Err(TrustError::NoteVerification);
261 }
262 let mut id = [0u8; 4];
263 id.copy_from_slice(&blob[..4]);
264 Ok(SignatureLine {
265 name,
266 key_id: id,
267 signature: blob[4..].to_vec(),
268 })
269}
270
271#[cfg(test)]
272#[path = "note_tests.rs"]
273mod tests;