Skip to main content

auths_transparency/
note.rs

1use base64::{Engine, engine::general_purpose::STANDARD};
2
3use crate::error::TransparencyError;
4use crate::types::MerkleHash;
5
6/// Ed25519 algorithm byte per C2SP signed-note spec.
7const ALG_ED25519: u8 = 0x01;
8
9/// Compute a C2SP key ID from a key name and Ed25519 public key.
10///
11/// `key_id = SHA-256(key_name + "\n" + 0x01 + pubkey)[0..4]`
12///
13/// Args:
14/// * `key_name` — The log's key name (e.g., "auths.dev/log").
15/// * `pubkey` — 32-byte Ed25519 public key.
16///
17/// Usage:
18/// ```ignore
19/// let key_id = compute_key_id("auths.dev/log", &pubkey_bytes);
20/// ```
21pub fn compute_key_id(key_name: &str, pubkey: &[u8; 32]) -> [u8; 4] {
22    let mut data = Vec::with_capacity(key_name.len() + 1 + 1 + 32);
23    data.extend_from_slice(key_name.as_bytes());
24    data.push(b'\n');
25    data.push(ALG_ED25519);
26    data.extend_from_slice(pubkey);
27
28    let hash = MerkleHash::sha256(&data);
29    let mut id = [0u8; 4];
30    id.copy_from_slice(&hash.as_bytes()[..4]);
31    id
32}
33
34/// Compute a C2SP key ID for an **ECDSA-P256** signer from its DER-encoded SPKI key.
35///
36/// Per the C2SP signed-note spec the ECDSA key ID is the truncated SHA-256 of the
37/// DER-encoded SubjectPublicKeyInfo — a DIFFERENT input from the Ed25519 formula
38/// in [`compute_key_id`] (which hashes `name || 0x0A || 0x01 || pubkey`). Computing
39/// an ECDSA hint with the Ed25519 formula yields a key ID that never matches the
40/// signature line, so the signer's key can never be selected.
41///
42/// Args:
43/// * `spki_der` — DER-encoded SubjectPublicKeyInfo for the ECDSA-P256 key.
44///
45/// Usage:
46/// ```ignore
47/// let key_id = compute_ecdsa_key_id(&rekor_spki_der);
48/// ```
49pub fn compute_ecdsa_key_id(spki_der: &[u8]) -> [u8; 4] {
50    let hash = MerkleHash::sha256(spki_der);
51    let mut id = [0u8; 4];
52    id.copy_from_slice(&hash.as_bytes()[..4]);
53    id
54}
55
56/// Build a C2SP signature line: `— <key_name> <base64(alg_byte + key_id + signature)>`.
57///
58/// Args:
59/// * `key_name` — The signer's key name.
60/// * `key_id` — 4-byte key ID from [`compute_key_id`].
61/// * `signature` — 64-byte Ed25519 signature.
62///
63/// Usage:
64/// ```ignore
65/// let line = build_signature_line("auths.dev/log", &key_id, &sig_bytes);
66/// ```
67pub fn build_signature_line(key_name: &str, key_id: &[u8; 4], signature: &[u8; 64]) -> String {
68    let mut sig_data = Vec::with_capacity(1 + 4 + 64);
69    sig_data.push(ALG_ED25519);
70    sig_data.extend_from_slice(key_id);
71    sig_data.extend_from_slice(signature);
72    let encoded = STANDARD.encode(&sig_data);
73    format!("\u{2014} {key_name} {encoded}\n")
74}
75
76/// Parse a C2SP signed note into its body and signature components.
77///
78/// A signed note has the format:
79/// ```text
80/// <body lines>\n
81/// \n
82/// — <key_name> <base64(alg + key_id + sig)>\n
83/// ```
84///
85/// Args:
86/// * `note` — The full signed note text.
87///
88/// Usage:
89/// ```ignore
90/// let (body, sigs) = parse_signed_note(note_text)?;
91/// ```
92pub fn parse_signed_note(note: &str) -> Result<(String, Vec<NoteSignature>), TransparencyError> {
93    let (body, sig_lines) = split_note(note)?;
94    let mut signatures = Vec::with_capacity(sig_lines.len());
95    for line in &sig_lines {
96        signatures.push(parse_signature_line(line)?);
97    }
98    Ok((body, signatures))
99}
100
101/// Split a signed note into its body and the text of each signature line (the
102/// part AFTER the `— ` marker). Shared by [`parse_signed_note`] (auths' internal
103/// alg-byte layout) and [`parse_signed_note_c2sp`] (the standard C2SP wire layout
104/// used by Sigstore Rekor and C2SP-conformant witnesses). Body framing — three
105/// lines plus a trailing `\n`, blank separator excluded — is identical for both.
106fn split_note(note: &str) -> Result<(String, Vec<String>), TransparencyError> {
107    let sig_marker = "\u{2014} ";
108    let lines: Vec<&str> = note.lines().collect();
109
110    let body_end_idx = lines
111        .iter()
112        .position(|l| l.starts_with(sig_marker))
113        .ok_or_else(|| TransparencyError::InvalidNote("no signature lines found".into()))?;
114
115    // Body is everything before the first signature line, trimming the blank
116    // separator line so it is byte-identical to `Checkpoint::to_note_body()`.
117    let mut body_lines = &lines[..body_end_idx];
118    if body_lines.last() == Some(&"") {
119        body_lines = &body_lines[..body_lines.len() - 1];
120    }
121    let body = body_lines.join("\n") + "\n";
122
123    let sig_lines = lines[body_end_idx..]
124        .iter()
125        .filter_map(|l| l.strip_prefix(sig_marker).map(str::to_string))
126        .collect();
127
128    Ok((body, sig_lines))
129}
130
131/// A parsed signature from a signed note.
132#[derive(Debug, Clone)]
133pub struct NoteSignature {
134    /// The signer's key name.
135    pub key_name: String,
136    /// Algorithm byte (0x01 for Ed25519).
137    pub algorithm: u8,
138    /// 4-byte key ID.
139    pub key_id: [u8; 4],
140    /// Raw signature bytes.
141    pub signature: Vec<u8>,
142}
143
144fn parse_signature_line(line: &str) -> Result<NoteSignature, TransparencyError> {
145    let space_idx = line
146        .find(' ')
147        .ok_or_else(|| TransparencyError::InvalidNote("malformed signature line".into()))?;
148
149    let key_name = &line[..space_idx];
150    let b64 = &line[space_idx + 1..];
151
152    let raw = STANDARD
153        .decode(b64.trim())
154        .map_err(|e| TransparencyError::InvalidNote(format!("base64 decode: {e}")))?;
155
156    if raw.len() < 5 {
157        return Err(TransparencyError::InvalidNote(
158            "signature data too short".into(),
159        ));
160    }
161
162    let algorithm = raw[0];
163    let mut key_id = [0u8; 4];
164    key_id.copy_from_slice(&raw[1..5]);
165    let signature = raw[5..].to_vec();
166
167    Ok(NoteSignature {
168        key_name: key_name.to_string(),
169        algorithm,
170        key_id,
171        signature,
172    })
173}
174
175/// A signature parsed in the **standard C2SP** signed-note wire layout:
176/// `base64(key_id[4] || signature)`, with NO leading algorithm byte. Sigstore
177/// Rekor and every C2SP-conformant witness emit this layout. (Contrast
178/// [`NoteSignature`], whose leading algorithm byte is an auths-internal extension
179/// produced by [`build_signature_line`] — using that parser on a standard note
180/// misaligns the key ID and signature by one byte.)
181#[derive(Debug, Clone)]
182pub struct C2spSignature {
183    /// The signer's key name (text after `— `, before the first space).
184    pub key_name: String,
185    /// 4-byte key ID (big-endian uint32) identifying the signer's key.
186    pub key_id: [u8; 4],
187    /// Raw signature bytes (for ECDSA: the ASN.1 DER ECDSA-Sig-Value).
188    pub signature: Vec<u8>,
189}
190
191/// Parse a signed note whose signatures use the standard C2SP wire layout.
192///
193/// Returns the note body (ending in `\n`, byte-identical to
194/// [`crate::checkpoint::Checkpoint::to_note_body`]) and each signature as a
195/// [`C2spSignature`] (`key_id[4] || signature`, no algorithm byte). Use this for
196/// external logs (Sigstore Rekor) and C2SP witnesses; use [`parse_signed_note`]
197/// for auths' own alg-byte-prefixed checkpoints.
198///
199/// Args:
200/// * `note` — The full signed-note text.
201///
202/// Usage:
203/// ```ignore
204/// let (body, sigs) = parse_signed_note_c2sp(rekor_checkpoint)?;
205/// ```
206pub fn parse_signed_note_c2sp(
207    note: &str,
208) -> Result<(String, Vec<C2spSignature>), TransparencyError> {
209    let (body, sig_lines) = split_note(note)?;
210    let mut signatures = Vec::with_capacity(sig_lines.len());
211    for line in &sig_lines {
212        signatures.push(parse_c2sp_signature_line(line)?);
213    }
214    Ok((body, signatures))
215}
216
217fn parse_c2sp_signature_line(line: &str) -> Result<C2spSignature, TransparencyError> {
218    let space_idx = line
219        .find(' ')
220        .ok_or_else(|| TransparencyError::InvalidNote("malformed signature line".into()))?;
221
222    let key_name = &line[..space_idx];
223    let b64 = &line[space_idx + 1..];
224
225    let raw = STANDARD
226        .decode(b64.trim())
227        .map_err(|e| TransparencyError::InvalidNote(format!("base64 decode: {e}")))?;
228
229    // Standard C2SP layout: 4-byte key ID followed by the signature. Require at
230    // least one signature byte beyond the key ID.
231    if raw.len() < 5 {
232        return Err(TransparencyError::InvalidNote(
233            "signature data too short".into(),
234        ));
235    }
236
237    let mut key_id = [0u8; 4];
238    key_id.copy_from_slice(&raw[..4]);
239    let signature = raw[4..].to_vec();
240
241    Ok(C2spSignature {
242        key_name: key_name.to_string(),
243        key_id,
244        signature,
245    })
246}
247
248/// Serialize a signed note from body text and signatures.
249///
250/// Args:
251/// * `body` — The note body (must end with `\n`).
252/// * `signatures` — Formatted signature lines from [`build_signature_line`].
253///
254/// Usage:
255/// ```ignore
256/// let note = serialize_signed_note(&body, &[sig_line]);
257/// ```
258pub fn serialize_signed_note(body: &str, signatures: &[String]) -> String {
259    let mut out =
260        String::with_capacity(body.len() + signatures.iter().map(|s| s.len()).sum::<usize>() + 1);
261    out.push_str(body);
262    out.push('\n');
263    for sig in signatures {
264        out.push_str(sig);
265    }
266    out
267}
268
269#[cfg(test)]
270mod tests {
271    use super::*;
272
273    #[test]
274    fn key_id_computation() {
275        let pubkey = [0xab; 32];
276        let key_id = compute_key_id("auths.dev/log", &pubkey);
277        assert_eq!(key_id.len(), 4);
278
279        // Deterministic
280        let key_id2 = compute_key_id("auths.dev/log", &pubkey);
281        assert_eq!(key_id, key_id2);
282
283        // Different key name → different ID
284        let key_id3 = compute_key_id("other.dev/log", &pubkey);
285        assert_ne!(key_id, key_id3);
286    }
287
288    #[test]
289    fn signature_line_format() {
290        let key_id = [0x01, 0x02, 0x03, 0x04];
291        let sig = [0xaa; 64];
292        let line = build_signature_line("auths.dev/log", &key_id, &sig);
293
294        assert!(line.starts_with("\u{2014} auths.dev/log "));
295        assert!(line.ends_with('\n'));
296
297        // Verify the base64 decodes to alg + key_id + sig
298        let parts: Vec<&str> = line.trim().splitn(3, ' ').collect();
299        let decoded = STANDARD.decode(parts[2]).unwrap();
300        assert_eq!(decoded[0], ALG_ED25519);
301        assert_eq!(&decoded[1..5], &key_id);
302        assert_eq!(&decoded[5..], &sig);
303    }
304
305    #[test]
306    fn signed_note_roundtrip() {
307        let body = "auths.dev/log\n42\nq6urq6urq6urq6urq6urq6urq6urq6urq6urq6urq6s=\n";
308        let key_id = [0x01, 0x02, 0x03, 0x04];
309        let sig = [0xcc; 64];
310        let sig_line = build_signature_line("auths.dev/log", &key_id, &sig);
311        let note = serialize_signed_note(body, &[sig_line]);
312
313        let (parsed_body, parsed_sigs) = parse_signed_note(&note).unwrap();
314        assert_eq!(parsed_body, body);
315        assert_eq!(parsed_sigs.len(), 1);
316        assert_eq!(parsed_sigs[0].key_name, "auths.dev/log");
317        assert_eq!(parsed_sigs[0].algorithm, ALG_ED25519);
318        assert_eq!(parsed_sigs[0].key_id, key_id);
319        assert_eq!(parsed_sigs[0].signature, sig.to_vec());
320    }
321
322    #[test]
323    fn parse_note_rejects_no_signatures() {
324        let note = "just body\nno sigs\n";
325        assert!(parse_signed_note(note).is_err());
326    }
327
328    #[test]
329    fn ecdsa_key_id_matches_rekor_hint() {
330        // Rekor production-shard SPKI (same constant pinned in
331        // `TrustConfig::default_config`).
332        const REKOR_PROD_PUBKEY_B64: &str = "MFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAE2G2Y+2tabdTV5BcGiBIx0a9fAFwrkBbmLSGtks4L3qX6yYY0zufBnhC8Ur/iy55GhWP/9A/bY2LhC30M9+RYtw==";
333        let der = STANDARD.decode(REKOR_PROD_PUBKEY_B64).unwrap();
334        // Reference vector: the key-ID hint carried in every Rekor checkpoint
335        // signature line ("wNI9aj…" base64 → 0xc0d23d6a).
336        assert_eq!(compute_ecdsa_key_id(&der), [0xc0, 0xd2, 0x3d, 0x6a]);
337    }
338
339    #[test]
340    fn ecdsa_and_ed25519_key_ids_use_distinct_formulas() {
341        // ECDSA hashes the DER SPKI; Ed25519 hashes name||0x0A||0x01||pubkey.
342        // The same 32 bytes routed through each formula must differ — guarding
343        // against an accidental reuse of the Ed25519 path for ECDSA.
344        let bytes = [0x11u8; 32];
345        let ed = compute_key_id("rekor.sigstore.dev", &bytes);
346        let ec = compute_ecdsa_key_id(&bytes);
347        assert_ne!(ed, ec);
348    }
349
350    #[test]
351    fn c2sp_signature_standard_layout_roundtrip() {
352        // Standard layout: base64(key_id[4] || sig), NO leading algorithm byte.
353        let key_id = [0xc0, 0xd2, 0x3d, 0x6a];
354        let sig = vec![0x30u8, 0x45, 0x02, 0x21, 0x00, 0xab, 0xcd];
355        let mut blob = Vec::new();
356        blob.extend_from_slice(&key_id);
357        blob.extend_from_slice(&sig);
358        let note = format!(
359            "rekor.sigstore.dev - 1\n42\nq6urq6urq6urq6urq6urq6urq6urq6urq6urq6urq6s=\n\n\u{2014} rekor.sigstore.dev {}\n",
360            STANDARD.encode(&blob)
361        );
362        let (body, sigs) = parse_signed_note_c2sp(&note).unwrap();
363        assert_eq!(
364            body,
365            "rekor.sigstore.dev - 1\n42\nq6urq6urq6urq6urq6urq6urq6urq6urq6urq6urq6s=\n"
366        );
367        assert_eq!(sigs.len(), 1);
368        assert_eq!(sigs[0].key_name, "rekor.sigstore.dev");
369        assert_eq!(sigs[0].key_id, key_id);
370        assert_eq!(sigs[0].signature, sig);
371    }
372}