From 108610e079b5fe10a1eabdeda00edfcaf0a864d9 Mon Sep 17 00:00:00 2001 From: bordumb Date: Mon, 22 Jun 2026 16:58:38 +0100 Subject: [PATCH 1/7] fix(auths-keri): guard receipt-couple parsing against malformed CESR parse_nontrans_receipt_couples handed the raw attachment stream straight to the CESR verkey/signature decoder, which slices the input by a length derived from the derivation code and panics out of bounds on a truncated or variable-length code. Add a shared take_matter_qb64 helper that reads the code, validates the input is ASCII and holds the full primitive, and returns its exact qb64 width before the decoder runs, so a malformed couple fails closed with a typed error. Adds a regression test. Auths-Id: did:keri:EB5cPHY0t-ejNC_rUzPS1dclTvd6kG-R9mQzjozCuGgd Auths-Device: did:keri:EB5cPHY0t-ejNC_rUzPS1dclTvd6kG-R9mQzjozCuGgd Auths-Anchor-Seq: 1 --- crates/auths-keri/src/cesr_encode.rs | 61 +++++++++++++++++++ crates/auths-keri/src/witness/cesr_receipt.rs | 43 +++++++------ 2 files changed, 86 insertions(+), 18 deletions(-) diff --git a/crates/auths-keri/src/cesr_encode.rs b/crates/auths-keri/src/cesr_encode.rs index a51c48d2d..ffac64aa0 100644 --- a/crates/auths-keri/src/cesr_encode.rs +++ b/crates/auths-keri/src/cesr_encode.rs @@ -65,6 +65,67 @@ pub(crate) fn decode_verkey(qb64: &str) -> Result<(Vec, String), KeriDecodeE .map_err(|e| KeriDecodeError::DecodeError(e.to_string())) } +/// CESR Matter hard-code character length, keyed by the code's first character +/// (mirrors cesride's `matter::hardage`, whose tables are `pub(crate)`). Returns +/// `None` for a counter (`-`), op (`_`), or otherwise non-Matter start byte. +fn matter_hard_len(first: u8) -> Option { + Some(match first { + b'A'..=b'Z' | b'a'..=b'z' => 1, + b'0' | b'4' | b'5' | b'6' => 2, + b'1' | b'2' | b'3' | b'7' | b'8' | b'9' => 4, + _ => return None, + }) +} + +/// Full qb64 character length for the CESR Matter codes auths parses (mirrors +/// cesride's `matter` sizage for these codes). Returns `None` for any other code, +/// so an unrecognized primitive fails closed rather than being handed to the +/// length-slicing decoder. +fn matter_full_len(code: &str) -> Option { + Some(match code { + "D" | "B" | "E" => 44, + "0B" | "0I" => 88, + "1AAI" | "1AAJ" => 48, + _ => return None, + }) +} + +/// Take the leading CESR Matter primitive off `s`, returning its exact qb64 +/// substring without consuming the remainder. +/// +/// cesride's qb64 decoder reads the derivation code, computes the primitive's full +/// size, and slices the input to it — so a truncated input slices out of bounds and +/// a non-ASCII input slices off a char boundary, both panicking. This validates the +/// code is known and the input holds the full primitive (and is ASCII through it) +/// *before* the decoder runs, so the panic is unreachable and a malformed primitive +/// fails closed with a typed error. +/// +/// Args: +/// * `s`: A qb64 string positioned at the start of a Matter primitive (a verkey, +/// signature, or digest); trailing bytes after the primitive are left for the +/// caller to consume. +pub(crate) fn take_matter_qb64(s: &str) -> Result<&str, KeriDecodeError> { + let bytes = s.as_bytes(); + let first = *bytes.first().ok_or(KeriDecodeError::EmptyInput)?; + let hs = matter_hard_len(first).ok_or_else(|| { + KeriDecodeError::DecodeError(format!("not a CESR primitive: {:?}", first as char)) + })?; + if bytes.len() < hs || !bytes[..hs].is_ascii() { + return Err(KeriDecodeError::DecodeError( + "truncated or non-ASCII CESR hard code".into(), + )); + } + let code = &s[..hs]; + let fs = matter_full_len(code).ok_or_else(|| KeriDecodeError::UnsupportedKeyType(code.into()))?; + if bytes.len() < fs || !bytes[..fs].is_ascii() { + return Err(KeriDecodeError::DecodeError(format!( + "{code} primitive needs {fs} ASCII chars, have {}", + bytes.len() + ))); + } + Ok(&s[..fs]) +} + /// CESR-encode a 32-byte Blake3-256 digest as a qb64 SAID/commitment (`E…`). /// /// Args: diff --git a/crates/auths-keri/src/witness/cesr_receipt.rs b/crates/auths-keri/src/witness/cesr_receipt.rs index cf637d42a..73319d800 100644 --- a/crates/auths-keri/src/witness/cesr_receipt.rs +++ b/crates/auths-keri/src/witness/cesr_receipt.rs @@ -96,13 +96,6 @@ fn curve_of_aid(aid: &str) -> Result { } } -/// The qb64 length a parsed primitive consumed (cesride tolerates trailing stream -/// data, slicing to the primitive's full size). -fn consumed(m: &M) -> Result { - Ok(m.qb64() - .map_err(|e| KeriDecodeError::DecodeError(e.to_string()))? - .len()) -} /// Emit a `-L` NonTransReceiptCouples attachment group, byte-aligned with keripy. /// @@ -137,18 +130,19 @@ pub fn parse_nontrans_receipt_couples(s: &str) -> Result)>, let (count, mut cur) = decode_counter(s, NONTRANS_RECEIPT_COUPLES)?; let mut out = Vec::with_capacity(count); for _ in 0..count { - // Witness AID: keep its qb64 form (the curve-tagged prefix) verbatim. - let verfer = - Verfer::new_with_qb64(cur).map_err(|e| KeriDecodeError::DecodeError(e.to_string()))?; - let vlen = consumed(&verfer)?; - let aid = Prefix::new_unchecked(cur[..vlen].to_string()); - cur = &cur[vlen..]; - // Signature: recover the raw bytes. - let cigar = Cigar::new_with_qb64(cur, None) - .map_err(|e| KeriDecodeError::DecodeError(e.to_string()))?; - let slen = consumed(&cigar)?; + // Witness AID: the curve-tagged verkey prefix, kept verbatim. Take its exact + // qb64 width before the decoder runs so a malformed code cannot slice out of + // bounds. + let aid_qb64 = crate::cesr_encode::take_matter_qb64(cur)?; + Verfer::new_with_qb64(aid_qb64).map_err(|e| KeriDecodeError::DecodeError(e.to_string()))?; + let aid = Prefix::new_unchecked(aid_qb64.to_string()); + cur = &cur[aid_qb64.len()..]; + // Signature: recover the raw bytes from its exact qb64 width. + let sig_qb64 = crate::cesr_encode::take_matter_qb64(cur)?; + let cigar = Cigar::new_with_qb64(sig_qb64, None) + .map_err(|e| KeriDecodeError::DecodeError(format!("Cigar decode: {e}")))?; out.push((aid, cigar.raw())); - cur = &cur[slen..]; + cur = &cur[sig_qb64.len()..]; } Ok(out) } @@ -223,6 +217,19 @@ mod tests { assert_eq!(parsed[1].1, s2); } + #[test] + fn parse_rejects_malformed_couple_without_panicking() { + // A -L group (count 1) whose first primitive is a truncated/variable-length + // code must fail closed, not panic on an out-of-bounds slice inside the CESR + // decoder. + assert!(parse_nontrans_receipt_couples("-LAB6AAA1IAA").is_err()); + // A non-ASCII byte where a primitive is expected is rejected too. + assert!(parse_nontrans_receipt_couples("-LABD\u{42c}").is_err()); + // A valid witness AID prefix but a truncated body is rejected, not sliced + // past the end. + assert!(parse_nontrans_receipt_couples("-LABBBERER").is_err()); + } + #[test] fn receipt_couplet_witness_pre_is_curve_tagged() { // The witness AID in each couple carries its curve in-band via the CESR From 29690e7f94f532e9b48533a803b368b05e2523d0 Mon Sep 17 00:00:00 2001 From: bordumb Date: Mon, 22 Jun 2026 16:58:38 +0100 Subject: [PATCH 2/7] fix(auths-keri): fail closed on a malformed CESR primitive in decode_qualified decode_qualified passed untrusted input straight to the verkey/digest decoder (out-of-bounds panic on a short variable-length code) and, on the miss path, sliced the error message at a fixed byte offset (panic when it split a multi-byte char). Take the exact primitive width via the shared guard first, so both the decode and the error message operate on a validated, bounded, ASCII primitive. Adds a regression test. Auths-Id: did:keri:EB5cPHY0t-ejNC_rUzPS1dclTvd6kG-R9mQzjozCuGgd Auths-Device: did:keri:EB5cPHY0t-ejNC_rUzPS1dclTvd6kG-R9mQzjozCuGgd Auths-Anchor-Seq: 1 --- crates/auths-keri/src/codec.rs | 25 +++++++++++++++++++++---- 1 file changed, 21 insertions(+), 4 deletions(-) diff --git a/crates/auths-keri/src/codec.rs b/crates/auths-keri/src/codec.rs index 772b2f6cf..0f96b4954 100644 --- a/crates/auths-keri/src/codec.rs +++ b/crates/auths-keri/src/codec.rs @@ -192,21 +192,25 @@ impl CesrCodec for CesrV1Codec { } fn decode_qualified(&self, qualified: &str) -> Result { - if let Ok(verfer) = Verfer::new(None, None, None, Some(qualified), None) { + // Take the exact primitive width before the cesride decoder runs, so a + // truncated or non-ASCII input cannot slice out of bounds / off a char + // boundary and panic. + let primitive = crate::cesr_encode::take_matter_qb64(qualified) + .map_err(|e| KeriTranslationError::DecodingFailed(e.to_string()))?; + if let Ok(verfer) = Verfer::new(None, None, None, Some(primitive), None) { return Ok(DecodedPrimitive { raw: verfer.raw(), code: verfer.code(), }); } - if let Ok(diger) = Diger::new(None, None, None, None, Some(qualified), None) { + if let Ok(diger) = Diger::new(None, None, None, None, Some(primitive), None) { return Ok(DecodedPrimitive { raw: diger.raw(), code: diger.code(), }); } Err(KeriTranslationError::DecodingFailed(format!( - "unrecognized CESR primitive: {}...", - &qualified[..qualified.len().min(8)] + "unrecognized CESR primitive: {primitive}" ))) } } @@ -216,6 +220,19 @@ impl CesrCodec for CesrV1Codec { mod tests { use super::*; + #[test] + fn decode_qualified_rejects_malformed_without_panicking() { + let codec = CesrV1Codec::new(); + // A variable-length code that overruns the input must fail closed, not + // panic on an out-of-bounds slice inside the decoder. + assert!(codec.decode_qualified("6AAA1IAA").is_err()); + // An unrecognized primitive whose byte boundaries straddle a multi-byte char + // must be rejected, not panic on the error-message slice. + assert!(codec.decode_qualified("DABCDE\u{42c}").is_err()); + // A bare unknown code is rejected. + assert!(codec.decode_qualified("zzz").is_err()); + } + /// Pins our cesride encoding against known **keripy 1.3.4** reference values /// for the 32-byte Ed25519 key `bytes(0..32)`. If cesride matches keripy here, /// routing all wire encoding through `CesrV1Codec` makes us byte-interoperable. From 1f9d882cc6029de2add5e8dfc7899de6aef872a7 Mon Sep 17 00:00:00 2001 From: bordumb Date: Mon, 22 Jun 2026 17:10:51 +0100 Subject: [PATCH 3/7] refactor(auths-keri): derive verkey length validation from the shared guard MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit KeriPublicKey::parse carried its own copy of the verkey qb64 lengths (44/48) and prefix checks. Route it through the shared take_matter_qb64 helper so those sizes live in one place, and reject trailing bytes after the primitive. No behavior change — the existing parse battery is the regression. Auths-Id: did:keri:EB5cPHY0t-ejNC_rUzPS1dclTvd6kG-R9mQzjozCuGgd Auths-Device: did:keri:EB5cPHY0t-ejNC_rUzPS1dclTvd6kG-R9mQzjozCuGgd Auths-Anchor-Seq: 1 --- crates/auths-keri/src/keys.rs | 22 +++++++--------------- 1 file changed, 7 insertions(+), 15 deletions(-) diff --git a/crates/auths-keri/src/keys.rs b/crates/auths-keri/src/keys.rs index 0150192d7..1d1569297 100644 --- a/crates/auths-keri/src/keys.rs +++ b/crates/auths-keri/src/keys.rs @@ -122,22 +122,14 @@ impl KeriPublicKey { // cesride's qb64 decoder derives a primitive's length from its derivation // code and slices the input by that length, so a non-ASCII, truncated, or // unknown code makes it slice off a char boundary or past the end and panic. - // Reject anything that is not an exact, known verkey primitive here so the - // parser fails closed on hostile input. The curve is still selected by the - // in-band code prefix, never by raw byte length. - let expected_len = if encoded.starts_with("1AAI") || encoded.starts_with("1AAJ") { - 48 // P-256 compressed verkey: 4-char code + 44 base64 chars - } else if encoded.starts_with('D') || encoded.starts_with('B') { - 44 // Ed25519 verkey: 1-char code + 43 base64 chars - } else { - return Err(KeriDecodeError::UnsupportedKeyType( - encoded.chars().take(4).collect(), + // Validate the input is exactly one well-formed CESR primitive (with no + // trailing bytes) before the decoder runs; the curve is then selected by the + // in-band code below, never by raw byte length. + let primitive = crate::cesr_encode::take_matter_qb64(encoded)?; + if primitive.len() != encoded.len() { + return Err(KeriDecodeError::DecodeError( + "trailing bytes after verkey primitive".into(), )); - }; - if !encoded.is_ascii() || encoded.len() != expected_len { - return Err(KeriDecodeError::DecodeError(format!( - "verkey primitive is not a {expected_len}-char ASCII CESR string" - ))); } let (bytes, code) = crate::cesr_encode::decode_verkey(encoded)?; From c75c8136bbb16199b5931c3ee9d39222481d69f9 Mon Sep 17 00:00:00 2001 From: bordumb Date: Mon, 22 Jun 2026 17:12:20 +0100 Subject: [PATCH 4/7] test(auths-keri): fuzz the receipt-couple parser + wire it into CI Adds a cargo-fuzz target over parse_nontrans_receipt_couples (invariant: never panic on attacker bytes) and adds it to the CI fuzz-smoke matrix. The parser now prevents the out-of-bounds decoder panic rather than relying on unwinding, so the target builds and runs clean. Auths-Id: did:keri:EB5cPHY0t-ejNC_rUzPS1dclTvd6kG-R9mQzjozCuGgd Auths-Device: did:keri:EB5cPHY0t-ejNC_rUzPS1dclTvd6kG-R9mQzjozCuGgd Auths-Anchor-Seq: 1 --- .github/workflows/ci.yml | 2 +- crates/auths-keri/fuzz/Cargo.toml | 7 +++++++ .../fuzz/fuzz_targets/fuzz_receipt_couples.rs | 18 ++++++++++++++++++ 3 files changed, 26 insertions(+), 1 deletion(-) create mode 100644 crates/auths-keri/fuzz/fuzz_targets/fuzz_receipt_couples.rs diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index c2d9945c4..2ed7e70c0 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -332,7 +332,7 @@ jobs: - dir: crates/auths-verifier/fuzz targets: verify_presentation_json verify_credential_json verify_chain attestation_parse did_parse - dir: crates/auths-keri/fuzz - targets: fuzz_cesr_import fuzz_parse_cesr fuzz_validate_kel + targets: fuzz_cesr_import fuzz_parse_cesr fuzz_validate_kel fuzz_receipt_couples steps: - uses: actions/checkout@v4 - uses: dtolnay/rust-toolchain@nightly diff --git a/crates/auths-keri/fuzz/Cargo.toml b/crates/auths-keri/fuzz/Cargo.toml index ae0b5a8d6..f3f149a00 100644 --- a/crates/auths-keri/fuzz/Cargo.toml +++ b/crates/auths-keri/fuzz/Cargo.toml @@ -43,3 +43,10 @@ path = "fuzz_targets/fuzz_validate_kel.rs" test = false doc = false bench = false + +[[bin]] +name = "fuzz_receipt_couples" +path = "fuzz_targets/fuzz_receipt_couples.rs" +test = false +doc = false +bench = false diff --git a/crates/auths-keri/fuzz/fuzz_targets/fuzz_receipt_couples.rs b/crates/auths-keri/fuzz/fuzz_targets/fuzz_receipt_couples.rs new file mode 100644 index 000000000..fe1430e34 --- /dev/null +++ b/crates/auths-keri/fuzz/fuzz_targets/fuzz_receipt_couples.rs @@ -0,0 +1,18 @@ +#![no_main] +//! NonTransReceiptCouples parse fuzz target. +//! +//! Feeds attacker-controlled bytes through `parse_nontrans_receipt_couples` — the +//! `-L` witness-receipt attachment reader, which decodes a stream of CESR verkey +//! and signature primitives. The invariant is that no malformed, truncated, or +//! variable-length code ever panics the parser: every input resolves to a typed +//! error or a parsed couple list, never a crash. + +use auths_keri::witness::parse_nontrans_receipt_couples; +use libfuzzer_sys::fuzz_target; + +fuzz_target!(|data: &[u8]| { + let Ok(s) = std::str::from_utf8(data) else { + return; + }; + let _ = parse_nontrans_receipt_couples(s); +}); From c3b498ab6127cd0cea64ad4c8b6b1f2c92cc0b3d Mon Sep 17 00:00:00 2001 From: bordumb Date: Mon, 22 Jun 2026 17:12:55 +0100 Subject: [PATCH 5/7] style: rustfmt the new CESR parse-guard code Auths-Id: did:keri:EB5cPHY0t-ejNC_rUzPS1dclTvd6kG-R9mQzjozCuGgd Auths-Device: did:keri:EB5cPHY0t-ejNC_rUzPS1dclTvd6kG-R9mQzjozCuGgd Auths-Anchor-Seq: 1 --- crates/auths-keri/src/cesr_encode.rs | 3 ++- crates/auths-keri/src/witness/cesr_receipt.rs | 1 - 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/crates/auths-keri/src/cesr_encode.rs b/crates/auths-keri/src/cesr_encode.rs index ffac64aa0..c066b6be5 100644 --- a/crates/auths-keri/src/cesr_encode.rs +++ b/crates/auths-keri/src/cesr_encode.rs @@ -116,7 +116,8 @@ pub(crate) fn take_matter_qb64(s: &str) -> Result<&str, KeriDecodeError> { )); } let code = &s[..hs]; - let fs = matter_full_len(code).ok_or_else(|| KeriDecodeError::UnsupportedKeyType(code.into()))?; + let fs = + matter_full_len(code).ok_or_else(|| KeriDecodeError::UnsupportedKeyType(code.into()))?; if bytes.len() < fs || !bytes[..fs].is_ascii() { return Err(KeriDecodeError::DecodeError(format!( "{code} primitive needs {fs} ASCII chars, have {}", diff --git a/crates/auths-keri/src/witness/cesr_receipt.rs b/crates/auths-keri/src/witness/cesr_receipt.rs index 73319d800..e4d52b18e 100644 --- a/crates/auths-keri/src/witness/cesr_receipt.rs +++ b/crates/auths-keri/src/witness/cesr_receipt.rs @@ -96,7 +96,6 @@ fn curve_of_aid(aid: &str) -> Result { } } - /// Emit a `-L` NonTransReceiptCouples attachment group, byte-aligned with keripy. /// /// Each couple is `(witness AID, signature)`: the witness AID is appended as its From e58fd238d488a04b0e0a674e8f27b3962aa4b280 Mon Sep 17 00:00:00 2001 From: bordumb Date: Mon, 22 Jun 2026 18:44:59 +0100 Subject: [PATCH 6/7] fix(auths-keri): reject non-ASCII CESR attachments before slicing MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The -A signature and -G source-seal attachment readers sliced the qb64 stream at byte offsets derived from each primitive's code, checking only the byte length — so a multi-byte UTF-8 char straddling a slice boundary panicked. These parsers run over a presented bundle's attachments in the offline verifier before any signature is checked. CESR qb64 is ASCII, so reject non-ASCII at the parser boundary; the byte-offset slices are then always on char boundaries. Adds a regression test. Auths-Id: did:keri:EB5cPHY0t-ejNC_rUzPS1dclTvd6kG-R9mQzjozCuGgd Auths-Device: did:keri:EB5cPHY0t-ejNC_rUzPS1dclTvd6kG-R9mQzjozCuGgd Auths-Anchor-Seq: 1 --- crates/auths-keri/src/events.rs | 24 ++++++++++++++++++++++++ 1 file changed, 24 insertions(+) diff --git a/crates/auths-keri/src/events.rs b/crates/auths-keri/src/events.rs index 48b2c0fa9..74d3f3bc0 100644 --- a/crates/auths-keri/src/events.rs +++ b/crates/auths-keri/src/events.rs @@ -1189,6 +1189,11 @@ pub fn parse_source_seal_couples(bytes: &[u8]) -> Result, Attach fn parse_source_seal_group(s: &str) -> Result<(Vec, &str), AttachmentError> { use cesride::{Matter, Saider, Seqner}; + // CESR qb64 is ASCII; reject non-ASCII at the boundary so the byte-offset slices + // below cannot split a multi-byte char and panic. + if !s.is_ascii() { + return Err(AttachmentError::Decode("non-ASCII CESR attachment".into())); + } let rest = s.strip_prefix("-G").ok_or_else(|| { AttachmentError::Decode("source-seal group must start with -G counter code".into()) })?; @@ -1523,6 +1528,11 @@ pub fn parse_attachment(bytes: &[u8]) -> Result, Attachmen fn parse_sig_group(s: &str) -> Result<(Vec, &str), AttachmentError> { use cesride::{Indexer, Siger}; + // CESR qb64 is ASCII; reject non-ASCII at the boundary so the byte-offset slices + // below cannot split a multi-byte char and panic. + if !s.is_ascii() { + return Err(AttachmentError::Decode("non-ASCII CESR attachment".into())); + } let rest = s.strip_prefix("-A").ok_or_else(|| { AttachmentError::Decode("attachment must start with -A counter code".into()) })?; @@ -1685,6 +1695,20 @@ mod tests { assert_eq!(back[0].sig, vec![0x42u8; 64]); } + #[test] + fn attachment_parsers_reject_multibyte_without_panicking() { + // qb64 is ASCII; a multi-byte UTF-8 char in a -A signature or -G seal group + // must fail closed, not panic on a non-char-boundary slice. + assert!(parse_attachment("-AAB\u{42c}".as_bytes()).is_err()); + assert!(parse_delegated_attachment("-AAB\u{42c}".as_bytes()).is_err()); + + // -G source-seal group with a multi-byte char straddling the Seqner slice. + let mut seal = String::from("-GAB"); + seal.push_str(&"A".repeat(23)); + seal.push('\u{42c}'); + assert!(parse_source_seal_couples(seal.as_bytes()).is_err()); + } + #[test] fn attachment_roundtrip_three_sigs() { let sigs = vec![ From 892d6111bd6960db8d065ab53894fbdce66b83c5 Mon Sep 17 00:00:00 2001 From: bordumb Date: Mon, 22 Jun 2026 18:46:36 +0100 Subject: [PATCH 7/7] test(auths-keri): fuzz the attachment parsers + wire into CI Adds a cargo-fuzz target over parse_attachment / parse_delegated_attachment (the -A/-G readers the offline verifier runs over presented attachments; invariant: never panic on attacker bytes) and adds it to the CI fuzz-smoke matrix. Auths-Id: did:keri:EB5cPHY0t-ejNC_rUzPS1dclTvd6kG-R9mQzjozCuGgd Auths-Device: did:keri:EB5cPHY0t-ejNC_rUzPS1dclTvd6kG-R9mQzjozCuGgd Auths-Anchor-Seq: 1 --- .github/workflows/ci.yml | 2 +- crates/auths-keri/fuzz/Cargo.toml | 7 +++++++ .../fuzz/fuzz_targets/fuzz_attachment_parse.rs | 17 +++++++++++++++++ 3 files changed, 25 insertions(+), 1 deletion(-) create mode 100644 crates/auths-keri/fuzz/fuzz_targets/fuzz_attachment_parse.rs diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 2ed7e70c0..7fbfc5229 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -332,7 +332,7 @@ jobs: - dir: crates/auths-verifier/fuzz targets: verify_presentation_json verify_credential_json verify_chain attestation_parse did_parse - dir: crates/auths-keri/fuzz - targets: fuzz_cesr_import fuzz_parse_cesr fuzz_validate_kel fuzz_receipt_couples + targets: fuzz_cesr_import fuzz_parse_cesr fuzz_validate_kel fuzz_receipt_couples fuzz_attachment_parse steps: - uses: actions/checkout@v4 - uses: dtolnay/rust-toolchain@nightly diff --git a/crates/auths-keri/fuzz/Cargo.toml b/crates/auths-keri/fuzz/Cargo.toml index f3f149a00..3720d89bf 100644 --- a/crates/auths-keri/fuzz/Cargo.toml +++ b/crates/auths-keri/fuzz/Cargo.toml @@ -50,3 +50,10 @@ path = "fuzz_targets/fuzz_receipt_couples.rs" test = false doc = false bench = false + +[[bin]] +name = "fuzz_attachment_parse" +path = "fuzz_targets/fuzz_attachment_parse.rs" +test = false +doc = false +bench = false diff --git a/crates/auths-keri/fuzz/fuzz_targets/fuzz_attachment_parse.rs b/crates/auths-keri/fuzz/fuzz_targets/fuzz_attachment_parse.rs new file mode 100644 index 000000000..bf1d39a77 --- /dev/null +++ b/crates/auths-keri/fuzz/fuzz_targets/fuzz_attachment_parse.rs @@ -0,0 +1,17 @@ +#![no_main] +//! CESR attachment parse fuzz target. +//! +//! Feeds attacker-controlled bytes through the `-A` signature and `-A ++ -G` seal +//! attachment readers (`parse_attachment` / `parse_delegated_attachment`) — the +//! parsers the offline verifier runs over a presented bundle's attachments before +//! any signature is checked. The invariant is that no malformed, truncated, or +//! multi-byte input ever panics: every input resolves to a typed error or a parsed +//! result. + +use auths_keri::{parse_attachment, parse_delegated_attachment}; +use libfuzzer_sys::fuzz_target; + +fuzz_target!(|data: &[u8]| { + let _ = parse_attachment(data); + let _ = parse_delegated_attachment(data); +});