use crate::{Error, Result, otp};
#[derive(Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
pub struct EntryId([u8; Self::LEN]);
impl EntryId {
pub const LEN: usize = 16;
pub fn generate() -> Result<Self> {
let mut bytes = [0_u8; Self::LEN];
crate::getrandom::fill(&mut bytes).map_err(|_| Error::RandomnessUnavailable)?;
Ok(Self(bytes))
}
#[must_use]
pub const fn from_bytes(bytes: [u8; Self::LEN]) -> Self {
Self(bytes)
}
#[must_use]
pub const fn as_bytes(&self) -> &[u8; Self::LEN] {
&self.0
}
}
impl core::fmt::Debug for EntryId {
fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
write!(f, "EntryId({self})")
}
}
impl core::fmt::Display for EntryId {
fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
for byte in self.0 {
write!(f, "{byte:02x}")?;
}
Ok(())
}
}
pub type Timestamp = u64;
#[derive(Debug, PartialEq, Eq)]
pub enum EntryKind {
Totp {
period: u64,
},
Hotp {
counter: u64,
},
Motp {
period: u64,
pin: Option<crate::motp::MotpPin>,
},
Yandex {
period: u64,
pin: Option<crate::yaotp::YandexPin>,
},
Steam,
}
impl EntryKind {
#[must_use]
pub const fn period(&self) -> Option<u64> {
match self {
Self::Totp { period } | Self::Motp { period, .. } | Self::Yandex { period, .. } => {
Some(*period)
}
Self::Hotp { .. } | Self::Steam => None,
}
}
#[must_use]
pub const fn step_seconds(&self) -> Option<u64> {
match self {
Self::Totp { period } | Self::Motp { period, .. } | Self::Yandex { period, .. } => {
Some(*period)
}
Self::Steam => Some(otp::STEAM_PERIOD),
Self::Hotp { .. } => None,
}
}
#[must_use]
pub(crate) fn without_empty_pin(self) -> Self {
match self {
Self::Motp { period, pin } => Self::Motp {
period,
pin: pin.filter(|pin| !pin.is_empty()),
},
Self::Yandex { period, pin } => Self::Yandex {
period,
pin: pin.filter(|pin| !pin.is_empty()),
},
other => other,
}
}
#[must_use]
pub const fn motp_pin(&self) -> Option<&crate::motp::MotpPin> {
match self {
Self::Motp { pin, .. } => pin.as_ref(),
_ => None,
}
}
#[must_use]
pub const fn yandex_pin(&self) -> Option<&crate::yaotp::YandexPin> {
match self {
Self::Yandex { pin, .. } => pin.as_ref(),
_ => None,
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum IconMime {
Png,
Jpeg,
Svg,
}
impl IconMime {
#[must_use]
pub const fn media_type(self) -> &'static str {
match self {
Self::Png => "image/png",
Self::Jpeg => "image/jpeg",
Self::Svg => "image/svg+xml",
}
}
#[must_use]
pub fn from_media_type(text: &str) -> Option<Self> {
if text.eq_ignore_ascii_case("image/png") {
Some(Self::Png)
} else if text.eq_ignore_ascii_case("image/jpeg") {
Some(Self::Jpeg)
} else if text.eq_ignore_ascii_case("image/svg+xml") {
Some(Self::Svg)
} else {
None
}
}
#[must_use]
pub const fn to_byte(self) -> u8 {
match self {
Self::Png => 0x01,
Self::Jpeg => 0x02,
Self::Svg => 0x03,
}
}
#[must_use]
pub const fn from_byte(byte: u8) -> Option<Self> {
match byte {
0x01 => Some(Self::Png),
0x02 => Some(Self::Jpeg),
0x03 => Some(Self::Svg),
_ => None,
}
}
}
#[derive(Clone, PartialEq, Eq)]
pub struct EntryIcon {
pub mime: IconMime,
pub bytes: Vec<u8>,
}
impl EntryIcon {
pub const MAX_BYTES: usize = Entry::MAX_FIELD_LEN - 1;
pub fn new(mime: IconMime, bytes: Vec<u8>) -> Result<Self> {
if bytes.len() > Self::MAX_BYTES {
return Err(Error::FieldTooLong {
field: "icon",
limit: Self::MAX_BYTES,
actual: bytes.len(),
});
}
Ok(Self { mime, bytes })
}
#[must_use]
pub fn hash(&self) -> [u8; 32] {
use sha2::{Digest, Sha256};
let mut hasher = Sha256::new();
hasher.update(self.mime.media_type().as_bytes());
hasher.update(&self.bytes);
let digest = hasher.finalize();
let mut out = [0_u8; 32];
out.copy_from_slice(&digest);
out
}
}
impl core::fmt::Debug for EntryIcon {
fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
f.debug_struct("EntryIcon")
.field("mime", &self.mime)
.field("bytes", &self.bytes.len())
.finish()
}
}
#[derive(Debug)]
pub struct Entry {
pub name: String,
pub issuer: Option<String>,
pub account: String,
pub secret: otp::OtpSecret,
pub algorithm: otp::Algorithm,
pub digits: otp::Digits,
pub kind: EntryKind,
pub note: String,
pub created_at: Timestamp,
pub updated_at: Timestamp,
pub icon: Option<EntryIcon>,
}
impl PartialEq for Entry {
fn eq(&self, other: &Self) -> bool {
self.name == other.name
&& self.issuer == other.issuer
&& self.account == other.account
&& self.note == other.note
&& self.created_at == other.created_at
&& self.updated_at == other.updated_at
&& self.algorithm == other.algorithm
&& self.digits == other.digits
&& self.kind == other.kind
&& self.icon == other.icon
&& self.secret == other.secret
}
}
impl Eq for Entry {}
impl Entry {
pub fn duplicate(&self) -> Result<Self> {
let encoded = super::encode_entry(self)?;
super::decode_entry(&encoded)
}
pub const MAX_FIELD_LEN: usize = 64 * 1024;
pub const MAX_FIELDS: usize = 64;
#[expect(
clippy::too_many_arguments,
reason = "every parameter is a required field; a builder would trade compile-time guarantees for runtime defaults"
)]
pub fn new(
name: String,
issuer: Option<String>,
account: String,
secret: otp::OtpSecret,
algorithm: otp::Algorithm,
digits: otp::Digits,
kind: EntryKind,
note: String,
created_at: Timestamp,
updated_at: Timestamp,
) -> Result<Self> {
if secret.len() == 0 {
return Err(Error::SecretEmpty);
}
if kind.period() == Some(0) {
return Err(Error::InvalidPeriod);
}
let kind = kind.without_empty_pin();
for (field, value) in [
("name", Some(&name)),
("issuer", issuer.as_ref()),
("account", Some(&account)),
("note", Some(¬e)),
] {
if let Some(value) = value
&& value.len() > Self::MAX_FIELD_LEN
{
return Err(Error::FieldTooLong {
field,
limit: Self::MAX_FIELD_LEN,
actual: value.len(),
});
}
}
Ok(Self {
name,
issuer: issuer.filter(|text| !text.is_empty()),
account,
secret,
algorithm,
digits,
kind,
note,
created_at,
updated_at,
icon: None,
})
}
#[must_use]
pub fn with_icon(mut self, icon: Option<EntryIcon>) -> Self {
self.icon = icon;
self
}
pub fn set_icon(&mut self, icon: Option<EntryIcon>) {
self.icon = icon;
}
#[must_use]
pub const fn has_icon(&self) -> bool {
self.icon.is_some()
}
#[must_use]
pub const fn uri_type(&self) -> crate::uri::UriType {
crate::uri::UriType::of(&self.kind)
}
}
pub fn entry_from_parsed_uri(parsed: crate::uri::OtpAuth, now: Timestamp) -> Result<Entry> {
let name = match &parsed.issuer {
Some(issuer) => format!("{issuer}:{}", parsed.account),
None => parsed.account.clone(),
};
Entry::new(
name,
parsed.issuer,
parsed.account,
parsed.secret,
parsed.algorithm,
parsed.digits,
parsed.kind,
String::new(),
now,
now,
)
}
#[cfg(test)]
mod tests {
use super::*;
fn sample_secret() -> otp::OtpSecret {
otp::OtpSecret::from_base32("JBSWY3DPEHPK3PXP").expect("valid secret")
}
fn sample_entry() -> Entry {
Entry::new(
"GitHub".into(),
Some("GitHub".into()),
"alice".into(),
sample_secret(),
otp::Algorithm::Sha1,
otp::Digits::SIX,
EntryKind::Totp { period: 30 },
String::new(),
1_700_000_000,
1_700_000_000,
)
.expect("valid entry")
}
#[test]
fn entry_id_is_16_bytes_and_hex_displayed() {
let id = EntryId::generate().expect("OS randomness available");
assert_eq!(id.as_bytes().len(), 16);
let text = id.to_string();
assert_eq!(text.len(), 32, "hexadecimal must be 32 characters: {text}");
assert!(text.chars().all(|c| c.is_ascii_hexdigit()));
assert_eq!(format!("{id:?}"), format!("EntryId({text})"));
}
#[test]
fn entry_id_generation_does_not_repeat() {
let ids: Vec<_> = (0..64)
.map(|_| EntryId::generate().expect("OS randomness available"))
.collect();
let unique: std::collections::HashSet<_> = ids.iter().collect();
assert_eq!(unique.len(), ids.len(), "random IDs repeated");
}
#[test]
fn rejects_empty_secret() {
let empty = otp::OtpSecret::from_bytes(Vec::new());
assert!(matches!(empty, Err(Error::SecretEmpty)));
}
#[test]
fn rejects_zero_period() {
for kind in [
EntryKind::Totp { period: 0 },
EntryKind::Motp {
period: 0,
pin: None,
},
EntryKind::Yandex {
period: 0,
pin: None,
},
] {
let err = Entry::new(
"x".into(),
None,
"a".into(),
sample_secret(),
otp::Algorithm::Sha1,
otp::Digits::SIX,
kind,
String::new(),
0,
0,
);
assert!(
matches!(err, Err(Error::InvalidPeriod)),
"a zero step must be rejected"
);
}
}
#[test]
fn rejects_oversized_field() {
let err = Entry::new(
"x".repeat(Entry::MAX_FIELD_LEN + 1),
None,
"a".into(),
sample_secret(),
otp::Algorithm::Sha1,
otp::Digits::SIX,
EntryKind::Totp { period: 30 },
String::new(),
0,
0,
);
match err {
Err(Error::FieldTooLong { field, .. }) => assert_eq!(field, "name"),
other => panic!("an over-long field must be rejected, actual {other:?}"),
}
}
#[test]
fn empty_issuer_is_normalized_to_none() {
let entry = Entry::new(
"x".into(),
Some(String::new()),
"a".into(),
sample_secret(),
otp::Algorithm::Sha1,
otp::Digits::SIX,
EntryKind::Totp { period: 30 },
String::new(),
0,
0,
)
.expect("the remaining fields are valid");
assert_eq!(entry.issuer, None, "an empty issuer must normalize to None");
let blank = Entry::new(
"x".into(),
Some(" ".into()),
"a".into(),
sample_secret(),
otp::Algorithm::Sha1,
otp::Digits::SIX,
EntryKind::Totp { period: 30 },
String::new(),
0,
0,
)
.expect("the remaining fields are valid");
assert_eq!(blank.issuer.as_deref(), Some(" "));
}
#[test]
fn every_kind_maps_to_its_own_uri_type() {
assert_eq!(
sample_entry().uri_type(),
crate::uri::UriType::Totp,
"a TOTP entry"
);
let hotp = Entry::new(
"h".into(),
None,
"a".into(),
sample_secret(),
otp::Algorithm::Sha1,
otp::Digits::SIX,
EntryKind::Hotp { counter: 7 },
String::new(),
0,
0,
)
.expect("valid entry");
assert_eq!(hotp.uri_type(), crate::uri::UriType::Hotp);
assert_eq!(hotp.kind, EntryKind::Hotp { counter: 7 });
for kind in [
EntryKind::Motp {
period: 10,
pin: None,
},
EntryKind::Yandex {
period: 30,
pin: None,
},
EntryKind::Steam,
] {
let entry = Entry::new(
"k".into(),
None,
"a".into(),
sample_secret(),
otp::Algorithm::Sha1,
otp::Digits::SIX,
kind,
String::new(),
0,
0,
)
.expect("valid entry");
assert_eq!(
entry.uri_type(),
crate::uri::UriType::of(&entry.kind),
"the URI type follows the kind"
);
assert_ne!(
entry.uri_type(),
crate::uri::UriType::Totp,
"a scheme of its own must not be written out as TOTP"
);
}
}
#[test]
fn an_empty_pin_is_normalized_to_none() {
let empty = Entry::new(
"x".into(),
None,
"a".into(),
sample_secret(),
otp::Algorithm::Sha1,
otp::Digits::SIX,
EntryKind::Motp {
period: 10,
pin: Some(crate::motp::MotpPin::new(String::new())),
},
String::new(),
0,
0,
)
.expect("the remaining fields are valid");
assert_eq!(
empty.kind,
EntryKind::Motp {
period: 10,
pin: None,
},
"an empty mOTP PIN must normalize to None"
);
let empty_yandex = Entry::new(
"x".into(),
None,
"a".into(),
sample_secret(),
otp::Algorithm::Sha256,
otp::Digits::EIGHT,
EntryKind::Yandex {
period: 30,
pin: Some(crate::yaotp::YandexPin::new(String::new())),
},
String::new(),
0,
0,
)
.expect("the remaining fields are valid");
assert_eq!(
empty_yandex.kind,
EntryKind::Yandex {
period: 30,
pin: None,
},
"an empty Yandex PIN must normalize to None"
);
let with_pin = Entry::new(
"x".into(),
None,
"a".into(),
sample_secret(),
otp::Algorithm::Sha1,
otp::Digits::SIX,
EntryKind::Motp {
period: 10,
pin: Some(crate::motp::MotpPin::new("1234".to_owned())),
},
String::new(),
0,
0,
)
.expect("the remaining fields are valid");
assert_eq!(
with_pin.kind.motp_pin().map(crate::motp::MotpPin::len),
Some(4),
"a real PIN must be kept"
);
}
#[test]
fn step_seconds_covers_every_kind() {
assert_eq!(EntryKind::Totp { period: 45 }.step_seconds(), Some(45));
assert_eq!(
EntryKind::Motp {
period: 10,
pin: None
}
.step_seconds(),
Some(10)
);
assert_eq!(
EntryKind::Yandex {
period: 30,
pin: None
}
.step_seconds(),
Some(30)
);
assert_eq!(EntryKind::Steam.step_seconds(), Some(otp::STEAM_PERIOD));
assert_eq!(EntryKind::Hotp { counter: 0 }.step_seconds(), None);
assert_eq!(EntryKind::Steam.period(), None);
assert_eq!(EntryKind::Hotp { counter: 3 }.period(), None);
}
#[test]
fn pins_are_reachable_through_the_kind() {
let motp = EntryKind::Motp {
period: 10,
pin: Some(crate::motp::MotpPin::new("1234".to_owned())),
};
let yandex = EntryKind::Yandex {
period: 30,
pin: Some(crate::yaotp::YandexPin::new("7586".to_owned())),
};
assert_eq!(motp.motp_pin().map(crate::motp::MotpPin::len), Some(4));
assert_eq!(motp.yandex_pin(), None);
assert_eq!(
yandex.yandex_pin().map(crate::yaotp::YandexPin::len),
Some(4)
);
assert_eq!(yandex.motp_pin(), None);
assert_eq!(EntryKind::Steam.motp_pin(), None);
assert_eq!(EntryKind::Steam.yandex_pin(), None);
}
#[test]
fn kind_equality_distinguishes_state_that_affects_the_code() {
let base = EntryKind::Motp {
period: 10,
pin: Some(crate::motp::MotpPin::new("1234".to_owned())),
};
let other_pin = EntryKind::Motp {
period: 10,
pin: Some(crate::motp::MotpPin::new("1235".to_owned())),
};
let no_pin = EntryKind::Motp {
period: 10,
pin: None,
};
let other_period = EntryKind::Motp {
period: 20,
pin: Some(crate::motp::MotpPin::new("1234".to_owned())),
};
assert_eq!(
base,
EntryKind::Motp {
period: 10,
pin: Some(crate::motp::MotpPin::new("1234".to_owned())),
}
);
assert_ne!(base, other_pin);
assert_ne!(base, no_pin);
assert_ne!(base, other_period);
assert_ne!(base, EntryKind::Hotp { counter: 10 });
}
#[test]
fn debug_does_not_leak_secret() {
let entry = sample_entry();
let debug = format!("{entry:?}");
assert!(
debug.contains("GitHub"),
"non-secret fields must appear as usual: {debug}"
);
assert!(
debug.contains("alice"),
"non-secret fields must appear as usual: {debug}"
);
let base32 = entry.secret.to_base32();
assert!(
!debug.contains(&base32),
"Debug leaked the Base32 secret: {debug}"
);
assert!(
!debug.contains("JBSWY3DP"),
"Debug leaked the secret: {debug}"
);
}
#[test]
fn convert_from_parsed_uri() {
let parsed =
crate::uri::parse("otpauth://totp/GitHub:alice?secret=JBSWY3DPEHPK3PXP&issuer=GitHub")
.expect("valid URI");
let expected_secret = parsed.secret.to_base32();
let entry = entry_from_parsed_uri(parsed, 1_700_000_000).expect("conversion succeeds");
assert_eq!(entry.issuer.as_deref(), Some("GitHub"));
assert_eq!(entry.account, "alice");
assert_eq!(entry.name, "GitHub:alice");
assert_eq!(entry.uri_type(), crate::uri::UriType::Totp);
assert_eq!(entry.kind, EntryKind::Totp { period: 30 });
assert!(entry.note.is_empty());
assert_eq!(entry.created_at, 1_700_000_000);
assert_eq!(entry.created_at, entry.updated_at);
assert_eq!(
entry.secret.to_base32(),
expected_secret,
"the secret changed after conversion"
);
}
}