Skip to content

Commit 7e6aa96

Browse files
committed
Harden Tokio cancellation cleanup
1 parent 041d05f commit 7e6aa96

11 files changed

Lines changed: 167 additions & 82 deletions

File tree

CHANGELOG.md

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,13 +11,18 @@
1111
release-toolchain pin.
1212
- Updated test-only byte iteration syntax for the Rust `1.97.0` Clippy policy.
1313
- Updated constant-time assembly evidence parsing for Rust `1.97.0`'s default
14-
v0 symbol mangling while preserving legacy-symbol compatibility.
14+
v0 symbol mangling while preserving legacy-symbol compatibility and requiring
15+
concrete text-section definitions for both formats.
1516
- Updated `base64-ng-bytes` to `bytes` `1.12.1` and
1617
`base64-ng-sanitization` to exact-pinned `sanitization` `1.2.4`.
1718
- Pinned GitHub Actions to the latest audited releases: checkout `v6.0.2`,
1819
rust-cache `v2.9.1`, and install-action `v2.83.0`.
1920
- Added exact-version cargo-nextest installation and the 210-test nextest suite
2021
to GitHub CI, with install-action fallback disabled.
22+
- Hardened Tokio companion cleanup with volatile wipes for fixed reader
23+
buffers and RAII-wiped read-all allocations that also cover cancellation.
24+
- Bounded Tokio limited-helper over-read to one lookahead byte and documented
25+
the adjacent-frame contract.
2126
- Pinned documented release-tool installs to the audited current versions,
2227
including cargo-nextest `0.9.140` and cargo-fuzz `0.13.2`.
2328
- Deferred the stronger RISC-V RVV proof and backend-admission review to

README.md

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -384,7 +384,10 @@ assert!(decoded.subtle_verify(b"hello"));
384384
`base64-ng-tokio` provides read-all/write-all async helpers and manual
385385
`AsyncRead`/`AsyncWrite` streaming adapters for applications that already use
386386
Tokio. Prefer the `*_limited` helper variants for request or frame boundaries
387-
controlled by a peer:
387+
controlled by a peer. Read-all temporary allocations are RAII-wiped on return,
388+
error, and cancellation. Limited helpers may consume one lookahead byte to
389+
detect overflow, so use an already bounded reader when adjacent input must
390+
remain unread:
388391

389392
```toml
390393
[dependencies]

crates/base64-ng-tokio/README.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,11 @@ variants when input size is controlled by a peer or request boundary. Writer
3333
shutdown is the finalization boundary: call `AsyncWriteExt::shutdown` to encode
3434
or validate a final partial quantum.
3535

36+
Read-all helper allocations are RAII-wiped on success, error, and cancellation.
37+
Limited helpers consume no more than the configured limit plus one lookahead
38+
byte used to detect overflow. Use a separately bounded reader or a streaming
39+
adapter when an adjacent frame's first byte must remain unread.
40+
3641
```rust
3742
use base64_ng::STANDARD;
3843
use base64_ng_tokio::{encode_reader_to_writer_limited, EncoderReader, EncoderWriter};

crates/base64-ng-tokio/src/lib.rs

Lines changed: 100 additions & 71 deletions
Original file line numberDiff line numberDiff line change
@@ -18,11 +18,12 @@
1818
//!
1919
//! # Security
2020
//!
21-
//! The read-all helpers use temporary `Vec<u8>` allocations and the normal
22-
//! strict decode path. They wipe those temporary vectors before returning, but
23-
//! they are not constant-time-oriented token validators or high-assurance
24-
//! secret decoders. For secret-bearing async frames, collect a bounded frame
25-
//! under the application's approved memory policy and decode through
21+
//! The read-all helpers use RAII-guarded temporary `Vec<u8>` allocations and
22+
//! the normal strict decode path. The guards wipe initialized bytes and spare
23+
//! capacity on ordinary return, I/O error, or future cancellation. They are
24+
//! not constant-time-oriented token validators or high-assurance secret
25+
//! decoders. For secret-bearing async frames, collect a bounded frame under
26+
//! the application's approved memory policy and decode through
2627
//! `base64_ng::ct`, staged CT decode, `base64-ng-derive`, or
2728
//! `base64-ng-sanitization`.
2829
@@ -56,20 +57,15 @@ where
5657
R: AsyncRead + Unpin + ?Sized,
5758
W: AsyncWrite + Unpin + ?Sized,
5859
{
59-
let mut input = Vec::new();
60-
reader.read_to_end(&mut input).await?;
61-
let mut output = match engine.encode_vec(&input).map_err(encode_io_error) {
62-
Ok(output) => output,
63-
Err(error) => {
64-
wipe_bytes(&mut input);
65-
return Err(error);
66-
}
67-
};
60+
let mut input = WipingVec::new();
61+
reader.read_to_end(input.as_mut_vec()).await?;
62+
let output = WipingVec::from_vec(
63+
engine
64+
.encode_vec(input.as_slice())
65+
.map_err(encode_io_error)?,
66+
);
6867
let written = output.len() as u64;
69-
let result = writer.write_all(&output).await;
70-
wipe_bytes(&mut input);
71-
wipe_bytes(&mut output);
72-
result?;
68+
writer.write_all(output.as_slice()).await?;
7369
Ok(written)
7470
}
7571

