Skip to content

Commit cf1154c

Browse files
committed
Add constant-time validation APIs
1 parent 181b0af commit cf1154c

6 files changed

Lines changed: 225 additions & 6 deletions

File tree

CHANGELOG.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,8 @@
66
- Added no-alloc validation-only APIs for strict and legacy profiles:
77
`validate_result`, `validate`, `validate_legacy_result`, and
88
`validate_legacy`.
9+
- Added constant-time-oriented validation-only APIs:
10+
`ct::CtEngine::validate_result` and `ct::CtEngine::validate`.
911
- Added dependency-free line-wrapped encoding with `LineWrap`, `LineEnding`,
1012
checked wrapped-length calculation, caller-owned output APIs, clear-tail
1113
wrapping, and `alloc` convenience helpers.

README.md

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -458,10 +458,10 @@ Security commitments:
458458
untrusted length metadata should never require a panic.
459459
- Scalar encode avoids input-derived alphabet table indexes, and scalar decode
460460
uses branch-minimized arithmetic. A separate `ct` module provides a
461-
constant-time-oriented scalar decode path for callers that need a narrower
462-
timing target. Its malformed-input errors are intentionally non-localized,
463-
clear-tail variants clear caller-owned buffers on error, and it is not
464-
documented as a formally verified cryptographic constant-time API.
461+
constant-time-oriented scalar validation and decode path for callers that
462+
need a narrower timing target. Its malformed-input errors are intentionally
463+
non-localized, clear-tail variants clear caller-owned buffers on error, and
464+
it is not documented as a formally verified cryptographic constant-time API.
465465
- Clear-tail encode/decode variants are available for callers that want
466466
best-effort cleanup of unused caller-owned buffers without adding a runtime
467467
dependency.

