Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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 fuzz_attachment_parse
steps:
- uses: actions/checkout@v4
- uses: dtolnay/rust-toolchain@nightly
Expand Down
14 changes: 14 additions & 0 deletions crates/auths-keri/fuzz/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -43,3 +43,17 @@ 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

[[bin]]
name = "fuzz_attachment_parse"
path = "fuzz_targets/fuzz_attachment_parse.rs"
test = false
doc = false
bench = false
17 changes: 17 additions & 0 deletions crates/auths-keri/fuzz/fuzz_targets/fuzz_attachment_parse.rs
Original file line number Diff line number Diff line change
@@ -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);
});
18 changes: 18 additions & 0 deletions crates/auths-keri/fuzz/fuzz_targets/fuzz_receipt_couples.rs
Original file line number Diff line number Diff line change
@@ -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);
});
62 changes: 62 additions & 0 deletions crates/auths-keri/src/cesr_encode.rs
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,68 @@ pub(crate) fn decode_verkey(qb64: &str) -> Result<(Vec<u8>, 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<usize> {
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<usize> {
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:
Expand Down
25 changes: 21 additions & 4 deletions crates/auths-keri/src/codec.rs
Original file line number Diff line number Diff line change
Expand Up @@ -192,21 +192,25 @@ impl CesrCodec for CesrV1Codec {
}

fn decode_qualified(&self, qualified: &str) -> Result<DecodedPrimitive, KeriTranslationError> {
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}"
)))
}
}
Expand All @@ -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.
Expand Down
24 changes: 24 additions & 0 deletions crates/auths-keri/src/events.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1189,6 +1189,11 @@ pub fn parse_source_seal_couples(bytes: &[u8]) -> Result<Vec<SourceSeal>, Attach
fn parse_source_seal_group(s: &str) -> Result<(Vec<SourceSeal>, &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())
})?;
Expand Down Expand Up @@ -1523,6 +1528,11 @@ pub fn parse_attachment(bytes: &[u8]) -> Result<Vec<IndexedSignature>, Attachmen
fn parse_sig_group(s: &str) -> Result<(Vec<IndexedSignature>, &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())
})?;
Expand Down Expand Up @@ -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![
Expand Down
22 changes: 7 additions & 15 deletions crates/auths-keri/src/keys.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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)?;
Expand Down
44 changes: 25 additions & 19 deletions crates/auths-keri/src/witness/cesr_receipt.rs
Original file line number Diff line number Diff line change
Expand Up @@ -96,14 +96,6 @@ fn curve_of_aid(aid: &str) -> Result<CurveType, KeriDecodeError> {
}
}

/// The qb64 length a parsed primitive consumed (cesride tolerates trailing stream
/// data, slicing to the primitive's full size).
fn consumed<M: Matter>(m: &M) -> Result<usize, KeriDecodeError> {
Ok(m.qb64()
.map_err(|e| KeriDecodeError::DecodeError(e.to_string()))?
.len())
}

/// Emit a `-L` NonTransReceiptCouples attachment group, byte-aligned with keripy.
///
/// Each couple is `(witness AID, signature)`: the witness AID is appended as its
Expand Down Expand Up @@ -137,18 +129,19 @@ pub fn parse_nontrans_receipt_couples(s: &str) -> Result<Vec<(Prefix, Vec<u8>)>,
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)
}
Expand Down Expand Up @@ -223,6 +216,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
Expand Down
Loading