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
18 changes: 6 additions & 12 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -260,7 +260,7 @@ jobs:
uses: taiki-e/install-action@cargo-llvm-cov

- name: Run tests (host, hermetic, whole workspace)
run: cargo test --workspace --locked
run: cargo test --workspace --locked --features fw-update/_fuzz,image-verify/_fuzz,tropic01-driver/_fuzz

- name: Set up Python
uses: actions/setup-python@v5
Expand Down Expand Up @@ -392,7 +392,7 @@ jobs:
continue-on-error: true
run: cargo +stable outdated --workspace --root-deps-only

# Job 6: Fuzzing (libFuzzer + ASan on the driver's attacker-facing parsers)
# Job 6: Fuzzing (libFuzzer + ASan on every attacker-facing decoder in the tree)
fuzz:
name: Fuzz Parsers
runs-on: ubuntu-latest
Expand All @@ -409,33 +409,27 @@ jobs:
with:
tool: cargo-fuzz

# The corpus accumulates across runs: every discovered input makes
# the next run start deeper.
- name: Restore fuzz corpus
uses: actions/cache@v4
with:
path: crates/tropic01-driver/fuzz/corpus
path: crates/*/fuzz/corpus
key: fuzz-corpus-${{ github.run_id }}
restore-keys: |
fuzz-corpus-

# 60 seconds per target on PR/push, 15 minutes on the weekly run.
- name: Fuzz each target
- name: Fuzz every target of every fuzz crate
run: |
secs=60
if [ "${{ github.event_name }}" = "schedule" ]; then secs=900; fi
cd crates/tropic01-driver
# Enumerate the targets so a newly added one is fuzzed automatically.
for t in $(cargo +nightly fuzz list); do
cargo +nightly fuzz run "$t" --target x86_64-unknown-linux-gnu -- -max_total_time="$secs" -timeout=10
done
bash scripts/fuzz-gate.sh --secs "$secs"

- name: Upload crash artifacts
if: failure()
uses: actions/upload-artifact@v4
with:
name: fuzz-artifacts
path: crates/tropic01-driver/fuzz/artifacts
path: crates/*/fuzz/artifacts

# Job 7: SonarQube Analysis
sonarqube:
Expand Down
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -112,7 +112,7 @@ scripts/ci-local.sh --quick # skip coverage and fuzz
| Coverage | `cargo-llvm-cov` | library line coverage floor |
| Advisories | `cargo deny` | blocks on any RustSec finding, SARIF export |
| Dependency policy | `cargo deny` / `cargo udeps` | license allow-list, trusted sources, no yanked or unused crates |
| Fuzz | `cargo fuzz` | the driver's attacker-facing parsers, longer on the weekly schedule |
| Fuzz | `cargo fuzz` | the driver's wire parsers, the signed-image verifier, the update state machine. `scripts/fuzz-gate.sh` walks the tree for `fuzz/` projects and aborts if what it finds differs from the crate list pinned in that script, so adding a fuzz crate needs an edit there. Longer runs on the weekly schedule |
| Quality scan | SonarQube | consumes the reports above |

See [`.github/workflows/ci.yml`](.github/workflows/ci.yml) for the full pipeline and [`sonar-project.properties`](sonar-project.properties) for the SonarQube configuration.
Expand Down
2 changes: 1 addition & 1 deletion crates/fw-update/fuzz/fuzz_targets/drive_machine.rs
Original file line number Diff line number Diff line change
Expand Up @@ -12,5 +12,5 @@ use libfuzzer_sys::fuzz_target;