docs/CONSTANT_TIME.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -65,6 +65,10 @@ pub mod ct {
6565
pub const URL_SAFE_NO_PAD: CtEngine<UrlSafe, false>;
6666

6767
impl<A, const PAD: bool> CtEngine<A, PAD> {
68+
pub fn validate_result(&self, input: &[u8]) -> Result<(), DecodeError>;
69+
70+
pub fn validate(&self, input: &[u8]) -> bool;
71+
6872
pub fn decode_slice(
6973
&self,
7074
input: &[u8],

docs/PLAN.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -316,7 +316,7 @@ the zero-runtime-dependency stance.
316316
symbols, padding conflicts, ASCII constraints, and deterministic errors.
317317
- Add line-wrapping encode support for PEM/MIME/common caller-selected wrapping
318318
policies, including CRLF and LF output.
319-
- Add validate-only APIs for strict, legacy, profile-aware, and
319+
- Completed validate-only APIs for strict, legacy, profile-aware, and
320320
constant-time-oriented validation use cases.
321321
- Completed zero-dependency stack-backed output helpers for short encoded
322322
values.

src/lib.rs

Lines changed: 160 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1266,7 +1266,10 @@ pub mod stream {
12661266
/// content through one opaque error. It is not documented as a formally
12671267
/// verified cryptographic constant-time API.
12681268
pub mod ct {
1269-
use super::{Alphabet, DecodeError, Standard, UrlSafe, ct_decode_in_place, ct_decode_slice};
1269+
use super::{
1270+
Alphabet, DecodeError, Standard, UrlSafe, ct_decode_in_place, ct_decode_slice,
1271+
ct_validate_decode,
1272+
};
12701273
use core::marker::PhantomData;
12711274

12721275
/// Standard Base64 constant-time-oriented decoder with padding.
@@ -1299,6 +1302,42 @@ pub mod ct {
12991302
}
13001303
}
13011304

1305+
/// Validates `input` without writing decoded bytes.
1306+
///
1307+
/// This uses the same constant-time-oriented symbol mapping and opaque
1308+
/// malformed-input error behavior as [`Self::decode_slice`]. Input
1309+
/// length, padding length, and final success or failure remain public.
1310+
///
1311+
/// # Examples
1312+
///
1313+
/// ```
1314+
/// use base64_ng::ct;
1315+
///
1316+
/// ct::STANDARD.validate_result(b"aGVsbG8=").unwrap();
1317+
/// assert!(ct::STANDARD.validate_result(b"aGVsbG8").is_err());
1318+
/// ```
1319+
pub fn validate_result(&self, input: &[u8]) -> Result<(), DecodeError> {
1320+
ct_validate_decode::<A, PAD>(input)
1321+
}
1322+
1323+
/// Returns whether `input` is valid for this constant-time-oriented
1324+
/// decoder.
1325+
///
1326+
/// This is a convenience wrapper around [`Self::validate_result`].
1327+
///
1328+
/// # Examples
1329+
///
1330+
/// ```
1331+
/// use base64_ng::ct;
1332+
///
1333+
/// assert!(ct::URL_SAFE_NO_PAD.validate(b"-_8"));
1334+
/// assert!(!ct::URL_SAFE_NO_PAD.validate(b"+/8"));
1335+
/// ```
1336+
#[must_use]
1337+
pub fn validate(&self, input: &[u8]) -> bool {
1338+
self.validate_result(input).is_ok()
1339+
}
1340+
13021341
/// Decodes `input` into `output`, returning the number of bytes
13031342
/// written.
13041343
///
@@ -4345,6 +4384,126 @@ fn ct_decode_in_place<A: Alphabet, const PAD: bool>(
43454384
}
43464385
}
43474386

4387+
fn ct_validate_decode<A: Alphabet, const PAD: bool>(input: &[u8]) -> Result<(), DecodeError> {
4388+
if input.is_empty() {
4389+
return Ok(());
4390+
}
4391+
4392+
if PAD {
4393+
ct_validate_padded::<A>(input)
4394+
} else {
4395+
ct_validate_unpadded::<A>(input)
4396+
}
4397+
}
4398+
4399+
fn ct_validate_padded<A: Alphabet>(input: &[u8]) -> Result<(), DecodeError> {
4400+
if !input.len().is_multiple_of(4) {
4401+
return Err(DecodeError::InvalidLength);
4402+
}
4403+
4404+
let padding = ct_padding_len(input);
4405+
let mut invalid_byte = 0u8;
4406+
let mut invalid_padding = 0u8;
4407+
let mut read = 0;
4408+
4409+
while read < input.len() {
4410+
let is_last = read + 4 == input.len();
4411+
let b0 = input[read];
4412+
let b1 = input[read + 1];
4413+
let b2 = input[read + 2];
4414+
let b3 = input[read + 3];
4415+
let (_, valid0) = ct_decode_ascii_base64::<A>(b0);
4416+
let (v1, valid1) = ct_decode_ascii_base64::<A>(b1);
4417+
let (v2, valid2) = ct_decode_ascii_base64::<A>(b2);
4418+
let (_, valid3) = ct_decode_ascii_base64::<A>(b3);
4419+
4420+
invalid_byte |= !valid0;
4421+
invalid_byte |= !valid1;
4422+
4423+
if is_last && padding == 2 {
4424+
invalid_padding |= ct_mask_nonzero_u8(v1 & 0b0000_1111);
4425+
} else if is_last && padding == 1 {
4426+
invalid_byte |= !valid2;
4427+
invalid_padding |= ct_mask_eq_u8(b2, b'=');
4428+
invalid_padding |= ct_mask_nonzero_u8(v2 & 0b0000_0011);
4429+
} else {
4430+
invalid_byte |= !valid2;
4431+
invalid_byte |= !valid3;
4432+
invalid_padding |= ct_mask_eq_u8(b2, b'=');
4433+
invalid_padding |= ct_mask_eq_u8(b3, b'=');
4434+
}
4435+
4436+
read += 4;
4437+
}
4438+
4439+
report_ct_error(invalid_byte, invalid_padding)
4440+
}
4441+
4442+
fn ct_validate_unpadded<A: Alphabet>(input: &[u8]) -> Result<(), DecodeError> {
4443+
if input.len() % 4 == 1 {
4444+
return Err(DecodeError::InvalidLength);
4445+
}
4446+
4447+
let mut invalid_byte = 0u8;
4448+
let mut invalid_padding = 0u8;
4449+
let mut read = 0;
4450+
4451+
while read + 4 <= input.len() {
4452+
let b0 = input[read];
4453+
let b1 = input[read + 1];
4454+
let b2 = input[read + 2];
4455+
let b3 = input[read + 3];
4456+
let (_, valid0) = ct_decode_ascii_base64::<A>(b0);
4457+
let (_, valid1) = ct_decode_ascii_base64::<A>(b1);
4458+
let (_, valid2) = ct_decode_ascii_base64::<A>(b2);
4459+
let (_, valid3) = ct_decode_ascii_base64::<A>(b3);
4460+
4461+
invalid_byte |= !valid0;
4462+
invalid_byte |= !valid1;
4463+
invalid_byte |= !valid2;
4464+
invalid_byte |= !valid3;
4465+
invalid_padding |= ct_mask_eq_u8(b0, b'=');
4466+
invalid_padding |= ct_mask_eq_u8(b1, b'=');
4467+
invalid_padding |= ct_mask_eq_u8(b2, b'=');
4468+
invalid_padding |= ct_mask_eq_u8(b3, b'=');
4469+
4470+
read += 4;
4471+
}
4472+
4473+
match input.len() - read {
4474+
0 => {}
4475+
2 => {
4476+
let b0 = input[read];
4477+
let b1 = input[read + 1];
4478+
let (_, valid0) = ct_decode_ascii_base64::<A>(b0);
4479+
let (v1, valid1) = ct_decode_ascii_base64::<A>(b1);
4480+
invalid_byte |= !valid0;
4481+
invalid_byte |= !valid1;
4482+
invalid_padding |= ct_mask_eq_u8(b0, b'=');
4483+
invalid_padding |= ct_mask_eq_u8(b1, b'=');
4484+
invalid_padding |= ct_mask_nonzero_u8(v1 & 0b0000_1111);
4485+
}
4486+
3 => {
4487+
let b0 = input[read];
4488+
let b1 = input[read + 1];
4489+
let b2 = input[read + 2];
4490+
let (_, valid0) = ct_decode_ascii_base64::<A>(b0);
4491+
let (_, valid1) = ct_decode_ascii_base64::<A>(b1);
4492+
let (v2, valid2) = ct_decode_ascii_base64::<A>(b2);
4493+
invalid_byte |= !valid0;
4494+
invalid_byte |= !valid1;
4495+
invalid_byte |= !valid2;
4496+
invalid_padding |= ct_mask_eq_u8(b0, b'=');
4497+
invalid_padding |= ct_mask_eq_u8(b1, b'=');
4498+
invalid_padding |= ct_mask_eq_u8(b2, b'=');
4499+
invalid_padding |= ct_mask_nonzero_u8(v2 & 0b0000_0011);
4500+
}
4501+
_ => return Err(DecodeError::InvalidLength),
4502+
}
4503+
4504+
report_ct_error(invalid_byte, invalid_padding)
4505+
}
4506+
43484507
fn ct_decode_padded<A: Alphabet>(input: &[u8], output: &mut [u8]) -> Result<usize, DecodeError> {
43494508
if !input.len().is_multiple_of(4) {
43504509
return Err(DecodeError::InvalidLength);

tests/rfc4648.rs

Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -438,6 +438,60 @@ fn ct_decoder_matches_strict_for_canonical_inputs() {
438438
}
439439
}
440440

441+
#[test]
442+
fn ct_validate_matches_constant_time_decode_policy() {
443+
for input_len in 0..64 {
444+
let mut input = [0u8; 64];
445+
for (index, byte) in input.iter_mut().enumerate() {
446+
*byte = (index * 17 + input_len * 7) as u8;
447+
}
448+
let input = &input[..input_len];
449+
450+
let mut encoded_buf = [0u8; 128];
451+
452+
let encoded_len = STANDARD.encode_slice(input, &mut encoded_buf).unwrap();
453+
let encoded = &encoded_buf[..encoded_len];
454+
assert_eq!(ct::STANDARD.validate_result(encoded), Ok(()));
455+
assert!(ct::STANDARD.validate(encoded));
456+
457+
let encoded_len = STANDARD_NO_PAD
458+
.encode_slice(input, &mut encoded_buf)
459+
.unwrap();
460+
let encoded = &encoded_buf[..encoded_len];
461+
assert_eq!(ct::STANDARD_NO_PAD.validate_result(encoded), Ok(()));
462+
assert!(ct::STANDARD_NO_PAD.validate(encoded));
463+
464+
let encoded_len = URL_SAFE.encode_slice(input, &mut encoded_buf).unwrap();
465+
let encoded = &encoded_buf[..encoded_len];
466+
assert_eq!(ct::URL_SAFE.validate_result(encoded), Ok(()));
467+
assert!(ct::URL_SAFE.validate(encoded));
468+
469+
let encoded_len = URL_SAFE_NO_PAD
470+
.encode_slice(input, &mut encoded_buf)
471+
.unwrap();
472+
let encoded = &encoded_buf[..encoded_len];
473+
assert_eq!(ct::URL_SAFE_NO_PAD.validate_result(encoded), Ok(()));
474+
assert!(ct::URL_SAFE_NO_PAD.validate(encoded));
475+
}
476+
477+
assert_eq!(
478+
ct::STANDARD.validate_result(b"AA-A"),
479+
Err(DecodeError::InvalidInput)
480+
);
481+
assert_eq!(
482+
ct::STANDARD.validate_result(b"Zh=="),
483+
Err(DecodeError::InvalidInput)
484+
);
485+
assert_eq!(
486+
ct::STANDARD_NO_PAD.validate_result(b"Zg=="),
487+
Err(DecodeError::InvalidInput)
488+
);
489+
assert_eq!(
490+
ct::STANDARD.validate_result(b"Zg"),
491+
Err(DecodeError::InvalidLength)
492+
);
493+
}
494+
441495
#[test]
442496
fn ct_decoder_rejects_malformed_inputs() {
443497
let mut output = [0u8; 8];

0 commit comments

Comments
 (0)