@@ -95,19 +91,14 @@ where
9591
R: AsyncRead + Unpin + ?Sized,
9692
W: AsyncWrite + Unpin + ?Sized,
9793
{
98-
let mut input = read_to_end_limited(reader, max_input_len).await?;
99-
let mut output = match engine.encode_vec(&input).map_err(encode_io_error) {
100-
Ok(output) => output,
101-
Err(error) => {
102-
wipe_bytes(&mut input);
103-
return Err(error);
104-
}
105-
};
94+
let input = read_to_end_limited(reader, max_input_len).await?;
95+
let output = WipingVec::from_vec(
96+
engine
97+
.encode_vec(input.as_slice())
98+
.map_err(encode_io_error)?,
99+
);
106100
let written = output.len() as u64;
107-
let result = writer.write_all(&output).await;
108-
wipe_bytes(&mut input);
109-
wipe_bytes(&mut output);
110-
result?;
101+
writer.write_all(output.as_slice()).await?;
111102
Ok(written)
112103
}
113104

@@ -130,20 +121,15 @@ where
130121
R: AsyncRead + Unpin + ?Sized,
131122
W: AsyncWrite + Unpin + ?Sized,
132123
{
133-
let mut input = Vec::new();
134-
reader.read_to_end(&mut input).await?;
135-
let mut output = match engine.decode_vec(&input).map_err(decode_io_error) {
136-
Ok(output) => output,
137-
Err(error) => {
138-
wipe_bytes(&mut input);
139-
return Err(error);
140-
}
141-
};
124+
let mut input = WipingVec::new();
125+
reader.read_to_end(input.as_mut_vec()).await?;
126+
let output = WipingVec::from_vec(
127+
engine
128+
.decode_vec(input.as_slice())
129+
.map_err(decode_io_error)?,
130+
);
142131
let written = output.len() as u64;
143-
let result = writer.write_all(&output).await;
144-
wipe_bytes(&mut input);
145-
wipe_bytes(&mut output);
146-
result?;
132+
writer.write_all(output.as_slice()).await?;
147133
Ok(written)
148134
}
149135

@@ -169,19 +155,14 @@ where
169155
R: AsyncRead + Unpin + ?Sized,
170156
W: AsyncWrite + Unpin + ?Sized,
171157
{
172-
let mut input = read_to_end_limited(reader, max_input_len).await?;
173-
let mut output = match engine.decode_vec(&input).map_err(decode_io_error) {
174-
Ok(output) => output,
175-
Err(error) => {
176-
wipe_bytes(&mut input);
177-
return Err(error);
178-
}
179-
};
158+
let input = read_to_end_limited(reader, max_input_len).await?;
159+
let output = WipingVec::from_vec(
160+
engine
161+
.decode_vec(input.as_slice())
162+
.map_err(decode_io_error)?,
163+
);
180164
let written = output.len() as u64;
181-
let result = writer.write_all(&output).await;
182-
wipe_bytes(&mut input);
183-
wipe_bytes(&mut output);
184-
result?;
165+
writer.write_all(output.as_slice()).await?;
185166
Ok(written)
186167
}
187168

@@ -227,37 +208,85 @@ fn wipe_bytes(bytes: &mut [u8]) {
227208
base64_ng::secure_wipe(bytes);
228209
}
229210

