metamorphic_log/note.rs
1//! C2SP [`signed-note`] parsing, serialization, and verification.
2//!
3//! A *signed note* is UTF-8 text followed by a blank line and one or more
4//! signature lines, each of the form:
5//!
6//! ```text
7//! — <key name> <base64(uint32 key id || signature)>\n
8//! ```
9//!
10//! (the leading character is an em dash, `U+2014`, then a space). The text the
11//! signatures cover **includes the final newline but not the separating blank
12//! line**. This module parses and serializes that wire format byte-for-byte
13//! compatibly with the deployed ecosystem (Go's `sumdb/note`, sigsum,
14//! transparency-dev), and verifies **classical Ed25519** witness/log signature
15//! lines via the single-source-of-truth primitive
16//! [`metamorphic_crypto::ed25519_verify`].
17//!
18//! ## Key ids and verifier keys
19//!
20//! The 4-byte key id binds a signature to a `(name, signature-type, public
21//! key)` tuple:
22//!
23//! ```text
24//! key id = SHA-256(key name || 0x0A || signature type || public key)[:4] (big-endian u32)
25//! ```
26//!
27//! A *verifier key* (`vkey`) is the text encoding a verifier shares:
28//!
29//! ```text
30//! <key name>+<hex(key id)>+<base64(signature type || public key)>
31//! ```
32//!
33//! ## Additive hybrid post-quantum signatures (Slice 3)
34//!
35//! The model is intentionally multi-signature and signature-type-tagged. A note
36//! may carry any number of signature lines, and verifiers MUST ignore lines
37//! from unknown keys. This is exactly what lets an **additive hybrid
38//! post-quantum** signature line ([`SignatureType::MetamorphicHybrid`]) slot in
39//! alongside the classical [`SignatureType::Ed25519`] line with **no format
40//! churn**: classical C2SP witnesses keep verifying (and co-signing) the Ed25519
41//! line and can still recompute the tree, while our own PQ-aware verifiers and
42//! monitors additionally check the composite line for post-quantum authenticity.
43//!
44//! ### Signature-type assignment (the central design decision)
45//!
46//! The additive PQ primitive is the metamorphic-crypto composite signature
47//! ([`metamorphic_crypto::sign`] / [`metamorphic_crypto::verify`]): **ML-DSA
48//! (FIPS 204) + a classical partner (Ed25519, or Ed448/ECDSA-P-521 in the
49//! matched suites), strict-AND**, with a 1-byte version/suite tag prefixing a
50//! self-describing wire format, signing a length-prefixed context-framed message
51//! (`I2OSP(len(ctx),8) || ctx || msg`). This construction matches **no**
52//! C2SP-assigned `signed-note` signature type:
53//!
54//! - `0x06` is **single-algorithm** *timestamped ML-DSA-44 (sub)tree
55//! cosignatures* (per `c2sp.org/tlog-cosignature`): one algorithm, a timestamp
56//! prefix, and cosignature-specific note semantics. Reusing it would
57//! misrepresent our hybrid composite to real ML-DSA-44 cosignature verifiers.
58//! - `0x02` (ECDSA) and `0x04` (timestamped Ed25519 cosignatures) likewise
59//! describe other constructions.
60//! - `0xfa`–`0xfe` are **reserved for future use by C2SP** — not ours to claim.
61//!
62//! C2SP provides exactly one correct escape: `0xff`, "reserved for signature
63//! types without an identifier byte assigned by this specification", which it
64//! RECOMMENDS be followed by "a longer identifier that is unlikely to collide".
65//! We therefore assign our composite the multi-byte type identifier
66//! [`HYBRID_SIG_IDENTIFIER`] (`0xff` followed by a versioned namespace label).
67//! This is forward-interop-safe: a C2SP verifier that doesn't know our key
68//! simply ignores the line (unknown key), and we never squat an assigned or
69//! reserved byte.
70//!
71//! The signature-type identifier participates in the key id and `vkey` exactly
72//! as the spec describes (`key id = SHA-256(name || 0x0A || type id ||
73//! pubkey)[:4]`; `vkey = name+hex(id)+base64(type id || pubkey)`); the spec's
74//! formula is defined over the full (multi-byte) type identifier, so nothing in
75//! the key-id/`vkey` math changes — only the identifier is longer. The composite
76//! *public key* material carried after the identifier is the metamorphic-crypto
77//! public key bytes (`tag || classical_pk || ml_dsa_pk`); its leading tag
78//! self-describes the `(Suite, SecurityLevel)` posture (see
79//! [`VerifierKey::hybrid_posture_tag`]), which the Slice-5 policy layer can later
80//! reconcile (declared == observed). The composite signature bytes carried after
81//! the key id are the metamorphic-crypto signature blob verbatim.
82//!
83//! ### Signing context
84//!
85//! The composite signs the note text under the fixed, versioned context
86//! [`HYBRID_SIG_CONTEXT`]. This binds a hybrid note signature to its purpose and
87//! is reproduced byte-identically across native Rust, WASM, and the Elixir NIF
88//! (the framing is metamorphic-crypto's `I2OSP(len(ctx),8) || ctx || msg`).
89//! Because ML-DSA signing is hedged/randomized, composite signature **bytes are
90//! not reproducible**, but **verification is fully deterministic** — so our KATs
91//! pin the (deterministic) public key / `vkey` and lock a stored signature that
92//! [`SignedNote::verify`] accepts byte-for-byte.
93//!
94//! [`signed-note`]: https://c2sp.org/signed-note
95//! [`metamorphic_crypto::sign`]: metamorphic_crypto::sign()
96//! [`metamorphic_crypto::verify`]: metamorphic_crypto::verify()
97
98use crate::encoding::{base64_decode, base64_encode, hex_decode, hex_encode};
99use crate::error::{Error, Result};
100
101/// The em dash + space prefix that begins every signature line (`U+2014 ` ).
102const SIG_PREFIX: &str = "— ";
103/// The blank-line separator between the note text and the signature block.
104const SIG_SPLIT: &str = "\n\n";
105/// Maximum number of signatures parsed from a single note (DoS guard). The spec
106/// requires accepting at least 16; we mirror Go's generous limit of 100.
107const MAX_SIGNATURES: usize = 100;
108
109/// The C2SP `signed-note` type identifier for the metamorphic-crypto hybrid
110/// composite signature (ML-DSA + classical, strict-AND).
111///
112/// It uses the spec's `0xff` escape ("signature types without an identifier byte
113/// assigned by this specification") followed by a versioned namespace label that
114/// is "unlikely to collide", as the spec RECOMMENDS. See the module-level docs
115/// for why no assigned/reserved byte fits this construction.
116pub const HYBRID_SIG_IDENTIFIER: &[u8] = b"\xffmetamorphic.app/composite-mldsa-ed25519/v1";
117
118/// The fixed, versioned signing context bound into every hybrid composite note
119/// signature (metamorphic-crypto frames it as `I2OSP(len(ctx),8) || ctx ||
120/// note_text`). Changing this label is a breaking change to the hybrid line.
121pub const HYBRID_SIG_CONTEXT: &str = "metamorphic.app/signed-note/v1";
122
123/// A note signature algorithm, identified by its C2SP `signed-note` type
124/// identifier (one or more bytes).
125///
126/// [`SignatureType::Ed25519`] (`0x01`) is the classical, witness-compatible
127/// algorithm. [`SignatureType::MetamorphicHybrid`] (the `0xff`-escaped
128/// [`HYBRID_SIG_IDENTIFIER`]) is the additive post-quantum composite. Other
129/// assigned bytes (ECDSA `0x02`, the cosignature types, etc.) are recognized as
130/// *unknown* and their lines are ignored by verifiers.
131#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
132pub enum SignatureType {
133 /// `0x01` — Ed25519 over the note text (RFC 8032).
134 Ed25519,
135 /// [`HYBRID_SIG_IDENTIFIER`] — the metamorphic-crypto ML-DSA + classical
136 /// composite (strict-AND), over the note text under [`HYBRID_SIG_CONTEXT`].
137 MetamorphicHybrid,
138}
139
140impl SignatureType {
141 /// The on-the-wire type identifier (one byte for Ed25519, the multi-byte
142 /// `0xff`-escaped label for the hybrid composite).
143 #[must_use]
144 pub fn type_identifier(self) -> &'static [u8] {
145 match self {
146 SignatureType::Ed25519 => &[0x01],
147 SignatureType::MetamorphicHybrid => HYBRID_SIG_IDENTIFIER,
148 }
149 }
150
151 /// Detect the signature type from the leading bytes of encoded key material
152 /// (`type identifier || public key`), returning the type and the byte length
153 /// of its identifier prefix.
154 fn detect(key: &[u8]) -> Result<(SignatureType, usize)> {
155 if key.first() == Some(&0x01) {
156 return Ok((SignatureType::Ed25519, 1));
157 }
158 if key.starts_with(HYBRID_SIG_IDENTIFIER) {
159 return Ok((
160 SignatureType::MetamorphicHybrid,
161 HYBRID_SIG_IDENTIFIER.len(),
162 ));
163 }
164 Err(Error::MalformedNote(format!(
165 "unsupported signature type (leading byte 0x{:02x})",
166 key.first().copied().unwrap_or(0)
167 )))
168 }
169}
170
171/// A trusted verifier key: the data needed to recognize and check signatures
172/// from one key.
173#[derive(Debug, Clone, PartialEq, Eq)]
174pub struct VerifierKey {
175 name: String,
176 key_id: u32,
177 sig_type: SignatureType,
178 public_key: Vec<u8>,
179}
180
181impl VerifierKey {
182 /// Build an Ed25519 verifier key from a name and 32-byte public key,
183 /// computing the key id per the spec.
184 ///
185 /// # Errors
186 /// Returns [`Error::MalformedNote`] if the name is invalid or the public key
187 /// is not 32 bytes.
188 pub fn new_ed25519(name: &str, public_key: &[u8]) -> Result<Self> {
189 if !is_valid_name(name) {
190 return Err(Error::MalformedNote(format!("invalid key name: {name:?}")));
191 }
192 if public_key.len() != 32 {
193 return Err(Error::MalformedNote(format!(
194 "Ed25519 public key must be 32 bytes, got {}",
195 public_key.len()
196 )));
197 }
198 let key_id = compute_key_id(name, SignatureType::Ed25519.type_identifier(), public_key);
199 Ok(Self {
200 name: name.to_string(),
201 key_id,
202 sig_type: SignatureType::Ed25519,
203 public_key: public_key.to_vec(),
204 })
205 }
206
207 /// Build a hybrid composite verifier key from a name and the
208 /// metamorphic-crypto public key bytes (`tag || classical_pk || ml_dsa_pk`),
209 /// computing the key id per the spec over [`HYBRID_SIG_IDENTIFIER`].
210 ///
211 /// # Errors
212 /// Returns [`Error::MalformedNote`] if the name is invalid or the public key
213 /// is empty.
214 pub fn new_hybrid(name: &str, public_key: &[u8]) -> Result<Self> {
215 if !is_valid_name(name) {
216 return Err(Error::MalformedNote(format!("invalid key name: {name:?}")));
217 }
218 if public_key.is_empty() {
219 return Err(Error::MalformedNote(
220 "hybrid composite public key must be non-empty".into(),
221 ));
222 }
223 let key_id = compute_key_id(
224 name,
225 SignatureType::MetamorphicHybrid.type_identifier(),
226 public_key,
227 );
228 Ok(Self {
229 name: name.to_string(),
230 key_id,
231 sig_type: SignatureType::MetamorphicHybrid,
232 public_key: public_key.to_vec(),
233 })
234 }
235
236 /// Parse a verifier key string `<name>+<hex key id>+<base64(type||key)>`.
237 ///
238 /// # Errors
239 /// Returns [`Error::MalformedNote`] if the structure, hex id, base64, key
240 /// length, or recomputed key id is invalid, or [`Error::MalformedNote`] for
241 /// an unsupported signature type.
242 pub fn parse(vkey: &str) -> Result<Self> {
243 let malformed = || Error::MalformedNote(format!("malformed verifier key: {vkey:?}"));
244 let (name, rest) = vkey.split_once('+').ok_or_else(malformed)?;
245 let (hash_hex, key_b64) = rest.split_once('+').ok_or_else(malformed)?;
246
247 if hash_hex.len() != 8 {
248 return Err(malformed());
249 }
250 let hash_bytes = hex_decode(hash_hex)?;
251 let declared_id =
252 u32::from_be_bytes([hash_bytes[0], hash_bytes[1], hash_bytes[2], hash_bytes[3]]);
253
254 let key = base64_decode(key_b64)?;
255 if key.is_empty() || !is_valid_name(name) {
256 return Err(malformed());
257 }
258
259 // key id is computed over the full (type-identifier || public-key)
260 // material, exactly as the spec defines it.
261 let computed_id = key_hash(name, &key);
262 if computed_id != declared_id {
263 return Err(Error::MalformedNote(format!(
264 "verifier key id mismatch: declared {declared_id:08x}, computed {computed_id:08x}"
265 )));
266 }
267
268 let (sig_type, id_len) = SignatureType::detect(&key)?;
269 let public_key = &key[id_len..];
270 match sig_type {
271 SignatureType::Ed25519 if public_key.len() != 32 => return Err(malformed()),
272 SignatureType::MetamorphicHybrid if public_key.is_empty() => return Err(malformed()),
273 _ => {}
274 }
275
276 Ok(Self {
277 name: name.to_string(),
278 key_id: declared_id,
279 sig_type,
280 public_key: public_key.to_vec(),
281 })
282 }
283
284 /// Encode this verifier key as a `vkey` string.
285 #[must_use]
286 pub fn encode(&self) -> String {
287 let id = self.sig_type.type_identifier();
288 let mut key = Vec::with_capacity(id.len() + self.public_key.len());
289 key.extend_from_slice(id);
290 key.extend_from_slice(&self.public_key);
291 format!(
292 "{}+{}+{}",
293 self.name,
294 hex_encode(&self.key_id.to_be_bytes()),
295 base64_encode(&key)
296 )
297 }
298
299 /// The key name.
300 #[must_use]
301 pub fn name(&self) -> &str {
302 &self.name
303 }
304
305 /// The 4-byte key id as a big-endian `u32`.
306 #[must_use]
307 pub fn key_id(&self) -> u32 {
308 self.key_id
309 }
310
311 /// The signature algorithm.
312 #[must_use]
313 pub fn signature_type(&self) -> SignatureType {
314 self.sig_type
315 }
316
317 /// The raw public key material (`type identifier`-stripped): the 32-byte
318 /// Ed25519 key, or the metamorphic-crypto composite public key bytes
319 /// (`tag || classical_pk || ml_dsa_pk`) for a hybrid key.
320 #[must_use]
321 pub fn public_key(&self) -> &[u8] {
322 &self.public_key
323 }
324
325 /// For a [`SignatureType::MetamorphicHybrid`] key, the metamorphic-crypto
326 /// composite **posture tag** — the leading byte of the composite public key
327 /// that self-describes its `(Suite, SecurityLevel)` (e.g. `0x02` = Hybrid
328 /// Cat-3). Returns `None` for non-hybrid keys.
329 ///
330 /// This is informational only; the authoritative posture decode lives in
331 /// metamorphic-crypto. It is surfaced so the Slice-5 `NamespacePolicy` layer
332 /// can later reconcile the *declared* posture against this *observed* tag
333 /// without this crate reimplementing any crypto.
334 #[must_use]
335 pub fn hybrid_posture_tag(&self) -> Option<u8> {
336 match self.sig_type {
337 SignatureType::MetamorphicHybrid => self.public_key.first().copied(),
338 SignatureType::Ed25519 => None,
339 }
340 }
341}
342
343/// A single signature line parsed from a note (not yet verified).
344#[derive(Debug, Clone, PartialEq, Eq)]
345pub struct Signature {
346 name: String,
347 key_id: u32,
348 /// The signature bytes following the 4-byte key id.
349 signature: Vec<u8>,
350}
351
352impl Signature {
353 /// The key name from the signature line.
354 #[must_use]
355 pub fn name(&self) -> &str {
356 &self.name
357 }
358
359 /// The 4-byte key id as a big-endian `u32`.
360 #[must_use]
361 pub fn key_id(&self) -> u32 {
362 self.key_id
363 }
364
365 /// The raw signature bytes (after the key id).
366 #[must_use]
367 pub fn signature(&self) -> &[u8] {
368 &self.signature
369 }
370
371 /// The base64 signature blob (`key id || signature`) as it appears on the
372 /// wire.
373 #[must_use]
374 fn to_base64(&self) -> String {
375 let mut blob = Vec::with_capacity(4 + self.signature.len());
376 blob.extend_from_slice(&self.key_id.to_be_bytes());
377 blob.extend_from_slice(&self.signature);
378 base64_encode(&blob)
379 }
380}
381
382/// A parsed signed note: the signed text plus its (still unverified) signature
383/// lines.
384#[derive(Debug, Clone, PartialEq, Eq)]
385pub struct SignedNote {
386 text: String,
387 signatures: Vec<Signature>,
388}
389
390impl SignedNote {
391 /// Create a signed note from text and signatures.
392 ///
393 /// # Errors
394 /// Returns [`Error::MalformedNote`] if `text` does not end in a newline.
395 pub fn new(text: String, signatures: Vec<Signature>) -> Result<Self> {
396 if !text.ends_with('\n') {
397 return Err(Error::MalformedNote("note text must end in newline".into()));
398 }
399 Ok(Self { text, signatures })
400 }
401
402 /// The note text (including its final newline; excluding the separating
403 /// blank line). This is the exact byte string signatures are computed over.
404 #[must_use]
405 pub fn text(&self) -> &str {
406 &self.text
407 }
408
409 /// The parsed signature lines.
410 #[must_use]
411 pub fn signatures(&self) -> &[Signature] {
412 &self.signatures
413 }
414
415 /// Parse a complete signed-note byte string.
416 ///
417 /// Mirrors the reference Go `note.Open` structural parse: validates UTF-8
418 /// and the no-control-characters rule, splits the text from the trailing
419 /// signature block at the **last** blank line, and parses each signature
420 /// line. Signatures are not verified here; call [`SignedNote::verify`].
421 ///
422 /// # Errors
423 /// Returns [`Error::MalformedNote`] for any structural violation.
424 pub fn parse(msg: &str) -> Result<Self> {
425 // UTF-8 is guaranteed by `&str`. Reject ASCII control chars except '\n'.
426 if msg.bytes().any(|b| b < 0x20 && b != b'\n') {
427 return Err(Error::MalformedNote(
428 "note contains a forbidden control character".into(),
429 ));
430 }
431
432 let split = msg
433 .rfind(SIG_SPLIT)
434 .ok_or_else(|| Error::MalformedNote("missing blank-line signature separator".into()))?;
435 let text = &msg[..split + 1];
436 let sig_block = &msg[split + 2..];
437 if sig_block.is_empty() || !sig_block.ends_with('\n') {
438 return Err(Error::MalformedNote(
439 "signature block is empty or unterminated".into(),
440 ));
441 }
442
443 let mut signatures = Vec::new();
444 for line in sig_block.lines() {
445 let body = line.strip_prefix(SIG_PREFIX).ok_or_else(|| {
446 Error::MalformedNote(format!("signature line missing '— ' prefix: {line:?}"))
447 })?;
448 let (name, b64) = body
449 .split_once(' ')
450 .ok_or_else(|| Error::MalformedNote("signature line missing space".into()))?;
451 if !is_valid_name(name) || b64.is_empty() {
452 return Err(Error::MalformedNote(format!(
453 "invalid signature line name/blob: {line:?}"
454 )));
455 }
456 let blob = base64_decode(b64)?;
457 if blob.len() < 5 {
458 return Err(Error::MalformedNote("signature blob too short".into()));
459 }
460 let key_id = u32::from_be_bytes([blob[0], blob[1], blob[2], blob[3]]);
461 signatures.push(Signature {
462 name: name.to_string(),
463 key_id,
464 signature: blob[4..].to_vec(),
465 });
466 if signatures.len() > MAX_SIGNATURES {
467 return Err(Error::MalformedNote("too many signatures".into()));
468 }
469 }
470
471 Self::new(text.to_string(), signatures)
472 }
473
474 /// Serialize this signed note to its canonical byte string:
475 /// `text || "\n" || signature lines`.
476 #[must_use]
477 pub fn marshal(&self) -> String {
478 let mut out = String::with_capacity(self.text.len() + 1 + self.signatures.len() * 80);
479 out.push_str(&self.text);
480 out.push('\n');
481 for sig in &self.signatures {
482 out.push_str(SIG_PREFIX);
483 out.push_str(&sig.name);
484 out.push(' ');
485 out.push_str(&sig.to_base64());
486 out.push('\n');
487 }
488 out
489 }
490
491 /// Verify the note against a set of trusted verifier keys.
492 ///
493 /// Following the C2SP `signed-note` rules:
494 /// - signatures whose `(name, key id)` match no trusted key are **ignored**;
495 /// - if a signature from a *known* key fails to verify, the whole note is
496 /// rejected ([`Error::InvalidSignature`]);
497 /// - if no signature from a trusted key verifies, the note is rejected
498 /// ([`Error::NoTrustedSignature`]).
499 ///
500 /// On success returns references to the signatures that verified.
501 ///
502 /// # Errors
503 /// [`Error::InvalidSignature`] or [`Error::NoTrustedSignature`] as above.
504 pub fn verify<'a>(&'a self, trusted: &[VerifierKey]) -> Result<Vec<&'a Signature>> {
505 let mut verified = Vec::new();
506 for sig in &self.signatures {
507 let Some(key) = trusted
508 .iter()
509 .find(|k| k.key_id == sig.key_id && k.name == sig.name)
510 else {
511 continue; // unknown key: ignore
512 };
513
514 let ok = match key.sig_type {
515 SignatureType::Ed25519 => {
516 // A wrong-length signature/key is a verification failure, not
517 // a structural parse error at this point.
518 metamorphic_crypto::ed25519_verify(
519 &key.public_key,
520 self.text.as_bytes(),
521 &sig.signature,
522 )
523 .unwrap_or(false)
524 }
525 SignatureType::MetamorphicHybrid => {
526 // Independently verify the composite (strict-AND ML-DSA +
527 // classical) via the single-source-of-truth primitive. The
528 // metamorphic-crypto API speaks base64; a malformed blob or
529 // key decodes to a verification failure here, never a panic.
530 let sig_b64 = base64_encode(&sig.signature);
531 let pk_b64 = base64_encode(&key.public_key);
532 metamorphic_crypto::verify(
533 self.text.as_bytes(),
534 HYBRID_SIG_CONTEXT,
535 &sig_b64,
536 &pk_b64,
537 )
538 .unwrap_or(false)
539 }
540 };
541
542 if ok {
543 verified.push(sig);
544 } else {
545 return Err(Error::InvalidSignature {
546 name: sig.name.clone(),
547 key_id: sig.key_id,
548 });
549 }
550 }
551
552 if verified.is_empty() {
553 return Err(Error::NoTrustedSignature);
554 }
555 Ok(verified)
556 }
557}
558
559/// Sign `text` with a raw Ed25519 seed, producing a [`Signature`] line for the
560/// given key name.
561///
562/// Provided for tests, tooling, and (eventually) emitting our own classical
563/// witness-compatible line. `text` must be the exact note text (ending in a
564/// newline); the signature is computed over it via the single-source-of-truth
565/// [`metamorphic_crypto::ed25519_sign`].
566///
567/// # Errors
568/// Returns [`Error::MalformedNote`] for an invalid name, and propagates a
569/// primitive error if `seed` is not 32 bytes.
570pub fn sign_ed25519(text: &str, name: &str, seed: &[u8]) -> Result<Signature> {
571 if !is_valid_name(name) {
572 return Err(Error::MalformedNote(format!("invalid key name: {name:?}")));
573 }
574 let public_key = metamorphic_crypto::ed25519_public_key(seed)
575 .map_err(|e| Error::MalformedNote(format!("invalid Ed25519 seed: {e}")))?;
576 let key_id = compute_key_id(name, SignatureType::Ed25519.type_identifier(), &public_key);
577 let signature = metamorphic_crypto::ed25519_sign(seed, text.as_bytes())
578 .map_err(|e| Error::MalformedNote(format!("Ed25519 signing failed: {e}")))?;
579 Ok(Signature {
580 name: name.to_string(),
581 key_id,
582 signature: signature.to_vec(),
583 })
584}
585
586/// Sign `text` with a metamorphic-crypto hybrid composite secret key (base64
587/// `tag || classical_seed || ml_dsa_seed`), producing an additive PQ
588/// [`Signature`] line for the given key name.
589///
590/// The signature is the composite (strict-AND ML-DSA + classical) over the note
591/// text under [`HYBRID_SIG_CONTEXT`], computed via the single-source-of-truth
592/// [`metamorphic_crypto::sign`]. Because ML-DSA signing is hedged, the bytes are
593/// not reproducible (but verification is deterministic). The matching verifier
594/// key is derived from the secret key's public half (see
595/// [`metamorphic_crypto::derive_public_key`]) and carried in the line's key id.
596///
597/// # Errors
598/// Returns [`Error::MalformedNote`] for an invalid name, and
599/// [`Error::HybridSignature`] if the secret key cannot be decoded/derived or the
600/// composite signature cannot be produced.
601pub fn sign_hybrid(text: &str, name: &str, secret_key_b64: &str) -> Result<Signature> {
602 if !is_valid_name(name) {
603 return Err(Error::MalformedNote(format!("invalid key name: {name:?}")));
604 }
605 let public_key_b64 = metamorphic_crypto::derive_public_key(secret_key_b64)
606 .map_err(|e| Error::HybridSignature(format!("invalid hybrid secret key: {e}")))?;
607 let public_key = base64_decode(&public_key_b64)?;
608 let key_id = compute_key_id(
609 name,
610 SignatureType::MetamorphicHybrid.type_identifier(),
611 &public_key,
612 );
613 let sig_b64 = metamorphic_crypto::sign(text.as_bytes(), HYBRID_SIG_CONTEXT, secret_key_b64)
614 .map_err(|e| Error::HybridSignature(format!("hybrid signing failed: {e}")))?;
615 let signature = base64_decode(&sig_b64)?;
616 Ok(Signature {
617 name: name.to_string(),
618 key_id,
619 signature,
620 })
621}
622
623/// `keyHash` over the full encoded key material (`type identifier || public
624/// key`): the big-endian `u32` of `SHA-256(name || 0x0A || key)[:4]`.
625fn key_hash(name: &str, key: &[u8]) -> u32 {
626 let mut buf = Vec::with_capacity(name.len() + 1 + key.len());
627 buf.extend_from_slice(name.as_bytes());
628 buf.push(0x0A);
629 buf.extend_from_slice(key);
630 let digest = metamorphic_crypto::hash::sha256(&buf);
631 u32::from_be_bytes([digest[0], digest[1], digest[2], digest[3]])
632}
633
634/// Compute the key id from a name, signature-type identifier, and public key.
635fn compute_key_id(name: &str, type_id: &[u8], public_key: &[u8]) -> u32 {
636 let mut key = Vec::with_capacity(type_id.len() + public_key.len());
637 key.extend_from_slice(type_id);
638 key.extend_from_slice(public_key);
639 key_hash(name, &key)
640}
641
642/// A key name is valid iff it is non-empty and contains no Unicode whitespace
643/// or `+`.
644fn is_valid_name(name: &str) -> bool {
645 !name.is_empty() && !name.chars().any(|c| c.is_whitespace() || c == '+')
646}
647
648#[cfg(all(test, not(target_arch = "wasm32")))]
649mod tests {
650 use super::*;
651
652 /// The canonical example verifier key + signed note from the signed-note
653 /// spec. Locking these proves byte-for-byte parse + verify interop.
654 const SPEC_VKEY: &str = "example.com/foo+530d903a+AekyeRrm56hApGFkyQR4ZCbV54Id2LKaANYcrnKv3U2k";
655 const SPEC_NOTE: &str = "This is an example message.\n\n— example.com/foo Uw2QOkn8srV1yJGh2VYRlL1Tnagv1YEq6TfXppzi2ONncAlTgK7Ztg1ERYNZXsYjOBH3mFXmRKuwHjG1Yu72IneyaQM=\n";
656
657 #[test]
658 fn spec_vkey_parses_and_round_trips() {
659 let vkey = VerifierKey::parse(SPEC_VKEY).unwrap();
660 assert_eq!(vkey.name(), "example.com/foo");
661 assert_eq!(vkey.key_id(), 0x530d_903a);
662 assert_eq!(vkey.signature_type(), SignatureType::Ed25519);
663 assert_eq!(vkey.encode(), SPEC_VKEY);
664 }
665
666 #[test]
667 fn spec_note_parses_and_verifies() {
668 let vkey = VerifierKey::parse(SPEC_VKEY).unwrap();
669 let note = SignedNote::parse(SPEC_NOTE).unwrap();
670 assert_eq!(note.text(), "This is an example message.\n");
671 assert_eq!(note.signatures().len(), 1);
672 assert_eq!(note.signatures()[0].key_id(), 0x530d_903a);
673
674 let verified = note.verify(&[vkey]).unwrap();
675 assert_eq!(verified.len(), 1);
676
677 // Marshalling reproduces the exact wire bytes.
678 assert_eq!(note.marshal(), SPEC_NOTE);
679 }
680
681 #[test]
682 fn tampered_text_fails_verification() {
683 let vkey = VerifierKey::parse(SPEC_VKEY).unwrap();
684 let tampered = SPEC_NOTE.replace("example message", "EVIL message");
685 let note = SignedNote::parse(&tampered).unwrap();
686 assert!(matches!(
687 note.verify(&[vkey]),
688 Err(Error::InvalidSignature { .. })
689 ));
690 }
691
692 #[test]
693 fn unknown_key_is_ignored_not_trusted() {
694 // No trusted keys at all → note has no verifiable signature.
695 let note = SignedNote::parse(SPEC_NOTE).unwrap();
696 assert!(matches!(note.verify(&[]), Err(Error::NoTrustedSignature)));
697 }
698
699 #[test]
700 fn sign_and_verify_round_trip() {
701 let (seed, pk) = metamorphic_crypto::ed25519_generate_keypair();
702 let text = "origin.example/log\n7\ncm9vdA==\n".to_string();
703 let sig = sign_ed25519(&text, "origin.example/log", &seed).unwrap();
704 let note = SignedNote::new(text.clone(), vec![sig]).unwrap();
705
706 let vkey = VerifierKey::new_ed25519("origin.example/log", &pk).unwrap();
707 let verified = note.verify(&[vkey]).unwrap();
708 assert_eq!(verified.len(), 1);
709
710 // Parse(marshal(x)) == x round trip.
711 let reparsed = SignedNote::parse(¬e.marshal()).unwrap();
712 assert_eq!(reparsed, note);
713 }
714
715 #[test]
716 fn parse_rejects_control_chars_and_missing_separator() {
717 assert!(SignedNote::parse("no separator\n").is_err());
718 assert!(SignedNote::parse("bad\x01char\n\n— a b AAAAAA==\n").is_err());
719 }
720
721 #[test]
722 fn key_id_matches_spec_formula() {
723 // Recompute the spec key id from the decoded public key.
724 let vkey = VerifierKey::parse(SPEC_VKEY).unwrap();
725 let recomputed = compute_key_id(
726 vkey.name(),
727 SignatureType::Ed25519.type_identifier(),
728 &vkey.public_key,
729 );
730 assert_eq!(recomputed, 0x530d_903a);
731 }
732
733 #[test]
734 fn hybrid_type_identifier_uses_0xff_escape() {
735 // The hybrid identifier MUST start with the C2SP 0xff escape and be
736 // longer than one byte (a namespaced label), per the spec recommendation.
737 let id = SignatureType::MetamorphicHybrid.type_identifier();
738 assert_eq!(id.first(), Some(&0xff));
739 assert!(id.len() > 1);
740 // Ed25519 stays a single 0x01 byte (byte-identical classical path).
741 assert_eq!(SignatureType::Ed25519.type_identifier(), &[0x01]);
742 }
743
744 #[test]
745 fn hybrid_sign_verify_and_vkey_round_trip() {
746 let kp = metamorphic_crypto::generate_signing_keypair(); // Hybrid Cat-3
747 let pk = base64_decode(&kp.public_key).unwrap();
748 let text = "origin.example/log\n7\ncm9vdA==\n".to_string();
749
750 let sig = sign_hybrid(&text, "origin.example/log", &kp.secret_key).unwrap();
751 let note = SignedNote::new(text, vec![sig]).unwrap();
752
753 let vkey = VerifierKey::new_hybrid("origin.example/log", &pk).unwrap();
754 assert_eq!(vkey.signature_type(), SignatureType::MetamorphicHybrid);
755 // Posture tag is the composite's leading byte (0x02 = Hybrid Cat-3).
756 assert_eq!(vkey.hybrid_posture_tag(), Some(0x02));
757 // vkey encodes and re-parses byte-for-byte (multi-byte type identifier).
758 assert_eq!(VerifierKey::parse(&vkey.encode()).unwrap(), vkey);
759
760 let verified = note.verify(&[vkey]).unwrap();
761 assert_eq!(verified.len(), 1);
762
763 // Parse(marshal(x)) == x round trip across the larger PQ blob.
764 let reparsed = SignedNote::parse(¬e.marshal()).unwrap();
765 assert_eq!(reparsed, note);
766 }
767
768 #[test]
769 fn hybrid_tampered_text_is_rejected() {
770 let kp = metamorphic_crypto::generate_signing_keypair();
771 let pk = base64_decode(&kp.public_key).unwrap();
772 let text = "origin.example/log\n7\ncm9vdA==\n".to_string();
773 let sig = sign_hybrid(&text, "origin.example/log", &kp.secret_key).unwrap();
774 let note = SignedNote::new(text, vec![sig]).unwrap();
775
776 // Forge a note with the same signatures but different text.
777 let forged = SignedNote::new(
778 "origin.example/log\n8\nZXZpbA==\n".to_string(),
779 note.signatures().to_vec(),
780 )
781 .unwrap();
782 let vkey = VerifierKey::new_hybrid("origin.example/log", &pk).unwrap();
783 assert!(matches!(
784 forged.verify(&[vkey]),
785 Err(Error::InvalidSignature { .. })
786 ));
787 }
788
789 #[test]
790 fn classical_and_hybrid_lines_coexist() {
791 let (seed, ed_pk) = metamorphic_crypto::ed25519_generate_keypair();
792 let kp = metamorphic_crypto::generate_signing_keypair();
793 let pk = base64_decode(&kp.public_key).unwrap();
794 let text = "origin.example/log\n9\ncm9vdA==\n".to_string();
795
796 let ed_sig = sign_ed25519(&text, "origin.example/log", &seed).unwrap();
797 let pq_sig = sign_hybrid(&text, "origin.example/log-pq", &kp.secret_key).unwrap();
798 let note = SignedNote::new(text, vec![ed_sig, pq_sig]).unwrap();
799
800 let ed_vkey = VerifierKey::new_ed25519("origin.example/log", &ed_pk).unwrap();
801 let pq_vkey = VerifierKey::new_hybrid("origin.example/log-pq", &pk).unwrap();
802
803 // A classical-only verifier accepts the note via the Ed25519 line and
804 // ignores the unknown PQ line.
805 assert_eq!(
806 note.verify(std::slice::from_ref(&ed_vkey)).unwrap().len(),
807 1
808 );
809 // A PQ-aware verifier with both keys accepts both lines.
810 assert_eq!(note.verify(&[ed_vkey, pq_vkey]).unwrap().len(), 2);
811 }
812}