Skip to main content

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;