fuzz_target!(|data: &[u8]|
{
fw_update::fuzz::drive_machine(data);
let _ = fw_update::fuzz::drive_machine(data);
});
28 changes: 17 additions & 11 deletions crates/fw-update/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -85,6 +85,7 @@ pub mod fuzz
{
use crate::MockFlash;
use crate::MockSeCounter;
use crate::SE_COUNTER_ORIGIN;
use crate::UpdateState;
use crate::Updater;
use image_verify::RootKey;
Expand All @@ -95,17 +96,18 @@ pub mod fuzz
/// from `data`, feeds them through [`Updater::receive_chunk`], then runs
/// verify, commit, boot, and confirm. The machine must never panic and must
/// never reach [`UpdateState::Committed`] for an image the verifier did not
/// accept. A genuinely valid image is essentially never produced by mutation,
/// so the path under test is the fail-closed rejection across the whole flow.
pub fn drive_machine(data: &[u8])
/// accept.
///
/// Returns true when the machine armed the commit.
pub fn drive_machine(data: &[u8]) -> bool
{
let root = match RootKey::from_bytes(crate::DEV_ROOT_KEY_TEST_ONLY)
{
Ok(key) => key,
Err(_) => return,
Err(_) => return false,
};
let flash = MockFlash::new(0);
let se = MockSeCounter::new(0);
let se = MockSeCounter::new(SE_COUNTER_ORIGIN);
let mut up = Updater::new(&root, flash, se);

// The first two bytes pick a declared length inside the modelled bank.
Expand All @@ -122,7 +124,7 @@ pub mod fuzz

if up.begin(total_len).is_err()
{
return;
return false;
}

// Each record is a 1-byte length prefix then that many payload bytes,
Expand All @@ -140,7 +142,7 @@ pub mod fuzz
{
// A rejected chunk fails closed: the machine must not commit.
assert_ne!(up.state(), UpdateState::Committed);
return;
return false;
}
offset = offset.saturating_add(chunk.len());
rest = tail;
Expand All @@ -152,15 +154,19 @@ pub mod fuzz
// A rejected image must never have armed a swap.
assert!(!up.flash().committed());
assert_ne!(up.state(), UpdateState::Committed);
return;
return false;
}

// The verifier accepted: the commit/confirm path must also stay sound.
if up.commit().is_ok()
if up.commit().is_err()
{
let _ = up.on_boot();
let _ = up.confirm(0);
return false;
}
assert_eq!(up.state(), UpdateState::Committed);
assert!(up.flash().committed());
let _ = up.on_boot();
let _ = up.confirm(0);
true
}
}

Expand Down
51 changes: 51 additions & 0 deletions crates/fw-update/src/tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -636,3 +636,54 @@ fn page_constants_are_consistent()
assert_eq!(PAGE_LEN, 256);
assert_eq!(CONFIRM_BOOTS, 1);
}

#[cfg(feature = "_fuzz")]
fn frame_for_fuzz_seam(image: &[u8]) -> std::vec::Vec<u8>
{
let mut framed = std::vec::Vec::new();
let declared = image.len() as u16;
framed.extend_from_slice(&declared.to_le_bytes());
for chunk in image.chunks(255)
{
framed.push(chunk.len() as u8);
framed.extend_from_slice(chunk);
}
framed
}

#[cfg(feature = "_fuzz")]
#[test]
fn the_fuzz_seam_reaches_a_commit_for_a_signed_image()
{
let image = build_image(DEV_SCALAR, 3, b"the fuzz seam must reach a commit");
let framed = frame_for_fuzz_seam(&image);

assert!(
crate::fuzz::drive_machine(&framed),
"the fuzz seam must ARM A COMMIT for an image signed with the dev scalar"
);
}

#[cfg(feature = "_fuzz")]
#[test]
fn the_fuzz_seam_rejects_an_image_signed_by_the_wrong_key()
{
let image = build_image(WRONG_SCALAR, 3, b"the fuzz seam must reject this");
let framed = frame_for_fuzz_seam(&image);

assert!(
!crate::fuzz::drive_machine(&framed),
"an image signed by the wrong key must never arm a commit"
);
}

