Skip to content

Commit 4285328

Browse files
committed
Harden secret wrappers and CT anchors
1 parent 4b73e99 commit 4285328

4 files changed

Lines changed: 132 additions & 19 deletions

File tree

docs/UNSAFE.md

Lines changed: 46 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -194,15 +194,17 @@ Status: active secret conversion helper when `alloc` is enabled.
194194

195195
Purpose:
196196

197-
- Convert bytes already validated as UTF-8 into `String` without a second
198-
fallible conversion after the vector has been moved out of `SecretBuffer`.
197+
- Convert bytes already validated as UTF-8, or bytes just extracted from a
198+
valid `String`, back into `String` without a second fallible conversion after
199+
the vector has crossed a secret-wrapper boundary.
199200
- Avoid an intermediate raw `Vec<u8>` drop path that would not run the crate's
200201
best-effort cleanup on panic.
201202

202203
Preconditions:
203204

204205
- Caller must validate the exact vector contents as UTF-8 immediately before
205-
handing ownership to this helper.
206+
handing ownership to this helper, or pass bytes produced directly by
207+
`String::into_bytes` without modifying the initialized prefix.
206208
- The vector must not be modified between validation and conversion.
207209

208210
Unsafe operation:
@@ -214,6 +216,9 @@ Safety argument:
214216

215217
- `SecretBuffer::try_into_exposed_string` validates `self.expose_secret()` with
216218
`core::str::from_utf8` before moving the same allocation into this helper.
219+
- `ExposedSecretString::from_string` receives a valid `String`, converts it to
220+
bytes, wipes only spare capacity past the initialized UTF-8 prefix, and then
221+
moves the same initialized prefix into this helper.
217222
- No mutation occurs between validation and conversion.
218223
- The returned `ExposedSecretString` retains redacted formatting and drop-time
219224
cleanup.
@@ -262,6 +267,44 @@ Limitations:
262267
microarchitecture certification.
263268
- Unsupported architectures fall back to the compiler fence only.
264269

270+
### `ct_decode_alphabet_byte`
271+
272+
Location: `src/lib.rs`
273+
274+
Status: active constant-time-oriented alphabet scanner.
275+
276+
Purpose:
277+
278+
- Decode one Base64 symbol by scanning all 64 alphabet entries instead of
279+
indexing a decode table or returning at the first match.
280+
- Keep the decoded-value and validity accumulators observable to the optimizer
281+
on every iteration of the fixed scan.
282+
283+
Preconditions:
284+
285+
- `A::ENCODE` is a validated 64-byte Base64 alphabet. Built-in alphabets and
286+
the `define_alphabet!` macro enforce this.
287+
288+
Unsafe operation:
289+
290+
- `core::ptr::read_volatile` reads initialized local `decoded` and `valid`
291+
accumulators after each OR reduction.
292+
293+
Safety argument:
294+
295+
- `decoded` and `valid` are initialized stack-local `u8` values for the entire
296+
loop.
297+
- The volatile reads do not read from caller memory and cannot violate bounds
298+
or aliasing requirements.
299+
- The function remains `#[inline(never)]` so generated-code review can inspect
300+
the scanner as a distinct helper.
301+
302+
Limitations:
303+
304+
- These volatile reads are optimizer barriers, not a formal proof of
305+
microarchitectural constant-time behavior. Release evidence and dudect remain
306+
required for high-assurance review.
307+
265308
### `wipe_vec_spare_capacity`
266309

267310
Location: `src/lib.rs`

scripts/validate-unsafe-boundary.sh

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -20,24 +20,24 @@ if [ "$matches" != "$allowed" ]; then
2020
fi
2121

2222
root_allow_count="$(grep -c '^#\[allow(unsafe_code)\]$' "$root_allowed" || true)"
23-
if [ "$root_allow_count" -ne 6 ]; then
24-
echo "unsafe boundary: src/lib.rs must have exactly six reviewed allow(unsafe_code) helpers"
23+
if [ "$root_allow_count" -ne 7 ]; then
24+
echo "unsafe boundary: src/lib.rs must have exactly seven reviewed allow(unsafe_code) helpers"
2525
exit 1
2626
fi
2727

