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)]
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 period: u64,
pub counter: Option<u64>,
pub note: String,
pub created_at: Timestamp,
pub updated_at: Timestamp,
}
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.period == other.period
&& self.counter == other.counter
&& 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 = "全部参数都是必填字段;改用 builder 会把编译期保证换成运行时默认值"
)]
pub fn new(
name: String,
issuer: Option<String>,
account: String,
secret: otp::OtpSecret,
algorithm: otp::Algorithm,
digits: otp::Digits,
period: u64,
counter: Option<u64>,
note: String,
created_at: Timestamp,
updated_at: Timestamp,
) -> Result<Self> {
if secret.len() == 0 {
return Err(Error::SecretEmpty);
}
if period == 0 {
return Err(Error::InvalidPeriod);
}
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,
period,
counter,
note,
created_at,
updated_at,
})
}
#[must_use]
pub const fn uri_type(&self) -> crate::uri::UriType {
if self.counter.is_some() {
crate::uri::UriType::Hotp
} else {
crate::uri::UriType::Totp
}
}
}
pub fn entry_from_parsed_uri(parsed: &crate::uri::OtpAuth, now: Timestamp) -> Result<Entry> {
let secret = otp::OtpSecret::from_base32(&parsed.secret.to_base32())?;
let name = match &parsed.issuer {
Some(issuer) => format!("{issuer}:{}", parsed.account),
None => parsed.account.clone(),
};
Entry::new(
name,
parsed.issuer.clone(),
parsed.account.clone(),
secret,
parsed.algorithm,
parsed.digits,
parsed.period,
parsed.counter,
String::new(),
now,
now,
)
}
#[cfg(test)]
mod tests {
use super::*;
fn sample_secret() -> otp::OtpSecret {
otp::OtpSecret::from_base32("JBSWY3DPEHPK3PXP").expect("合法种子")
}
fn sample_entry() -> Entry {
Entry::new(
"GitHub".into(),
Some("GitHub".into()),
"alice".into(),
sample_secret(),
otp::Algorithm::Sha1,
otp::Digits::Six,
30,
None,
String::new(),
1_700_000_000,
1_700_000_000,
)
.expect("合法条目")
}
#[test]
fn entry_id_is_16_bytes_and_hex_displayed() {
let id = EntryId::generate().expect("OS 随机源可用");
assert_eq!(id.as_bytes().len(), 16);
let text = id.to_string();
assert_eq!(text.len(), 32, "十六进制应为 32 字符:{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 随机源可用"))
.collect();
let unique: std::collections::HashSet<_> = ids.iter().collect();
assert_eq!(unique.len(), ids.len(), "随机 ID 出现重复");
}
#[test]
fn rejects_empty_secret() {
let empty = otp::OtpSecret::from_bytes(Vec::new());
assert!(matches!(empty, Err(Error::SecretEmpty)));
}
#[test]
fn rejects_zero_period() {
let err = Entry::new(
"x".into(),
None,
"a".into(),
sample_secret(),
otp::Algorithm::Sha1,
otp::Digits::Six,
0,
None,
String::new(),
0,
0,
);
assert!(matches!(err, Err(Error::InvalidPeriod)));
}
#[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,
30,
None,
String::new(),
0,
0,
);
match err {
Err(Error::FieldTooLong { field, .. }) => assert_eq!(field, "name"),
other => panic!("应拒绝超长字段,实际 {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,
30,
None,
String::new(),
0,
0,
)
.expect("其余字段合法");
assert_eq!(entry.issuer, None, "空发行方应归一为 None");
let blank = Entry::new(
"x".into(),
Some(" ".into()),
"a".into(),
sample_secret(),
otp::Algorithm::Sha1,
otp::Digits::Six,
30,
None,
String::new(),
0,
0,
)
.expect("其余字段合法");
assert_eq!(blank.issuer.as_deref(), Some(" "));
}
#[test]
fn uri_type_follows_counter_presence() {
let totp = sample_entry();
assert_eq!(totp.uri_type(), crate::uri::UriType::Totp);
let hotp = Entry::new(
"h".into(),
None,
"a".into(),
sample_secret(),
otp::Algorithm::Sha1,
otp::Digits::Six,
30,
Some(7),
String::new(),
0,
0,
)
.expect("合法条目");
assert_eq!(hotp.uri_type(), crate::uri::UriType::Hotp);
assert_eq!(hotp.counter, Some(7));
}
#[test]
fn debug_does_not_leak_secret() {
let entry = sample_entry();
let debug = format!("{entry:?}");
assert!(debug.contains("GitHub"), "非秘密字段应正常:{debug}");
assert!(debug.contains("alice"), "非秘密字段应正常:{debug}");
let base32 = entry.secret.to_base32();
assert!(
!debug.contains(&base32),
"Debug 泄露了种子的 Base32:{debug}"
);
assert!(!debug.contains("JBSWY3DP"), "Debug 泄露了种子:{debug}");
}
#[test]
fn convert_from_parsed_uri() {
let parsed =
crate::uri::parse("otpauth://totp/GitHub:alice?secret=JBSWY3DPEHPK3PXP&issuer=GitHub")
.expect("合法 URI");
let entry = entry_from_parsed_uri(&parsed, 1_700_000_000).expect("可转换");
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!(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(),
parsed.secret.to_base32(),
"转换后种子发生了变化"
);
}
}