# BCX v0.3.0 Pentest Report

Tag: v0.3.0
Commit: 669653ad44ce79eb7f5231ec7a08118ce6748d2b
Status: PASS
Tester: Maintainer pentest
Date: 2026-06-19
Scope: BCX v0.3.0 release candidate

## Summary

This report records the release pentest result for BCX `v0.3.0`. The root
`PENTEST.md` scratch input was reviewed, remediated where release-scope fixes
were required, and then captured here by digest before deletion.

Input-Digest: sha256:ca609c56c20252dd61a12150833b8ebeaa71a322cf3cf6a69eb374417350c542

## Scratch Report

```text
Findings

---
[HIGH-1] Constant-Time Bypass: Length Short-Circuit in IdentifierBytes::ct_eq

Severity: High
Location: crates/bcx-core/src/ids.rs:42-44
Type: Timing Side-Channel / Non-Constant-Time Comparison

Description:

fn ct_eq(&self, other: &Self) -> bool {
    self.len == other.len && bool::from(self.bytes.ct_eq(&other.bytes))
}

The short-circuit && operator means that when two identifiers have different lengths, execution returns immediately after a fast u8 == u8 comparison — never reaching the constant-time self.bytes.ct_eq(...). When lengths match, the full MAX-length padded array is compared (taking measurably longer). An adversary who can trigger repeated comparisons and observe timing can learn whether their probe shares a length with the reference value — without knowing the content.

This affects all variable-length identifier types: SubjectId (1–64 bytes), RealmId (1–64), ProfileId (1–32), ProofSuiteId (1–32), PolicyId (1–32), NativeBindingId (1–64). It does not affect StatementId or CheckpointId (both are fixed 32 bytes, so len is always equal).

In an admission or delegation check where one of these identifiers gates access, an oracle constructed from repeated comparison timing could enumerate the length of an expected identifier and narrow a brute-force search space significantly.

Remediation:

use subtle::{ConstantTimeEq, Choice};

fn ct_eq(&self, other: &Self) -> bool {
    // Non-short-circuit bitwise AND — both branches always execute.
    let len_eq: Choice = self.len.ct_eq(&other.len);
    let bytes_eq: Choice = self.bytes.ct_eq(&other.bytes);
    bool::from(len_eq & bytes_eq)
}

subtle::ConstantTimeEq is already available in the workspace. Replacing && with & on Choice values ensures both comparisons always execute regardless of the length result.

---
[HIGH-2] Hybrid Signature Timing Oracle via Short-Circuit Verification

Severity: High
Location: crates/bcx-crypto/src/envelope.rs:273-281
Type: Timing Side-Channel / Cryptographic Oracle

Description:

fn verify_hybrid(&self, envelope: &SignatureEnvelope<'_>, canonical_payload: &[u8])
    -> Result<HybridVerified, VerificationError>
{
    let (ed25519, ml_dsa_65) = ...;
    self.verify_ed25519(ed25519, canonical_payload)?;   // Early return on failure
    self.verify_ml_dsa_65(ml_dsa_65, canonical_payload)?;
    Ok(HybridVerified(()))
}

The ? operator short-circuits: if verify_ed25519 fails, verify_ml_dsa_65 is never called. ML-DSA-65 verification is computationally expensive relative to Ed25519 — the timing difference between "Ed25519 failed" (fast path) and "Ed25519 valid, ML-DSA-65 failed" (slow path) is large and measurable. An adversary can use this as an oracle to test the validity of the Ed25519 component in isolation, decoupled from the hybrid result.

In a high-assurance military deployment where hybrid signatures are used precisely to prevent exploitation of either component independently, this reintroduces a classical-component oracle.

Remediation:

Always execute both verifications before inspecting either result:

fn verify_hybrid(&self, envelope: &SignatureEnvelope<'_>, canonical_payload: &[u8])
    -> Result<HybridVerified, VerificationError>
{
    let (ed25519, ml_dsa_65) = envelope
        .algorithm()
        .split_hybrid(envelope.signature())
        .ok_or(VerificationError::InvalidSignature)?;

    // Both verifications always run — no short-circuit.
    let ed25519_ok = self.verify_ed25519(ed25519, canonical_payload).is_ok();
    let ml_dsa_ok  = self.verify_ml_dsa_65(ml_dsa_65, canonical_payload).is_ok();

    // Non-short-circuit AND to combine results.
    if ed25519_ok & ml_dsa_ok {
        Ok(HybridVerified(()))
    } else {
        Err(VerificationError::InvalidSignature)
    }
}

Note: this guarantees both paths always run at the BCX layer. Backend implementors of HybridVerifier must also run their own verifications to completion regardless of intermediate results; document this requirement explicitly in the trait contract.

---
[MEDIUM-1] Truncating as u8 Cast — Not Compile-Time Enforced

Severity: Medium
Location: crates/bcx-core/src/ids.rs:30
Type: Arithmetic — Potential Truncation
Confirmed by: cargo clippy -- -W clippy::cast_possible_truncation

Description:

len: bytes.len() as u8,

A runtime guard at line 19 checks bytes.len() > u8::MAX as usize, but the cast at line 30 uses the unsound as operator which silently truncates. All current MAX values (≤ 64) are safely below 255, so the guard fires first for those instantiations. However, the IdentifierBytes<MIN, MAX> type is a private generic — a future caller who instantiates it with MAX > 255 would have the guard catch the large-length case, but the as u8 cast is still semantically unsound because the guard and the cast are decoupled. Clippy flags this correctly.

Remediation:

let len = u8::try_from(bytes.len()).map_err(|_| ValidationError::TooLarge)?;
// ...
Ok(Self {
    bytes: stored,
    len,
})

This makes the bound compiler-enforced and removes the need for the separate bytes.len() > u8::MAX as usize guard, which becomes dead code in all current instantiations.

---
[MEDIUM-2] Algorithm Downgrade Path — No Minimum-Strength Enforcement

Severity: Medium
Location: crates/bcx-crypto/src/envelope.rs:63-88
Type: Logic Flaw / Cryptographic Downgrade

Description:

AlgorithmPolicy is a flat allow-list with no notion of minimum strength or algorithm priority. If an operator configugnatureAlgorithm::Ed25519, SignatureAlgorithm::HybridEd25519MlDsa65]) to allow both during a transition period, amessage carrying only an Ed25519 signature is fully admitted. The post-quantum component is bypassed entirely by choiIn a military sovereign profile, hybrid signatures exist precisely to prevent an adversary with a quantum computer frclassical component. Allowing classical-only as an admitted alternative nullifies this property for any operationwhere the sender chooses Ed25519.

Remediation:

The policy layer should support mutually-exclusive algorithm sets per operation class, or a minimum-strength concept:

pub enum AlgorithmRequirement {
    /// Exactly this algorithm — no alternatives.
    Exact(SignatureAlgorithm),
    /// Any algorithm from this ordered set (strongest first).
    AnyOf(&'static [SignatureAlgorithm]),
}

At minimum, document explicitly in AlgorithmPolicy that admitting multiple algorithms allows the sender to choose the weaker one, and that Sovereign profile deployments should admit exactly one algorithm.

---
[MEDIUM-3] Non-Constant-Time Hash Implementation on Nonce

Severity: Medium
Location: crates/bcx-core/src/ids.rs:352-362
Type: Timing Side-Channel / Sensitive Data in Non-CT Path

Description:

impl core::hash::Hash for Nonce {
    fn hash<H: core::hash::Hasher>(&self, state: &mut H) {
        core::hash::Hash::hash(&self.0, state);
    }
}

The doc comment correctly warns that this is not constant-time and that high-assurance replay caches should not rely ementation still exists and can be used to insert Nonce values into a HashMap. A timing-observable hash table (through bucket collision patterns) leaks Nonce byte content. A sophisticated attacker controlling insertion of adversarial keys can create collisions that reduce the effective security of the nonce to the hash output width.

Because Nonce is intentionally not Clone or Copy (to minimize copies in memory), the Hash implementation creates an apparent safe path that actually undermines the intent.

Remediation:

Consider removing Hash from Nonce entirely and requiring replay cache implementors to use a keyed structure (SipHasher-backed map with a random key, or a dedicated constant-time set):

// Remove the Hash impl entirely and let the compiler enforce the restriction.
// Document that replay caches must use a keyed or constant-time map structure.

If Hash must stay for ergonomics, gate it on a #[cfg(feature = "nonce-hash-unsafe")] feature with a compile-time warn and visible in dependency trees.

---
[MEDIUM-4] Missing Zeroize on Digest and IdentifierBytes — Memory Residue Risk

Severity: Medium
Location: crates/bcx-core/src/ids.rs — Digest (line 104), IdentifierBytes (line 6)
Type: Sensitive Data Leakage — Memory Residue

Description:

Nonce correctly implements Drop with self.0.zeroize(), preventing secrets from lingering in freed memory. However, Digest and IdentifierBytes have no Drop impl. Both types are stack-allocated with fixed-size arrays that will not be zeroed on drop.

In a cold-boot attack or process memory dump scenario:

- SignatureEnvelope.key_id (a Digest) — the public key commitment — remains in memory after the envelope is dropped.
- IdentifierBytes fields backing SubjectId, RealmId, PolicyId, etc. — authorization identifiers — remain readable.
- EventId, CapabilityRef, PolicyEpoch (all wrapping Digest) — causal chain commitments — persist.

While digests are typically public, in a pseudonymous-identity BCX deployment, SubjectId and RealmId bytes may carry tion that must not survive the processing boundary.

Remediation:

// In Digest
impl Drop for Digest {
    fn drop(&mut self) {
        self.0.zeroize();
    }
}

// In IdentifierBytes
impl<const MIN: usize, const MAX: usize> Drop for IdentifierBytes<MIN, MAX> {
    fn drop(&mut self) {
        self.bytes.zeroize();
        self.len.zeroize();
    }
}

Both Digest and u8 arrays implement Zeroize through the zeroize crate already in scope. Note that adding Drop to typeremoving Copy — this is a breaking API change for Digest. One option is to make zeroing opt-in via a ZeroizeOnDropnewtype wrapper rather than modifying the base types.

---
[LOW-1] AlgorithmPolicy::new Accepts an Empty Allowed-Algorithms List

Severity: Low
Location: crates/bcx-crypto/src/envelope.rs:69-73
Type: Logic Flaw / Silent Misconfiguration

Description:

pub const fn new(admitted: &'a [SignatureAlgorithm]) -> Self {
    Self { admitted }
}

AlgorithmPolicy::new(&[]) constructs a deny-all policy without error. Every subsequent verification call will return ail-closed is better than fail-open, a misconfigured deny-all policy fails invisibly — every message is rejected with
the same error as a legitimate policy block. Operators may spend significant time debugging before discovering the po

Remediation:

pub const fn new(admitted: &'a [SignatureAlgorithm]) -> Option<Self> {
    if admitted.is_empty() {
        None
    } else {
        Some(Self { admitted })
    }
}

Or use a named constructor deny_all() for the empty case to make intent explicit.

---
[LOW-2] WireLimits::DEVELOPMENT Is a Public Production-Accessible Constant

Severity: Low
Location: crates/bcx-wire/src/lib.rs:61-66
Type: Configuration / Misconfiguration Risk

Description:

pub const DEVELOPMENT: Self = Self {
    maximum_message_len: 1_048_576,  // 1MB
    maximum_parent_events: 16,
    maximum_why_depth: 5,
    maximum_explanation_events: 100,
};

DEVELOPMENT is a pub const with no feature gate, test-only marker, or naming convention (_DEV/_UNSAFE) that signals it must not be used in production. An integrator under time pressure who reaches for WireLimits::DEVELOPMENT in a production path will have significantly looser bounds than their deployment profile requires. The maximum_message_len: 1_048_576 allows 1MB messages that might be fine in development but inappropriate in constrained environments.

Remediation:

#[cfg(any(test, feature = "testing-utils"))]
pub const DEVELOPMENT: Self = Self { ... };

Or rename to WireLimits::UNSAFE_DEVELOPMENT_DO_NOT_USE_IN_PRODUCTION as a naming deterrent, and document clearly in the const's doc comment. Alternatively, require production deployments to explicitly construct limits via WireLimits::new(...).

---
[LOW-3] validate() Is Public on Already-Validated Types — Re-Validation With Looser Limits Possible

Severity: Low
Location: crates/bcx-crypto/src/envelope.rs:141, crates/bcx-wire/src/lib.rs:147
Type: Logic Flaw / Defense-in-Depth Gap

Description:

Both SignatureEnvelope::validate(&self, limits) and WireHeader::validate(&self, limits) are pub. A SignatureEnvelope constructed with strict limits (e.g., a 64KB cap) can be re-validated by an external caller with WireLimits::DEVELOPMENT (1MB cap), effectively
re-opening a size bound that was already enforced at construction time. All current algorithms have fixed-length signnot exploitable today — but new variable-length algorithm additions would immediately be affected.

Remediation:

pub(crate) fn validate(&self, limits: WireLimits) -> Result<(), VerificationError> { ... }

If external re-validation is intentional (e.g., to allow context-specific limit enforcement at verification time), document the security assumption explicitly and add a test that passing looser limits than construction-time limits does not bypass any size check.

---
Informational — Protocol Gaps vs. Stated Security Controls

The docs/security-controls.md lists required controls for the first production profile. The following controls are documented as required but have no type-level representation in the current codebase. These are not bugs in what exists — they are gaps between the stated requirements and current implementation scope. They should be tracked to prevent premature integration.

┌───────────────────────┬──────────────────────────────────────────────────────────────┬───────────────────────────────────────────────────────────────────────────────┐
│        Control        │                      Document Reference                      │                                                   Status                                                   │
├───────────────────────┼──────────────────────────────────────────────────────────────┼────────────────────────────────────────────────────────────────────────────────────────────────────────────┤
│ Expiry / freshness    │ security-controls.md: "nonce and expiry"                     │ No Expiry or Timestamp type enforce freshness if the replay cache is cleared. │
├───────────────────────┼──────────────────────────────────────────────────────────────┼────────────────────────────────────────────────────────────────────────────────────────────────────────────┤
│ Audience binding      │ security-controls.md: "audience binding"                     │ No Audience type or binding                                                   │
├───────────────────────┼──────────────────────────────────────────────────────────────┼───────────────────────────────────────────────────────────────────────────────┤
│ Replay cache hook     │ security-controls.md: "replay cache hook"                    │ No ReplayCache trait or interface defined. Nonce exists but has no associated cache.                       │
├───────────────────────┼──────────────────────────────────────────────────────────────┼────────────────────────────────────────────────────────────────────────────────────────────────────────────┤
│ Key rotation evidence │ security-controls.md: "key rotation and revocation evidence" │ No revocation or rotation ty                                                  │
└───────────────────────┴──────────────────────────────────────────────────────────────┴───────────────────────────────────────────────────────────────────────────────┘

---
```