2828
if ! awk '
2929
/^#\[allow\(unsafe_code\)\]$/ {
3030
allow_line = NR
3131
}
32-
/^fn wipe_bytes\(/ || /^fn wipe_barrier\(/ || /^fn wipe_vec_spare_capacity\(/ || /^fn ct_error_gate_barrier\(/ || /^fn constant_time_eq_same_len\(/ || /^fn string_from_validated_secret_bytes\(/ {
32+
/^fn wipe_bytes\(/ || /^fn wipe_barrier\(/ || /^fn wipe_vec_spare_capacity\(/ || /^fn ct_error_gate_barrier\(/ || /^fn constant_time_eq_same_len\(/ || /^fn string_from_validated_secret_bytes\(/ || /^fn ct_decode_alphabet_byte/ {
3333
if (allow_line != NR - 1) {
3434
failed = 1
3535
}
3636
seen += 1
3737
}
38-
END { exit failed || seen != 6 }
38+
END { exit failed || seen != 7 }
3939
' "$root_allowed"; then
40-
echo "unsafe boundary: src/lib.rs allow(unsafe_code) must apply only to reviewed cleanup, secret-conversion, comparison, and CT gate helpers"
40+
echo "unsafe boundary: src/lib.rs allow(unsafe_code) must apply only to reviewed cleanup, secret-conversion, comparison, CT scan, and CT gate helpers"
4141
exit 1
4242
fi
4343

src/lib.rs

Lines changed: 40 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -3671,8 +3671,9 @@ pub struct SecretBuffer {
36713671

36723672
/// Owned secret bytes extracted from [`SecretBuffer`].
36733673
///
3674-
/// This wrapper keeps redacted formatting and best-effort drop-time cleanup
3675-
/// after a [`SecretBuffer`] is consumed for owned interop. Use
3674+
/// This wrapper keeps redacted formatting, best-effort spare-capacity clearing
3675+
/// at construction time, and best-effort full wipe on drop after a
3676+
/// [`SecretBuffer`] is consumed for owned interop. Use
36763677
/// [`Self::into_exposed_unprotected_vec_caller_must_zeroize`] only when a raw
36773678
/// `Vec<u8>` is unavoidable and the caller will handle cleanup.
36783679
#[cfg(feature = "alloc")]
@@ -3771,8 +3772,9 @@ impl AsMut<[u8]> for ExposedSecretVec {
37713772

37723773
/// Owned secret UTF-8 text extracted from [`SecretBuffer`].
37733774
///
3774-
/// This wrapper keeps redacted formatting and best-effort drop-time cleanup
3775-
/// after a [`SecretBuffer`] is consumed for string interop. Use
3775+
/// This wrapper keeps redacted formatting, best-effort spare-capacity clearing
3776+
/// at construction time, and best-effort full wipe on drop after a
3777+
/// [`SecretBuffer`] is consumed for string interop. Use
37763778
/// [`Self::into_exposed_unprotected_string_caller_must_zeroize`] only when a
37773779
/// raw `String` is unavoidable and the caller will handle cleanup.
37783780
#[cfg(feature = "alloc")]
@@ -3785,6 +3787,9 @@ impl ExposedSecretString {
37853787
/// Wraps an owned UTF-8 string as exposed secret text.
37863788
#[must_use]
37873789
pub fn from_string(text: alloc::string::String) -> Self {
3790+
let mut bytes = text.into_bytes();
3791+
wipe_vec_spare_capacity(&mut bytes);
3792+
let text = string_from_validated_secret_bytes(bytes);
37883793
Self { text }
37893794
}
37903795

@@ -5084,7 +5089,11 @@ fn constant_time_eq_same_len(left: &[u8], right: &[u8]) -> bool {
50845089
diff = unsafe { core::ptr::read_volatile(&raw const diff) };
50855090
}
50865091
ct_error_gate_barrier(diff, 0);
5087-
diff == 0
5092+
// SAFETY: `diff` is an initialized local `u8`; this final volatile read
5093+
// keeps the public equality comparison dependent on a post-barrier load of
5094+
// the accumulated value.
5095+
let result = unsafe { core::ptr::read_volatile(&raw const diff) };
5096+
result == 0
50885097
}
50895098

50905099
#[cfg(feature = "alloc")]
@@ -7973,7 +7982,10 @@ fn ct_decode_padded_in_place<A: Alphabet>(buffer: &mut [u8]) -> Result<usize, De
79737982
wipe_bytes(buffer);
79747983
return Err(DecodeError::InvalidInput);
79757984
}
7976-
report_ct_error(invalid_byte, invalid_padding)?;
7985+
if let Err(err) = report_ct_error(invalid_byte, invalid_padding) {
7986+
wipe_bytes(buffer);
7987+
return Err(err);
7988+
}
79777989
Ok(write)
79787990
}
79797991

@@ -8137,7 +8149,10 @@ fn ct_decode_unpadded_in_place<A: Alphabet>(buffer: &mut [u8]) -> Result<usize,
81378149
wipe_bytes(buffer);
81388150
return Err(DecodeError::InvalidInput);
81398151
}
8140-
report_ct_error(invalid_byte, invalid_padding)?;
8152+
if let Err(err) = report_ct_error(invalid_byte, invalid_padding) {
8153+
wipe_bytes(buffer);
8154+
return Err(err);
8155+
}
81418156
Ok(write)
81428157
}
81438158

@@ -8184,15 +8199,30 @@ fn read_tail_or_mark_invalid<'a>(
81848199
}
81858200

81868201
#[inline(never)]
8202+
#[allow(unsafe_code)]
81878203
fn ct_decode_alphabet_byte<A: Alphabet>(byte: u8) -> (u8, u8) {
81888204
let mut decoded = 0u8;
81898205
let mut valid = 0u8;
81908206
let mut candidate = 0u8;
81918207

81928208
while candidate < 64 {
8193-
let matches = ct_mask_eq_u8(byte, A::ENCODE[candidate as usize]);
8194-
decoded |= candidate & matches;
8195-
valid |= matches;
8209+
let matches = core::hint::black_box(ct_mask_eq_u8(
8210+
core::hint::black_box(byte),
8211+
core::hint::black_box(A::ENCODE[candidate as usize]),
8212+
));
8213+
decoded = core::hint::black_box(
8214+
core::hint::black_box(decoded) | core::hint::black_box(candidate & matches),
8215+
);
8216+
// SAFETY: `decoded` is an initialized local `u8`; the volatile read is
8217+
// an optimizer barrier for the fixed 64-iteration alphabet scan and
8218+
// does not access caller memory.
8219+
decoded = unsafe { core::ptr::read_volatile(&raw const decoded) };
8220+
valid =
8221+
core::hint::black_box(core::hint::black_box(valid) | core::hint::black_box(matches));
8222+
// SAFETY: `valid` is an initialized local `u8`; the volatile read is an
8223+
// optimizer barrier for the fixed 64-iteration alphabet scan and does
8224+
// not access caller memory.
8225+
valid = unsafe { core::ptr::read_volatile(&raw const valid) };
81968226
candidate += 1;
81978227
}
81988228

tests/rfc4648.rs

Lines changed: 41 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@ use base64_ng::{
1111
use base64_ng::stream::{Decoder, DecoderReader, Encoder, EncoderReader};
1212

1313
#[cfg(feature = "alloc")]
14-
use base64_ng::SecretBuffer;
14+
use base64_ng::{ExposedSecretString, ExposedSecretVec, SecretBuffer};
1515

1616
#[cfg(feature = "stream")]
1717
use std::io::{Cursor, Read, Write};
@@ -2840,6 +2840,46 @@ fn secret_buffer_from_vec_preserves_visible_bytes_with_spare_capacity() {
28402840
assert_eq!(secret.expose_secret(), b"\xff");
28412841
}
28422842

2843+
#[cfg(feature = "alloc")]
2844+
fn assert_vec_spare_capacity_zeroed(mut bytes: Vec<u8>, len: usize) {
2845+
let capacity = bytes.capacity();
2846+
assert!(capacity > len);
2847+
2848+
// SAFETY: the tested wrappers explicitly initialize spare capacity with
2849+
// zero bytes before this helper receives the vector. Extending the length
2850+
// lets the regression test inspect the spare region that would otherwise
2851+
// remain outside safe slice access.
2852+
unsafe {
2853+
bytes.set_len(capacity);
2854+
}
2855+
assert!(bytes[len..].iter().all(|byte| *byte == 0));
2856+
bytes.fill(0);
2857+
}
2858+
2859+
#[cfg(feature = "alloc")]
2860+
#[test]
2861+
fn secret_wrappers_clear_spare_capacity_at_construction() {
2862+
let mut bytes = vec![0xab; 64];
2863+
bytes.truncate(32);
2864+
let exposed = ExposedSecretVec::from_vec(bytes);
2865+
let bytes = exposed.into_exposed_unprotected_vec_caller_must_zeroize();
2866+
assert_vec_spare_capacity_zeroed(bytes, 32);
2867+
2868+
let mut bytes = vec![0xcd; 64];
2869+
bytes.truncate(32);
2870+
let secret = SecretBuffer::from_vec(bytes);
2871+
let exposed = secret.into_exposed_vec();
2872+
let bytes = exposed.into_exposed_unprotected_vec_caller_must_zeroize();
2873+
assert_vec_spare_capacity_zeroed(bytes, 32);
2874+
2875+
let mut text = String::with_capacity(64);
2876+
text.push_str("XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX");
2877+
text.truncate(32);
2878+
let exposed = ExposedSecretString::from_string(text);
2879+
let text = exposed.into_exposed_unprotected_string_caller_must_zeroize();
2880+
assert_vec_spare_capacity_zeroed(text.into_bytes(), 32);
2881+
}
2882+
28432883
#[cfg(feature = "alloc")]
28442884
#[test]
28452885
fn secret_buffer_try_from_byte_array_uses_strict_standard_base64() {

0 commit comments

Comments
 (0)