#[cfg(feature = "_fuzz")]
#[test]
fn the_fuzz_entry_point_survives_degenerate_inputs()
{
assert!(!crate::fuzz::drive_machine(&[]));
assert!(!crate::fuzz::drive_machine(&[0x00]));
assert!(!crate::fuzz::drive_machine(&[0xFF, 0xFF]));
assert!(!crate::fuzz::drive_machine(&[0x00, 0x00, 0xFF, 0x01, 0x02]));
assert!(!crate::fuzz::drive_machine(&[0x10, 0x00, 0x02, 0xAA, 0xBB]));
}
2 changes: 1 addition & 1 deletion crates/tropic01-driver/fuzz/Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

59 changes: 37 additions & 22 deletions crates/tropic01-driver/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -228,34 +228,49 @@ pub mod fuzz
let _ = crate::device::image_version(data);
}

/// Drives the certificate-chain verifier over arbitrary bytes with a fixed
/// pinned anchor. Must never panic. The anchor's exact value is irrelevant:
/// fuzzing targets the bounded DER parsing in front of the crypto, which
/// fails closed on essentially every mutated input.
/// A fixed P-521 SEC1 point (`0x04 || X(66) || Y(66)`), the model TEST root.
///
/// [`crate::cert::RootAnchor::from_sec1_p521`] validates the point and
/// returns an error for a bad one, and [`verify_cert_chain`] below can only
/// run the parser it fuzzes once that call succeeds.
#[cfg(feature = "attestation")]
pub(crate) const FUZZ_ANCHOR_POINT: [u8; 133] = [
0x04, 0x01, 0x35, 0xc7, 0xa2, 0x4d, 0x16, 0xb3, 0x74, 0xb2, 0x07, 0xad,
0xe8, 0xfe, 0x50, 0xf5, 0x03, 0xad, 0x34, 0xe0, 0xe5, 0x96, 0xc8, 0x3f,
0xc9, 0x8a, 0xdb, 0x4c, 0x43, 0x88, 0xca, 0x0a, 0xd9, 0xb2, 0x4e, 0x77,
0xe9, 0x84, 0xb8, 0x97, 0x82, 0x53, 0xa8, 0xe0, 0xd6, 0xfd, 0x68, 0xea,
0xa8, 0xd9, 0xc9, 0xa9, 0xa6, 0xc8, 0x83, 0x5a, 0x13, 0x8c, 0xcc, 0xff,
0x51, 0x13, 0x0d, 0xa1, 0x09, 0x86, 0x80, 0x00, 0xcd, 0xf7, 0xfa, 0xd5,
0xa0, 0x2b, 0xbd, 0x84, 0x45, 0x3c, 0x56, 0x36, 0xf2, 0x5f, 0x1c, 0x39,
0x5b, 0xdc, 0x22, 0xee, 0x7b, 0x44, 0x1a, 0x81, 0xb5, 0x9f, 0x20, 0x40,
0x53, 0x89, 0xf4, 0x7d, 0x65, 0xf0, 0x74, 0xa6, 0x02, 0xf9, 0x33, 0x2d,
0xf1, 0x33, 0x79, 0xf2, 0x7d, 0x65, 0x4f, 0x4e, 0x1b, 0x0f, 0xd4, 0x56,
0xc1, 0xa9, 0x9f, 0x54, 0x36, 0x64, 0x0f, 0x7e, 0xe0, 0x4e, 0x1b, 0x48,
0x81,
];

