Skip to content

Commit 3e97852

Browse files
committed
Address 1.0.1 pentest findings
1 parent 34f1739 commit 3e97852

6 files changed

Lines changed: 147 additions & 24 deletions

File tree

CHANGELOG.md

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,17 @@
88
- Added README compiler-compatibility evidence for Rust `1.90.0` through
99
Rust `1.96.0`, while continuing to recommend the latest stable Rust for new
1010
deployments.
11+
- Hardened wrapped-line decode prefix checks with checked offset arithmetic.
12+
- Made `ct::CtEngine::decode_slice_staged_clear_tail` report
13+
`DecodeError::StagingTooSmall` when the private staging buffer, rather than
14+
the caller output buffer, is undersized.
15+
- Tightened `BackendPolicy::HighAssuranceScalarOnly` so it also requires a CT
16+
result gate classified as a hardware speculation barrier.
17+
- Reduced legacy whitespace decode traversal drift by sharing the byte
18+
iterator used by validation and decode.
19+
- Added a guarded transfer when converting `SecretBuffer` into
20+
`ExposedSecretString`, plus documentation for cleanup-boundary escape hatches
21+
and CT loop guard debug/release behavior.
1122

1223
## 1.0.0 - 2026-05-19
1324

docs/INVARIANTS.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -95,6 +95,11 @@ Evidence:
9595
access and public length dispatch.
9696
- Malformed-content errors are accumulated and reported as an opaque error to
9797
avoid localizing the first malformed byte in the ct path.
98+
- Internal CT loop guard failures use debug assertions during development and
99+
fail closed to `DecodeError::InvalidInput` in release builds by setting the
100+
accumulated invalid-input masks. This creates a deliberate debug/release
101+
diagnostic difference: debug builds catch invariant violations loudly, while
102+
release builds avoid panicking on sensitive decode paths.
98103

99104
Evidence:
100105

docs/SECURITY_CONTROLS.md

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -41,7 +41,9 @@ The caller still owns:
4141
- treating
4242
`into_exposed_unprotected_array_caller_must_zeroize` calls as boundaries
4343
where redacted formatting and crate-owned drop-time cleanup intentionally
44-
stop for returned bare arrays
44+
stop for returned bare arrays. These methods are safe Rust escape hatches,
45+
but they have a quasi-unsafe cleanup contract: callers must zero the returned
46+
array with their own approved mechanism before it leaves scope.
4547
- understanding that stack-backed buffers can clear their own backing arrays
4648
but cannot clear historical stack-frame copies made by compiler spills,
4749
caller code, panic machinery, crash handlers, or operating system capture

docs/SIMD.md

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -83,7 +83,11 @@ weakening the scalar trust base.
8383
- `runtime::require_backend_policy()` allows deployments to enforce scalar
8484
execution, disabled SIMD features, or no detected SIMD candidate.
8585
- `BackendPolicy::HighAssuranceScalarOnly` combines scalar execution, disabled
86-
SIMD features, no detected SIMD candidate, and unsafe-boundary enforcement.
86+
SIMD features, no detected SIMD candidate, unsafe-boundary enforcement, and a
87+
CT result gate classified as a hardware speculation barrier. It rejects
88+
targets that report only an ordering fence or compiler fence. On AArch64,
89+
this reports emitted `isb sy` plus CSDB hint code; deployments must still
90+
attest whether that hint is effective on their specific core.
8791
- Runtime backend, posture, and policy enums provide stable string identifiers
8892
for logs and release evidence.
8993
- Runtime backend reports and policy failures format as stable key/value

src/lib.rs

Lines changed: 110 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -337,7 +337,14 @@ pub mod runtime {
337337
/// Require no SIMD candidate to be visible to this build and target.
338338
NoDetectedSimdCandidate,
339339
/// Require scalar execution, the `simd` feature disabled, no detected
340-
/// SIMD candidate, and the unsafe boundary enforced.
340+
/// SIMD candidate, the unsafe boundary enforced, and a CT result gate
341+
/// classified as a native hardware speculation barrier.
342+
///
343+
/// This policy intentionally rejects targets that report only an
344+
/// ordering fence or compiler fence for the CT result gate. On `AArch64`,
345+
/// the reported hardware barrier means the crate emitted `isb sy` plus
346+
/// the CSDB hint; deployments must still attest whether that hint is
347+
/// effective on their specific core.
341348
HighAssuranceScalarOnly,
342349
}
343350

