use hmac::digest::{Digest, KeyInit, Mac};
use sha2::Sha256;
use subtle::ConstantTimeEq;
use crate::{Error, ExposeSecret, Result, otp::OtpSecret, secret::SecretString};
pub const PERIOD: u64 = 30;
pub const CODE_LEN: usize = 8;
pub const SECRET_LEN: usize = 16;
pub const SECRET_FULL_LEN: usize = 26;
const ALPHABET_LEN: u64 = 26;
const CHECKSUM_BITS: usize = 12;
const CHECKSUM_POLYNOMIAL: u16 = 0x18f3;
const CHECKSUM_POLYNOMIAL_LOW: u16 = CHECKSUM_POLYNOMIAL & 0x0fff;
fn secret_checksum(secret: &[u8]) -> u16 {
let data_bits = secret.len() * 8 - CHECKSUM_BITS;
let mut register = 0_u16;
for index in 0..data_bits {
let bit = u16::from((secret[index / 8] >> (7 - index % 8)) & 1);
let overflow = (register >> (CHECKSUM_BITS - 1)) & 1;
register = ((register << 1) | bit) & 0x0fff;
if overflow == 1 {
register ^= CHECKSUM_POLYNOMIAL_LOW;
}
}
register
}
fn stored_checksum(secret: &[u8]) -> u16 {
(u16::from(secret[secret.len() - 2] & 0x0f) << 8) | u16::from(secret[secret.len() - 1])
}
pub struct YandexPin {
pin: SecretString,
}
impl YandexPin {
#[must_use]
pub fn new(pin: String) -> Self {
Self { pin: pin.into() }
}
pub(crate) fn expose(&self) -> &str {
self.pin.expose_secret()
}
#[must_use]
pub fn len(&self) -> usize {
self.expose().len()
}
#[must_use]
pub fn is_empty(&self) -> bool {
self.expose().is_empty()
}
}
impl core::fmt::Debug for YandexPin {
fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
f.debug_struct("YandexPin")
.field("len", &self.len())
.field("pin", &"<redacted>")
.finish()
}
}
impl PartialEq for YandexPin {
fn eq(&self, other: &Self) -> bool {
self.expose()
.as_bytes()
.ct_eq(other.expose().as_bytes())
.into()
}
}
impl Eq for YandexPin {}
pub fn normalize_secret(secret: &[u8]) -> Result<OtpSecret> {
match secret.len() {
SECRET_LEN => OtpSecret::from_bytes(secret.to_vec()),
SECRET_FULL_LEN => {
if secret_checksum(secret) != stored_checksum(secret) {
return Err(Error::YandexSecretChecksum);
}
OtpSecret::from_bytes(secret[..SECRET_LEN].to_vec())
}
len => Err(Error::YandexSecretLength { len }),
}
}
pub fn yaotp_code(
secret: &OtpSecret,
pin: Option<&YandexPin>,
period: u64,
unix_time: u64,
) -> Result<String> {
if period == 0 {
return Err(Error::InvalidPeriod);
}
let Some(pin) = pin else {
return Err(Error::PinRequired {
otp_type: "Yandex OTP",
});
};
let key_hash = {
let mut hasher = Sha256::new();
hasher.update(pin.expose().as_bytes());
hasher.update(secret.expose());
hasher.finalize()
};
let key: &[u8] = if key_hash[0] == 0 {
&key_hash[1..]
} else {
&key_hash[..]
};
let counter = unix_time / period;
let mut mac = <hmac::SimpleHmac<Sha256> as KeyInit>::new_from_slice(key)
.expect("HMAC accepts a key of any length");
Mac::update(&mut mac, &counter.to_be_bytes());
let hash = Mac::finalize(mac).into_bytes();
let offset = usize::from(hash[hash.len() - 1] & 0x0f);
let mut truncated = [0_u8; 8];
truncated.copy_from_slice(
hash.get(offset..offset + 8)
.expect("a SHA-256 digest has 32 bytes and the offset is at most 15"),
);
truncated[0] &= 0x7f;
let mut value = u64::from_be_bytes(truncated);
let mut code = [0_u8; CODE_LEN];
for slot in code.iter_mut().rev() {
*slot = b'a'
+ u8::try_from(value % ALPHABET_LEN).expect("a remainder below 26 always fits in u8");
value /= ALPHABET_LEN;
}
Ok(String::from_utf8(code.to_vec()).expect("the alphabet is ASCII"))
}
#[cfg(test)]
mod tests {
use super::*;
use proptest::prelude::*;
const VECTORS: [(&str, &str, u64, &str); 5] = [
(
"5239",
"6SB2IKNM6OBZPAVBVTOHDKS4FAAAAAAADFUTQMBTRY",
1_641_559_648,
"umozdicq",
),
(
"7586",
"LA2V6KMCGYMWWVEW64RNP3JA3IAAAAAAHTSG4HRZPI",
1_581_064_020,
"oactmacq",
),
(
"7586",
"LA2V6KMCGYMWWVEW64RNP3JA3IAAAAAAHTSG4HRZPI",
1_581_090_810,
"wemdwrix",
),
(
"5210481216086702",
"JBGSAU4G7IEZG6OY4UAXX62JU4AAAAAAHTSG4HXU3M",
1_581_091_469,
"dfrpywob",
),
(
"5210481216086702",
"JBGSAU4G7IEZG6OY4UAXX62JU4AAAAAAHTSG4HXU3M",
1_581_093_059,
"vunyprpd",
),
];
const ZERO_PREFIX_SECRET: &str = "6a87d52d0afb0baab93811cd80dcff03";
const ZERO_PREFIX_PIN: &str = "0";
const ZERO_PREFIX_CODE: &str = "khfjdkeg";
const ZERO_PREFIX_TIME: u64 = 1_700_000_000;
fn secret_from_base32(encoded: &str) -> OtpSecret {
let decoded = crate::base32::decode(encoded).expect("a valid Base32 secret");
normalize_secret(decoded.expose_secret()).expect("a valid Yandex secret length")
}
fn unhex(text: &str) -> Vec<u8> {
assert!(
text.len().is_multiple_of(2),
"hex input must have an even length"
);
text.as_bytes()
.chunks(2)
.map(|pair| {
let text = core::str::from_utf8(pair).expect("hex is ASCII");
u8::from_str_radix(text, 16).expect("hex digits")
})
.collect()
}
fn reference_code(secret: &[u8], pin: &str, unix_time: u64) -> String {
const BLOCK: usize = 64;
fn sha256(data: &[u8]) -> [u8; 32] {
let digest = Sha256::digest(data);
let mut out = [0_u8; 32];
out.copy_from_slice(&digest);
out
}
let mut key_input = Vec::with_capacity(pin.len() + secret.len());
key_input.extend_from_slice(pin.as_bytes());
key_input.extend_from_slice(secret);
let digest = sha256(&key_input);
let key: &[u8] = if digest[0] == 0 {
&digest[1..]
} else {
&digest
};
let mut padded = [0_u8; BLOCK];
if key.len() <= BLOCK {
padded[..key.len()].copy_from_slice(key);
} else {
padded[..32].copy_from_slice(&sha256(key));
}
let mut inner = Vec::with_capacity(BLOCK + 8);
inner.extend(padded.iter().map(|byte| byte ^ 0x36));
inner.extend_from_slice(&(unix_time / PERIOD).to_be_bytes());
let inner_hash = sha256(&inner);
let mut outer = Vec::with_capacity(BLOCK + 32);
outer.extend(padded.iter().map(|byte| byte ^ 0x5c));
outer.extend_from_slice(&inner_hash);
let hash = sha256(&outer);
let offset = usize::from(hash[31] & 0x0f);
let mut eight = [0_u8; 8];
eight.copy_from_slice(&hash[offset..offset + 8]);
eight[0] &= 0x7f;
let mut value = u64::from_be_bytes(eight);
let mut letters = Vec::with_capacity(CODE_LEN);
for _ in 0..CODE_LEN {
letters.push(b'a' + (value % ALPHABET_LEN) as u8);
value /= ALPHABET_LEN;
}
letters.reverse();
String::from_utf8(letters).expect("ASCII")
}
#[test]
fn vectors_from_the_reference_implementation() {
for (pin, secret_base32, time, expected) in VECTORS {
let secret = secret_from_base32(secret_base32);
let pin = YandexPin::new(pin.to_owned());
let code = yaotp_code(&secret, Some(&pin), PERIOD, time).expect("valid inputs");
assert_eq!(
code, expected,
"pin={pin:?} secret={secret_base32} time={time}"
);
}
}
#[test]
fn agrees_with_the_independent_implementation() {
let secret = unhex("58355f298236196b5496f722d7ed20da");
for time in [
0,
1,
29,
30,
1_581_064_020,
1_581_090_810,
1_700_000_000,
4_102_444_800,
] {
for pin in ["", "0", "7586", "5210481216086702", "pin with spaces"] {
let ours = yaotp_code(
&OtpSecret::from_bytes(secret.clone()).expect("a secret"),
Some(&YandexPin::new(pin.to_owned())),
PERIOD,
time,
)
.expect("valid inputs");
assert_eq!(
ours,
reference_code(&secret, pin, time),
"pin={pin:?} time={time}"
);
}
}
}
#[test]
fn zero_prefixed_key_hash_drops_the_first_byte() {
let secret = OtpSecret::from_bytes(unhex(ZERO_PREFIX_SECRET)).expect("a secret");
let pin = YandexPin::new(ZERO_PREFIX_PIN.to_owned());
let mut hasher = Sha256::new();
hasher.update(ZERO_PREFIX_PIN.as_bytes());
hasher.update(secret.expose());
assert_eq!(
hasher.finalize()[0],
0,
"this vector must exercise the zero-prefix branch"
);
let code = yaotp_code(&secret, Some(&pin), PERIOD, ZERO_PREFIX_TIME).expect("valid");
assert_eq!(code, ZERO_PREFIX_CODE);
assert_eq!(
code,
reference_code(
&unhex(ZERO_PREFIX_SECRET),
ZERO_PREFIX_PIN,
ZERO_PREFIX_TIME
)
);
}
#[test]
fn both_secret_forms_normalise_to_sixteen_bytes() {
let short = secret_from_base32("LA2V6KMCGYMWWVEW64RNP3JA3I");
let long = secret_from_base32("LA2V6KMCGYMWWVEW64RNP3JA3IAAAAAAHTSG4HRZPI");
assert_eq!(short.len(), SECRET_LEN);
assert_eq!(long.len(), SECRET_LEN);
assert_eq!(short.expose(), long.expose());
assert_eq!(long.to_base32(), "LA2V6KMCGYMWWVEW64RNP3JA3I");
}
#[test]
fn other_secret_lengths_are_rejected() {
for len in [0_usize, 1, 15, 17, 25, 27, 32] {
let bytes = vec![0x42_u8; len];
assert!(
matches!(
normalize_secret(&bytes),
Err(Error::YandexSecretLength { len: reported }) if reported == len
),
"length {len} must be rejected"
);
}
}
#[test]
fn published_secret_samples_are_accepted_or_rejected() {
assert_eq!(
secret_from_base32("LA2V6KMCGYMWWVEW64RNP3JA3IAAAAAAHTSG4HRZPI").len(),
SECRET_LEN
);
assert_eq!(
secret_from_base32("LA2V6KMCGYMWWVEW64RNP3JA3I").len(),
SECRET_LEN
);
let corrupted =
crate::base32::decode("AA2V6KMCGYMWWVEW64RNP3JA3IAAAAAAHTSG4HRZPI").expect("Base32");
assert!(matches!(
normalize_secret(corrupted.expose_secret()),
Err(Error::YandexSecretChecksum)
));
let wrong_length = crate::base32::decode("AA2V6KMCGJA3IAAAAAAHTSG4HRZPI").expect("Base32");
assert!(matches!(
normalize_secret(wrong_length.expose_secret()),
Err(Error::YandexSecretLength { len: 18 })
));
}
#[test]
fn checksum_values_of_the_published_samples() {
for (encoded, expected) in [
("LA2V6KMCGYMWWVEW64RNP3JA3IAAAAAAHTSG4HRZPI", 0x097a_u16),
("6SB2IKNM6OBZPAVBVTOHDKS4FAAAAAAADFUTQMBTRY", 0x038e),
("JBGSAU4G7IEZG6OY4UAXX62JU4AAAAAAHTSG4HXU3M", 0x04db),
] {
let raw = crate::base32::decode(encoded).expect("Base32");
let raw = raw.expose_secret();
assert_eq!(raw.len(), SECRET_FULL_LEN, "{encoded}");
assert_eq!(secret_checksum(raw), expected, "{encoded}");
assert_eq!(stored_checksum(raw), expected, "{encoded}");
}
}
#[test]
fn every_single_bit_flip_in_the_data_is_detected() {
let raw =
crate::base32::decode("LA2V6KMCGYMWWVEW64RNP3JA3IAAAAAAHTSG4HRZPI").expect("Base32");
let original = raw.expose_secret().to_vec();
assert!(normalize_secret(&original).is_ok());
for bit in 0..(original.len() * 8 - CHECKSUM_BITS) {
let mut damaged = original.clone();
damaged[bit / 8] ^= 1 << (7 - bit % 8);
assert!(
matches!(normalize_secret(&damaged), Err(Error::YandexSecretChecksum)),
"flipping data bit {bit} was not detected"
);
}
}
#[test]
fn missing_pin_is_rejected_and_empty_pin_is_not() {
let secret = secret_from_base32("LA2V6KMCGYMWWVEW64RNP3JA3I");
assert!(matches!(
yaotp_code(&secret, None, PERIOD, 1_700_000_000),
Err(Error::PinRequired {
otp_type: "Yandex OTP"
})
));
let empty = YandexPin::new(String::new());
let code = yaotp_code(&secret, Some(&empty), PERIOD, 1_700_000_000).expect("valid inputs");
assert_eq!(code.len(), CODE_LEN);
}
#[test]
fn zero_period_is_rejected() {
let secret = secret_from_base32("LA2V6KMCGYMWWVEW64RNP3JA3I");
let pin = YandexPin::new("7586".to_owned());
assert!(matches!(
yaotp_code(&secret, Some(&pin), 0, 1_700_000_000),
Err(Error::InvalidPeriod)
));
}
#[test]
fn code_shape_is_eight_lower_case_letters() {
let secret = secret_from_base32("LA2V6KMCGYMWWVEW64RNP3JA3I");
let pin = YandexPin::new("7586".to_owned());
for time in (1_600_000_000..1_600_000_000 + 100 * PERIOD).step_by(PERIOD as usize) {
let code = yaotp_code(&secret, Some(&pin), PERIOD, time).expect("valid inputs");
assert_eq!(code.len(), CODE_LEN, "time={time}");
assert!(
code.bytes().all(|b| b.is_ascii_lowercase()),
"not a lower-case letter code: {code}"
);
}
}
#[test]
fn secret_and_pin_both_affect_the_code() {
let secret = secret_from_base32("LA2V6KMCGYMWWVEW64RNP3JA3I");
let other = OtpSecret::from_bytes(unhex("58355f298236196b5496f722d7ed20db"))
.expect("a non-empty secret");
let pin = YandexPin::new("7586".to_owned());
let other_pin = YandexPin::new("7587".to_owned());
let base = yaotp_code(&secret, Some(&pin), PERIOD, 1_700_000_000).expect("valid");
assert_eq!(
base,
yaotp_code(&secret, Some(&pin), PERIOD, 1_700_000_000).expect("valid")
);
assert_ne!(
base,
yaotp_code(&other, Some(&pin), PERIOD, 1_700_000_000).expect("valid")
);
assert_ne!(
base,
yaotp_code(&secret, Some(&other_pin), PERIOD, 1_700_000_000).expect("valid")
);
}
#[test]
fn trailing_base32_padding_bits_do_not_change_the_secret() {
let padded = secret_from_base32("LA2V6KMCGYMWWVEW64RNP3JA3I");
let other_padding = secret_from_base32("LA2V6KMCGYMWWVEW64RNP3JA3J");
assert_eq!(padded.expose(), other_padding.expose());
let meaningful = secret_from_base32("LA2V6KMCGYMWWVEW64RNP3JA3A");
assert_ne!(padded.expose(), meaningful.expose());
}
#[test]
fn debug_never_leaks_the_pin() {
let pin = YandexPin::new("5210481216086702".to_owned());
let debug = format!("{pin:?}");
assert!(
debug.contains("redacted"),
"not marked as redacted: {debug}"
);
for leak in ["5210481216086702", "5210", "6702"] {
assert!(!debug.contains(leak), "Debug leaks the PIN: {debug}");
}
}
#[test]
fn pin_equality() {
let a = YandexPin::new("7586".to_owned());
let b = YandexPin::new("7586".to_owned());
let c = YandexPin::new("7587".to_owned());
assert_eq!(a, b);
assert_ne!(a, c);
assert_eq!(YandexPin::new(String::new()).len(), 0);
assert!(YandexPin::new(String::new()).is_empty());
}
proptest! {
#[test]
fn agrees_with_the_independent_implementation_for_arbitrary_inputs(
secret in proptest::collection::vec(any::<u8>(), SECRET_LEN),
pin in "[ -~]{0,24}",
time in any::<u64>(),
) {
let code = yaotp_code(
&OtpSecret::from_bytes(secret.clone()).expect("a non-empty secret"),
Some(&YandexPin::new(pin.clone())),
PERIOD,
time,
)
.expect("valid inputs");
prop_assert_eq!(&code, &reference_code(&secret, &pin, time));
prop_assert_eq!(code.len(), CODE_LEN);
prop_assert!(code.bytes().all(|byte| byte.is_ascii_lowercase()));
}
}
}