230-
async fn read_to_end_limited<R>(reader: &mut R, max_input_len: usize) -> io::Result<Vec<u8>>
211+
struct WipingVec(Vec<u8>);
212+
213+
impl WipingVec {
214+
fn new() -> Self {
215+
Self(Vec::new())
216+
}
217+
218+
fn with_capacity(capacity: usize) -> Self {
219+
Self(Vec::with_capacity(capacity))
220+
}
221+
222+
fn from_vec(bytes: Vec<u8>) -> Self {
223+
Self(bytes)
224+
}
225+
226+
fn as_slice(&self) -> &[u8] {
227+
&self.0
228+
}
229+
230+
fn as_mut_vec(&mut self) -> &mut Vec<u8> {
231+
&mut self.0
232+
}
233+
234+
fn len(&self) -> usize {
235+
self.0.len()
236+
}
237+
}
238+
239+
impl Drop for WipingVec {
240+
fn drop(&mut self) {
241+
// Initialize the existing spare capacity without reallocating so the
242+
// reviewed wipe primitive covers the complete allocation.
243+
self.0.resize(self.0.capacity(), 0);
244+
wipe_bytes(&mut self.0);
245+
self.0.clear();
246+
}
247+
}
248+
249+
struct WipingArray<const N: usize>([u8; N]);
250+
251+
impl<const N: usize> WipingArray<N> {
252+
const fn new() -> Self {
253+
Self([0; N])
254+
}
255+
}
256+
257+
impl<const N: usize> Drop for WipingArray<N> {
258+
fn drop(&mut self) {
259+
wipe_bytes(&mut self.0);
260+
}
261+
}
262+
263+
async fn read_to_end_limited<R>(reader: &mut R, max_input_len: usize) -> io::Result<WipingVec>
231264
where
232265
R: AsyncRead + Unpin + ?Sized,
233266
{
234-
let mut input = Vec::with_capacity(max_input_len.min(READ_ALL_EAGER_CAP));
235-
let mut chunk = [0u8; 8192];
267+
let mut input = WipingVec::with_capacity(max_input_len.min(READ_ALL_EAGER_CAP));
268+
let mut chunk = WipingArray::<8192>::new();
236269

237270
loop {
238-
let read = match reader.read(&mut chunk).await {
239-
Ok(read) => read,
240-
Err(error) => {
241-
wipe_bytes(&mut chunk);
242-
wipe_bytes(&mut input);
243-
return Err(error);
244-
}
271+
let remaining = max_input_len - input.len();
272+
let read_cap = if remaining < chunk.0.len() {
273+
remaining + 1
274+
} else {
275+
chunk.0.len()
245276
};
277+
let read = reader.read(&mut chunk.0[..read_cap]).await?;
246278
if read == 0 {
247-
wipe_bytes(&mut chunk);
248279
return Ok(input);
249280
}
250281

251-
if read > max_input_len.saturating_sub(input.len()) {
252-
wipe_bytes(&mut chunk);
253-
wipe_bytes(&mut input);
282+
if read > remaining {
254283
return Err(io::Error::new(
255284
io::ErrorKind::InvalidData,
256285
"base64-ng-tokio input exceeds configured limit",
257286
));
258287
}
259288

260-
input.extend_from_slice(&chunk[..read]);
261-
wipe_bytes(&mut chunk);
289+
input.0.extend_from_slice(&chunk.0[..read]);
290+
wipe_bytes(&mut chunk.0);
262291
}
263292
}

crates/base64-ng-tokio/src/readers.rs

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -71,9 +71,9 @@ where
7171
}
7272

7373
fn clear_buffers(&mut self) {
74-
self.pending.fill(0);
74+
wipe_bytes(&mut self.pending);
7575
self.pending_len = 0;
76-
self.output.fill(0);
76+
wipe_bytes(&mut self.output);
7777
self.output_pos = 0;
7878
self.output_len = 0;
7979
}
@@ -289,9 +289,9 @@ where
289289
}
290290

291291
fn clear_buffers(&mut self) {
292-
self.pending.fill(0);
292+
wipe_bytes(&mut self.pending);
293293
self.pending_len = 0;
294-
self.output.fill(0);
294+
wipe_bytes(&mut self.output);
295295
self.output_pos = 0;
296296
self.output_len = 0;
297297
}

crates/base64-ng-tokio/tests/tokio.rs

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,16 @@ impl ScriptedReader {
2828
actions: actions.into_iter().collect(),
2929
}
3030
}
31+
32+
fn remaining_data_len(&self) -> usize {
33+
self.actions
34+
.iter()
35+
.map(|action| match action {
36+
ReadAction::Data(bytes) => bytes.len(),
37+
ReadAction::Error | ReadAction::Pending => 0,
38+
})
39+
.sum()
40+
}
3141
}
3242