@@ -492,6 +499,10 @@ pub mod runtime {
492499
&& !self.simd_feature_enabled
493500
&& !self.accelerated_backend_active
494501
&& self.unsafe_boundary_enforced
502+
&& matches!(
503+
self.ct_gate_posture,
504+
CtGatePosture::HardwareSpeculationBarrier
505+
)
495506
}
496507
}
497508
}
@@ -2845,7 +2856,9 @@ impl LineWrap {
28452856
///
28462857
/// Panics when `line_len` is zero. Base64 wrapping requires a non-zero
28472858
/// encoded line length; accepting zero would make progress impossible for
2848-
/// wrapped encoders.
2859+
/// wrapped encoders. This constructor is callable at runtime, so do not
2860+
/// pass attacker-controlled or externally configured values here; use
2861+
/// [`Self::checked_new`] for those cases.
28492862
#[must_use]
28502863
pub const fn new(line_len: usize, line_ending: LineEnding) -> Self {
28512864
assert!(line_len != 0, "base64 line wrap length must be non-zero");
@@ -3093,6 +3106,12 @@ impl<const CAP: usize> ExposedEncodedArray<CAP> {
30933106
/// This is an unprotected escape hatch. The returned array will not be
30943107
/// cleared by this crate on drop. Callers must clear it with their own
30953108
/// approved zeroization policy.
3109+
///
3110+
/// # Security
3111+
///
3112+
/// Treat this as a cleanup-boundary API. Failing to clear the returned
3113+
/// array leaves the encoded bytes in ordinary caller-owned memory until
3114+
/// overwritten by later stack or heap activity.
30963115
#[must_use = "caller must zeroize the returned array"]
30973116
pub fn into_exposed_unprotected_array_caller_must_zeroize(mut self) -> ([u8; CAP], usize) {
30983117
let len = self.len;
@@ -3410,6 +3429,12 @@ impl<const CAP: usize> ExposedDecodedArray<CAP> {
34103429
/// This is an unprotected escape hatch. The returned array will not be
34113430
/// cleared by this crate on drop. Callers must clear it with their own
34123431
/// approved zeroization policy.
3432+
///
3433+
/// # Security
3434+
///
3435+
/// Treat this as a cleanup-boundary API. Failing to clear the returned
3436+
/// array leaves decoded bytes, which may be secret-bearing, in ordinary
3437+
/// caller-owned memory until overwritten by later stack or heap activity.
34133438
#[must_use = "caller must zeroize the returned array"]
34143439
pub fn into_exposed_unprotected_array_caller_must_zeroize(mut self) -> ([u8; CAP], usize) {
34153440
let len = self.len;
@@ -3756,6 +3781,32 @@ impl Drop for ExposedSecretVec {
37563781
}
37573782
}
37583783

3784+
#[cfg(feature = "alloc")]
3785+
struct WipeVecGuard {
3786+
bytes: alloc::vec::Vec<u8>,
3787+
}
3788+
3789+
#[cfg(feature = "alloc")]
3790+
impl WipeVecGuard {
3791+
fn from_vec(bytes: alloc::vec::Vec<u8>) -> Self {
3792+
Self { bytes }
3793+
}
3794+
3795+
fn into_validated_secret_string(mut self) -> alloc::string::String {
3796+
wipe_vec_spare_capacity(&mut self.bytes);
3797+
let bytes = core::mem::take(&mut self.bytes);
3798+
core::mem::forget(self);
3799+
string_from_validated_secret_bytes(bytes)
3800+
}
3801+
}
3802+
3803+
#[cfg(feature = "alloc")]
3804+
impl Drop for WipeVecGuard {
3805+
fn drop(&mut self) {
3806+
wipe_vec_all(&mut self.bytes);
3807+
}
3808+
}
3809+
37593810
#[cfg(feature = "alloc")]
37603811
impl AsRef<[u8]> for ExposedSecretVec {
37613812
fn as_ref(&self) -> &[u8] {
@@ -3945,15 +3996,13 @@ impl SecretBuffer {
39453996
return Err(self);
39463997
}
39473998

3948-
// Security invariant: do not add fallible or allocating work between
3949-
// taking `exposed.bytes` and wrapping it as `ExposedSecretString`.
3950-
// During that narrow move-only window the bytes are temporarily in a
3951-
// plain `Vec<u8>`.
3999+
// Keep the bytes behind a wiping guard until the final infallible
4000+
// ownership transfer into `String`.
39524001
let mut exposed = self.into_exposed_vec();
3953-
let bytes = core::mem::take(&mut exposed.bytes);
4002+
let guard = WipeVecGuard::from_vec(core::mem::take(&mut exposed.bytes));
39544003
drop(exposed);
39554004
Ok(ExposedSecretString::from_string(
3956-
string_from_validated_secret_bytes(bytes),
4005+
guard.into_validated_secret_string(),
39574006
))
39584007
}
39594008

@@ -6892,6 +6941,13 @@ pub enum DecodeError {
68926941
/// Available output bytes.
68936942
available: usize,
68946943
},
6944+
/// The caller-provided constant-time staging buffer is too small.
6945+
StagingTooSmall {
6946+
/// Required staging bytes.
6947+
required: usize,
6948+
/// Available staging bytes.
6949+
available: usize,
6950+
},
68956951
}
68966952

68976953
impl core::fmt::Display for DecodeError {
@@ -6913,6 +6969,13 @@ impl core::fmt::Display for DecodeError {
69136969
f,
69146970
"base64 decode output buffer too small: required {required}, available {available}"
69156971
),
6972+
Self::StagingTooSmall {
6973+
required,
6974+
available,
6975+
} => write!(
6976+
f,
6977+
"base64 decode staging buffer too small: required {required}, available {available}"
6978+
),
69166979
}
69176980
}
69186981
}
@@ -6930,27 +6993,51 @@ impl DecodeError {
69306993
Self::InvalidLineWrap { index } => Self::InvalidLineWrap {
69316994
index: index + offset,
69326995
},
6933-
Self::InvalidInput | Self::InvalidLength | Self::OutputTooSmall { .. } => self,
6996+
Self::InvalidInput
6997+
| Self::InvalidLength
6998+
| Self::OutputTooSmall { .. }
6999+
| Self::StagingTooSmall { .. } => self,
69347000
}
69357001
}
69367002
}
69377003

69387004
#[cfg(feature = "std")]
69397005
impl std::error::Error for DecodeError {}
69407006

7007+
struct LegacyBytes<'a> {
7008+
input: &'a [u8],
7009+
index: usize,
7010+
}
7011+
7012+
impl<'a> LegacyBytes<'a> {
7013+
const fn new(input: &'a [u8]) -> Self {
7014+
Self { input, index: 0 }
7015+
}
7016+
7017+
fn next_byte(&mut self) -> Option<(usize, u8)> {
7018+
while self.index < self.input.len() {
7019+
let index = self.index;
7020+
let byte = self.input[index];
7021+
self.index += 1;
7022+
if !is_legacy_whitespace(byte) {
7023+
return Some((index, byte));
7024+
}
7025+
}
7026+
None
7027+
}
7028+
}
7029+
69417030
fn validate_legacy_decode<A: Alphabet, const PAD: bool>(
69427031
input: &[u8],
69437032
) -> Result<usize, DecodeError> {
7033+
let mut bytes = LegacyBytes::new(input);
69447034
let mut chunk = [0u8; 4];
69457035
let mut indexes = [0usize; 4];
69467036
let mut chunk_len = 0;
69477037
let mut required = 0;
69487038
let mut terminal_seen = false;
69497039

6950-
for (index, byte) in input.iter().copied().enumerate() {
6951-
if is_legacy_whitespace(byte) {
6952-
continue;
6953-
}
7040+
while let Some((index, byte)) = bytes.next_byte() {
69547041
if terminal_seen {
69557042
return Err(DecodeError::InvalidPadding { index });
69567043
}
@@ -6984,16 +7071,14 @@ fn decode_legacy_to_slice<A: Alphabet, const PAD: bool>(
69847071
input: &[u8],
69857072
output: &mut [u8],
69867073
) -> Result<usize, DecodeError> {
7074+
let mut bytes = LegacyBytes::new(input);
69877075
let mut chunk = [0u8; 4];
69887076
let mut indexes = [0usize; 4];
69897077
let mut chunk_len = 0;
69907078
let mut write = 0;
69917079
let mut terminal_seen = false;
69927080

6993-
for (index, byte) in input.iter().copied().enumerate() {
6994-
if is_legacy_whitespace(byte) {
6995-
continue;
6996-
}
7081+
while let Some((index, byte)) = bytes.next_byte() {
69977082
if terminal_seen {
69987083
return Err(DecodeError::InvalidPadding { index });
69997084
}
@@ -7095,7 +7180,9 @@ impl<'a> WrappedBytes<'a> {
70957180

70967181
fn starts_with_line_ending(&self) -> bool {
70977182
let line_ending = self.wrap.line_ending.as_bytes();
7098-
let end = self.index + line_ending.len();
7183+
let Some(end) = self.index.checked_add(line_ending.len()) else {
7184+
return false;
7185+
};
70997186
end <= self.input.len() && &self.input[self.index..end] == line_ending
71007187
}
71017188
}
@@ -7230,7 +7317,8 @@ fn map_chunk_error(err: DecodeError, indexes: &[usize; 4]) -> DecodeError {
72307317
DecodeError::InvalidInput
72317318
| DecodeError::InvalidLineWrap { .. }
72327319
| DecodeError::InvalidLength
7233-
| DecodeError::OutputTooSmall { .. } => err,
7320+
| DecodeError::OutputTooSmall { .. }
7321+
| DecodeError::StagingTooSmall { .. } => err,
72347322
}
72357323
}
72367324

@@ -7248,7 +7336,8 @@ fn map_partial_chunk_error(err: DecodeError, indexes: &[usize; 4], len: usize) -
72487336
| DecodeError::InvalidLineWrap { .. }
72497337
| DecodeError::InvalidInput
72507338
| DecodeError::InvalidLength
7251-
| DecodeError::OutputTooSmall { .. } => err,
7339+
| DecodeError::OutputTooSmall { .. }
7340+
| DecodeError::StagingTooSmall { .. } => err,
72527341
}
72537342
}
72547343

@@ -7632,7 +7721,7 @@ fn ct_decode_slice_staged_clear_tail<A: Alphabet, const PAD: bool>(
76327721
if staging.len() < required {
76337722
wipe_bytes(output);
76347723
wipe_bytes(staging);
7635-
return Err(DecodeError::OutputTooSmall {
7724+
return Err(DecodeError::StagingTooSmall {
76367725
required,
76377726
available: staging.len(),
76387727
});

tests/rfc4648.rs

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -651,6 +651,18 @@ fn runtime_backend_policy_assertions_are_explicit() {
651651
artificial_error.to_string(),
652652
"runtime backend policy `high-assurance-scalar-only` was not satisfied (active=scalar candidate=avx2 candidate_detection_mode=compile-time-target-features candidate_required_cpu_features=[avx2] simd_feature_enabled=true accelerated_backend_active=false unsafe_boundary_enforced=false security_posture=simd-candidate-scalar-active wipe_posture=hardware-fence ct_gate_posture=hardware-speculation-barrier)"
653653
);
654+
let ordering_fence_report = runtime::BackendReport {
655+
active: runtime::Backend::Scalar,
656+
candidate: runtime::Backend::Scalar,
657+
candidate_detection_mode: runtime::CandidateDetectionMode::SimdFeatureDisabled,
658+
simd_feature_enabled: false,
659+
accelerated_backend_active: false,
660+
unsafe_boundary_enforced: true,
661+
security_posture: runtime::SecurityPosture::ScalarOnly,
662+
wipe_posture: runtime::WipePosture::HardwareFence,
663+
ct_gate_posture: runtime::CtGatePosture::OrderingFence,
664+
};
665+
assert!(!ordering_fence_report.satisfies(runtime::BackendPolicy::HighAssuranceScalarOnly));
654666

655667
let simd_feature_policy =
656668
runtime::require_backend_policy(runtime::BackendPolicy::SimdFeatureDisabled);
@@ -1092,7 +1104,7 @@ fn ct_decode_slice_staged_clear_tail_copies_only_after_success() {
10921104
let mut staging = [0xdd; 1];
10931105
assert_eq!(
10941106
ct::STANDARD.decode_slice_staged_clear_tail(b"aGk=", &mut output, &mut staging),
1095-
Err(DecodeError::OutputTooSmall {
1107+
Err(DecodeError::StagingTooSmall {
10961108
required: 2,
10971109
available: 1,
10981110
})

0 commit comments

Comments
 (0)