use sha1::{Digest, Sha1};
use crate::drbg::otp::{compute_hmac, HashAlgorithm};
use crate::error::{CryptoError, Result};
pub const OCRA_MIN_KEY_LEN: usize = 16;
pub const OCRA_MIN_DIGITS: u32 = 4;
pub const OCRA_MAX_DIGITS: u32 = 10;
const SHA1_OUTPUT_LEN: usize = 20;
#[derive(Clone, Copy)]
pub struct OcraRequest<'a> {
pub counter: u64,
pub challenge: &'a [u8],
pub password: Option<&'a [u8]>,
pub session: &'a [u8],
pub timestamp: Option<u64>,
}
impl<'a> std::fmt::Debug for OcraRequest<'a> {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("OcraRequest")
.field("counter", &self.counter)
.field("challenge_len", &self.challenge.len())
.field("password", &match self.password {
Some(_) => "Some(<redacted>)",
None => "None",
})
.field("session_len", &self.session.len())
.field("timestamp", &self.timestamp)
.finish()
}
}
#[derive(Clone, Copy, PartialEq, Eq)]
pub struct OcraResponse {
pub value: u64,
pub digits: u32,
}
impl std::fmt::Debug for OcraResponse {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("OcraResponse")
.field("value", &format!("<{} digits redacted>", self.digits))
.field("digits", &self.digits)
.finish()
}
}
impl OcraResponse {
pub fn format_code(&self) -> String {
format!("{:0>width$}", self.value, width = self.digits as usize)
}
pub fn verify(&self, expected: &OcraResponse) -> bool {
if self.digits != expected.digits {
return false;
}
use subtle::ConstantTimeEq;
let a = self.value.to_be_bytes();
let b = expected.value.to_be_bytes();
a.ct_eq(&b).into()
}
}
pub fn ocra(
key: &[u8],
req: &OcraRequest<'_>,
digits: u32,
algo: HashAlgorithm,
) -> Result<OcraResponse> {
if !(OCRA_MIN_DIGITS..=OCRA_MAX_DIGITS).contains(&digits) {
return Err(CryptoError::InvalidParameter(format!(
"OCRA digits {digits} out of range {}..={}",
OCRA_MIN_DIGITS, OCRA_MAX_DIGITS
)));
}
if key.len() < OCRA_MIN_KEY_LEN {
return Err(CryptoError::InvalidKeyLength {
algorithm: "OCRA",
expected: OCRA_MIN_KEY_LEN,
got: key.len(),
});
}
let mut message: Vec<u8> =
Vec::with_capacity(8 + SHA1_OUTPUT_LEN + SHA1_OUTPUT_LEN + req.session.len() + 8);
message.extend_from_slice(&req.counter.to_be_bytes());
let mut q_hasher = Sha1::new();
q_hasher.update(req.challenge);
let q_hash = q_hasher.finalize();
message.extend_from_slice(&q_hash);
let mut p_buffer = [0u8; SHA1_OUTPUT_LEN];
if let Some(pw) = req.password {
let mut p_hasher = Sha1::new();
p_hasher.update(pw);
let p_hash = p_hasher.finalize();
p_buffer.copy_from_slice(&p_hash);
}
message.extend_from_slice(&p_buffer);
message.extend_from_slice(req.session);
match req.timestamp {
Some(ts) => message.extend_from_slice(&ts.to_be_bytes()),
None => message.extend_from_slice(&[0u8; 8]),
}
let hmac_result = compute_hmac(key, &message, algo);
if hmac_result.len() < 4 {
return Err(CryptoError::InvalidParameter(
"OCRA HMAC output too short".into(),
));
}
let offset = (hmac_result[hmac_result.len() - 1] & 0x0F) as usize;
let truncated_u32 = u32::from_be_bytes([
hmac_result[offset],
hmac_result[offset + 1],
hmac_result[offset + 2],
hmac_result[offset + 3],
]) & 0x7FFF_FFFF_u32;
let value = (truncated_u32 as u64) % 10u64.pow(digits);
Ok(OcraResponse { value, digits })
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn rfc6287_a1_qn08_sha1_canonical_196958() {
let key = b"12345678901234567890";
let req = OcraRequest {
counter: 0,
challenge: b"00000000",
password: None,
session: b"",
timestamp: None,
};
let resp = ocra(key, &req, 6, HashAlgorithm::Sha1).expect("ocra");
assert_eq!(
resp.value, 196_958,
"OCRA RFC 6287 \u{00a7}A.1 Test #1 expected 196958; got {}. \
If the value differs, verify against the published RFC text \
before shipping.",
resp.value
);
assert_eq!(resp.format_code(), "196958");
}
#[test]
fn sha256_deterministic_same_input_same_output() {
let key = b"a-key-with-32-bytes-for-sha256!!!!"; let req = OcraRequest {
counter: 0,
challenge: b"12345678",
password: None,
session: b"",
timestamp: None,
};
let a = ocra(key, &req, 8, HashAlgorithm::Sha256).unwrap();
let b = ocra(key, &req, 8, HashAlgorithm::Sha256).unwrap();
assert_eq!(a, b);
}
#[test]
fn sha512_deterministic_same_input_same_output() {
let key = b"a-64-byte-key-for-sha512-pad-pad-pad-pad-pad-pad-pad0"; let req = OcraRequest {
counter: 0,
challenge: b"12345678",
password: None,
session: b"",
timestamp: None,
};
let a = ocra(key, &req, 10, HashAlgorithm::Sha512).unwrap();
let b = ocra(key, &req, 10, HashAlgorithm::Sha512).unwrap();
assert_eq!(a, b);
assert!(a.value <= 9_999_999_999);
}
#[test]
fn counter_change_changes_response_sha1() {
let key = b"12345678901234567890";
let req0 = OcraRequest {
counter: 0,
challenge: b"12345678",
password: None,
session: b"",
timestamp: None,
};
let req1 = OcraRequest {
counter: 1,
..req0
};
let r0 = ocra(key, &req0, 6, HashAlgorithm::Sha1).unwrap();
let r1 = ocra(key, &req1, 6, HashAlgorithm::Sha1).unwrap();
assert_ne!(
r0.value, r1.value,
"counter=0 vs counter=1 produced the same response; OCRA counter input is broken"
);
}
#[test]
fn challenge_change_changes_response_sha1() {
let key = b"12345678901234567890";
let req_zero = OcraRequest {
counter: 0,
challenge: b"00000000",
password: None,
session: b"",
timestamp: None,
};
let req_alt = OcraRequest {
challenge: b"12345678",
..req_zero
};
let r0 = ocra(key, &req_zero, 6, HashAlgorithm::Sha1).unwrap();
let r1 = ocra(key, &req_alt, 6, HashAlgorithm::Sha1).unwrap();
assert_ne!(r0.value, r1.value, "challenge change did not change response");
}
#[test]
fn timestamp_change_changes_response_sha1() {
let key = b"12345678901234567890";
let req_no_ts = OcraRequest {
counter: 0,
challenge: b"12345678",
password: None,
session: b"",
timestamp: None,
};
let req_with_ts = OcraRequest {
timestamp: Some(1_700_000_000),
..req_no_ts
};
let r0 = ocra(key, &req_no_ts, 6, HashAlgorithm::Sha1).unwrap();
let r1 = ocra(key, &req_with_ts, 6, HashAlgorithm::Sha1).unwrap();
assert_ne!(r0.value, r1.value, "timestamp change did not change response");
}
#[test]
fn password_change_changes_response_sha1() {
let key = b"12345678901234567890";
let no_pw = OcraRequest {
counter: 0,
challenge: b"12345678",
password: None,
session: b"",
timestamp: Some(1_700_000_000),
};
let with_pw = OcraRequest {
password: Some(b"1234"),
..no_pw
};
let r0 = ocra(key, &no_pw, 6, HashAlgorithm::Sha1).unwrap();
let r1 = ocra(key, &with_pw, 6, HashAlgorithm::Sha1).unwrap();
assert_ne!(r0.value, r1.value, "password change did not change response");
}
#[test]
fn session_change_changes_response_sha1() {
let key = b"12345678901234567890";
let no_session = OcraRequest {
counter: 0,
challenge: b"12345678",
password: None,
session: b"",
timestamp: None,
};
let with_session = OcraRequest {
session: b"0001",
..no_session
};
let r0 = ocra(key, &no_session, 6, HashAlgorithm::Sha1).unwrap();
let r1 = ocra(key, &with_session, 6, HashAlgorithm::Sha1).unwrap();
assert_ne!(r0.value, r1.value, "session change did not change response");
}
#[test]
fn digits_out_of_range_rejected() {
let key = b"12345678901234567890";
let req = OcraRequest {
counter: 0,
challenge: b"00000000",
password: None,
session: b"",
timestamp: None,
};
assert!(matches!(
ocra(key, &req, 3, HashAlgorithm::Sha1),
Err(CryptoError::InvalidParameter(_))
));
assert!(matches!(
ocra(key, &req, 11, HashAlgorithm::Sha1),
Err(CryptoError::InvalidParameter(_))
));
}
#[test]
fn key_too_short_rejected() {
let key = b"short"; let req = OcraRequest {
counter: 0,
challenge: b"00000000",
password: None,
session: b"",
timestamp: None,
};
assert!(matches!(
ocra(key, &req, 6, HashAlgorithm::Sha1),
Err(CryptoError::InvalidKeyLength {
algorithm: "OCRA",
expected: 16,
got: 5,
})
));
}
#[test]
fn verify_constant_time_match_and_mismatch() {
let key = b"12345678901234567890";
let req = OcraRequest {
counter: 0,
challenge: b"00000000",
password: None,
session: b"",
timestamp: None,
};
let resp = ocra(key, &req, 6, HashAlgorithm::Sha1).unwrap();
assert!(resp.verify(&resp), "self-match must verify");
let diff_value = OcraResponse {
value: resp.value.wrapping_add(1),
digits: resp.digits,
};
assert!(!resp.verify(&diff_value), "different value must fail");
let diff_digits = OcraResponse {
value: resp.value,
digits: resp.digits + 1,
};
assert!(
!resp.verify(&diff_digits),
"different digit count must fail"
);
}
}