3343
impl AsyncRead for ScriptedReader {
@@ -135,6 +145,22 @@ async fn limited_decode_reports_oversized_input_before_writing() {
135145
assert_eq!(output, b"untouched");
136146
}
137147

148+
#[tokio::test]
149+
async fn limited_helpers_consume_at_most_limit_plus_one_byte() {
150+
let input_len = 16_384;
151+
let limit = 100;
152+
let mut input = ScriptedReader::new([ReadAction::Data(vec![b'x'; input_len])]);
153+
let mut output = b"untouched".to_vec();
154+
155+
let error = encode_reader_to_writer_limited(&STANDARD, &mut input, &mut output, limit)
156+
.await
157+
.unwrap_err();
158+
159+
assert_eq!(error.kind(), std::io::ErrorKind::InvalidData);
160+
assert_eq!(input.remaining_data_len(), input_len - limit - 1);
161+
assert_eq!(output, b"untouched");
162+
}
163+
138164
#[tokio::test]
139165
async fn limited_reader_helpers_round_trip_at_limit() {
140166
let mut input = &b"hello"[..];

docs/ASYNC.md

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,13 @@ input size before writing output. Its `EncoderReader`, `DecoderReader`,
1212
`EncoderWriter`, and `DecoderWriter` adapters use explicit state machines,
1313
fixed internal buffers, and drop cleanup.
1414

15+
Read-all helper allocations are held behind RAII guards before the first
16+
suspension point. Their initialized bytes and spare capacity are wiped on
17+
success, I/O error, or future cancellation. Limited helpers request at most the
18+
remaining allowance plus one lookahead byte. Generic `AsyncRead` cannot return
19+
that lookahead byte to the source; callers that must preserve adjacent framed
20+
input should provide an already bounded reader or use a streaming adapter.
21+
1522
## Current Status
1623

1724
- The `stream` feature provides `std::io` streaming wrappers.
@@ -22,7 +29,7 @@ fixed internal buffers, and drop cleanup.
2229
the crate today.
2330
- `base64-ng-tokio` provides optional read-all/write-all helpers for projects
2431
that already admit Tokio. Prefer its limited helpers for peer-controlled
25-
input.
32+
input. Their temporary allocations use cancellation-safe RAII cleanup.
2633
- `base64-ng-tokio` also provides streaming adapters: `EncoderReader`,
2734
`DecoderReader`, `EncoderWriter`, and `DecoderWriter`.
2835
- Async writer shutdown is the finalization boundary. Call

docs/CT_ASM_REVIEW.md

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -43,8 +43,9 @@ Review focus:
4343
- Does generated code keep the scalar ct path independent from SIMD dispatch?
4444
- Does `ct_error_gate_barrier` remain a separate non-inlined symbol in release
4545
and LTO artifacts before opaque malformed-input reporting?
46-
- Do the automated symbol checks find the required boundaries under both
47-
legacy Rust mangling and Rust `1.97.0`'s default v0 mangling?
46+
- Do the automated symbol checks find concrete text-section definitions for
47+
the required boundaries under both legacy Rust mangling and Rust `1.97.0`'s
48+
default v0 mangling?
4849
- Does `constant_time_eq_public_len` remain a separate non-inlined symbol in
4950
release and LTO artifacts, and does the equal-length loop scan all bytes
5051
rather than lowering into an early-exit compare?

release-notes/RELEASE_NOTES_1.3.7.md

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,13 +14,18 @@ the `base64-ng` crate family.
1414
the release-toolchain pin.
1515
- Updated test-only byte iteration syntax for Rust `1.97.0` Clippy.
1616
- Updated the constant-time assembly evidence parser for Rust `1.97.0`'s v0
17-
symbols while retaining compatibility with legacy Rust symbol mangling.
17+
symbols while retaining compatibility with legacy Rust symbol mangling and
18+
requiring concrete text-section definitions for both formats.
1819
- Updated the optional bytes and sanitization companions to `bytes` `1.12.1`
1920
and exact-pinned `sanitization` `1.2.4`.
2021
- Pinned GitHub Actions to checkout `v6.0.2`, rust-cache `v2.9.1`, and
2122
install-action `v2.83.0`, using their verified release commits.
2223
- Added the 210-test nextest suite to GitHub CI and disabled install-action
2324
fallback installation.
25+
- Hardened Tokio reader-buffer cleanup and made read-all temporary allocations
26+
wipe through RAII on success, error, and cancellation.
27+
- Limited oversized-input detection to one lookahead byte and documented the
28+
adjacent-frame behavior for generic `AsyncRead` sources.
2429
- Pinned documented release/deep-check tool versions to the current audited
2530
releases, including cargo-nextest `0.9.140` and cargo-fuzz `0.13.2`.
2631
- Scheduled the stronger RISC-V RVV proof and backend-admission review for

0 commit comments

Comments
 (0)