Skip to main content

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(&note.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(&note.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}