/// Drives the certificate-chain verifier over arbitrary bytes under the fixed
/// anchor above. Must never panic.
#[cfg(feature = "attestation")]
pub fn verify_cert_chain(data: &[u8])
{
// A fixed, REAL P-521 SEC1 point (0x04 || X(66) || Y(66)). The anchor now
// validates the point at construction, so a real on-curve point is used.
// Its exact value is irrelevant to the fuzz target, which exercises the
// bounded DER parsing in front of the crypto. This is the model TEST root.
const FUZZ_ANCHOR_POINT: [u8; 133] = [
0x04, 0x01, 0x35, 0xc7, 0xa2, 0x4d, 0x16, 0xb3, 0x74, 0xb2, 0x07, 0xad,
0xe8, 0xfe, 0x50, 0xf5, 0x03, 0xad, 0x34, 0xe0, 0xe5, 0x96, 0xc8, 0x3f,
0xc9, 0x8a, 0xdb, 0x4c, 0x43, 0x88, 0xca, 0x0a, 0xd9, 0xb2, 0x4e, 0x77,
0xe9, 0x84, 0xb8, 0x97, 0x82, 0x53, 0xa8, 0xe0, 0xd6, 0xfd, 0x68, 0xea,
0xa8, 0xd9, 0xc9, 0xa9, 0xa6, 0xc8, 0x83, 0x5a, 0x13, 0x8c, 0xcc, 0xff,
0x51, 0x13, 0x0d, 0xa1, 0x09, 0x86, 0x80, 0x00, 0xcd, 0xf7, 0xfa, 0xd5,
0xa0, 0x2b, 0xbd, 0x84, 0x45, 0x3c, 0x56, 0x36, 0xf2, 0x5f, 0x1c, 0x39,
0x5b, 0xdc, 0x22, 0xee, 0x7b, 0x44, 0x1a, 0x81, 0xb5, 0x9f, 0x20, 0x40,
0x53, 0x89, 0xf4, 0x7d, 0x65, 0xf0, 0x74, 0xa6, 0x02, 0xf9, 0x33, 0x2d,
0xf1, 0x33, 0x79, 0xf2, 0x7d, 0x65, 0x4f, 0x4e, 0x1b, 0x0f, 0xd4, 0x56,
0xc1, 0xa9, 0x9f, 0x54, 0x36, 0x64, 0x0f, 0x7e, 0xe0, 0x4e, 0x1b, 0x48,
0x81,
];
if let Ok(anchor) = crate::cert::RootAnchor::from_sec1_p521(&FUZZ_ANCHOR_POINT)
{
let _ = crate::cert::verify_cert_chain(data, &anchor);
}
}

#[cfg(all(test, feature = "attestation"))]
mod tests
{
#[test]
fn the_fuzz_anchor_point_is_a_valid_p521_point()
{
assert!
(
crate::cert::RootAnchor::from_sec1_p521(&super::FUZZ_ANCHOR_POINT).is_ok(),
"the fuzz anchor must be accepted, or verify_cert_chain fuzzes nothing"
);
}
}
}
14 changes: 4 additions & 10 deletions scripts/ci-local.sh
Original file line number Diff line number Diff line change
Expand Up @@ -165,14 +165,7 @@ coverage_stage()

fuzz_stage()
{
(
cd crates/tropic01-driver || exit 1
# Enumerate the targets so a newly added one is fuzzed automatically.
for t in $(cargo +nightly fuzz list)
do
cargo +nightly fuzz run "$t" -- -max_total_time="$FUZZ_SECS" -timeout=10 || exit 1
done
)
bash scripts/fuzz-gate.sh --secs "$FUZZ_SECS"
}

embedded_stage()
Expand Down Expand Up @@ -227,7 +220,8 @@ RUSTFLAGS="-D warnings" run "check (thumbv8m)" \
cargo check -p tropic01-driver --locked --target thumbv8m.main-none-eabihf
unset RUSTFLAGS

run "test (host)" cargo test --workspace --locked
run "test (host)" cargo test --workspace --locked \
--features fw-update/_fuzz,image-verify/_fuzz,tropic01-driver/_fuzz

run "clippy (json report + strict)" clippy_reports

Expand Down Expand Up @@ -274,7 +268,7 @@ then

if have cargo-fuzz && rustup toolchain list | grep -q nightly
then
run "fuzz (${FUZZ_SECS}s per target)" fuzz_stage
run "fuzz (every fuzz crate, ${FUZZ_SECS}s per target)" fuzz_stage
else
skip "fuzz" "cargo install cargo-fuzz (and a nightly toolchain)"
fi
Expand Down
Loading
Loading